diff options
| -rw-r--r-- | packages/frontend/src/lib/snapshot-sequencer.ts | 47 | ||||
| -rw-r--r-- | packages/frontend/tests/snapshot-sequencer.test.ts | 92 |
2 files changed, 139 insertions, 0 deletions
diff --git a/packages/frontend/src/lib/snapshot-sequencer.ts b/packages/frontend/src/lib/snapshot-sequencer.ts new file mode 100644 index 0000000..fccc9ef --- /dev/null +++ b/packages/frontend/src/lib/snapshot-sequencer.ts @@ -0,0 +1,47 @@ +/** + * Tiny race guard for "the most-recent request wins" semantics. + * + * When a frontend component fans out multiple HTTP calls that each return a + * full snapshot of shared state — and applying an older snapshot would clobber + * a newer one — wrap each call with `seq = sequencer.begin()` before send and + * `sequencer.accept(seq)` before applying the response. Older sequences are + * rejected. + * + * Why: the Claude Wake Schedule's POST /toggle and GET /wake-schedule both + * return the *whole* schedule. If a user toggles hour 9 (request A) and then + * hour 10 (request B), and B's response arrives before A's, the older A + * response — which doesn't know about hour 10 yet — would otherwise overwrite + * hour 10 right out of the UI. A per-hour counter is NOT enough because the + * race spans different hours (and also covers the initial-load vs first-click + * race). + * + * `>=` on accept is intentional: if seq equals the latest applied seq, the + * response is a redundant arrival of the most-recent winner — accepting it + * (idempotently) is fine. The discriminator is *strictly less than*. + */ +export class SnapshotSequencer { + private nextSeq = 0; + private latestApplied = 0; + + /** Tag a new request. Call before sending; pass the returned seq to accept(). */ + begin(): number { + this.nextSeq += 1; + return this.nextSeq; + } + + /** + * Decide whether to apply a response. Returns true if this seq is the + * newest seen so far (and updates the watermark); false if a newer + * response has already won. + */ + accept(seq: number): boolean { + if (seq < this.latestApplied) return false; + this.latestApplied = seq; + return true; + } + + /** Inspect (for tests / debugging). */ + get state(): { nextSeq: number; latestApplied: number } { + return { nextSeq: this.nextSeq, latestApplied: this.latestApplied }; + } +} diff --git a/packages/frontend/tests/snapshot-sequencer.test.ts b/packages/frontend/tests/snapshot-sequencer.test.ts new file mode 100644 index 0000000..f2c5b8e --- /dev/null +++ b/packages/frontend/tests/snapshot-sequencer.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { SnapshotSequencer } from "../src/lib/snapshot-sequencer.js"; + +describe("SnapshotSequencer", () => { + it("accepts the first response unconditionally", () => { + const s = new SnapshotSequencer(); + const seq = s.begin(); + expect(s.accept(seq)).toBe(true); + }); + + it("accepts responses in send order", () => { + const s = new SnapshotSequencer(); + const a = s.begin(); + const b = s.begin(); + const c = s.begin(); + expect(s.accept(a)).toBe(true); + expect(s.accept(b)).toBe(true); + expect(s.accept(c)).toBe(true); + }); + + it("rejects an older response that arrives AFTER a newer one (the core race)", () => { + // Sequence: user clicks hour 9 (A), then hour 10 (B). B arrives first. + const s = new SnapshotSequencer(); + const a = s.begin(); // toggle hour 9 + const b = s.begin(); // toggle hour 10 + + // B arrives first — applied. + expect(s.accept(b)).toBe(true); + + // A arrives later — must be dropped, else the snapshot from B (which + // knows about both 9 and 10) gets overwritten by A's stale snapshot + // (which only knows about 9), and hour 10 vanishes from the UI. + expect(s.accept(a)).toBe(false); + }); + + it("rejects ALL straggler responses once a newer one wins", () => { + const s = new SnapshotSequencer(); + const a = s.begin(); + const b = s.begin(); + const c = s.begin(); + expect(s.accept(c)).toBe(true); + expect(s.accept(b)).toBe(false); + expect(s.accept(a)).toBe(false); + }); + + it("handles the initial-load vs first-click race", () => { + // On mount: $effect fires loadFromServer (seq=1). + // Before it lands, user clicks a hour (seq=2). + const s = new SnapshotSequencer(); + const initial = s.begin(); + const click = s.begin(); + + // Click response arrives first — applied. + expect(s.accept(click)).toBe(true); + // Initial load straggles in — must be dropped (it pre-dates the click). + expect(s.accept(initial)).toBe(false); + }); + + it("treats an equal seq as accept (idempotent re-arrival of the winner)", () => { + const s = new SnapshotSequencer(); + const a = s.begin(); + expect(s.accept(a)).toBe(true); + // Defensive: same seq accepted again (shouldn't happen in practice + // but the semantics must be 'no-op accept', not 'reject'). + expect(s.accept(a)).toBe(true); + }); + + it("seq numbers are monotonic and unique across begin() calls", () => { + const s = new SnapshotSequencer(); + const seen = new Set<number>(); + let prev = 0; + for (let i = 0; i < 100; i++) { + const seq = s.begin(); + expect(seq).toBeGreaterThan(prev); + expect(seen.has(seq)).toBe(false); + seen.add(seq); + prev = seq; + } + }); + + it("state inspector reflects last-applied watermark", () => { + const s = new SnapshotSequencer(); + expect(s.state).toEqual({ nextSeq: 0, latestApplied: 0 }); + const a = s.begin(); + const b = s.begin(); + s.accept(b); + expect(s.state).toEqual({ nextSeq: 2, latestApplied: b }); + // A is too old now — accept() returns false and watermark doesn't move back. + expect(s.accept(a)).toBe(false); + expect(s.state.latestApplied).toBe(b); + }); +}); |
