Files
minabot/src/ops/backups.ts

100 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Database } from "bun:sqlite";
import { chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
export type BackupConfig = { directory: string; replica?: string; retain: number; intervalMs: number; enabled: boolean };
const filenamePattern = /^minabot-\d{4}-\d{2}-\d{2}T[\d-]+Z-[a-f0-9-]+\.sqlite$/;
export function backupConfig(database: string, env = process.env): BackupConfig {
const retain = Number(env.BACKUP_RETAIN ?? 7);
const hours = Number(env.BACKUP_INTERVAL_HOURS ?? 24);
if (!Number.isInteger(retain) || retain < 2 || retain > 365 || !Number.isFinite(hours) || hours < 1 || hours > 168)
throw new Error("BACKUP_RETAIN must be 2–365 and BACKUP_INTERVAL_HOURS must be 1–168");
const base = dirname(database);
const directory = resolve(base, env.BACKUP_DIR ?? "backups");
const replica = env.BACKUP_REPLICA_DIR ? resolve(base, env.BACKUP_REPLICA_DIR) : undefined;
if (replica === directory) throw new Error("The backup replica must use a separate directory");
return { directory, replica, retain, intervalMs: hours * 3600000, enabled: env.BACKUP_ENABLED !== "false" };
}
function durable(path: string) {
chmodSync(path, 0o600);
const fd = openSync(path, "r"); try { fsyncSync(fd); } finally { closeSync(fd); }
}
export function verifyBackup(path: string) {
const db = new Database(path, { readonly: true, strict: true });
try {
const integrity = db.query("PRAGMA integrity_check").all() as { integrity_check: string }[];
if (integrity.length !== 1 || integrity[0]?.integrity_check !== "ok" || db.query("PRAGMA foreign_key_check").all().length)
throw new Error("Backup integrity check failed");
db.query("SELECT id FROM users LIMIT 1").all();
db.query("SELECT id FROM habits LIMIT 1").all();
db.query("SELECT hash FROM __drizzle_migrations LIMIT 1").all();
} finally { db.close(); }
}
function snapshots(directory: string) {
if (!existsSync(directory)) return [];
return readdirSync(directory).filter(name => filenamePattern.test(name)).sort().reverse();
}
export function latestBackupTime(config: BackupConfig) {
const latest = snapshots(config.directory)[0];
if (!latest || (config.replica && !existsSync(join(config.replica, latest)))) return 0;
return statSync(join(config.directory, latest)).mtimeMs;
}
export function createBackup(database: Database, config: BackupConfig) {
const name = `minabot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomUUID()}.sqlite`;
mkdirSync(config.directory, { recursive: true, mode: 0o700 });
const path = join(config.directory, name);
const staging = `${path}.partial`;
mkdirSync(staging, { mode: 0o700 });
const temporary = join(staging, "snapshot.sqlite");
try {
// VACUUM INTO includes committed WAL transactions in one consistent snapshot.
// SQLite creates the output inside a private staging directory.
database.query("VACUUM INTO ?").run(temporary);
verifyBackup(temporary); durable(temporary); renameSync(temporary, path);
if (config.replica) {
// Require the configured mount to exist; never silently create an absent mount.
if (!statSync(config.replica).isDirectory()) throw new Error("Backup replica directory unavailable");
const replica = join(config.replica, name);
copyFileSync(path, `${replica}.partial`, constants.COPYFILE_EXCL);
durable(`${replica}.partial`); verifyBackup(`${replica}.partial`); renameSync(`${replica}.partial`, replica);
}
for (const directory of [config.directory, ...(config.replica ? [config.replica] : [])])
for (const old of snapshots(directory).slice(config.retain)) unlinkSync(join(directory, old));
return path;
} finally { rmSync(staging, { recursive: true, force: true }); }
}
export function restoreBackup(source: string, destination: string) {
if (existsSync(destination) || existsSync(`${destination}-wal`) || existsSync(`${destination}-shm`))
throw new Error("Restore destination must be a new path with no SQLite sidecars");
verifyBackup(source);
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
const temporary = `${destination}.${crypto.randomUUID()}.partial`;
try {
copyFileSync(source, temporary, constants.COPYFILE_EXCL); chmodSync(temporary, 0o600);
const restored = new Database(temporary);
try {
restored.exec("PRAGMA journal_mode=DELETE; DELETE FROM sessions;");
if (restored.query("SELECT name FROM sqlite_master WHERE name = 'reminder_settings'").get()) restored.exec("UPDATE reminder_settings SET enabled=0");
} finally { restored.close(); }
verifyBackup(temporary); durable(temporary);
// Link is exclusive: a concurrently-created destination is never overwritten.
linkSync(temporary, destination);
} finally { if (existsSync(temporary)) unlinkSync(temporary); }
return destination;
}
export function startBackups(database: Database, config: BackupConfig) {
const state = { enabled: config.enabled, lastSuccess: 0, failed: false };
if (!config.enabled) return { state, stop() {} };
const tick = () => {
try {
const latest = latestBackupTime(config);
if (latest && Date.now() - latest < config.intervalMs) { state.lastSuccess = latest; state.failed = false; return; }
createBackup(database, config); state.lastSuccess = Date.now(); state.failed = false;
console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date().toISOString() }));
} catch { state.failed = true; console.error(JSON.stringify({ event: "backup_failed", timestamp: new Date().toISOString() })); }
};
tick();
const timer = setInterval(tick, 60000); timer.unref();
return { state, stop: () => clearInterval(timer) };
}