import { expect, test } from "bun:test"; import { Database } from "bun:sqlite"; import { existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { fixture } from "../habits/test-fixture"; import { backupConfig, createBackup, latestBackupTime, restoreBackup, startBackups } from "./backups"; test("WAL snapshots restore committed progress, revoke sessions, replicate, and retain only owned snapshots", async () => { const directory = mkdtempSync(join(tmpdir(), "minabot-backup-")); const f = fixture(join(directory, "live.sqlite")); try { f.sqlite.exec("PRAGMA journal_mode=WAL"); const config = backupConfig(join(directory, "live.sqlite"), { BACKUP_RETAIN: "2", BACKUP_REPLICA_DIR: "replica" }); mkdirSync(config.replica!); const habit = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "cups" }, 201); await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, "PUT", { count: 6 }); f.sqlite.exec("INSERT INTO reminder_settings (user_id, enabled, time, quiet_start, quiet_end, updated_at) VALUES ('alice', 1, '20:00', '22:00', '08:00', 0)"); const first = createBackup(f.sqlite, config); expect(statSync(first).mode & 0o777).toBe(0o600); expect(latestBackupTime(config)).toBeGreaterThan(0); const destination = join(directory, "restored.sqlite"); restoreBackup(first, destination); const restored = new Database(destination, { readonly: true }); try { expect(restored.query("SELECT count FROM habit_days").get()).toEqual({ count: 6 }); expect(restored.query("SELECT * FROM sessions").all()).toEqual([]); expect(restored.query("SELECT enabled FROM reminder_settings").get()).toEqual({ enabled: 0 }); expect(restored.query("PRAGMA integrity_check").get()).toEqual({ integrity_check: "ok" }); } finally { restored.close(); } expect(() => restoreBackup(first, destination)).toThrow("new path"); writeFileSync(join(config.directory, "keep.txt"), "unrelated"); createBackup(f.sqlite, config); createBackup(f.sqlite, config); expect(readdirSync(config.directory).filter(name => name.endsWith(".sqlite"))).toHaveLength(2); expect(readdirSync(config.replica!)).toHaveLength(2); expect(readdirSync(config.directory)).toContain("keep.txt"); const runner = startBackups(f.sqlite, config); await runner.ready; expect(runner.state.failed).toBe(false); expect(runner.state.lastSuccess).toBeGreaterThan(0); runner.stop(); } finally { f.close(); rmSync(directory, { recursive: true, force: true }); } }); test("invalid backups and missing replica mounts fail without overwriting data", () => { const directory = mkdtempSync(join(tmpdir(), "minabot-backup-failure-")); const f = fixture(); try { const bad = join(directory, "bad.sqlite"); writeFileSync(bad, "not a database"); expect(() => restoreBackup(bad, join(directory, "target.sqlite"))).toThrow(); const config = backupConfig(join(directory, "live.sqlite"), { BACKUP_REPLICA_DIR: "missing" }); expect(() => createBackup(f.sqlite, config)).toThrow(); expect(latestBackupTime(config)).toBe(0); } finally { f.close(); rmSync(directory, { recursive: true, force: true }); } }); test("backup and restore CLIs work against a live database without overwriting it", async () => { const directory = mkdtempSync(join(tmpdir(), "minabot-backup-cli-")); const path = join(directory, "live.sqlite"); const f = fixture(path); try { f.sqlite.exec("PRAGMA journal_mode=WAL"); await f.json('/habits', 'POST', { name: 'CLI recovery', method: 'manual' }, 201); const backup = Bun.spawnSync([process.execPath, 'scripts/backup.ts'], { env: { ...process.env, DATABASE_PATH: path, BACKUP_DIR: 'backups', BACKUP_REPLICA_DIR: '', BACKUP_RETAIN: '7', BACKUP_INTERVAL_HOURS: '24' }, }); expect(backup.exitCode).toBe(0); const snapshot = backup.stdout.toString().trim(); const restored = join(directory, 'recovered.sqlite'); const result = Bun.spawnSync([process.execPath, 'scripts/restore.ts', snapshot, restored]); expect(result.exitCode).toBe(0); const check = new Database(restored, { readonly: true }); try { expect(check.query('SELECT count(*) AS n FROM habits').get()).toEqual({ n: 1 }); } finally { check.close(); } expect(f.sqlite.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 }); } finally { f.close(); rmSync(directory, { recursive: true, force: true }); } }); test('scheduled backups yield to the event loop, retain WAL data, and verify replicas', async () => { const directory = mkdtempSync(join(tmpdir(), 'minabot-async-backup-')); const path = join(directory, 'live.sqlite'), f = fixture(path); let runner: ReturnType | undefined; try { f.sqlite.exec('PRAGMA journal_mode=WAL'); await f.json('/habits', 'POST', { name: 'Async snapshot', method: 'manual' }, 201); const config = backupConfig(path, { BACKUP_REPLICA_DIR: 'replica' }); mkdirSync(config.replica!); let yielded = false; const timer = new Promise(resolve => setTimeout(() => { yielded = true; resolve(); }, 0)); runner = startBackups(path, config); expect(runner.state.lastSuccess).toBe(0); await runner.ready; expect(yielded).toBe(true); await timer; expect(runner.state.failed).toBe(false); expect(runner.state.lastSuccess).toBeGreaterThan(0); const snapshots = readdirSync(config.directory).filter(file => file.endsWith('.sqlite')); expect(snapshots).toHaveLength(1); const check = new Database(join(config.replica!, snapshots[0]!), { readonly: true }); try { expect(check.query('SELECT count(*) AS n FROM habits').get()).toEqual({ n: 1 }); } finally { check.close(); } await runner.tick(); expect(readdirSync(config.directory).filter(file => file.endsWith('.sqlite'))).toHaveLength(1); } finally { runner?.stop(); f.close(); rmSync(directory, { recursive: true, force: true }); } }); test('scheduled backup ticks coalesce, expose failure and recovery, and stop admitting work', async () => { const config = backupConfig('/tmp/test-only.sqlite', {}); let calls = 0, release!: (value: number) => void; const run = async () => { calls++; return new Promise(resolve => { release = resolve; }); }; const runner = startBackups('/tmp/test-only.sqlite', config, run); expect(runner.tick()).toBe(runner.ready); expect(calls).toBe(1); release(123); await runner.ready; expect(runner.state.lastSuccess).toBe(123); const second = runner.tick(); expect(calls).toBe(2); runner.stop(); release(456); await second; await runner.tick(); expect(calls).toBe(2); const failed = startBackups('/tmp/test-only.sqlite', config, async () => { throw new Error('test failure'); }); await failed.ready; expect(failed.state.failed).toBe(true); failed.stop(); const disabled = startBackups('/tmp/test-only.sqlite', { ...config, enabled: false }, run); await disabled.ready; expect(calls).toBe(2); disabled.stop(); }); test('the first scheduled snapshot initializes cold WAL bookkeeping without changing source rows', async () => { const directory = mkdtempSync(join(tmpdir(), 'minabot-cold-wal-')); const path = join(directory, 'live.sqlite'), f = fixture(path); f.sqlite.exec('PRAGMA journal_mode=DELETE'); f.close(); const db = new Database(path, { readwrite: true, create: false, strict: true }); let runner: ReturnType | undefined; try { db.exec('PRAGMA journal_mode=WAL'); expect(existsSync(`${path}-shm`)).toBe(false); runner = startBackups(db, backupConfig(path, {})); await runner.ready; expect(runner.state.failed).toBe(false); expect(runner.state.lastSuccess).toBeGreaterThan(0); expect(db.query('SELECT count(*) AS n FROM users').get()).toEqual({ n: 2 }); expect(db.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 }); expect(db.query('SELECT total_changes() AS n').get()).toEqual({ n: 0 }); } finally { runner?.stop(); db.close(); rmSync(directory, { recursive: true, force: true }); } }); test('the scheduled snapshot never creates a missing source database', async () => { const directory = mkdtempSync(join(tmpdir(), 'minabot-missing-source-')); const path = join(directory, 'missing.sqlite'); const runner = startBackups(path, backupConfig(path, {})); try { await runner.ready; expect(runner.state.failed).toBe(true); expect(existsSync(path)).toBe(false); } finally { runner.stop(); rmSync(directory, { recursive: true, force: true }); } });