import Database from 'better-sqlite3'; import { mkdirSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { config } from '../config.js'; import schemaSql from './schema.sql?raw'; import { requirement, type BetaPlan, type BetaRequirement } from '../../beta-plan.js'; import { emptyMetrics } from '../fleet/beta-evaluation.js'; let _db: Database.Database | null = null; // Desired shape of the `servers` table. Every column here is added to the // table on boot if missing — introspection via PRAGMA table_info handles // both fresh DBs and legacy DBs that predate any given column. CREATE TABLE // IF NOT EXISTS in schema.sql is a no-op on existing tables, so it can't // backfill columns; this can. const SERVERS_COLUMNS: Array<{ name: string; ddl: string }> = [ { name: 'container_state', ddl: 'ALTER TABLE servers ADD COLUMN container_state TEXT' }, // Base columns that shipped in the very first schema stay here for parity // with fresh installs — no ALTER is issued if they're already present. { name: 'komodo_server', ddl: 'ALTER TABLE servers ADD COLUMN komodo_server TEXT' }, { name: 'game_port', ddl: 'ALTER TABLE servers ADD COLUMN game_port INTEGER' }, { name: 'rcon_host', ddl: 'ALTER TABLE servers ADD COLUMN rcon_host TEXT' }, { name: 'last_players', ddl: 'ALTER TABLE servers ADD COLUMN last_players INTEGER' }, { name: 'last_checked_at', ddl: 'ALTER TABLE servers ADD COLUMN last_checked_at TEXT' }, // Post-v0.1 additions from the perk-a-cola/diagnostics work { name: 'last_map', ddl: 'ALTER TABLE servers ADD COLUMN last_map TEXT' }, { name: 'last_gametype', ddl: 'ALTER TABLE servers ADD COLUMN last_gametype TEXT' }, { name: 'last_roster', ddl: 'ALTER TABLE servers ADD COLUMN last_roster TEXT' }, { name: 'last_seen_at', ddl: 'ALTER TABLE servers ADD COLUMN last_seen_at TEXT' }, { name: 'last_error', ddl: 'ALTER TABLE servers ADD COLUMN last_error TEXT' }, { name: 'last_reply_snippet', ddl: 'ALTER TABLE servers ADD COLUMN last_reply_snippet TEXT' }, // Script-scope columns — static per server, populated via server-edit UI. { name: 'game', ddl: 'ALTER TABLE servers ADD COLUMN game TEXT' }, { name: 'map', ddl: 'ALTER TABLE servers ADD COLUMN map TEXT' }, // Provisioning columns — populated when EC creates the deployment. { name: 'game_mode', ddl: 'ALTER TABLE servers ADD COLUMN game_mode TEXT' }, { name: 'map_slug', ddl: 'ALTER TABLE servers ADD COLUMN map_slug TEXT' }, { name: 'host_dir', ddl: 'ALTER TABLE servers ADD COLUMN host_dir TEXT' }, { name: 'config_file', ddl: 'ALTER TABLE servers ADD COLUMN config_file TEXT' }, { name: 'game_id', ddl: 'ALTER TABLE servers ADD COLUMN game_id TEXT' }, { name: 'image', ddl: 'ALTER TABLE servers ADD COLUMN image TEXT' }, { name: 'created_by', ddl: 'ALTER TABLE servers ADD COLUMN created_by TEXT' }, { name: 'destroyed_at', ddl: 'ALTER TABLE servers ADD COLUMN destroyed_at TEXT' }, // Occupancy + match timers — see schema.sql for semantics. { name: 'active_since', ddl: 'ALTER TABLE servers ADD COLUMN active_since TEXT' }, { name: 'inactive_since', ddl: 'ALTER TABLE servers ADD COLUMN inactive_since TEXT' }, { name: 'game_started_at', ddl: 'ALTER TABLE servers ADD COLUMN game_started_at TEXT' }, { name: 'game_ended_at', ddl: 'ALTER TABLE servers ADD COLUMN game_ended_at TEXT' }, { name: 'game_cycle_key', ddl: 'ALTER TABLE servers ADD COLUMN game_cycle_key TEXT' } ]; function migrateServers(handle: Database.Database): void { const rows = handle.prepare('PRAGMA table_info(servers)').all() as Array<{ name: string }>; const existing = new Set(rows.map((r) => r.name)); for (const col of SERVERS_COLUMNS) { if (!existing.has(col.name)) { handle.exec(col.ddl); } } } // Additive columns on tables the script model owns. Same pattern as // migrateServers — CREATE TABLE IF NOT EXISTS won't backfill columns onto // pre-existing tables, so we introspect and ALTER as needed. const SCRIPTS_COLUMNS: Array<{ name: string; ddl: string }> = [ { name: 'kind', ddl: "ALTER TABLE scripts ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'" } ]; const SCRIPT_VERSIONS_COLUMNS: Array<{ name: string; ddl: string }> = [ { name: 'release_version', ddl: 'ALTER TABLE script_versions ADD COLUMN release_version TEXT' }, // state was added post-launch to make untested/tested/test-failed visible // in the UI. Existing rows default to 'untested' — this is safe because // any real promoted version's script has current_version_id set (which is // the authoritative "promoted" signal), and any version already gated // through a ready-promote beta will be re-marked next time that state // machine fires. { name: 'state', ddl: "ALTER TABLE script_versions ADD COLUMN state TEXT NOT NULL DEFAULT 'untested'" }, { name: 'kind', ddl: "ALTER TABLE script_versions ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'" } ]; const BETA_SCRIPTS_COLUMNS: Array<{ name: string; ddl: string }> = [ { name: 'kind', ddl: "ALTER TABLE beta_scripts ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'" } ]; const DEPLOYMENT_SCRIPTS_COLUMNS: Array<{ name: string; ddl: string }> = [ { name: 'kind', ddl: "ALTER TABLE deployment_scripts ADD COLUMN kind TEXT NOT NULL DEFAULT 'script'" } ]; const DEPLOYMENTS_COLUMNS: Array<{ name: string; ddl: string }> = [ { name: 'script_id', ddl: 'ALTER TABLE deployments ADD COLUMN script_id INTEGER REFERENCES scripts(id)' }, { name: 'script_version_id', ddl: 'ALTER TABLE deployments ADD COLUMN script_version_id INTEGER REFERENCES script_versions(id)' }, { name: 'prior_version_id', ddl: 'ALTER TABLE deployments ADD COLUMN prior_version_id INTEGER REFERENCES script_versions(id)' }, { name: 'beta_id', ddl: 'ALTER TABLE deployments ADD COLUMN beta_id INTEGER REFERENCES betas(id)' }, { name: 'rolled_back_by', ddl: 'ALTER TABLE deployments ADD COLUMN rolled_back_by TEXT REFERENCES deployments(id)' } ]; function migrateTable(handle: Database.Database, table: string, cols: Array<{ name: string; ddl: string }>): void { const rows = handle.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; const existing = new Set(rows.map((r) => r.name)); for (const col of cols) { if (!existing.has(col.name)) handle.exec(col.ddl); } } // Backfill the multi-script join tables from the legacy single-script columns. // Idempotent — every INSERT is guarded by a NOT EXISTS clause on the target // row's primary key. Safe to run on every boot even after the deprecated // columns are eventually dropped. function backfillMultiScriptJoins(handle: Database.Database): void { handle.exec( `INSERT INTO beta_scripts (beta_id, script_id, script_version_id) SELECT b.id, b.script_id, b.script_version_id FROM betas b WHERE b.script_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM beta_scripts bs WHERE bs.beta_id = b.id)` ); handle.exec( `INSERT INTO beta_server_scripts (beta_id, server, script_id, had_original, prior_version_id) SELECT bs.beta_id, bs.server, b.script_id, bs.had_original, bs.prior_version_id FROM beta_servers bs JOIN betas b ON b.id = bs.beta_id WHERE b.script_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM beta_server_scripts x WHERE x.beta_id = bs.beta_id AND x.server = bs.server AND x.script_id = b.script_id )` ); handle.exec( `INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id) SELECT d.id, d.script_id, d.script_version_id, d.prior_version_id FROM deployments d WHERE d.script_id IS NOT NULL AND d.script_version_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM deployment_scripts ds WHERE ds.deployment_id = d.id)` ); } // The pre-script-model beta tables had entirely different shapes (slug PK, // beta_scripts, beta_deploys). schema.sql defines the new shape; CREATE TABLE // IF NOT EXISTS is a no-op if the old tables already exist, so we have to // detect and drop them first. Any outstanding rows abort the boot so the // operator can promote/rollback in the old app before upgrading. function dropLegacyBetaTables(handle: Database.Database): void { const legacy = handle .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('betas','beta_scripts','beta_deploys')") .all() as Array<{ name: string }>; if (legacy.length === 0) return; const legacyNames = legacy.map((r) => r.name); // The old `betas` table had a `slug` column; the new one has `id` + `script_id`. // If we see `slug`, we know it's the old shape. const betasCols = handle.prepare("PRAGMA table_info(betas)").all() as Array<{ name: string }>; const isLegacyShape = betasCols.some((c) => c.name === 'slug'); if (!isLegacyShape) return; for (const table of ['beta_deploys', 'beta_scripts', 'betas']) { if (!legacyNames.includes(table)) continue; const row = handle.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }; if (row.n > 0) { throw new Error( `legacy ${table} still has ${row.n} rows — promote or rollback outstanding betas in the previous version before upgrading` ); } } // Drop in FK-safe order. for (const table of ['beta_deploys', 'beta_scripts', 'betas']) { if (legacyNames.includes(table)) handle.exec(`DROP TABLE ${table}`); } } // One-shot guard for the -beta/.OLD → DB-snapshot cutover. Legacy running/ // ready-promote betas from the old code have -beta. and .OLD files on // disk with no corresponding beta_file_snapshots rows — restoring them under // the new rollback path would delete-instead-of-restore. If a legacy active // beta is detected on the first boot after upgrade, refuse to start so the // operator can rollback/promote under the old code. A settings sentinel marks // the cutover complete so subsequent boots with (legitimately new-scheme) // active betas aren't blocked. function assertSnapshotSchemeSafe(handle: Database.Database): void { const done = handle.prepare("SELECT value FROM settings WHERE key = 'snapshots_v1'").get() as | { value: string } | undefined; if (done) return; const active = handle .prepare("SELECT id FROM betas WHERE state IN ('running','ready-promote') LIMIT 1") .get() as { id: number } | undefined; if (active) { throw new Error( `close active beta ${active.id} before upgrading — snapshot scheme is incompatible with legacy -beta/.OLD state` ); } handle.prepare("INSERT INTO settings (key, value) VALUES ('snapshots_v1', '1')").run(); } // Preserve old plans as reviewable checklists instead of silently passing checks // that the three-event protocol can no longer perform. Existing evidence stays // in history; active progress starts fresh under the new gameplay contract. function migrateGameTelemetry(handle: Database.Database): void { if (handle.prepare("SELECT 1 FROM settings WHERE key = 'game_telemetry_v2'").get()) return; handle.transaction(() => { const convert = (raw: string): string => { const plan = JSON.parse(raw) as BetaPlan; plan.requirements = plan.requirements.map(r => { const { event: _event, ...rest } = r as BetaRequirement & { event?: string }; return ['feature', 'restart-budget', 'clean-logs'].includes(r.kind) ? { ...requirement('manual', r.id), label: `Review: ${r.label}`.slice(0, 200) } : rest; }); return JSON.stringify(plan); }; const plans = handle.prepare('SELECT beta_id, plan FROM beta_test_plans').all() as Array<{ beta_id: number; plan: string }>; for (const row of plans) handle.prepare('UPDATE beta_test_plans SET plan = ? WHERE beta_id = ?').run(convert(row.plan), row.beta_id); const presets = handle.prepare('SELECT name, plan FROM beta_test_presets').all() as Array<{ name: string; plan: string }>; for (const row of presets) handle.prepare('UPDATE beta_test_presets SET plan = ? WHERE name = ?').run(convert(row.plan), row.name); handle.prepare(`UPDATE beta_test_runs SET metrics = ?, cursors = '{}' WHERE retired_at IS NULL AND beta_id IN (SELECT id FROM betas WHERE state IN ('running','ready-promote'))`).run(JSON.stringify(emptyMetrics())); handle.exec(`UPDATE beta_test_plans SET outcome = 'active', reason = 'Gameplay telemetry updated; collecting fresh script events' WHERE outcome IN ('active','passed') AND beta_id IN (SELECT id FROM betas WHERE state IN ('running','ready-promote')); UPDATE script_versions SET state = 'untested' WHERE state = 'tested' AND id IN (SELECT bs.script_version_id FROM beta_scripts bs JOIN beta_test_plans p ON p.beta_id = bs.beta_id WHERE p.outcome = 'active') AND id NOT IN (SELECT current_version_id FROM scripts WHERE current_version_id IS NOT NULL) AND NOT EXISTS (SELECT 1 FROM beta_scripts bs JOIN betas b ON b.id = bs.beta_id WHERE bs.script_version_id = script_versions.id AND b.state = 'promoted'); UPDATE betas SET state = 'running' WHERE state = 'ready-promote' AND id IN (SELECT beta_id FROM beta_test_plans WHERE outcome = 'active'); UPDATE beta_servers SET stage = 'observing' WHERE stage = 'ready' AND beta_id IN (SELECT beta_id FROM beta_test_plans WHERE outcome = 'active'); INSERT INTO settings (key, value) VALUES ('game_telemetry_v2', '1');`); })(); } export function db(): Database.Database { if (_db) return _db; mkdirSync(dirname(resolve(config.dbPath)), { recursive: true }); const handle = new Database(config.dbPath); // Do all the risky init on a local handle first — only assign the module // singleton once migrations succeed. A half-migrated handle that's cached // silently breaks every subsequent query without a chance to retry. try { handle.pragma('journal_mode = WAL'); handle.pragma('foreign_keys = ON'); dropLegacyBetaTables(handle); handle.exec(schemaSql); migrateServers(handle); // Column additions must run BEFORE any statement that references the // new columns. schema.sql cannot include those references — see the // comment there. migrateTable(handle, 'scripts', SCRIPTS_COLUMNS); migrateTable(handle, 'script_versions', SCRIPT_VERSIONS_COLUMNS); migrateTable(handle, 'beta_scripts', BETA_SCRIPTS_COLUMNS); migrateTable(handle, 'deployment_scripts', DEPLOYMENT_SCRIPTS_COLUMNS); migrateTable(handle, 'deployments', DEPLOYMENTS_COLUMNS); migrateTable(handle, 'server_telemetry', [{ name: 'payload', ddl: 'ALTER TABLE server_telemetry ADD COLUMN payload TEXT' }]); // Kind-referencing indexes: safe here because migrateTable above just // ALTER'd the column into existence if it was missing. handle.exec( `CREATE UNIQUE INDEX IF NOT EXISTS idx_scripts_one_baseline_per_scope ON scripts(scope_type, scope_value) WHERE kind = 'baseline'` ); handle.exec(`CREATE INDEX IF NOT EXISTS idx_scripts_kind ON scripts(kind)`); backfillMultiScriptJoins(handle); assertSnapshotSchemeSafe(handle); migrateGameTelemetry(handle); } catch (err) { handle.close(); throw err; } _db = handle; return _db; } export function closeDb(): void { if (_db) { _db.close(); _db = null; } }