Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions packages/db/src/query/live/collection-config-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1864,14 +1864,23 @@ function createChildCollectionEntry(
* 3. Drain nested buffers β€” route buffered grandchild changes to per-entry states
* 4. Flush per-entry states β€” recursively flush nested includes on each entry
* 5. Parent DELETEs β€” clean up child entries and routing index
*
* `routingCleanup` collects the rows whose internal routing stamp must be
* dropped once the whole recursive pass is done; the outermost call owns it.
*/
function flushIncludesState(
includesState: Array<IncludesOutputState>,
parentCollection: Collection<any, any, any>,
parentId: string,
parentChanges: Map<unknown, Changes<any>> | null,
parentSyncMethods: SyncMethods<any> | null,
routingCleanup?: Set<Record<PropertyKey, any>>,
): void {
// The outermost call owns the cleanup set; nested calls inherit it so the
// stamp survives until every parent group has been flushed.
const isOutermostCall = routingCleanup === undefined
const cleanup = routingCleanup ?? new Set<Record<PropertyKey, any>>()

for (const state of includesState) {
// Phase 1: Parent INSERTs β€” ensure a child Collection exists for every parent
if (parentChanges) {
Expand Down Expand Up @@ -2008,6 +2017,7 @@ function flushIncludesState(
entry.collection.id,
childChanges,
entry.syncMethods,
cleanup,
)
}
}
Expand All @@ -2022,6 +2032,7 @@ function flushIncludesState(
entry.collection.id,
null,
entry.syncMethods,
cleanup,
)
}
}
Expand All @@ -2044,6 +2055,7 @@ function flushIncludesState(
entry.collection.id,
null,
entry.syncMethods,
cleanup,
)
deepBufferDirty.add(correlationKey)
}
Expand Down Expand Up @@ -2128,10 +2140,21 @@ function flushIncludesState(
}
}

// Clean up the internal routing stamp from parent/child results
// Defer clearing the internal routing stamp: a single child result object can
// be routed to several parent groups (sibling parents that share a
// correlation key), and each of those groups reads the stamp when its own
// nested level is flushed. Stripping it here would drop the deeper levels for
// every group flushed after the first, so collect the rows and clear them
// once the whole recursive pass is done.
if (parentChanges) {
for (const [, changes] of parentChanges) {
delete changes.value[INCLUDES_ROUTING]
cleanup.add(changes.value)
}
}

if (isOutermostCall) {
for (const value of cleanup) {
delete value[INCLUDES_ROUTING]
}
}
}
Expand Down
117 changes: 117 additions & 0 deletions packages/db/tests/query/includes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6759,4 +6759,121 @@ describe(`includes subqueries`, () => {
])
})
})

// Regression test for https://github.com/TanStack/db/issues/1685
//
// Shape of the collections:
//
// root 1 ──> mid 1 ─┐
// β”œβ”€β”€> shared 1 ──> leaf 1
// root 2 ──> mid 2 β”€β”˜
//
// Both root rows resolve (through distinct `mid` rows) to the *same* `shared`
// row, which in turn resolves to the same `leaf` row. The deepest level was
// being dropped on all but one of the parent rows.
describe(`nested subqueries with a shared middle level`, () => {
type Leaf = { id: number }
type Shared = { id: number; leafId: number }
type Mid = { id: number; sharedId: number }
type Root = { id: number; midId: number }

function buildCollections() {
const leaves = createCollection(
mockSyncCollectionOptions<Leaf>({
id: `leaves`,
getKey: (r) => r.id,
initialData: [{ id: 1 }],
}),
)

// The shared level: a single row that both `mid` rows point at.
const shareds = createCollection(
mockSyncCollectionOptions<Shared>({
id: `shareds`,
getKey: (r) => r.id,
initialData: [{ id: 1, leafId: 1 }],
}),
)

const mids = createCollection(
mockSyncCollectionOptions<Mid>({
id: `mids`,
getKey: (r) => r.id,
initialData: [
{ id: 1, sharedId: 1 },
{ id: 2, sharedId: 1 },
],
}),
)

const roots = createCollection(
mockSyncCollectionOptions<Root>({
id: `roots`,
getKey: (r) => r.id,
initialData: [
{ id: 1, midId: 1 },
{ id: 2, midId: 2 },
],
}),
)

return { leaves, shareds, mids, roots }
}

it(`keeps the deepest level on every parent row`, async () => {
const { leaves, shareds, mids, roots } = buildCollections()

const live = createLiveQueryCollection((q) =>
q.from({ root: roots }).select(({ root }) => ({
...root,
mid: toArray(
q
.from({ mid: mids })
.where(({ mid }) => eq(mid.id, root.midId))
.select(({ mid }) => ({
...mid,
shared: toArray(
q
.from({ shared: shareds })
.where(({ shared }) => eq(shared.id, mid.sharedId))
.select(({ shared }) => ({
...shared,
leaf: toArray(
q
.from({ leaf: leaves })
.where(({ leaf }) => eq(leaf.id, shared.leafId))
.findOne(),
),
}))
.findOne(),
),
}))
.findOne(),
),
})),
)

await live.preload()

const rows = live.toArray
expect(rows).toHaveLength(2)

for (const root of rows) {
const mid = root.mid.at(0)
expect(mid, `root ${root.id} is missing its mid`).toBeDefined()
expect(mid!.id).toBe(root.midId)

const shared = mid!.shared.at(0)
expect(shared, `root ${root.id} is missing its shared`).toBeDefined()
expect(shared!.id).toBe(1)

// The regression: `leaf` was undefined on one of the two root rows,
// because `shared 1` is reached through two different `mid` rows.
expect(
stripVirtualProps(shared!.leaf.at(0)!),
`root ${root.id} dropped its leaf`,
).toEqual({ id: 1 })
}
})
})
})