Optimistic atomic increment on the useQuery hook (0.6.0) - #30
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Counters lag. A live todo (
create/update/removethrough the hook) is optimistic and instant, but a counter goes throughupdateMany({ 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 aworkingguard (drops fast taps).What
bool.entities.<table>.useQuery().increment(id, field, by?)(bydefaults to 1):$inc, so simultaneous taps can't lose each other's clicks;The overlay is ABSOLUTE, not a delta (this is the load-bearing detail)
The first cut used a
+bydelta overlay. That double-counts against the private doorbell's echo of your own write: the row-bearing ding lands onserverwith 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 inemit, exactly likeupdate's overlay, so it stays idempotent with both the echo and the read-back.currentis read from the live snapshot, so rapid unsettled taps still stack.On the read-back
Each increment does the
$incthen a keyedfilterread-back before dropping the overlay. The doorbell echo also settlesserver, 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 inlive.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,
LiveEntityStoremerge,create/update/remove) is untouched.Rollout
The bool-side follow-up (seed
^0.6.0, teachincrement, gate it onisIncrementCapableSdkSpec) is codehs/bool#1063, which is blocked on this shipping tolatest. Order: merge this → cutv0.6.0release (publisheslatest) → un-draft and merge bool#1063.🤖 Generated with Claude Code