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

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

View File

@@ -0,0 +1,140 @@
CREATE SCHEMA "planes";
--> statement-breakpoint
CREATE TABLE "planes"."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 "planes"."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 "planes"."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,
"search" text NOT NULL,
"wikidata" text NOT NULL,
"wikipedia" text,
"kind" text,
"description" text,
"summary" text,
"role" text,
"roles" text[] DEFAULT '{}'::text[] NOT NULL,
"role_label" text,
"country" text,
"first_flight" text,
"first_flight_year" smallint,
"introduced_year" smallint,
"retired_year" smallint,
"status" text,
"in_service" boolean,
"number_built" integer,
"makers" text[] DEFAULT '{}'::text[] NOT NULL,
"engine_type" text,
"engine_count" smallint,
"engines" text[] DEFAULT '{}'::text[] NOT NULL,
"crew" smallint,
"passengers" smallint,
"length_m" real,
"wingspan_m" real,
"height_m" real,
"rotor_m" real,
"mtow_kg" integer,
"max_speed_kmh" smallint,
"cruise_kmh" smallint,
"range_km" integer,
"ceiling_m" integer,
"wing" text,
"gear" text,
"icao" text[] DEFAULT '{}'::text[] NOT NULL,
"recognize" 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 "planes"."makers" ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "photos_type_position_key" ON "planes"."photos" ("type_id","position");--> statement-breakpoint
CREATE UNIQUE INDEX "types_dex_key" ON "planes"."types" ("dex");--> statement-breakpoint
CREATE UNIQUE INDEX "types_wikidata_key" ON "planes"."types" ("wikidata");--> statement-breakpoint
CREATE UNIQUE INDEX "types_maker_slug_key" ON "planes"."types" ("maker_id","slug");--> statement-breakpoint
CREATE INDEX "types_number_built_idx" ON "planes"."types" ("number_built");--> statement-breakpoint
CREATE INDEX "types_first_flight_year_idx" ON "planes"."types" ("first_flight_year");--> statement-breakpoint
CREATE INDEX "types_search_trgm_idx" ON "planes"."types" USING gin ("search" public.gin_trgm_ops);--> statement-breakpoint
ALTER TABLE "planes"."photos" ADD CONSTRAINT "photos_type_id_types_id_fkey" FOREIGN KEY ("type_id") REFERENCES "planes"."types"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "planes"."types" ADD CONSTRAINT "types_maker_id_makers_id_fkey" FOREIGN KEY ("maker_id") REFERENCES "planes"."makers"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "planes"."types" ADD CONSTRAINT "types_family_id_types_id_fkey" FOREIGN KEY ("family_id") REFERENCES "planes"."types"("id") ON DELETE SET NULL;--> statement-breakpoint
-- Hand-written: updated_at triggers, grants, GUI registration. Data comes from scripts/planes-import.ts and scripts/planes-photos.ts.
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['makers','types'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON planes.%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 planes TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA planes TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA planes TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA planes GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA planes 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 planes TO personal_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA planes TO personal_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA planes 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 ('planes','makers','planes-makers','Plane makers','core',false,false,1000),
('planes','types','planes-types','Plane types','core',false,false,1000),
('planes','photos','planes-photos','Plane 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

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