437 lines
26 KiB
TypeScript
437 lines
26 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import { installReportRevisions } from '../reports/revisions.js';
|
|
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';
|
|
import { initializeFleetSettings } from './fleet-settings.js';
|
|
import { migrateServerHistory } from './server-history.js';
|
|
import { migrateNativeInstances } from '../backends/schema.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: '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: 'rcon_failures', ddl: 'ALTER TABLE servers ADD COLUMN rcon_failures INTEGER NOT NULL DEFAULT 0' },
|
|
{ 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);
|
|
}
|
|
}
|
|
// Retire external-backend registry fields, including required fields that
|
|
// would otherwise prevent native inserts into an upgraded database.
|
|
const retired = new Set(['komodo_type', 'komodo_name', 'komodo_server']);
|
|
handle.transaction(() => {
|
|
for (const column of rows) {
|
|
if (retired.has(column.name)) handle.exec(`ALTER TABLE servers DROP COLUMN "${column.name.replaceAll('"', '""')}"`);
|
|
}
|
|
})();
|
|
}
|
|
|
|
// 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 bs.server_deleted_at IS NULL
|
|
AND EXISTS (SELECT 1 FROM servers WHERE name = bs.server)
|
|
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'"));
|
|
const columns = (handle.prepare('PRAGMA table_info(server_deployments)').all() as { name: string }[])
|
|
.map(column => `"${column.name.replaceAll('"', '""')}"`).join(', ');
|
|
handle.exec(`INSERT INTO server_deployments_new (rowid, ${columns})
|
|
SELECT rowid, ${columns} 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 the host agent. 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, 'deployment_files', [
|
|
{ name: 'snapshot_pending', ddl: 'ALTER TABLE deployment_files ADD COLUMN snapshot_pending INTEGER NOT NULL DEFAULT 0' }
|
|
]);
|
|
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' },
|
|
{ name: 'phase_key', ddl: 'ALTER TABLE server_deployments ADD COLUMN phase_key TEXT' },
|
|
{ name: 'phase_started_at', ddl: 'ALTER TABLE server_deployments ADD COLUMN phase_started_at TEXT' },
|
|
{ name: 'observed_at', ddl: 'ALTER TABLE server_deployments ADD COLUMN observed_at TEXT' },
|
|
{ name: 'observation_json', ddl: 'ALTER TABLE server_deployments ADD COLUMN observation_json TEXT' }
|
|
]);
|
|
migrateTable(handle, 'server_deployment_events', [
|
|
{ name: 'kind', ddl: 'ALTER TABLE server_deployment_events ADD COLUMN kind TEXT' },
|
|
{ name: 'phase_key', ddl: 'ALTER TABLE server_deployment_events ADD COLUMN phase_key TEXT' },
|
|
{ name: 'details_json', ddl: 'ALTER TABLE server_deployment_events ADD COLUMN details_json TEXT' }
|
|
]);
|
|
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` }]);
|
|
}
|
|
migrateTable(handle, 'maintenance_jobs', [
|
|
{ name: 'kind', ddl: "ALTER TABLE maintenance_jobs ADD COLUMN kind TEXT NOT NULL DEFAULT 'legacy'" },
|
|
{ name: 'origin', ddl: "ALTER TABLE maintenance_jobs ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'" }
|
|
]);
|
|
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, 'beta_test_runs', [{ name: 'revision', ddl: 'ALTER TABLE beta_test_runs ADD COLUMN revision INTEGER NOT NULL DEFAULT 1' }]);
|
|
migrateTable(handle, 'deployment_scripts', DEPLOYMENT_SCRIPTS_COLUMNS);
|
|
migrateTable(handle, 'deployments', DEPLOYMENTS_COLUMNS);
|
|
// Older databases gain rolled_back_by above, after schema.sql runs.
|
|
handle.exec('CREATE INDEX IF NOT EXISTS idx_deployments_rolled_back_by ON deployments(rolled_back_by)');
|
|
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_launch_recipes', [
|
|
{ name: 'recipe_summary', ddl: 'ALTER TABLE server_launch_recipes ADD COLUMN recipe_summary TEXT' }
|
|
]);
|
|
migrateServerHistory(handle);
|
|
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' }]);
|
|
migrateTable(handle, 'leaderboard_match_players', [
|
|
{ name: 'first_observed_round', ddl: 'ALTER TABLE leaderboard_match_players ADD COLUMN first_observed_round INTEGER' },
|
|
{ name: 'present_at_start', ddl: 'ALTER TABLE leaderboard_match_players ADD COLUMN present_at_start INTEGER' }
|
|
]);
|
|
backfillLeaderboard(handle);
|
|
initializeFleetSettings(handle);
|
|
migrateNativeInstances(handle);
|
|
// Lifecycle history is retained indefinitely. Admission and status reads
|
|
// must narrow by server or active state instead of scanning that history.
|
|
// Install after migrations that rebuild this table or add server_deleted_at.
|
|
handle.exec(`CREATE INDEX IF NOT EXISTS idx_server_deployments_server
|
|
ON server_deployments(server, server_deleted_at);
|
|
CREATE INDEX IF NOT EXISTS idx_server_deployments_state
|
|
ON server_deployments(state, action, server_deleted_at);
|
|
CREATE INDEX IF NOT EXISTS idx_server_deployments_server_state
|
|
ON server_deployments(server, server_deleted_at, state, action);
|
|
CREATE INDEX IF NOT EXISTS idx_server_deployments_started_at
|
|
ON server_deployments(started_at);
|
|
CREATE INDEX IF NOT EXISTS idx_server_deployments_submission
|
|
ON server_deployments(server, server_deleted_at, action, submitted_at);
|
|
CREATE INDEX IF NOT EXISTS idx_server_deployments_finished_at
|
|
ON server_deployments(finished_at) WHERE server_deleted_at IS NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_server_deployments_unsettled
|
|
ON server_deployments(id) WHERE state NOT IN ('done','warning','cancelled');`);
|
|
installReportRevisions(handle);
|
|
} catch (err) {
|
|
handle.close();
|
|
throw err;
|
|
}
|
|
|
|
// Cache only runtime queries, after migrations have finished. Statements
|
|
// belong to this connection, so reopening the DB starts with an empty cache.
|
|
// Callers share statement options (e.g. raw()); use consistent options for
|
|
// identical SQL and pass values to run/get/all rather than binding permanently.
|
|
const statements = new Map<string, Database.Statement>();
|
|
const prepare = handle.prepare.bind(handle);
|
|
handle.prepare = ((sql: string) => {
|
|
let statement = statements.get(sql);
|
|
if (!statement) {
|
|
statement = prepare(sql);
|
|
statements.set(sql, statement);
|
|
}
|
|
return statement;
|
|
}) as typeof handle.prepare;
|
|
|
|
_db = handle;
|
|
return _db;
|
|
}
|
|
|
|
export function closeDb(): void {
|
|
if (_db) {
|
|
_db.close();
|
|
_db = null;
|
|
}
|
|
}
|