This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -45,8 +45,6 @@ beforeEach(async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(epoch);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { ensureGameFileSchema } = await import('../../src/lib/server/game-files/schema.js');
|
||||
ensureGameFileSchema(db());
|
||||
for (const game of ['t5', 't6']) {
|
||||
db()
|
||||
.prepare(
|
||||
|
||||
@@ -431,6 +431,7 @@ describe('collection cancellation', () => {
|
||||
const detail = await launch(1);
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
db().exec('ALTER TABLE server_launch_collections DROP COLUMN cancelled_at');
|
||||
db().pragma('user_version = 0');
|
||||
closeDb();
|
||||
const { getCollection, cancelCollection } =
|
||||
await import('../../src/lib/server/fleet/collections.js');
|
||||
|
||||
@@ -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;
|
||||
}[];
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture([]);
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
it('initializes every schema on first open and runs no migration SQL on later opens', async () => {
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
const { databaseVersion } = await import('../../src/lib/server/db/migrations.js');
|
||||
const handle = db();
|
||||
expect(handle.pragma('user_version', { simple: true })).toBe(databaseVersion);
|
||||
for (const table of [
|
||||
'servers',
|
||||
'native_instances',
|
||||
'ec_hosts',
|
||||
'auth_users',
|
||||
'game_file_versions'
|
||||
])
|
||||
expect(handle.prepare('SELECT name FROM sqlite_master WHERE name=?').get(table)).toEqual({
|
||||
name: table
|
||||
});
|
||||
handle.exec(
|
||||
"INSERT INTO servers(name,last_reply_snippet) VALUES('saved','sanitized diagnostic')"
|
||||
);
|
||||
const schemaVersion = handle.pragma('schema_version', { simple: true });
|
||||
closeDb();
|
||||
const exec = vi.spyOn(Database.prototype, 'exec');
|
||||
const prepare = vi.spyOn(Database.prototype, 'prepare');
|
||||
const reopened = db();
|
||||
expect(exec).not.toHaveBeenCalled();
|
||||
expect(prepare).not.toHaveBeenCalled();
|
||||
expect(reopened.pragma('schema_version', { simple: true })).toBe(schemaVersion);
|
||||
expect(reopened.prepare('SELECT name,last_reply_snippet FROM servers').get()).toEqual({
|
||||
name: 'saved',
|
||||
last_reply_snippet: 'sanitized diagnostic'
|
||||
});
|
||||
expect(reopened.pragma('foreign_key_check')).toEqual([]);
|
||||
});
|
||||
|
||||
it('adopts a fully migrated unversioned database without replaying destructive backfills', async () => {
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
const { databaseVersion } = await import('../../src/lib/server/db/migrations.js');
|
||||
const handle = db();
|
||||
handle.exec(`INSERT INTO servers(name,desired_state,last_reply_snippet) VALUES('saved','stopped','keep');
|
||||
INSERT INTO file_blobs(sha256,size,content) VALUES('hash',1,X'01');
|
||||
INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'saved','test.gsc');
|
||||
INSERT INTO script_versions(id,script_id,version,sha256,size,content_hash) VALUES(1,1,1,'hash',1,'hash');
|
||||
INSERT INTO betas(id,script_id,script_version_id,trigger_kind,state) VALUES(1,1,1,'bundle','running');
|
||||
INSERT INTO beta_test_plans(beta_id,plan,outcome) VALUES(1,'{"requirements":[]}','active');`);
|
||||
// All pre-versioning releases used 0; their sentinels are compatibility inputs.
|
||||
handle.pragma('user_version = 0');
|
||||
closeDb();
|
||||
expect(db().pragma('user_version', { simple: true })).toBe(databaseVersion);
|
||||
expect(db().prepare('SELECT desired_state,last_reply_snippet FROM servers').get()).toEqual({
|
||||
desired_state: 'stopped',
|
||||
last_reply_snippet: 'keep'
|
||||
});
|
||||
expect(db().prepare('SELECT state FROM betas').get()).toEqual({ state: 'running' });
|
||||
expect(db().prepare('SELECT plan FROM beta_test_plans').get()).toEqual({
|
||||
plan: '{"requirements":[]}'
|
||||
});
|
||||
expect(db().pragma('foreign_key_check')).toEqual([]);
|
||||
});
|
||||
|
||||
it('closes a failed startup handle and resumes from the last committed version', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { migrations, databaseVersion } = await import('../../src/lib/server/db/migrations.js');
|
||||
const hosts = migrations.find(
|
||||
(migration) => migration.name === 'hosts and saved command files'
|
||||
)!;
|
||||
const run = hosts.run;
|
||||
const failure = vi.spyOn(hosts, 'run').mockImplementation((handle) => {
|
||||
run(handle);
|
||||
throw new Error('interrupted host upgrade');
|
||||
});
|
||||
expect(db).toThrow('interrupted host upgrade');
|
||||
const inspect = new Database(fixture.dbPath);
|
||||
try {
|
||||
expect(inspect.pragma('user_version', { simple: true })).toBe(hosts.version - 1);
|
||||
expect(
|
||||
inspect.prepare("SELECT 1 FROM sqlite_master WHERE name='ec_hosts'").get()
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
inspect.close();
|
||||
}
|
||||
failure.mockRestore();
|
||||
const completed = migrations
|
||||
.slice(0, hosts.version - 1)
|
||||
.map((migration) => vi.spyOn(migration, 'run'));
|
||||
expect(db().pragma('user_version', { simple: true })).toBe(databaseVersion);
|
||||
for (const migration of completed) expect(migration).not.toHaveBeenCalled();
|
||||
expect(db().pragma('foreign_key_check')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the legacy snapshot safety guard and rolls back an unsafe first upgrade', async () => {
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
handle.exec(`INSERT INTO file_blobs(sha256,size,content) VALUES('hash',1,X'01');
|
||||
INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'saved','test.gsc');
|
||||
INSERT INTO script_versions(id,script_id,version,sha256,size,content_hash) VALUES(1,1,1,'hash',1,'hash');
|
||||
INSERT INTO betas(id,script_id,script_version_id,trigger_kind,state) VALUES(1,1,1,'bundle','running');
|
||||
DELETE FROM settings WHERE key='snapshots_v1';`);
|
||||
handle.pragma('user_version = 0');
|
||||
closeDb();
|
||||
expect(db).toThrow('close active beta 1 before upgrading');
|
||||
const inspect = new Database(fixture.dbPath);
|
||||
try {
|
||||
expect(inspect.pragma('user_version', { simple: true })).toBe(0);
|
||||
expect(inspect.prepare('SELECT state FROM betas').get()).toEqual({ state: 'running' });
|
||||
// The join backfill precedes the guard, but must roll back with it.
|
||||
expect(inspect.prepare('SELECT * FROM beta_scripts').all()).toEqual([]);
|
||||
expect(inspect.pragma('foreign_key_check')).toEqual([]);
|
||||
} finally {
|
||||
inspect.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a database from a newer application without changing its version or schema', async () => {
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
const { databaseVersion } = await import('../../src/lib/server/db/migrations.js');
|
||||
db().pragma(`user_version = ${databaseVersion + 1}`);
|
||||
const schemaVersion = db().pragma('schema_version', { simple: true });
|
||||
closeDb();
|
||||
expect(db).toThrow('not supported by this application');
|
||||
const inspect = new Database(fixture.dbPath);
|
||||
try {
|
||||
expect(inspect.pragma('user_version', { simple: true })).toBe(databaseVersion + 1);
|
||||
expect(inspect.pragma('schema_version', { simple: true })).toBe(schemaVersion);
|
||||
} finally {
|
||||
inspect.close();
|
||||
}
|
||||
});
|
||||
@@ -368,6 +368,7 @@ describe('matching a blueprint exactly', () => {
|
||||
// Recreate the older schema, which predates the history preservation trigger.
|
||||
db().exec('DROP TRIGGER preserve_deleted_server_history');
|
||||
db().exec('ALTER TABLE server_launch_collections DROP COLUMN retained_servers');
|
||||
db().pragma('user_version = 0');
|
||||
closeDb();
|
||||
const { previewCollection } = await import('../../src/lib/server/fleet/collections.js');
|
||||
expect(previewCollection(plan.id).mode).toBe('fill');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -65,6 +65,7 @@ describe('fleet settings API and persistence', () => {
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare("DELETE FROM settings WHERE key = 'fleet_settings'").run();
|
||||
db().prepare("INSERT INTO settings (key, value) VALUES ('unrelated', 'true')").run();
|
||||
db().pragma('user_version = 0');
|
||||
closeDb();
|
||||
vi.stubEnv('DEPLOY_KEEP_DAYS', '14');
|
||||
vi.stubEnv('DEPLOYMENT_RESTART_CONCURRENCY', '4');
|
||||
@@ -92,6 +93,7 @@ describe('fleet settings API and persistence', () => {
|
||||
db()
|
||||
.prepare("UPDATE settings SET value = ? WHERE key = 'fleet_settings'")
|
||||
.run(JSON.stringify(previous));
|
||||
db().pragma('user_version = 0');
|
||||
closeDb();
|
||||
vi.stubEnv('DEPLOY_KEEP_DAYS', 'bad');
|
||||
vi.stubEnv('DEPLOYMENT_RESTART_CONCURRENCY', '9');
|
||||
@@ -129,6 +131,7 @@ describe('fleet settings API and persistence', () => {
|
||||
.prepare("UPDATE settings SET value = ? WHERE key = 'fleet_settings'")
|
||||
.run(JSON.stringify(previous));
|
||||
db().prepare("INSERT INTO settings(key,value) VALUES('unrelated','keep')").run();
|
||||
db().pragma('user_version = 0');
|
||||
closeDb();
|
||||
vi.stubEnv('DEPLOY_KEEP_DAYS', 'bad');
|
||||
const { GET } = await import('../../src/routes/api/settings/+server.js');
|
||||
|
||||
@@ -354,6 +354,7 @@ describe('host game-file transfer progress', () => {
|
||||
const before = cache();
|
||||
database.db().exec('ALTER TABLE host_library_caches DROP COLUMN transfer_progress');
|
||||
database.db().exec('ALTER TABLE host_library_caches DROP COLUMN bytes_verified');
|
||||
database.db().pragma('user_version = 0');
|
||||
database.closeDb();
|
||||
expect(cache()).toMatchObject({
|
||||
hostId,
|
||||
|
||||
@@ -238,6 +238,7 @@ describe('measured instance capacity', () => {
|
||||
db().exec(
|
||||
'UPDATE ec_resource_allowances SET cpu_cores=cpu_cores*1.25, memory_bytes=memory_bytes*1.25'
|
||||
);
|
||||
db().pragma('user_version = 0');
|
||||
closeDb();
|
||||
const { capacityCalibration } = await import('../../src/lib/server/hosts/calibration.js');
|
||||
expect(capacityCalibration(hostId)).toEqual(model);
|
||||
|
||||
@@ -51,14 +51,14 @@ async function instance(hostId: string, retired = false) {
|
||||
|
||||
describe('host deletion', () => {
|
||||
it('adds the deletion marker to existing host schemas without losing hosts', async () => {
|
||||
const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
|
||||
const { migrateHostSchema } = await import('../../src/lib/server/hosts/schema.js');
|
||||
const legacy = new Database(':memory:');
|
||||
try {
|
||||
legacy.exec(
|
||||
"CREATE TABLE ec_hosts(id TEXT PRIMARY KEY, name TEXT NOT NULL); INSERT INTO ec_hosts VALUES('old','Existing host')"
|
||||
);
|
||||
ensureHostSchema(legacy);
|
||||
ensureHostSchema(legacy);
|
||||
migrateHostSchema(legacy);
|
||||
migrateHostSchema(legacy);
|
||||
expect(legacy.prepare('SELECT * FROM ec_hosts').get()).toEqual({
|
||||
id: 'old',
|
||||
name: 'Existing host',
|
||||
@@ -99,8 +99,6 @@ describe('host deletion', () => {
|
||||
const file = join(fixture.root, 'game-files-sentinel');
|
||||
await writeFile(file, 'Game files remain');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { ensureGameFileSchema } = await import('../../src/lib/server/game-files/schema.js');
|
||||
ensureGameFileSchema(db());
|
||||
db()
|
||||
.prepare(
|
||||
"INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES('library','T5','t5','',?)"
|
||||
|
||||
@@ -222,6 +222,7 @@ describe('routine host observation retention', () => {
|
||||
api.handle.exec(
|
||||
"UPDATE ec_host_operations SET observation_success=0; DELETE FROM ec_host_schema_migrations WHERE name='completed-status-batches'"
|
||||
);
|
||||
api.handle.pragma('user_version = 0');
|
||||
api.closeDb();
|
||||
expect(
|
||||
api
|
||||
@@ -380,6 +381,7 @@ describe('routine host observation retention', () => {
|
||||
DROP TRIGGER operation_history_count_update; DROP TABLE ec_host_operation_history_counts;
|
||||
DELETE FROM ec_host_schema_migrations WHERE name='history-counts-v1'`);
|
||||
api.handle.exec('ALTER TABLE ec_host_operations DROP COLUMN observation_success');
|
||||
api.handle.pragma('user_version = 0');
|
||||
api.closeDb();
|
||||
|
||||
expect(api.pruneHostObservations()).toBe(2);
|
||||
|
||||
@@ -574,6 +574,7 @@ describe('host operation history', () => {
|
||||
ALTER TABLE ec_host_operations DROP COLUMN server_name;
|
||||
ALTER TABLE ec_host_operations DROP COLUMN target_path;
|
||||
ALTER TABLE ec_host_operations DROP COLUMN source;`);
|
||||
api.handle.pragma('user_version = 0');
|
||||
closeDb();
|
||||
const history = api.listHostOperationHistory(1, 'all');
|
||||
expect(history.operations).toHaveLength(4);
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { runMigrations, type Migration } from '../../src/lib/server/db/migration-runner.js';
|
||||
|
||||
let handle: Database.Database;
|
||||
beforeEach(() => {
|
||||
handle = new Database(':memory:');
|
||||
handle.pragma('foreign_keys = ON');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
handle.close();
|
||||
});
|
||||
|
||||
it('applies ordered upgrades once and appends only pending versions', () => {
|
||||
const first = vi.fn((db: Database.Database) =>
|
||||
db.exec("CREATE TABLE records(value TEXT); INSERT INTO records VALUES('original')")
|
||||
);
|
||||
const migrations: Migration[] = [{ version: 1, name: 'records', run: first }];
|
||||
runMigrations(handle, migrations);
|
||||
handle.exec("UPDATE records SET value='edited'");
|
||||
const next = vi.fn((db: Database.Database) => {
|
||||
expect(db.pragma('user_version', { simple: true })).toBe(1);
|
||||
db.exec('ALTER TABLE records ADD COLUMN added INTEGER DEFAULT 7');
|
||||
});
|
||||
migrations.push({ version: 2, name: 'additional field', run: next });
|
||||
runMigrations(handle, migrations);
|
||||
runMigrations(handle, migrations);
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(handle.prepare('SELECT * FROM records').get()).toEqual({ value: 'edited', added: 7 });
|
||||
expect(handle.pragma('user_version', { simple: true })).toBe(2);
|
||||
});
|
||||
|
||||
it('rolls back schema, data and version on failure, then retries only the failed step', () => {
|
||||
let fail = true;
|
||||
const migrations: Migration[] = [
|
||||
{
|
||||
version: 1,
|
||||
name: 'records',
|
||||
run: (db) =>
|
||||
db.exec("CREATE TABLE records(value TEXT); INSERT INTO records VALUES('old')")
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
name: 'backfill',
|
||||
run: (db) => {
|
||||
db.exec(
|
||||
"ALTER TABLE records ADD COLUMN extra TEXT; UPDATE records SET value='new'"
|
||||
);
|
||||
if (fail) throw new Error('interrupted');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
name: 'index',
|
||||
run: (db) => db.exec('CREATE INDEX records_value ON records(value)')
|
||||
}
|
||||
];
|
||||
expect(() => runMigrations(handle, migrations)).toThrow('interrupted');
|
||||
expect(handle.pragma('user_version', { simple: true })).toBe(1);
|
||||
expect(handle.prepare('SELECT * FROM records').get()).toEqual({ value: 'old' });
|
||||
expect(handle.pragma('index_list(records)')).toEqual([]);
|
||||
fail = false;
|
||||
runMigrations(handle, migrations);
|
||||
expect(handle.prepare('SELECT * FROM records').get()).toEqual({ value: 'new', extra: null });
|
||||
expect(handle.pragma('user_version', { simple: true })).toBe(3);
|
||||
});
|
||||
|
||||
it('does not commit a migration if recording its version fails', () => {
|
||||
const pragma = handle.pragma.bind(handle);
|
||||
vi.spyOn(handle, 'pragma').mockImplementation((sql, options) => {
|
||||
if (sql === 'user_version = 1') throw new Error('version write failed');
|
||||
return pragma(sql, options);
|
||||
});
|
||||
expect(() =>
|
||||
runMigrations(handle, [
|
||||
{
|
||||
version: 1,
|
||||
name: 'records',
|
||||
run: (db) => db.exec('CREATE TABLE records(value TEXT)')
|
||||
}
|
||||
])
|
||||
).toThrow('version write failed');
|
||||
expect(pragma('user_version', { simple: true })).toBe(0);
|
||||
expect(
|
||||
handle.prepare("SELECT 1 FROM sqlite_master WHERE name='records'").get()
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rebuilds a referenced table without cascading and restores foreign key enforcement', () => {
|
||||
handle.exec(`CREATE TABLE parent(id INTEGER PRIMARY KEY);
|
||||
CREATE TABLE child(parent_id INTEGER REFERENCES parent(id) ON DELETE CASCADE);
|
||||
INSERT INTO parent VALUES(1); INSERT INTO child VALUES(1);`);
|
||||
runMigrations(handle, [
|
||||
{
|
||||
version: 1,
|
||||
name: 'rebuild',
|
||||
mode: 'foreign-keys-off',
|
||||
run: (db) =>
|
||||
db.exec(`CREATE TABLE replacement(id INTEGER PRIMARY KEY, label TEXT);
|
||||
INSERT INTO replacement(id) SELECT id FROM parent;
|
||||
DROP TABLE parent; ALTER TABLE replacement RENAME TO parent;`)
|
||||
}
|
||||
]);
|
||||
expect(handle.prepare('SELECT * FROM child').all()).toEqual([{ parent_id: 1 }]);
|
||||
expect(handle.pragma('foreign_keys', { simple: true })).toBe(1);
|
||||
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
||||
});
|
||||
|
||||
it('rolls back broken references and restores foreign keys on failure', () => {
|
||||
handle.exec(`CREATE TABLE parent(id INTEGER PRIMARY KEY);
|
||||
CREATE TABLE child(parent_id INTEGER REFERENCES parent(id));`);
|
||||
expect(() =>
|
||||
runMigrations(handle, [
|
||||
{
|
||||
version: 1,
|
||||
name: 'broken reference',
|
||||
mode: 'foreign-keys-off',
|
||||
run: (db) => db.exec('INSERT INTO child VALUES(99)')
|
||||
}
|
||||
])
|
||||
).toThrow('broken references');
|
||||
expect(handle.prepare('SELECT * FROM child').all()).toEqual([]);
|
||||
expect(handle.pragma('foreign_keys', { simple: true })).toBe(1);
|
||||
expect(handle.pragma('user_version', { simple: true })).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves interrupted autocommit work pending for a resumable retry', () => {
|
||||
let fail = true;
|
||||
const migrations: Migration[] = [
|
||||
{
|
||||
version: 1,
|
||||
name: 'vacuum',
|
||||
mode: 'autocommit',
|
||||
run: (db) => {
|
||||
expect(db.inTransaction).toBe(false);
|
||||
db.exec('CREATE TABLE IF NOT EXISTS records(value TEXT); VACUUM');
|
||||
if (fail) throw new Error('interrupted after vacuum');
|
||||
}
|
||||
}
|
||||
];
|
||||
expect(() => runMigrations(handle, migrations)).toThrow('interrupted after vacuum');
|
||||
expect(handle.pragma('user_version', { simple: true })).toBe(0);
|
||||
fail = false;
|
||||
runMigrations(handle, migrations);
|
||||
expect(handle.pragma('user_version', { simple: true })).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects newer databases and invalid migration sequences before running work', () => {
|
||||
const run = vi.fn();
|
||||
handle.pragma('user_version = 2');
|
||||
expect(() => runMigrations(handle, [{ version: 1, name: 'older app', run }])).toThrow(
|
||||
'Database version 2 is not supported'
|
||||
);
|
||||
handle.pragma('user_version = 0');
|
||||
expect(() => runMigrations(handle, [{ version: 2, name: 'gap', run }])).toThrow('consecutive');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -21,8 +21,6 @@ afterEach(async () => {
|
||||
|
||||
async function intent() {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
|
||||
ensureHostSchema(db());
|
||||
const { beginDeployment } = await import('../../src/lib/server/provisioning/history.js');
|
||||
const id = beginDeployment(server, 'create');
|
||||
db().prepare("UPDATE server_deployments SET state='queued' WHERE id=?").run(id);
|
||||
|
||||
@@ -75,6 +75,7 @@ describe('plugin release versions', () => {
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
const { version } = createScript(plugin);
|
||||
db().exec('ALTER TABLE script_versions DROP COLUMN release_version');
|
||||
db().pragma('user_version = 0');
|
||||
closeDb();
|
||||
expect(getVersion(version.id)).toMatchObject({
|
||||
id: version.id,
|
||||
|
||||
@@ -122,6 +122,7 @@ it('adds the requester column to existing databases without inventing historical
|
||||
await import('../../src/lib/server/provisioning/history.js');
|
||||
const id = beginDeployment('legacy', 'restart');
|
||||
db().exec('ALTER TABLE server_deployments DROP COLUMN requested_by');
|
||||
db().pragma('user_version = 0');
|
||||
closeDb();
|
||||
expect(getDeployment(id)?.deployment).toMatchObject({ requested_by: null, triggered_by: 'ui' });
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
Reference in New Issue
Block a user