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
6 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 −0
package.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": {

view file

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 }

view file

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,

view file

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+}

view file

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 	});

view file

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+});

view file