fix(storage): genuine CAS for updateWhere on IndexedDb + HttpTabularProxy#628
Merged
Conversation
The previous implementation did a query() + put() read-modify-write across two separate IDB transactions, so two concurrent updateWhere callers with overlapping match criteria could both read the same pre-state and only one write would land. The comment even acknowledged the race. Replace with a single readwrite transaction that opens a cursor (narrowed via the same equality-prefix planner as count()), finds the first row satisfying match, applies patch in place via cursor.update(), and resolves on tx.oncomplete. Overlapping writes now serialize on the underlying IDB transaction rather than racing between the read and the write, and IDB's native UNIQUE indexes reject a colliding patch with ConstraintError, mirroring how put() already handles uniqueness. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U172SSv5Tai5yJhhzwjaex
…an atomic server-side op The previous implementation forwarded query() and put() as separate calls across the wire, so two concurrent updateWhere calls would race on the server just like the pre-fix IndexedDb path — the client's local read-modify-write leaves a gap between the query and the put. Replace the split with a single "updateWhere" wire op that the server resolves atomically against its own tabular backend (SQL UPDATE ... WHERE ... RETURNING on the SQL backends, and now a single readwrite txn on the IndexedDb backend). The proxy just forwards match+patch and re-emits the put event locally for downstream subscribers. The server-side route case lives in builder/packages/api/src/routes/storage.ts; a companion change adds it there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U172SSv5Tai5yJhhzwjaex
Extend the generic updateWhere suite with a concurrency case: two overlapping updateWhere calls with the same before-value predicate must have exactly one winner and the row must end at that winner's value. The unlocked query()+put() implementation the IndexedDb and HttpTabularProxy backends used before this branch would either report two "winners" or silently clobber one write; this test pins the CAS contract for every backend that runs the generic suite. Also route the HttpTabularProxyStorage fake server's switch through a new "updateWhere" case so the proxy exercises the same generic contract end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U172SSv5Tai5yJhhzwjaex
Coverage Report
File CoverageNo changed files found. |
sroussey
added a commit
that referenced
this pull request
Jul 10, 2026
## @workglow/ai ### Features #### providers - add xAI (Grok) AI provider(#622) ## workglow ### Features - add node-llama-cpp provider to workglow meta-package (#629) - add OpenRouter provider for @workglow/ai (#626) #### providers - add xAI (Grok) AI provider(#622) ## @workglow/storage ### Bug Fixes #### storage - genuine CAS for updateWhere on IndexedDb + HttpTabularProxy (#628) ## @workglow/test ### Features - add OpenRouter provider for @workglow/ai (#626) #### providers - add xAI (Grok) AI provider(#622) ### Bug Fixes #### storage - genuine CAS for updateWhere on IndexedDb + HttpTabularProxy (#628) ## @workglow/indexeddb ### Bug Fixes #### storage - genuine CAS for updateWhere on IndexedDb + HttpTabularProxy (#628) ## @workglow/xai ### Features #### providers - add xAI (Grok) AI provider(#622) ## @workglow/openrouter ### Features - add OpenRouter provider for @workglow/ai (#626) ## @workglow/cli ### Features #### providers - add xAI (Grok) AI provider(#622)
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.
Summary
Two HIGH-severity bugs in
updateWhere— both a client-side read-modify-write overquery()+put()that races between the read and the write — are replaced with genuine CAS.Bug 1:
IndexedDbTabularStorage.updateWhereFile:
packages/indexeddb/src/storage/IndexedDbTabularStorage.tsBefore:
this.query(match)followed bythis.put(updated)— two separate IDB transactions. Two concurrent callers with overlappingmatchcriteria both read the same pre-state and only one write survives. The existing JSDoc even self-acknowledged the race.After: a single
readwritetransaction that opens a cursor on the object store (narrowed via the same equality-prefix plannercount()uses), walks to the first row satisfyingmatch, appliespatchin place viacursor.update(), and resolves the returnedEntity | undefinedontx.oncomplete.safeEmit("put", ...)andhybridManager?.notifyLocalChange()fire only after the commit succeeds — matching the invariantsput()already upholds. UNIQUE-index enforcement is delegated to IDB's nativeConstraintError(same shape asput()).Overlapping
updateWherecalls now serialize on the IDB readwrite lock rather than racing.Bug 2:
HttpTabularProxyStorage.updateWhereFile:
packages/storage/src/tabular/HttpTabularProxyStorage.tsBefore: the proxy issued a
querywire call, a local merge, and aputwire call — so two concurrent proxy calls race on the server the same way. Even if the server had SQLite's atomicUPDATE ... RETURNING, the proxy couldn't use it.After: a single
updateWherewire op forwards{ match, patch }to the server, which resolves it atomically against whatever tabular backend the server uses (SQLUPDATE ... WHERE ... RETURNINGon the SQL backends; on IndexedDb, the same single-txn CAS above). The proxy just returnsentity ?? undefinedand re-emits a localputevent for downstream subscribers.Test
File:
packages/test/src/test/storage-tabular/genericTabularStorageTests.tsExtended the existing
describe("updateWhere tests", …)block with a CAS race case: seed a row withvalue: 0, kick off two overlappingupdateWhere({ id, value: 0 }, { value: N })calls viaPromise.all, and assert exactly one winner (the other must resolveundefined) and that the persisted row reflects the winner's value. The pre-fixIndexedDbandHttpTabularProxypaths would have reported two winners or silently clobbered one write.File:
packages/test/src/test/storage-tabular/HttpTabularProxyStorage.test.tsAdded an
updateWherecase to the fake server's switch so the generic contract round-trips over the mocked wire.Test plan
bun scripts/test.ts storage vitest— all 1531 tests passbunx turbo run build-js --filter=@workglow/storage --filter=@workglow/indexeddbbun run build:types🤖 Generated with Claude Code
https://claude.ai/code/session_01U172SSv5Tai5yJhhzwjaex
Generated by Claude Code