summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/frontend/src/lib/snapshot-sequencer.ts47
-rw-r--r--packages/frontend/tests/snapshot-sequencer.test.ts92
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);
+ });
+});