feat: automate verified SQLite backups and safe recovery
This commit is contained in:
50
src/ops/backups.test.ts
Normal file
50
src/ops/backups.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { 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 });
|
||||
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("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);
|
||||
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 }); }
|
||||
});
|
||||
Reference in New Issue
Block a user