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

This commit is contained in:
2026-10-24 23:22:20 +00:00
parent 8515cf0e9c
commit ef9524aa0f
6 changed files with 25705 additions and 1 deletions

View File

@@ -28,7 +28,8 @@ export default defineConfig({
'music', 'music',
'hardware', 'hardware',
'planes', 'planes',
'wildlife' 'wildlife',
'trains'
], ],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' }, migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true, verbose: true,

View File

@@ -0,0 +1,167 @@
CREATE SCHEMA "trains";
--> statement-breakpoint
CREATE TABLE "trains"."makers" (
"id" serial PRIMARY KEY,
"slug" text NOT NULL,
"name" text NOT NULL,
"full_name" text,
"country" text,
"wikidata" text,
"wikipedia" text,
"type_count" integer DEFAULT 0 NOT NULL,
"first_year" smallint,
"last_year" smallint,
"built_total" integer DEFAULT 0 NOT NULL,
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
"logo" text,
"logo_file" text,
"logo_author" text,
"logo_license" text,
"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 "trains"."operators" (
"id" serial PRIMARY KEY,
"slug" text NOT NULL,
"name" text NOT NULL,
"full_name" text,
"country" text,
"wikidata" text,
"wikipedia" text,
"type_count" integer DEFAULT 0 NOT NULL,
"logo" text,
"logo_file" text,
"logo_author" text,
"logo_license" text,
"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 "trains"."photos" (
"id" serial PRIMARY KEY,
"type_id" integer NOT NULL,
"position" smallint NOT NULL,
"kind" text NOT NULL,
"file" text NOT NULL,
"caption" text,
"thumb" text NOT NULL,
"width" integer NOT NULL,
"height" integer NOT NULL,
"author" text,
"license" text,
"license_url" text
);
--> statement-breakpoint
CREATE TABLE "trains"."type_operators" (
"type_id" integer,
"operator_id" integer,
"position" smallint DEFAULT 0 NOT NULL,
CONSTRAINT "type_operators_pkey" PRIMARY KEY("type_id","operator_id")
);
--> statement-breakpoint
CREATE TABLE "trains"."types" (
"id" serial PRIMARY KEY,
"dex" integer NOT NULL,
"maker_id" integer NOT NULL,
"family_id" integer,
"slug" text NOT NULL,
"name" text NOT NULL,
"full_name" text NOT NULL,
"nl_name" text,
"search" text NOT NULL,
"wikidata" text NOT NULL,
"wikipedia" text,
"description" text,
"summary" text,
"category" text NOT NULL,
"category_label" text,
"traction" text,
"country" text,
"countries" text[] DEFAULT '{}'::text[] NOT NULL,
"makers" text[] DEFAULT '{}'::text[] NOT NULL,
"operators" text[] DEFAULT '{}'::text[] NOT NULL,
"first_built" smallint,
"last_built" smallint,
"introduced_year" smallint,
"retired_year" smallint,
"status" text,
"in_service" boolean,
"number_built" integer,
"max_speed_kmh" smallint,
"power_kw" integer,
"gauge" text,
"gauge_mm" smallint,
"electric_system" text,
"wheels" text,
"length_m" real,
"weight_t" real,
"capacity" smallint,
"formation" text,
"designer" text,
"nicknames" 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 "makers_slug_key" ON "trains"."makers" ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "operators_slug_key" ON "trains"."operators" ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "photos_type_position_key" ON "trains"."photos" ("type_id","position");--> statement-breakpoint
CREATE INDEX "type_operators_operator_idx" ON "trains"."type_operators" ("operator_id");--> statement-breakpoint
CREATE UNIQUE INDEX "types_dex_key" ON "trains"."types" ("dex");--> statement-breakpoint
CREATE UNIQUE INDEX "types_wikidata_key" ON "trains"."types" ("wikidata");--> statement-breakpoint
CREATE UNIQUE INDEX "types_maker_slug_key" ON "trains"."types" ("maker_id","slug");--> statement-breakpoint
CREATE INDEX "types_number_built_idx" ON "trains"."types" ("number_built");--> statement-breakpoint
CREATE INDEX "types_first_built_idx" ON "trains"."types" ("first_built");--> statement-breakpoint
CREATE INDEX "types_category_idx" ON "trains"."types" ("category");--> statement-breakpoint
CREATE INDEX "types_search_trgm_idx" ON "trains"."types" USING gin ("search" public.gin_trgm_ops);--> statement-breakpoint
ALTER TABLE "trains"."photos" ADD CONSTRAINT "photos_type_id_types_id_fkey" FOREIGN KEY ("type_id") REFERENCES "trains"."types"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "trains"."type_operators" ADD CONSTRAINT "type_operators_type_id_types_id_fkey" FOREIGN KEY ("type_id") REFERENCES "trains"."types"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "trains"."type_operators" ADD CONSTRAINT "type_operators_operator_id_operators_id_fkey" FOREIGN KEY ("operator_id") REFERENCES "trains"."operators"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "trains"."types" ADD CONSTRAINT "types_maker_id_makers_id_fkey" FOREIGN KEY ("maker_id") REFERENCES "trains"."makers"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "trains"."types" ADD CONSTRAINT "types_family_id_types_id_fkey" FOREIGN KEY ("family_id") REFERENCES "trains"."types"("id") ON DELETE SET NULL;--> statement-breakpoint
-- Hand-written: updated_at triggers, grants, GUI registration. Data comes from scripts/trains-import.ts and scripts/trains-photos.ts.
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['makers','operators','types'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON trains.%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 trains TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA trains TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA trains TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA trains GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA trains 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 trains TO personal_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA trains TO personal_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA trains 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 ('trains','makers','trains-makers','Train makers','core',false,false,1000),
('trains','operators','trains-operators','Train operators','core',false,false,1000),
('trains','types','trains-types','Train types','core',false,false,1000),
('trains','photos','trains-photos','Train 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 $$;

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -22,3 +22,4 @@ 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'); export const wildlifeSchema = pgSchema('wildlife');
export const trainsSchema = pgSchema('trains');

View File

@@ -0,0 +1,187 @@
import { sql } from 'drizzle-orm';
import {
boolean,
index,
integer,
primaryKey,
real,
smallint,
text,
timestamp,
uniqueIndex,
type AnyPgColumn
} from 'drizzle-orm/pg-core';
import { trainsSchema } from './schemas';
import { id, timestamps } from './helpers';
// Public train "pokedex" (/trackers/trains). Read-only catalogue built from open data by
// scripts/trains-import.ts (re-runnable; upserts types by Wikidata id so Dex numbers stay
// stable) and scripts/trains-photos.ts (Commons photos + maker/operator logos). Sources:
// - English Wikipedia (CC BY-SA): every article with {{Infobox locomotive}}, {{Infobox train}}
// or {{Infobox tram}} (builder, operators, years, number built, speed, power, gauge, ...).
// - Wikidata (CC0): manufacturer (P176), operator (P137), country, number built, labels.
// Only classes/types (individual locomotives and list articles are skipped). Unknown stays null.
export const trainMakers = trainsSchema.table(
'makers',
{
id: id(),
slug: text('slug').notNull(),
name: text('name').notNull(),
fullName: text('full_name'),
country: text('country'),
wikidata: text('wikidata'),
wikipedia: text('wikipedia'),
typeCount: integer('type_count').notNull().default(0),
firstYear: smallint('first_year'),
lastYear: smallint('last_year'),
/** Sum of number built over the maker's types (families are not double counted). */
builtTotal: integer('built_total').notNull().default(0),
sources: text('sources').array().notNull().default([]),
/** Logo thumbnail (Wikimedia Commons, 250px) and its credit. */
logo: text('logo'),
logoFile: text('logo_file'),
logoAuthor: text('logo_author'),
logoLicense: text('logo_license'),
/** Hero photo: lead photo of the maker's most built type. */
photo: text('photo'),
...timestamps
},
(t) => [uniqueIndex('makers_slug_key').on(t.slug)]
);
export const trainOperators = trainsSchema.table(
'operators',
{
id: id(),
slug: text('slug').notNull(),
name: text('name').notNull(),
fullName: text('full_name'),
country: text('country'),
wikidata: text('wikidata'),
wikipedia: text('wikipedia'),
typeCount: integer('type_count').notNull().default(0),
logo: text('logo'),
logoFile: text('logo_file'),
logoAuthor: text('logo_author'),
logoLicense: text('logo_license'),
photo: text('photo'),
...timestamps
},
(t) => [uniqueIndex('operators_slug_key').on(t.slug)]
);
export const trainTypes = trainsSchema.table(
'types',
{
id: id(),
dex: integer('dex').notNull(),
makerId: integer('maker_id')
.notNull()
.references(() => trainMakers.id, { onDelete: 'cascade' }),
/** Parent class in the dex (e.g. ICE 3 -> Velaro), from Wikidata "subclass of". */
familyId: integer('family_id').references((): AnyPgColumn => trainTypes.id, {
onDelete: 'set null'
}),
slug: text('slug').notNull(),
/** Name without the maker ("Velaro"); full_name is the article title ("Siemens Velaro"). */
name: text('name').notNull(),
fullName: text('full_name').notNull(),
/** Dutch label from Wikidata, when it differs. */
nlName: text('nl_name'),
/** Lowercase names, aliases, maker, operators and class numbers for ILIKE / trigram search. */
search: text('search').notNull(),
wikidata: text('wikidata').notNull(),
wikipedia: text('wikipedia'),
description: text('description'),
/** First sentences of the Wikipedia article. */
summary: text('summary'),
/** steam, diesel, electric (locomotives), high-speed, multiple unit, metro, tram, carriage, other. */
category: text('category').notNull(),
/** Infobox stock type / power type ("Electric multiple unit"). */
categoryLabel: text('category_label'),
/** steam, diesel, electric, battery, hydrogen, gas turbine, bi-mode, none. */
traction: text('traction'),
country: text('country'),
/** Country of origin plus the countries of its operators. */
countries: text('countries').array().notNull().default([]),
makers: text('makers').array().notNull().default([]),
operators: text('operators').array().notNull().default([]),
firstBuilt: smallint('first_built'),
lastBuilt: smallint('last_built'),
introducedYear: smallint('introduced_year'),
retiredYear: smallint('retired_year'),
/** in service, retired, preserved. */
status: text('status'),
inService: boolean('in_service'),
numberBuilt: integer('number_built'),
maxSpeedKmh: smallint('max_speed_kmh'),
powerKw: integer('power_kw'),
gauge: text('gauge'),
gaugeMm: smallint('gauge_mm'),
electricSystem: text('electric_system'),
/** Whyte (4-6-2) for steam, else UIC (Bo'Bo'). */
wheels: text('wheels'),
lengthM: real('length_m'),
weightT: real('weight_t'),
capacity: smallint('capacity'),
formation: text('formation'),
designer: text('designer'),
nicknames: text('nicknames'),
sources: text('sources').array().notNull().default([]),
/** Lead photo thumbnail URL (960px), denormalised from trains.photos for the card grid. */
photo: text('photo'),
photoCount: smallint('photo_count').notNull().default(0),
photosCheckedAt: timestamp('photos_checked_at', { withTimezone: true }),
...timestamps
},
(t) => [
uniqueIndex('types_dex_key').on(t.dex),
uniqueIndex('types_wikidata_key').on(t.wikidata),
uniqueIndex('types_maker_slug_key').on(t.makerId, t.slug),
index('types_number_built_idx').on(t.numberBuilt),
index('types_first_built_idx').on(t.firstBuilt),
index('types_category_idx').on(t.category),
index('types_search_trgm_idx').using('gin', sql`${t.search} public.gin_trgm_ops`)
]
);
export const trainTypeOperators = trainsSchema.table(
'type_operators',
{
typeId: integer('type_id')
.notNull()
.references(() => trainTypes.id, { onDelete: 'cascade' }),
operatorId: integer('operator_id')
.notNull()
.references(() => trainOperators.id, { onDelete: 'cascade' }),
position: smallint('position').notNull().default(0)
},
(t) => [
primaryKey({ columns: [t.typeId, t.operatorId] }),
index('type_operators_operator_idx').on(t.operatorId)
]
);
/** Freely licensed photos from Wikimedia Commons; only thumbnail URLs are stored, nothing is downloaded. */
export const trainPhotos = trainsSchema.table(
'photos',
{
id: id(),
typeId: integer('type_id')
.notNull()
.references(() => trainTypes.id, { onDelete: 'cascade' }),
position: smallint('position').notNull(),
/** lead (Wikidata image), infobox (article infobox), commons (Commons category), article (enwiki). */
kind: text('kind').notNull(),
file: text('file').notNull(),
caption: text('caption'),
thumb: text('thumb').notNull(),
width: integer('width').notNull(),
height: integer('height').notNull(),
author: text('author'),
license: text('license'),
licenseUrl: text('license_url')
},
(t) => [uniqueIndex('photos_type_position_key').on(t.typeId, t.position)]
);