Skip to content

Optimistic atomic increment on the useQuery hook (0.6.0) - #30

Merged
HomemadeToast57 merged 3 commits into
mainfrom
jack/optimistic-atomic-increment
Aug 20, 2026
Merged

HomemadeToast57 merged 3 commits into
mainfrom
jack/optimistic-atomic-increment

Conversation

@HomemadeToast57

@HomemadeToast57 HomemadeToast57 commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Why

Counters lag. A live todo (create/update/remove through the hook) is optimistic and instant, but a counter goes through updateMany({ id }, { $inc }), which is atomic but not optimistic and not on the hook. So the number can't move until the server echo returns, and generated code papered over it with a local bump + refetch() (flickers) and a working guard (drops fast taps).

What

bool.entities.<table>.useQuery().increment(id, field, by?) (by defaults to 1):

  • bumps the field in the same frame as the tap (optimistic overlay);
  • writes atomically as SQL $inc, so simultaneous taps can't lose each other's clicks;
  • reads the committed row back before retiring the overlay, so the digit never dips to a stale value;
  • rolls back and surfaces the error on failure; never throws.

The overlay is ABSOLUTE, not a delta (this is the load-bearing detail)

The first cut used a +by delta overlay. That double-counts against the private doorbell's echo of your own write: the row-bearing ding lands on server with the committed value while the delta is still up, so it adds on top — 0 → 1 → 2 → 1, worse the faster you tap. Caught in live testing, not by the initial unit tests. The fix stores the absolute optimistic value (current + by) and SETs the field in emit, exactly like update's overlay, so it stays idempotent with both the echo and the read-back. current is read from the live snapshot, so rapid unsettled taps still stack.

On the read-back

Each increment does the $inc then a keyed filter read-back before dropping the overlay. The doorbell echo also settles server, so the read-back is close to redundant — it exists to avoid a brief dip in the ~100ms before the echo lands. Deliberate tradeoff (one extra GET vs a visible dip); flagging it for review.

Tests

bun test: 201 pass. New in live.test.ts: optimistic bump with no double-count, rapid taps summing atomically, rollback on failure, and a regression that fires a mid-flight doorbell echo and asserts no double-count. Typecheck + build clean.

Verified live

Confirmed end to end on a preview: a fresh counter on the fixed prerelease climbs cleanly on fast clicks with no bounce (the delta version bounced through 2). Additive; the realtime engine (doorbell, LiveEntityStore merge, create/update/remove) is untouched.

Rollout

The bool-side follow-up (seed ^0.6.0, teach increment, gate it on isIncrementCapableSdkSpec) is codehs/bool#1063, which is blocked on this shipping to latest. Order: merge this → cut v0.6.0 release (publishes latest) → un-draft and merge bool#1063.

🤖 Generated with Claude Code

HomemadeToast57 and others added 3 commits August 20, 2026 14:21
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@HomemadeToast57
HomemadeToast57 merged commit c54b3ba into main Aug 20, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant