From 2e48984cd89d830c3d7fde11790ab25b7dcae56d Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 25 Feb 2026 10:45:14 +0100 Subject: [PATCH 1/7] feat: add toArray() for includes subqueries toArray() wraps an includes subquery so the parent row contains Array instead of Collection. When children change, the parent row is re-emitted with a fresh array snapshot. - Add ToArrayWrapper class and toArray() function - Add materializeAsArray flag to IncludesSubquery IR node - Detect ToArrayWrapper in builder, pass flag through compiler - Re-emit parent rows on child changes for toArray entries - Add SelectValue type support for ToArrayWrapper - Add tests for basic toArray, reactivity, ordering, and limits Co-Authored-By: Claude Opus 4.6 --- packages/db/src/query/builder/functions.ts | 9 + packages/db/src/query/builder/index.ts | 13 +- packages/db/src/query/builder/types.ts | 2 + packages/db/src/query/compiler/index.ts | 3 + packages/db/src/query/index.ts | 2 + packages/db/src/query/ir.ts | 1 + .../query/live/collection-config-builder.ts | 69 ++++++-- packages/db/tests/query/includes.test.ts | 161 +++++++++++++++++- 8 files changed, 245 insertions(+), 15 deletions(-) diff --git a/packages/db/src/query/builder/functions.ts b/packages/db/src/query/builder/functions.ts index 887c1468d..93147fd08 100644 --- a/packages/db/src/query/builder/functions.ts +++ b/packages/db/src/query/builder/functions.ts @@ -3,6 +3,7 @@ import { toExpression } from './ref-proxy.js' import type { BasicExpression } from '../ir' import type { RefProxy } from './ref-proxy.js' import type { RefLeaf } from './types.js' +import type { QueryBuilder } from './index.js' type StringRef = | RefLeaf @@ -376,3 +377,11 @@ export const operators = [ ] as const export type OperatorName = (typeof operators)[number] + +export class ToArrayWrapper { + constructor(public readonly query: QueryBuilder) {} +} + +export function toArray(query: QueryBuilder): ToArrayWrapper { + return new ToArrayWrapper(query) +} diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index 7b7b6d3e8..9e4747841 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -23,6 +23,7 @@ import { createRefProxyWithSelected, toExpression, } from './ref-proxy.js' +import { ToArrayWrapper } from './functions.js' import type { NamespacedRow, SingleResult } from '../../types.js' import type { Aggregate, @@ -863,7 +864,14 @@ function buildNestedSelect(obj: any, parentAliases: Array = []): any { continue } if (v instanceof BaseQueryBuilder) { - out[k] = buildIncludesSubquery(v, k, parentAliases) + out[k] = buildIncludesSubquery(v, k, parentAliases, false) + continue + } + if (v instanceof ToArrayWrapper) { + if (!(v.query instanceof BaseQueryBuilder)) { + throw new Error(`toArray() must wrap a subquery builder`) + } + out[k] = buildIncludesSubquery(v.query, k, parentAliases, true) continue } out[k] = buildNestedSelect(v, parentAliases) @@ -880,6 +888,7 @@ function buildIncludesSubquery( childBuilder: BaseQueryBuilder, fieldName: string, parentAliases: Array, + materializeAsArray: boolean, ): IncludesSubquery { const childQuery = childBuilder._getQuery() @@ -943,7 +952,7 @@ function buildIncludesSubquery( where: modifiedWhere.length > 0 ? modifiedWhere : undefined, } - return new IncludesSubquery(modifiedQuery, parentRef, childRef, fieldName) + return new IncludesSubquery(modifiedQuery, parentRef, childRef, fieldName, materializeAsArray) } /** diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 11360dd82..42d13bc8f 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -9,6 +9,7 @@ import type { Value, } from '../ir.js' import type { QueryBuilder } from './index.js' +import type { ToArrayWrapper } from './functions.js' /** * Context - The central state container for query builder operations @@ -174,6 +175,7 @@ type SelectValue = | undefined // Optional values | { [key: string]: SelectValue } | Array> + | ToArrayWrapper // toArray() wrapped subquery // Recursive shape for select objects allowing nested projections type SelectShape = { [key: string]: SelectValue | SelectShape } diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index d749e6e3a..6834fea0d 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -55,6 +55,8 @@ export interface IncludesCompilationResult { hasOrderBy: boolean /** Full compilation result for the child query (for nested includes + alias tracking) */ childCompilationResult: CompilationResult + /** When true, the output layer materializes children as Array instead of Collection */ + materializeAsArray: boolean } /** @@ -320,6 +322,7 @@ export function compileQuery( subquery.query.orderBy && subquery.query.orderBy.length > 0 ), childCompilationResult: childResult, + materializeAsArray: subquery.materializeAsArray, }) // Replace includes entry in select with a null placeholder diff --git a/packages/db/src/query/index.ts b/packages/db/src/query/index.ts index c8a8d5e38..810cb0cf2 100644 --- a/packages/db/src/query/index.ts +++ b/packages/db/src/query/index.ts @@ -60,6 +60,8 @@ export { sum, min, max, + // Includes helpers + toArray, } from './builder/functions.js' // Ref proxy utilities diff --git a/packages/db/src/query/ir.ts b/packages/db/src/query/ir.ts index 64ddd22c7..04c28afd7 100644 --- a/packages/db/src/query/ir.ts +++ b/packages/db/src/query/ir.ts @@ -139,6 +139,7 @@ export class IncludesSubquery extends BaseExpression { public correlationField: PropRef, // Parent-side ref (e.g., project.id) public childCorrelationField: PropRef, // Child-side ref (e.g., issue.projectId) public fieldName: string, // Result field name (e.g., "issues") + public materializeAsArray: boolean = false, // When true, parent gets Array instead of Collection ) { super() } diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index c2d7387d8..1e0b9204b 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -788,6 +788,7 @@ export class CollectionConfigBuilder< config.collection, this.id, hasParentChanges ? changesToApply : null, + config, ) } @@ -820,6 +821,7 @@ export class CollectionConfigBuilder< correlationField: entry.correlationField, childCorrelationField: entry.childCorrelationField, hasOrderBy: entry.hasOrderBy, + materializeAsArray: entry.materializeAsArray, childRegistry: new Map(), pendingChildChanges: new Map(), correlationToParentKeys: new Map(), @@ -1311,6 +1313,8 @@ type IncludesOutputState = { childCorrelationField: PropRef /** Whether the child query has an ORDER BY clause */ hasOrderBy: boolean + /** When true, parent gets Array instead of Collection */ + materializeAsArray: boolean /** Maps correlation key value → child Collection entry */ childRegistry: Map /** Pending child changes: correlationKey → Map */ @@ -1641,6 +1645,7 @@ function flushIncludesState( parentCollection: Collection, parentId: string, parentChanges: Map> | null, + parentSyncMethods: SyncMethods | null, ): void { for (const state of includesState) { // Phase 1: Parent INSERTs — ensure a child Collection exists for every parent @@ -1676,21 +1681,31 @@ function flushIncludesState( } parentKeys.add(parentKey) - // Attach child Collection to the parent result - parentResult[state.fieldName] = - state.childRegistry.get(correlationKey)!.collection + // Attach child Collection (or array snapshot for toArray) to the parent result + if (state.materializeAsArray) { + parentResult[state.fieldName] = [ + ...state.childRegistry.get(correlationKey)!.collection.toArray, + ] + } else { + parentResult[state.fieldName] = + state.childRegistry.get(correlationKey)!.collection + } } } } } + // Track affected correlation keys for toArray re-emit (before clearing pendingChildChanges) + const affectedCorrelationKeys = state.materializeAsArray + ? new Set(state.pendingChildChanges.keys()) + : null + // Phase 2: Child changes — apply to child Collections // Track which entries had child changes and capture their childChanges maps const entriesWithChildChanges = new Map< unknown, { entry: ChildCollectionEntry; childChanges: Map> } >() - if (state.pendingChildChanges.size > 0) { for (const [correlationKey, childChanges] of state.pendingChildChanges) { // Ensure child Collection exists for this correlation key @@ -1706,14 +1721,17 @@ function flushIncludesState( state.childRegistry.set(correlationKey, entry) } - // Attach the child Collection to ANY parent that has this correlation key - attachChildCollectionToParent( - parentCollection, - state.fieldName, - correlationKey, - state.correlationToParentKeys, - entry.collection, - ) + // For non-toArray: attach the child Collection to ANY parent that has this correlation key + // For toArray: skip — the array snapshot is set during re-emit below + if (!state.materializeAsArray) { + attachChildCollectionToParent( + parentCollection, + state.fieldName, + correlationKey, + state.correlationToParentKeys, + entry.collection, + ) + } // Apply child changes to the child Collection if (entry.syncMethods) { @@ -1760,6 +1778,7 @@ function flushIncludesState( entry.collection, entry.collection.id, childChanges, + null, ) } } @@ -1773,10 +1792,36 @@ function flushIncludesState( entry.collection, entry.collection.id, null, + null, ) } } + // For toArray entries: re-emit affected parents with updated array snapshots + if ( + state.materializeAsArray && + parentSyncMethods && + affectedCorrelationKeys && + affectedCorrelationKeys.size > 0 + ) { + parentSyncMethods.begin() + for (const correlationKey of affectedCorrelationKeys) { + const parentKeys = state.correlationToParentKeys.get(correlationKey) + if (!parentKeys) continue + const entry = state.childRegistry.get(correlationKey) + for (const parentKey of parentKeys) { + const item = parentCollection.get(parentKey as any) + if (item) { + if (entry) { + item[state.fieldName] = [...entry.collection.toArray] + } + parentSyncMethods.write({ value: item, type: `update` }) + } + } + } + parentSyncMethods.commit() + } + // Phase 5: Parent DELETEs — dispose child Collections and clean up if (parentChanges) { const fieldPath = state.correlationField.path.slice(1) diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index 07a76a5c8..9a996fd9f 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { createLiveQueryCollection, eq } from '../../src/query/index.js' +import { createLiveQueryCollection, eq, toArray } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' import { mockSyncCollectionOptions } from '../utils.js' @@ -565,4 +565,163 @@ describe(`includes subqueries`, () => { ]) }) }) + + describe(`toArray`, () => { + function buildToArrayQuery() { + return createLiveQueryCollection((q) => + q + .from({ p: projects }) + .select(({ p }) => ({ + id: p.id, + name: p.name, + issues: toArray( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ + id: i.id, + title: i.title, + })), + ), + })), + ) + } + + it(`produces arrays on parent rows, not Collections`, async () => { + const collection = buildToArrayQuery() + await collection.preload() + + const alpha = collection.get(1) as any + expect(Array.isArray(alpha.issues)).toBe(true) + expect(alpha.issues.sort((a: any, b: any) => a.id - b.id)).toEqual([ + { id: 10, title: `Bug in Alpha` }, + { id: 11, title: `Feature for Alpha` }, + ]) + + const beta = collection.get(2) as any + expect(Array.isArray(beta.issues)).toBe(true) + expect(beta.issues).toEqual([{ id: 20, title: `Bug in Beta` }]) + }) + + it(`empty parents get empty arrays`, async () => { + const collection = buildToArrayQuery() + await collection.preload() + + const gamma = collection.get(3) as any + expect(Array.isArray(gamma.issues)).toBe(true) + expect(gamma.issues).toEqual([]) + }) + + it(`adding a child re-emits the parent with updated array`, async () => { + const collection = buildToArrayQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 12, projectId: 1, title: `New Alpha issue` }, + }) + issues.utils.commit() + + const alpha = collection.get(1) as any + expect(Array.isArray(alpha.issues)).toBe(true) + expect(alpha.issues.sort((a: any, b: any) => a.id - b.id)).toEqual([ + { id: 10, title: `Bug in Alpha` }, + { id: 11, title: `Feature for Alpha` }, + { id: 12, title: `New Alpha issue` }, + ]) + }) + + it(`removing a child re-emits the parent with updated array`, async () => { + const collection = buildToArrayQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `delete`, + value: sampleIssues.find((i) => i.id === 10)!, + }) + issues.utils.commit() + + const alpha = collection.get(1) as any + expect(alpha.issues).toEqual([{ id: 11, title: `Feature for Alpha` }]) + }) + + it(`array respects ORDER BY`, async () => { + const collection = createLiveQueryCollection((q) => + q + .from({ p: projects }) + .select(({ p }) => ({ + id: p.id, + name: p.name, + issues: toArray( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .orderBy(({ i }) => i.title, `asc`) + .select(({ i }) => ({ + id: i.id, + title: i.title, + })), + ), + })), + ) + + await collection.preload() + + const alpha = collection.get(1) as any + expect(alpha.issues).toEqual([ + { id: 10, title: `Bug in Alpha` }, + { id: 11, title: `Feature for Alpha` }, + ]) + }) + + it(`ordered toArray with limit applied per parent`, async () => { + const collection = createLiveQueryCollection((q) => + q + .from({ p: projects }) + .select(({ p }) => ({ + id: p.id, + name: p.name, + issues: toArray( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .orderBy(({ i }) => i.title, `asc`) + .limit(1) + .select(({ i }) => ({ + id: i.id, + title: i.title, + })), + ), + })), + ) + + await collection.preload() + + const alpha = collection.get(1) as any + expect(alpha.issues).toEqual([{ id: 10, title: `Bug in Alpha` }]) + + const beta = collection.get(2) as any + expect(beta.issues).toEqual([{ id: 20, title: `Bug in Beta` }]) + + const gamma = collection.get(3) as any + expect(gamma.issues).toEqual([]) + }) + + it(`existing Collection-based includes still work`, async () => { + // Non-toArray includes should be unaffected + const collection = buildIncludesQuery() + await collection.preload() + + const alpha = collection.get(1) as any + // Should be a Collection, not an array + expect(Array.isArray(alpha.issues)).toBe(false) + expect(alpha.issues.toArray).toBeDefined() + expect(childItems(alpha.issues)).toEqual([ + { id: 10, title: `Bug in Alpha` }, + { id: 11, title: `Feature for Alpha` }, + ]) + }) + }) }) From 2b70338f8bf0469a1ef7be80b84af0e2fc635269 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 25 Feb 2026 11:29:32 +0100 Subject: [PATCH 2/7] Removed obsolete test --- packages/db/tests/query/includes.test.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index 9a996fd9f..96825db2e 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -709,19 +709,5 @@ describe(`includes subqueries`, () => { expect(gamma.issues).toEqual([]) }) - it(`existing Collection-based includes still work`, async () => { - // Non-toArray includes should be unaffected - const collection = buildIncludesQuery() - await collection.preload() - - const alpha = collection.get(1) as any - // Should be a Collection, not an array - expect(Array.isArray(alpha.issues)).toBe(false) - expect(alpha.issues.toArray).toBeDefined() - expect(childItems(alpha.issues)).toEqual([ - { id: 10, title: `Bug in Alpha` }, - { id: 11, title: `Feature for Alpha` }, - ]) - }) - }) +}) }) From 24dbd8bf7387e97bb912285ae205324992339894 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 25 Feb 2026 13:50:38 +0100 Subject: [PATCH 3/7] Small fix --- packages/db/src/query/live/collection-config-builder.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 1e0b9204b..8e5b4d658 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -1415,6 +1415,7 @@ function createPerEntryIncludesStates( correlationField: setup.compilationResult.correlationField, childCorrelationField: setup.compilationResult.childCorrelationField, hasOrderBy: setup.compilationResult.hasOrderBy, + materializeAsArray: setup.compilationResult.materializeAsArray, childRegistry: new Map(), pendingChildChanges: new Map(), correlationToParentKeys: new Map(), From 18e712e1b183083996c434198f2411a80347d34d Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 25 Feb 2026 14:14:18 +0100 Subject: [PATCH 4/7] Tests for changes to deeply nested queries --- packages/db/tests/query/includes.test.ts | 688 ++++++++++++++++++++++- 1 file changed, 678 insertions(+), 10 deletions(-) diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index 96825db2e..74f4f2e78 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -84,17 +84,27 @@ function childItems(collection: any, sortKey = `id`): Array { * plain sorted array, turning any nested child Collections into nested arrays. * This lets tests compare the full hierarchical result as a single literal. */ -function toTree(collection: any, sortKey = `id`): Array { - const rows = [...collection.toArray].sort( - (a: any, b: any) => a[sortKey] - b[sortKey], - ) +function toTree(collectionOrArray: any, sortKey = `id`): Array { + const rows = ( + Array.isArray(collectionOrArray) + ? [...collectionOrArray] + : [...collectionOrArray.toArray] + ).sort((a: any, b: any) => a[sortKey] - b[sortKey]) return rows.map((row: any) => { + if (typeof row !== `object` || row === null) return row const out: Record = {} for (const [key, value] of Object.entries(row)) { - out[key] = - value && typeof value === `object` && `toArray` in (value as any) - ? toTree(value, sortKey) - : value + if (Array.isArray(value)) { + out[key] = toTree(value, sortKey) + } else if ( + value && + typeof value === `object` && + `toArray` in (value as any) + ) { + out[key] = toTree(value, sortKey) + } else { + out[key] = value + } } return out }) @@ -453,7 +463,14 @@ describe(`includes subqueries`, () => { }) }) - describe(`nested includes`, () => { + // Nested includes: two-level parent → child → grandchild (Project → Issue → Comment). + // Each level (Issue/Comment) can be materialized as a live Collection or a plain array (via toArray). + // We test all four combinations: + // Collection → Collection — both levels are live Collections + // Collection → toArray — issues are Collections, comments are arrays + // toArray → Collection — issues are arrays, comments are Collections + // toArray → toArray — both levels are plain arrays + describe(`nested includes: Collection → Collection`, () => { function buildNestedQuery() { return createLiveQueryCollection((q) => q.from({ p: projects }).select(({ p }) => ({ @@ -564,6 +581,104 @@ describe(`includes subqueries`, () => { { id: 101, body: `Fixed it` }, ]) }) + + it(`adding an issue (middle-level insert) creates a child with empty comments`, async () => { + const collection = buildNestedQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 30, projectId: 3, title: `Gamma issue` }, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { id: 10, title: `Bug in Alpha`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { id: 11, title: `Feature for Alpha`, comments: [] }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + }, + { + id: 3, + name: `Gamma`, + issues: [{ id: 30, title: `Gamma issue`, comments: [] }], + }, + ]) + }) + + it(`removing an issue (middle-level delete) removes it from the parent`, async () => { + const collection = buildNestedQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `delete`, + value: sampleIssues.find((i) => i.id === 11)!, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { id: 10, title: `Bug in Alpha`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + }, + { + id: 3, + name: `Gamma`, + issues: [], + }, + ]) + }) + + it(`updating an issue title (middle-level update) reflects in the parent`, async () => { + const collection = buildNestedQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `update`, + value: { id: 10, projectId: 1, title: `Renamed Bug` }, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { id: 10, title: `Renamed Bug`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { id: 11, title: `Feature for Alpha`, comments: [] }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + }, + { + id: 3, + name: `Gamma`, + issues: [], + }, + ]) + }) }) describe(`toArray`, () => { @@ -709,5 +824,558 @@ describe(`includes subqueries`, () => { expect(gamma.issues).toEqual([]) }) -}) + }) + + describe(`nested includes: Collection → toArray`, () => { + function buildCollectionToArrayQuery() { + return 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: toArray( + q + .from({ c: comments }) + .where(({ c }) => eq(c.issueId, i.id)) + .select(({ c }) => ({ + id: c.id, + body: c.body, + })), + ), + })), + })), + ) + } + + it(`initial load: issues are Collections, comments are arrays`, async () => { + const collection = buildCollectionToArrayQuery() + await collection.preload() + + const alpha = collection.get(1) as any + // issues should be a Collection + expect(alpha.issues.toArray).toBeDefined() + + const issue10 = alpha.issues.get(10) + // comments should be an array + expect(Array.isArray(issue10.comments)).toBe(true) + expect(issue10.comments.sort((a: any, b: any) => a.id - b.id)).toEqual([ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ]) + + const issue11 = alpha.issues.get(11) + expect(Array.isArray(issue11.comments)).toBe(true) + expect(issue11.comments).toEqual([]) + }) + + it(`adding a comment (grandchild-only change) updates the issue's comments array`, async () => { + const collection = buildCollectionToArrayQuery() + await collection.preload() + + const issue11Before = (collection.get(1) as any).issues.get(11) + expect(issue11Before.comments).toEqual([]) + + comments.utils.begin() + comments.utils.write({ + type: `insert`, + value: { id: 110, issueId: 11, body: `Great feature` }, + }) + comments.utils.commit() + + const issue11After = (collection.get(1) as any).issues.get(11) + expect(Array.isArray(issue11After.comments)).toBe(true) + expect(issue11After.comments).toEqual([ + { id: 110, body: `Great feature` }, + ]) + }) + + it(`removing a comment (grandchild-only change) updates the issue's comments array`, async () => { + const collection = buildCollectionToArrayQuery() + await collection.preload() + + const issue10Before = (collection.get(1) as any).issues.get(10) + expect(issue10Before.comments).toHaveLength(2) + + comments.utils.begin() + comments.utils.write({ + type: `delete`, + value: sampleComments.find((c) => c.id === 100)!, + }) + comments.utils.commit() + + const issue10After = (collection.get(1) as any).issues.get(10) + expect(issue10After.comments).toEqual([{ id: 101, body: `Fixed it` }]) + }) + + it(`adding an issue (middle-level insert) creates a child with empty comments array`, async () => { + const collection = buildCollectionToArrayQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 30, projectId: 3, title: `Gamma issue` }, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { id: 10, title: `Bug in Alpha`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { id: 11, title: `Feature for Alpha`, comments: [] }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + }, + { + id: 3, + name: `Gamma`, + issues: [{ id: 30, title: `Gamma issue`, comments: [] }], + }, + ]) + }) + + it(`removing an issue (middle-level delete) removes it from the parent`, async () => { + const collection = buildCollectionToArrayQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `delete`, + value: sampleIssues.find((i) => i.id === 11)!, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { id: 10, title: `Bug in Alpha`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + }, + { + id: 3, + name: `Gamma`, + issues: [], + }, + ]) + }) + + it(`updating an issue title (middle-level update) reflects in the parent`, async () => { + const collection = buildCollectionToArrayQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `update`, + value: { id: 10, projectId: 1, title: `Renamed Bug` }, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { id: 10, title: `Renamed Bug`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { id: 11, title: `Feature for Alpha`, comments: [] }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + }, + { + id: 3, + name: `Gamma`, + issues: [], + }, + ]) + }) + }) + + describe(`nested includes: toArray → Collection`, () => { + function buildToArrayToCollectionQuery() { + return createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: toArray( + 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, + })), + })), + ), + })), + ) + } + + it(`initial load: issues are arrays, comments are Collections`, async () => { + const collection = buildToArrayToCollectionQuery() + await collection.preload() + + const alpha = collection.get(1) as any + expect(Array.isArray(alpha.issues)).toBe(true) + + const sortedIssues = alpha.issues.sort( + (a: any, b: any) => a.id - b.id, + ) + // comments should be Collections + expect(sortedIssues[0].comments.toArray).toBeDefined() + expect( + childItems(sortedIssues[0].comments), + ).toEqual([ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ]) + + expect(sortedIssues[1].comments.toArray).toBeDefined() + expect(childItems(sortedIssues[1].comments)).toEqual([]) + }) + + it(`adding a comment updates the nested Collection (live reference)`, async () => { + const collection = buildToArrayToCollectionQuery() + await collection.preload() + + const alpha = collection.get(1) as any + const issue11 = alpha.issues.find((i: any) => i.id === 11) + expect(childItems(issue11.comments)).toEqual([]) + + comments.utils.begin() + comments.utils.write({ + type: `insert`, + value: { id: 110, issueId: 11, body: `Great feature` }, + }) + comments.utils.commit() + + // The Collection reference on the issue object is live + expect(childItems(issue11.comments)).toEqual([ + { id: 110, body: `Great feature` }, + ]) + }) + + it(`adding an issue re-emits the parent with updated array including nested Collection`, async () => { + const collection = buildToArrayToCollectionQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 30, projectId: 3, title: `Gamma issue` }, + }) + issues.utils.commit() + + const gamma = collection.get(3) as any + expect(Array.isArray(gamma.issues)).toBe(true) + expect(gamma.issues).toHaveLength(1) + expect(gamma.issues[0].id).toBe(30) + expect(gamma.issues[0].comments.toArray).toBeDefined() + expect(childItems(gamma.issues[0].comments)).toEqual([]) + }) + + it(`removing an issue re-emits the parent with updated array`, async () => { + const collection = buildToArrayToCollectionQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `delete`, + value: sampleIssues.find((i) => i.id === 11)!, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { id: 10, title: `Bug in Alpha`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + }, + { + id: 3, + name: `Gamma`, + issues: [], + }, + ]) + }) + + it(`updating an issue title re-emits the parent with updated array`, async () => { + const collection = buildToArrayToCollectionQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `update`, + value: { id: 10, projectId: 1, title: `Renamed Bug` }, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { id: 10, title: `Renamed Bug`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { id: 11, title: `Feature for Alpha`, comments: [] }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + }, + { + id: 3, + name: `Gamma`, + issues: [], + }, + ]) + }) + }) + + describe(`nested includes: toArray → toArray`, () => { + function buildToArrayToArrayQuery() { + return createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: toArray( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ + id: i.id, + title: i.title, + comments: toArray( + q + .from({ c: comments }) + .where(({ c }) => eq(c.issueId, i.id)) + .select(({ c }) => ({ + id: c.id, + body: c.body, + })), + ), + })), + ), + })), + ) + } + + it(`initial load: both levels are arrays`, async () => { + const collection = buildToArrayToArrayQuery() + await collection.preload() + + const alpha = collection.get(1) as any + expect(Array.isArray(alpha.issues)).toBe(true) + + const sortedIssues = alpha.issues.sort( + (a: any, b: any) => a.id - b.id, + ) + expect(Array.isArray(sortedIssues[0].comments)).toBe(true) + expect( + sortedIssues[0].comments.sort((a: any, b: any) => a.id - b.id), + ).toEqual([ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ]) + + expect(sortedIssues[1].comments).toEqual([]) + }) + + it(`adding a comment (grandchild-only change) updates both array levels`, async () => { + const collection = buildToArrayToArrayQuery() + await collection.preload() + + comments.utils.begin() + comments.utils.write({ + type: `insert`, + value: { id: 110, issueId: 11, body: `Great feature` }, + }) + comments.utils.commit() + + const alpha = collection.get(1) as any + expect(Array.isArray(alpha.issues)).toBe(true) + const issue11 = alpha.issues.find((i: any) => i.id === 11) + expect(Array.isArray(issue11.comments)).toBe(true) + expect(issue11.comments).toEqual([{ id: 110, body: `Great feature` }]) + }) + + it(`removing a comment (grandchild-only change) updates both array levels`, async () => { + const collection = buildToArrayToArrayQuery() + await collection.preload() + + comments.utils.begin() + comments.utils.write({ + type: `delete`, + value: sampleComments.find((c) => c.id === 100)!, + }) + comments.utils.commit() + + const alpha = collection.get(1) as any + const issue10 = alpha.issues.find((i: any) => i.id === 10) + expect(issue10.comments).toEqual([{ id: 101, body: `Fixed it` }]) + }) + + it(`adding an issue (middle-level insert) re-emits parent with updated array`, async () => { + const collection = buildToArrayToArrayQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 30, projectId: 3, title: `Gamma issue` }, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { id: 10, title: `Bug in Alpha`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { id: 11, title: `Feature for Alpha`, comments: [] }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + }, + { + id: 3, + name: `Gamma`, + issues: [{ id: 30, title: `Gamma issue`, comments: [] }], + }, + ]) + }) + + it(`removing an issue (middle-level delete) re-emits parent with updated array`, async () => { + const collection = buildToArrayToArrayQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `delete`, + value: sampleIssues.find((i) => i.id === 11)!, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { id: 10, title: `Bug in Alpha`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + }, + { + id: 3, + name: `Gamma`, + issues: [], + }, + ]) + }) + + it(`updating an issue title (middle-level update) re-emits parent with updated array`, async () => { + const collection = buildToArrayToArrayQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `update`, + value: { id: 10, projectId: 1, title: `Renamed Bug` }, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { id: 10, title: `Renamed Bug`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { id: 11, title: `Feature for Alpha`, comments: [] }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + }, + { + id: 3, + name: `Gamma`, + issues: [], + }, + ]) + }) + + it(`concurrent child + grandchild changes in the same transaction`, async () => { + const collection = buildToArrayToArrayQuery() + await collection.preload() + + // Add a new issue AND a comment on an existing issue in one transaction + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 30, projectId: 3, title: `Gamma issue` }, + }) + issues.utils.commit() + + comments.utils.begin() + comments.utils.write({ + type: `insert`, + value: { id: 110, issueId: 11, body: `Great feature` }, + }) + comments.utils.commit() + + // Gamma should have the new issue with empty comments + const gamma = collection.get(3) as any + expect(gamma.issues).toHaveLength(1) + expect(gamma.issues[0].id).toBe(30) + expect(gamma.issues[0].comments).toEqual([]) + + // Alpha's issue 11 should have the new comment + const alpha = collection.get(1) as any + const issue11 = alpha.issues.find((i: any) => i.id === 11) + expect(issue11.comments).toEqual([{ id: 110, body: `Great feature` }]) + }) + }) }) From b1525649fe9ce67be86876baed83e17dd194a6b4 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 25 Feb 2026 14:41:37 +0100 Subject: [PATCH 5/7] Fix changes being emitted on deeply nested collections --- .../db/src/query/live/collection-config-builder.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 8e5b4d658..10ac69c0d 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -1779,7 +1779,7 @@ function flushIncludesState( entry.collection, entry.collection.id, childChanges, - null, + entry.syncMethods, ) } } @@ -1793,20 +1793,22 @@ function flushIncludesState( entry.collection, entry.collection.id, null, - null, + entry.syncMethods, ) } } // For toArray entries: re-emit affected parents with updated array snapshots + const toArrayReEmitKeys = state.materializeAsArray + ? new Set([...(affectedCorrelationKeys || []), ...dirtyFromBuffers]) + : null if ( - state.materializeAsArray && parentSyncMethods && - affectedCorrelationKeys && - affectedCorrelationKeys.size > 0 + toArrayReEmitKeys && + toArrayReEmitKeys.size > 0 ) { parentSyncMethods.begin() - for (const correlationKey of affectedCorrelationKeys) { + for (const correlationKey of toArrayReEmitKeys) { const parentKeys = state.correlationToParentKeys.get(correlationKey) if (!parentKeys) continue const entry = state.childRegistry.get(correlationKey) From fd4b00cb2a52194f6b29bb297ab82b7965cd6bcf Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:46:57 +0000 Subject: [PATCH 6/7] ci: apply automated fixes --- packages/db/src/query/builder/index.ts | 8 +- .../query/live/collection-config-builder.ts | 6 +- packages/db/tests/query/includes.test.ts | 296 +++++++++++++----- 3 files changed, 223 insertions(+), 87 deletions(-) diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index 9e4747841..81713865e 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -952,7 +952,13 @@ function buildIncludesSubquery( where: modifiedWhere.length > 0 ? modifiedWhere : undefined, } - return new IncludesSubquery(modifiedQuery, parentRef, childRef, fieldName, materializeAsArray) + return new IncludesSubquery( + modifiedQuery, + parentRef, + childRef, + fieldName, + materializeAsArray, + ) } /** diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 10ac69c0d..f539436e8 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -1802,11 +1802,7 @@ function flushIncludesState( const toArrayReEmitKeys = state.materializeAsArray ? new Set([...(affectedCorrelationKeys || []), ...dirtyFromBuffers]) : null - if ( - parentSyncMethods && - toArrayReEmitKeys && - toArrayReEmitKeys.size > 0 - ) { + if (parentSyncMethods && toArrayReEmitKeys && toArrayReEmitKeys.size > 0) { parentSyncMethods.begin() for (const correlationKey of toArrayReEmitKeys) { const parentKeys = state.correlationToParentKeys.get(correlationKey) diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index 74f4f2e78..c3b912242 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { createLiveQueryCollection, eq, toArray } from '../../src/query/index.js' +import { + createLiveQueryCollection, + eq, + toArray, +} from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' import { mockSyncCollectionOptions } from '../utils.js' @@ -598,14 +602,27 @@ describe(`includes subqueries`, () => { id: 1, name: `Alpha`, issues: [ - { id: 10, title: `Bug in Alpha`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { + id: 10, + title: `Bug in Alpha`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, { id: 11, title: `Feature for Alpha`, comments: [] }, ], }, { id: 2, name: `Beta`, - issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], }, { id: 3, @@ -631,13 +648,26 @@ describe(`includes subqueries`, () => { id: 1, name: `Alpha`, issues: [ - { id: 10, title: `Bug in Alpha`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { + id: 10, + title: `Bug in Alpha`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, ], }, { id: 2, name: `Beta`, - issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], }, { id: 3, @@ -663,14 +693,27 @@ describe(`includes subqueries`, () => { id: 1, name: `Alpha`, issues: [ - { id: 10, title: `Renamed Bug`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { + id: 10, + title: `Renamed Bug`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, { id: 11, title: `Feature for Alpha`, comments: [] }, ], }, { id: 2, name: `Beta`, - issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], }, { id: 3, @@ -684,21 +727,19 @@ describe(`includes subqueries`, () => { describe(`toArray`, () => { function buildToArrayQuery() { return createLiveQueryCollection((q) => - q - .from({ p: projects }) - .select(({ p }) => ({ - id: p.id, - name: p.name, - issues: toArray( - q - .from({ i: issues }) - .where(({ i }) => eq(i.projectId, p.id)) - .select(({ i }) => ({ - id: i.id, - title: i.title, - })), - ), - })), + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: toArray( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ + id: i.id, + title: i.title, + })), + ), + })), ) } @@ -764,22 +805,20 @@ describe(`includes subqueries`, () => { it(`array respects ORDER BY`, async () => { const collection = createLiveQueryCollection((q) => - q - .from({ p: projects }) - .select(({ p }) => ({ - id: p.id, - name: p.name, - issues: toArray( - q - .from({ i: issues }) - .where(({ i }) => eq(i.projectId, p.id)) - .orderBy(({ i }) => i.title, `asc`) - .select(({ i }) => ({ - id: i.id, - title: i.title, - })), - ), - })), + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: toArray( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .orderBy(({ i }) => i.title, `asc`) + .select(({ i }) => ({ + id: i.id, + title: i.title, + })), + ), + })), ) await collection.preload() @@ -793,23 +832,21 @@ describe(`includes subqueries`, () => { it(`ordered toArray with limit applied per parent`, async () => { const collection = createLiveQueryCollection((q) => - q - .from({ p: projects }) - .select(({ p }) => ({ - id: p.id, - name: p.name, - issues: toArray( - q - .from({ i: issues }) - .where(({ i }) => eq(i.projectId, p.id)) - .orderBy(({ i }) => i.title, `asc`) - .limit(1) - .select(({ i }) => ({ - id: i.id, - title: i.title, - })), - ), - })), + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: toArray( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .orderBy(({ i }) => i.title, `asc`) + .limit(1) + .select(({ i }) => ({ + id: i.id, + title: i.title, + })), + ), + })), ) await collection.preload() @@ -823,7 +860,6 @@ describe(`includes subqueries`, () => { const gamma = collection.get(3) as any expect(gamma.issues).toEqual([]) }) - }) describe(`nested includes: Collection → toArray`, () => { @@ -928,14 +964,27 @@ describe(`includes subqueries`, () => { id: 1, name: `Alpha`, issues: [ - { id: 10, title: `Bug in Alpha`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { + id: 10, + title: `Bug in Alpha`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, { id: 11, title: `Feature for Alpha`, comments: [] }, ], }, { id: 2, name: `Beta`, - issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], }, { id: 3, @@ -961,13 +1010,26 @@ describe(`includes subqueries`, () => { id: 1, name: `Alpha`, issues: [ - { id: 10, title: `Bug in Alpha`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { + id: 10, + title: `Bug in Alpha`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, ], }, { id: 2, name: `Beta`, - issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], }, { id: 3, @@ -993,14 +1055,27 @@ describe(`includes subqueries`, () => { id: 1, name: `Alpha`, issues: [ - { id: 10, title: `Renamed Bug`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { + id: 10, + title: `Renamed Bug`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, { id: 11, title: `Feature for Alpha`, comments: [] }, ], }, { id: 2, name: `Beta`, - issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], }, { id: 3, @@ -1044,14 +1119,10 @@ describe(`includes subqueries`, () => { const alpha = collection.get(1) as any expect(Array.isArray(alpha.issues)).toBe(true) - const sortedIssues = alpha.issues.sort( - (a: any, b: any) => a.id - b.id, - ) + const sortedIssues = alpha.issues.sort((a: any, b: any) => a.id - b.id) // comments should be Collections expect(sortedIssues[0].comments.toArray).toBeDefined() - expect( - childItems(sortedIssues[0].comments), - ).toEqual([ + expect(childItems(sortedIssues[0].comments)).toEqual([ { id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }, ]) @@ -1116,13 +1187,26 @@ describe(`includes subqueries`, () => { id: 1, name: `Alpha`, issues: [ - { id: 10, title: `Bug in Alpha`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { + id: 10, + title: `Bug in Alpha`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, ], }, { id: 2, name: `Beta`, - issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], }, { id: 3, @@ -1148,14 +1232,27 @@ describe(`includes subqueries`, () => { id: 1, name: `Alpha`, issues: [ - { id: 10, title: `Renamed Bug`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { + id: 10, + title: `Renamed Bug`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, { id: 11, title: `Feature for Alpha`, comments: [] }, ], }, { id: 2, name: `Beta`, - issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], }, { id: 3, @@ -1201,9 +1298,7 @@ describe(`includes subqueries`, () => { const alpha = collection.get(1) as any expect(Array.isArray(alpha.issues)).toBe(true) - const sortedIssues = alpha.issues.sort( - (a: any, b: any) => a.id - b.id, - ) + const sortedIssues = alpha.issues.sort((a: any, b: any) => a.id - b.id) expect(Array.isArray(sortedIssues[0].comments)).toBe(true) expect( sortedIssues[0].comments.sort((a: any, b: any) => a.id - b.id), @@ -1265,14 +1360,27 @@ describe(`includes subqueries`, () => { id: 1, name: `Alpha`, issues: [ - { id: 10, title: `Bug in Alpha`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { + id: 10, + title: `Bug in Alpha`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, { id: 11, title: `Feature for Alpha`, comments: [] }, ], }, { id: 2, name: `Beta`, - issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], }, { id: 3, @@ -1298,13 +1406,26 @@ describe(`includes subqueries`, () => { id: 1, name: `Alpha`, issues: [ - { id: 10, title: `Bug in Alpha`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { + id: 10, + title: `Bug in Alpha`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, ], }, { id: 2, name: `Beta`, - issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], }, { id: 3, @@ -1330,14 +1451,27 @@ describe(`includes subqueries`, () => { id: 1, name: `Alpha`, issues: [ - { id: 10, title: `Renamed Bug`, comments: [{ id: 100, body: `Looks bad` }, { id: 101, body: `Fixed it` }] }, + { + id: 10, + title: `Renamed Bug`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, { id: 11, title: `Feature for Alpha`, comments: [] }, ], }, { id: 2, name: `Beta`, - issues: [{ id: 20, title: `Bug in Beta`, comments: [{ id: 200, body: `Same bug` }] }], + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], }, { id: 3, From ef1e90a4c6594bb1f5a2d36a9adb9cc71db627c0 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 25 Feb 2026 14:54:12 +0100 Subject: [PATCH 7/7] Changeset --- .changeset/includes-to-array.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/includes-to-array.md diff --git a/.changeset/includes-to-array.md b/.changeset/includes-to-array.md new file mode 100644 index 000000000..fd59eaea3 --- /dev/null +++ b/.changeset/includes-to-array.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +feat: add `toArray()` wrapper for includes subqueries to materialize child results as plain arrays instead of live Collections