import { randomInt } from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { drizzle, type SQLJsDatabase } from 'drizzle-orm/sql-js'; import initSqlJs, { type Database as RawSqlJsDatabase } from 'sql.js'; import { loadAirportsGeoJson } from '@/lib/airports'; import { buildSeed, mulberry32 } from '@/lib/game/seed'; import * as schema from './schema'; // SQLite via sql.js (WASM) — no native compilation, so it builds and runs in the // alpine Docker image without a toolchain. Trade-off: the DB lives in memory and // every write must be exported to disk; persist() does that atomically (tmp file // + rename) and is called after each mutation. export interface GameDb { db: SQLJsDatabase; raw: RawSqlJsDatabase; file: string; } // CREATE TABLE statements mirroring lib/db/schema.ts — keep in sync. const DDL = ` CREATE TABLE IF NOT EXISTS planes ( id TEXT PRIMARY KEY NOT NULL, model TEXT NOT NULL, status TEXT NOT NULL, fuel REAL NOT NULL, max_fuel REAL NOT NULL, location_icao TEXT NOT NULL, dest_icao TEXT, lat REAL NOT NULL, lon REAL NOT NULL, departure_sim_time INTEGER, queue TEXT NOT NULL DEFAULT '[]', health REAL NOT NULL DEFAULT 100, repair_until_sim_time INTEGER ); CREATE TABLE IF NOT EXISTS packages ( id TEXT PRIMARY KEY NOT NULL, origin_icao TEXT NOT NULL, dest_icao TEXT NOT NULL, reward REAL NOT NULL, cargo_type TEXT NOT NULL, cargo_count INTEGER NOT NULL, weight_kg REAL NOT NULL DEFAULT 2000, special TEXT, deadline_sim_time INTEGER NOT NULL, status TEXT NOT NULL, plane_id TEXT, created_sim_time INTEGER NOT NULL, created_real_ms INTEGER NOT NULL DEFAULT 0, slot_free_at_real_ms INTEGER ); CREATE TABLE IF NOT EXISTS account ( cash REAL NOT NULL, reputation REAL NOT NULL ); CREATE TABLE IF NOT EXISTS sim_state ( clock_ms INTEGER NOT NULL, time_scale REAL NOT NULL, paused INTEGER NOT NULL, last_real_tick_ms INTEGER NOT NULL DEFAULT 0, last_selected_time_scale REAL NOT NULL DEFAULT 1 ); `; // v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF // NOT EXISTS won't add them, so ALTER (guarded by PRAGMA table_info). Fresh DBs // already have everything from DDL above and this is a no-op for them. function migrateColumns(raw: RawSqlJsDatabase): void { const cols = (table: string): Set => new Set(raw.exec(`PRAGMA table_info(${table})`)[0].values.map((r) => String(r[1]))); if (!cols('sim_state').has('last_real_tick_ms')) { raw.exec('ALTER TABLE sim_state ADD COLUMN last_real_tick_ms INTEGER NOT NULL DEFAULT 0'); } if (!cols('planes').has('departure_sim_time')) { raw.exec('ALTER TABLE planes ADD COLUMN departure_sim_time INTEGER'); } // v0.12.0: the flight destination column (set by fly, cleared on arrival). if (!cols('planes').has('dest_icao')) { raw.exec('ALTER TABLE planes ADD COLUMN dest_icao TEXT'); } // v0.25.0 (ISS-043): the flight queue — ordered JSON array of not-yet-started leg ICACs. if (!cols('planes').has('queue')) { raw.exec(`ALTER TABLE planes ADD COLUMN queue TEXT NOT NULL DEFAULT '[]'`); } // v0.27.0 (ISS-045): package weight — pre-existing rows backfill to a mid-range default. if (!cols('packages').has('weight_kg')) { raw.exec('ALTER TABLE packages ADD COLUMN weight_kg REAL NOT NULL DEFAULT 2000'); } // v0.37.0 (ISS-073): package special status — nullable, no default; pre-existing rows stay NULL = none. if (!cols('packages').has('special')) { raw.exec('ALTER TABLE packages ADD COLUMN special TEXT'); } // v0.39.0 (ISS-069): plane health — pre-existing rows backfill to full health. if (!cols('planes').has('health')) { raw.exec('ALTER TABLE planes ADD COLUMN health REAL NOT NULL DEFAULT 100'); } // v0.39.0 (ISS-069): repair gate — nullable, no default; pre-existing rows stay NULL = not in repair. if (!cols('planes').has('repair_until_sim_time')) { raw.exec('ALTER TABLE planes ADD COLUMN repair_until_sim_time INTEGER'); } // v0.65.0: real-time creation stamp — legacy rows backfill to NOW so they get a fresh full lifetime // instead of expiring on the first tick (created_real_ms = 0 is far in the past). if (!cols('packages').has('created_real_ms')) { raw.exec(`ALTER TABLE packages ADD COLUMN created_real_ms INTEGER NOT NULL DEFAULT ${Date.now()}`); } // v0.65.0: per-(origin, band) refill cooldown — nullable; legacy rows stay NULL = no cooldown. if (!cols('packages').has('slot_free_at_real_ms')) { raw.exec('ALTER TABLE packages ADD COLUMN slot_free_at_real_ms INTEGER'); } // v0.30.0 (ISS-057): last-selected speed — pre-existing rows backfill to the row's OWN // current time_scale so a legacy DB resumes at the speed it was running, not the default. if (!cols('sim_state').has('last_selected_time_scale')) { raw.exec('ALTER TABLE sim_state ADD COLUMN last_selected_time_scale REAL NOT NULL DEFAULT 1'); raw.exec('UPDATE sim_state SET last_selected_time_scale = time_scale'); } } // v0.12.0 one-time migration: contracts world → packages world. Detected by the // presence of the old `contracts` table (fresh and already-migrated DBs never // have it, so this runs exactly once per file). Preserves account/sim_state rows // and every plane row; planes are reset to GROUNDED at their locationIcao with // departure_sim_time/dest_icao cleared — an in-flight plane from the old state // simply lands back where it took off. Old contract data is wiped (the packages // table starts empty and the engine's spawn rules reseed the market on the next // tick). The orphaned pre-v0.12.0 `contract_id` column is dropped too (SQLite // ≥3.35 DROP COLUMN; guarded so a failure can never block startup). function migrateContractsToPackages(raw: RawSqlJsDatabase): void { const cols = new Set( raw.exec('PRAGMA table_info(planes)')[0].values.map((r) => String(r[1])), ); raw.exec( `UPDATE planes SET status = 'available', departure_sim_time = NULL, dest_icao = NULL` + (cols.has('contract_id') ? ', contract_id = NULL' : ''), ); if (cols.has('contract_id')) { try { raw.exec('ALTER TABLE planes DROP COLUMN contract_id'); } catch { // Leaving the orphaned column is harmless — nothing references it anymore. } } raw.exec('DROP TABLE contracts'); } // FLIGHT_DB_PATH override exists so unit tests can point at a temp file instead // of the real ./db/flight.db. export function defaultDbPath(): string { return process.env.FLIGHT_DB_PATH ?? path.join(process.cwd(), 'db', 'flight.db'); } async function open(file: string): Promise { const SQL = await initSqlJs(); let bytes: Buffer | undefined; try { bytes = await fs.readFile(file); } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; } const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database(); raw.exec(DDL); // no-op for tables that already exist in an older file migrateColumns(raw); // v0.12.0: run BEFORE the seed check — a pre-v0.12.0 file has account rows, so // without this it would keep the dead contracts table forever. const tables = new Set( raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])), ); if (tables.has('contracts')) { migrateContractsToPackages(raw); } const instance: GameDb = { db: drizzle(raw, { schema }), raw, file }; // First-run seed: only when the DB is empty (no account row yet). const res = raw.exec('SELECT COUNT(*) FROM account'); if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) { console.warn(`flight db: no existing world at ${file} — seeding a fresh one`); await seedEmpty(instance); } // v0.7.0 fixup: pre-v0.7.0 DBs got last_real_tick_ms = 0 from the ALTER TABLE // default. Reset it to "now" so the first tick-on-read resumes cleanly instead // of taking one clamped catch-up jump. Fresh seeds already carry a real value. const simRows = raw.exec('SELECT clock_ms, last_real_tick_ms FROM sim_state'); if (simRows[0]?.values?.length && Number(simRows[0].values[0][1]) === 0) { raw.exec(`UPDATE sim_state SET last_real_tick_ms = ${Date.now()}`); await persist(instance); } return instance; } async function seedEmpty(instance: GameDb): Promise { const fc = await loadAirportsGeoJson(); const airports = fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon, })); // Random per-install seed; tests call buildSeed directly with a fixed RNG. const rng = mulberry32(randomInt(0, 2 ** 31)); const data = buildSeed({ airports, rng, nowMs: Date.now() }); // SeedPlane.queue is a string[]; the column stores it as JSON text. const { queue, ...planeCols } = data.plane; await instance.db.insert(schema.planes).values({ ...planeCols, queue: JSON.stringify(queue ?? []) }); await instance.db.insert(schema.packages).values(data.packages); await instance.db.insert(schema.account).values(data.account); await instance.db.insert(schema.simState).values(data.simState); await persist(instance); } // Durably save the in-memory DB: export bytes, write to a temp file, rename over // the target so a crash mid-write never leaves a truncated DB. export async function persist(instance: GameDb): Promise { const bytes = instance.raw.export(); await fs.mkdir(path.dirname(instance.file), { recursive: true }); const tmp = `${instance.file}.tmp`; await fs.writeFile(tmp, Buffer.from(bytes)); await fs.rename(tmp, instance.file); } // Singleton for the app process; openGameDb() is the uncached variant (tests). ISS-129 (v0.57.0): // lives on globalThis because Next.js can load this module more than once per process — // instrumentation.ts and route handlers get separate module instances, so a plain `let` would give // each its own in-memory sql.js DB (both persisting to the same file). One world per process. const g = globalThis as unknown as { __flightGameDb?: Promise }; export function getGameDb(): Promise { return (g.__flightGameDb ??= open(defaultDbPath())); } export async function openGameDb(file: string): Promise { return open(file); }