Skip to content

Nested include flush misclassifies sync-confirmed child updates as inserts, crashing duplicate-key diagnostics #1495

@Nick-Motion

Description

@Nick-Motion
  • I've validated the bug against the latest version of DB packages

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

  1. Create generic parents and children collections with localOnlyCollectionOptions(...).
  2. Capture begin, write, and commit from the sync config and keep rowUpdateMode: 'full'.
  3. Create a live query that selects parents and materializes children with toArray(...).
  4. Mount useLiveQuery(...).
  5. Insert one parent and one child through the captured sync context.
  6. Update the existing child with syncContext.write({ type: 'update', value: fullRow }).
  7. 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

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) {

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions