feat: v0.6.0 — data foundation: Drizzle ORM + SQLite (sql.js/WASM) persistence, first-run seed, read-only GET /api/state snapshot

This commit is contained in:
2026-09-17 11:51:42 +00:00
parent 4d9a1e9fa9
commit 7fdc43bc7e
23 changed files with 1006 additions and 8 deletions
+123
View File
@@ -0,0 +1,123 @@
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<typeof schema>;
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
);
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
);
`;
// 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<GameDb> {
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);
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);
}
return instance;
}
async function seedEmpty(instance: GameDb): Promise<void> {
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<void> {
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<GameDb> | null = null;
export function getGameDb(): Promise<GameDb> {
if (!ready) ready = open(defaultDbPath());
return ready;
}
export async function openGameDb(file: string): Promise<GameDb> {
return open(file);
}