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, contract_id TEXT, lat REAL NOT NULL, lon REAL NOT NULL, departure_sim_time INTEGER ); CREATE TABLE IF NOT EXISTS contracts ( 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, deadline_sim_time INTEGER NOT NULL, status TEXT NOT NULL, plane_id TEXT, created_sim_time INTEGER NOT NULL ); 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 ); `; // 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 migrate(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'); } } // 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); migrate(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) { 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() }); await instance.db.insert(schema.planes).values(data.plane); await instance.db.insert(schema.contracts).values(data.contracts); 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). let ready: Promise | null = null; export function getGameDb(): Promise { if (!ready) ready = open(defaultDbPath()); return ready; } export async function openGameDb(file: string): Promise { return open(file); }