wildlife: schema and migration
All checks were successful
Deploy production / deploy (push) Successful in 1m4s

This commit is contained in:
2026-10-24 21:54:25 +00:00
parent 59b35c0886
commit 3f251145be
6 changed files with 23971 additions and 1 deletions

View File

@@ -19,5 +19,6 @@ export * from './groceries';
export * from './home';
export * from './cars';
export * from './planes';
export * from './wildlife';
export * from './music';
export * from './hardware';

View File

@@ -21,3 +21,4 @@ export const carsSchema = pgSchema('cars');
export const musicSchema = pgSchema('music');
export const hardwareSchema = pgSchema('hardware');
export const planesSchema = pgSchema('planes');
export const wildlifeSchema = pgSchema('wildlife');

View File

@@ -0,0 +1,135 @@
import { sql } from 'drizzle-orm';
import {
boolean,
index,
integer,
real,
smallint,
text,
timestamp,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { wildlifeSchema } from './schemas';
import { id, timestamps } from './helpers';
// Public wildlife "pokedex" (/trackers/wildlife). Read-only catalogue built from open data by
// scripts/wildlife-import.ts (re-runnable; upserts species by iNaturalist taxon id so Dex
// numbers stay stable) and scripts/wildlife-photos.ts (CC-licensed iNaturalist photos,
// hotlinked, never downloaded). Sources:
// - iNaturalist (API, CC0 taxonomy): wild animal species observed in the Netherlands, in
// Europe and the most observed species worldwide; Dutch + English names, taxonomy,
// IUCN Red List status, research-grade observation counts and Wikipedia intros.
// - Wikidata (CC0): Dutch label, IUCN status fallback, GBIF id, body mass, length,
// lifespan and the year/author of the scientific name.
// - GBIF: occurrence records in the Netherlands.
export const wildlifeGroups = wildlifeSchema.table(
'groups',
{
id: id(),
slug: text('slug').notNull(),
name: text('name').notNull(),
nameNl: text('name_nl').notNull(),
emoji: text('emoji').notNull(),
sortOrder: smallint('sort_order').notNull().default(0),
speciesCount: integer('species_count').notNull().default(0),
nlCount: integer('nl_count').notNull().default(0),
/** Lead photo of the group's most seen species in NL. */
photo: text('photo'),
...timestamps
},
(t) => [uniqueIndex('groups_slug_key').on(t.slug)]
);
export const wildlifeSpecies = wildlifeSchema.table(
'species',
{
id: id(),
dex: integer('dex').notNull(),
groupId: integer('group_id')
.notNull()
.references(() => wildlifeGroups.id, { onDelete: 'cascade' }),
/** Scientific name slug ("vulpes-vulpes"). */
slug: text('slug').notNull(),
scientificName: text('scientific_name').notNull(),
nameEn: text('name_en'),
nameNl: text('name_nl'),
/** Lowercase names (nl, en, scientific, order, family), for ILIKE / trigram search. */
search: text('search').notNull(),
inatId: integer('inat_id').notNull(),
gbifKey: integer('gbif_key'),
wikidata: text('wikidata'),
wikipediaEn: text('wikipedia_en'),
wikipediaNl: text('wikipedia_nl'),
className: text('class_name'),
orderName: text('order_name'),
familyName: text('family_name'),
/** IUCN Red List category: LC, NT, VU, EN, CR, EW, EX, DD (null = not evaluated). */
iucn: text('iucn'),
/** Dutch Red List / national status from iNaturalist, as published ("Kwetsbaar"). */
nlStatus: text('nl_status'),
/** native / introduced / endemic in the Netherlands (iNaturalist). */
nlEstablishment: text('nl_establishment'),
extinct: boolean('extinct').notNull().default(false),
/** Research-grade wild observations on iNaturalist in the Netherlands / Europe / worldwide. */
nlObs: integer('nl_obs').notNull().default(0),
europeObs: integer('europe_obs').notNull().default(0),
worldObs: integer('world_obs').notNull().default(0),
/** GBIF occurrence records in the Netherlands (all datasets). */
gbifNl: integer('gbif_nl'),
/** Where it made the list: nl, europe or world. */
scope: text('scope').notNull(),
summary: text('summary'),
summaryNl: text('summary_nl'),
facts: text('facts').array().notNull().default([]),
massKg: real('mass_kg'),
lengthM: real('length_m'),
lifespanYears: real('lifespan_years'),
namedYear: smallint('named_year'),
namedBy: text('named_by'),
sources: text('sources').array().notNull().default([]),
/** Lead photo (iNaturalist "medium" URL), denormalised from wildlife.photos for the cards. */
photo: text('photo'),
photoCount: smallint('photo_count').notNull().default(0),
photosCheckedAt: timestamp('photos_checked_at', { withTimezone: true }),
...timestamps
},
(t) => [
uniqueIndex('species_dex_key').on(t.dex),
uniqueIndex('species_inat_key').on(t.inatId),
uniqueIndex('species_slug_key').on(t.slug),
index('species_group_idx').on(t.groupId),
index('species_nl_obs_idx').on(t.nlObs),
index('species_search_trgm_idx').using('gin', sql`${t.search} public.gin_trgm_ops`)
]
);
/** CC-licensed photos; only the hotlinked URL and the credit are stored. */
export const wildlifePhotos = wildlifeSchema.table(
'photos',
{
id: id(),
speciesId: integer('species_id')
.notNull()
.references(() => wildlifeSpecies.id, { onDelete: 'cascade' }),
position: smallint('position').notNull(),
/** taxon (iNaturalist taxon photo) or observation (research-grade observation photo). */
kind: text('kind').notNull(),
/** iNaturalist photo id. */
photoId: integer('photo_id').notNull(),
/** "medium" (500px) URL; small/large/original are derived from it. */
url: text('url').notNull(),
width: integer('width'),
height: integer('height'),
author: text('author'),
/** cc-by, cc-by-nc, cc0, ... */
license: text('license').notNull(),
/** Observation or photo page on iNaturalist. */
pageUrl: text('page_url'),
place: text('place')
},
(t) => [
uniqueIndex('photos_species_position_key').on(t.speciesId, t.position),
uniqueIndex('photos_species_photo_key').on(t.speciesId, t.photoId)
]
);