From a9761370040a3fb0e75e724539922db918259726 Mon Sep 17 00:00:00 2001 From: Julius Lipp Date: Thu, 16 Jul 2026 19:15:13 -0700 Subject: [PATCH 1/2] fix(db): materialize includes into parent rows before commit The includes flush patched parent rows in place only after the live query collection committed, without emitting an event. Consumers that copy rows at emit time (e.g. a live query layered on the collection, the shape framework adapters produce for component-level queries) captured the pre-patch row with its included fields unset, and a parent-only update never triggered the follow-up re-emit, so those consumers kept empty includes until an unrelated child change recomputed the row. Materialize the current includes state into the new parent change values before begin/commit; the post-commit flush re-applies the same values idempotently and the child-change re-emit path is unchanged. Fixes #1635 --- .changeset/heavy-pandas-tell.md | 5 ++ .../query/live/collection-config-builder.ts | 46 ++++++++++++++++ packages/db/tests/query/includes.test.ts | 52 +++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 .changeset/heavy-pandas-tell.md diff --git a/.changeset/heavy-pandas-tell.md b/.changeset/heavy-pandas-tell.md new file mode 100644 index 0000000000..0d2a301d73 --- /dev/null +++ b/.changeset/heavy-pandas-tell.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db": patch +--- + +Materialize includes into parent rows before they commit so emitted change events already carry their included values. The includes flush ran only after the parent live query collection committed, patching stored rows in place without emitting; any consumer that copies rows at emit time — most commonly another live query layered on the collection, the shape framework adapters produce for component-level queries — captured the pre-patch row with its included fields unset. A parent-only update (no child changes) never triggered the follow-up re-emit, so those consumers kept empty includes until an unrelated child change recomputed the row. Fixes #1635. diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 4d80b3fe17..0e03661091 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -808,6 +808,11 @@ export class CollectionConfigBuilder< // 1. Flush parent changes if (hasParentChanges) { + // Materialize the current includes state into the new parent rows + // before they commit, so the emitted change events already carry + // their included values (the includes flush below patches stored + // rows in place without emitting). + patchParentChangesFromIncludes(includesState, changesToApply) begin() changesToApply.forEach(this.applyChanges.bind(this, config)) commit() @@ -1238,6 +1243,47 @@ function materializesInline(state: IncludesOutputState): boolean { return state.materialization !== `collection` } +/** + * Materializes the current includes state into freshly computed parent rows + * before they are committed, so the emitted change events already carry + * their included values. + * + * The post-commit includes flush patches stored parent rows in place without + * emitting an event, which consumers that copy rows at emit time (e.g. + * another live query layered on this collection) never observe: a + * parent-only update would wipe their included fields until an unrelated + * child change re-emitted the row. + */ +function patchParentChangesFromIncludes( + includesState: Array, + parentChanges: Map>, +): void { + for (const state of includesState) { + if (!materializesInline(state)) continue + for (const [, changes] of parentChanges) { + if (changes.inserts > 0) { + const parentResult = changes.value + const routing = parentResult[INCLUDES_ROUTING]?.[state.fieldName] + const correlationKey = routing?.correlationKey + if (correlationKey != null) { + const routingKey = computeRoutingKey( + correlationKey, + routing?.parentContext ?? null, + ) + setIncludedValue( + parentResult, + state.resultPath, + materializeIncludedValue( + state, + state.childRegistry.get(routingKey), + ), + ) + } + } + } + } +} + function materializeIncludedValue( state: IncludesOutputState, entry: ChildCollectionEntry | undefined, diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index 2eba6f122a..bb17fec694 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -711,6 +711,58 @@ describe(`includes subqueries`, () => { }) }) + describe(`parent-only updates`, () => { + it(`keeps materialized includes on rows observed through a layered live query`, async () => { + const collection = createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: materialize( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ + id: i.id, + title: i.title, + })), + ), + })), + ) + await collection.preload() + + // A second live query layered on top copies rows at emit time — the + // shape framework adapters produce for component-level queries. The + // emitted parent row must already carry its materialized includes: a + // post-commit in-place patch is invisible to this layer. + const layered = createLiveQueryCollection((q) => + q.from({ row: collection }), + ) + await layered.preload() + + expect(sortedPlainRows((layered.get(1) as any).issues)).toEqual([ + { id: 10, title: `Bug in Alpha` }, + { id: 11, title: `Feature for Alpha` }, + ]) + + // Update only the parent row; no child rows change. + projects.utils.begin() + projects.utils.write({ + type: `update`, + value: { id: 1, name: `Alpha renamed` }, + }) + projects.utils.commit() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + const renamed = layered.get(1) as any + expect(renamed.name).toBe(`Alpha renamed`) + expect(sortedPlainRows(renamed.issues)).toEqual([ + { id: 10, title: `Bug in Alpha` }, + { id: 11, title: `Feature for Alpha` }, + ]) + }) + }) + describe(`inner join filtering`, () => { it(`only shows children for parents matching a WHERE clause`, async () => { const collection = createLiveQueryCollection((q) => From c8d03e445863bc355ef08d2e5a4bf657424f6c1e Mon Sep 17 00:00:00 2001 From: Julius Lipp Date: Thu, 16 Jul 2026 19:35:49 -0700 Subject: [PATCH 2/2] refactor(db): address review feedback on includes pre-commit patch - Type patchParentChangesFromIncludes over the caller's Changes instead of Changes, with one narrow cast for symbol-indexed routing - Replace the fixed test sleep with vi.waitFor and drop the as-any casts (the layered query's inferred row type carries name/issues) - Trim comments to the function contract Co-Authored-By: Claude Fable 5 --- .../query/live/collection-config-builder.ts | 20 +++++-------------- packages/db/tests/query/includes.test.ts | 15 +++++--------- 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 0e03661091..210ec6022b 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -808,10 +808,6 @@ export class CollectionConfigBuilder< // 1. Flush parent changes if (hasParentChanges) { - // Materialize the current includes state into the new parent rows - // before they commit, so the emitted change events already carry - // their included values (the includes flush below patches stored - // rows in place without emitting). patchParentChangesFromIncludes(includesState, changesToApply) begin() changesToApply.forEach(this.applyChanges.bind(this, config)) @@ -1244,25 +1240,19 @@ function materializesInline(state: IncludesOutputState): boolean { } /** - * Materializes the current includes state into freshly computed parent rows - * before they are committed, so the emitted change events already carry + * Materializes the current includes state into freshly computed parent + * change values, so the change events emitted on commit already carry * their included values. - * - * The post-commit includes flush patches stored parent rows in place without - * emitting an event, which consumers that copy rows at emit time (e.g. - * another live query layered on this collection) never observe: a - * parent-only update would wipe their included fields until an unrelated - * child change re-emitted the row. */ -function patchParentChangesFromIncludes( +function patchParentChangesFromIncludes( includesState: Array, - parentChanges: Map>, + parentChanges: Map>, ): void { for (const state of includesState) { if (!materializesInline(state)) continue for (const [, changes] of parentChanges) { if (changes.inserts > 0) { - const parentResult = changes.value + const parentResult = changes.value as Record const routing = parentResult[INCLUDES_ROUTING]?.[state.fieldName] const correlationKey = routing?.correlationKey if (correlationKey != null) { diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index bb17fec694..829f5f61bf 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -730,21 +730,16 @@ describe(`includes subqueries`, () => { ) await collection.preload() - // A second live query layered on top copies rows at emit time — the - // shape framework adapters produce for component-level queries. The - // emitted parent row must already carry its materialized includes: a - // post-commit in-place patch is invisible to this layer. const layered = createLiveQueryCollection((q) => q.from({ row: collection }), ) await layered.preload() - expect(sortedPlainRows((layered.get(1) as any).issues)).toEqual([ + expect(sortedPlainRows(layered.get(1)!.issues)).toEqual([ { id: 10, title: `Bug in Alpha` }, { id: 11, title: `Feature for Alpha` }, ]) - // Update only the parent row; no child rows change. projects.utils.begin() projects.utils.write({ type: `update`, @@ -752,11 +747,11 @@ describe(`includes subqueries`, () => { }) projects.utils.commit() - await new Promise((resolve) => setTimeout(resolve, 10)) + await vi.waitFor(() => { + expect(layered.get(1)?.name).toBe(`Alpha renamed`) + }) - const renamed = layered.get(1) as any - expect(renamed.name).toBe(`Alpha renamed`) - expect(sortedPlainRows(renamed.issues)).toEqual([ + expect(sortedPlainRows(layered.get(1)!.issues)).toEqual([ { id: 10, title: `Bug in Alpha` }, { id: 11, title: `Feature for Alpha` }, ])