From b9cb5297626059d0c9eff7861080daadf8e7e1fd Mon Sep 17 00:00:00 2001 From: Jack Singer Date: Thu, 20 Aug 2026 14:21:06 -0500 Subject: [PATCH 1/3] feat(live): optimistic atomic increment on the useQuery hook Add `useQuery().increment(id, field, by?)` so counters feel instant without losing atomicity. It bumps the field on the row immediately via the optimistic overlay, writes atomically as an SQL `$inc` (so simultaneous clicks cannot clobber each other), then reads the committed row back before retiring the overlay, so the number never dips between the bump and the server value. Rolls back on failure and surfaces the error on the snapshot; never throws. This removes the reason generated counter code hand-wired a local bump plus a full refetch (which flickered when the refetch raced the change broadcast) and a `working` guard that dropped fast taps. Rapid taps now stack on the overlay and each lands as its own atomic `$inc`. Version bumped to 0.6.0. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 25 ++++++++++++++ package.json | 2 +- src/entities.ts | 6 ++++ src/live.test.ts | 87 +++++++++++++++++++++++++++++++++++++++++++++++- src/live.ts | 49 +++++++++++++++++++++++++++ src/react.tsx | 1 + 6 files changed, 168 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ec77d4..19395b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 0.6.0 + +Adds an optimistic atomic increment to the live query hook, so counters feel +instant without giving up atomic safety. + +- **New: `bool.entities..useQuery().increment(id, field, by?)`** (`by` + defaults to 1). It bumps the field on the row in the same frame as the tap, + performs the write as an atomic SQL `$inc` so two people incrementing at once + cannot lose each other's clicks, and then reads the committed row back before + retiring the overlay, so the digit never dips between the optimistic bump and + the server value. A failed write rolls the bump back and surfaces the error on + the snapshot; it never throws. Resolves to the committed row, or null on + failure. + + This replaces the pattern generated counter code kept reaching for: a manual + local bump reconciled against a full `refetch()`, which flickered when the + refetch raced the change broadcast, plus a `working` guard that dropped fast + taps. `increment` needs neither. Rapid taps stack on the overlay and each + lands as its own atomic `$inc`. + + For counters, likes, votes, and stock decrements, reach for `increment` + instead of `update(id, { count: current + 1 })` (a read-modify-write that + loses concurrent changes) or the one-off `updateMany({ id }, { $inc: {...} })` + (atomic, but not optimistic and not on the hook). + ## 0.5.0 Types every error the AI plane can return, and normalizes the one field whose diff --git a/package.json b/package.json index 4862148..8ca0dd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.5.0", + "version": "0.6.0", "description": "Client SDK for apps built on Bool \u2014 gateway data access, end-user auth, the AI battery, the React auth layer, and the local-dev CLI (link, types, deploy).", "type": "module", "main": "./dist/index.js", diff --git a/src/entities.ts b/src/entities.ts index e790712..58b2c59 100644 --- a/src/entities.ts +++ b/src/entities.ts @@ -155,6 +155,12 @@ export type EntityQueryResult = { create: (fields: Partial) => Promise; /** Optimistic patch. Resolves to the committed row, or null on failure. */ update: (id: string, fields: Partial) => Promise; + /** Optimistic ATOMIC increment of one numeric field (`by` defaults to 1). + * The number moves instantly, the write adds in SQL so simultaneous clicks + * can't clobber each other, and it settles from the server. Resolves to the + * committed row, or null on failure. Use this for counters/likes/votes/stock + * instead of read-then-write or a manual bump-and-refetch. */ + increment: (id: string, field: string, by?: number) => Promise; /** Optimistic delete. Resolves false on failure (row restored). */ remove: (id: string) => Promise; /** Force a full reload (rarely needed — changes arrive on their own). */ diff --git a/src/live.test.ts b/src/live.test.ts index abf71f7..0da99db 100644 --- a/src/live.test.ts +++ b/src/live.test.ts @@ -28,14 +28,23 @@ function deferred() { function makeHarness(initial: Row[] = []) { const rows = new Map(initial.map((r) => [r.id, { ...r }])); const listeners = new Set<(p: BoolChangePayload) => void>(); - const calls: { list: number; filter: FilterQuery[]; creates: Partial[] } = { + const calls: { + list: number; + filter: FilterQuery[]; + creates: Partial[]; + updateMany: FilterQuery[]; + } = { list: 0, filter: [], creates: [], + updateMany: [], }; // When set, the next list/filter call parks on this deferred instead of // resolving immediately (then clears, so later calls auto-resolve). let gate: ReturnType> | null = null; + // Same idea for updateMany, so a test can hold the atomic write in flight and + // assert the optimistic overlay is already showing. + let updateManyGate: ReturnType> | null = null; const visible = () => [...rows.values()].map((r) => ({ ...r })); const handler = { @@ -75,6 +84,25 @@ function makeHarness(initial: Row[] = []) { rows.set(id, row); return { ...row }; }, + async updateMany(q: FilterQuery, ops: Record) { + calls.updateMany.push(q); + if (updateManyGate) { + const g = updateManyGate; + updateManyGate = null; + await g.promise; + } + const ids = (q.id as string[]) ?? []; + const inc = (ops.$inc ?? {}) as Record; + for (const id of ids) { + const r = rows.get(id); + if (!r) continue; + for (const [f, d] of Object.entries(inc)) { + (r as Record)[f] = Number((r as Record)[f] ?? 0) + d; + } + rows.set(id, r); + } + return { success: true, updated: ids.length, has_more: false }; + }, async delete(id: string) { rows.delete(id); return { success: true }; @@ -96,6 +124,10 @@ function makeHarness(initial: Row[] = []) { gate = deferred(); return gate; }, + gateNextUpdateMany() { + updateManyGate = deferred(); + return updateManyGate; + }, }; } @@ -523,6 +555,59 @@ describe("LiveEntityStore: optimistic mutations", () => { stop(); }); + test("increment bumps the field instantly and settles without double-counting", async () => { + const h = makeHarness([{ id: "1", rank: 5 }]); + const store = new LiveEntityStore(h.handler); + const stop = store.start(); + await tick(); + + const gate = h.gateNextUpdateMany(); + const p = store.increment("1", "rank", 1); + // Optimistic: the number moved in the same frame, before the write settled. + expect(store.getSnapshot().data[0]!.rank).toBe(6); + + gate.resolve(); + const settled = await p; + // Settled from the read-back: the overlay retired without adding a 2nd time. + expect(settled?.rank).toBe(6); + expect(store.getSnapshot().data[0]!.rank).toBe(6); + // Went through the atomic path, scoped to this id. + expect(h.calls.updateMany.at(-1)).toEqual({ id: ["1"] }); + stop(); + }); + + test("increment stacks rapid taps optimistically and lands on the summed total", async () => { + const h = makeHarness([{ id: "1", rank: 0 }]); + const store = new LiveEntityStore(h.handler); + const stop = store.start(); + await tick(); + + // Three taps, none awaited between, so the overlay shows the running sum with + // no throttle, and each write is an atomic $inc so none clobbers another. + const taps = [store.increment("1", "rank"), store.increment("1", "rank"), store.increment("1", "rank")]; + expect(store.getSnapshot().data[0]!.rank).toBe(3); // instant, cumulative + await Promise.all(taps); + expect(store.getSnapshot().data[0]!.rank).toBe(3); // atomic writes summed + stop(); + }); + + test("increment rolls back the bump and surfaces the error when the write fails", async () => { + const h = makeHarness([{ id: "1", rank: 5 }]); + const store = new LiveEntityStore(h.handler); + const stop = store.start(); + await tick(); + + h.handler.updateMany = (async () => { + throw new Error("increment failed"); + }) as typeof h.handler.updateMany; + + const result = await store.increment("1", "rank", 1); + expect(result).toBeNull(); + expect(store.getSnapshot().data[0]!.rank).toBe(5); // rolled back to committed + expect((store.getSnapshot().error as Error).message).toBe("increment failed"); + stop(); + }); + test("the doorbell echo of your own write reconciles to a no-op (no duplicates)", async () => { const h = makeHarness(); const store = new LiveEntityStore(h.handler); diff --git a/src/live.ts b/src/live.ts index b424957..cd1adfa 100644 --- a/src/live.ts +++ b/src/live.ts @@ -169,6 +169,7 @@ export function matchesFilter(row: Record, query: FilterQuery): type PendingOp = | { kind: "create"; id: string; row: Partial } | { kind: "update"; id: string; patch: Partial } + | { kind: "increment"; id: string; inc: Record } | { kind: "remove"; id: string }; /** Generate a client-side row id (the optimistic-UI cornerstone: ONE id shared @@ -313,6 +314,42 @@ export class LiveEntityStore { } } + /** Optimistic ATOMIC increment of one numeric field (`by` defaults to 1). + * + * The overlay bumps the field by `by` immediately, so the number moves in the + * same frame as the tap, with no read-modify-write and no wait for the server + * echo. The write itself is the atomic `$inc` (`col = col + n` in SQL), so two + * people incrementing at the same instant can't lose each other's clicks. Once + * it commits we read the row back BEFORE retiring the overlay: that read is + * ordered after our committed write, so it already includes this increment + * (and any concurrent ones), and swapping overlay for committed in one emit + * means the digit never dips in between. A failed write drops the overlay + * (rolls back) and surfaces the error on the snapshot; it never throws. + * + * This is the ONE counter pattern hand-rolled app code kept getting wrong: an + * optimistic bump reconciled against a full refetch flickered when the refetch + * raced the change broadcast. Owning it here is what makes counters feel + * instant without giving up atomicity. */ + async increment(id: string, field: string, by = 1): Promise { + const op: PendingOp = { kind: "increment", id, inc: { [field]: by } }; + this.pending.push(op); + this.emit(); + try { + await this.handler.updateMany({ id: [id] } as FilterQuery, { $inc: { [field]: by } }); + const rows = await this.handler.filter({ id: [id] } as FilterQuery, undefined, 1); + if (rows[0]) this.server.set(id, rows[0]); + else this.server.delete(id); // removed while our increment was in flight + this.snapshot = { ...this.snapshot, error: null }; + return rows[0] ?? null; + } catch (error) { + this.snapshot = { ...this.snapshot, error }; + return null; + } finally { + this.pending = this.pending.filter((p) => p !== op); + this.emit(); + } + } + // ---- loading & reconciling ------------------------------------------------ private async load(): Promise { @@ -426,6 +463,18 @@ export class LiveEntityStore { } else if (op.kind === "update") { const base = byId.get(op.id); if (base) byId.set(op.id, { ...base, ...op.patch }); + } else if (op.kind === "increment") { + // Bump the numeric field(s) on the committed row. Only when the row is + // present. Incrementing something not yet loaded is a no-op overlay, + // the same way an update to an unknown id is. + const base = byId.get(op.id); + if (base) { + const next = { ...base } as Record; + for (const [f, d] of Object.entries(op.inc)) { + next[f] = Number(base[f as keyof T] ?? 0) + d; + } + byId.set(op.id, next as T); + } } else { byId.delete(op.id); } diff --git a/src/react.tsx b/src/react.tsx index a5852c7..9139ecd 100644 --- a/src/react.tsx +++ b/src/react.tsx @@ -366,6 +366,7 @@ function useEntityHandler( error: snap.error, create: (fields) => store.create(fields), update: (id, fields) => store.update(id, fields), + increment: (id, field, by) => store.increment(id, field, by), remove: (id) => store.remove(id), refetch: () => store.refetch(), }; From 93744d740893bfa1ff54342f9eca2c937e944ca7 Mon Sep 17 00:00:00 2001 From: Jack Singer Date: Thu, 20 Aug 2026 15:23:37 -0500 Subject: [PATCH 2/3] fix(live): make the optimistic increment overlay absolute, not a delta A `+by` delta overlay double-counts against the private doorbell's echo of your own write. The row-bearing ding lands on `server` with the committed value (which already counts the write) while the overlay is still up, so the delta adds on top: 0 -> optimistic 1 -> ding sets server 1, delta still +1 -> 2 -> overlay retires -> 1. The digit visibly bounces, and worse the faster you tap (each in-flight delta stacks another echo). Caught live on a real counter. Store the ABSOLUTE optimistic value (current + by) and SET the field in emit, exactly like update's overlay, so it stays idempotent with the echo and the read-back. `current` comes from the live snapshot, so rapid unsettled taps still stack correctly. New test pins that a mid-flight echo does not double-count. Co-Authored-By: Claude Opus 4.8 --- src/live.test.ts | 25 +++++++++++++++++++++++++ src/live.ts | 48 ++++++++++++++++++++++++------------------------ 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/src/live.test.ts b/src/live.test.ts index 0da99db..a4f6de8 100644 --- a/src/live.test.ts +++ b/src/live.test.ts @@ -608,6 +608,31 @@ describe("LiveEntityStore: optimistic mutations", () => { stop(); }); + test("increment does not double-count when the doorbell echoes the write mid-flight", async () => { + const h = makeHarness([{ id: "1", rank: 0 }]); + const store = new LiveEntityStore(h.handler); + const stop = store.start(); + await tick(); + + const gate = h.gateNextUpdateMany(); + const p = store.increment("1", "rank", 1); + expect(store.getSnapshot().data[0]!.rank).toBe(1); // optimistic, same frame + + // The private doorbell echoes our own committed write as a row-bearing ding + // (rank already 1) while the optimistic overlay is still up. A +1 delta + // overlay would land ON TOP -> 2 (the fast-tap bounce Jack hit). The absolute + // overlay SETS the field, so it stays 1. + h.ding({ table: "t", op: "UPDATE", id: "1", row: { id: "1", rank: 1 } }); + await settle(); + expect(store.getSnapshot().data[0]!.rank).toBe(1); // NOT 2 + + gate.resolve(); + await p; + expect(store.getSnapshot().data[0]!.rank).toBe(1); + stop(); + }); + + test("the doorbell echo of your own write reconciles to a no-op (no duplicates)", async () => { const h = makeHarness(); const store = new LiveEntityStore(h.handler); diff --git a/src/live.ts b/src/live.ts index cd1adfa..da5d364 100644 --- a/src/live.ts +++ b/src/live.ts @@ -169,7 +169,7 @@ export function matchesFilter(row: Record, query: FilterQuery): type PendingOp = | { kind: "create"; id: string; row: Partial } | { kind: "update"; id: string; patch: Partial } - | { kind: "increment"; id: string; inc: Record } + | { kind: "increment"; id: string; field: string; value: number } | { kind: "remove"; id: string }; /** Generate a client-side row id (the optimistic-UI cornerstone: ONE id shared @@ -316,22 +316,27 @@ export class LiveEntityStore { /** Optimistic ATOMIC increment of one numeric field (`by` defaults to 1). * - * The overlay bumps the field by `by` immediately, so the number moves in the - * same frame as the tap, with no read-modify-write and no wait for the server - * echo. The write itself is the atomic `$inc` (`col = col + n` in SQL), so two - * people incrementing at the same instant can't lose each other's clicks. Once - * it commits we read the row back BEFORE retiring the overlay: that read is - * ordered after our committed write, so it already includes this increment - * (and any concurrent ones), and swapping overlay for committed in one emit - * means the digit never dips in between. A failed write drops the overlay + * The number moves in the same frame as the tap, and the write itself is the + * atomic `$inc` (`col = col + n` in SQL), so two people incrementing at the + * same instant can't lose each other's clicks. Once it commits we read the row + * back before retiring the overlay, so the digit never dips to a stale value + * between the tap and the server value. A failed write drops the overlay * (rolls back) and surfaces the error on the snapshot; it never throws. * - * This is the ONE counter pattern hand-rolled app code kept getting wrong: an - * optimistic bump reconciled against a full refetch flickered when the refetch - * raced the change broadcast. Owning it here is what makes counters feel - * instant without giving up atomicity. */ + * The overlay stores the ABSOLUTE optimistic value (current + by), not a `+by` + * delta, and this is load-bearing. The private doorbell echoes our own write + * back as a row-bearing ding that lands on `server` while the overlay is still + * up. A delta would then add ON TOP of a committed row that already counts our + * write (0 -> optimistic 1 -> ding sets server 1, delta still +1 -> 2 -> drop + * overlay -> 1): the digit visibly bounces, worse the faster you tap. An + * absolute overlay SETS the field, so it stays idempotent with the echo (and + * with the read-back), exactly like `update`'s overlay. `current` is read from + * the live snapshot, so rapid taps that haven't settled stack correctly. */ async increment(id: string, field: string, by = 1): Promise { - const op: PendingOp = { kind: "increment", id, inc: { [field]: by } }; + const current = this.snapshot.data.find((r) => r.id === id) ?? this.server.get(id); + const value = + Number((current as Record | undefined)?.[field] ?? 0) + by; + const op: PendingOp = { kind: "increment", id, field, value }; this.pending.push(op); this.emit(); try { @@ -464,17 +469,12 @@ export class LiveEntityStore { const base = byId.get(op.id); if (base) byId.set(op.id, { ...base, ...op.patch }); } else if (op.kind === "increment") { - // Bump the numeric field(s) on the committed row. Only when the row is - // present. Incrementing something not yet loaded is a no-op overlay, - // the same way an update to an unknown id is. + // SET the field to the optimistic ABSOLUTE value (not add a delta), so + // the doorbell echo of our own write can't double-count. Only when the + // row is present; incrementing something not yet loaded is a no-op + // overlay, the same way an update to an unknown id is. const base = byId.get(op.id); - if (base) { - const next = { ...base } as Record; - for (const [f, d] of Object.entries(op.inc)) { - next[f] = Number(base[f as keyof T] ?? 0) + d; - } - byId.set(op.id, next as T); - } + if (base) byId.set(op.id, { ...base, [op.field]: op.value } as T); } else { byId.delete(op.id); } From 62225b47cb8ba1a5363fb6502717dbc567729b53 Mon Sep 17 00:00:00 2001 From: Jack Singer Date: Thu, 20 Aug 2026 16:45:07 -0500 Subject: [PATCH 3/3] harden increment against read-back races and blips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two concurrency fixes to the 0.6.0 optimistic increment, both self-healing before but visible in the feature's headline case (fast taps on a counter): - Stamp each tap with a per-id sequence and let only the newest tap's settle read-back write `server`. An earlier tap's read-back can execute early (reading a stale count) yet have its response land after a later tap's, which previously set the digit back to the stale value until the next doorbell ding. - Split the atomic write from the settle read-back. The write is authoritative: once it commits we never roll back. Only a failed WRITE drops the overlay and surfaces an error. A committed write whose read-back then blips offline no longer reports a spurious failure (the old code set the error and returned null, which an app reads as "the save failed") — the increment is durable and the row-bearing echo settles the value. Also correct the stale UpdateOps doc: $inc/$mul are atomic via bool_apply_numeric now, not read-modify-write. Two regression tests pin the out-of-order read-back and the read-back-failure paths. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 ++++ src/entities.ts | 9 ++++-- src/live.test.ts | 78 ++++++++++++++++++++++++++++++++++++++++++++++++ src/live.ts | 48 ++++++++++++++++++++++++----- 4 files changed, 131 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19395b7..64a306b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ instant without giving up atomic safety. the snapshot; it never throws. Resolves to the committed row, or null on failure. + Rapid taps are safe against their read-backs racing: each tap is stamped, and + only the newest one's read-back may settle `server`, so a stale response + landing late cannot rewind the count. And a write that commits but whose + read-back then blips offline is not rolled back or reported as an error — the + increment is durable and the doorbell echo settles the value. + This replaces the pattern generated counter code kept reaching for: a manual local bump reconciled against a full `refetch()`, which flickered when the refetch raced the change broadcast, plus a `working` guard that dropped fast diff --git a/src/entities.ts b/src/entities.ts index 58b2c59..e938367 100644 --- a/src/entities.ts +++ b/src/entities.ts @@ -62,9 +62,12 @@ export type FilterQuery = { $nor?: FilterQuery[]; }; -/** MongoDB-style update operators. `$set` is applied as one atomic PATCH; the - * others (`$inc`/`$mul`/`$push`/`$pull`/`$unset`) are applied read-modify-write - * (see updateMany docs — not atomic under concurrent writers). */ +/** MongoDB-style update operators. `$set`/`$unset` apply as one atomic PATCH, + * and `$inc`/`$mul` are atomic too — done in SQL (`col = col + n`) via the + * per-schema `bool_apply_numeric` function, so concurrent writers can't lose + * each other's arithmetic (older schemas without the function fall back to + * read-modify-write). Only the array operators (`$push`/`$pull`) are + * read-modify-write and not atomic under concurrent writers. See updateMany. */ export type UpdateOps = Partial<{ $set: Record; $inc: Record; diff --git a/src/live.test.ts b/src/live.test.ts index a4f6de8..ee65956 100644 --- a/src/live.test.ts +++ b/src/live.test.ts @@ -632,6 +632,84 @@ describe("LiveEntityStore: optimistic mutations", () => { stop(); }); + test("a stale read-back landing after a newer tap does not rewind the count", async () => { + // Two rapid taps. The atomic writes commit in order (0 -> 1 -> 2), but the + // settle read-backs race: the NEWER tap's read-back (value 2) lands first, + // then the OLDER tap's stale read-back (value 1) lands late. Without the + // per-id seq guard the late stale response would set `server` back to 1; + // the guard drops it so the digit holds at 2. + const filters: Array>> = []; + let committed = 0; + const handler = { + async list() { + return [{ id: "1", rank: 0 }]; + }, + async filter() { + const d = deferred(); + filters.push(d); + return d.promise; + }, + async updateMany(_q: FilterQuery, ops: Record) { + committed += Number(ops.$inc?.rank ?? 0); + return { success: true, updated: 1, has_more: false }; + }, + subscribe() { + return () => {}; + }, + } as unknown as EntityHandler; + + const store = new LiveEntityStore(handler); + const stop = store.start(); + await tick(); + + const p1 = store.increment("1", "rank", 1); // seq 1, commits -> 1 + const p2 = store.increment("1", "rank", 1); // seq 2, commits -> 2 + await tick(); // both writes settled; both read-backs now parked, in order + expect(filters.length).toBe(2); + expect(committed).toBe(2); + + filters[1]!.resolve([{ id: "1", rank: 2 }]); // newer tap's read-back, fresh + filters[0]!.resolve([{ id: "1", rank: 1 }]); // older tap's read-back, STALE + await Promise.all([p1, p2]); + + expect(store.getSnapshot().data[0]!.rank).toBe(2); // not rewound to 1 + stop(); + }); + + test("a read-back that fails after the write committed does not roll back or error", async () => { + // The atomic write commits, then the settle read-back blips offline. The + // increment is durable, so we must NOT surface an error or signal failure + // as if the write were lost — the doorbell echo will deliver the true value. + const handler = { + async list() { + return [{ id: "1", rank: 5 }]; + }, + async filter() { + throw new Error("read-back offline"); + }, + async updateMany() { + return { success: true, updated: 1, has_more: false }; + }, + subscribe() { + return () => {}; + }, + } as unknown as EntityHandler; + + const store = new LiveEntityStore(handler); + const stop = store.start(); + await tick(); + + const result = await store.increment("1", "rank", 1); + // No spurious error on a write that actually succeeded (the load-bearing + // fix: the old code surfaced the read-back's error and returned null here, + // which reads to the app as "the save failed"). + expect(store.getSnapshot().error).toBeNull(); + // The overlay retired to the last committed value we know (5) rather than + // dropping below it; the echo settles it to 6. It never throws. + expect(store.getSnapshot().data[0]!.rank).toBe(5); + expect(result).toBeNull(); + stop(); + }); test("the doorbell echo of your own write reconciles to a no-op (no duplicates)", async () => { const h = makeHarness(); diff --git a/src/live.ts b/src/live.ts index da5d364..5eac9c1 100644 --- a/src/live.ts +++ b/src/live.ts @@ -197,6 +197,11 @@ export class LiveEntityStore { /** Monotonic full-load counter — the anti-rewind guard. Only the response to * the NEWEST load may apply; anything else is a stale answer arriving late. */ private loadSeq = 0; + /** Per-id monotonic increment counter — the same anti-rewind guard, but for + * an increment's settle read-back: only the NEWEST tap's read-back may write + * `server`, so an earlier tap's read-back landing late can't rewind the count + * to a stale value. Grows one small entry per row ever incremented. */ + private incSeq = new Map(); private unsubscribe: (() => void) | null = null; constructor( @@ -320,8 +325,10 @@ export class LiveEntityStore { * atomic `$inc` (`col = col + n` in SQL), so two people incrementing at the * same instant can't lose each other's clicks. Once it commits we read the row * back before retiring the overlay, so the digit never dips to a stale value - * between the tap and the server value. A failed write drops the overlay - * (rolls back) and surfaces the error on the snapshot; it never throws. + * between the tap and the server value. A failed WRITE drops the overlay + * (rolls back) and surfaces the error on the snapshot; it never throws. A write + * that commits but whose settle read-back then fails is NOT rolled back — the + * increment is durable, and the row-bearing doorbell echo settles the value. * * The overlay stores the ABSOLUTE optimistic value (current + by), not a `+by` * delta, and this is load-bearing. The private doorbell echoes our own write @@ -337,22 +344,49 @@ export class LiveEntityStore { const value = Number((current as Record | undefined)?.[field] ?? 0) + by; const op: PendingOp = { kind: "increment", id, field, value }; + // Stamp this tap so a stale read-back can't rewind `server` (see incSeq). + const seq = (this.incSeq.get(id) ?? 0) + 1; + this.incSeq.set(id, seq); this.pending.push(op); this.emit(); + try { await this.handler.updateMany({ id: [id] } as FilterQuery, { $inc: { [field]: by } }); - const rows = await this.handler.filter({ id: [id] } as FilterQuery, undefined, 1); - if (rows[0]) this.server.set(id, rows[0]); - else this.server.delete(id); // removed while our increment was in flight - this.snapshot = { ...this.snapshot, error: null }; - return rows[0] ?? null; } catch (error) { + // The atomic write itself failed — nothing committed. Roll the overlay + // back and surface the error, exactly like a failed create/update. this.snapshot = { ...this.snapshot, error }; + this.pending = this.pending.filter((p) => p !== op); + this.emit(); return null; + } + + // The write COMMITTED. From here we never roll back — the increment is + // durable even if the settle read-back fails. Read the row back so `server` + // holds the true value before the overlay retires (no dip in the gap before + // the doorbell echo), but only when we are still the newest tap for this id; + // an older read-back landing late must not overwrite a fresher one. + let settled: T | null = null; + try { + const rows = await this.handler.filter({ id: [id] } as FilterQuery, undefined, 1); + settled = rows[0] ?? null; + if (this.incSeq.get(id) === seq) { + if (rows[0]) this.server.set(id, rows[0]); + else this.server.delete(id); // removed while our increment was in flight + } + this.snapshot = { ...this.snapshot, error: null }; + } catch { + // Write committed but the settle read-back failed (offline blip). Do NOT + // roll a durable write back and do NOT surface an error. Leave `server` + // untouched: the overlay retires to whatever's committed, and the + // row-bearing doorbell echo delivers the authoritative value. (Folding our + // own delta in here would double-count if the echo already landed.) + this.snapshot = { ...this.snapshot, error: null }; } finally { this.pending = this.pending.filter((p) => p !== op); this.emit(); } + return settled; } // ---- loading & reconciling ------------------------------------------------