Skip to content

Conversation

@KyleAMathews
Copy link
Collaborator

@KyleAMathews KyleAMathews commented Jan 14, 2026

Summary

Manual write operations (writeUpsert, writeInsert, writeUpdate, writeDelete) now correctly update syncedData when called from within mutation handlers that include async operations. Previously, syncedData would appear stale until the next sync cycle.


Root Cause

The bug occurs in this sequence:

  1. User calls collection.update() which creates a transaction
  2. The onUpdate handler fires and makes an async API call (await fetch(...))
  3. During the await, the transaction transitions to persisting state
  4. Handler calls writeUpsert() to update syncedData with server response
  5. Bug: commitPendingTransactions() sees hasPersistingTransaction=true and skips processing the sync transaction

The guard if (!hasPersistingTransaction || hasTruncateSync) exists to prevent sync operations from overwriting in-flight optimistic updates. But manual writes are different—they're intentionally updating syncedData to match server state, and should be processed immediately.

Approach

Added an immediate flag to sync transactions that bypasses the persisting-transaction check:

// sync.ts - begin() now accepts options
begin: (options?: { immediate?: boolean }) => {
  this.state.pendingSyncedTransactions.push({
    committed: false,
    operations: [],
    deletedKeys: new Set(),
    immediate: options?.immediate,  // new
  })
}

// manual-sync.ts - write operations use immediate: true
ctx.begin({ immediate: true })

The condition becomes:

if (!hasPersistingTransaction || hasTruncateSync || hasImmediateSync) {
  // Process sync transactions
}

Key Invariants

  1. Manual writes (writeUpsert, etc.) must update syncedData synchronously, regardless of transaction state
  2. When an immediate (or truncate) transaction triggers processing, all committed sync transactions are processed together to preserve causal ordering. This prevents earlier non-immediate transactions from being applied later and overwriting newer state.
  3. Without an immediate/truncate transaction, sync operations wait for persisting transactions to complete

Non-goals

  • Did not change the general sync flow behavior—only manual writes get the immediate flag
  • Did not expose immediate in the public API beyond the existing write utilities

Trade-offs

Alternative considered: Process only the immediate transaction, leaving other committed sync transactions queued.

Rejected because this would break ordering: if you have [syncA, syncB(immediate)] and only process syncB, then syncA would apply later and could overwrite syncB's changes with stale data. Processing all committed transactions together maintains the queue's causal ordering.

Alternative considered: Process all sync transactions immediately regardless of persisting state.

Rejected because this could cause race conditions where a slow sync response overwrites optimistic state that's still being persisted. The immediate flag is surgical—it only triggers when there's an intentional manual write.


Verification

pnpm test -- packages/query-db-collection/tests/query.test.ts

The new test should update syncedData immediately when writeUpsert is called after async API in onUpdate handler reproduces the exact bug scenario.

Files Changed

File Changes
packages/db/src/types.ts Added immediate option to begin() signature with JSDoc
packages/db/src/collection/sync.ts Pass immediate option through to pending transaction
packages/db/src/collection/state.ts Track hasImmediateSync flag, process when true; added comment explaining ordering semantics
packages/query-db-collection/src/manual-sync.ts Use begin({ immediate: true }) for write operations
packages/query-db-collection/tests/query.test.ts Regression test for the async-onUpdate scenario

🤖 Generated with Claude Code

…nsactions

Manual write operations (writeInsert, writeUpdate, writeDelete, writeUpsert)
were not updating syncedData when called from within a mutation handler
(e.g., onUpdate with refetch: false). This caused an "off by one" bug where
the cache would show stale data until the next sync operation.

Root cause: commitPendingTransactions() skipped processing sync transactions
when a persisting user transaction was active, but manual writes need to
update syncedData synchronously.

Fix: Add an `immediate` flag to sync transactions. When begin() is called
with { immediate: true }, the transaction bypasses the persisting transaction
check and is processed immediately. Manual write operations now use this flag.

Changes:
- Add `immediate?: boolean` to PendingSyncedTransaction interface
- Update begin() to accept optional { immediate?: boolean } parameter
- Modify commitPendingTransactions() to process immediate transactions
  regardless of persisting transaction state
- Update performWriteOperations() to use begin({ immediate: true })
- Add regression test for writeUpsert in onUpdate with refetch: false
@changeset-bot
Copy link

changeset-bot bot commented Jan 14, 2026

🦋 Changeset detected

Latest commit: d75453c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@tanstack/db Patch
@tanstack/query-db-collection Patch
@tanstack/angular-db Patch
@tanstack/electric-db-collection Patch
@tanstack/offline-transactions Patch
@tanstack/powersync-db-collection Patch
@tanstack/react-db Patch
@tanstack/rxdb-db-collection Patch
@tanstack/solid-db Patch
@tanstack/svelte-db Patch
@tanstack/trailbase-db-collection Patch
@tanstack/vue-db Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Jan 14, 2026

More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@1130

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@1130

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@1130

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@1130

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@1130

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@1130

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@1130

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@1130

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@1130

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@1130

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@1130

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@1130

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@1130

commit: d75453c

@github-actions
Copy link
Contributor

github-actions bot commented Jan 14, 2026

Size Change: +46 B (+0.05%)

Total Size: 90.6 kB

Filename Size Change
./packages/db/dist/esm/collection/state.js 3.49 kB +30 B (+0.87%)
./packages/db/dist/esm/collection/sync.js 2.4 kB +16 B (+0.67%)
ℹ️ View Unchanged
Filename Size
./packages/db/dist/esm/collection/change-events.js 1.39 kB
./packages/db/dist/esm/collection/changes.js 1.19 kB
./packages/db/dist/esm/collection/events.js 388 B
./packages/db/dist/esm/collection/index.js 3.32 kB
./packages/db/dist/esm/collection/indexes.js 1.1 kB
./packages/db/dist/esm/collection/lifecycle.js 1.67 kB
./packages/db/dist/esm/collection/mutations.js 2.34 kB
./packages/db/dist/esm/collection/subscription.js 3.62 kB
./packages/db/dist/esm/deferred.js 207 B
./packages/db/dist/esm/errors.js 4.49 kB
./packages/db/dist/esm/event-emitter.js 748 B
./packages/db/dist/esm/index.js 2.69 kB
./packages/db/dist/esm/indexes/auto-index.js 742 B
./packages/db/dist/esm/indexes/base-index.js 766 B
./packages/db/dist/esm/indexes/btree-index.js 1.93 kB
./packages/db/dist/esm/indexes/lazy-index.js 1.1 kB
./packages/db/dist/esm/indexes/reverse-index.js 513 B
./packages/db/dist/esm/local-only.js 837 B
./packages/db/dist/esm/local-storage.js 2.1 kB
./packages/db/dist/esm/optimistic-action.js 359 B
./packages/db/dist/esm/paced-mutations.js 496 B
./packages/db/dist/esm/proxy.js 3.75 kB
./packages/db/dist/esm/query/builder/functions.js 733 B
./packages/db/dist/esm/query/builder/index.js 4.08 kB
./packages/db/dist/esm/query/builder/ref-proxy.js 1.05 kB
./packages/db/dist/esm/query/compiler/evaluators.js 1.42 kB
./packages/db/dist/esm/query/compiler/expressions.js 430 B
./packages/db/dist/esm/query/compiler/group-by.js 1.87 kB
./packages/db/dist/esm/query/compiler/index.js 1.96 kB
./packages/db/dist/esm/query/compiler/joins.js 2 kB
./packages/db/dist/esm/query/compiler/order-by.js 1.45 kB
./packages/db/dist/esm/query/compiler/select.js 1.06 kB
./packages/db/dist/esm/query/expression-helpers.js 1.43 kB
./packages/db/dist/esm/query/ir.js 673 B
./packages/db/dist/esm/query/live-query-collection.js 360 B
./packages/db/dist/esm/query/live/collection-config-builder.js 5.4 kB
./packages/db/dist/esm/query/live/collection-registry.js 264 B
./packages/db/dist/esm/query/live/collection-subscriber.js 1.93 kB
./packages/db/dist/esm/query/live/internal.js 145 B
./packages/db/dist/esm/query/optimizer.js 2.56 kB
./packages/db/dist/esm/query/predicate-utils.js 2.97 kB
./packages/db/dist/esm/query/subset-dedupe.js 921 B
./packages/db/dist/esm/scheduler.js 1.3 kB
./packages/db/dist/esm/SortedMap.js 1.3 kB
./packages/db/dist/esm/strategies/debounceStrategy.js 247 B
./packages/db/dist/esm/strategies/queueStrategy.js 428 B
./packages/db/dist/esm/strategies/throttleStrategy.js 246 B
./packages/db/dist/esm/transactions.js 2.9 kB
./packages/db/dist/esm/utils.js 924 B
./packages/db/dist/esm/utils/browser-polyfills.js 304 B
./packages/db/dist/esm/utils/btree.js 5.61 kB
./packages/db/dist/esm/utils/comparison.js 852 B
./packages/db/dist/esm/utils/cursor.js 457 B
./packages/db/dist/esm/utils/index-optimization.js 1.51 kB
./packages/db/dist/esm/utils/type-guards.js 157 B

compressed-size-action::db-package-size

@github-actions
Copy link
Contributor

github-actions bot commented Jan 14, 2026

Size Change: 0 B

Total Size: 3.47 kB

ℹ️ View Unchanged
Filename Size
./packages/react-db/dist/esm/index.js 225 B
./packages/react-db/dist/esm/useLiveInfiniteQuery.js 1.17 kB
./packages/react-db/dist/esm/useLiveQuery.js 1.12 kB
./packages/react-db/dist/esm/useLiveSuspenseQuery.js 559 B
./packages/react-db/dist/esm/usePacedMutations.js 401 B

compressed-size-action::react-db-package-size

KyleAMathews and others added 3 commits January 13, 2026 17:19
When hasImmediateSync or hasTruncateSync is true, we process ALL
committed sync transactions, not just the immediate ones. This
preserves causal ordering - if we only processed the immediate
transaction, earlier non-immediate ones would apply later and
could overwrite newer state.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
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.

3 participants