From 48bcd69031fab1cff1667662d14b689dee37a9c4 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:21:22 +0200 Subject: [PATCH] feat(backup): VACUUM INTO snapshot core (create/list/prune) + config + off-box hook --- packages/server/src/config.ts | 9 +++ packages/server/src/utils/backup.test.ts | 62 +++++++++++++++++ packages/server/src/utils/backup.ts | 84 ++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 packages/server/src/utils/backup.test.ts create mode 100644 packages/server/src/utils/backup.ts diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 51642fbd..46e5e3ba 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -69,6 +69,15 @@ export const config = { dbPath: env('DB_PATH', resolve(__dirname, '../../../data/backspace.db')), maxUploadSize: envInt('MAX_UPLOAD_SIZE', 104857600), registrationOpen: envBool('REGISTRATION_OPEN', true), + backup: { + dir: envOptional('BACKUP_DIR') ?? resolve(dirname(env('DB_PATH', resolve(__dirname, '../../../data/backspace.db'))), 'backups'), + intervalHours: envInt('BACKUP_INTERVAL_HOURS', 24), + keepScheduled: envInt('BACKUP_KEEP_SCHEDULED', 7), + keepPreMigration: envInt('BACKUP_KEEP_PREMIGRATION', 5), + keepManual: envInt('BACKUP_KEEP_MANUAL', 10), + offsiteCmd: envOptional('BACKUP_OFFSITE_CMD'), + disabled: envBool('BACKUP_DISABLED', false), + }, } as const; if (config.jwtSecret.length < 32) { diff --git a/packages/server/src/utils/backup.test.ts b/packages/server/src/utils/backup.test.ts new file mode 100644 index 00000000..66282560 --- /dev/null +++ b/packages/server/src/utils/backup.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +let tmpDir: string; + +vi.mock('../config.js', () => ({ + config: { + backup: { + get dir() { return tmpDir; }, + intervalHours: 24, keepScheduled: 2, keepPreMigration: 2, keepManual: 2, + offsiteCmd: undefined, disabled: false, + }, + }, +})); + +import { createSnapshot, pruneSnapshots, listSnapshots } from './backup.js'; + +function seededDb(): Database.Database { + const db = new Database(':memory:'); + db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)'); + db.prepare('INSERT INTO t (v) VALUES (?)').run('a'); + db.prepare('INSERT INTO t (v) VALUES (?)').run('b'); + return db; +} + +beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bk-')); }); +afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + +describe('createSnapshot', () => { + it('writes a valid standalone DB with identical rows', () => { + const db = seededDb(); + const out = createSnapshot(db, 'manual'); + expect(fs.existsSync(out)).toBe(true); + const copy = new Database(out, { readonly: true }); + const n = (copy.prepare('SELECT COUNT(*) AS n FROM t').get() as { n: number }).n; + expect(n).toBe(2); + copy.close(); + }); + + it('encodes the reason in the filename', () => { + const db = seededDb(); + const out = createSnapshot(db, 'pre-migration'); + expect(path.basename(out)).toMatch(/pre-migration\.db$/); + }); +}); + +describe('pruneSnapshots', () => { + it('keeps only keep newest per reason', () => { + const db = seededDb(); + for (let i = 0; i < 4; i++) { + // unique names: createSnapshot uses a timestamp; force distinct mtimes + const p = createSnapshot(db, 'manual'); + fs.utimesSync(p, new Date(1000 + i), new Date(1000 + i)); + } + pruneSnapshots(); + const remaining = listSnapshots().filter(s => s.reason === 'manual'); + expect(remaining.length).toBe(2); // keepManual = 2 + }); +}); diff --git a/packages/server/src/utils/backup.ts b/packages/server/src/utils/backup.ts new file mode 100644 index 00000000..88d00b31 --- /dev/null +++ b/packages/server/src/utils/backup.ts @@ -0,0 +1,84 @@ +import Database from 'better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { config } from '../config.js'; + +export type SnapshotReason = 'pre-migration' | 'scheduled' | 'manual'; + +export interface SnapshotInfo { + path: string; + reason: SnapshotReason; + bytes: number; + mtimeMs: number; +} + +const REASONS: SnapshotReason[] = ['pre-migration', 'scheduled', 'manual']; + +function ensureDir(): string { + fs.mkdirSync(config.backup.dir, { recursive: true }); + return config.backup.dir; +} + +function timestamp(): string { + // 2026-06-20T14:03:09.123Z -> 20260620T140309123 (ms precision keeps names unique + // and sortable; VACUUM INTO throws if the target file already exists). + return new Date().toISOString().replace(/[-:.]/g, '').replace(/Z$/, ''); +} + +/** Synchronous, WAL-safe snapshot via VACUUM INTO. Returns the absolute path. */ +export function createSnapshot(db: Database.Database, reason: SnapshotReason): string { + const dir = ensureDir(); + const ts = timestamp(); + // VACUUM INTO throws if the target already exists. Millisecond precision keeps + // names unique under normal use, but tight loops can collide within the same + // millisecond — append a disambiguating counter on collision to guarantee a + // free, sortable path. + let file = path.join(dir, `backspace-${ts}-${reason}.db`); + for (let n = 1; fs.existsSync(file); n++) { + file = path.join(dir, `backspace-${ts}-${String(n).padStart(3, '0')}-${reason}.db`); + } + db.prepare('VACUUM INTO ?').run(file); + runOffsite(file); + return file; +} + +function runOffsite(snapshotPath: string): void { + const cmd = config.backup.offsiteCmd; + if (!cmd) return; + // Best-effort: failures are logged, never fatal. + execFile('/bin/sh', ['-c', `${cmd} "$1"`, 'sh', snapshotPath], (err, _stdout, stderr) => { + if (err) console.error(`[backup] off-box hook failed: ${err.message} ${stderr ?? ''}`); + }); +} + +export function listSnapshots(): SnapshotInfo[] { + if (!fs.existsSync(config.backup.dir)) return []; + return fs.readdirSync(config.backup.dir) + .filter(f => f.endsWith('.db')) + .map((f): SnapshotInfo | null => { + const reason = REASONS.find(r => f.endsWith(`-${r}.db`)); + if (!reason) return null; + const full = path.join(config.backup.dir, f); + const st = fs.statSync(full); + return { path: full, reason, bytes: st.size, mtimeMs: st.mtimeMs }; + }) + .filter((s): s is SnapshotInfo => s !== null) + .sort((a, b) => b.mtimeMs - a.mtimeMs); +} + +export function pruneSnapshots(): void { + const keep: Record = { + 'pre-migration': config.backup.keepPreMigration, + scheduled: config.backup.keepScheduled, + manual: config.backup.keepManual, + }; + for (const reason of REASONS) { + const ofReason = listSnapshots().filter(s => s.reason === reason); // newest-first + for (const stale of ofReason.slice(keep[reason])) { + try { fs.unlinkSync(stale.path); } catch (err) { + console.error(`[backup] failed to prune ${stale.path}: ${(err as Error).message}`); + } + } + } +}