277 lines
16 KiB
TypeScript
277 lines
16 KiB
TypeScript
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.<ext> 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;
|
|
}
|
|
}
|