Skip to content

fix(db): publish one event per key for metadata-only sync commits - #1795

Open
elylucas wants to merge 1 commit into
TanStack:mainfrom
elylucas:fix/metadata-only-sync-duplicate-events
Open

fix(db): publish one event per key for metadata-only sync commits#1795
elylucas wants to merge 1 commit into
TanStack:mainfrom
elylucas:fix/metadata-only-sync-duplicate-events

Conversation

@elylucas

@elylucas elylucas commented Sep 1, 2026

Copy link
Copy Markdown

🎯 Changes

Fixes #1767.

Two optimistic writes in a row on the same key blow up on the second one:

Error: Query contributors with the same row key are not congruent

What's going on

When a collection publishes a batch of changes, that batch is a diff, so each key should appear in it at most once. Here it appeared twice, with both events saying the same thing.

The sequence, using a query collection as the example:

  1. You make an optimistic update, and your mutationFn acknowledges it with utils.writeUpsert.
  2. writeUpsert refreshes the query cache, and applying that result records row ownership metadata. That metadata write goes into its own sync transaction — one with no row operations at all, just a metadata entry.
  3. That transaction stays pending while your transaction is persisting.
  4. When your transaction completes, two different code paths each notice the row switching from optimistic to synced ($synced: false → true), and both emit an event for it. Both land in the same published batch.

The guard meant to prevent that double-emit is recentlySyncedKeys, seeded by capturePreSyncVisibleState. But it derived its keys from a sync transaction's row operations only, while commitPendingTransactions derives its keys from operations plus row metadata writes. A metadata-only transaction fell straight through the gap between those two sets.

Downstream, the duplicated diff leaves the live query counting the old row at -2 and the new one at +2. The next optimistic write on that key then leaves two positive contributors with different values, which is exactly what the keyed reduction rejects.

That also explains the shape of the bug report: round one is what corrupts the bookkeeping, and round two is what notices.

The fix

Pull the "which keys does this sync commit touch" derivation into a single helper, collectAffectedKeys, and use it in all three places that ask the question — commitPendingTransactions, capturePreSyncVisibleState, and cancelPendingSyncedTransaction. The bug was those key sets drifting apart, so this keeps them from drifting again rather than just patching the one that was behind.

Behaviour change is limited to the two sites that were missing metadata keys. commitPendingTransactions already had them.

Tests

Two regression tests, both of which fail with the exact error before the fix:

  • packages/db/tests/collection-metadata-only-sync-commit.test.ts — core-level. Drives a metadata-only sync transaction directly through the sync API while an optimistic mutation is in flight, and asserts no published batch ever names one key twice.
  • packages/query-db-collection/tests/optimistic-writeback.test.ts — the reported path, via utils.writeUpsert.

Why the existing tests missed it

The suite covers sync transactions that carry row operations, and covers row metadata, but never a transaction that writes metadata with no row operations while an optimistic mutation is pending — the one shape where the two key sets disagree. The core test above closes that gap, and its "a batch never names a key twice" assertion is the more general form of the invariant, so it should catch other ways of producing a malformed batch too.

Verification

db 3071 · query-db-collection 170 + 114 e2e · electric 242 · react-db 161 · offline-transactions 65 · powersync 92 · plus trailbase, rxdb, solid, svelte, vue and angular — all passing, typecheck and lint clean.

One note: collection-events.test.ts > should emit events with correct structure times out under full-suite load on my machine. It does the same on an unmodified main (3 runs out of 3), so it looks pre-existing and unrelated to this change.

✅ Checklist

  • I have tested this code locally with pnpm test.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • Bug Fixes

    • Fixed duplicate change events when synchronizing metadata-only updates.
    • Prevented live queries from becoming inconsistent after repeated optimistic writes to the same record.
    • Ensured synchronized metadata updates correctly participate in change tracking.
  • Tests

    • Added regression coverage for metadata-only synchronization and consecutive optimistic updates.

A sync transaction that writes only row metadata, with no row operations,
still retires a completed optimistic mutation and flips virtual props such
as `$synced`. `commitPendingTransactions` accounted for that, but
`capturePreSyncVisibleState` derived its key set from row operations alone,
so the key never entered `recentlySyncedKeys`. `recomputeOptimisticState`
and `commitPendingTransactions` then each emitted the same transition, and
the published batch named one key twice.

That is a malformed diff: the live query counts the old row at -2 and the
new one at +2, so the next optimistic write on the key leaves two positive
contributors and the keyed reduction throws "Query contributors with the
same row key are not congruent".

Extract the shared key derivation into `collectAffectedKeys` and use it in
all three sites that ask which keys a sync commit touches, so they cannot
drift apart again.

Fixes TanStack#1767

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 1ea24d4d-9bc5-4758-8662-0330ef03e21f

📥 Commits

Reviewing files that changed from the base of the PR and between 68366ec and d48f28d.

📒 Files selected for processing (4)
  • .changeset/fix-metadata-only-sync-duplicate-events.md
  • packages/db/src/collection/state.ts
  • packages/db/tests/collection-metadata-only-sync-commit.test.ts
  • packages/query-db-collection/tests/optimistic-writeback.test.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The collection state now includes metadata-only sync keys when tracking affected rows. Sync commit, cancellation, and pre-sync capture use the shared helper. New regression tests cover duplicate events and consecutive optimistic writebacks.

Changes

Metadata-only sync event deduplication

Layer / File(s) Summary
Affected-key tracking
packages/db/src/collection/state.ts
collectAffectedKeys gathers keys from sync operations and row metadata writes. Sync commit, cancellation, and pre-sync capture use this helper.
Regression coverage
packages/db/tests/collection-metadata-only-sync-commit.test.ts, packages/query-db-collection/tests/optimistic-writeback.test.ts, .changeset/fix-metadata-only-sync-duplicate-events.md
Tests verify unique change batches and two consecutive optimistic writebacks on the same key. The changeset declares a patch release for @tanstack/db.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to d48f2

This localized change prevents duplicate events for metadata-only sync commits and protects optimistic writebacks from corrupting keyed query state; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary change: preventing duplicate events for metadata-only sync commits.
Description check ✅ Passed The description follows the required template. It explains the motivation, implementation, tests, checklist status, release impact, and changeset.
Linked Issues check ✅ Passed The changes address issue #1767 by aligning affected-key tracking for metadata-only sync transactions. The regression tests cover duplicate event publication and consecutive optimistic writes through …
Out of Scope Changes check ✅ Passed All changes support the linked issue: the shared helper fixes affected-key tracking, the changeset records the release, and both test files verify the regression paths.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 3…
Full details: Linked Issues check

Explanation

The changes address issue #1767 by aligning affected-key tracking for metadata-only sync transactions. The regression tests cover duplicate event publication and consecutive optimistic writes through the reported writeback path.

Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 3 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

transaction.isPersisted.promise never settles for the second consecutive insert (0.8.x)

1 participant