Files
electric-cherry/src/lib/server/db/index.ts
T

359 lines
21 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';
import { backfillLeaderboard } from '../fleet/leaderboard-ingest.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_checked_at', ddl: 'ALTER TABLE servers ADD COLUMN container_checked_at TEXT' },
{ name: 'container_state', ddl: 'ALTER TABLE servers ADD COLUMN container_state TEXT' },
{ name: 'desired_state', ddl: "ALTER TABLE servers ADD COLUMN desired_state TEXT NOT NULL DEFAULT 'running' CHECK (desired_state IN ('running','stopped'))" },
// 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');`);
})();
}
function migrateProvisioningActions(handle: Database.Database): void {
const table = handle.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'server_deployments'").get() as { sql: string };
if (table.sql.includes("'rebuild'")) return;
// Rebuild the CHECK constraint without cascading into events, recipes, or
// collection members. Preserve rowids: they order same-server operations.
handle.pragma('foreign_keys = OFF');
try {
handle.transaction(() => {
handle.exec(table.sql.replace('server_deployments', 'server_deployments_new')
.replace("'create', 'destroy'", "'create', 'start', 'restart', 'rebuild', 'stop', 'destroy'"));
handle.exec(`INSERT INTO server_deployments_new (rowid, id, server, action, state, stage, started_at, finished_at, triggered_by, error, warnings)
SELECT rowid, id, server, action, state, stage, started_at, finished_at, triggered_by, error, warnings FROM server_deployments;
DROP TABLE server_deployments;
ALTER TABLE server_deployments_new RENAME TO server_deployments;`);
if ((handle.pragma('foreign_key_check') as unknown[]).length) throw new Error('Provisioning migration found broken references');
})();
} finally { handle.pragma('foreign_keys = ON'); }
}
/** Rebuild constrained tables while preserving links, indexes, triggers, and row order. */
function migrateWatchdogConstraints(handle: Database.Database): void {
for (const name of ['provisioning_sources', 'deployment_servers']) {
const { sql } = handle.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) as { sql: string };
const replacement = name === 'provisioning_sources'
? (sql.includes("'watchdog'") ? sql : sql.replace("'campaign','beta','maintenance'", "'campaign','beta','maintenance','watchdog'"))
: sql.replace(/server\s+TEXT NOT NULL REFERENCES servers\(name\)/, 'server TEXT NOT NULL');
if (sql === replacement) continue;
const dependent = handle.prepare("SELECT sql FROM sqlite_master WHERE tbl_name = ? AND type IN ('index','trigger') AND sql IS NOT NULL")
.all(name) as { sql: string }[];
handle.pragma('foreign_keys = OFF');
try {
handle.transaction(() => {
handle.exec(replacement.replace(name, name + '_watchdog_new'));
handle.exec(`INSERT INTO ${name}_watchdog_new (rowid, ${
(handle.prepare(`PRAGMA table_info(${name})`).all() as { name: string }[]).map(col => col.name).join(', ')
}) SELECT rowid, ${
(handle.prepare(`PRAGMA table_info(${name})`).all() as { name: string }[]).map(col => col.name).join(', ')
} FROM ${name}; DROP TABLE ${name}; ALTER TABLE ${name}_watchdog_new RENAME TO ${name};`);
for (const object of dependent) handle.exec(object.sql);
if ((handle.pragma('foreign_key_check') as unknown[]).length) throw new Error('Watchdog migration found broken references');
})();
} finally { handle.pragma('foreign_keys = ON'); }
}
}
function backfillDesiredState(handle: Database.Database): void {
if (handle.prepare("SELECT 1 FROM settings WHERE key = 'watchdog_desired_state_v1'").get()) return;
handle.transaction(() => {
// A failed stop may have reached Komodo. Preserve that explicit intent.
handle.exec(`UPDATE servers SET desired_state = 'stopped' WHERE 'stop' = (
SELECT action FROM server_deployments d WHERE d.server = servers.name AND d.state != 'cancelled'
AND d.action IN ('create','start','restart','rebuild','stop')
ORDER BY d.rowid DESC LIMIT 1);
INSERT INTO settings (key, value) VALUES ('watchdog_desired_state_v1', '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);
migrateProvisioningActions(handle);
migrateWatchdogConstraints(handle);
migrateTable(handle, 'restart_rollout', [{ name: 'operation_id', ddl: 'ALTER TABLE restart_rollout ADD COLUMN operation_id TEXT' }]);
migrateTable(handle, 'server_deployments', [
{ name: 'submitted_at', ddl: 'ALTER TABLE server_deployments ADD COLUMN submitted_at TEXT' },
{ name: 'checked_at', ddl: 'ALTER TABLE server_deployments ADD COLUMN checked_at TEXT' },
{ name: 'players', ddl: 'ALTER TABLE server_deployments ADD COLUMN players INTEGER' }
]);
for (const table of ['deployment_servers', 'beta_servers', 'maintenance_jobs']) {
migrateTable(handle, table, [{ name: 'provisioning_id', ddl: `ALTER TABLE ${table} ADD COLUMN provisioning_id TEXT` }]);
}
migrateServers(handle);
backfillDesiredState(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_launch_collections', [
{ name: 'mode', ddl: "ALTER TABLE server_launch_collections ADD COLUMN mode TEXT NOT NULL DEFAULT 'fill'" },
{ name: 'retained_servers', ddl: "ALTER TABLE server_launch_collections ADD COLUMN retained_servers TEXT NOT NULL DEFAULT '[]'" }
]);
migrateTable(handle, 'server_collection_previews', [
{ name: 'mode', ddl: "ALTER TABLE server_collection_previews ADD COLUMN mode TEXT NOT NULL DEFAULT 'fill'" },
{ name: 'fleet_snapshot', ddl: 'ALTER TABLE server_collection_previews ADD COLUMN fleet_snapshot TEXT' }
]);
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);
migrateTable(handle, 'leaderboard_matches', [{ name: 'start_players', ddl: 'ALTER TABLE leaderboard_matches ADD COLUMN start_players INTEGER' }]);
backfillLeaderboard(handle);
} catch (err) {
handle.close();
throw err;
}
_db = handle;
return _db;
}
export function closeDb(): void {
if (_db) {
_db.close();
_db = null;
}
}