Use ordered SQLite migrations (Refs #75)
CI / checks (push) Successful in 5m6s

This commit is contained in:
2026-10-10 00:01:18 -04:00
parent a0663e43b8
commit 8d1bb69591
56 changed files with 1085 additions and 736 deletions
+16
View File
@@ -220,6 +220,22 @@ retained across restarts; interrupted transactions roll back and retry on startu
The one-time vacuum needs disk space for a temporary database copy. **Do not
remove `cherry.db-wal` to recover from an interrupted upgrade.**
Database upgrades are ordered in `src/lib/server/db/migrations.ts` and tracked by
SQLite's `PRAGMA user_version`. Startup applies only pending versions, including
host, authentication and game-file schemas, before serving requests or starting
workers. Databases from older releases begin at version 0; the compatibility
steps honor their existing migration markers so completed backfills are preserved.
An application cannot open a database with a newer version than it supports.
For a schema or data upgrade, append a consecutive version to that list. Do not
edit an applied migration or rely on changing `schema.sql` to upgrade databases.
Each step and its version commit in one transaction. Table rebuilds can select
`foreign-keys-off`, which disables enforcement before the transaction and checks
references before committing. The resumable incremental-vacuum step uses
`autocommit` because SQLite forbids `VACUUM` inside a transaction; its version is
recorded only after the rebuild and host preparation boundary recovery succeed.
Connection tuning and query optimization still run on every open.
The controller uses a 128 MiB page cache; each reader worker uses 32 MiB.
Main-database migrations use 16 MiB with memory mapping disabled. Temporary
storage stays disk-backed. Containers use `/data/.sqlite-tmp` for scratch files;
-6
View File
@@ -85,12 +85,6 @@ async function main() {
};
closeDb = database.closeDb;
const handle = database.db();
const hosts = await vite.ssrLoadModule('/src/lib/server/hosts/schema.ts');
const auth = await vite.ssrLoadModule('/src/lib/server/auth/schema.ts');
const files = await vite.ssrLoadModule('/src/lib/server/game-files/schema.ts');
hosts.ensureHostSchema(handle);
auth.ensureAuthSchema(handle);
files.ensureGameFileSchema(handle);
seedFixtureData(handle, options.now);
const password = randomBytes(24).toString('base64url');
const { hashPassword } = await vite.ssrLoadModule('/src/lib/server/auth/passwords.js');
-2
View File
@@ -9,7 +9,6 @@ import {
} from '../activity.js';
import { serverTitle } from '../ui/server-labels.js';
import { db } from './db/index.js';
import { ensureHostSchema } from './hosts/schema.js';
import { importantOperationSql } from './hosts/observations.js';
const initialized = new WeakSet<Database.Database>();
@@ -190,7 +189,6 @@ function present(row: SqlRow): ActivityRow {
export function activityHistory(input: ActivityQuery = {}, now = Date.now()): ActivityPage {
const query = normalizeActivityQuery(input);
const handle = db();
ensureHostSchema(handle);
if (!initialized.has(handle)) {
handle.function('activity_server_title', { deterministic: true }, (name: unknown) =>
typeof name === 'string' ? serverTitle(name) : ''
+1 -4
View File
@@ -1,6 +1,5 @@
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { db } from '../db/index.js';
import { ensureAuthSchema } from './schema.js';
import { hashPassword, verifyPassword } from './passwords.js';
import { systemUsername, type AuthUser, type UserRole } from '../../auth.js';
@@ -9,9 +8,7 @@ export const SESSION_LIFETIME_MS = 12 * 60 * 60_000;
export const SESSION_IDLE_MS = 30 * 60_000;
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
function database() {
const handle = db();
ensureAuthSchema(handle);
return handle;
return db();
}
type UserRow = {
id: string;
+1 -4
View File
@@ -1,7 +1,5 @@
import type Database from 'better-sqlite3';
const initialized = new WeakSet<Database.Database>();
export function ensureAuthSchema(handle: Database.Database): void {
if (initialized.has(handle)) return;
export function migrateAuthSchema(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS auth_users (
id TEXT PRIMARY KEY, username TEXT NOT NULL COLLATE NOCASE UNIQUE,
@@ -24,5 +22,4 @@ export function ensureAuthSchema(handle: Database.Database): void {
);
CREATE INDEX IF NOT EXISTS auth_audit_created_at ON auth_audit(created_at);
`);
initialized.add(handle);
}
+4 -614
View File
@@ -1,417 +1,15 @@
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';
import { migrateServerKeys } from './server-keys.js';
import { ensureAutoscalingSchema } from '../autoscaling/schema.js';
import { ensureRebalancingSchema } from '../rebalancing/schema.js';
import { installPerformanceIndexes } from './performance-indexes.js';
import { migrateFileBlobs } from './file-blobs.js';
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
import { migrationStep, tuneConnection, tuneMigrationConnection } from './connection.js';
import { migrations } from './migrations.js';
import { runMigrations } from './migration-runner.js';
import { tuneConnection, tuneMigrationConnection } from './connection.js';
import { timeStatement } from './slow-statements.js';
let _db: Database.Database | null = null;
let optimizeTimer: NodeJS.Timeout | undefined;
// 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.
// Existing bundle membership is authoritative. Only single-script betas can
// inherit server membership from the deprecated primary-script columns.
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 (SELECT COUNT(*) FROM beta_scripts x WHERE x.beta_id = b.id) = 1
AND EXISTS (SELECT 1 FROM beta_scripts x WHERE x.beta_id = b.id AND x.script_id = b.script_id)
AND NOT EXISTS (
SELECT 1 FROM beta_server_scripts x
WHERE x.beta_id = bs.beta_id AND x.server = bs.server
)`
);
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("'autoscaling'")
? sql
: sql.replace(
/'campaign','beta','maintenance'(?:,'watchdog')?/,
"'campaign','beta','maintenance','watchdog','autoscaling'"
)
: 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;
@@ -426,215 +24,7 @@ export function db(): Database.Database {
handle.pragma('auto_vacuum = INCREMENTAL');
handle.pragma('foreign_keys = ON');
tuneMigrationConnection(handle);
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: 'requested_by',
ddl: 'ALTER TABLE server_deployments ADD COLUMN requested_by TEXT'
},
{
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);
// Old snippets may contain player addresses, including hex-encoded bytes.
// Discard them once; newly retained diagnostics are sanitized failure replies only.
if (
!handle.prepare("SELECT 1 FROM settings WHERE key='rcon_private_diagnostics_v1'").get()
) {
handle.transaction(() => {
handle.exec(`UPDATE servers SET last_reply_snippet=NULL;
UPDATE servers SET last_error='bad-reply:unparseable print reply'
WHERE last_error LIKE 'bad-reply:print reply not recognized:%';
INSERT INTO settings(key,value) VALUES('rcon_private_diagnostics_v1','1');`);
})();
}
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: 'cancelled_at',
ddl: 'ALTER TABLE server_launch_collections ADD COLUMN cancelled_at TEXT'
},
{
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);
migrationStep('native instance schema and file plans', () =>
migrateNativeInstances(handle)
);
migrationStep('per-game server key pools', () => migrateServerKeys(handle));
migrationStep('autoscaling schema', () => ensureAutoscalingSchema(handle));
migrationStep('rebalancing schema', () => ensureRebalancingSchema(handle));
// File-blob migration removes the legacy content column altogether.
// Reordering it first would unnecessarily copy every artifact twice.
migrateFileBlobs(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');`);
installPerformanceIndexes(handle);
installReportRevisions(handle);
migrationStep('retention clocks and indexes', () =>
retentionClocks(handle, {
deployments: ['started_at', 'finished_at'],
server_deployments: ['started_at', 'finished_at'],
maintenance_jobs: ['requested_at', 'finished_at'],
watchdog_incidents: ['first_seen_at', 'finished_at'],
leaderboard_matches: ['started_at', 'last_received_at', 'ended_at'],
betas: ['opened_at', 'closed_at'],
script_versions: ['uploaded_at'],
retention_controls: ['resolved_at']
})
);
// Existing databases require one rebuild to gain the incremental-vacuum page map.
enableIncrementalVacuum(handle);
runMigrations(handle, migrations);
handle.pragma('optimize = 0x10002');
handle.pragma('shrink_memory');
tuneConnection(handle);
+586
View File
@@ -0,0 +1,586 @@
import type Database from 'better-sqlite3';
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';
// Compatibility bridge for unversioned databases. Historical column checks and
// sentinels distinguish releases that already applied destructive backfills.
// Freeze this bridge; add future upgrades to the ordered list in migrations.ts.
// Desired shape of the `servers` table. Every column here is added to the
// legacy table 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.
// Existing bundle membership is authoritative. Only single-script betas can
// inherit server membership from the deprecated primary-script columns.
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 (SELECT COUNT(*) FROM beta_scripts x WHERE x.beta_id = b.id) = 1
AND EXISTS (SELECT 1 FROM beta_scripts x WHERE x.beta_id = b.id AND x.script_id = b.script_id)
AND NOT EXISTS (
SELECT 1 FROM beta_server_scripts x
WHERE x.beta_id = bs.beta_id AND x.server = bs.server
)`
);
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("'autoscaling'")
? sql
: sql.replace(
/'campaign','beta','maintenance'(?:,'watchdog')?/,
"'campaign','beta','maintenance','watchdog','autoscaling'"
)
: 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 migrateLegacySchema(handle: Database.Database): void {
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: 'requested_by',
ddl: 'ALTER TABLE server_deployments ADD COLUMN requested_by TEXT'
},
{
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);
// Old snippets may contain player addresses, including hex-encoded bytes.
// Discard them once; newly retained diagnostics are sanitized failure replies only.
if (!handle.prepare("SELECT 1 FROM settings WHERE key='rcon_private_diagnostics_v1'").get()) {
handle.transaction(() => {
handle.exec(`UPDATE servers SET last_reply_snippet=NULL;
UPDATE servers SET last_error='bad-reply:unparseable print reply'
WHERE last_error LIKE 'bad-reply:print reply not recognized:%';
INSERT INTO settings(key,value) VALUES('rcon_private_diagnostics_v1','1');`);
})();
}
backfillDesiredState(handle);
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: 'cancelled_at',
ddl: 'ALTER TABLE server_launch_collections ADD COLUMN cancelled_at TEXT'
},
{
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);
}
export function installLifecycleIndexes(handle: Database.Database): void {
// 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');`);
}
+3 -5
View File
@@ -1,9 +1,7 @@
import { db } from './index.js';
// The heavy lifting is idempotent DDL inside schema.sql, which db() runs on
// first handle. This module exists as an explicit hook for future migrations
// (numbered .sql files applied via a schema_version row) and to be called
// from hooks.server.ts so boot-time failures surface early.
// Called from hooks.server.ts so startup fails before workers or requests run.
// db() applies the ordered migrations.ts list using PRAGMA user_version.
export function migrate(): void {
db().prepare('SELECT 1').get();
db();
}
+60
View File
@@ -0,0 +1,60 @@
import type Database from 'better-sqlite3';
import { migrationStep } from './connection.js';
export interface Migration {
version: number;
name: string;
run: (handle: Database.Database) => void;
// Table rebuilds must disable foreign keys before opening the transaction.
// VACUUM cannot run in one; autocommit steps must be safe to resume.
mode?: 'foreign-keys-off' | 'autocommit';
}
export function runMigrations(handle: Database.Database, migrations: readonly Migration[]): void {
for (const [index, migration] of migrations.entries()) {
if (migration.version !== index + 1)
throw new Error('Database migrations must have consecutive versions starting at 1');
}
const version = () => handle.pragma('user_version', { simple: true }) as number;
const assertSupported = (current: number) => {
if (current < 0 || current > migrations.length)
throw new Error(
`Database version ${current} is not supported by this application (latest: ${migrations.length})`
);
};
const initialVersion = version();
assertSupported(initialVersion);
if (handle.inTransaction) throw new Error('Database migrations require their own transaction');
for (const migration of migrations) {
if (initialVersion >= migration.version) continue;
migrationStep(`${migration.version}: ${migration.name}`, () => {
const foreignKeys = handle.pragma('foreign_keys', { simple: true });
if (migration.mode === 'foreign-keys-off') handle.pragma('foreign_keys = OFF');
try {
const apply = () => {
// Recheck after acquiring the write lock in case another opener migrated.
const current = version();
assertSupported(current);
if (current >= migration.version) return;
if (current !== migration.version - 1)
throw new Error(`Missing database migration before ${migration.version}`);
migration.run(handle);
if (
migration.mode === 'foreign-keys-off' &&
(handle.pragma('foreign_key_check') as unknown[]).length
)
throw new Error(
`Database migration ${migration.version} left broken references`
);
handle.pragma(`user_version = ${migration.version}`);
};
if (migration.mode === 'autocommit') apply();
else handle.transaction(apply).immediate();
} finally {
if (migration.mode === 'foreign-keys-off')
handle.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`);
}
});
}
}
+71
View File
@@ -0,0 +1,71 @@
import { migrateLegacySchema, installLifecycleIndexes } from './legacy.js';
import { migrateNativeInstances } from '../backends/schema.js';
import { migrateServerKeys } from './server-keys.js';
import { ensureAutoscalingSchema } from '../autoscaling/schema.js';
import { ensureRebalancingSchema } from '../rebalancing/schema.js';
import { migrateFileBlobs } from './file-blobs.js';
import { installPerformanceIndexes } from './performance-indexes.js';
import { installReportRevisions } from '../reports/revisions.js';
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
import { migrateHostSchema } from '../hosts/schema.js';
import { migrateAuthSchema } from '../auth/schema.js';
import { migrateGameFileSchema } from '../game-files/schema.js';
import type { Migration } from './migration-runner.js';
// Append only: never renumber entries or change an applied migration to add an
// upgrade. These initial steps bridge every historical, unversioned database;
// their old sentinels are compatibility inputs, not the migration ledger.
export const migrations: readonly Migration[] = [
{
version: 1,
name: 'legacy controller schema and backfills',
mode: 'foreign-keys-off',
run: migrateLegacySchema
},
{
version: 2,
name: 'native instances and file plans',
mode: 'foreign-keys-off',
run: migrateNativeInstances
},
{ version: 3, name: 'per-game server key pools', run: migrateServerKeys },
{ version: 4, name: 'autoscaling', run: ensureAutoscalingSchema },
{ version: 5, name: 'rebalancing', run: ensureRebalancingSchema },
// Native file plans are stripped before artifact bodies are moved to blobs.
{ version: 6, name: 'shared file blobs', run: migrateFileBlobs },
{
version: 7,
name: 'query indexes and report revisions',
run: (handle) => {
installLifecycleIndexes(handle);
installPerformanceIndexes(handle);
installReportRevisions(handle);
}
},
{
version: 8,
name: 'retention clocks and indexes',
run: (handle) =>
retentionClocks(handle, {
deployments: ['started_at', 'finished_at'],
server_deployments: ['started_at', 'finished_at'],
maintenance_jobs: ['requested_at', 'finished_at'],
watchdog_incidents: ['first_seen_at', 'finished_at'],
leaderboard_matches: ['started_at', 'last_received_at', 'ended_at'],
betas: ['opened_at', 'closed_at'],
script_versions: ['uploaded_at'],
retention_controls: ['resolved_at']
})
},
{ version: 9, name: 'hosts and saved command files', run: migrateHostSchema },
{ version: 10, name: 'authentication', run: migrateAuthSchema },
{ version: 11, name: 'game-file libraries', run: migrateGameFileSchema },
{
version: 12,
name: 'incremental vacuum page map',
mode: 'autocommit',
run: enableIncrementalVacuum
}
];
export const databaseVersion = migrations.length;
+4 -4
View File
@@ -8,12 +8,12 @@ CREATE TABLE IF NOT EXISTS file_blob_references (
);
CREATE INDEX IF NOT EXISTS idx_file_blob_references_hash ON file_blob_references(sha256);
-- Electric Cherry schema. Idempotent — safe to run at every boot via migrate.ts.
-- Base schema for the version-0 compatibility bridge in migrations.ts.
-- Future schema changes belong in new numbered migrations.
-- The old betas/beta_scripts/beta_deploys tables are dropped in-code before this
-- runs (see dropLegacyBetaTables in db/index.ts); the new script-centric model
-- runs (see dropLegacyBetaTables in db/legacy.ts); the new script-centric model
-- lives here.
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- Historical gameplay deliberately has no FK to servers: destroying a server
@@ -138,7 +138,7 @@ CREATE TABLE IF NOT EXISTS scripts (
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_scripts_scope ON scripts(scope_type, scope_value);
-- kind-referencing indexes are created in db/index.ts AFTER migrateTable adds
-- kind-referencing indexes are created in db/legacy.ts after migrateTable adds
-- the column to legacy DBs. Putting them here would fail with "no such column:
-- kind" on an existing pre-kind DB, breaking boot.
-3
View File
@@ -22,7 +22,6 @@ async function exclusive<T>(id: number, action: () => Promise<T>): Promise<T> {
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { readFile, exists } from '../backends/files.js';
import { getHostOperation } from '../hosts/index.js';
import { ensureHostSchema } from '../hosts/schema.js';
import { join, posix } from 'node:path';
import { betaArchiveOf, configFileOf, destOfKind, type ArtifactKind } from '../config.js';
@@ -668,7 +667,6 @@ export async function reviseBeta(
'invalid-state'
);
}
ensureHostSchema(db());
assertNativeActivationSettled(betaId);
if (stagedFileChanges('beta', String(betaId)).some((row) => row.state === 'applying')) {
throw new BetaError(
@@ -961,7 +959,6 @@ async function rollbackBetaInternal(
versionId: br.script_version_id
}));
ensureHostSchema(db());
db().transaction(() => {
const jobs = db()
.prepare(
@@ -1,5 +1,4 @@
import { db } from '../db/index.js';
import { ensureHostSchema } from '../hosts/schema.js';
import { HEARTBEAT_MAX_AGE_MS } from '../hosts/index.js';
import { parseHostInventory } from '../hosts/request-context.js';
import type { Host } from '../../hosts.js';
@@ -70,7 +69,6 @@ interface Record {
export class MonitoringSnapshot {
private records = new Map<string, Record>();
constructor(serverName?: string) {
ensureHostSchema(db());
this.refresh(serverName);
}
refresh(server?: string): void {
+1 -6
View File
@@ -5,8 +5,6 @@ import { dirname, join, resolve } from 'node:path';
import { db } from '../db/index.js';
import { config } from '../config.js';
import { getHost, getHostOperation, listHosts, queueHostOperation } from '../hosts/index.js';
import { ensureGameFileSchema } from './schema.js';
import { ensureHostSchema } from '../hosts/schema.js';
import {
manifestChecksum,
receiveGameFile,
@@ -35,10 +33,7 @@ import type {
} from '../../game-files.js';
function database() {
const handle = db();
ensureGameFileSchema(handle);
ensureHostSchema(handle);
return handle;
return db();
}
export const gameFilesRoot = () =>
resolve(process.env.GAME_FILES_ROOT ?? join(dirname(config.dbPath), 'game-files'));
+1 -9
View File
@@ -1,13 +1,6 @@
import type Database from 'better-sqlite3';
const initialized = new WeakSet<Database.Database>();
export function ensureGameFileSchema(handle: Database.Database): void {
if (initialized.has(handle)) return;
// Worker readers open only after the controller has initialized the schema.
if (handle.readonly) {
initialized.add(handle);
return;
}
export function migrateGameFileSchema(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS game_file_libraries (
id TEXT PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE UNIQUE,
@@ -62,5 +55,4 @@ export function ensureGameFileSchema(handle: Database.Database): void {
handle.exec('ALTER TABLE host_library_caches ADD COLUMN bytes_verified INTEGER');
if (!cacheColumns.some((column) => column.name === 'transfer_progress'))
handle.exec('ALTER TABLE host_library_caches ADD COLUMN transfer_progress TEXT');
initialized.add(handle);
}
+1 -4
View File
@@ -1,6 +1,5 @@
import { fleetSettings } from '../settings.js';
import { db } from '../db/index.js';
import { ensureHostSchema } from './schema.js';
import type {
CapacityCalibration,
CapacitySample,
@@ -25,9 +24,7 @@ interface AllowanceRow {
computed_at: string;
}
function database() {
const handle = db();
ensureHostSchema(handle);
return handle;
return db();
}
/** Read retained minute peaks without contacting agents or recalculating allowances. */
-2
View File
@@ -1,5 +1,4 @@
import { db } from '../db/index.js';
import { ensureHostSchema } from './schema.js';
import type { CapacityHistory, CapacityQuery, CapacityRange } from '../../capacity.js';
const windows: Record<CapacityRange, { duration: number; bucketMs: number }> = {
@@ -11,7 +10,6 @@ const windows: Record<CapacityRange, { duration: number; bucketMs: number }> = {
/** Read on the report worker: heartbeat history can contain millions of rows. */
export function readCapacityHistory(query: CapacityQuery, now = Date.now()): CapacityHistory {
const handle = db();
ensureHostSchema(handle);
const { duration, bucketMs } = windows[query.range];
const startAt = now - duration;
const count = duration / bucketMs;
+1 -4
View File
@@ -5,7 +5,6 @@ import { EventEmitter } from 'node:events';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { isIP } from 'node:net';
import { db } from '../db/index.js';
import { ensureHostSchema } from './schema.js';
import {
successfulObservation,
routineObservationSql,
@@ -60,9 +59,7 @@ export function waitForHostWork(
}
const digest = (token: string) => createHash('sha256').update(token).digest('hex');
function database() {
const handle = db();
ensureHostSchema(handle);
return handle;
return db();
}
type HostRow = {
id: string;
+1 -14
View File
@@ -7,7 +7,6 @@ import {
} from '../db/file-blobs.js';
import { retentionClocks } from '../db/retention-clocks.js';
import { installHistoryCounts } from './history-counts.js';
import { migrationStep } from '../db/connection.js';
import type Database from 'better-sqlite3';
import {
successfulObservationSql,
@@ -16,19 +15,7 @@ import {
importantOperationSql
} from './observations.js';
const initialized = new WeakSet<Database.Database>();
export function ensureHostSchema(handle: Database.Database): void {
if (initialized.has(handle)) return;
// Background readers open only after controller migrations have completed.
if (handle.readonly) {
initialized.add(handle);
return;
}
migrationStep('host schema and saved command files', () => migrateHostSchema(handle));
initialized.add(handle);
}
function migrateHostSchema(handle: Database.Database): void {
export function migrateHostSchema(handle: Database.Database): void {
ensureFileBlobSchema(handle);
handle.exec(`
CREATE TABLE IF NOT EXISTS ec_hosts (
@@ -1,6 +1,5 @@
import { pauseScalingForOperation } from '../autoscaling/ownership.js';
import { db } from '../db/index.js';
import { ensureHostSchema } from '../hosts/schema.js';
import { recordProgress } from './progress.js';
import { requestRebalanceStopForOperation } from '../rebalancing/admission.js';
@@ -42,7 +41,6 @@ export function cancelProvisioningJob(
reason = 'Cancelled by operator',
options: { preserveRebalanceRun?: boolean } = {}
): boolean {
ensureHostSchema(db());
return db().transaction(() => {
if (provisioningCancelled(id)) return true;
if (!canCancelProvisioningJob(id)) return false;
@@ -1,4 +1,3 @@
import { ensureHostSchema } from '../hosts/schema.js';
import { db } from '../db/index.js';
import { fleetSettings } from '../settings.js';
@@ -16,7 +15,6 @@ interface Holder {
}
export function lifecycleHolders(): Holder[] {
ensureHostSchema(db());
// Find capacity owners through their active state or outstanding command.
// A correlated OR over d used to walk every retained job and fetch its agent
// receipt on every admission, even when all that history was completed.
-3
View File
@@ -23,7 +23,6 @@ import {
} from './cancellation.js';
import { committedRebalanceStartup } from './concurrency.js';
import { nativeDeployment } from '../backends/native.js';
import { ensureHostSchema } from '../hosts/schema.js';
import { safetyOverride, safetyOverrideAvailability } from './safety-override.js';
import { serverTitle } from '../../ui/server-labels.js';
import { provisioningQueueStatus } from '../../ui/provisioning-queue.js';
@@ -420,7 +419,6 @@ export function resumeCleanup(id: string): boolean {
}
export function canResumeVerification(id: string): boolean {
ensureHostSchema(db());
return !!db()
.prepare(
`SELECT d.id FROM server_deployments d
@@ -444,7 +442,6 @@ export function canResumeVerification(id: string): boolean {
/** A conclusively failed initial create can enter the normal rebuild flow. */
export function canRetryWithRebuild(id: string): boolean {
ensureHostSchema(db());
return !!db()
.prepare(
`SELECT d.id FROM server_deployments d
-2
View File
@@ -14,7 +14,6 @@ import { choosePlacement, availableHostPort, type PlacementOptions } from '../ho
import { randomBytes, randomUUID } from 'node:crypto';
import { posix } from 'node:path';
import { db } from '../db/index.js';
import { ensureHostSchema } from '../hosts/schema.js';
import { unsettledCancelledCommand } from './cancellation.js';
import { config, relDestForKind, serverDir } from '../config.js';
import {
@@ -1014,7 +1013,6 @@ export async function processNativeProvisioning(
}) => string | null,
options: { wait?: boolean } = {}
): Promise<void> {
ensureHostSchema(db());
const cancelled = db()
.prepare(
`SELECT d.id FROM server_deployments d
-2
View File
@@ -10,7 +10,6 @@ import type {
LeaderboardEntry
} from '../../leaderboard.js';
import type { CapacityHistory, CapacityQuery } from '../../capacity.js';
import { ensureHostSchema } from '../hosts/schema.js';
class ReportWorker {
private worker: Worker | null = null;
@@ -116,7 +115,6 @@ export const playerReport = (guid: string, offset: number, options: LeaderboardO
options
});
export const capacityHistoryReport = (query: CapacityQuery) => {
ensureHostSchema(db());
// Game changes current placement estimates, not measured host resources.
return capacity.run<CapacityHistory>('capacity', {
query: { ...query, game: 'all' },
-14
View File
@@ -3,9 +3,6 @@ import { createHash } from 'node:crypto';
import { db } from './db/index.js';
import { fleetSettings } from './settings.js';
import { runtimeLaunchSql } from './fleet/runtime-config.js';
import { ensureHostSchema } from './hosts/schema.js';
import { ensureGameFileSchema } from './game-files/schema.js';
import { ensureAuthSchema } from './auth/schema.js';
import { logResultMetadata } from './hosts/log-results.js';
import type { FleetSettings } from '../fleet-settings.js';
@@ -266,10 +263,6 @@ function versionReferences(): string {
}
return clauses.join(' AND ') || '1';
}
function initialize() {
ensureHostSchema(db());
ensureAuthSchema(db());
}
function withoutFileBodies(value: unknown): unknown {
if (Array.isArray(value)) return value.map(withoutFileBodies);
@@ -364,7 +357,6 @@ function selection(policy: Policy, settings = fleetSettings()) {
}
// better-sqlite3 accepts unused named bindings, keeping selection reusable.
export function retentionPreview(settings = fleetSettings()) {
initialize();
return policies().map((policy) => {
const query = selection(policy, settings);
const summary = db()
@@ -393,8 +385,6 @@ export function setRetentionControl(input: {
protected?: boolean;
resolve?: boolean;
}) {
initialize();
ensureGameFileSchema(db());
const policy =
policies().find((policy) => policy.category === input.category) ??
(['game-file', 'upload'].includes(input.category)
@@ -449,7 +439,6 @@ export function setRetentionControl(input: {
/** Each policy removes at most 500 eligible records per pass; dependencies are checked again under the transaction. */
export function pruneHistory(only?: string): number {
initialize();
if (only === 'observation') return pruneRoutineObservations();
let removed = only ? 0 : pruneRoutineObservations();
for (const policy of policies().filter(
@@ -594,7 +583,6 @@ function samplePolicies(s = fleetSettings()) {
];
}
export function sampleRetentionPreview(settings = fleetSettings()) {
initialize();
const result = samplePolicies(settings)
.filter((p) => p.category !== 'telemetry')
.map((p) => ({
@@ -634,7 +622,6 @@ export function sampleRetentionPreview(settings = fleetSettings()) {
return result;
}
export function pruneSampleHistory() {
initialize();
let removed = 0;
for (const p of samplePolicies().filter((p) => p.enabled))
removed += db()
@@ -663,7 +650,6 @@ export function pruneSampleHistory() {
export async function drainRetention(): Promise<number> {
let total = 0,
logCursor = 0;
initialize();
while (true) {
const removed = pruneHistory() + pruneSampleHistory();
total += removed;
+5
View File
@@ -1,5 +1,7 @@
import type Database from 'better-sqlite3';
import { defaultFleetSettings } from '../../src/lib/fleet-settings.js';
import { migrateHostSchema } from '../../src/lib/server/hosts/schema.js';
import { migrateGameFileSchema } from '../../src/lib/server/game-files/schema.js';
export function initializeStandaloneSettings(handle: Database.Database) {
handle.exec(`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY,value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS retention_controls (category TEXT NOT NULL,record_id TEXT NOT NULL,protected INTEGER NOT NULL DEFAULT 0,resolved_at TEXT,PRIMARY KEY(category,record_id));
@@ -7,4 +9,7 @@ export function initializeStandaloneSettings(handle: Database.Database) {
handle
.prepare("INSERT OR IGNORE INTO settings(key,value) VALUES('fleet_settings',?)")
.run(JSON.stringify(defaultFleetSettings));
// These isolated library tests mock db(), bypassing startup migrations.
migrateHostSchema(handle);
migrateGameFileSchema(handle);
}
+3
View File
@@ -136,6 +136,7 @@ it.each(['dry-run', 'create-only'])('turns saved %s policies off once on upgrade
"UPDATE autoscaling_policies SET mode=?,settings=json_set(settings,'$.mode',?) WHERE id=?"
)
.run(mode, mode, saved.id);
db().pragma('user_version = 0');
closeDb();
const migrated = scaling.getScalingPolicy(saved.id)!;
expect(migrated).toMatchObject({
@@ -177,6 +178,7 @@ it.each([false, true])(
db()
.prepare('UPDATE server_deployments SET submitted_at=? WHERE id=?')
.run(new Date().toISOString(), id);
db().pragma('user_version = 0');
closeDb();
expect(scaling.getScalingPolicy(saved.id)?.mode).toBe('off');
expect(
@@ -211,6 +213,7 @@ it('removes saved cooldowns on upgrade without changing the empty hold or pausin
"UPDATE autoscaling_policies SET next_action_at=?,settings=json_set(settings,'$.cooldownMinutes',60) WHERE id=?"
)
.run(Date.now() + 3600_000, saved.id);
db().pragma('user_version = 0');
closeDb();
const restored = scaling.getScalingPolicy(saved.id)!;
expect(restored).toEqual(saved);
+1
View File
@@ -108,6 +108,7 @@ describe('per-server beta membership', () => {
const x = await seedBeta(false);
x.assign('first', x.scripts[0].id);
x.db().prepare('DELETE FROM beta_scripts WHERE beta_id = ?').run(x.betaId);
x.db().pragma('user_version = 0');
x.closeDb();
expect(x.memberships()).toEqual([
{
+1
View File
@@ -583,6 +583,7 @@ describe('durable beta test plans', () => {
.prepare('INSERT INTO beta_test_presets (name, plan) VALUES (?, ?)')
.run('Old plan', JSON.stringify(oldPlan));
db().prepare("DELETE FROM settings WHERE key = 'game_telemetry_v2'").run();
db().pragma('user_version = 0');
closeDb();
const { testView } = await import('../../src/lib/server/fleet/beta-tests.js');
const { getBeta } = await import('../../src/lib/server/fleet/beta.js');
+1
View File
@@ -69,6 +69,7 @@ describe('reviewed beta promotion', () => {
const legacy = new Database(fixture.dbPath);
try {
legacy.exec('ALTER TABLE deployment_files DROP COLUMN snapshot_pending');
legacy.pragma('user_version = 0');
} finally {
legacy.close();
}
-2
View File
@@ -18,8 +18,6 @@ afterEach(async () => {
async function host(id: string, createdAt = epoch - 7 * 86_400_000) {
const { db } = await import('../../src/lib/server/db/index.js');
const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
ensureHostSchema(db());
db()
.prepare('INSERT INTO ec_hosts (id,name,created_at) VALUES (?,?,?)')
.run(id, id, new Date(createdAt).toISOString());
-2
View File
@@ -45,8 +45,6 @@ beforeEach(async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(epoch);
const { db } = await import('../../src/lib/server/db/index.js');
const { ensureGameFileSchema } = await import('../../src/lib/server/game-files/schema.js');
ensureGameFileSchema(db());
for (const game of ['t5', 't6']) {
db()
.prepare(
@@ -431,6 +431,7 @@ describe('collection cancellation', () => {
const detail = await launch(1);
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
db().exec('ALTER TABLE server_launch_collections DROP COLUMN cancelled_at');
db().pragma('user_version = 0');
closeDb();
const { getCollection, cancelCollection } =
await import('../../src/lib/server/fleet/collections.js');
+1 -5
View File
@@ -34,13 +34,9 @@ it('indexes campaign cascades and beta evidence on fresh and reopened databases'
}
});
it('covers every foreign key child prefix, including lazy host and library schemas', async () => {
it('covers every foreign key child prefix, including host and library schemas', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
const { ensureGameFileSchema } = await import('../../src/lib/server/game-files/schema.js');
const handle = db();
ensureHostSchema(handle);
ensureGameFileSchema(handle);
const tables = handle.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as {
name: string;
}[];
+140
View File
@@ -0,0 +1,140 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
});
afterEach(async () => {
vi.restoreAllMocks();
await fixture.cleanup();
});
it('initializes every schema on first open and runs no migration SQL on later opens', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { databaseVersion } = await import('../../src/lib/server/db/migrations.js');
const handle = db();
expect(handle.pragma('user_version', { simple: true })).toBe(databaseVersion);
for (const table of [
'servers',
'native_instances',
'ec_hosts',
'auth_users',
'game_file_versions'
])
expect(handle.prepare('SELECT name FROM sqlite_master WHERE name=?').get(table)).toEqual({
name: table
});
handle.exec(
"INSERT INTO servers(name,last_reply_snippet) VALUES('saved','sanitized diagnostic')"
);
const schemaVersion = handle.pragma('schema_version', { simple: true });
closeDb();
const exec = vi.spyOn(Database.prototype, 'exec');
const prepare = vi.spyOn(Database.prototype, 'prepare');
const reopened = db();
expect(exec).not.toHaveBeenCalled();
expect(prepare).not.toHaveBeenCalled();
expect(reopened.pragma('schema_version', { simple: true })).toBe(schemaVersion);
expect(reopened.prepare('SELECT name,last_reply_snippet FROM servers').get()).toEqual({
name: 'saved',
last_reply_snippet: 'sanitized diagnostic'
});
expect(reopened.pragma('foreign_key_check')).toEqual([]);
});
it('adopts a fully migrated unversioned database without replaying destructive backfills', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { databaseVersion } = await import('../../src/lib/server/db/migrations.js');
const handle = db();
handle.exec(`INSERT INTO servers(name,desired_state,last_reply_snippet) VALUES('saved','stopped','keep');
INSERT INTO file_blobs(sha256,size,content) VALUES('hash',1,X'01');
INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'saved','test.gsc');
INSERT INTO script_versions(id,script_id,version,sha256,size,content_hash) VALUES(1,1,1,'hash',1,'hash');
INSERT INTO betas(id,script_id,script_version_id,trigger_kind,state) VALUES(1,1,1,'bundle','running');
INSERT INTO beta_test_plans(beta_id,plan,outcome) VALUES(1,'{"requirements":[]}','active');`);
// All pre-versioning releases used 0; their sentinels are compatibility inputs.
handle.pragma('user_version = 0');
closeDb();
expect(db().pragma('user_version', { simple: true })).toBe(databaseVersion);
expect(db().prepare('SELECT desired_state,last_reply_snippet FROM servers').get()).toEqual({
desired_state: 'stopped',
last_reply_snippet: 'keep'
});
expect(db().prepare('SELECT state FROM betas').get()).toEqual({ state: 'running' });
expect(db().prepare('SELECT plan FROM beta_test_plans').get()).toEqual({
plan: '{"requirements":[]}'
});
expect(db().pragma('foreign_key_check')).toEqual([]);
});
it('closes a failed startup handle and resumes from the last committed version', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { migrations, databaseVersion } = await import('../../src/lib/server/db/migrations.js');
const hosts = migrations.find(
(migration) => migration.name === 'hosts and saved command files'
)!;
const run = hosts.run;
const failure = vi.spyOn(hosts, 'run').mockImplementation((handle) => {
run(handle);
throw new Error('interrupted host upgrade');
});
expect(db).toThrow('interrupted host upgrade');
const inspect = new Database(fixture.dbPath);
try {
expect(inspect.pragma('user_version', { simple: true })).toBe(hosts.version - 1);
expect(
inspect.prepare("SELECT 1 FROM sqlite_master WHERE name='ec_hosts'").get()
).toBeUndefined();
} finally {
inspect.close();
}
failure.mockRestore();
const completed = migrations
.slice(0, hosts.version - 1)
.map((migration) => vi.spyOn(migration, 'run'));
expect(db().pragma('user_version', { simple: true })).toBe(databaseVersion);
for (const migration of completed) expect(migration).not.toHaveBeenCalled();
expect(db().pragma('foreign_key_check')).toEqual([]);
});
it('keeps the legacy snapshot safety guard and rolls back an unsafe first upgrade', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const handle = db();
handle.exec(`INSERT INTO file_blobs(sha256,size,content) VALUES('hash',1,X'01');
INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'saved','test.gsc');
INSERT INTO script_versions(id,script_id,version,sha256,size,content_hash) VALUES(1,1,1,'hash',1,'hash');
INSERT INTO betas(id,script_id,script_version_id,trigger_kind,state) VALUES(1,1,1,'bundle','running');
DELETE FROM settings WHERE key='snapshots_v1';`);
handle.pragma('user_version = 0');
closeDb();
expect(db).toThrow('close active beta 1 before upgrading');
const inspect = new Database(fixture.dbPath);
try {
expect(inspect.pragma('user_version', { simple: true })).toBe(0);
expect(inspect.prepare('SELECT state FROM betas').get()).toEqual({ state: 'running' });
// The join backfill precedes the guard, but must roll back with it.
expect(inspect.prepare('SELECT * FROM beta_scripts').all()).toEqual([]);
expect(inspect.pragma('foreign_key_check')).toEqual([]);
} finally {
inspect.close();
}
});
it('rejects a database from a newer application without changing its version or schema', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { databaseVersion } = await import('../../src/lib/server/db/migrations.js');
db().pragma(`user_version = ${databaseVersion + 1}`);
const schemaVersion = db().pragma('schema_version', { simple: true });
closeDb();
expect(db).toThrow('not supported by this application');
const inspect = new Database(fixture.dbPath);
try {
expect(inspect.pragma('user_version', { simple: true })).toBe(databaseVersion + 1);
expect(inspect.pragma('schema_version', { simple: true })).toBe(schemaVersion);
} finally {
inspect.close();
}
});
@@ -368,6 +368,7 @@ describe('matching a blueprint exactly', () => {
// Recreate the older schema, which predates the history preservation trigger.
db().exec('DROP TRIGGER preserve_deleted_server_history');
db().exec('ALTER TABLE server_launch_collections DROP COLUMN retained_servers');
db().pragma('user_version = 0');
closeDb();
const { previewCollection } = await import('../../src/lib/server/fleet/collections.js');
expect(previewCollection(plan.id).mode).toBe('fill');
+1
View File
@@ -163,6 +163,7 @@ it('migrates legacy command and receipt bytes without changing replay identity',
"DELETE FROM ec_host_schema_migrations WHERE name IN ('staged-operation-payloads','operation-file-blobs')"
)
.run();
db().pragma('user_version = 0');
closeDb();
expect(hosts.getHostOperation('legacy')).toMatchObject({ payload, result, state: 'running' });
expect(
+3
View File
@@ -65,6 +65,7 @@ describe('fleet settings API and persistence', () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
db().prepare("DELETE FROM settings WHERE key = 'fleet_settings'").run();
db().prepare("INSERT INTO settings (key, value) VALUES ('unrelated', 'true')").run();
db().pragma('user_version = 0');
closeDb();
vi.stubEnv('DEPLOY_KEEP_DAYS', '14');
vi.stubEnv('DEPLOYMENT_RESTART_CONCURRENCY', '4');
@@ -92,6 +93,7 @@ describe('fleet settings API and persistence', () => {
db()
.prepare("UPDATE settings SET value = ? WHERE key = 'fleet_settings'")
.run(JSON.stringify(previous));
db().pragma('user_version = 0');
closeDb();
vi.stubEnv('DEPLOY_KEEP_DAYS', 'bad');
vi.stubEnv('DEPLOYMENT_RESTART_CONCURRENCY', '9');
@@ -129,6 +131,7 @@ describe('fleet settings API and persistence', () => {
.prepare("UPDATE settings SET value = ? WHERE key = 'fleet_settings'")
.run(JSON.stringify(previous));
db().prepare("INSERT INTO settings(key,value) VALUES('unrelated','keep')").run();
db().pragma('user_version = 0');
closeDb();
vi.stubEnv('DEPLOY_KEEP_DAYS', 'bad');
const { GET } = await import('../../src/routes/api/settings/+server.js');
@@ -354,6 +354,7 @@ describe('host game-file transfer progress', () => {
const before = cache();
database.db().exec('ALTER TABLE host_library_caches DROP COLUMN transfer_progress');
database.db().exec('ALTER TABLE host_library_caches DROP COLUMN bytes_verified');
database.db().pragma('user_version = 0');
database.closeDb();
expect(cache()).toMatchObject({
hostId,
@@ -238,6 +238,7 @@ describe('measured instance capacity', () => {
db().exec(
'UPDATE ec_resource_allowances SET cpu_cores=cpu_cores*1.25, memory_bytes=memory_bytes*1.25'
);
db().pragma('user_version = 0');
closeDb();
const { capacityCalibration } = await import('../../src/lib/server/hosts/calibration.js');
expect(capacityCalibration(hostId)).toEqual(model);
+3 -5
View File
@@ -51,14 +51,14 @@ async function instance(hostId: string, retired = false) {
describe('host deletion', () => {
it('adds the deletion marker to existing host schemas without losing hosts', async () => {
const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
const { migrateHostSchema } = await import('../../src/lib/server/hosts/schema.js');
const legacy = new Database(':memory:');
try {
legacy.exec(
"CREATE TABLE ec_hosts(id TEXT PRIMARY KEY, name TEXT NOT NULL); INSERT INTO ec_hosts VALUES('old','Existing host')"
);
ensureHostSchema(legacy);
ensureHostSchema(legacy);
migrateHostSchema(legacy);
migrateHostSchema(legacy);
expect(legacy.prepare('SELECT * FROM ec_hosts').get()).toEqual({
id: 'old',
name: 'Existing host',
@@ -99,8 +99,6 @@ describe('host deletion', () => {
const file = join(fixture.root, 'game-files-sentinel');
await writeFile(file, 'Game files remain');
const { db } = await import('../../src/lib/server/db/index.js');
const { ensureGameFileSchema } = await import('../../src/lib/server/game-files/schema.js');
ensureGameFileSchema(db());
db()
.prepare(
"INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES('library','T5','t5','',?)"
@@ -222,6 +222,7 @@ describe('routine host observation retention', () => {
api.handle.exec(
"UPDATE ec_host_operations SET observation_success=0; DELETE FROM ec_host_schema_migrations WHERE name='completed-status-batches'"
);
api.handle.pragma('user_version = 0');
api.closeDb();
expect(
api
@@ -380,6 +381,7 @@ describe('routine host observation retention', () => {
DROP TRIGGER operation_history_count_update; DROP TABLE ec_host_operation_history_counts;
DELETE FROM ec_host_schema_migrations WHERE name='history-counts-v1'`);
api.handle.exec('ALTER TABLE ec_host_operations DROP COLUMN observation_success');
api.handle.pragma('user_version = 0');
api.closeDb();
expect(api.pruneHostObservations()).toBe(2);
@@ -574,6 +574,7 @@ describe('host operation history', () => {
ALTER TABLE ec_host_operations DROP COLUMN server_name;
ALTER TABLE ec_host_operations DROP COLUMN target_path;
ALTER TABLE ec_host_operations DROP COLUMN source;`);
api.handle.pragma('user_version = 0');
closeDb();
const history = api.listHostOperationHistory(1, 'all');
expect(history.operations).toHaveLength(4);
+1
View File
@@ -638,6 +638,7 @@ it('backfills valid saved telemetry once while skipping malformed and legacy sna
insert.run('t6_zombie_town_1', 99, 4, receivedAt, '{malformed');
insert.run('t6_zombie_farm_1', 99, 4, receivedAt, null);
db().prepare("DELETE FROM settings WHERE key = 'leaderboard_v1'").run();
db().pragma('user_version = 0');
closeDb();
expect(await matches()).toEqual([
expect.objectContaining({ highest_round: 8, start_observed: 0 })
+1
View File
@@ -291,6 +291,7 @@ it('migrates legacy participation as unknown before snapshot backfill and preser
)
.run(saved.server, 8, 1, saved.receivedAt, JSON.stringify(saved));
handle.prepare("DELETE FROM settings WHERE key = 'leaderboard_v1'").run();
handle.pragma('user_version = 0');
closeDb();
expect(
db()
+159
View File
@@ -0,0 +1,159 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { runMigrations, type Migration } from '../../src/lib/server/db/migration-runner.js';
let handle: Database.Database;
beforeEach(() => {
handle = new Database(':memory:');
handle.pragma('foreign_keys = ON');
});
afterEach(() => {
vi.restoreAllMocks();
handle.close();
});
it('applies ordered upgrades once and appends only pending versions', () => {
const first = vi.fn((db: Database.Database) =>
db.exec("CREATE TABLE records(value TEXT); INSERT INTO records VALUES('original')")
);
const migrations: Migration[] = [{ version: 1, name: 'records', run: first }];
runMigrations(handle, migrations);
handle.exec("UPDATE records SET value='edited'");
const next = vi.fn((db: Database.Database) => {
expect(db.pragma('user_version', { simple: true })).toBe(1);
db.exec('ALTER TABLE records ADD COLUMN added INTEGER DEFAULT 7');
});
migrations.push({ version: 2, name: 'additional field', run: next });
runMigrations(handle, migrations);
runMigrations(handle, migrations);
expect(first).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledTimes(1);
expect(handle.prepare('SELECT * FROM records').get()).toEqual({ value: 'edited', added: 7 });
expect(handle.pragma('user_version', { simple: true })).toBe(2);
});
it('rolls back schema, data and version on failure, then retries only the failed step', () => {
let fail = true;
const migrations: Migration[] = [
{
version: 1,
name: 'records',
run: (db) =>
db.exec("CREATE TABLE records(value TEXT); INSERT INTO records VALUES('old')")
},
{
version: 2,
name: 'backfill',
run: (db) => {
db.exec(
"ALTER TABLE records ADD COLUMN extra TEXT; UPDATE records SET value='new'"
);
if (fail) throw new Error('interrupted');
}
},
{
version: 3,
name: 'index',
run: (db) => db.exec('CREATE INDEX records_value ON records(value)')
}
];
expect(() => runMigrations(handle, migrations)).toThrow('interrupted');
expect(handle.pragma('user_version', { simple: true })).toBe(1);
expect(handle.prepare('SELECT * FROM records').get()).toEqual({ value: 'old' });
expect(handle.pragma('index_list(records)')).toEqual([]);
fail = false;
runMigrations(handle, migrations);
expect(handle.prepare('SELECT * FROM records').get()).toEqual({ value: 'new', extra: null });
expect(handle.pragma('user_version', { simple: true })).toBe(3);
});
it('does not commit a migration if recording its version fails', () => {
const pragma = handle.pragma.bind(handle);
vi.spyOn(handle, 'pragma').mockImplementation((sql, options) => {
if (sql === 'user_version = 1') throw new Error('version write failed');
return pragma(sql, options);
});
expect(() =>
runMigrations(handle, [
{
version: 1,
name: 'records',
run: (db) => db.exec('CREATE TABLE records(value TEXT)')
}
])
).toThrow('version write failed');
expect(pragma('user_version', { simple: true })).toBe(0);
expect(
handle.prepare("SELECT 1 FROM sqlite_master WHERE name='records'").get()
).toBeUndefined();
});
it('rebuilds a referenced table without cascading and restores foreign key enforcement', () => {
handle.exec(`CREATE TABLE parent(id INTEGER PRIMARY KEY);
CREATE TABLE child(parent_id INTEGER REFERENCES parent(id) ON DELETE CASCADE);
INSERT INTO parent VALUES(1); INSERT INTO child VALUES(1);`);
runMigrations(handle, [
{
version: 1,
name: 'rebuild',
mode: 'foreign-keys-off',
run: (db) =>
db.exec(`CREATE TABLE replacement(id INTEGER PRIMARY KEY, label TEXT);
INSERT INTO replacement(id) SELECT id FROM parent;
DROP TABLE parent; ALTER TABLE replacement RENAME TO parent;`)
}
]);
expect(handle.prepare('SELECT * FROM child').all()).toEqual([{ parent_id: 1 }]);
expect(handle.pragma('foreign_keys', { simple: true })).toBe(1);
expect(handle.pragma('foreign_key_check')).toEqual([]);
});
it('rolls back broken references and restores foreign keys on failure', () => {
handle.exec(`CREATE TABLE parent(id INTEGER PRIMARY KEY);
CREATE TABLE child(parent_id INTEGER REFERENCES parent(id));`);
expect(() =>
runMigrations(handle, [
{
version: 1,
name: 'broken reference',
mode: 'foreign-keys-off',
run: (db) => db.exec('INSERT INTO child VALUES(99)')
}
])
).toThrow('broken references');
expect(handle.prepare('SELECT * FROM child').all()).toEqual([]);
expect(handle.pragma('foreign_keys', { simple: true })).toBe(1);
expect(handle.pragma('user_version', { simple: true })).toBe(0);
});
it('leaves interrupted autocommit work pending for a resumable retry', () => {
let fail = true;
const migrations: Migration[] = [
{
version: 1,
name: 'vacuum',
mode: 'autocommit',
run: (db) => {
expect(db.inTransaction).toBe(false);
db.exec('CREATE TABLE IF NOT EXISTS records(value TEXT); VACUUM');
if (fail) throw new Error('interrupted after vacuum');
}
}
];
expect(() => runMigrations(handle, migrations)).toThrow('interrupted after vacuum');
expect(handle.pragma('user_version', { simple: true })).toBe(0);
fail = false;
runMigrations(handle, migrations);
expect(handle.pragma('user_version', { simple: true })).toBe(1);
});
it('rejects newer databases and invalid migration sequences before running work', () => {
const run = vi.fn();
handle.pragma('user_version = 2');
expect(() => runMigrations(handle, [{ version: 1, name: 'older app', run }])).toThrow(
'Database version 2 is not supported'
);
handle.pragma('user_version = 0');
expect(() => runMigrations(handle, [{ version: 2, name: 'gap', run }])).toThrow('consecutive');
expect(run).not.toHaveBeenCalled();
});
@@ -21,8 +21,6 @@ afterEach(async () => {
async function intent() {
const { db } = await import('../../src/lib/server/db/index.js');
const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
ensureHostSchema(db());
const { beginDeployment } = await import('../../src/lib/server/provisioning/history.js');
const id = beginDeployment(server, 'create');
db().prepare("UPDATE server_deployments SET state='queued' WHERE id=?").run(id);
+1
View File
@@ -75,6 +75,7 @@ describe('plugin release versions', () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { version } = createScript(plugin);
db().exec('ALTER TABLE script_versions DROP COLUMN release_version');
db().pragma('user_version = 0');
closeDb();
expect(getVersion(version.id)).toMatchObject({
id: version.id,
@@ -122,6 +122,7 @@ it('adds the requester column to existing databases without inventing historical
await import('../../src/lib/server/provisioning/history.js');
const id = beginDeployment('legacy', 'restart');
db().exec('ALTER TABLE server_deployments DROP COLUMN requested_by');
db().pragma('user_version = 0');
closeDb();
expect(getDeployment(id)?.deployment).toMatchObject({ requested_by: null, triggered_by: 'ui' });
});
+1
View File
@@ -103,6 +103,7 @@ it('discards legacy plain text and hex snippets and unsafe error details once du
db().exec(`DELETE FROM settings WHERE key='rcon_private_diagnostics_v1';
UPDATE servers SET last_error='bad-reply:print reply not recognized: "player 192.0.2.123:28960"';
INSERT INTO servers(name,last_reply_snippet) VALUES ('legacy-hex','0000 31 39 32 2e 30 2e 32 2e 31 32 33 3a 32 38 39 36 192.0.2.123:2896');`);
db().pragma('user_version = 0');
closeDb();
expect(db().prepare('SELECT last_reply_snippet FROM servers').all()).toEqual([
{ last_reply_snippet: null },
-2
View File
@@ -89,11 +89,9 @@ it('expires completed file payloads while keeping history, replay identity, hold
it('retains rebalance recovery files until the move is reconciled, then lets history expire', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
const { pruneHistory } = await import('../../src/lib/server/retention.js');
const { putFileBlob } = await import('../../src/lib/server/db/file-blobs.js');
const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js');
ensureHostSchema(db());
saveFleetSettings({
...fleetSettings(),
historyKeepCount: 0,
-2
View File
@@ -12,8 +12,6 @@ afterEach(async () => {
});
async function api() {
const { db } = await import('../../src/lib/server/db/index.js');
const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
ensureHostSchema(db());
return {
db: db(),
...(await import('../../src/lib/server/retention.js')),
-6
View File
@@ -188,13 +188,7 @@ describe('useful synthetic fixture data', () => {
vi.resetModules();
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
const { ensureAuthSchema } = await import('../../src/lib/server/auth/schema.js');
const { ensureGameFileSchema } = await import('../../src/lib/server/game-files/schema.js');
handle = db();
ensureHostSchema(handle);
ensureAuthSchema(handle);
ensureGameFileSchema(handle);
seedFixtureData(handle, NOW);
}, 20_000);
beforeEach(() => handle.exec('SAVEPOINT fixture_test'));
+2 -2
View File
@@ -72,9 +72,9 @@ it('recovers actual launch credentials and holds both sides of an unresolved leg
previous.exec(`DROP TABLE server_keys;
CREATE TABLE server_keys(server_id TEXT PRIMARY KEY,pluto_key TEXT NOT NULL,updated_at TEXT NOT NULL DEFAULT (datetime('now')));`);
const { migrateNativeInstances } = await import('../../src/lib/server/backends/schema.js');
const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
const { migrateHostSchema } = await import('../../src/lib/server/hosts/schema.js');
migrateNativeInstances(previous);
ensureHostSchema(previous);
migrateHostSchema(previous);
previous.exec(`INSERT INTO ec_hosts(id,name,created_at) VALUES('host','Fixture host','2026-01-01');
INSERT INTO servers(name,game) VALUES('t5_zombie_theater_1','t5'),('t5_zombie_pentagon_1','t5');
INSERT INTO server_keys(server_id,pluto_key) VALUES('t5_zombie_theater_1','edited-saved-key'),
+1
View File
@@ -206,6 +206,7 @@ describe('database fleet and container readiness', () => {
const { db, getServer } = await setup();
const { closeDb } = await import('../../src/lib/server/db/index.js');
db.exec('ALTER TABLE servers DROP COLUMN rcon_failures');
db.pragma('user_version = 0');
closeDb();
expect(getServer('managed')).toMatchObject({
rcon_failures: 0,
+1
View File
@@ -416,6 +416,7 @@ it('migrates old round-only storage without fabricating player identities', asyn
db()
.prepare('INSERT INTO server_telemetry VALUES (?, ?, ?, ?)')
.run(payload.server, 99, 4, new Date().toISOString());
db().pragma('user_version = 0');
closeDb();
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
expect(listTelemetry()).toEqual([]);