wildlife: schema and migration
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
This commit is contained in:
@@ -27,7 +27,8 @@ export default defineConfig({
|
|||||||
'cars',
|
'cars',
|
||||||
'music',
|
'music',
|
||||||
'hardware',
|
'hardware',
|
||||||
'planes'
|
'planes',
|
||||||
|
'wildlife'
|
||||||
],
|
],
|
||||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||||
verbose: true,
|
verbose: true,
|
||||||
|
|||||||
122
drizzle/20261024215408_wildlife/migration.sql
Normal file
122
drizzle/20261024215408_wildlife/migration.sql
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
CREATE SCHEMA "wildlife";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "wildlife"."groups" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"name_nl" text NOT NULL,
|
||||||
|
"emoji" text NOT NULL,
|
||||||
|
"sort_order" smallint DEFAULT 0 NOT NULL,
|
||||||
|
"species_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"nl_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"photo" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "wildlife"."photos" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"species_id" integer NOT NULL,
|
||||||
|
"position" smallint NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"photo_id" integer NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"width" integer,
|
||||||
|
"height" integer,
|
||||||
|
"author" text,
|
||||||
|
"license" text NOT NULL,
|
||||||
|
"page_url" text,
|
||||||
|
"place" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "wildlife"."species" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"dex" integer NOT NULL,
|
||||||
|
"group_id" integer NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"scientific_name" text NOT NULL,
|
||||||
|
"name_en" text,
|
||||||
|
"name_nl" text,
|
||||||
|
"search" text NOT NULL,
|
||||||
|
"inat_id" integer NOT NULL,
|
||||||
|
"gbif_key" integer,
|
||||||
|
"wikidata" text,
|
||||||
|
"wikipedia_en" text,
|
||||||
|
"wikipedia_nl" text,
|
||||||
|
"class_name" text,
|
||||||
|
"order_name" text,
|
||||||
|
"family_name" text,
|
||||||
|
"iucn" text,
|
||||||
|
"nl_status" text,
|
||||||
|
"nl_establishment" text,
|
||||||
|
"extinct" boolean DEFAULT false NOT NULL,
|
||||||
|
"nl_obs" integer DEFAULT 0 NOT NULL,
|
||||||
|
"europe_obs" integer DEFAULT 0 NOT NULL,
|
||||||
|
"world_obs" integer DEFAULT 0 NOT NULL,
|
||||||
|
"gbif_nl" integer,
|
||||||
|
"scope" text NOT NULL,
|
||||||
|
"summary" text,
|
||||||
|
"summary_nl" text,
|
||||||
|
"facts" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"mass_kg" real,
|
||||||
|
"length_m" real,
|
||||||
|
"lifespan_years" real,
|
||||||
|
"named_year" smallint,
|
||||||
|
"named_by" text,
|
||||||
|
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"photo" text,
|
||||||
|
"photo_count" smallint DEFAULT 0 NOT NULL,
|
||||||
|
"photos_checked_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "groups_slug_key" ON "wildlife"."groups" ("slug");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "photos_species_position_key" ON "wildlife"."photos" ("species_id","position");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "photos_species_photo_key" ON "wildlife"."photos" ("species_id","photo_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "species_dex_key" ON "wildlife"."species" ("dex");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "species_inat_key" ON "wildlife"."species" ("inat_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "species_slug_key" ON "wildlife"."species" ("slug");--> statement-breakpoint
|
||||||
|
CREATE INDEX "species_group_idx" ON "wildlife"."species" ("group_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "species_nl_obs_idx" ON "wildlife"."species" ("nl_obs");--> statement-breakpoint
|
||||||
|
CREATE INDEX "species_search_trgm_idx" ON "wildlife"."species" USING gin ("search" public.gin_trgm_ops);--> statement-breakpoint
|
||||||
|
ALTER TABLE "wildlife"."photos" ADD CONSTRAINT "photos_species_id_species_id_fkey" FOREIGN KEY ("species_id") REFERENCES "wildlife"."species"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "wildlife"."species" ADD CONSTRAINT "species_group_id_groups_id_fkey" FOREIGN KEY ("group_id") REFERENCES "wildlife"."groups"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
-- Hand-written: updated_at triggers, grants, GUI registration. Data comes from scripts/wildlife-import.ts and scripts/wildlife-photos.ts.
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH t IN ARRAY ARRAY['groups','species'] LOOP
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON wildlife.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT USAGE ON SCHEMA wildlife TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA wildlife TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA wildlife TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA wildlife GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA wildlife GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||||
|
GRANT USAGE ON SCHEMA wildlife TO personal_ro;
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA wildlife TO personal_ro;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA wildlife GRANT SELECT ON TABLES TO personal_ro;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||||
|
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||||
|
VALUES ('wildlife','groups','wildlife-groups','Wildlife groups','core',false,false,1000),
|
||||||
|
('wildlife','species','wildlife-species','Wildlife species','core',false,false,1000),
|
||||||
|
('wildlife','photos','wildlife-photos','Wildlife photos','core',false,false,1000)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||||
|
PERFORM meta.sync();
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
23710
drizzle/20261024215408_wildlife/snapshot.json
Normal file
23710
drizzle/20261024215408_wildlife/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -19,5 +19,6 @@ export * from './groceries';
|
|||||||
export * from './home';
|
export * from './home';
|
||||||
export * from './cars';
|
export * from './cars';
|
||||||
export * from './planes';
|
export * from './planes';
|
||||||
|
export * from './wildlife';
|
||||||
export * from './music';
|
export * from './music';
|
||||||
export * from './hardware';
|
export * from './hardware';
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ export const carsSchema = pgSchema('cars');
|
|||||||
export const musicSchema = pgSchema('music');
|
export const musicSchema = pgSchema('music');
|
||||||
export const hardwareSchema = pgSchema('hardware');
|
export const hardwareSchema = pgSchema('hardware');
|
||||||
export const planesSchema = pgSchema('planes');
|
export const planesSchema = pgSchema('planes');
|
||||||
|
export const wildlifeSchema = pgSchema('wildlife');
|
||||||
|
|||||||
135
src/lib/server/db/schema/wildlife.ts
Normal file
135
src/lib/server/db/schema/wildlife.ts
Normal 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)
|
||||||
|
]
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user