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..15a0a98025 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -907,8 +907,10 @@ export class CollectionConfigBuilder< entry.childCompilationResult.includes, syncState, ) - state.nestedRoutingIndex = new Map() - state.nestedRoutingReverseIndex = new Map() + state.nestedRoutingIndex = state.nestedSetups.map(() => new Map()) + state.nestedRoutingReverseIndex = state.nestedSetups.map( + () => new Map(), + ) } return state @@ -1150,6 +1152,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 +1168,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 +1202,35 @@ type IncludesOutputState = { correlationToParentKeys: Map> /** Shared nested pipeline setups (one per nested includes level) */ nestedSetups?: Array - /** nestedCorrelationKey → parentCorrelationKey */ - nestedRoutingIndex?: Map - /** parentCorrelationKey → Set */ - nestedRoutingReverseIndex?: Map> + /** + * Per nested setup: 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. + * + * Keyed per-setup (one map per nested include field): two sibling includes can + * resolve the same correlation value (regionId === currencyId), so a shared + * store would let one include's cleanup drop the route the other still needs. + */ + nestedRoutingIndex?: Array>>> + /** Per nested setup: parentCorrelationKey → Set. */ + nestedRoutingReverseIndex?: Array>> + /** + * 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 +1341,7 @@ function setupNestedPipelines( const setup: NestedIncludesSetup = { compilationResult: entry, buffer, + snapshot: new Map(), } // Recursively set up deeper levels @@ -1334,14 +1378,101 @@ function createPerEntryIncludesStates( if (setup.nestedSetups) { state.nestedSetups = setup.nestedSetups - state.nestedRoutingIndex = new Map() - state.nestedRoutingReverseIndex = new Map() + state.nestedRoutingIndex = setup.nestedSetups.map(() => new Map()) + state.nestedRoutingReverseIndex = setup.nestedSetups.map(() => new Map()) } return state }) } +function cloneSnapshotValue(value: T): T { + if (value == null || typeof value !== `object`) { + return value + } + + return (Array.isArray(value) ? [...value] : { ...value }) as T +} + +/** + * 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: cloneSnapshotValue(changes.value), + orderByIndex: changes.orderByIndex, + count: 0, + } + snap.set(childKey, row) + } + row.count += changes.inserts - changes.deletes + if (changes.inserts > 0) { + row.value = cloneSnapshotValue(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: cloneSnapshotValue(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. @@ -1353,46 +1484,60 @@ function drainNestedBuffers(state: IncludesOutputState): Set { for (let i = 0; i < state.nestedSetups.length; i++) { const setup = state.nestedSetups[i]! + const routeIndex = state.nestedRoutingIndex![i]! const toDelete: Array = [] for (const [nestedCorrelationKey, childChanges] of setup.buffer) { - const parentCorrelationKey = - state.nestedRoutingIndex!.get(nestedCorrelationKey) - if (parentCorrelationKey === undefined) { + const parentRoutes = routeIndex.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 +1553,44 @@ 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( + routeIndex: Map>>, + reverseIndex: Map>, + correlationKey: unknown, + nestedRoutingKey: unknown, + childKey: unknown, +): void { + const parents = routeIndex.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) { + routeIndex.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 = reverseIndex.get(correlationKey) + if (reverseSet) { + reverseSet.delete(nestedRoutingKey) + if (reverseSet.size === 0) { + reverseIndex.delete(correlationKey) + } + } + } +} + function updateRoutingIndex( state: IncludesOutputState, correlationKey: unknown, @@ -1415,8 +1598,17 @@ 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]! + const routeIndex = state.nestedRoutingIndex![i]! + const reverseIndex = state.nestedRoutingReverseIndex![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 +1622,73 @@ 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( + routeIndex, + reverseIndex, + correlationKey, + prevNestedKey, + childKey, + ) + perParent!.delete(childKey) + } + if (nestedCorrelationKey != null) { - state.nestedRoutingIndex!.set(nestedRoutingKey, correlationKey) - let reverseSet = state.nestedRoutingReverseIndex!.get(correlationKey) + let parents = routeIndex.get(nestedRoutingKey) + if (!parents) { + parents = new Map() + routeIndex.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 = reverseIndex.get(correlationKey) if (!reverseSet) { reverseSet = new Set() - state.nestedRoutingReverseIndex!.set(correlationKey, reverseSet) + reverseIndex.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 +1702,18 @@ 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( + routeIndex, + reverseIndex, + correlationKey, + nestedRoutingKey, + childKey, + ) + } + const perParent = childToNested.get(correlationKey) + if (perParent) { + perParent.delete(childKey) + if (perParent.size === 0) childToNested.delete(correlationKey) } } } @@ -1476,12 +1730,31 @@ function cleanRoutingIndexOnDelete( ): void { if (!state.nestedRoutingReverseIndex) return - const nestedKeys = state.nestedRoutingReverseIndex.get(correlationKey) - if (nestedKeys) { + // The whole parent group is gone, so drop it from every nested setup's route + // (along with all the child keys it tracked); other sibling parent groups may + // still reference the same nested correlation key. + for (let i = 0; i < state.nestedRoutingReverseIndex.length; i++) { + const reverseIndex = state.nestedRoutingReverseIndex[i]! + const routeIndex = state.nestedRoutingIndex![i]! + const nestedKeys = reverseIndex.get(correlationKey) + if (!nestedKeys) continue for (const nestedKey of nestedKeys) { - state.nestedRoutingIndex!.delete(nestedKey) + const parents = routeIndex.get(nestedKey) + if (parents) { + parents.delete(correlationKey) + if (parents.size === 0) { + routeIndex.delete(nestedKey) + } + } + } + reverseIndex.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) } - state.nestedRoutingReverseIndex.delete(correlationKey) } } diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index 0124ebbeac..58684bc53b 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -12,7 +12,11 @@ import { import { createCollection } from '../../src/collection/index.js' import { CleanupQueue } from '../../src/collection/cleanup-queue.js' import { localOnlyCollectionOptions } from '../../src/local-only.js' -import { mockSyncCollectionOptions, stripVirtualProps } from '../utils.js' +import { + flushPromises, + mockSyncCollectionOptions, + stripVirtualProps, +} from '../utils.js' import type { SyncConfig } from '../../src/types.js' type Project = { @@ -4902,6 +4906,980 @@ 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` }, + ]) + }) + + it(`keeps deeper nested includes reactive for a sibling group added after load`, async () => { + type Product = { id: number; title: string } + type PriceRange = { id: number; productId: number; regionId: number } + type Region = { id: number; name: string; countryId: number } + type Country = { id: number; name: string } + + const products = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-late-sibling-products`, + getKey: (p) => p.id, + initialData: [ + { id: 1, title: `T-Shirt` }, + { id: 2, title: `Hoodie` }, + ], + }), + ) + const priceRanges = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-late-sibling-price-ranges`, + getKey: (r) => r.id, + initialData: [{ id: 1, productId: 1, regionId: 1 }], + }), + ) + const regions = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-late-sibling-regions`, + getKey: (r) => r.id, + initialData: [{ id: 1, name: `Europe`, countryId: 1 }], + }), + ) + const countries = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-late-sibling-countries`, + getKey: (c) => c.id, + initialData: [{ id: 1, name: `France` }], + }), + ) + + await Promise.all([ + products.preload(), + priceRanges.preload(), + regions.preload(), + countries.preload(), + ]) + + const collection = createLiveQueryCollection({ + id: `shared-corr-late-sibling-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, + country: toArray( + q + .from({ c: countries }) + .where(({ c }) => eq(c.id, r.countryId)) + .select(({ c }) => ({ id: c.id, name: c.name })), + ), + })), + ), + })), + ), + })), + }) + await collection.preload() + + priceRanges.insert({ id: 2, productId: 2, regionId: 1 }) + await flushPromises() + + priceRanges.delete(1) + await flushPromises() + + countries.update(1, (draft) => { + draft.name = `Renamed France` + }) + await flushPromises() + + expect(toTree(collection)).toEqual([ + { + id: 1, + title: `T-Shirt`, + priceRanges: [], + }, + { + id: 2, + title: `Hoodie`, + priceRanges: [ + { + id: 2, + regionId: 1, + region: [ + { + id: 1, + name: `Europe`, + country: [{ id: 1, name: `Renamed France` }], + }, + ], + }, + ], + }, + ]) + }) + + // When two parent groups share a deepest correlation key and one of them is + // deleted, the surviving group must keep its nested grandchildren. + it(`resolves two nested includes on the same child independently when they share a correlation value`, 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: `shared-corr-value-products`, + getKey: (p) => p.id, + initialData: [{ id: 1, title: `T-Shirt` }], + }), + ) + // The price range points at region 1 and currency 1: both nested includes + // correlate on the same value. + const priceRanges = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-value-price-ranges`, + getKey: (r) => r.id, + initialData: [{ id: 1, productId: 1, regionId: 1, currencyId: 1 }], + }), + ) + const regions = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-value-regions`, + getKey: (r) => r.id, + initialData: [ + { id: 1, name: `Europe` }, + { id: 2, name: `North America` }, + ], + }), + ) + const currencies = createCollection( + localOnlyCollectionOptions({ + id: `shared-corr-value-currencies`, + getKey: (c) => c.id, + initialData: [{ id: 1, code: `EUR` }], + }), + ) + + await Promise.all([ + products.preload(), + priceRanges.preload(), + regions.preload(), + currencies.preload(), + ]) + + const collection = createLiveQueryCollection({ + id: `shared-corr-value-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, + currency: toArray( + q + .from({ c: currencies }) + .where(({ c }) => eq(c.id, pr.currencyId)) + .select(({ c }) => ({ id: c.id, code: c.code })), + ), + region: toArray( + q + .from({ r: regions }) + .where(({ r }) => eq(r.id, pr.regionId)) + .select(({ r }) => ({ id: r.id, name: r.name })), + ), + })), + ), + })), + }) + await collection.preload() + + // Re-point only the region include; the currency include still resolves 1. + priceRanges.update(1, (draft) => { + draft.regionId = 2 + }) + await new Promise((r) => setTimeout(r, 50)) + + // A later currency change must still reach the currency include. + currencies.update(1, (draft) => { + draft.code = `USD` + }) + await new Promise((r) => setTimeout(r, 50)) + + expect(toTree(collection)).toEqual([ + { + id: 1, + title: `T-Shirt`, + priceRanges: [ + { + id: 1, + currency: [{ id: 1, code: `USD` }], + region: [{ id: 2, name: `North America` }], + }, + ], + }, + ]) + }) + + 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`, () => {