diff --git a/.changeset/nested-toarray-shared-buffer-overlap.md b/.changeset/nested-toarray-shared-buffer-overlap.md new file mode 100644 index 0000000000..17571d8b27 --- /dev/null +++ b/.changeset/nested-toarray-shared-buffer-overlap.md @@ -0,0 +1,9 @@ +--- +'@tanstack/db': patch +--- + +fix(db): nested `toArray` includes dropping children when sibling parent groups share a correlation key + +With three (or more) levels of nested `toArray` includes, when two children in different parent groups shared the same deepest correlation key, only one of them received the nested rows and the other came back as an empty array. The nested-pipeline routing index mapped each nested correlation key to a single parent group and the shared buffer entry was deleted after routing to the first match, so sibling groups sharing the key were dropped. + +The routing index now maps a nested correlation key to all parent groups that reference it and fans buffered grandchild changes out to each. A per-level snapshot of already-materialized rows also seeds parent groups that start referencing an existing correlation key after the rows were drained (e.g. inserted after the initial load), since the pipeline does not re-emit them. diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index b40e6e431a..14d18033c2 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -1150,6 +1150,13 @@ function createOrderByComparator( } } +type SnapshotRow = { + value: any + orderByIndex: string | undefined + /** Net multiplicity (inserts − deletes) currently materialized for this row */ + count: number +} + /** * Shared buffer setup for a single nested includes level. * Pipeline output writes into the buffer; during flush the buffer is drained @@ -1159,6 +1166,15 @@ type NestedIncludesSetup = { compilationResult: IncludesCompilationResult /** Shared buffer: nestedCorrelationKey → Map */ buffer: Map>> + /** + * Cumulative net-present grandchild rows per nested correlation key. The + * buffer holds only deltas since the last drain and is cleared once drained, + * so a parent group that starts referencing an existing correlation key + * *after* the rows were already drained (the pipeline does not re-emit them) + * would otherwise see nothing. The snapshot lets such late-arriving parent + * groups be seeded with the rows their siblings already received. + */ + snapshot: Map> /** For 3+ levels of nesting */ nestedSetups?: Array } @@ -1184,10 +1200,31 @@ type IncludesOutputState = { correlationToParentKeys: Map> /** Shared nested pipeline setups (one per nested includes level) */ nestedSetups?: Array - /** nestedCorrelationKey → parentCorrelationKey */ - nestedRoutingIndex?: Map + /** + * nestedCorrelationKey → (parentCorrelationKey → Set). + * One nested correlation key can map to multiple parent groups when sibling + * parents share the same correlation value (e.g. two price ranges that + * reference the same region), so buffered grandchild changes must fan out to + * every parent group rather than a single one. + * + * Within a single parent group, multiple child rows can share the same nested + * correlation key (e.g. two price ranges in the same product both pointing at + * region 1). We track the referencing child keys so the parent group is only + * dropped from the route once its *last* referencing child row is removed — + * deleting one sibling must not strand the survivor. + */ + nestedRoutingIndex?: Map>> /** parentCorrelationKey → Set */ nestedRoutingReverseIndex?: Map> + /** + * Per nested setup: parentCorrelationKey → (childKey → current nestedKey). + * Records which nested key each child row currently routes to, so an update + * that changes a child row's nested correlation key can drop its *previous* + * reference (the update change only carries the new key). Keyed per-setup so a + * change to one nested include never disturbs a different nested include on the + * same child row. + */ + nestedRoutingChildToNested?: Array>> } type ChildCollectionEntry = { @@ -1298,6 +1335,7 @@ function setupNestedPipelines( const setup: NestedIncludesSetup = { compilationResult: entry, buffer, + snapshot: new Map(), } // Recursively set up deeper levels @@ -1342,6 +1380,85 @@ function createPerEntryIncludesStates( }) } +/** + * Folds a drained delta into a nested setup's cumulative snapshot, tracking the + * net multiplicity per child row and dropping rows (and empty keys) once their + * net count reaches zero. + */ +function accumulateSnapshot( + setup: NestedIncludesSetup, + nestedCorrelationKey: unknown, + childChanges: Map>, +): void { + let snap = setup.snapshot.get(nestedCorrelationKey) + if (!snap) { + snap = new Map() + setup.snapshot.set(nestedCorrelationKey, snap) + } + + for (const [childKey, changes] of childChanges) { + let row = snap.get(childKey) + if (!row) { + row = { + value: changes.value, + orderByIndex: changes.orderByIndex, + count: 0, + } + snap.set(childKey, row) + } + row.count += changes.inserts - changes.deletes + if (changes.inserts > 0) { + row.value = changes.value + if (changes.orderByIndex !== undefined) { + row.orderByIndex = changes.orderByIndex + } + } + if (row.count <= 0) { + snap.delete(childKey) + } + } + + if (snap.size === 0) { + setup.snapshot.delete(nestedCorrelationKey) + } +} + +/** + * Seeds a parent group's per-entry state with the rows already materialized for + * a nested correlation key. Used when a parent group starts referencing a key + * whose rows were drained (and cleared from the buffer) in an earlier flush, so + * the pipeline will not re-emit them. + */ +function seedParentFromSnapshot( + state: IncludesOutputState, + setupIndex: number, + parentCorrelationKey: unknown, + nestedCorrelationKey: unknown, +): void { + const setup = state.nestedSetups![setupIndex]! + const snap = setup.snapshot.get(nestedCorrelationKey) + if (!snap || snap.size === 0) return + + const entry = state.childRegistry.get(parentCorrelationKey) + if (!entry || !entry.includesStates) return + + const entryState = entry.includesStates[setupIndex]! + let byChild = entryState.pendingChildChanges.get(nestedCorrelationKey) + if (!byChild) { + byChild = new Map() + entryState.pendingChildChanges.set(nestedCorrelationKey, byChild) + } + for (const [childKey, row] of snap) { + if (byChild.has(childKey)) continue + byChild.set(childKey, { + deletes: 0, + inserts: row.count, + value: row.value, + orderByIndex: row.orderByIndex, + }) + } +} + /** * Drains shared buffers into per-entry states using the routing index. * Returns the set of parent correlation keys that had changes routed to them. @@ -1356,43 +1473,56 @@ function drainNestedBuffers(state: IncludesOutputState): Set { const toDelete: Array = [] for (const [nestedCorrelationKey, childChanges] of setup.buffer) { - const parentCorrelationKey = - state.nestedRoutingIndex!.get(nestedCorrelationKey) - if (parentCorrelationKey === undefined) { + const parentRoutes = state.nestedRoutingIndex!.get(nestedCorrelationKey) + if (parentRoutes === undefined || parentRoutes.size === 0) { // Unroutable — parent not yet seen; keep in buffer continue } - const entry = state.childRegistry.get(parentCorrelationKey) - if (!entry || !entry.includesStates) { - continue - } - - // Route changes into this entry's per-entry state at position i - const entryState = entry.includesStates[i]! - for (const [childKey, changes] of childChanges) { - let byChild = entryState.pendingChildChanges.get(nestedCorrelationKey) - if (!byChild) { - byChild = new Map() - entryState.pendingChildChanges.set(nestedCorrelationKey, byChild) + // A single nested correlation key can map to multiple parent groups when + // sibling parents share the same correlation value. Fan the buffered + // changes out to each ready parent group; only drop the buffer entry once + // it has been routed to at least one parent. + let routedToAny = false + for (const parentCorrelationKey of parentRoutes.keys()) { + const entry = state.childRegistry.get(parentCorrelationKey) + if (!entry || !entry.includesStates) { + continue } - const existing = byChild.get(childKey) - if (existing) { - existing.inserts += changes.inserts - existing.deletes += changes.deletes - if (changes.inserts > 0) { - existing.value = changes.value - if (changes.orderByIndex !== undefined) { - existing.orderByIndex = changes.orderByIndex + + // Route changes into this entry's per-entry state at position i + const entryState = entry.includesStates[i]! + for (const [childKey, changes] of childChanges) { + let byChild = entryState.pendingChildChanges.get(nestedCorrelationKey) + if (!byChild) { + byChild = new Map() + entryState.pendingChildChanges.set(nestedCorrelationKey, byChild) + } + const existing = byChild.get(childKey) + if (existing) { + existing.inserts += changes.inserts + existing.deletes += changes.deletes + if (changes.inserts > 0) { + existing.value = changes.value + if (changes.orderByIndex !== undefined) { + existing.orderByIndex = changes.orderByIndex + } } + } else { + byChild.set(childKey, { ...changes }) } - } else { - byChild.set(childKey, { ...changes }) } + + dirtyCorrelationKeys.add(parentCorrelationKey) + routedToAny = true } - dirtyCorrelationKeys.add(parentCorrelationKey) - toDelete.push(nestedCorrelationKey) + if (routedToAny) { + // Fold the drained delta into the cumulative snapshot so a parent group + // that starts referencing this nested key later can be seeded with it. + accumulateSnapshot(setup, nestedCorrelationKey, childChanges) + toDelete.push(nestedCorrelationKey) + } } for (const key of toDelete) { @@ -1408,6 +1538,43 @@ function drainNestedBuffers(state: IncludesOutputState): Set { * Maps nested correlation keys to parent correlation keys so that * grandchild changes can be routed to the correct per-entry state. */ +/** + * Removes a single child row's reference to a nested routing key from a parent + * group's route, dropping the parent (and the nested key, and the reverse-index + * entry) once no child row in the group references the key anymore. + */ +function removeChildKeyFromRoute( + state: IncludesOutputState, + correlationKey: unknown, + nestedRoutingKey: unknown, + childKey: unknown, +): void { + const parents = state.nestedRoutingIndex!.get(nestedRoutingKey) + const childKeys = parents?.get(correlationKey) + if (!parents || !childKeys) return + + childKeys.delete(childKey) + // Only drop the parent group from the route once its last child row + // referencing this nested key is gone — a surviving sibling in the same + // parent group must keep receiving grandchild changes. + if (childKeys.size === 0) { + parents.delete(correlationKey) + if (parents.size === 0) { + state.nestedRoutingIndex!.delete(nestedRoutingKey) + } + // The reverse index tracks parent → nested keys at group granularity, so + // only drop the entry when no child row in this parent group references the + // nested key anymore. + const reverseSet = state.nestedRoutingReverseIndex!.get(correlationKey) + if (reverseSet) { + reverseSet.delete(nestedRoutingKey) + if (reverseSet.size === 0) { + state.nestedRoutingReverseIndex!.delete(correlationKey) + } + } + } +} + function updateRoutingIndex( state: IncludesOutputState, correlationKey: unknown, @@ -1415,8 +1582,15 @@ function updateRoutingIndex( ): void { if (!state.nestedSetups) return - for (const setup of state.nestedSetups) { - for (const [, change] of childChanges) { + // Lazily allocate the per-setup childKey → nestedKey tracking maps. + if (!state.nestedRoutingChildToNested) { + state.nestedRoutingChildToNested = state.nestedSetups.map(() => new Map()) + } + + for (let i = 0; i < state.nestedSetups.length; i++) { + const setup = state.nestedSetups[i]! + const childToNested = state.nestedRoutingChildToNested[i]! + for (const [childKey, change] of childChanges) { if (change.inserts > 0) { // Read the nested routing key from the INCLUDES_ROUTING stamp. // Must use the composite routing key (not raw correlationKey) to match @@ -1430,14 +1604,72 @@ function updateRoutingIndex( nestedParentContext, ) + // An update (inserts > 0 && deletes > 0) can change a child row's nested + // correlation key (e.g. a price range's regionId changes). The change + // only carries the NEW key, so drop the row's previous reference for + // THIS setup using the recorded mapping before re-routing it. + // + // This relies on the compiler stamping the FULL INCLUDES_ROUTING map on + // every emitted row (one entry per nested include field), so for an + // unrelated nested include the recomputed nestedRoutingKey equals the + // recorded one and the guard below is a no-op — a change to one nested + // include never disturbs the recorded key of another on the same row. + const perParent = childToNested.get(correlationKey) + const prevNestedKey = perParent?.get(childKey) + if (prevNestedKey !== undefined && prevNestedKey !== nestedRoutingKey) { + removeChildKeyFromRoute( + state, + correlationKey, + prevNestedKey, + childKey, + ) + perParent!.delete(childKey) + } + if (nestedCorrelationKey != null) { - state.nestedRoutingIndex!.set(nestedRoutingKey, correlationKey) + let parents = state.nestedRoutingIndex!.get(nestedRoutingKey) + if (!parents) { + parents = new Map() + state.nestedRoutingIndex!.set(nestedRoutingKey, parents) + } + let childKeys = parents.get(correlationKey) + // The parent group is "new" for this nested key only when no child row + // in it referenced the key before; that's the case that needs seeding. + const isNewParent = !childKeys || childKeys.size === 0 + if (!childKeys) { + childKeys = new Set() + parents.set(correlationKey, childKeys) + } + childKeys.add(childKey) let reverseSet = state.nestedRoutingReverseIndex!.get(correlationKey) if (!reverseSet) { reverseSet = new Set() state.nestedRoutingReverseIndex!.set(correlationKey, reverseSet) } reverseSet.add(nestedRoutingKey) + + // Record the row's current nested key for this setup so a later update + // that changes it can release the old reference. Reuse perParent when + // it already exists to avoid a second lookup. + let recorded = perParent + if (!recorded) { + recorded = new Map() + childToNested.set(correlationKey, recorded) + } + recorded.set(childKey, nestedRoutingKey) + + // If this parent group is newly associated with a nested key whose + // rows were already drained (and cleared from the buffer) in an + // earlier flush, the pipeline will not re-emit them. Seed this parent + // from the cumulative snapshot so it receives the same rows its + // siblings already have. + if (isNewParent) { + seedParentFromSnapshot(state, i, correlationKey, nestedRoutingKey) + } + } else if (perParent && perParent.size === 0) { + // The row no longer has a nested key (cleared via update) and held no + // others — drop the now-empty per-parent record. + childToNested.delete(correlationKey) } } else if (change.deletes > 0 && change.inserts === 0) { // Remove from routing index @@ -1451,15 +1683,17 @@ function updateRoutingIndex( ) if (nestedCorrelationKey != null) { - state.nestedRoutingIndex!.delete(nestedRoutingKey) - const reverseSet = - state.nestedRoutingReverseIndex!.get(correlationKey) - if (reverseSet) { - reverseSet.delete(nestedRoutingKey) - if (reverseSet.size === 0) { - state.nestedRoutingReverseIndex!.delete(correlationKey) - } - } + removeChildKeyFromRoute( + state, + correlationKey, + nestedRoutingKey, + childKey, + ) + } + const perParent = childToNested.get(correlationKey) + if (perParent) { + perParent.delete(childKey) + if (perParent.size === 0) childToNested.delete(correlationKey) } } } @@ -1479,10 +1713,26 @@ function cleanRoutingIndexOnDelete( const nestedKeys = state.nestedRoutingReverseIndex.get(correlationKey) if (nestedKeys) { for (const nestedKey of nestedKeys) { - state.nestedRoutingIndex!.delete(nestedKey) + // The whole parent group is gone, so drop it from the nested key's route + // (along with all the child keys it tracked); other sibling parent groups + // may still reference the same nested correlation key. + const parents = state.nestedRoutingIndex!.get(nestedKey) + if (parents) { + parents.delete(correlationKey) + if (parents.size === 0) { + state.nestedRoutingIndex!.delete(nestedKey) + } + } } state.nestedRoutingReverseIndex.delete(correlationKey) } + + // Drop the per-setup childKey → nestedKey records for this parent group. + if (state.nestedRoutingChildToNested) { + for (const childToNested of state.nestedRoutingChildToNested) { + childToNested.delete(correlationKey) + } + } } /** diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index 0124ebbeac..d03681164d 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -4902,6 +4902,755 @@ describe(`includes subqueries`, () => { expect(data().runs[0].texts).toBe(run1TextsBefore) }) + + // Three collection levels (products -> priceRanges -> region). When two + // price ranges in different parent groups point at the same deepest + // correlation key (regionId 1, one under each product), each must still + // resolve its own copy of the nested `region` array. + it(`resolves nested grandchildren for sibling groups sharing a correlation key`, async () => { + type Product = { id: number; title: string } + type PriceRange = { id: number; productId: number; regionId: number } + type Region = { id: number; name: string } + + const products = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-products`, + getKey: (p) => p.id, + initialData: [ + { id: 1, title: `T-Shirt` }, + { id: 2, title: `Hoodie` }, + ], + }), + ) + + const priceRanges = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-price-ranges`, + getKey: (r) => r.id, + initialData: [ + { id: 1, productId: 1, regionId: 1 }, + { id: 2, productId: 1, regionId: 2 }, + { id: 3, productId: 2, regionId: 1 }, // same regionId as priceRange 1 + ], + }), + ) + + const regions = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-regions`, + getKey: (r) => r.id, + initialData: [ + { id: 1, name: `Europe` }, + { id: 2, name: `North America` }, + ], + }), + ) + + await Promise.all([ + products.preload(), + priceRanges.preload(), + regions.preload(), + ]) + + const collection = createLiveQueryCollection({ + id: `shared-corr-live`, + query: (q) => + q.from({ p: products }).select(({ p }) => ({ + id: p.id, + title: p.title, + priceRanges: toArray( + q + .from({ pr: priceRanges }) + .where(({ pr }) => eq(pr.productId, p.id)) + .select(({ pr }) => ({ + id: pr.id, + regionId: pr.regionId, + region: toArray( + q + .from({ r: regions }) + .where(({ r }) => eq(r.id, pr.regionId)) + .select(({ r }) => ({ id: r.id, name: r.name })), + ), + })), + ), + })), + }) + + await collection.preload() + + expect(toTree(collection)).toEqual([ + { + id: 1, + title: `T-Shirt`, + priceRanges: [ + { + id: 1, + regionId: 1, + region: [{ id: 1, name: `Europe` }], + }, + { + id: 2, + regionId: 2, + region: [{ id: 2, name: `North America` }], + }, + ], + }, + { + id: 2, + title: `Hoodie`, + priceRanges: [ + { + id: 3, + regionId: 1, + region: [{ id: 1, name: `Europe` }], + }, + ], + }, + ]) + }) + + // When a second parent group starts referencing a deepest correlation key + // that another group already resolved (the sibling price range is inserted + // after the initial load), the newly inserted group must also receive the + // nested grandchildren. + it(`fans nested grandchildren out to a sibling group inserted after load`, async () => { + type Product = { id: number; title: string } + type PriceRange = { id: number; productId: number; regionId: number } + type Region = { id: number; name: string } + + const products = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-incremental-products`, + getKey: (p) => p.id, + initialData: [ + { id: 1, title: `T-Shirt` }, + { id: 2, title: `Hoodie` }, + ], + }), + ) + const priceRanges = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-incremental-price-ranges`, + getKey: (r) => r.id, + initialData: [{ id: 1, productId: 1, regionId: 1 }], + }), + ) + const regions = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-incremental-regions`, + getKey: (r) => r.id, + initialData: [{ id: 1, name: `Europe` }], + }), + ) + + await Promise.all([ + products.preload(), + priceRanges.preload(), + regions.preload(), + ]) + + const collection = createLiveQueryCollection({ + id: `shared-corr-incremental-live`, + query: (q) => + q.from({ p: products }).select(({ p }) => ({ + id: p.id, + title: p.title, + priceRanges: toArray( + q + .from({ pr: priceRanges }) + .where(({ pr }) => eq(pr.productId, p.id)) + .select(({ pr }) => ({ + id: pr.id, + regionId: pr.regionId, + region: toArray( + q + .from({ r: regions }) + .where(({ r }) => eq(r.id, pr.regionId)) + .select(({ r }) => ({ id: r.id, name: r.name })), + ), + })), + ), + })), + }) + await collection.preload() + + // Insert a second price range under a different product, sharing regionId 1. + priceRanges.insert({ id: 3, productId: 2, regionId: 1 }) + await new Promise((r) => setTimeout(r, 50)) + + const tree = toTree(collection) + const tshirt = tree.find((p: any) => p.title === `T-Shirt`) + const hoodie = tree.find((p: any) => p.title === `Hoodie`) + expect(tshirt.priceRanges.find((pr: any) => pr.id === 1).region).toEqual([ + { id: 1, name: `Europe` }, + ]) + expect(hoodie.priceRanges.find((pr: any) => pr.id === 3).region).toEqual([ + { id: 1, name: `Europe` }, + ]) + }) + + // When two parent groups share a deepest correlation key and one of them is + // deleted, the surviving group must keep its nested grandchildren. + it(`isolates a nested correlation-key update from a second nested include on the same child`, async () => { + type Product = { id: number; title: string } + type PriceRange = { + id: number + productId: number + regionId: number + currencyId: number + } + type Region = { id: number; name: string } + type Currency = { id: number; code: string } + + const products = createCollection( + localOnlyCollectionOptions({ + id: `temp2-products`, + getKey: (p) => p.id, + initialData: [{ id: 1, title: `T-Shirt` }], + }), + ) + const priceRanges = createCollection( + localOnlyCollectionOptions({ + id: `temp2-price-ranges`, + getKey: (r) => r.id, + initialData: [{ id: 1, productId: 1, regionId: 1, currencyId: 9 }], + }), + ) + const regions = createCollection( + localOnlyCollectionOptions({ + id: `temp2-regions`, + getKey: (r) => r.id, + initialData: [ + { id: 1, name: `Europe` }, + { id: 2, name: `North America` }, + ], + }), + ) + const currencies = createCollection( + localOnlyCollectionOptions({ + id: `temp2-currencies`, + getKey: (c) => c.id, + initialData: [{ id: 9, code: `EUR` }], + }), + ) + + await Promise.all([ + products.preload(), + priceRanges.preload(), + regions.preload(), + currencies.preload(), + ]) + + const collection = createLiveQueryCollection({ + id: `temp2-live`, + query: (q) => + q.from({ p: products }).select(({ p }) => ({ + id: p.id, + title: p.title, + priceRanges: toArray( + q + .from({ pr: priceRanges }) + .where(({ pr }) => eq(pr.productId, p.id)) + .select(({ pr }) => ({ + id: pr.id, + region: toArray( + q + .from({ r: regions }) + .where(({ r }) => eq(r.id, pr.regionId)) + .select(({ r }) => ({ id: r.id, name: r.name })), + ), + currency: toArray( + q + .from({ c: currencies }) + .where(({ c }) => eq(c.id, pr.currencyId)) + .select(({ c }) => ({ id: c.id, code: c.code })), + ), + })), + ), + })), + }) + await collection.preload() + + // Change ONLY regionId; currency must still resolve, and a later currency + // rename must still reach this price range. + priceRanges.update(1, (draft) => { + draft.regionId = 2 + }) + await new Promise((r) => setTimeout(r, 50)) + + currencies.update(9, (draft) => { + draft.code = `USD` + }) + await new Promise((r) => setTimeout(r, 50)) + + expect(toTree(collection)).toEqual([ + { + id: 1, + title: `T-Shirt`, + priceRanges: [ + { + id: 1, + region: [{ id: 2, name: `North America` }], + currency: [{ id: 9, code: `USD` }], + }, + ], + }, + ]) + }) + + it(`keeps the survivor's data when a child changes its nested key then a sibling sharing the old key is deleted`, async () => { + type Product = { id: number; title: string } + type PriceRange = { id: number; productId: number; regionId: number } + type Region = { id: number; name: string } + + const products = createCollection( + localOnlyCollectionOptions({ + id: `temp-upd-products`, + getKey: (p) => p.id, + initialData: [{ id: 1, title: `T-Shirt` }], + }), + ) + const priceRanges = createCollection( + localOnlyCollectionOptions({ + id: `temp-upd-price-ranges`, + getKey: (r) => r.id, + initialData: [ + { id: 1, productId: 1, regionId: 1 }, + { id: 2, productId: 1, regionId: 1 }, + ], + }), + ) + const regions = createCollection( + localOnlyCollectionOptions({ + id: `temp-upd-regions`, + getKey: (r) => r.id, + initialData: [ + { id: 1, name: `Europe` }, + { id: 2, name: `North America` }, + ], + }), + ) + + await Promise.all([ + products.preload(), + priceRanges.preload(), + regions.preload(), + ]) + + const collection = createLiveQueryCollection({ + id: `temp-upd-live`, + query: (q) => + q.from({ p: products }).select(({ p }) => ({ + id: p.id, + title: p.title, + priceRanges: toArray( + q + .from({ pr: priceRanges }) + .where(({ pr }) => eq(pr.productId, p.id)) + .select(({ pr }) => ({ + id: pr.id, + regionId: pr.regionId, + region: toArray( + q + .from({ r: regions }) + .where(({ r }) => eq(r.id, pr.regionId)) + .select(({ r }) => ({ id: r.id, name: r.name })), + ), + })), + ), + })), + }) + await collection.preload() + + // pr_1 moves from region 1 to region 2 (both pr_1, pr_2 started at region 1) + priceRanges.update(1, (draft) => { + draft.regionId = 2 + }) + await new Promise((r) => setTimeout(r, 50)) + + // delete pr_2 (the remaining referencer of region 1) + priceRanges.delete(2) + await new Promise((r) => setTimeout(r, 50)) + + // rename region 1 — nothing references it anymore, must NOT affect pr_1 + regions.update(1, (draft) => { + draft.name = `Renamed Europe` + }) + await new Promise((r) => setTimeout(r, 50)) + + expect(toTree(collection)).toEqual([ + { + id: 1, + title: `T-Shirt`, + priceRanges: [ + { id: 1, regionId: 2, region: [{ id: 2, name: `North America` }] }, + ], + }, + ]) + }) + + it(`keeps grandchildren on the surviving sibling after the other is deleted`, async () => { + type Product = { id: number; title: string } + type PriceRange = { id: number; productId: number; regionId: number } + type Region = { id: number; name: string } + + const products = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-delete-products`, + getKey: (p) => p.id, + initialData: [ + { id: 1, title: `T-Shirt` }, + { id: 2, title: `Hoodie` }, + ], + }), + ) + const priceRanges = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-delete-price-ranges`, + getKey: (r) => r.id, + initialData: [ + { id: 1, productId: 1, regionId: 1 }, + { id: 3, productId: 2, regionId: 1 }, + ], + }), + ) + const regions = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-delete-regions`, + getKey: (r) => r.id, + initialData: [{ id: 1, name: `Europe` }], + }), + ) + + await Promise.all([ + products.preload(), + priceRanges.preload(), + regions.preload(), + ]) + + const collection = createLiveQueryCollection({ + id: `shared-corr-delete-live`, + query: (q) => + q.from({ p: products }).select(({ p }) => ({ + id: p.id, + title: p.title, + priceRanges: toArray( + q + .from({ pr: priceRanges }) + .where(({ pr }) => eq(pr.productId, p.id)) + .select(({ pr }) => ({ + id: pr.id, + regionId: pr.regionId, + region: toArray( + q + .from({ r: regions }) + .where(({ r }) => eq(r.id, pr.regionId)) + .select(({ r }) => ({ id: r.id, name: r.name })), + ), + })), + ), + })), + }) + await collection.preload() + + // Delete the Hoodie's price range (the sibling sharing regionId 1). + priceRanges.delete(3) + await new Promise((r) => setTimeout(r, 50)) + + const tree = toTree(collection) + const tshirt = tree.find((p: any) => p.title === `T-Shirt`) + const hoodie = tree.find((p: any) => p.title === `Hoodie`) + expect(tshirt.priceRanges.find((pr: any) => pr.id === 1).region).toEqual([ + { id: 1, name: `Europe` }, + ]) + expect(hoodie.priceRanges).toEqual([]) + }) + + it(`keeps routing when one of multiple same-parent siblings sharing a nested key is deleted`, async () => { + type Product = { id: number; title: string } + type PriceRange = { id: number; productId: number; regionId: number } + type Region = { id: number; name: string } + + const products = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-same-parent-products`, + getKey: (p) => p.id, + initialData: [{ id: 1, title: `T-Shirt` }], + }), + ) + const priceRanges = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-same-parent-price-ranges`, + getKey: (r) => r.id, + initialData: [ + { id: 1, productId: 1, regionId: 1 }, + { id: 2, productId: 1, regionId: 1 }, + ], + }), + ) + const regions = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-same-parent-regions`, + getKey: (r) => r.id, + initialData: [{ id: 1, name: `Europe` }], + }), + ) + + await Promise.all([ + products.preload(), + priceRanges.preload(), + regions.preload(), + ]) + + const collection = createLiveQueryCollection({ + id: `shared-corr-same-parent-live`, + query: (q) => + q.from({ p: products }).select(({ p }) => ({ + id: p.id, + title: p.title, + priceRanges: toArray( + q + .from({ pr: priceRanges }) + .where(({ pr }) => eq(pr.productId, p.id)) + .select(({ pr }) => ({ + id: pr.id, + regionId: pr.regionId, + region: toArray( + q + .from({ r: regions }) + .where(({ r }) => eq(r.id, pr.regionId)) + .select(({ r }) => ({ id: r.id, name: r.name })), + ), + })), + ), + })), + }) + await collection.preload() + + priceRanges.delete(1) + await new Promise((r) => setTimeout(r, 50)) + + regions.update(1, (draft) => { + draft.name = `Renamed Europe` + }) + await new Promise((r) => setTimeout(r, 50)) + + expect(toTree(collection)).toEqual([ + { + id: 1, + title: `T-Shirt`, + priceRanges: [ + { + id: 2, + regionId: 1, + region: [{ id: 1, name: `Renamed Europe` }], + }, + ], + }, + ]) + }) + + // The shared-correlation-key routing is independent of how each level is + // materialized, so the same guarantee must hold when the nested levels are + // left as live Collections (no toArray/materialize wrapper). + it(`resolves nested grandchildren for sibling groups when levels stay Collections`, async () => { + type Product = { id: number; title: string } + type PriceRange = { id: number; productId: number; regionId: number } + type Region = { id: number; name: string } + + const products = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-collection-products`, + getKey: (p) => p.id, + initialData: [ + { id: 1, title: `T-Shirt` }, + { id: 2, title: `Hoodie` }, + ], + }), + ) + const priceRanges = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-collection-price-ranges`, + getKey: (r) => r.id, + initialData: [ + { id: 1, productId: 1, regionId: 1 }, + { id: 2, productId: 1, regionId: 2 }, + { id: 3, productId: 2, regionId: 1 }, + ], + }), + ) + const regions = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-collection-regions`, + getKey: (r) => r.id, + initialData: [ + { id: 1, name: `Europe` }, + { id: 2, name: `North America` }, + ], + }), + ) + + await Promise.all([ + products.preload(), + priceRanges.preload(), + regions.preload(), + ]) + + const collection = createLiveQueryCollection({ + id: `shared-corr-collection-live`, + query: (q) => + q.from({ p: products }).select(({ p }) => ({ + id: p.id, + title: p.title, + priceRanges: q + .from({ pr: priceRanges }) + .where(({ pr }) => eq(pr.productId, p.id)) + .select(({ pr }) => ({ + id: pr.id, + regionId: pr.regionId, + region: q + .from({ r: regions }) + .where(({ r }) => eq(r.id, pr.regionId)) + .select(({ r }) => ({ id: r.id, name: r.name })), + })), + })), + }) + await collection.preload() + + // toTree recursively unwraps the nested live Collections into arrays. + expect(toTree(collection)).toEqual([ + { + id: 1, + title: `T-Shirt`, + priceRanges: [ + { id: 1, regionId: 1, region: [{ id: 1, name: `Europe` }] }, + { + id: 2, + regionId: 2, + region: [{ id: 2, name: `North America` }], + }, + ], + }, + { + id: 2, + title: `Hoodie`, + priceRanges: [ + { id: 3, regionId: 1, region: [{ id: 1, name: `Europe` }] }, + ], + }, + ]) + }) + + // Same guarantee for materialize(), which produces array/singleton + // snapshots through the same nested-includes routing. + it(`resolves nested grandchildren for sibling groups with materialize()`, async () => { + type Product = { id: number; title: string } + type PriceRange = { id: number; productId: number; regionId: number } + type Region = { id: number; name: string } + + const products = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-materialize-products`, + getKey: (p) => p.id, + initialData: [ + { id: 1, title: `T-Shirt` }, + { id: 2, title: `Hoodie` }, + ], + }), + ) + const priceRanges = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-materialize-price-ranges`, + getKey: (r) => r.id, + initialData: [ + { id: 1, productId: 1, regionId: 1 }, + { id: 2, productId: 1, regionId: 2 }, + { id: 3, productId: 2, regionId: 1 }, + ], + }), + ) + const regions = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-materialize-regions`, + getKey: (r) => r.id, + initialData: [ + { id: 1, name: `Europe` }, + { id: 2, name: `North America` }, + ], + }), + ) + + await Promise.all([ + products.preload(), + priceRanges.preload(), + regions.preload(), + ]) + + const collection = createLiveQueryCollection({ + id: `shared-corr-materialize-live`, + query: (q) => + q.from({ p: products }).select(({ p }) => ({ + id: p.id, + title: p.title, + priceRanges: materialize( + q + .from({ pr: priceRanges }) + .where(({ pr }) => eq(pr.productId, p.id)) + .select(({ pr }) => ({ + id: pr.id, + regionId: pr.regionId, + region: materialize( + q + .from({ r: regions }) + .where(({ r }) => eq(r.id, pr.regionId)) + .select(({ r }) => ({ id: r.id, name: r.name })), + ), + })), + ), + })), + }) + await collection.preload() + + expect(toTree(collection)).toEqual([ + { + id: 1, + title: `T-Shirt`, + priceRanges: [ + { id: 1, regionId: 1, region: [{ id: 1, name: `Europe` }] }, + { + id: 2, + regionId: 2, + region: [{ id: 2, name: `North America` }], + }, + ], + }, + { + id: 2, + title: `Hoodie`, + priceRanges: [ + { id: 3, regionId: 1, region: [{ id: 1, name: `Europe` }] }, + ], + }, + ]) + + // Post-load: insert a price range under Hoodie that references regionId 2, + // a correlation key already materialized for T-Shirt at load. This drives + // the late-arrival snapshot re-emit path through materialize() — the new + // sibling group must be seeded with the already-drained North America row + // without disturbing T-Shirt's existing nested rows. + priceRanges.insert({ id: 4, productId: 2, regionId: 2 }) + await new Promise((r) => setTimeout(r, 50)) + + const tree = toTree(collection) + const tshirt = tree.find((p: any) => p.title === `T-Shirt`) + const hoodie = tree.find((p: any) => p.title === `Hoodie`) + expect(tshirt.priceRanges.find((pr: any) => pr.id === 2).region).toEqual([ + { id: 2, name: `North America` }, + ]) + expect(hoodie.priceRanges.find((pr: any) => pr.id === 4).region).toEqual([ + { id: 2, name: `North America` }, + ]) + }) }) describe(`many sibling toArray includes with chained derived collections`, () => {