Skip to content

Comments

fix(db): re-accumulate pending changes by custom getKey to avoid DuplicateKeySyncError#1290

Merged
kevin-dp merged 4 commits intomainfrom
getKey-collision
Feb 23, 2026
Merged

fix(db): re-accumulate pending changes by custom getKey to avoid DuplicateKeySyncError#1290
kevin-dp merged 4 commits intomainfrom
getKey-collision

Conversation

@kevin-dp
Copy link
Contributor

@kevin-dp kevin-dp commented Feb 23, 2026

Summary

  • When a LEFT JOIN live query has a custom getKey that only considers the left collection's identity (ignoring the right collection, e.g. because it knows it's a 1-to-1 relation), populating the right collection causes the IVM to retract old rows and insert new rows with different D2 internal keys that map to the same custom key. Because pendingChanges is keyed by D2 key, these appear as a separate INSERT and DELETE. If the INSERT is processed first, the sync layer throws DuplicateKeySyncError.
  • Fixes this by re-accumulating pendingChanges by custom key in flushPendingChanges, merging the retract + insert for the same custom key into a single UPDATE. This does not change how new rows are inserted — it only applies when the same custom key appears in both a delete and an insert within the same flush.

Test plan

  • Existing test reproduces the bug (left join with custom getKey should not throw DuplicateKeySyncError)
  • Both autoIndex: off and autoIndex: eager variants pass
  • Full join test suite passes (94 tests, no regressions)

Closes #677

🤖 Generated with Claude Code

@changeset-bot
Copy link

changeset-bot bot commented Feb 23, 2026

🦋 Changeset detected

Latest commit: 12c6ca8

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/angular-db Patch
@tanstack/electric-db-collection Patch
@tanstack/offline-transactions Patch
@tanstack/powersync-db-collection Patch
@tanstack/query-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 Feb 23, 2026

More templates

@tanstack/angular-db

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

@tanstack/db

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

@tanstack/db-ivm

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

@tanstack/electric-db-collection

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

@tanstack/offline-transactions

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

@tanstack/powersync-db-collection

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

@tanstack/query-db-collection

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

@tanstack/react-db

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

@tanstack/rxdb-db-collection

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

@tanstack/solid-db

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

@tanstack/svelte-db

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

@tanstack/trailbase-db-collection

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

@tanstack/vue-db

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

commit: 12c6ca8

@github-actions
Copy link
Contributor

github-actions bot commented Feb 23, 2026

Size Change: +109 B (+0.12%)

Total Size: 92.6 kB

Filename Size Change
./packages/db/dist/esm/query/live/collection-config-builder.js 5.55 kB +109 B (+2%)
ℹ️ View Unchanged
Filename Size
./packages/db/dist/esm/collection/change-events.js 1.39 kB
./packages/db/dist/esm/collection/changes.js 1.22 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.75 kB
./packages/db/dist/esm/collection/mutations.js 2.34 kB
./packages/db/dist/esm/collection/state.js 3.49 kB
./packages/db/dist/esm/collection/subscription.js 3.71 kB
./packages/db/dist/esm/collection/sync.js 2.41 kB
./packages/db/dist/esm/deferred.js 207 B
./packages/db/dist/esm/errors.js 4.7 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 2.17 kB
./packages/db/dist/esm/indexes/lazy-index.js 1.1 kB
./packages/db/dist/esm/indexes/reverse-index.js 538 B
./packages/db/dist/esm/local-only.js 808 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.09 kB
./packages/db/dist/esm/query/builder/ref-proxy.js 1.05 kB
./packages/db/dist/esm/query/compiler/evaluators.js 1.43 kB
./packages/db/dist/esm/query/compiler/expressions.js 430 B
./packages/db/dist/esm/query/compiler/group-by.js 2.23 kB
./packages/db/dist/esm/query/compiler/index.js 2.04 kB
./packages/db/dist/esm/query/compiler/joins.js 2.11 kB
./packages/db/dist/esm/query/compiler/order-by.js 1.45 kB
./packages/db/dist/esm/query/compiler/select.js 1.09 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-registry.js 264 B
./packages/db/dist/esm/query/live/collection-subscriber.js 2.42 kB
./packages/db/dist/esm/query/live/internal.js 145 B
./packages/db/dist/esm/query/optimizer.js 2.62 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 952 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 Feb 23, 2026

Size Change: 0 B

Total Size: 3.7 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.34 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

…icateKeySyncError

When a LEFT JOIN live query has a custom getKey and the right collection
is populated after initial sync, the IVM retracts old rows and inserts
new rows with different D2 internal keys that map to the same custom key.
Because pendingChanges is keyed by D2 key, these end up as separate
entries. If the INSERT is processed first, the sync layer throws
DuplicateKeySyncError.

Fix by re-accumulating pendingChanges by custom key in flushPendingChanges
when this.config.getKey is set. This merges entries that the IVM sees as
distinct D2 rows but the user considers the same logical row, producing
a single UPDATE instead of a conflicting INSERT + DELETE.

Closes #677

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kevin-dp kevin-dp changed the title test(db): reproduce getKey collision on left join with delayed right-side sync fix(db): re-accumulate pending changes by custom getKey to avoid DuplicateKeySyncError Feb 23, 2026
kevin-dp and others added 2 commits February 23, 2026 14:35
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Collaborator

@samwillis samwillis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM :shipit:

// to the same user-visible key. Re-accumulate by custom key so that a
// retract + insert for the same logical row merges into an UPDATE
// instead of a separate DELETE and INSERT that can race.
if (this.config.getKey) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good that this is only used when there is a getKey 👍

@kevin-dp kevin-dp merged commit 77b815e into main Feb 23, 2026
8 checks passed
@kevin-dp kevin-dp deleted the getKey-collision branch February 23, 2026 13:55
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.

live query with joins emits delete events on ready

2 participants