summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 10:41:47 +0900
committerAdam Malczewski <[email protected]>2026-06-01 10:41:47 +0900
commitecce93d9dfecd0c61c0f8c3fa8024f60359e1f17 (patch)
tree81ea46a48b2c67ec3bba74102fd80334c0e63409
parentdbabca525ee70126b3d2264dad88c0dcca630b83 (diff)
downloaddispatch-ecce93d9dfecd0c61c0f8c3fa8024f60359e1f17.tar.gz
dispatch-ecce93d9dfecd0c61c0f8c3fa8024f60359e1f17.zip
feat(frontend): SnapshotSequencer — reusable 'most-recent request wins' race guard
Tiny, dependency-free class for the common pattern where a 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. begin() tags a new request, accept(seq) decides whether to apply the response. Pulled out as its own module (rather than inlined in ClaudeReset) because the next consumer of this pattern shouldn't have to re-derive it. The contract is small enough to test exhaustively in isolation: - accepts the first response unconditionally - accepts responses in send order - rejects an older response that arrives AFTER a newer one (the core race that motivated this) - rejects ALL stragglers once a newer one wins - handles the initial-load vs first-click race - equal seq is idempotent accept (defensive) - begin() seqs are monotonic and unique - state inspector reflects the watermark 8 tests, all green. No Svelte dependency — usable from any TS file.
-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);
+ });
+});