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

@@ -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)]
);