Build and Push Docker Image / build-and-push (push) Successful in 10m22s
- Reader: chapter cross-reference map (M) from IQ Bible's reference graph - Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs - Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts - Settings split into Account, Preferences, Notifications, Security, Your data tabs - ntfy server field: catch a topic typed into the server box Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
63 lines
2.9 KiB
JavaScript
63 lines
2.9 KiB
JavaScript
// Automatic daily snapshots of the whole database (every account), kept next to it in DATA_DIR/backups.
|
|
// A snapshot is a complete, consistent copy made while the app keeps running, and is restored with
|
|
// scripts/restore-data.sh. BACKUP_KEEP sets how many to keep (default 7); BACKUP_DISABLED=1 turns it off.
|
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { logEvent } from './health.js';
|
|
import { backupDatabase, dataDir, replaceDatabase } from './db.js';
|
|
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
export const backupDir = () => join(dataDir(), 'backups');
|
|
const stamp = (d) => d.toISOString().replace(/[:T]/g, '-').slice(0, 16);
|
|
export const isSnapshot = (name) => /^projects-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}\.db$/.test(name);
|
|
|
|
export function listSnapshots(dir = backupDir()) {
|
|
if (!existsSync(dir)) return [];
|
|
return readdirSync(dir).filter(isSnapshot).sort().reverse()
|
|
.map((name) => ({ name, bytes: statSync(join(dir, name)).size, at: statSync(join(dir, name)).mtimeMs }));
|
|
}
|
|
|
|
// Deletes all but the newest `keep` snapshots; returns the names removed.
|
|
export function pruneSnapshots(keep = 7, dir = backupDir()) {
|
|
const old = listSnapshots(dir).slice(Math.max(1, keep));
|
|
old.forEach((s) => unlinkSync(join(dir, s.name)));
|
|
return old.map((s) => s.name);
|
|
}
|
|
|
|
export async function runBackup(now = new Date()) {
|
|
const dir = backupDir();
|
|
mkdirSync(dir, { recursive: true });
|
|
const file = join(dir, `projects-${stamp(now)}.db`);
|
|
await backupDatabase(file);
|
|
pruneSnapshots(Number(process.env.BACKUP_KEEP) || 7, dir);
|
|
return file;
|
|
}
|
|
|
|
export const snapshotPath = (name) => (isSnapshot(name) && existsSync(join(backupDir(), name)) ? join(backupDir(), name) : null);
|
|
|
|
// Puts a snapshot back as the live database. A fresh snapshot of what is there now is taken first (from a
|
|
// copy of the chosen file, so the two can't collide), so a restore can itself be undone.
|
|
export async function restoreSnapshot(name, now = new Date()) {
|
|
const src = snapshotPath(name);
|
|
if (!src) throw new Error('No such snapshot.');
|
|
const hold = join(backupDir(), `.restoring-${Date.now()}.tmp`);
|
|
copyFileSync(src, hold);
|
|
try {
|
|
const before = await runBackup(now);
|
|
replaceDatabase(hold);
|
|
return { restored: name, safetyCopy: before };
|
|
} finally { unlinkSync(hold); }
|
|
}
|
|
|
|
// Takes a snapshot when the newest is more than a day old (checked hourly, and once at start-up).
|
|
export function startBackupScheduler() {
|
|
if (process.env.BACKUP_DISABLED === '1') return;
|
|
const tick = () => {
|
|
const latest = listSnapshots()[0];
|
|
if (latest && Date.now() - latest.at < DAY) return;
|
|
runBackup().then((f) => console.log(`Database snapshot saved: ${f}`)).catch((err) => { console.error('Database snapshot failed:', err.message); logEvent('backup', err.message); });
|
|
};
|
|
setTimeout(tick, 30_000).unref();
|
|
setInterval(tick, 60 * 60 * 1000).unref();
|
|
}
|