Files
TREK/server/tests/unit/nest/sqlite-database-backup.test.ts
T
Maurice 0aa5b36661 fix(backup): back up and restore through an injected database port
Backup, restore and the demo reset each handled the SQLite file
themselves: backup.impl closed and reinitialized the connection, built
MaintenanceRepository with new, and assumed the database was
data/travel.db. That tied the whole chain to one local file and hid two
bugs.

They now go through DATABASE_BACKUP, a port declared in the shared
database kernel (snapshot, verify, keep a copy before a restore, replace).
SqliteDatabaseBackup implements it with what the code did before:
VACUUM INTO for a consistent copy, and a close, copy, sidecar drop,
rename and reopen through DatabaseLifecycle for a restore. BackupService
injects it and hands it to backup.impl. The demo reset and the baseline
save use the same port; the first baseline is saved by DemoResetJob once
the app is up, because the boot-time seed runs before anything can be
injected. Another engine can plug in a logical dump behind the port.

Fixes on the way:

- The backup and the restore ignored TREK_DB_FILE. The backup looked for
  data/travel.db and, with the database elsewhere, silently left it out
  of the archive; the restore wrote data/travel.db and never replaced
  the live database. Both now use DatabaseLifecycle.file, the path the
  connection runs on, and the pre-restore copy sits next to it.
- A failed VACUUM INTO fell back to archiving the live WAL-mode file
  without a word, the torn copy the snapshot exists to prevent. The
  backup now fails: nothing is archived or committed, the existing
  backups are untouched, a partial snapshot is removed, and the log says
  why. A missing database file and a failed checkpoint are logged too.
2026-10-08 18:38:28 +02:00

218 lines
8.8 KiB
TypeScript

/**
* SqliteDatabaseBackup: the database backup port for a SQLite file.
*
* The snapshot and the swap run for real here, against a database file in a
* temp directory: a VACUUM INTO through the real MaintenanceRepository, and the
* copy, sidecar removal and rename of a restore. Only the connection lifecycle
* is a stand-in, because closing and reopening the process's own connection is
* DatabaseLifecycle's business (tests/unit/nest/database/).
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn(), logDebug: vi.fn() }));
vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock);
import { SqliteDatabaseBackup } from '../../../src/nest/backup/sqlite-database-backup';
import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository';
import type { DatabaseLifecycle } from '../../../src/nest/database/database-lifecycle.service';
import { withRequestContext } from '../../../src/nest/database/request-context';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
let dir: string;
let liveFile: string;
let lifecycle: { file: string; close: ReturnType<typeof vi.fn>; reopen: ReturnType<typeof vi.fn> };
function maintenanceStub() {
return { walCheckpoint: vi.fn(async () => {}), vacuumInto: vi.fn(async (_target: string) => {}) };
}
function portWith(maintenance: { walCheckpoint: unknown; vacuumInto: unknown }): SqliteDatabaseBackup {
return new SqliteDatabaseBackup(lifecycle as unknown as DatabaseLifecycle, maintenance as unknown as MaintenanceRepository);
}
function writeDb(file: string, label: string): void {
const db = new Database(file);
db.exec('CREATE TABLE marker (label TEXT NOT NULL)');
db.prepare('INSERT INTO marker (label) VALUES (?)').run(label);
db.close();
}
function readLabel(file: string): string {
const db = new Database(file, { readonly: true });
try {
return (db.prepare('SELECT label FROM marker').get() as { label: string }).label;
} finally {
db.close();
}
}
beforeEach(() => {
vi.clearAllMocks();
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-sqlite-backup-'));
liveFile = path.join(dir, 'live.db');
lifecycle = { file: liveFile, close: vi.fn(), reopen: vi.fn(async () => {}) };
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
describe('SqliteDatabaseBackup', () => {
it('SQLBK-001: names the archive entry travel.db and reports the file the connection runs on', () => {
const port = portWith(maintenanceStub());
expect(port.archiveEntry).toBe('travel.db');
expect(port.location()).toBe(liveFile);
});
it('SQLBK-002: can snapshot an existing file, never the in-memory database or a missing file', () => {
const port = portWith(maintenanceStub());
expect(port.canSnapshot()).toBe(false);
writeDb(liveFile, 'live');
expect(port.canSnapshot()).toBe(true);
lifecycle.file = ':memory:';
expect(port.canSnapshot()).toBe(false);
});
it('SQLBK-003: the checkpoint is the WAL checkpoint', async () => {
const maintenance = maintenanceStub();
await portWith(maintenance).checkpoint();
expect(maintenance.walCheckpoint).toHaveBeenCalledTimes(1);
});
describe('snapshot, through the real VACUUM INTO', () => {
let live: Database.Database;
let orm: TestOrm;
beforeEach(async () => {
writeDb(liveFile, 'live');
live = new Database(liveFile);
live.exec('PRAGMA journal_mode = WAL');
orm = await createTestOrm(live);
});
afterEach(async () => {
await orm.close();
live.close();
});
it('SQLBK-004: writes a consistent copy of the live database', async () => {
// A write that sits in the WAL only: VACUUM INTO still carries it.
live.prepare('UPDATE marker SET label = ?').run('after');
const target = path.join(dir, 'copy.db');
const port = portWith(new MaintenanceRepository(orm.em));
await withRequestContext(orm.orm, () => port.snapshot(target));
expect(readLabel(target)).toBe('after');
});
it('SQLBK-005: a VACUUM INTO that fails rejects and leaves nothing at the target', async () => {
const target = path.join(dir, 'missing-dir', 'copy.db');
const port = portWith(new MaintenanceRepository(orm.em));
await expect(withRequestContext(orm.orm, () => port.snapshot(target))).rejects.toThrow();
expect(fs.existsSync(target)).toBe(false);
});
});
it('SQLBK-006: a partial file a failed snapshot left behind is removed before the error goes on', async () => {
const target = path.join(dir, 'partial.db');
const maintenance = maintenanceStub();
maintenance.vacuumInto.mockImplementationOnce(async (to: string) => {
fs.writeFileSync(to, 'half a database');
throw new Error('database or disk is full');
});
await expect(portWith(maintenance).snapshot(target)).rejects.toThrow('database or disk is full');
expect(fs.existsSync(target)).toBe(false);
});
it('SQLBK-007: verify refuses an unpacked archive without a database', () => {
expect(portWith(maintenanceStub()).verify(dir)).toEqual({ error: 'Invalid backup: travel.db not found', status: 400 });
});
it('SQLBK-008: keeps a copy of the database a restore replaces next to it, and says where', async () => {
const maintenance = maintenanceStub();
const kept = await portWith(maintenance).keepCopyBeforeRestore();
expect(kept).not.toBeNull();
expect(path.dirname(kept as string)).toBe(dir);
expect(path.basename(kept as string)).toMatch(/^pre-restore-\d+\.db$/);
expect(maintenance.vacuumInto).toHaveBeenCalledWith(kept);
expect(logMock.logInfo).toHaveBeenCalledWith(`Restore: the replaced database was kept as ${kept}`);
});
it('SQLBK-009: a copy that cannot be kept does not block the restore', async () => {
const maintenance = maintenanceStub();
maintenance.vacuumInto.mockRejectedValueOnce('database disk image is malformed');
await expect(portWith(maintenance).keepCopyBeforeRestore()).resolves.toBeNull();
expect(logMock.logWarn).toHaveBeenCalledWith('Restore: could not keep a copy of the current database (database disk image is malformed)');
});
describe('replace', () => {
it('SQLBK-010: closes, swaps the file in by rename, drops the old sidecars and reopens', async () => {
writeDb(liveFile, 'live');
const source = path.join(dir, 'restored.db');
writeDb(source, 'restored');
const liveBytes = fs.readFileSync(liveFile);
const sourceBytes = fs.readFileSync(source);
fs.writeFileSync(liveFile + '-wal', 'stale wal');
fs.writeFileSync(liveFile + '-shm', 'stale shm');
// Which database sits at the live path at each step, told by its bytes:
// no handle is opened on a file whose sidecars are deliberately stale.
const which = () => {
const now = fs.readFileSync(liveFile);
if (now.equals(liveBytes)) return 'live';
return now.equals(sourceBytes) ? 'restored' : 'other';
};
const order: string[] = [];
lifecycle.close.mockImplementation(() => order.push(`close:${which()}`));
lifecycle.reopen.mockImplementation(async () => {
order.push(`reopen:${which()}`);
});
const result = await portWith(maintenanceStub()).replace(source);
expect(result).toEqual({ reopenError: null });
expect(order).toEqual(['close:live', 'reopen:restored']);
expect(fs.existsSync(liveFile + '-wal')).toBe(false);
expect(fs.existsSync(liveFile + '-shm')).toBe(false);
expect(fs.existsSync(liveFile + '.restore-tmp')).toBe(false);
// The source stays: it belongs to the caller (an unpacked archive, the demo baseline).
expect(readLabel(source)).toBe('restored');
});
it('SQLBK-011: a reopen that fails after the swap is reported, not thrown', async () => {
writeDb(liveFile, 'live');
const source = path.join(dir, 'restored.db');
writeDb(source, 'restored');
const failure = new Error('database is locked');
lifecycle.reopen.mockRejectedValueOnce(failure);
const result = await portWith(maintenanceStub()).replace(source);
expect(result.reopenError).toBe(failure);
expect(readLabel(liveFile)).toBe('restored');
});
it('SQLBK-012: a swap that fails still reopens, leaves the live file as it was and rethrows', async () => {
writeDb(liveFile, 'live');
await expect(portWith(maintenanceStub()).replace(path.join(dir, 'does-not-exist.db'))).rejects.toThrow(/ENOENT/);
expect(lifecycle.close).toHaveBeenCalledTimes(1);
expect(lifecycle.reopen).toHaveBeenCalledTimes(1);
expect(readLabel(liveFile)).toBe('live');
});
});
});