feat(backup): VACUUM INTO snapshot core (create/list/prune) + config + off-box hook
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<Reason> 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
|
||||
});
|
||||
});
|
||||
@@ -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<SnapshotReason, number> = {
|
||||
'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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user