309 lines
15 KiB
TypeScript
309 lines
15 KiB
TypeScript
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);
|
|
});
|
|
|
|
it('adds the generation evidence boundary to a version 23 database without changing instance identity', async () => {
|
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
|
const handle = db();
|
|
handle.exec(
|
|
"INSERT INTO native_instances(id,server,host_id,generation,library_id,image,port,created_at) VALUES('instance','s','host',2,'lib','image',1,'2026-10-14T00:00:00Z')"
|
|
);
|
|
handle.exec('ALTER TABLE native_instances DROP COLUMN generation_reconciled_at');
|
|
handle.pragma('user_version = 23');
|
|
closeDb();
|
|
expect(
|
|
db().prepare('SELECT id,generation,generation_reconciled_at FROM native_instances').get()
|
|
).toEqual({ id: 'instance', generation: 2, generation_reconciled_at: null });
|
|
expect(db().pragma('foreign_key_check')).toEqual([]);
|
|
});
|