Skip to content

fix(storage): genuine CAS for updateWhere on IndexedDb + HttpTabularProxy#628

Merged
sroussey merged 3 commits into
mainfrom
claude/beautiful-mayer-xl6481
Jul 10, 2026
Merged

fix(storage): genuine CAS for updateWhere on IndexedDb + HttpTabularProxy#628
sroussey merged 3 commits into
mainfrom
claude/beautiful-mayer-xl6481

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator

Summary

Two HIGH-severity bugs in updateWhere — both a client-side read-modify-write over query() + put() that races between the read and the write — are replaced with genuine CAS.

Bug 1: IndexedDbTabularStorage.updateWhere

File: packages/indexeddb/src/storage/IndexedDbTabularStorage.ts

Before: this.query(match) followed by this.put(updated) — two separate IDB transactions. Two concurrent callers with overlapping match criteria both read the same pre-state and only one write survives. The existing JSDoc even self-acknowledged the race.

After: a single readwrite transaction that opens a cursor on the object store (narrowed via the same equality-prefix planner count() uses), walks to the first row satisfying match, applies patch in place via cursor.update(), and resolves the returned Entity | undefined on tx.oncomplete. safeEmit("put", ...) and hybridManager?.notifyLocalChange() fire only after the commit succeeds — matching the invariants put() already upholds. UNIQUE-index enforcement is delegated to IDB's native ConstraintError (same shape as put()).

Overlapping updateWhere calls now serialize on the IDB readwrite lock rather than racing.

Bug 2: HttpTabularProxyStorage.updateWhere

File: packages/storage/src/tabular/HttpTabularProxyStorage.ts

Before: the proxy issued a query wire call, a local merge, and a put wire call — so two concurrent proxy calls race on the server the same way. Even if the server had SQLite's atomic UPDATE ... RETURNING, the proxy couldn't use it.

After: a single updateWhere wire op forwards { match, patch } to the server, which resolves it atomically against whatever tabular backend the server uses (SQL UPDATE ... WHERE ... RETURNING on the SQL backends; on IndexedDb, the same single-txn CAS above). The proxy just returns entity ?? undefined and re-emits a local put event for downstream subscribers.

Server-side wiring: HttpTabularProxy's server implementation lives in builder/packages/api/src/routes/storage.ts. A companion PR in builder adds the updateWhere route case there.

Test

File: packages/test/src/test/storage-tabular/genericTabularStorageTests.ts

Extended the existing describe("updateWhere tests", …) block with a CAS race case: seed a row with value: 0, kick off two overlapping updateWhere({ id, value: 0 }, { value: N }) calls via Promise.all, and assert exactly one winner (the other must resolve undefined) and that the persisted row reflects the winner's value. The pre-fix IndexedDb and HttpTabularProxy paths would have reported two winners or silently clobbered one write.

File: packages/test/src/test/storage-tabular/HttpTabularProxyStorage.test.ts

Added an updateWhere case 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 pass
  • bunx turbo run build-js --filter=@workglow/storage --filter=@workglow/indexeddb
  • bun run build:types

🤖 Generated with Claude Code

https://claude.ai/code/session_01U172SSv5Tai5yJhhzwjaex


Generated by Claude Code

claude added 3 commits July 10, 2026 08:29
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
@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 62.67% 26066 / 41589
🔵 Statements 62.51% 26971 / 43145
🔵 Functions 63.49% 4928 / 7761
🔵 Branches 51.27% 12761 / 24885
File CoverageNo changed files found.
Generated in workflow #2692 for commit 568451f by the Vitest Coverage Report Action

@sroussey sroussey merged commit b92e0fa into main Jul 10, 2026
14 checks passed
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)
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.

2 participants