From 8d1bb69591c359027d1fb26cc917fdfbce521d82 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 10 Oct 2026 00:01:18 -0400 Subject: [PATCH] Use ordered SQLite migrations (Refs #75) --- docs/development/README.md | 16 + scripts/seed-fixture.ts | 6 - src/lib/server/activity.ts | 2 - src/lib/server/auth/index.ts | 5 +- src/lib/server/auth/schema.ts | 5 +- src/lib/server/db/index.ts | 618 +----------------- src/lib/server/db/legacy.ts | 586 +++++++++++++++++ src/lib/server/db/migrate.ts | 8 +- src/lib/server/db/migration-runner.ts | 60 ++ src/lib/server/db/migrations.ts | 71 ++ src/lib/server/db/schema.sql | 8 +- src/lib/server/fleet/beta.ts | 3 - src/lib/server/fleet/monitoring-snapshot.ts | 2 - src/lib/server/game-files/index.ts | 7 +- src/lib/server/game-files/schema.ts | 10 +- src/lib/server/hosts/calibration.ts | 5 +- src/lib/server/hosts/capacity-history.ts | 2 - src/lib/server/hosts/index.ts | 5 +- src/lib/server/hosts/schema.ts | 15 +- src/lib/server/provisioning/cancellation.ts | 2 - src/lib/server/provisioning/concurrency.ts | 2 - src/lib/server/provisioning/history.ts | 3 - src/lib/server/provisioning/native.ts | 2 - src/lib/server/reports/index.ts | 2 - src/lib/server/retention.ts | 14 - tests/helpers/settings.ts | 5 + tests/unit/autoscaling-policies.test.ts | 3 + tests/unit/beta-memberships.test.ts | 1 + tests/unit/beta-plans.test.ts | 1 + tests/unit/beta-promotion.test.ts | 1 + tests/unit/capacity-history.test.ts | 2 - tests/unit/capacity-overview.test.ts | 2 - tests/unit/collection-cancellation.test.ts | 1 + tests/unit/database-indexes.test.ts | 6 +- tests/unit/database-migrations.test.ts | 140 ++++ tests/unit/deployment-collections.test.ts | 1 + tests/unit/file-blobs.test.ts | 1 + tests/unit/fleet-settings.test.ts | 3 + .../unit/game-file-transfer-progress.test.ts | 1 + tests/unit/host-capacity-calibration.test.ts | 1 + tests/unit/host-deletion.test.ts | 8 +- tests/unit/host-observation-retention.test.ts | 2 + tests/unit/host-operation-history.test.ts | 1 + tests/unit/leaderboard-ingest.test.ts | 1 + tests/unit/leaderboard-records.test.ts | 1 + tests/unit/migration-runner.test.ts | 159 +++++ tests/unit/native-creation-intents.test.ts | 2 - tests/unit/plugin-versions.test.ts | 1 + tests/unit/provisioning-requester.test.ts | 1 + tests/unit/rcon-diagnostics.test.ts | 1 + tests/unit/retention-storage.test.ts | 2 - tests/unit/retention.test.ts | 2 - tests/unit/seed-fixture.test.ts | 6 - tests/unit/server-key-migration.test.ts | 4 +- tests/unit/server-status.test.ts | 1 + tests/unit/telemetry.test.ts | 1 + 56 files changed, 1085 insertions(+), 736 deletions(-) create mode 100644 src/lib/server/db/legacy.ts create mode 100644 src/lib/server/db/migration-runner.ts create mode 100644 src/lib/server/db/migrations.ts create mode 100644 tests/unit/database-migrations.test.ts create mode 100644 tests/unit/migration-runner.test.ts diff --git a/docs/development/README.md b/docs/development/README.md index 72f77bb..250466e 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -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; diff --git a/scripts/seed-fixture.ts b/scripts/seed-fixture.ts index ebdf1c2..6f06330 100644 --- a/scripts/seed-fixture.ts +++ b/scripts/seed-fixture.ts @@ -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'); diff --git a/src/lib/server/activity.ts b/src/lib/server/activity.ts index a710f77..bc9a591 100644 --- a/src/lib/server/activity.ts +++ b/src/lib/server/activity.ts @@ -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(); @@ -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) : '' diff --git a/src/lib/server/auth/index.ts b/src/lib/server/auth/index.ts index 059f9df..1167d51 100644 --- a/src/lib/server/auth/index.ts +++ b/src/lib/server/auth/index.ts @@ -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; diff --git a/src/lib/server/auth/schema.ts b/src/lib/server/auth/schema.ts index 673af36..4ccd89a 100644 --- a/src/lib/server/auth/schema.ts +++ b/src/lib/server/auth/schema.ts @@ -1,7 +1,5 @@ import type Database from 'better-sqlite3'; -const initialized = new WeakSet(); -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); } diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 1eb9b10..430eead 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -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. 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); diff --git a/src/lib/server/db/legacy.ts b/src/lib/server/db/legacy.ts new file mode 100644 index 0000000..ad17fba --- /dev/null +++ b/src/lib/server/db/legacy.ts @@ -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. 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');`); +} diff --git a/src/lib/server/db/migrate.ts b/src/lib/server/db/migrate.ts index dba739f..176acc5 100644 --- a/src/lib/server/db/migrate.ts +++ b/src/lib/server/db/migrate.ts @@ -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(); } diff --git a/src/lib/server/db/migration-runner.ts b/src/lib/server/db/migration-runner.ts new file mode 100644 index 0000000..c02e39f --- /dev/null +++ b/src/lib/server/db/migration-runner.ts @@ -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'}`); + } + }); + } +} diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts new file mode 100644 index 0000000..389a07c --- /dev/null +++ b/src/lib/server/db/migrations.ts @@ -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; diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 9db3ca9..58ea543 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -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. diff --git a/src/lib/server/fleet/beta.ts b/src/lib/server/fleet/beta.ts index e55605e..96a65ce 100644 --- a/src/lib/server/fleet/beta.ts +++ b/src/lib/server/fleet/beta.ts @@ -22,7 +22,6 @@ async function exclusive(id: number, action: () => Promise): Promise { 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( diff --git a/src/lib/server/fleet/monitoring-snapshot.ts b/src/lib/server/fleet/monitoring-snapshot.ts index f30e3c3..db66a93 100644 --- a/src/lib/server/fleet/monitoring-snapshot.ts +++ b/src/lib/server/fleet/monitoring-snapshot.ts @@ -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(); constructor(serverName?: string) { - ensureHostSchema(db()); this.refresh(serverName); } refresh(server?: string): void { diff --git a/src/lib/server/game-files/index.ts b/src/lib/server/game-files/index.ts index bb8bf2b..870e2ea 100644 --- a/src/lib/server/game-files/index.ts +++ b/src/lib/server/game-files/index.ts @@ -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')); diff --git a/src/lib/server/game-files/schema.ts b/src/lib/server/game-files/schema.ts index 680e09c..8a26311 100644 --- a/src/lib/server/game-files/schema.ts +++ b/src/lib/server/game-files/schema.ts @@ -1,13 +1,6 @@ import type Database from 'better-sqlite3'; -const initialized = new WeakSet(); -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); } diff --git a/src/lib/server/hosts/calibration.ts b/src/lib/server/hosts/calibration.ts index d4ed2ff..39e8f16 100644 --- a/src/lib/server/hosts/calibration.ts +++ b/src/lib/server/hosts/calibration.ts @@ -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. */ diff --git a/src/lib/server/hosts/capacity-history.ts b/src/lib/server/hosts/capacity-history.ts index d0a9bee..163775a 100644 --- a/src/lib/server/hosts/capacity-history.ts +++ b/src/lib/server/hosts/capacity-history.ts @@ -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 = { @@ -11,7 +10,6 @@ const windows: Record = { /** 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; diff --git a/src/lib/server/hosts/index.ts b/src/lib/server/hosts/index.ts index 46317f0..50d7bbf 100644 --- a/src/lib/server/hosts/index.ts +++ b/src/lib/server/hosts/index.ts @@ -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; diff --git a/src/lib/server/hosts/schema.ts b/src/lib/server/hosts/schema.ts index 41af912..101b189 100644 --- a/src/lib/server/hosts/schema.ts +++ b/src/lib/server/hosts/schema.ts @@ -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(); -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 ( diff --git a/src/lib/server/provisioning/cancellation.ts b/src/lib/server/provisioning/cancellation.ts index 6edb1cb..b76fd29 100644 --- a/src/lib/server/provisioning/cancellation.ts +++ b/src/lib/server/provisioning/cancellation.ts @@ -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; diff --git a/src/lib/server/provisioning/concurrency.ts b/src/lib/server/provisioning/concurrency.ts index 2cdc23b..314e5e2 100644 --- a/src/lib/server/provisioning/concurrency.ts +++ b/src/lib/server/provisioning/concurrency.ts @@ -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. diff --git a/src/lib/server/provisioning/history.ts b/src/lib/server/provisioning/history.ts index 0563fd2..6a959c5 100644 --- a/src/lib/server/provisioning/history.ts +++ b/src/lib/server/provisioning/history.ts @@ -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 diff --git a/src/lib/server/provisioning/native.ts b/src/lib/server/provisioning/native.ts index 988e19d..82b5779 100644 --- a/src/lib/server/provisioning/native.ts +++ b/src/lib/server/provisioning/native.ts @@ -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 { - ensureHostSchema(db()); const cancelled = db() .prepare( `SELECT d.id FROM server_deployments d diff --git a/src/lib/server/reports/index.ts b/src/lib/server/reports/index.ts index bd22a04..4810c7f 100644 --- a/src/lib/server/reports/index.ts +++ b/src/lib/server/reports/index.ts @@ -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('capacity', { query: { ...query, game: 'all' }, diff --git a/src/lib/server/retention.ts b/src/lib/server/retention.ts index 4a57c4e..52ec21f 100644 --- a/src/lib/server/retention.ts +++ b/src/lib/server/retention.ts @@ -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 { let total = 0, logCursor = 0; - initialize(); while (true) { const removed = pruneHistory() + pruneSampleHistory(); total += removed; diff --git a/tests/helpers/settings.ts b/tests/helpers/settings.ts index 753ddf4..ca3690c 100644 --- a/tests/helpers/settings.ts +++ b/tests/helpers/settings.ts @@ -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); } diff --git a/tests/unit/autoscaling-policies.test.ts b/tests/unit/autoscaling-policies.test.ts index 6c6bacb..527cd20 100644 --- a/tests/unit/autoscaling-policies.test.ts +++ b/tests/unit/autoscaling-policies.test.ts @@ -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); diff --git a/tests/unit/beta-memberships.test.ts b/tests/unit/beta-memberships.test.ts index 2c340cd..687aacf 100644 --- a/tests/unit/beta-memberships.test.ts +++ b/tests/unit/beta-memberships.test.ts @@ -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([ { diff --git a/tests/unit/beta-plans.test.ts b/tests/unit/beta-plans.test.ts index a8ab59d..14fc16b 100644 --- a/tests/unit/beta-plans.test.ts +++ b/tests/unit/beta-plans.test.ts @@ -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'); diff --git a/tests/unit/beta-promotion.test.ts b/tests/unit/beta-promotion.test.ts index 91119b6..7c2b861 100644 --- a/tests/unit/beta-promotion.test.ts +++ b/tests/unit/beta-promotion.test.ts @@ -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(); } diff --git a/tests/unit/capacity-history.test.ts b/tests/unit/capacity-history.test.ts index f30615e..240cc14 100644 --- a/tests/unit/capacity-history.test.ts +++ b/tests/unit/capacity-history.test.ts @@ -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()); diff --git a/tests/unit/capacity-overview.test.ts b/tests/unit/capacity-overview.test.ts index f0f6e2d..257cc3d 100644 --- a/tests/unit/capacity-overview.test.ts +++ b/tests/unit/capacity-overview.test.ts @@ -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( diff --git a/tests/unit/collection-cancellation.test.ts b/tests/unit/collection-cancellation.test.ts index a9424ca..c467af2 100644 --- a/tests/unit/collection-cancellation.test.ts +++ b/tests/unit/collection-cancellation.test.ts @@ -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'); diff --git a/tests/unit/database-indexes.test.ts b/tests/unit/database-indexes.test.ts index 61c91ca..64ae0d8 100644 --- a/tests/unit/database-indexes.test.ts +++ b/tests/unit/database-indexes.test.ts @@ -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; }[]; diff --git a/tests/unit/database-migrations.test.ts b/tests/unit/database-migrations.test.ts new file mode 100644 index 0000000..fbec139 --- /dev/null +++ b/tests/unit/database-migrations.test.ts @@ -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(); + } +}); diff --git a/tests/unit/deployment-collections.test.ts b/tests/unit/deployment-collections.test.ts index e2448a7..100659f 100644 --- a/tests/unit/deployment-collections.test.ts +++ b/tests/unit/deployment-collections.test.ts @@ -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'); diff --git a/tests/unit/file-blobs.test.ts b/tests/unit/file-blobs.test.ts index cc93d38..25d591f 100644 --- a/tests/unit/file-blobs.test.ts +++ b/tests/unit/file-blobs.test.ts @@ -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( diff --git a/tests/unit/fleet-settings.test.ts b/tests/unit/fleet-settings.test.ts index c112483..aaf11cf 100644 --- a/tests/unit/fleet-settings.test.ts +++ b/tests/unit/fleet-settings.test.ts @@ -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'); diff --git a/tests/unit/game-file-transfer-progress.test.ts b/tests/unit/game-file-transfer-progress.test.ts index fc044d1..3270ff1 100644 --- a/tests/unit/game-file-transfer-progress.test.ts +++ b/tests/unit/game-file-transfer-progress.test.ts @@ -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, diff --git a/tests/unit/host-capacity-calibration.test.ts b/tests/unit/host-capacity-calibration.test.ts index ce756a2..a8b4ddd 100644 --- a/tests/unit/host-capacity-calibration.test.ts +++ b/tests/unit/host-capacity-calibration.test.ts @@ -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); diff --git a/tests/unit/host-deletion.test.ts b/tests/unit/host-deletion.test.ts index 8ba84a7..b23ae69 100644 --- a/tests/unit/host-deletion.test.ts +++ b/tests/unit/host-deletion.test.ts @@ -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','',?)" diff --git a/tests/unit/host-observation-retention.test.ts b/tests/unit/host-observation-retention.test.ts index 0140076..7671c32 100644 --- a/tests/unit/host-observation-retention.test.ts +++ b/tests/unit/host-observation-retention.test.ts @@ -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); diff --git a/tests/unit/host-operation-history.test.ts b/tests/unit/host-operation-history.test.ts index 7b78cc4..7c963c3 100644 --- a/tests/unit/host-operation-history.test.ts +++ b/tests/unit/host-operation-history.test.ts @@ -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); diff --git a/tests/unit/leaderboard-ingest.test.ts b/tests/unit/leaderboard-ingest.test.ts index 0e76ffa..84468b7 100644 --- a/tests/unit/leaderboard-ingest.test.ts +++ b/tests/unit/leaderboard-ingest.test.ts @@ -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 }) diff --git a/tests/unit/leaderboard-records.test.ts b/tests/unit/leaderboard-records.test.ts index 2803184..c607e1c 100644 --- a/tests/unit/leaderboard-records.test.ts +++ b/tests/unit/leaderboard-records.test.ts @@ -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() diff --git a/tests/unit/migration-runner.test.ts b/tests/unit/migration-runner.test.ts new file mode 100644 index 0000000..f617b3a --- /dev/null +++ b/tests/unit/migration-runner.test.ts @@ -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(); +}); diff --git a/tests/unit/native-creation-intents.test.ts b/tests/unit/native-creation-intents.test.ts index 8c9b0ec..f530799 100644 --- a/tests/unit/native-creation-intents.test.ts +++ b/tests/unit/native-creation-intents.test.ts @@ -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); diff --git a/tests/unit/plugin-versions.test.ts b/tests/unit/plugin-versions.test.ts index b423f4d..0e4dc05 100644 --- a/tests/unit/plugin-versions.test.ts +++ b/tests/unit/plugin-versions.test.ts @@ -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, diff --git a/tests/unit/provisioning-requester.test.ts b/tests/unit/provisioning-requester.test.ts index 5ed98ce..b29dfc9 100644 --- a/tests/unit/provisioning-requester.test.ts +++ b/tests/unit/provisioning-requester.test.ts @@ -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' }); }); diff --git a/tests/unit/rcon-diagnostics.test.ts b/tests/unit/rcon-diagnostics.test.ts index da602e6..8dba885 100644 --- a/tests/unit/rcon-diagnostics.test.ts +++ b/tests/unit/rcon-diagnostics.test.ts @@ -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 }, diff --git a/tests/unit/retention-storage.test.ts b/tests/unit/retention-storage.test.ts index 936ebaf..19dd7de 100644 --- a/tests/unit/retention-storage.test.ts +++ b/tests/unit/retention-storage.test.ts @@ -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, diff --git a/tests/unit/retention.test.ts b/tests/unit/retention.test.ts index 09cf2cf..f2358a0 100644 --- a/tests/unit/retention.test.ts +++ b/tests/unit/retention.test.ts @@ -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')), diff --git a/tests/unit/seed-fixture.test.ts b/tests/unit/seed-fixture.test.ts index e146d8d..b8511a2 100644 --- a/tests/unit/seed-fixture.test.ts +++ b/tests/unit/seed-fixture.test.ts @@ -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')); diff --git a/tests/unit/server-key-migration.test.ts b/tests/unit/server-key-migration.test.ts index aabf725..74791b4 100644 --- a/tests/unit/server-key-migration.test.ts +++ b/tests/unit/server-key-migration.test.ts @@ -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'), diff --git a/tests/unit/server-status.test.ts b/tests/unit/server-status.test.ts index 79baf07..2748114 100644 --- a/tests/unit/server-status.test.ts +++ b/tests/unit/server-status.test.ts @@ -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, diff --git a/tests/unit/telemetry.test.ts b/tests/unit/telemetry.test.ts index 334a56c..f1d9b05 100644 --- a/tests/unit/telemetry.test.ts +++ b/tests/unit/telemetry.test.ts @@ -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([]);