From 291e6c8dd9d9fd770053ff620630e7e21e744914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nathan=20=F0=9F=94=B6=20Tarbert?= <66887028+NathanTarbert@users.noreply.github.com> Date: Sat, 22 Aug 2026 07:27:41 -0400 Subject: [PATCH] fix: give the in-memory snapshot store the guard the table has (#158) createInMemorySnapshotStore.save was a bare Map.set, while the database store expresses only-ever-forward as setWhere: lt(computerSnapshot.snapshotId, values.snapshotId). Two snapshots of one computer can complete out of order inside a single process as easily as across two, so a test reaching for the memory store because it has no database was told a different story about which save wins than the table tells. The guard is >= rather than > to match lt(stored, incoming) exactly: a second delivery of the generation already held carries nothing newer to say. This is part (c) of #158. The insert-path race in part (a) is not fixed here; no generation-keyed guard on that path can be correct, and the reasoning is on the issue. --- server/src/computer/snapshot-store.ts | 7 +++++ server/tests/computer-snapshot-store.test.ts | 29 ++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/server/src/computer/snapshot-store.ts b/server/src/computer/snapshot-store.ts index 157638f5..450c31fc 100644 --- a/server/src/computer/snapshot-store.ts +++ b/server/src/computer/snapshot-store.ts @@ -145,6 +145,13 @@ export function createInMemorySnapshotStore(): SnapshotStore { const snapshots = new Map(); return { save: async (computerId, snapshot) => { + // Only ever forward, the same rule the table's `setWhere` applies, because the two stores have + // to agree about when a save wins. Two snapshots of one computer can complete out of order in a + // single process as easily as across two, and a test that reaches for this store because it has + // no database would otherwise be told a different story about what the gateway resolves + // against: the older page here, the newer one in a deployment. + const held = snapshots.get(computerId); + if (held && held.snapshotId >= snapshot.snapshotId) return; snapshots.set(computerId, snapshot); }, load: async (computerId) => snapshots.get(computerId), diff --git a/server/tests/computer-snapshot-store.test.ts b/server/tests/computer-snapshot-store.test.ts index c0fedd28..3f30d10e 100644 --- a/server/tests/computer-snapshot-store.test.ts +++ b/server/tests/computer-snapshot-store.test.ts @@ -67,6 +67,35 @@ describe("the in-memory snapshot store", () => { expect(loaded?.elements.get("e9")?.name).toBe("Cancel"); }); + test("an older snapshot arriving late does not overwrite the newer one", async () => { + // The property #46 established, asked of this store rather than of the table. A test that reaches + // for the in-memory store because it has no database must not be told a different story about + // when a save wins: two snapshots of one computer can complete out of order here too, and the + // generation is what decides between them in both implementations. + const store = createInMemorySnapshotStore(); + await store.save( + "default", + snapshot(8, [{ ref: "e9", role: "button", name: "Cancel" }]), + ); + + await store.save( + "default", + snapshot(7, [{ ref: "e9", role: "button", name: "Submit order" }]), + ); + + // A save of the generation already held loses too, the way `setWhere`'s `lt` refuses it: the + // stored snapshot is the one that generation named, and a second delivery of it carries nothing + // newer to say. + await store.save( + "default", + snapshot(8, [{ ref: "e9", role: "button", name: "Submit order" }]), + ); + + const loaded = await store.load("default"); + expect(loaded?.snapshotId).toBe(8); + expect(loaded?.elements.get("e9")?.name).toBe("Cancel"); + }); + test("clearing forgets the snapshot, so nothing resolves against a wiped computer", async () => { const store = createInMemorySnapshotStore(); await store.save(