trains: schema and migration
All checks were successful
Deploy production / deploy (push) Successful in 1m5s
All checks were successful
Deploy production / deploy (push) Successful in 1m5s
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
167
drizzle/20261024232143_trains/migration.sql
Normal file
167
drizzle/20261024232143_trains/migration.sql
Normal 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 $$;
|
||||||
25347
drizzle/20261024232143_trains/snapshot.json
Normal file
25347
drizzle/20261024232143_trains/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
187
src/lib/server/db/schema/trains.ts
Normal file
187
src/lib/server/db/schema/trains.ts
Normal 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)]
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user