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 * as schema from './schema'; // SQLite via sql.js (WASM) — no native compilation, so it builds and runs in the // 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). export interface GameDb { db: SQLJsDatabase; raw: RawSqlJsDatabase; file: string; } // CREATE TABLE statements mirroring lib/db/schema.ts — keep in sync. _meta is DB plumbing // (not game state): a brand-new sql.js database exports as ZERO bytes, so without at least // one real table there would be nothing valid to persist on the ./db volume from first boot. const DDL = ` CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT); CREATE TABLE IF NOT EXISTS saves ( id INTEGER PRIMARY KEY AUTOINCREMENT, seed TEXT NOT NULL, turn INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, schema_version INTEGER -- which save-schema version this row was backfilled up to (NULL = v1) ); CREATE TABLE IF NOT EXISTS solar_systems ( id INTEGER PRIMARY KEY AUTOINCREMENT, save_id INTEGER NOT NULL REFERENCES saves(id), name TEXT NOT NULL, x REAL NOT NULL, y REAL NOT NULL, discovered INTEGER NOT NULL DEFAULT 0, is_current INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS jump_routes ( from_system INTEGER NOT NULL REFERENCES solar_systems(id), to_system INTEGER NOT NULL REFERENCES solar_systems(id), PRIMARY KEY (from_system, to_system) ); CREATE TABLE IF NOT EXISTS planets ( id INTEGER PRIMARY KEY AUTOINCREMENT, system_id INTEGER NOT NULL REFERENCES solar_systems(id), parent_id INTEGER, -- NULL for a planet, set to the parent planet's id for a moon name TEXT NOT NULL, type TEXT NOT NULL, hazard INTEGER NOT NULL DEFAULT 0, explored INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS inventory_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, save_id INTEGER NOT NULL REFERENCES saves(id), kind TEXT NOT NULL, -- 'resource' | 'part' name TEXT NOT NULL, qty INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS crew ( id INTEGER PRIMARY KEY AUTOINCREMENT, save_id INTEGER NOT NULL REFERENCES saves(id), name TEXT NOT NULL, role TEXT NOT NULL, trait_good TEXT NOT NULL, trait_bad TEXT NOT NULL, origin TEXT NOT NULL -- 'STARTING CREW' or ', ' for members found while exploring ); CREATE TABLE IF NOT EXISTS ship_systems ( id INTEGER PRIMARY KEY AUTOINCREMENT, save_id INTEGER NOT NULL REFERENCES saves(id), type TEXT NOT NULL, level INTEGER NOT NULL DEFAULT 0, -- 0 = not built yet ('built' is derived: level > 0) capacity INTEGER NOT NULL DEFAULT 0, UNIQUE (save_id, type) ); CREATE TABLE IF NOT EXISTS tech ( id INTEGER PRIMARY KEY AUTOINCREMENT, save_id INTEGER NOT NULL REFERENCES saves(id), slug TEXT NOT NULL, -- stable key, e.g. WEAPONS_SYSTEMS name TEXT NOT NULL, -- display name, e.g. 'WEAPONS SYSTEMS' cost_kind TEXT NOT NULL, -- 'resource' | 'part' cost_qty INTEGER NOT NULL DEFAULT 1, prerequisites TEXT NOT NULL DEFAULT '', -- comma-separated tech slugs, '' when none effect TEXT NOT NULL, -- effect slug — semantics in EFFECTS (lib/game/tech.ts) researched INTEGER NOT NULL DEFAULT 0, UNIQUE (save_id, slug) ); CREATE TABLE IF NOT EXISTS missions ( id INTEGER PRIMARY KEY AUTOINCREMENT, save_id INTEGER NOT NULL REFERENCES saves(id), type TEXT NOT NULL, -- 'DELIVER' | 'EXPLORE' | 'FIND' description TEXT NOT NULL, -- human-readable objective, e.g. "Jump to Kepler Drift" target_system_id INTEGER, -- DELIVER only — destination system (NULL otherwise) condition_kind TEXT, -- FIND only — 'resource' | 'part' (NULL otherwise) progress_target INTEGER NOT NULL DEFAULT 1, -- EXPLORE: scans required; 1 for the others progress INTEGER NOT NULL DEFAULT 0, -- scans counted since acceptance; 0/1 for DELIVER/FIND reward_kind TEXT NOT NULL, -- 'resource' | 'part' reward_qty INTEGER NOT NULL DEFAULT 1, state TEXT NOT NULL DEFAULT 'AVAILABLE' -- 'AVAILABLE' | 'ACTIVE' | 'DONE' ); CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, save_id INTEGER NOT NULL REFERENCES saves(id), turn INTEGER NOT NULL, type TEXT NOT NULL, message TEXT NOT NULL, created_at TEXT NOT NULL ); `; // SPACE_WANDERER_DB_PATH override exists so unit tests can point at a temp file instead // of the real ./db/space-wanderer.db. export function defaultDbPath(): string { return process.env.SPACE_WANDERER_DB_PATH ?? path.join(process.cwd(), 'db', 'space-wanderer.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 // Idempotent evolution for files created by older code: CREATE TABLE IF NOT EXISTS above cannot // add columns, so check and add any that are missing. This is what makes DB wipes unnecessary — // old saves open fine and get backfilled by ensureSave (see lib/game/save.ts). const saveCols = raw.exec('PRAGMA table_info(saves)')[0]?.values.map((v) => v[1]) ?? []; if (!saveCols.includes('schema_version')) { raw.exec('ALTER TABLE saves ADD COLUMN schema_version INTEGER'); } const instance: GameDb = { db: drizzle(raw, { schema }), raw, file }; // First run: stamp when this DB was created and land the file on disk so the ./db volume // mount has a valid SQLite file to persist from boot (instrumentation.ts opens at server start). if (!bytes) { raw.run('INSERT OR IGNORE INTO _meta (key, value) VALUES (?, ?)', ['created_at', new Date().toISOString()]); await persist(instance); } return 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). // 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 { __spaceWandererGameDb?: Promise }; export function getGameDb(): Promise { return (g.__spaceWandererGameDb ??= open(defaultDbPath())); } export async function openGameDb(file: string): Promise { return open(file); }