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(); } }); it('adds policy report invalidation when upgrading a database at version 12', async () => { const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const handle = db(); for (const table of ['autoscaling_policies', 'settings']) for (const action of ['INSERT', 'UPDATE', 'DELETE']) handle.exec(`DROP TRIGGER report_metrics_${table}_${action}`); handle.pragma('user_version = 12'); closeDb(); const upgraded = db(); const revision = () => ( upgraded .prepare("SELECT revision FROM report_revisions WHERE topic='metrics'") .get() as { revision: number } ).revision; const before = revision(); upgraded .prepare( "INSERT INTO settings(key,value) VALUES('autoscaling_paused','1') ON CONFLICT(key) DO UPDATE SET value='1'" ) .run(); expect(revision()).toBeGreaterThan(before); const paused = revision(); upgraded.prepare("INSERT INTO settings(key,value) VALUES('unrelated-setting','1')").run(); expect(revision()).toBe(paused); upgraded.prepare("DELETE FROM settings WHERE key='autoscaling_paused'").run(); expect(revision()).toBeGreaterThan(paused); }); it('adds match report invalidation on upgrade without invalidating for score heartbeats', async () => { const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const handle = db(); for (const table of ['leaderboard_matches', 'leaderboard_match_players']) for (const action of ['INSERT', 'UPDATE', 'DELETE']) handle.exec(`DROP TRIGGER report_metrics_${table}_${action}`); handle.pragma('user_version = 13'); closeDb(); const upgraded = db(); const revision = () => ( upgraded .prepare("SELECT revision FROM report_revisions WHERE topic='metrics'") .get() as { revision: number } ).revision; const before = revision(); upgraded.exec(`INSERT INTO leaderboard_matches (id,server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint) VALUES(1,'removed','t5','zombie_moon','t5:zombie_moon','Moon', '2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z','game_start','start');`); expect(revision()).toBeGreaterThan(before); const started = revision(); upgraded.exec(`INSERT INTO leaderboard_match_players (match_id,guid,first_seen_at,last_seen_at) VALUES(1,'player','2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z');`); expect(revision()).toBeGreaterThan(started); const joined = revision(); upgraded.exec(`UPDATE leaderboard_matches SET highest_round=2,last_event='heartbeat', last_received_at='2026-10-10T12:00:30.000Z' WHERE id=1; UPDATE leaderboard_match_players SET kills=10,present=0 WHERE match_id=1;`); expect(revision()).toBe(joined); upgraded.exec("UPDATE leaderboard_matches SET map_name='Custom Moon' WHERE id=1"); expect(revision()).toBeGreaterThan(joined); const renamed = revision(); upgraded.exec('DELETE FROM leaderboard_matches WHERE id=1'); expect(revision()).toBeGreaterThan(renamed); expect(upgraded.prepare('SELECT * FROM leaderboard_match_players').all()).toEqual([]); }); it('upgrades player report invalidation from version 15 for participation clocks and durable identities', async () => { const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const { installMetricsMatchRevisions } = await import('../../src/lib/server/reports/revisions.js'); const handle = db(); handle.exec( 'DROP TRIGGER report_metrics_leaderboard_match_players_UPDATE; DROP INDEX idx_leaderboard_players_guid_first_seen' ); for (const action of ['INSERT', 'UPDATE', 'DELETE']) handle.exec(`DROP TRIGGER report_metrics_leaderboard_players_${action}`); installMetricsMatchRevisions(handle); handle.pragma('user_version = 15'); closeDb(); const upgraded = db(); const revision = () => ( upgraded .prepare("SELECT revision FROM report_revisions WHERE topic='metrics'") .get() as { revision: number } ).revision; upgraded.exec(`INSERT INTO leaderboard_matches (id,server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint) VALUES(1,'removed','t5','moon','moon','Moon','2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z','game_start','test'); INSERT INTO leaderboard_match_players(match_id,guid,first_seen_at,last_seen_at) VALUES(1,'player','2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z');`); let before = revision(); upgraded.exec("UPDATE leaderboard_match_players SET last_seen_at='2026-10-10T13:00:00.000Z'"); expect(revision()).toBeGreaterThan(before); before = revision(); upgraded.exec(`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES('t5','player','Player','2026-10-10T12:00:00.000Z','2026-10-10T13:00:00.000Z')`); expect(revision()).toBeGreaterThan(before); before = revision(); upgraded.exec( "UPDATE leaderboard_players SET name='Renamed',last_seen_at='2026-10-10T14:00:00.000Z'" ); expect(revision()).toBe(before); upgraded.exec("UPDATE leaderboard_players SET first_seen_at='2026-10-09T12:00:00.000Z'"); expect(revision()).toBeGreaterThan(before); expect( upgraded .prepare( "SELECT name FROM sqlite_master WHERE name='idx_leaderboard_players_guid_first_seen'" ) .get() ).toBeDefined(); }); it('adds provisioning report invalidation at version 14 without invalidating for progress updates', async () => { const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const handle = db(); for (const table of ['server_deployments', 'provisioning_sources']) for (const action of ['INSERT', 'UPDATE', 'DELETE']) handle.exec(`DROP TRIGGER report_metrics_${table}_${action}`); handle.pragma('user_version = 14'); closeDb(); const upgraded = db(); const revision = () => ( upgraded .prepare("SELECT revision FROM report_revisions WHERE topic='metrics'") .get() as { revision: number } ).revision; const before = revision(); upgraded.exec(`INSERT INTO server_deployments (id,server,action,state,stage,started_at,triggered_by) VALUES('operation','t5_zombie_moon_1','create','waiting','Queued', '2026-10-10T12:00:00.000Z','test')`); expect(revision()).toBe(before); upgraded.exec(`INSERT INTO provisioning_sources(operation_id,kind,source_id,server) VALUES('operation','autoscaling','policy','t5_zombie_moon_1')`); expect(revision()).toBeGreaterThan(before); const linked = revision(); upgraded.exec( "UPDATE server_deployments SET stage='Still waiting',observed_at='2026-10-10T12:00:15.000Z'" ); expect(revision()).toBe(linked); upgraded.exec( "UPDATE server_deployments SET state='done',finished_at='2026-10-10T12:01:12.000Z'" ); expect(revision()).toBeGreaterThan(linked); const completed = revision(); upgraded.exec('DELETE FROM server_deployments'); expect(revision()).toBeGreaterThan(completed); });