sync.test.ts

10.0 KB · raw

1import { Database } from "bun:sqlite";2import { beforeAll, describe, expect, test } from "bun:test";3import { cp, mkdir, mkdtemp, writeFile } from "node:fs/promises";4import { tmpdir } from "node:os";5import { join } from "node:path";6import { openBucket } from "../src/bucket/reader.ts";7import { syncAll } from "../src/bucket/sync.ts";8import {9	fileAt,10	filesAt,11	flagReason,12	headId,13	history,14	objectBytes,15	repoStats,16	saveByPrefix,17	timeline,18} from "../src/mirror/queries.ts";19import { openMirror } from "../src/mirror/store.ts";20import { openKeprDb } from "../src/server/db.ts";21import { type Fixture, makeKohBucket } from "./fixture.ts";2223const text = (bytes: Uint8Array | null) => new TextDecoder().decode(bytes ?? new Uint8Array());2425async function syncTarget(fx: Fixture) {26	const dataDir = await mkdtemp(join(tmpdir(), "kepr-data-"));27	const target = {28		bucket: openBucket({ kind: "folder", path: fx.bucketDir }),29		kepr: openKeprDb(dataDir),30		mirrorDir: join(dataDir, "mirror"),31		tarballDir: join(dataDir, "tarballs"),32	};33	return { ...target, mirror: () => openMirror(target.mirrorDir, fx.repo) };34}3536describe("a first sync of a koh3 bucket", () => {37	let fx: Fixture;38	let t: Awaited<ReturnType<typeof syncTarget>>;39	beforeAll(async () => {40		fx = await makeKohBucket();41		t = await syncTarget(fx);42		await syncAll(t);43	});44	const [head, checkpoint, second, first] = [0, 1, 2, 3].map((i) => () => fx.saveIds[i] as string);4546	test("registers the repo private, with a clean last sync", () => {47		const row = t.kepr.query("SELECT public, last_sync_at, last_sync_error FROM repos").get() as {48			public: number;49			last_sync_at: number;50			last_sync_error: string | null;51		};52		expect(row.public).toBe(0);53		expect(row.last_sync_error).toBeNull();54		expect(row.last_sync_at).toBeGreaterThan(0);55	});5657	test("history is newest first and leaves out the checkpoint", () => {58		expect(headId(t.mirror())).toBe(head());59		expect(history(t.mirror(), 10, 0).map((s) => s.id)).toEqual([head(), second(), first()]);60		expect(history(t.mirror(), 1, 1).map((s) => s.message)).toEqual(["add readme and entry point"]);61		expect(timeline(t.mirror()).map((s) => s.id)).toEqual([first(), second(), head()]);62		expect(saveByPrefix(t.mirror(), checkpoint())?.is_checkpoint).toBe(true);63	});6465	test("finds a save by a unique prefix of six or more characters", () => {66		const save = saveByPrefix(t.mirror(), second().slice(0, 6));67		expect([save?.id, save?.agent, save?.session]).toEqual([second(), "test-agent", "fixture"]);68		expect(saveByPrefix(t.mirror(), second().slice(0, 5))).toBeNull();69		expect(saveByPrefix(t.mirror(), "zzzzzz")).toBeNull();70	});7172	test("reads files and their bytes at any save", () => {73		const paths = filesAt(t.mirror(), head()).map((f) => f.path);74		expect(paths).toContain("src/lib/deep/util.ts");75		const now = fileAt(t.mirror(), head(), "src/main.ts");76		const then = fileAt(t.mirror(), second(), "src/main.ts");77		expect(text(objectBytes(t.mirror(), now?.hash as string))).toContain("hello");78		expect(text(objectBytes(t.mirror(), then?.hash as string))).toContain("'hi'");79		expect(fileAt(t.mirror(), first(), "src/main.ts")).toBeNull();80		expect(fileAt(t.mirror(), head(), "docs/logo.png")?.size).toBe(70);81	});8283	test("counts saves without checkpoints, and lines and language at head", () => {84		const stats = repoStats(t.mirror());85		expect(stats.saves).toBe(3);86		expect(stats.language).toBe("TypeScript");87		// README 9 + guide 3 + secret 1 + util 1 + main 2 + .kohignore88		expect(stats.lines).toBeGreaterThan(16);89		// vendor/big.c's 100 lines of C count toward neither.90		expect(stats.lines).toBeLessThan(100);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 new save syncs incrementally and a bad format is recorded as the last sync error", async () => {129	const fx = await makeKohBucket("growing");130	const t = await syncTarget(fx);131	await syncAll(t);132	await writeFile(join(fx.repoDir, "new.txt"), "new\n");133	Bun.spawnSync(["koh3", "save", "add new"], {134		cwd: fx.repoDir,135		env: { PATH: process.env.PATH, HOME: join(fx.repoDir, "../../home"), NO_COLOR: "1" },136	});137	await syncAll(t);138	expect(history(t.mirror(), 1, 0)[0]?.message).toBe("add new");139140	await writeFile(join(fx.bucketDir, fx.repo, "format"), "koh-remote 2");141	await syncAll(t);142	const row = t.kepr.query("SELECT last_sync_error FROM repos").get() as {143		last_sync_error: string;144	};145	expect(row.last_sync_error).toContain('remote format "koh-remote 2"');146});147148test("a whole-history strip in the mirror reaches arriving saves made up to its time only (§10.2)", async () => {149	const fx = await makeKohBucket("late");150	const koh = (...args: string[]) =>151		Bun.spawnSync(["koh3", ...args], {152			cwd: fx.repoDir,153			env: { PATH: process.env.PATH, HOME: join(fx.repoDir, "../../home"), NO_COLOR: "1" },154		});155	// Every save so far holds secret.txt. These pre-strip records stand in for saves another machine156	// made before the strip and offers after it: koh3 rewrites only the records already on the remote.157	const before = await mkdtemp(join(tmpdir(), "kepr-before-"));158	await cp(join(fx.bucketDir, fx.repo), before, { recursive: true });159	expect(await Bun.file(join(before, "saves", fx.saveIds[0] as string)).text()).toContain(160		'"secret.txt"',161	);162	Bun.sleepSync(1100);163	await fx.strip("secret.txt");164	Bun.sleepSync(1100);165	// Strip leaves the working tree alone, so this save holds secret.txt again, made after the strip.166	await writeFile(join(fx.repoDir, "after.txt"), "after\n");167	koh("save", "save again after the strip");168	koh("offer");169	const after = JSON.parse(koh("view", "--json").stdout.toString()).head_id as string;170171	const view = await mkdtemp(join(tmpdir(), "kepr-view-"));172	const viewRepo = join(view, fx.repo);173	await cp(before, viewRepo, { recursive: true });174	await cp(join(fx.bucketDir, fx.repo, "events"), join(viewRepo, "events"), { recursive: true });175	// First sync: the remote as it stood at the first save, plus the strip event.176	await writeFile(join(viewRepo, "head"), fx.saveIds[3] as string);177	const t = await syncTarget({ ...fx, bucketDir: view });178	await syncAll(t);179	expect(t.mirror().query("SELECT count(*) AS n FROM events WHERE kind = 'strip'").get()).toEqual({180		n: 1,181	});182	// Then the later saves arrive: the pre-strip ones as first written, and the one made after.183	await cp(join(fx.bucketDir, fx.repo), viewRepo, { recursive: true });184	for (const id of fx.saveIds.slice(0, 3)) {185		await cp(join(before, "saves", id), join(viewRepo, "saves", id));186	}187	await syncAll(t);188	expect(headId(t.mirror())).toBe(after);189	for (const id of fx.saveIds) expect(fileAt(t.mirror(), id, "secret.txt")).toBeNull();190	expect(fileAt(t.mirror(), after, "secret.txt")).not.toBeNull();191});192193describe("the mirror's schema version", () => {194	test("a version 2 mirror reopens and any other version is refused as disposable", async () => {195		const dir = await mkdtemp(join(tmpdir(), "kepr-mirror-"));196		const created = openMirror(dir, "one");197		created.run("PRAGMA wal_checkpoint(TRUNCATE)");198		await Bun.write(join(dir, "copy.db"), Bun.file(join(dir, "one.db")));199		expect(openMirror(dir, "copy").query("PRAGMA user_version").get()).toEqual({200			user_version: 2,201		});202203		for (const version of [1, 3]) {204			const other = new Database(join(dir, `v${version}.db`));205			other.run(`PRAGMA user_version = ${version}`);206			other.close();207			expect(() => openMirror(dir, `v${version}`)).toThrow("disposable");208		}209	});210});211212test("a strip that arrives older than the saves already mirrored still strips them (§10.2, R6)", async () => {213	const fx = await makeKohBucket("older-strip");214	const t = await syncTarget(fx);215	await syncAll(t);216	// Every save in the mirror holds secret.txt …217	for (const id of fx.saveIds) expect(fileAt(t.mirror(), id, "secret.txt")).not.toBeNull();218219	// … and now a strip arrives whose own time is older than all of them, which is what a220	// strip made on another machine and offered late looks like. The saves already here are221	// reached whatever their time; only saves arriving later answer to the time rule.222	const events = join(fx.bucketDir, fx.repo, "events");223	await mkdir(events, { recursive: true });224	await writeFile(225		join(events, "0000000000aa"),226		JSON.stringify({227			id: "0000000000aa",228			kind: "strip",229			save_id: "",230			path: "secret.txt",231			reason: "a secret got saved",232			created_at: 1,233			machine: "elsewhere",234		}),235	);236	await syncAll(t);237238	for (const id of fx.saveIds) expect(fileAt(t.mirror(), id, "secret.txt")).toBeNull();239	expect(fileAt(t.mirror(), fx.saveIds[0] as string, "src/main.ts")).not.toBeNull();240});