planes: schema and migration

This commit is contained in:
2026-10-24 20:10:36 +00:00
parent 6249e6daeb
commit cc6c7c8ede
6 changed files with 22916 additions and 1 deletions

View File

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

View File

@@ -0,0 +1,157 @@
import { sql } from 'drizzle-orm';
import {
boolean,
index,
integer,
real,
smallint,
text,
timestamp,
uniqueIndex,
type AnyPgColumn
} from 'drizzle-orm/pg-core';
import { planesSchema } from './schemas';
import { id, timestamps } from './helpers';
// Public plane "pokedex" (/trackers/planes). Read-only catalogue built from open data by
// scripts/planes-import.ts (re-runnable; upserts types by Wikidata id so Dex numbers stay
// stable) and scripts/planes-photos.ts (Commons photos + maker logos). Sources:
// - Wikidata (CC0): aircraft families/models with an English Wikipedia article.
// - English Wikipedia (CC BY-SA): infobox status/type/number built, the specifications
// block (dimensions, MTOW, speed, range, ceiling, crew, capacity, engines) and the intro,
// plus ICAO type designators from "List of aircraft type designators".
// Only production types (no prototypes, drones or one-offs). Unknown stays null.
export const planeMakers = planesSchema.table(
'makers',
{
id: id(),
slug: text('slug').notNull(),
/** Short name as used in article titles ("Cessna"); full_name is the Wikidata label. */
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 top-level 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 planeTypes = planesSchema.table(
'types',
{
id: id(),
dex: integer('dex').notNull(),
makerId: integer('maker_id')
.notNull()
.references(() => planeMakers.id, { onDelete: 'cascade' }),
/** Parent type in the dex (e.g. 737 MAX -> 737), from Wikidata "subclass of". */
familyId: integer('family_id').references((): AnyPgColumn => planeTypes.id, {
onDelete: 'set null'
}),
slug: text('slug').notNull(),
/** Name without the maker ("737"), full_name is the article title ("Boeing 737"). */
name: text('name').notNull(),
fullName: text('full_name').notNull(),
/** Lowercase maker + names + aliases + ICAO codes, for ILIKE / trigram search. */
search: text('search').notNull(),
wikidata: text('wikidata').notNull(),
wikipedia: text('wikipedia'),
/** Wikidata class: family, model or type. */
kind: text('kind'),
description: text('description'),
/** First sentences of the Wikipedia article. */
summary: text('summary'),
/** Primary role: airliner, regional, cargo, business, general aviation, glider, helicopter, military, airship. */
role: text('role'),
roles: text('roles').array().notNull().default([]),
/** Infobox "type" (e.g. "Narrow-body airliner"). */
roleLabel: text('role_label'),
country: text('country'),
/** ISO date trimmed to its precision (YYYY, YYYY-MM or YYYY-MM-DD). */
firstFlight: text('first_flight'),
firstFlightYear: smallint('first_flight_year'),
introducedYear: smallint('introduced_year'),
retiredYear: smallint('retired_year'),
/** in production, in service, in development, retired. */
status: text('status'),
inService: boolean('in_service'),
numberBuilt: integer('number_built'),
/** All manufacturers (Wikidata labels). */
makers: text('makers').array().notNull().default([]),
/** jet, turboprop, piston, turboshaft, electric, rocket, none. */
engineType: text('engine_type'),
engineCount: smallint('engine_count'),
engines: text('engines').array().notNull().default([]),
crew: smallint('crew'),
passengers: smallint('passengers'),
lengthM: real('length_m'),
wingspanM: real('wingspan_m'),
heightM: real('height_m'),
rotorM: real('rotor_m'),
mtowKg: integer('mtow_kg'),
maxSpeedKmh: smallint('max_speed_kmh'),
cruiseKmh: smallint('cruise_kmh'),
rangeKm: integer('range_km'),
ceilingM: integer('ceiling_m'),
/** Wikidata wing configuration / undercarriage labels. */
wing: text('wing'),
gear: text('gear'),
icao: text('icao').array().notNull().default([]),
/** "How to recognise it", derived from engines, wings, gear and size. */
recognize: text('recognize'),
sources: text('sources').array().notNull().default([]),
/** Lead photo thumbnail URL (960px), denormalised from planes.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_flight_year_idx').on(t.firstFlightYear),
index('types_search_trgm_idx').using('gin', sql`${t.search} public.gin_trgm_ops`)
]
);
/** Freely licensed photos from Wikimedia Commons; only thumbnail URLs are stored, nothing is downloaded. */
export const planePhotos = planesSchema.table(
'photos',
{
id: id(),
typeId: integer('type_id')
.notNull()
.references(() => planeTypes.id, { onDelete: 'cascade' }),
position: smallint('position').notNull(),
/** lead (Wikidata image), variant (sub-type image), article (enwiki), commons (Commons category). */
kind: text('kind').notNull(),
/** Commons file name without the "File:" prefix. */
file: text('file').notNull(),
caption: text('caption'),
/** 960px thumbnail on upload.wikimedia.org; other standard widths are derived from it. */
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)]
);

View File

@@ -20,3 +20,4 @@ export const homeSchema = pgSchema('home');
export const carsSchema = pgSchema('cars');
export const musicSchema = pgSchema('music');
export const hardwareSchema = pgSchema('hardware');
export const planesSchema = pgSchema('planes');