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 46 kepr.query("VACUUM INTO ?").run(join(tmp, "kepr.db"));47 48 const copy = new Database(join(tmp, "kepr.db"));49 copy.run("DELETE FROM sessions");50 copy.run("VACUUM");51 copy.close();52 const assets = join(dataDir, "assets");53 if (existsSync(assets)) cpSync(assets, join(tmp, "assets"), { recursive: true });54 rmSync(join(root, name), { recursive: true, force: true });55 renameSync(tmp, join(root, name));56 for (const old of backups(dataDir).slice(0, -KEEP)) {57 rmSync(join(root, old), { recursive: true, force: true });58 }59 return name;60}616263export function backupIfDue(kepr: Database, dataDir: string, now = Date.now()): string | null {64 const newest = backups(dataDir).at(-1);65 return newest && newest > stamp(now - DAY_MS) ? null : makeBackup(kepr, dataDir, now);66}676869export function startBackupLoop(kepr: Database, dataDir: string): void {70 const tick = () => {71 try {72 backupIfDue(kepr, dataDir);73 } catch (error) {74 console.error(`kepr: backup failed: ${error}`);75 }76 };77 tick();78 setInterval(tick, CHECK_MS);79}808182export function newestBackupTarball(83 dataDir: string,84): { name: string; bytes: Uint8Array<ArrayBuffer> } | null {85 const name = backups(dataDir).at(-1);86 if (!name) return null;87 const dir = join(dataDir, "backups", name);88 const files = readdirSync(dir, { recursive: true, withFileTypes: true })89 .filter((entry) => entry.isFile())90 .map((entry) => {91 const path = join(entry.parentPath, entry.name);92 return { path: relative(dir, path), bytes: readFileSync(path) };93 });94 return { name, bytes: buildTarball(`kepr-backup-${name}`, files) };95}