124 lines
4.0 KiB
TypeScript
124 lines
4.0 KiB
TypeScript
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);
|
|
}
|