4538c2a89e9a
add daily backups of kepr.db by vacuum into plus assets into data/backups with the last 14 kept and sessions left out, the status page's last backup and download backup as a tarball, a restore test onto a fresh instance, the docs' admin and backup sections, and phase 7 in continuity (v3.0.39)
- time
- 2026-09-26 20:29 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- kepr 3 implementation
- parent
- 0c07b179ceeb
↓ kepr-4538c2a8.tar.gzatlas at this save
files changed6 files
package.json+1 −1src/admin/console.ts+18 −3src/main.ts+2 −0src/server/backup.ts+94 −0tests/admin.test.ts+1 −1tests/backup.test.ts+151 −0package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.38",3+ "version": "3.0.39",44 "private": true,55 "type": "module",66 "scripts": {src/admin/console.ts+18 −3
@@ -2,7 +2,8 @@22 import { checkFormat, listRepos } from "../bucket/repo.ts";33 import type { SyncTarget } from "../bucket/sync.ts";44 import { type Html, html } from "../pages/html.ts";5-import { type PageContext, page } from "../pages/layout.ts";5+import { type PageContext, page, plural } from "../pages/layout.ts";6+import { backups, newestBackupTarball } from "../server/backup.ts";67 import { formToken } from "../server/login.ts";78 89 export type AdminContext = PageContext & { sync: SyncTarget; dataDir: string };@@ -62,7 +63,7 @@6263 return total;6364 }6465 65-/** §24's Status section at `/admin`: the bucket, each repo's format, and the running version. */66+/** §24's Status section at `/admin`: the bucket, each repo's format, backups, the running version. */6667 export function registerStatus(app: Hono, ctx: AdminContext): void {6768 app.get("/admin", async (c) => {6869 let bucket: Html;@@ -78,7 +79,7 @@7879 }7980 }),8081 );81- bucket = html`<p>Bucket reachable: ${repos.length} repos.</p><ul>${formats}</ul>`;82+ bucket = html`<p>Bucket reachable: ${plural(repos.length, "repo")}.</p><ul>${formats}</ul>`;8283 } catch (error) {8384 bucket = html`<p class="issues-error" role="alert">Bucket unreachable: ${String(error)}</p>`;8485 }@@ -86,14 +87,28 @@8687 version: string;8788 };8889 const lines = (await sourceLines()).toLocaleString("en-US");90+ const newest = backups(ctx.dataDir).at(-1);91+ const backup = newest92+ ? html`<p>Last backup: ${newest.replace(/^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z$/, "$1-$2-$3 $4:$5:$6 UTC")}. <a href="/admin/backup">download backup</a></p>`93+ : html`<p>No backup yet.</p>`;8994 return c.html(9095 adminPage(9196 c,9297 ctx,9398 "status",9499 html`<section class="repo-section markdown">${bucket}100+${backup}95101 <p>Running kepr ${version}: ${lines} lines of TypeScript.</p></section>`,96102 ),97103 );104+ });105+106+ app.get("/admin/backup", (c) => {107+ const backup = newestBackupTarball(ctx.dataDir);108+ if (!backup) return c.notFound();109+ return c.body(backup.bytes, 200, {110+ "Content-Type": "application/gzip",111+ "Content-Disposition": `attachment; filename="kepr-backup-${backup.name}.tar.gz"`,112+ });98113 });99114 }src/main.ts+2 −0
@@ -2,12 +2,14 @@22 import { openBucket } from "./bucket/reader.ts";33 import { startSyncLoop } from "./bucket/sync.ts";44 import { createApp } from "./server/app.ts";5+import { startBackupLoop } from "./server/backup.ts";56 import { ConfigError, loadConfig } from "./server/config.ts";67 import { openKeprDb } from "./server/db.ts";78 89 try {910 const config = await loadConfig("kepr.toml");1011 const kepr = openKeprDb(config.dataDir);12+ startBackupLoop(kepr, config.dataDir);1113 startSyncLoop({1214 bucket: openBucket(config.bucket),1315 kepr,src/server/backup.ts+94 −0
@@ -0,0 +1,94 @@1+import { Database } from "bun:sqlite";2+import {3+ cpSync,4+ existsSync,5+ mkdirSync,6+ readdirSync,7+ readFileSync,8+ renameSync,9+ rmSync,10+} from "node:fs";11+import { join, relative } from "node:path";12+import { buildTarball } from "../tarball/tar.ts";13+14+const KEEP = 14;15+const DAY_MS = 24 * 3600 * 1000;16+const CHECK_MS = 3600 * 1000;17+const NAME = /^\d{8}T\d{6}Z$/;18+19+/** A backup's name: its UTC time, `20260926T031500Z`, so names sort in time order. */20+function stamp(ms: number): string {21+ return new Date(ms).toISOString().replace(/[-:]|\.\d{3}/g, "");22+}23+24+/** The backups in `data/backups/`, oldest first. */25+export 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+}33+34+/**35+ * §19's backup: `VACUUM INTO` of kepr.db and a copy of `assets/`, built in a hidden folder and36+ * renamed into `data/backups/<time>/`, so a half-made backup is never the newest. Keeps the last 14.37+ */38+export 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+ // It holds reporters' contact emails, so only Kepr's user may read it.44+ mkdirSync(tmp, { recursive: true, mode: 0o700 });45+ kepr.query("VACUUM INTO ?").run(join(tmp, "kepr.db"));46+ // A restored backup must not bring live sign-ins with it.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+}60+61+/** Makes a backup when the newest is a day old or there is none; returns its name, else null. */62+export 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+}66+67+/** Daily backups: checked at startup and every hour, so a restart never skips a day. */68+export 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+}79+80+/** The newest backup as a `.tar.gz` for "download backup" (§24), or null when there is none. */81+export 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+}tests/admin.test.ts+1 −1
@@ -66,7 +66,7 @@6666 expect(admin.status).toBe(200);6767 expect(admin.headers.get("cache-control")).toBe("no-store");6868 const text = await admin.text();69- expect(text).toContain("Bucket reachable: 1 repos.");69+ expect(text).toContain("Bucket reachable: 1 repo.");7070 expect(text).toContain("format ok");7171 expect(text).toMatch(/Running kepr 3\.0\.\d+: [\d,]+ lines of TypeScript/);7272 });tests/backup.test.ts+151 −0
@@ -0,0 +1,151 @@1+import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";2+import { mkdtemp, readdir, writeFile } from "node:fs/promises";3+import { tmpdir } from "node:os";4+import { join } from "node:path";5+import { fileIssue } from "../src/issues/store.ts";6+import { backupIfDue, backups, makeBackup } from "../src/server/backup.ts";7+import { openKeprDb } from "../src/server/db.ts";8+import { Browser, type Instance, instance } from "./browser.ts";9+import { makeRepos, writeRelease } from "./fixture.ts";10+11+const PNG = Uint8Array.from(12+ atob(13+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",14+ ),15+ (ch) => ch.charCodeAt(0),16+);17+18+describe("backups", () => {19+ test("one a day, named by time, and only the last 14 kept", async () => {20+ const dataDir = await mkdtemp(join(tmpdir(), "kepr-backups-"));21+ const kepr = openKeprDb(dataDir);22+ const start = Date.parse("2026-09-01T03:00:00Z");23+ expect(backupIfDue(kepr, dataDir, start)).toBe("20260901T030000Z");24+ expect(backupIfDue(kepr, dataDir, start + 23 * 3600 * 1000)).toBeNull();25+ expect(backupIfDue(kepr, dataDir, start + 24 * 3600 * 1000)).toBe("20260902T030000Z");26+ for (let day = 2; day < 17; day++) makeBackup(kepr, dataDir, start + day * 86400 * 1000);27+ const kept = backups(dataDir);28+ expect(kept.length).toBe(14);29+ expect(kept[0]).toBe("20260904T030000Z");30+ expect(kept.at(-1)).toBe("20260917T030000Z");31+ // No half-made backup is left behind.32+ expect((await readdir(join(dataDir, "backups"))).sort()).toEqual(kept);33+ });34+});35+36+describe("a backup restored onto a fresh instance works", () => {37+ let clock = Date.parse("2026-09-26T12:00:00Z");38+ let old: Instance;39+ let fresh: Instance;40+ let operator: Browser;41+ let bucketDir: string;42+ let signature = "";43+ let extracted: string[] = [];44+45+ beforeAll(async () => {46+ setSystemTime(new Date(clock));47+ const repos = await makeRepos(["app", "hush"]);48+ bucketDir = repos.bucketDir;49+ await writeRelease(bucketDir, "app", {50+ version: "1.0.0",51+ save: repos.saves.app as string,52+ notes: "record notes",53+ files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],54+ });55+ old = await instance(bucketDir);56+ operator = new Browser(old.app);57+ await operator.signIn();58+ await operator.post("/admin/repos/app", { public: "1", category: "tools", sort_order: "3" });59+ const site = new FormData();60+ for (const [key, value] of Object.entries({61+ site_name: "restored",62+ handle: "ada",63+ bio: "Hi.",64+ })) {65+ site.set(key, value);66+ }67+ site.set("signature", new File([PNG], "sig.png", { type: "image/png" }));68+ await operator.post("/admin/site", site);69+ signature = (70+ old.kepr.query("SELECT value FROM settings WHERE key = 'signature'").get() as {71+ value: string;72+ }73+ ).value;74+ fileIssue(old.kepr, {75+ repo: "app",76+ title: "Keep me",77+ body: "b",78+ type: "bug",79+ version: "",80+ reporterName: "Ada",81+ contactEmail: "[email protected]",82+ ipHash: "x",83+ filedAt: 1,84+ });85+ await operator.post("/admin/issues/app/1", { action: "reply", body: "Answered." });86+ await operator.post("/admin/releases/app/1.0.0", { notes: "edited notes" });87+ await old.app.request("/app/releases/1.0.0/linux-x86_64");88+ clock += 60_000;89+ setSystemTime(new Date(clock));90+ makeBackup(old.kepr, old.dataDir);91+92+ // "download backup", unpacked by tar into a fresh data directory, as an operator would.93+ await operator.get("/admin");94+ const download = await operator.request("/admin/backup");95+ expect(download.status).toBe(200);96+ expect(download.headers.get("content-disposition")).toBe(97+ 'attachment; filename="kepr-backup-20260926T120100Z.tar.gz"',98+ );99+ const work = await mkdtemp(join(tmpdir(), "kepr-restore-"));100+ await writeFile(join(work, "backup.tar.gz"), new Uint8Array(await download.arrayBuffer()));101+ const tar = Bun.spawnSync(["tar", "-xzf", "backup.tar.gz"], { cwd: work });102+ expect(tar.exitCode).toBe(0);103+ const restored = join(work, "kepr-backup-20260926T120100Z");104+ extracted = (await readdir(restored, { recursive: true })).sort();105+ // Mirrors are not in the backup; the fresh instance's first sync rebuilds them.106+ fresh = await instance(bucketDir, restored);107+ });108+ afterAll(() => setSystemTime());109+110+ const text = async (path: string) => (await fresh.app.request(path)).text();111+112+ test("the backup holds kepr.db and assets, and no sessions", async () => {113+ expect(extracted).toEqual(["assets", `assets/${signature}`, "kepr.db"]);114+ expect(fresh.kepr.query("SELECT COUNT(*) AS n FROM sessions").get()).toEqual({ n: 0 });115+ expect(await (await operator.get("/admin")).text()).toContain(116+ 'Last backup: 2026-09-26 12:01:00 UTC. <a href="/admin/backup">download backup</a>',117+ );118+ const stale = new Browser(fresh.app);119+ stale.cookies = new Map(operator.cookies);120+ expect((await stale.get("/admin")).status).toBe(401);121+ expect((await new Browser(fresh.app).signIn()).status).toBe(303);122+ });123+124+ test("settings, visibility and the signature survive", async () => {125+ expect(await text("/")).toContain("<title>restored</title>");126+ expect((await fresh.app.request("/app")).status).toBe(200);127+ expect((await fresh.app.request("/hush")).status).toBe(404);128+ expect(await text("/browse")).toContain("tools");129+ const profile = await text("/@ada");130+ expect(profile).toContain(`src="/static/assets/${signature}"`);131+ const served = await fresh.app.request(`/static/assets/${signature}`);132+ expect(new Uint8Array(await served.arrayBuffer())).toEqual(PNG);133+ });134+135+ test("issues with their thread and contact, release notes and counts survive", async () => {136+ const thread = await text("/app/issues/1");137+ expect(thread).toContain("Keep me");138+ expect(thread).toContain("Answered.");139+ const admin = new Browser(fresh.app);140+ await admin.signIn();141+ expect(await (await admin.get("/admin/issues/app/1")).text()).toContain("[email protected]");142+ expect(JSON.parse(await text("/api/app/releases/latest")).notes).toBe("edited notes");143+ const stats = [...(await text("/@ada")).matchAll(/profile-stat-number">([^<]*)</g)].map(144+ (m) => m[1],145+ );146+ expect(stats).toEqual(["1", "1", "1", "1"]);147+ // New activity keeps counting and numbering from where the backup left off.148+ await fresh.app.request("/app/releases/1.0.0/linux-x86_64");149+ expect(await text("/@ada")).toContain('profile-stat-number">2<');150+ });151+});