1import { Database } from "bun:sqlite";2import {3 cpSync,4 existsSync,5 mkdirSync,6 readdirSync,7 readFileSync,8 renameSync,9 rmSync,10} from "node:fs";11import { join, relative } from "node:path";12import { buildTarball } from "../tarball/tar.ts";1314const KEEP = 14;15const DAY_MS = 24 * 3600 * 1000;16const CHECK_MS = 3600 * 1000;17const NAME = /^\d{8}T\d{6}Z$/;181920function stamp(ms: number): string {21 return new Date(ms).toISOString().replace(/[-:]|\.\d{3}/g, "");22}232425export function backups(dataDir: string): string[] {26 const root = join(dataDir, "backups");27 return existsSync(root)28 ? readdirSync(root)29 .filter((name) => NAME.test(name))30 .sort()31 : [];32}333435363738export function makeBackup(kepr: Database, dataDir: string, now = Date.now()): string {39 const root = join(dataDir, "backups");40 const name = stamp(now);41 const tmp = join(root, `.${name}`);42 rmSync(tmp, { recursive: true, force: true });43 44 mkdirSync(tmp, { recursive: true, mode: 0o700 });45 kepr.query("VACUUM INTO ?").run(join(tmp, "kepr.db"));46 47 const copy = new Database(join(tmp, "kepr.db"));48 copy.run("DELETE FROM sessions");49 copy.run("VACUUM");50 copy.close();51 const assets = join(dataDir, "assets");52 if (existsSync(assets)) cpSync(assets, join(tmp, "assets"), { recursive: true });53 rmSync(join(root, name), { recursive: true, force: true });54 renameSync(tmp, join(root, name));55 for (const old of backups(dataDir).slice(0, -KEEP)) {56 rmSync(join(root, old), { recursive: true, force: true });57 }58 return name;59}606162export function backupIfDue(kepr: Database, dataDir: string, now = Date.now()): string | null {63 const newest = backups(dataDir).at(-1);64 return newest && newest > stamp(now - DAY_MS) ? null : makeBackup(kepr, dataDir, now);65}666768export function startBackupLoop(kepr: Database, dataDir: string): void {69 const tick = () => {70 try {71 backupIfDue(kepr, dataDir);72 } catch (error) {73 console.error(`kepr: backup failed: ${error}`);74 }75 };76 tick();77 setInterval(tick, CHECK_MS);78}798081export function newestBackupTarball(82 dataDir: string,83): { name: string; bytes: Uint8Array<ArrayBuffer> } | null {84 const name = backups(dataDir).at(-1);85 if (!name) return null;86 const dir = join(dataDir, "backups", name);87 const files = readdirSync(dir, { recursive: true, withFileTypes: true })88 .filter((entry) => entry.isFile())89 .map((entry) => {90 const path = join(entry.parentPath, entry.name);91 return { path: relative(dir, path), bytes: readFileSync(path) };92 });93 return { name, bytes: buildTarball(`kepr-backup-${name}`, files) };94}