sync.test.ts

16.1 KB · raw

1import { Database } from "bun:sqlite";2import { beforeAll, describe, expect, test } from "bun:test";3import { cp, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";4import { tmpdir } from "node:os";5import { join } from "node:path";6import { deflateSync } from "node:zlib";7import { type Bucket, openBucket } from "../src/bucket/reader.ts";8import { syncAll, syncRepo } from "../src/bucket/sync.ts";9import {10	fileAt,11	filesAt,12	flagReason,13	headId,14	history,15	objectBytes,16	repoStats,17	saveByPrefix,18	timeline,19} from "../src/mirror/queries.ts";20import { mirrorState, openMirror } from "../src/mirror/store.ts";21import { openKeprDb } from "../src/server/db.ts";22import { Browser, instance } from "./browser.ts";23import { type Fixture, makeKohBucket } from "./fixture.ts";2425const text = (bytes: Uint8Array | null) => new TextDecoder().decode(bytes ?? new Uint8Array());2627async function syncTarget(fx: Fixture) {28	const dataDir = await mkdtemp(join(tmpdir(), "kepr-data-"));29	const target = {30		bucket: openBucket({ kind: "folder", path: fx.bucketDir }),31		kepr: openKeprDb(dataDir),32		mirrorDir: join(dataDir, "mirror"),33	};34	return { ...target, mirror: () => openMirror(target.mirrorDir, fx.repo) };35}3637describe("a first sync of a koh3 bucket", () => {38	let fx: Fixture;39	let t: Awaited<ReturnType<typeof syncTarget>>;40	beforeAll(async () => {41		fx = await makeKohBucket();42		t = await syncTarget(fx);43		await syncAll(t);44	});45	const [head, checkpoint, second, first] = [0, 1, 2, 3].map((i) => () => fx.saveIds[i] as string);4647	test("registers the repo private, with a clean last sync", () => {48		const row = t.kepr.query("SELECT public, last_sync_at, last_sync_error FROM repos").get() as {49			public: number;50			last_sync_at: number;51			last_sync_error: string | null;52		};53		expect(row.public).toBe(0);54		expect(row.last_sync_error).toBeNull();55		expect(row.last_sync_at).toBeGreaterThan(0);56	});5758	test("history is newest first and leaves out the checkpoint", () => {59		expect(headId(t.mirror())).toBe(head());60		expect(history(t.mirror(), 10, 0).map((s) => s.id)).toEqual([head(), second(), first()]);61		expect(history(t.mirror(), 1, 1).map((s) => s.message)).toEqual(["add readme and entry point"]);62		expect(timeline(t.mirror()).map((s) => s.id)).toEqual([first(), second(), head()]);63		expect(saveByPrefix(t.mirror(), checkpoint())?.is_checkpoint).toBe(true);64	});6566	test("finds a save by a unique prefix of six or more characters", () => {67		const save = saveByPrefix(t.mirror(), second().slice(0, 6));68		expect([save?.id, save?.agent, save?.session]).toEqual([second(), "test-agent", "fixture"]);69		expect(saveByPrefix(t.mirror(), second().slice(0, 5))).toBeNull();70		expect(saveByPrefix(t.mirror(), "zzzzzz")).toBeNull();71	});7273	test("reads files and their bytes at any save", () => {74		const paths = filesAt(t.mirror(), head()).map((f) => f.path);75		expect(paths).toContain("src/lib/deep/util.ts");76		const now = fileAt(t.mirror(), head(), "src/main.ts");77		const then = fileAt(t.mirror(), second(), "src/main.ts");78		expect(text(objectBytes(t.mirror(), now?.hash as string))).toContain("hello");79		expect(text(objectBytes(t.mirror(), then?.hash as string))).toContain("'hi'");80		expect(fileAt(t.mirror(), first(), "src/main.ts")).toBeNull();81		expect(fileAt(t.mirror(), head(), "docs/logo.png")?.size).toBe(70);82	});8384	test("counts saves without checkpoints, and lines and language at head", () => {85		const stats = repoStats(t.mirror());86		expect(stats.saves).toBe(3);87		expect(stats.language).toBe("TypeScript");88		// Source lines only: util.ts 2 + main.ts 2. The README, guide, secret and .kohignore are not89		// code, and vendor/big.c's 100 lines of C are someone else's.90		expect(stats.lines).toBe(4);91		expect(stats.updated).toBe(saveByPrefix(t.mirror(), head())?.created_at ?? -1);92	});9394	test("stores the flag event", () => {95		const flag = t.mirror().query("SELECT kind, save_id, reason FROM events").get();96		expect(flag).toEqual({ kind: "flag", save_id: first(), reason: "holds a secret" });97	});9899	test("a save's flag state is its newest flag or clear event, by time and then ID", async () => {100		expect(flagReason(t.mirror(), first())).toBe("holds a secret");101		expect(flagReason(t.mirror(), head())).toBeNull();102		const mirror = openMirror(await mkdtemp(join(tmpdir(), "kepr-flags-")), "flags");103		const event = mirror.query(104			"INSERT INTO events (id, kind, save_id, reason, created_at, machine) VALUES (?, ?, 'aaaaaaaaaaaa', ?, ?, 'm')",105		);106		event.run("000000000001", "flag", "first", 10);107		expect(flagReason(mirror, "aaaaaaaaaaaa")).toBe("first");108		event.run("000000000002", "clear", null, 20);109		expect(flagReason(mirror, "aaaaaaaaaaaa")).toBeNull();110		// Same second: the larger ID is the newer.111		event.run("000000000003", "flag", "again", 20);112		expect(flagReason(mirror, "aaaaaaaaaaaa")).toBe("again");113		event.run("000000000000", "clear", null, 20);114		expect(flagReason(mirror, "aaaaaaaaaaaa")).toBe("again");115	});116117	test("a strip reaches the mirror and drops the object", async () => {118		const secret = fileAt(t.mirror(), head(), "secret.txt")?.hash as string;119		expect(objectBytes(t.mirror(), secret)).not.toBeNull();120		await fx.strip("secret.txt");121		await syncAll(t);122		for (const id of fx.saveIds) expect(fileAt(t.mirror(), id, "secret.txt")).toBeNull();123		expect(objectBytes(t.mirror(), secret)).toBeNull();124		expect(fileAt(t.mirror(), head(), "src/main.ts")).not.toBeNull();125	});126});127128test("a whole six-character ID finds its own save, not a longer one it begins", async () => {129	const mirror = openMirror(await mkdtemp(join(tmpdir(), "kepr-mirror-")), "ids");130	const add = mirror.query(131		"INSERT INTO saves (id, parent, message, created_at, machine) VALUES (?, NULL, ?, 1, 'm')",132	);133	add.run("af96c8", "migrated from Koh 2");134	add.run("af96c8123456", "made by Koh 3");135	expect(saveByPrefix(mirror, "af96c8")?.message).toBe("migrated from Koh 2");136	expect(saveByPrefix(mirror, "af96c81")?.message).toBe("made by Koh 3");137});138139test("tells its target about each repo that synced cleanly, and not one that failed", async () => {140	const fx = await makeKohBucket("told");141	const t = await syncTarget(fx);142	const synced: string[] = [];143	await syncAll({ ...t, synced: (repo) => synced.push(repo) });144	expect(synced).toEqual(["told"]);145	await writeFile(join(fx.bucketDir, fx.repo, "format"), "koh-remote 2");146	await syncAll({ ...t, synced: (repo) => synced.push(repo) });147	expect(synced).toEqual(["told"]);148});149150test("a sync lands history in batches, so one cut short resumes where it stopped", async () => {151	const fx = await makeKohBucket("batched");152	const bucket = openBucket({ kind: "folder", path: fx.bucketDir });153	const mirror = openMirror(await mkdtemp(join(tmpdir(), "kepr-mirror-")), fx.repo);154	// The bucket goes away as soon as the first batch has landed.155	const failing: Bucket = {156		...bucket,157		get: (key) =>158			key.includes("/objects/") && mirrorState(mirror, "head") !== null159				? Promise.reject(new Error("connection reset"))160				: bucket.get(key),161	};162	await expect(syncRepo(failing, mirror, fx.repo, [], 1)).rejects.toThrow("connection reset");163	const first = fx.saveIds[3] as string;164	expect(mirrorState(mirror, "head")).toBe(first);165	expect(history(mirror, 10, 0).map((s) => s.id)).toEqual([first]);166167	await syncRepo(bucket, mirror, fx.repo, [], 1);168	expect(headId(mirror)).toBe(fx.saveIds[0] as string);169	expect(history(mirror, 10, 0)).toHaveLength(3);170	expect(flagReason(mirror, first)).toBe("holds a secret");171});172173test("a save larger than a batch is stored ahead of itself, and a sync cut short within it resumes", async () => {174	const fx = await makeKohBucket("ahead");175	const bucket = openBucket({ kind: "folder", path: fx.bucketDir });176	const mirror = openMirror(await mkdtemp(join(tmpdir(), "kepr-mirror-")), fx.repo);177	let objectGets = 0;178	const failing: Bucket = {179		...bucket,180		get: (key) =>181			key.includes("/objects/") && ++objectGets > 2182				? Promise.reject(new Error("connection reset"))183				: bucket.get(key),184	};185	await expect(syncRepo(failing, mirror, fx.repo, [], 1)).rejects.toThrow("connection reset");186	const stored = () =>187		mirror.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM objects").get()?.n;188	expect(mirrorState(mirror, "head")).toBeNull();189	expect(stored()).toBe(2);190191	await syncRepo(bucket, mirror, fx.repo, [], 1);192	expect(headId(mirror)).toBe(fx.saveIds[0] as string);193	expect(history(mirror, 10, 0)).toHaveLength(3);194	const guide = fileAt(mirror, fx.saveIds[0] as string, "docs/guide.md");195	expect(text(objectBytes(mirror, guide?.hash as string))).toBe(196		"# Guide\n\nRead me. It is short.\n",197	);198});199200test("a new save syncs incrementally and a bad format is recorded as the last sync error", async () => {201	const fx = await makeKohBucket("growing");202	const t = await syncTarget(fx);203	await syncAll(t);204	await writeFile(join(fx.repoDir, "new.txt"), "new\n");205	Bun.spawnSync(["koh3", "save", "add new"], {206		cwd: fx.repoDir,207		env: { PATH: process.env.PATH, HOME: join(fx.repoDir, "../../home"), NO_COLOR: "1" },208	});209	await syncAll(t);210	expect(history(t.mirror(), 1, 0)[0]?.message).toBe("add new");211212	await writeFile(join(fx.bucketDir, fx.repo, "format"), "koh-remote 2");213	await syncAll(t);214	const row = t.kepr.query("SELECT last_sync_error FROM repos").get() as {215		last_sync_error: string;216	};217	expect(row.last_sync_error).toContain('remote format "koh-remote 2"');218});219220test("a whole-history strip reaches every save, those arriving after it too, whatever their time (§10.2)", async () => {221	const fx = await makeKohBucket("late");222	const view = await mkdtemp(join(tmpdir(), "kepr-view-"));223	const viewRepo = join(view, fx.repo);224	await cp(join(fx.bucketDir, fx.repo), viewRepo, { recursive: true });225	// A strip older than every save here: a strip bans its path from all of history, so no save226	// keeps it, however much later it was made or arrives.227	await mkdir(join(viewRepo, "events"), { recursive: true });228	await writeFile(229		join(viewRepo, "events", "0000000000bb"),230		JSON.stringify({231			id: "0000000000bb",232			kind: "strip",233			save_id: "",234			path: "secret.txt",235			reason: "a secret got saved",236			created_at: 1,237			machine: "elsewhere",238		}),239	);240	const oldest = fx.saveIds.at(-1) as string;241	const newest = fx.saveIds[0] as string;242	// First sync: the strip and the oldest save only.243	await writeFile(join(viewRepo, "head"), oldest);244	const t = await syncTarget({ ...fx, bucketDir: view });245	await syncAll(t);246	expect(fileAt(t.mirror(), oldest, "secret.txt")).toBeNull();247	// Then the rest arrive, each made long after the strip.248	await writeFile(join(viewRepo, "head"), newest);249	await syncAll(t);250	expect(headId(t.mirror())).toBe(newest);251	for (const id of fx.saveIds) expect(fileAt(t.mirror(), id, "secret.txt")).toBeNull();252	expect(fileAt(t.mirror(), newest, "src/main.ts")).not.toBeNull();253});254255describe("the mirror's schema version", () => {256	test("a version 2 mirror reopens and any other version is refused as disposable", async () => {257		const dir = await mkdtemp(join(tmpdir(), "kepr-mirror-"));258		const created = openMirror(dir, "one");259		created.run("PRAGMA wal_checkpoint(TRUNCATE)");260		await Bun.write(join(dir, "copy.db"), Bun.file(join(dir, "one.db")));261		expect(openMirror(dir, "copy").query("PRAGMA user_version").get()).toEqual({262			user_version: 2,263		});264265		for (const version of [1, 3]) {266			const other = new Database(join(dir, `v${version}.db`));267			other.run(`PRAGMA user_version = ${version}`);268			other.close();269			expect(() => openMirror(dir, `v${version}`)).toThrow("disposable");270		}271	});272});273274test("a strip that arrives older than the saves already mirrored still strips them (§10.2, R6)", async () => {275	const fx = await makeKohBucket("older-strip");276	const t = await syncTarget(fx);277	await syncAll(t);278	// Every save in the mirror holds secret.txt …279	for (const id of fx.saveIds) expect(fileAt(t.mirror(), id, "secret.txt")).not.toBeNull();280281	// … and now a strip arrives whose own time is older than all of them, which is what a282	// strip made on another machine and offered late looks like. Every save is reached, whatever283	// its time.284	const events = join(fx.bucketDir, fx.repo, "events");285	await mkdir(events, { recursive: true });286	await writeFile(287		join(events, "0000000000aa"),288		JSON.stringify({289			id: "0000000000aa",290			kind: "strip",291			save_id: "",292			path: "secret.txt",293			reason: "a secret got saved",294			created_at: 1,295			machine: "elsewhere",296		}),297	);298	await syncAll(t);299300	for (const id of fx.saveIds) expect(fileAt(t.mirror(), id, "secret.txt")).toBeNull();301	expect(fileAt(t.mirror(), fx.saveIds[0] as string, "src/main.ts")).not.toBeNull();302});303304test("a save with an unsafe path lands without it, keeps syncing, and the admin status names it (A3)", async () => {305	const fx = await makeKohBucket("quarantine");306	const head = fx.saveIds[0] as string;307	const key = join(fx.bucketDir, fx.repo, "saves", head);308	const record = JSON.parse(await readFile(key, "utf8"));309	record.files.push({ ...record.files[0], path: "Icon\r" });310	await writeFile(key, JSON.stringify(record));311	const inst = await instance(fx.bucketDir);312	const mirror = openMirror(join(inst.dataDir, "mirror"), fx.repo);313	expect(inst.kepr.query("SELECT last_sync_error FROM repos").get()).toEqual({314		last_sync_error: null,315	});316	expect(filesAt(mirror, head).map((file) => file.path)).toContain("README.md");317	expect(filesAt(mirror, head).map((file) => file.path)).not.toContain("Icon\r");318319	await writeFile(join(fx.repoDir, "later.txt"), "later\n");320	const env = { PATH: process.env.PATH, HOME: join(fx.repoDir, "../../home"), NO_COLOR: "1" };321	Bun.spawnSync(["koh3", "save", "a later save"], { cwd: fx.repoDir, env });322	Bun.spawnSync(["koh3", "offer"], { cwd: fx.repoDir, env });323	await syncAll({324		bucket: openBucket({ kind: "folder", path: fx.bucketDir }),325		kepr: inst.kepr,326		mirrorDir: join(inst.dataDir, "mirror"),327	});328	expect(history(mirror, 1, 0)[0]?.message).toBe("a later save");329330	const browser = new Browser(inst.app);331	await browser.signIn();332	const page = await (await browser.get("/admin/repos")).text();333	expect(page).toContain("1 path quarantined: Icon\\x0d");334});335336test("a strip event with an illegal path is ignored, as Koh ignores it (§5.5)", async () => {337	const fx = await makeKohBucket("illegal-strip");338	for (const [id, path] of [339		["aaaaaaaaaaaa", "src//"],340		["bbbbbbbbbbbb", ""],341		["cccccccccccc", "src\ud800"],342	]) {343		const event = { id, kind: "strip", save_id: "", path, reason: "", created_at: 1, machine: "x" };344		await writeFile(join(fx.bucketDir, fx.repo, "events", id as string), JSON.stringify(event));345	}346	const t = await syncTarget(fx);347	await syncAll(t);348	expect(t.kepr.query("SELECT last_sync_error FROM repos").get()).toEqual({349		last_sync_error: null,350	});351	expect(filesAt(t.mirror(), fx.saveIds[0] as string).map((file) => file.path)).toContain(352		"src/main.ts",353	);354});355356test("an object that inflates past 512 MiB is quarantined without stopping the sync (A3)", async () => {357	const fx = await makeKohBucket("oversized");358	const head = fx.saveIds[0] as string;359	const key = join(fx.bucketDir, fx.repo, "saves", head);360	const record = JSON.parse(await readFile(key, "utf8"));361	const hash = "ab".repeat(32);362	record.files.push({ path: "huge.bin", hash, executable: false });363	await writeFile(key, JSON.stringify(record));364	await writeFile(365		join(fx.bucketDir, fx.repo, "objects", hash),366		deflateSync(new Uint8Array(512 * 1024 * 1024 + 1)),367	);368	const t = await syncTarget(fx);369	await syncAll(t);370	expect(t.kepr.query("SELECT last_sync_error FROM repos").get()).toEqual({371		last_sync_error: null,372	});373	expect(filesAt(t.mirror(), head).map((file) => file.path)).not.toContain("huge.bin");374	expect(JSON.parse(mirrorState(t.mirror(), "quarantined") ?? "[]")).toEqual(["huge.bin"]);375});376377test("the line count skips a file over 8 MiB (A3)", async () => {378	const fx = await makeKohBucket("large-text");379	const t = await syncTarget(fx);380	await syncAll(t);381	const before = repoStats(t.mirror()).lines;382	await writeFile(join(fx.repoDir, "big.txt"), "x\n".repeat(4.5 * 1024 * 1024));383	const env = { PATH: process.env.PATH, HOME: join(fx.repoDir, "../../home"), NO_COLOR: "1" };384	Bun.spawnSync(["koh3", "save", "add a big text file"], { cwd: fx.repoDir, env });385	Bun.spawnSync(["koh3", "offer"], { cwd: fx.repoDir, env });386	await syncAll(t);387	expect(history(t.mirror(), 1, 0)[0]?.message).toBe("add a big text file");388	expect(repoStats(t.mirror()).lines).toBe(before);389});