Describe the bug
With a nested include like toArray(...), a sync-confirmed full-row update for an existing child can be flushed as insert into the per-parent child collection even though that child key already exists.
That enters duplicate-key diagnostics in CollectionSyncManager.write(...). Internal child include collections have no config.utils, so the diagnostic path crashes on this.config.utils[LIVE_QUERY_INTERNAL]:
TypeError: Cannot read properties of undefined (reading 'Symbol(liveQueryInternal)')
Normal insert(...) and optimistic update(...) APIs do not reproduce this. The bug requires the sync path: "the sync layer confirms this existing row changed; here is the new full row."
To Reproduce
- Create generic
parents and children collections with localOnlyCollectionOptions(...).
- Capture
begin, write, and commit from the sync config and keep rowUpdateMode: 'full'.
- Create a live query that selects parents and materializes children with
toArray(...).
- Mount
useLiveQuery(...).
- Insert one parent and one child through the captured sync context.
- Update the existing child with
syncContext.write({ type: 'update', value: fullRow }).
- Observe:
TypeError: Cannot read properties of undefined (reading 'Symbol(liveQueryInternal)')
Control case: children.update(...) does not reproduce.
Repro test:
import {
type ChangeMessageOrDeleteKeyMessage,
createCollection,
createLiveQueryCollection,
eq,
localOnlyCollectionOptions,
toArray,
} from '@tanstack/db'
import { useLiveQuery } from '@tanstack/react-db'
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
type ParentRow = { id: string; name: string; rank: string }
type ChildRow = { id: string; parentId: string; name: string; rank: string }
type SyncWriteContext<T extends object> = {
begin: (options?: { immediate?: boolean }) => void
write: (message: ChangeMessageOrDeleteKeyMessage<T, string>) => void
commit: () => void
}
function createHydratableLocalCollection<T extends { id: string }>(id: string) {
const options = localOnlyCollectionOptions<T>({ id, getKey: (row) => row.id })
let syncContext: SyncWriteContext<T> | null = null
const collection = createCollection({
...options,
sync: {
...options.sync,
rowUpdateMode: 'full',
sync: (params) => {
syncContext = {
begin: params.begin,
write: params.write as SyncWriteContext<T>['write'],
commit: params.commit,
}
return options.sync.sync(params)
},
},
})
collection.startSyncImmediate()
return {
collection,
write(message: ChangeMessageOrDeleteKeyMessage<T, string>) {
if (!syncContext) throw new Error(`Sync is not ready for ${id}`)
syncContext.begin({ immediate: true })
syncContext.write(message)
syncContext.commit()
},
}
}
describe('TanStack DB nested include regression', () => {
it('crashes on sync-confirmed full-row child updates', async () => {
const parents = createHydratableLocalCollection<ParentRow>('parents')
const children = createHydratableLocalCollection<ChildRow>('children')
const parentsWithChildren = createLiveQueryCollection({
id: 'parents-with-children',
query: (q) =>
q.from({ parents: parents.collection }).select(({ parents }) => ({
...parents,
children: toArray(
q
.from({ children: children.collection })
.where(({ children }) => eq(children.parentId, parents.id))
),
})),
})
const { result } = renderHook(() => useLiveQuery(parentsWithChildren))
act(() => {
parents.write({
type: 'insert',
value: { id: 'parent-1', name: 'Parent', rank: '0' },
})
children.write({
type: 'insert',
value: {
id: 'child-1',
parentId: 'parent-1',
name: 'Child A',
rank: '0',
},
})
})
await waitFor(() => {
expect(result.current.data?.[0]?.children.map((row) => row.name)).toEqual(
['Child A']
)
})
act(() => {
children.write({
type: 'update',
value: {
id: 'child-1',
parentId: 'parent-1',
name: 'Child B',
rank: '0',
},
})
})
await waitFor(() => {
expect(result.current.data?.[0]?.children.map((row) => row.name)).toEqual(
['Child B']
)
})
})
})
Expected behavior
flushIncludesState(...) should write update, not insert, when the child key already exists. Duplicate-key diagnostics should not assume config.utils exists (or properly set on child collections?).
Screenshots
N/A
Desktop (please complete the following information):
- OS: Linux
- Browser: N/A
- Version: N/A
Smartphone (please complete the following information):
- Device: N/A
- OS: N/A
- Browser: N/A
- Version: N/A
Additional context
- Versions:
@tanstack/db 0.6.5
@tanstack/react-db 0.1.83
- Relevant source:
Suggested fix:
diff --git a/src/collection/sync.ts b/src/collection/sync.ts
@@ -151,8 +151,8 @@ export class CollectionSyncManager<
// throwing a duplicate-key error during reconciliation.
messageType = `update`
} else {
- const utils = this.config
- .utils as Partial<LiveQueryCollectionUtils>
+ const utils = (this.config.utils ??
+ {}) as Partial<LiveQueryCollectionUtils>
const internal = utils[LIVE_QUERY_INTERNAL]
throw new DuplicateKeySyncError(key, this.id, {
hasCustomGetKey: internal?.hasCustomGetKey ?? false,
diff --git a/src/query/live/collection-config-builder.ts b/src/query/live/collection-config-builder.ts
@@ -1644,14 +1644,18 @@ function flushIncludesState(
if (entry.orderByIndices && change.orderByIndex !== undefined) {
entry.orderByIndices.set(change.value, change.orderByIndex)
}
+ const childAlreadyExists = entry.syncMethods.collection.has(
+ childKey as any,
+ )
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),
- ))
+ childAlreadyExists)
) {
entry.syncMethods.write({ value: change.value, type: `update` })
} else if (change.deletes > 0) {
Describe the bug
With a nested include like
toArray(...), a sync-confirmed full-row update for an existing child can be flushed asinsertinto the per-parent child collection even though that child key already exists.That enters duplicate-key diagnostics in
CollectionSyncManager.write(...). Internal child include collections have noconfig.utils, so the diagnostic path crashes onthis.config.utils[LIVE_QUERY_INTERNAL]:Normal
insert(...)and optimisticupdate(...)APIs do not reproduce this. The bug requires the sync path: "the sync layer confirms this existing row changed; here is the new full row."To Reproduce
parentsandchildrencollections withlocalOnlyCollectionOptions(...).begin,write, andcommitfrom the sync config and keeprowUpdateMode: 'full'.toArray(...).useLiveQuery(...).syncContext.write({ type: 'update', value: fullRow }).Control case:
children.update(...)does not reproduce.Repro test:
Expected behavior
flushIncludesState(...)should writeupdate, notinsert, when the child key already exists. Duplicate-key diagnostics should not assumeconfig.utilsexists (or properly set on child collections?).Screenshots
N/A
Desktop (please complete the following information):
Smartphone (please complete the following information):
Additional context
@tanstack/db0.6.5@tanstack/react-db0.1.83Suggested fix: