From 56c38a9f58c8588a488b8f89cfb3d332c6671608 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 18 Jun 2026 13:58:51 -0600 Subject: [PATCH 1/6] fix: guard live query internals in sync errors --- packages/db/src/collection/sync.ts | 7 +++--- packages/db/tests/collection.test.ts | 37 +++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 82fffb772..af89ed2cf 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -151,9 +151,10 @@ export class CollectionSyncManager< // throwing a duplicate-key error during reconciliation. messageType = `update` } else { - const utils = this.config - .utils as Partial - const internal = utils[LIVE_QUERY_INTERNAL] + const utils = this.config.utils as + | Partial + | undefined + const internal = utils?.[LIVE_QUERY_INTERNAL] throw new DuplicateKeySyncError(key, this.id, { hasCustomGetKey: internal?.hasCustomGetKey ?? false, hasJoins: internal?.hasJoins ?? false, diff --git a/packages/db/tests/collection.test.ts b/packages/db/tests/collection.test.ts index 7fc5d67b4..e76ffc4e1 100644 --- a/packages/db/tests/collection.test.ts +++ b/packages/db/tests/collection.test.ts @@ -4,6 +4,7 @@ import { createCollection } from '../src/collection/index.js' import { CollectionRequiresConfigError, DuplicateKeyError, + DuplicateKeySyncError, InvalidKeyError, KeyUpdateNotAllowedError, MissingDeleteHandlerError, @@ -18,7 +19,12 @@ import { stripVirtualProps, withExpectedRejection, } from './utils' -import type { ChangeMessage, MutationFn, PendingMutation } from '../src/types' +import type { + ChangeMessage, + MutationFn, + PendingMutation, + SyncConfig, +} from '../src/types' const getStateValue = ( collection: { state: Map }, @@ -42,6 +48,35 @@ describe(`Collection`, () => { expect(() => createCollection()).toThrow(CollectionRequiresConfigError) }) + it(`throws DuplicateKeySyncError instead of TypeError when config has no utils`, async () => { + let begin!: () => void + let write!: Parameters< + SyncConfig<{ id: number; text: string }>[`sync`] + >[0][`write`] + + const collection = createCollection<{ id: number; text: string }, number>({ + id: `duplicate-key-no-utils-test`, + getKey: (item) => item.id, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + params.begin() + params.write({ type: `insert`, value: { id: 1, text: `one` } }) + params.commit() + params.markReady() + }, + }, + }) + + await collection.stateWhenReady() + + begin() + expect(() => + write({ type: `insert`, value: { id: 1, text: `changed` } }), + ).toThrow(DuplicateKeySyncError) + }) + it(`removes optimistic insert when sync confirms with a different server-generated key`, async () => { const options = mockSyncCollectionOptionsNoInitialState<{ id: number From dbb87aaae613d3096b8e9e2f76ba7ec477e46f89 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 18 Jun 2026 13:59:37 -0600 Subject: [PATCH 2/6] chore: add changeset for sync duplicate key fix --- .changeset/fix-sync-duplicate-key-utils.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-sync-duplicate-key-utils.md diff --git a/.changeset/fix-sync-duplicate-key-utils.md b/.changeset/fix-sync-duplicate-key-utils.md new file mode 100644 index 000000000..c8e6ca0f8 --- /dev/null +++ b/.changeset/fix-sync-duplicate-key-utils.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Fix duplicate-key sync reconciliation for collection configs without live query internals so it reports the intended duplicate key error instead of throwing a TypeError. From 24f31fb2c372a3bdd3b33fd34ec827cf9691b114 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 18 Jun 2026 15:28:58 -0600 Subject: [PATCH 3/6] fix: reconcile duplicate includes child inserts --- .../query/live/collection-config-builder.ts | 15 ++-- packages/db/tests/collection.test.ts | 2 +- packages/db/tests/query/includes.test.ts | 54 ++++++++++++++ .../query-db-collection/tests/query.test.ts | 74 ++++++++++++++++++- 4 files changed, 138 insertions(+), 7 deletions(-) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index b40e6e431..46df59d7b 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -1671,14 +1671,19 @@ function flushIncludesState( if (entry.orderByIndices && change.orderByIndex !== undefined) { entry.orderByIndices.set(change.value, change.orderByIndex) } + const key = entry.syncMethods.collection.getKeyFromItem( + change.value, + ) + const childAlreadyExists = entry.syncMethods.collection.has(key) + if (change.inserts > 0 && change.deletes === 0) { - entry.syncMethods.write({ value: change.value, type: `insert` }) + entry.syncMethods.write({ + value: change.value, + type: childAlreadyExists ? `update` : `insert`, + }) } else if ( change.inserts > change.deletes || - (change.inserts === change.deletes && - entry.syncMethods.collection.has( - entry.syncMethods.collection.getKeyFromItem(change.value), - )) + (change.inserts === change.deletes && childAlreadyExists) ) { entry.syncMethods.write({ value: change.value, type: `update` }) } else if (change.deletes > 0) { diff --git a/packages/db/tests/collection.test.ts b/packages/db/tests/collection.test.ts index e76ffc4e1..c204b89af 100644 --- a/packages/db/tests/collection.test.ts +++ b/packages/db/tests/collection.test.ts @@ -51,7 +51,7 @@ describe(`Collection`, () => { it(`throws DuplicateKeySyncError instead of TypeError when config has no utils`, async () => { let begin!: () => void let write!: Parameters< - SyncConfig<{ id: number; text: string }>[`sync`] + SyncConfig<{ id: number; text: string }, number>[`sync`] >[0][`write`] const collection = createCollection<{ id: number; text: string }, number>({ diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index 0124ebbea..c3ba06f03 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -567,6 +567,60 @@ describe(`includes subqueries`, () => { }) describe(`change propagation`, () => { + it(`Collection includes: joined child update does not duplicate insert into child collection`, async () => { + type LineItem = { id: number; productId: number; qty: number } + type Product = { id: number; categoryId: number; name: string } + + const lineItems = createCollection( + mockSyncCollectionOptions({ + id: `includes-line-items`, + getKey: (lineItem) => lineItem.id, + initialData: [{ id: 1, productId: 10, qty: 1 }], + }), + ) + const products = createCollection( + mockSyncCollectionOptions({ + id: `includes-products`, + getKey: (product) => product.id, + initialData: [{ id: 10, categoryId: 1, name: `Widget` }], + }), + ) + + const collection = createLiveQueryCollection((q) => + q.from({ lineItem: lineItems }).select(({ lineItem }) => ({ + id: lineItem.id, + product: q + .from({ product: products }) + .where(({ product }) => eq(product.id, lineItem.productId)) + .select(({ product }) => ({ + id: product.id, + categoryId: product.categoryId, + name: product.name, + })), + })), + ) + await collection.preload() + + lineItems.utils.begin() + expect(() => { + lineItems.utils.write({ + type: `delete`, + value: { id: 1, productId: 10, qty: 1 }, + }) + lineItems.utils.write({ + type: `insert`, + value: { id: 1, productId: 10, qty: 2 }, + }) + }).not.toThrow() + lineItems.utils.commit() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(childItems((collection.get(1) as any).product)).toEqual([ + { id: 10, categoryId: 1, name: `Widget` }, + ]) + }) + it(`Collection includes: child change does not re-emit the parent row`, async () => { const collection = buildIncludesQuery() await collection.preload() diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 0c501ab02..f0d110ba0 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -9,7 +9,10 @@ import { inArray, or, } from '@tanstack/db' -import { stripVirtualProps } from '../../db/tests/utils' +import { + mockSyncCollectionOptions, + stripVirtualProps, +} from '../../db/tests/utils' import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' import { queryCollectionOptions } from '../src/query' import type { QueryFunctionContext } from '@tanstack/query-core' @@ -228,6 +231,75 @@ describe(`QueryCollection`, () => { expect(collection._state.syncedData.get(`2`)).toEqual(initialItems[1]) }) + it(`should not duplicate insert into includes child collection after update refetch`, async () => { + type LineItem = { id: string; productId: string } + type Product = { id: string; categoryId: number; name: string } + + const lineItems = createCollection( + mockSyncCollectionOptions({ + id: `query-collection-line-items`, + getKey: (lineItem) => lineItem.id, + initialData: [{ id: `line-1`, productId: `product-1` }], + }), + ) + + let productsData: Array = [ + { id: `product-1`, categoryId: 1, name: `Widget` }, + ] + + const products = createCollection( + queryCollectionOptions({ + id: `query-collection-products`, + queryClient, + queryKey: [`products`], + queryFn: vi + .fn() + .mockImplementation(() => Promise.resolve(productsData)), + getKey: (product) => product.id, + startSync: true, + onUpdate: async ({ transaction }) => { + for (const mutation of transaction.mutations) { + productsData = productsData.map((product) => + product.id === mutation.key + ? (mutation.modified) + : product, + ) + } + }, + }), + ) + + const collection = createLiveQueryCollection((q) => + q.from({ lineItem: lineItems }).select(({ lineItem }) => ({ + id: lineItem.id, + product: q + .from({ product: products }) + .where(({ product }) => eq(product.id, lineItem.productId)) + .select(({ product }) => ({ + id: product.id, + categoryId: product.categoryId, + name: product.name, + })), + })), + ) + + await collection.preload() + + expect(() => { + products.update(`product-1`, (draft) => { + draft.categoryId = 2 + }) + }).not.toThrow() + + await vi.waitFor(() => { + expect(stripVirtualProps(products.get(`product-1`) as any)).toEqual({ + id: `product-1`, + categoryId: 2, + name: `Widget`, + }) + }) + }) + it(`should update collection when query data changes`, async () => { const queryKey = [`testItems`] const initialItems: Array = [ From c1832723c0ea39520ed2ad12d5f495e6a4df847b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:30:18 +0000 Subject: [PATCH 4/6] ci: apply automated fixes --- packages/query-db-collection/tests/query.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index f0d110ba0..ee0ca8602 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -260,9 +260,7 @@ describe(`QueryCollection`, () => { onUpdate: async ({ transaction }) => { for (const mutation of transaction.mutations) { productsData = productsData.map((product) => - product.id === mutation.key - ? (mutation.modified) - : product, + product.id === mutation.key ? mutation.modified : product, ) } }, From 7cba2013eb4bba92872aa95530c0e14ec5246b72 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 18 Jun 2026 15:41:40 -0600 Subject: [PATCH 5/6] docs: update changeset for includes reconciliation fix --- .changeset/fix-sync-duplicate-key-utils.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-sync-duplicate-key-utils.md b/.changeset/fix-sync-duplicate-key-utils.md index c8e6ca0f8..b9d245768 100644 --- a/.changeset/fix-sync-duplicate-key-utils.md +++ b/.changeset/fix-sync-duplicate-key-utils.md @@ -2,4 +2,4 @@ '@tanstack/db': patch --- -Fix duplicate-key sync reconciliation for collection configs without live query internals so it reports the intended duplicate key error instead of throwing a TypeError. +Fix live query includes reconciliation so updates that re-emit existing child rows update internal child collections instead of attempting duplicate inserts, and ensure duplicate-key sync errors handle collection configs without live query internals. From 8305b9d353f958aed4450c3de965a7b4714b32ff Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 18 Jun 2026 15:58:35 -0600 Subject: [PATCH 6/6] test: tighten includes reconciliation assertions --- packages/db/tests/query/includes.test.ts | 10 +++++----- packages/query-db-collection/tests/query.test.ts | 4 +++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index c3ba06f03..87bb4ea5b 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -614,11 +614,11 @@ describe(`includes subqueries`, () => { }).not.toThrow() lineItems.utils.commit() - await new Promise((resolve) => setTimeout(resolve, 10)) - - expect(childItems((collection.get(1) as any).product)).toEqual([ - { id: 10, categoryId: 1, name: `Widget` }, - ]) + await vi.waitFor(() => { + expect(childItems((collection.get(1) as any).product)).toEqual([ + { id: 10, categoryId: 1, name: `Widget` }, + ]) + }) }) it(`Collection includes: child change does not re-emit the parent row`, async () => { diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index ee0ca8602..b9ccb8f55 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -290,7 +290,9 @@ describe(`QueryCollection`, () => { }).not.toThrow() await vi.waitFor(() => { - expect(stripVirtualProps(products.get(`product-1`) as any)).toEqual({ + expect( + stripVirtualProps((collection.get(`line-1`) as any).product.toArray[0]), + ).toEqual({ id: `product-1`, categoryId: 2, name: `Widget`,