import Database from 'better-sqlite3'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import { enableIncrementalVacuum, retentionClocks } from '../../src/lib/server/db/retention-clocks.js'; let fixture: Fixture; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); afterEach(async () => { await fixture.cleanup(); }); const old = new Date(Date.now() - 90 * 86400000).toISOString(); it('expires completed file payloads while keeping history, replay identity, holds and active work', async () => { const { db } = await import('../../src/lib/server/db/index.js'); const hosts = await import('../../src/lib/server/hosts/index.js'); const { pruneHistory, setRetentionControl } = await import('../../src/lib/server/retention.js'); const { putFileBlob } = await import('../../src/lib/server/db/file-blobs.js'); const host = hosts.createHost({ name: 'Files' }); const payload = { files: [{ path: 'plugin.dll', contentBase64: Buffer.from('file').toString('base64') }] }; const completed = hosts.queueHostOperation(host.id, 'files.stage', payload, { idempotencyKey: 'finished' }); hosts.reportHostOperation(host.id, completed.id, { state: 'completed', result: { before: [{ path: 'plugin.dll', contentBase64: 'YmVmb3Jl' }] } }); const active = hosts.queueHostOperation(host.id, 'files.stage', payload); const held = hosts.queueHostOperation(host.id, 'files.stage', payload); hosts.reportHostOperation(host.id, held.id, { state: 'completed', result: {} }); setRetentionControl({ category: 'operation', id: held.id, protected: true }); const failed = hosts.queueHostOperation(host.id, 'files.stage', payload); hosts.reportHostOperation(host.id, failed.id, { state: 'failed', error: 'unfinished repair' }); db().prepare('UPDATE ec_host_operations SET updated_at=?').run(old); db().exec("INSERT INTO servers(name) VALUES('s')"); const hash = putFileBlob(Buffer.from('recipe-only')); db() .prepare( `INSERT INTO server_deployments(id,server,action,state,stage,started_at,finished_at,triggered_by) VALUES('launch','s','create','done','done',?,?,'test')` ) .run(old, old); db() .prepare('INSERT INTO server_launch_recipes(deployment_id,recipe) VALUES(?,?)') .run( 'launch', JSON.stringify({ files: [{ path: 'launch.dll', sha256: hash, contentHash: hash }] }) ); expect(pruneHistory('workflow-payload')).toBe(1); expect(db().prepare("SELECT state FROM server_deployments WHERE id='launch'").get()).toEqual({ state: 'done' }); expect( JSON.parse( (db().prepare('SELECT recipe FROM server_launch_recipes').get() as { recipe: string }) .recipe ) ).toEqual({ files: [{ path: 'launch.dll', sha256: hash }] }); expect(db().prepare('SELECT 1 FROM file_blobs WHERE sha256=?').get(hash)).toBeUndefined(); expect(pruneHistory('operation-payload')).toBe(1); expect(hosts.getHostOperation(completed.id)).toMatchObject({ state: 'completed', payload: { files: [{ path: 'plugin.dll' }] }, result: { before: [{ path: 'plugin.dll' }] } }); expect(hosts.getHostOperation(active.id)?.payload).toEqual(payload); expect(hosts.getHostOperation(held.id)?.payload).toEqual(payload); expect(hosts.getHostOperation(failed.id)?.payload).toEqual(payload); expect( hosts.queueHostOperation(host.id, 'files.stage', payload, { idempotencyKey: 'finished' }).id ).toBe(completed.id); expect(() => hosts.queueHostOperation( host.id, 'files.stage', { files: [] }, { idempotencyKey: 'finished' } ) ).toThrow('different request'); expect(pruneHistory('operation-payload')).toBe(0); }); 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, provisioningKeepDays: 1, payloadKeepDays: 1 }); db() .prepare( `INSERT INTO server_deployments(id,server,action,state,stage,started_at,finished_at,triggered_by) VALUES('move','s','rebuild','done','healthy',?,?,'rebalance:run')` ) .run(old, old); const hash = putFileBlob(Buffer.from('frozen deployed file')); db() .prepare('INSERT INTO server_launch_recipes(deployment_id,recipe) VALUES(?,?)') .run( 'move', JSON.stringify({ files: [{ path: 'script.gsc', sha256: hash, contentHash: hash }] }) ); db() .prepare( `INSERT INTO rebalancing_runs(id,settings,state,created_at,updated_at,message) VALUES('run','{}','running',?,?,'waiting')` ) .run(old, old); db() .prepare( `INSERT INTO rebalancing_moves(id,run_id,ordinal,server,instance_id,generation,source_host_id,target_host_id,candidate,state,operation_id,message,updated_at) VALUES('m','run',0,'s','i',1,'a','b','{}','moving','move','waiting',?)` ) .run(old); expect(pruneHistory('workflow-payload')).toBe(0); expect(pruneHistory('provisioning')).toBe(0); db().prepare("UPDATE rebalancing_moves SET state='failed'").run(); expect(pruneHistory('workflow-payload')).toBe(0); expect(pruneHistory('provisioning')).toBe(0); db().prepare("UPDATE rebalancing_moves SET state='done'").run(); expect(pruneHistory('workflow-payload')).toBe(1); expect(pruneHistory('provisioning')).toBe(1); expect(db().prepare('SELECT state,operation_id FROM rebalancing_moves').get()).toEqual({ state: 'done', operation_id: null }); }); it('normalizes legacy retention clocks once and reuses clock indexes', () => { const handle = new Database(':memory:'); try { handle.exec(`CREATE TABLE history(id INTEGER PRIMARY KEY, at TEXT); CREATE INDEX history_age ON history(at); INSERT INTO history(at) VALUES('2026-01-01 12:00:00'),('2026-01-01T08:00:00-04:00'),(NULL)`); retentionClocks(handle, { history: ['at'] }); expect(handle.prepare('SELECT at FROM history ORDER BY id').all()).toEqual([ { at: '2026-01-01T12:00:00.000Z' }, { at: '2026-01-01T12:00:00.000Z' }, { at: null } ]); expect(handle.pragma('index_list(history)')).toHaveLength(1); const prepare = vi.spyOn(handle, 'prepare'); retentionClocks(handle, { history: ['at'] }); expect(prepare.mock.calls.some(([sql]) => sql.startsWith('UPDATE'))).toBe(false); } finally { handle.close(); } }); it('enables incremental vacuum once and preserves host enable command boundaries', () => { const handle = new Database(fixture.dbPath); try { handle.exec(`CREATE TABLE ec_host_operations(id TEXT, host_id TEXT); CREATE TABLE ec_host_enable_requests(host_id TEXT, operation_floor INTEGER); INSERT INTO ec_host_operations(rowid,id,host_id) VALUES(10,'before','h'),(30,'after','h'); INSERT INTO ec_host_enable_requests VALUES('h',20)`); enableIncrementalVacuum(handle); expect(handle.pragma('auto_vacuum', { simple: true })).toBe(2); const eligible = () => handle .prepare( `SELECT id FROM ec_host_operations o JOIN ec_host_enable_requests e ON e.host_id=o.host_id WHERE o.rowid>e.operation_floor` ) .all(); expect(eligible()).toEqual([{ id: 'after' }]); const exec = vi.spyOn(handle, 'exec'); enableIncrementalVacuum(handle); expect(exec.mock.calls).toHaveLength(0); expect(eligible()).toEqual([{ id: 'after' }]); } finally { handle.close(); } });