// SQLite persistence for games (one row per game id; the full GameState as JSON). import { mkdirSync } from 'node:fs'; import path from 'node:path'; import Database from 'better-sqlite3'; import type { GameState } from '../shared/types'; export interface Db { save(state: GameState): void; load(id: string): GameState | null; listIds(): string[]; delete(id: string): boolean; close(): void; } export function openDb(filePath: string): Db { const dir = path.dirname(filePath); if (dir) mkdirSync(dir, { recursive: true }); const db = new Database(filePath); db.pragma('journal_mode = WAL'); db.exec( `CREATE TABLE IF NOT EXISTS games ( id TEXT PRIMARY KEY, data TEXT NOT NULL, updated_at TEXT NOT NULL )`, ); const upsert = db.prepare( `INSERT INTO games (id, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`, ); const byId = db.prepare('SELECT data FROM games WHERE id = ?'); const all = db.prepare('SELECT id FROM games ORDER BY updated_at DESC'); const del = db.prepare('DELETE FROM games WHERE id = ?'); return { save(state) { upsert.run(state.id, JSON.stringify(state), new Date().toISOString()); }, load(id) { const row = byId.get(id) as { data: string } | undefined; if (!row) return null; try { const state = JSON.parse(row.data) as GameState; // (v0.22, #16 P3) Rows saved before `GameState.unowned` existed load // with it defaulted: a legacy game has no no-man's-land regions. if (state.unowned === undefined) state.unowned = []; // (v0.23, #16 P4a) Same for pending nation-op proposals: a legacy game // has none awaiting confirm/reject. if (state.pendingNationOps === undefined) state.pendingNationOps = []; return state; } catch { return null; } }, listIds() { return (all.all() as { id: string }[]).map((r) => r.id); }, delete(id) { return del.run(id).changes > 0; }, close() { db.close(); }, }; }