From 309e89e6eb1a9682c34bd0bce5ca3455d15e957b Mon Sep 17 00:00:00 2001 From: Iwan Birrer Date: Sat, 4 Apr 2026 21:29:09 +0200 Subject: [PATCH] fix: duplicate alias in sibling includes silently breaks nested children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two sibling includes in .select() used the same alias (e.g., { i: issues } and { i: tags }), nested child collections silently produced empty results. The root cause was that all includes aliases were flattened into a single namespace — sharing one D2 graph input and one subscription, so the second sibling's collection data overwrote the first. Fix: give each includes subquery its own independent D2 input. - extractCollectionAliases no longer traverses into IncludesSubquery nodes, keeping collectionByAlias scoped to the top-level query. - compileQuery accepts a createInput factory; when processing includes, each child gets fresh inputs for its source aliases via collectAllSourceAliases + createInput(). - compileBasePipeline merges the new inputs into inputsCache and compiledAliasToCollectionId under unique keys (__inc_N_alias), so each gets its own subscription feeding the correct collection. --- packages/db/src/query/compiler/index.ts | 49 ++++++++++-- .../query/live/collection-config-builder.ts | 18 +++++ packages/db/src/query/live/utils.ts | 3 +- packages/db/tests/query/includes.test.ts | 75 +++++++++++++++++++ 4 files changed, 139 insertions(+), 6 deletions(-) diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index 70786ca8d..7f841341c 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -141,6 +141,9 @@ export function compileQuery( // For includes: parent key stream to inner-join with this query's FROM parentKeyStream?: KeyedStream, childCorrelationField?: PropRef, + // Factory to create a fresh D2 input for an includes subquery alias. + // Each sibling gets its own input to avoid alias collisions. + createInput?: (alias: string, collectionId: string) => KeyedStream, ): CompilationResult { // Check if the original raw query has already been compiled const cachedResult = cache.get(rawQuery) @@ -391,10 +394,16 @@ export function compileQuery( } : subquery.query + // Give each includes child its own D2 inputs so that sibling + // subqueries using the same alias letter get independent streams. + const childInputs = createInput + ? createInputsForSources(childQuery, allInputs, createInput) + : allInputs + // Recursively compile child query WITH the parent key stream const childResult = compileQuery( childQuery, - allInputs, + childInputs, collections, subscriptions, callbacks, @@ -405,12 +414,9 @@ export function compileQuery( queryMapping, parentKeys, subquery.childCorrelationField, + createInput, ) - // Merge child's alias metadata into parent's - Object.assign(aliasToCollectionId, childResult.aliasToCollectionId) - Object.assign(aliasRemapping, childResult.aliasRemapping) - includesResults.push({ pipeline: childResult.pipeline, fieldName: subquery.fieldName, @@ -741,6 +747,39 @@ function collectDirectCollectionAliases(query: QueryIR): Set { return aliases } +/** + * Creates fresh D2 inputs for all source aliases (FROM + JOINs) in a query, + * following FROM/JOIN subqueries recursively but skipping includes. + * Returns a copy of `parentInputs` with fresh inputs for the child's own aliases. + */ +function createInputsForSources( + query: QueryIR, + parentInputs: Record, + createInput: (alias: string, collectionId: string) => KeyedStream, +): Record { + const inputs = { ...parentInputs } + + function walkFrom(from: CollectionRef | QueryRef) { + if (from.type === `collectionRef`) { + inputs[from.alias] = createInput(from.alias, from.collection.id) + } else if (from.type === `queryRef`) { + walkQuery(from.query) + } + } + + function walkQuery(q: QueryIR) { + walkFrom(q.from) + if (q.join) { + for (const join of q.join) { + walkFrom(join.from) + } + } + } + + walkQuery(query) + return inputs +} + /** * Validates the structure of a query and its subqueries. * Checks that subqueries don't reuse collection aliases from parent queries. diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 3a4e948e1..0bd9e9263 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -680,6 +680,18 @@ export class CollectionConfigBuilder< ]), ) + // Each includes subquery gets a fresh D2 input under a unique key so + // that sibling subqueries using the same alias don't share a stream. + let includesInputCounter = 0 + const includesAliasById: Record = {} + const createIncludesInput = (alias: string, collectionId: string) => { + const uniqueKey = `__inc_${includesInputCounter++}_${alias}` + const input = this.graphCache!.newInput() + this.inputsCache![uniqueKey] = input + includesAliasById[uniqueKey] = collectionId + return input as KeyedStream + } + const compilation = compileQuery( this.query, this.inputsCache as Record, @@ -691,12 +703,18 @@ export class CollectionConfigBuilder< (windowFn: (options: WindowOptions) => void) => { this.windowFn = windowFn }, + undefined, // cache + undefined, // queryMapping + undefined, // parentKeyStream + undefined, // childCorrelationField + createIncludesInput, ) this.pipelineCache = compilation.pipeline this.sourceWhereClausesCache = compilation.sourceWhereClauses this.compiledAliasToCollectionId = compilation.aliasToCollectionId this.includesCache = compilation.includes + Object.assign(this.compiledAliasToCollectionId, includesAliasById) // Defensive check: verify all compiled aliases have corresponding inputs // This should never happen since all aliases come from user declarations, diff --git a/packages/db/src/query/live/utils.ts b/packages/db/src/query/live/utils.ts index 6f37a0587..bfd0a9b3b 100644 --- a/packages/db/src/query/live/utils.ts +++ b/packages/db/src/query/live/utils.ts @@ -142,8 +142,9 @@ export function extractCollectionAliases( if (typeof key === `string` && key.startsWith(`__SPREAD_SENTINEL__`)) { continue } + // Skip includes — their aliases are scoped independently via separate D2 inputs if (value instanceof IncludesSubquery) { - traverse(value.query) + continue } else if (isNestedSelectObject(value)) { traverseSelect(value) } diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index 9d923bdfa..9b08008e3 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -4061,4 +4061,79 @@ describe(`includes subqueries`, () => { ]) }) }) + + describe(`duplicate alias in sibling includes`, () => { + type Tag = { + id: number + projectId: number + label: string + } + + const sampleTags: Array = [ + { id: 1, projectId: 1, label: `urgent` }, + { id: 2, projectId: 1, label: `frontend` }, + { id: 3, projectId: 2, label: `backend` }, + ] + + function createTagsCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `includes-tags`, + getKey: (t) => t.id, + initialData: sampleTags, + }), + ) + } + + it(`same alias in sibling includes does not break nested children`, async () => { + // Tags uses alias "i" — same as issues. Each sibling gets its own + // independent D2 input, so nested comments are still populated. + const tags = createTagsCollection() + + const collection = createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ + id: i.id, + title: i.title, + comments: q + .from({ c: comments }) + .where(({ c }) => eq(c.issueId, i.id)) + .select(({ c }) => ({ + id: c.id, + body: c.body, + })), + })), + tags: q + .from({ i: tags }) // same alias "i" as issues + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ + id: i.id, + label: i.label, + })), + })), + ) + + await collection.preload() + + const alpha = collection.get(1) as any + + // Tags should be populated + expect(childItems(alpha.tags)).toEqual([ + { id: 1, label: `urgent` }, + { id: 2, label: `frontend` }, + ]) + + // Nested comments should also be populated despite the duplicate alias "i" + const issue10 = alpha.issues.get(10) + expect(childItems(issue10.comments)).toEqual([ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ]) + }) + }) })