654b34548308

strip every save in the mirror, whatever its time, so Kepr and Koh agree that a strip bans its path from all of history (v3.0.76)

time
2026-09-28 05:50 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
strip removes a path for good
parent
1be3f9f00948
3 files package.json+1 −1src/mirror/store.ts+8 −28tests/sync.test.ts+28 −38
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.75",3+	"version": "3.0.76",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/mirror/store.ts+8 −28
@@ -158,7 +158,7 @@158158 	return new Set(rows.map((row) => row.version));159159 }160160 161-/** Lands a sync like Koh's steal: objects, events, existing-save strips, saves, then all strips. */161+/** Lands a sync like Koh's steal: objects, events, saves, then every strip over every save. */162162 export function land(db: Database, landing: Landing): void {163163 	const putObject = db.query("INSERT OR IGNORE INTO objects (hash, size, data) VALUES (?, ?, ?)");164164 	const putEvent = db.query(@@ -180,7 +180,6 @@180180 		for (const e of landing.events) {181181 			putEvent.run(e.id, e.kind, e.save_id, e.path, e.reason, e.created_at, e.machine);182182 		}183-		applyNewStrips(db, landing.events);184183 		for (const s of landing.saves) {185184 			putSave.run(186185 				s.id,@@ -205,44 +204,25 @@205204 	})();206205 }207206 208-function applyNewStrips(db: Database, events: EventRecord[]): void {209-	const strips = events.filter((event) => event.kind === "strip");210-	if (strips.length === 0) return;211-	const remove = db.query(212-		`DELETE FROM save_files213-		WHERE (path = $path OR substr(path, 1, length($dir)) = $dir)214-		AND save_id IN (215-			SELECT id FROM saves216-			WHERE CASE WHEN $save = '' THEN 1 ELSE id = $save END217-		)`,218-	);219-	for (const strip of strips) {220-		const path = strip.path ?? "";221-		const dir = `${path.replace(/\/+$/, "")}/`;222-		remove.run({ path, dir, save: strip.save_id });223-	}224-}225-226-// §10.2: a strip of one save reaches that save; a strip of every save reaches the saves made up to227-// and including its own time, so a file saved again after the strip survives. Re-applying every228-// strip is harmless, which lets a save that arrives after its strip event be covered too.207+// §10.2: a strip of one save reaches that save; a strip of every save bans its path from all of208+// history, whenever a save was made or arrives. Re-applying every strip is harmless, which is how a209+// save that arrives after its strip event is covered too.229210 function applyStrips(db: Database): void {230211 	const strips = db231-		.query<{ save_id: string; path: string; created_at: number }, []>(232-			"SELECT save_id, path, created_at FROM events WHERE kind = 'strip'",212+		.query<{ save_id: string; path: string }, []>(213+			"SELECT save_id, path FROM events WHERE kind = 'strip'",233214 		)234215 		.all();235216 	if (strips.length === 0) return;236217 	const remove = db.query(237218 		`DELETE FROM save_files238219 		WHERE (path = $path OR substr(path, 1, length($dir)) = $dir)239-		AND save_id IN (SELECT id FROM saves WHERE240-			CASE WHEN $save = '' THEN created_at <= $at ELSE id = $save END)`,220+		AND ($save = '' OR save_id = $save)`,241221 	);242222 	for (const strip of strips) {243223 		// Koh compares the prefix exactly, never with LIKE, so `_` and `%` in a path match only themselves.244224 		const dir = `${strip.path.replace(/\/+$/, "")}/`;245-		remove.run({ path: strip.path, dir, save: strip.save_id, at: strip.created_at });225+		remove.run({ path: strip.path, dir, save: strip.save_id });246226 	}247227 	db.run("DELETE FROM objects WHERE hash NOT IN (SELECT hash FROM save_files)");248228 }

view file

tests/sync.test.ts+28 −38
@@ -145,49 +145,39 @@145145 	expect(row.last_sync_error).toContain('remote format "koh-remote 2"');146146 });147147 148-test("a whole-history strip in the mirror reaches arriving saves made up to its time only (§10.2)", async () => {148+test("a whole-history strip reaches every save, those arriving after it too, whatever their time (§10.2)", async () => {149149 	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;170-171150 	const view = await mkdtemp(join(tmpdir(), "kepr-view-"));172151 	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);152+	await cp(join(fx.bucketDir, fx.repo), viewRepo, { recursive: true });153+	// A strip older than every save here: a strip bans its path from all of history, so no save154+	// keeps it, however much later it was made or arrives.155+	await mkdir(join(viewRepo, "events"), { recursive: true });156+	await writeFile(157+		join(viewRepo, "events", "0000000000bb"),158+		JSON.stringify({159+			id: "0000000000bb",160+			kind: "strip",161+			save_id: "",162+			path: "secret.txt",163+			reason: "a secret got saved",164+			created_at: 1,165+			machine: "elsewhere",166+		}),167+	);168+	const oldest = fx.saveIds.at(-1) as string;169+	const newest = fx.saveIds[0] as string;170+	// First sync: the strip and the oldest save only.171+	await writeFile(join(viewRepo, "head"), oldest);177172 	const t = await syncTarget({ ...fx, bucketDir: view });178173 	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-	}174+	expect(fileAt(t.mirror(), oldest, "secret.txt")).toBeNull();175+	// Then the rest arrive, each made long after the strip.176+	await writeFile(join(viewRepo, "head"), newest);187177 	await syncAll(t);188-	expect(headId(t.mirror())).toBe(after);178+	expect(headId(t.mirror())).toBe(newest);189179 	for (const id of fx.saveIds) expect(fileAt(t.mirror(), id, "secret.txt")).toBeNull();190-	expect(fileAt(t.mirror(), after, "secret.txt")).not.toBeNull();180+	expect(fileAt(t.mirror(), newest, "src/main.ts")).not.toBeNull();191181 });192182 193183 describe("the mirror's schema version", () => {@@ -217,8 +207,8 @@217207 	for (const id of fx.saveIds) expect(fileAt(t.mirror(), id, "secret.txt")).not.toBeNull();218208 219209 	// … 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.210+	// strip made on another machine and offered late looks like. Every save is reached, whatever211+	// its time.222212 	const events = join(fx.bucketDir, fx.repo, "events");223213 	await mkdir(events, { recursive: true });224214 	await writeFile(

view file