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
↓ kepr-654b3454.tar.gzatlas at this save
files changed3 files
package.json+1 −1src/mirror/store.ts+8 −28tests/sync.test.ts+28 −38package.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": {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 }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(