Skip to content

Commit e29aab3

Browse files
KyleAMathewsclaudeautofix-ci[bot]
authored
fix(db): propagate changes through nested toArray includes at depth 3+ (#1457)
* fix(db): propagate changes through nested toArray includes at depth 3+ flushIncludesState only flushed entries dirty from direct child changes (Phase 2) or single-level buffer drain (Phase 3). When only the deepest level changed (e.g., textDeltas in runs→texts→textDeltas), intermediate levels had no changes, so Phase 4 never triggered recursive flushing and deep buffer changes were permanently stranded. Add a third pass in Phase 4 that scans child registry entries for pending deep nested buffer changes via hasPendingIncludesChanges(). Entries with stranded buffers are recursively flushed and their correlation keys added to the inline re-emit set. Also prevent toArray()/concat(toArray()) from being silently wrapped as Value nodes inside expressions like coalesce() — throw a clear error at query construction time instead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: add changeset for nested includes fix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: apply automated fixes * fix: resolve TypeScript errors in chained collection test Remove explicit generic parameter from createLiveQueryCollection and use `any` casts for query/select callbacks in the chained darix pattern test, matching the style used in other test cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: move repro tests into existing includes.test.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent ed20b94 commit e29aab3

5 files changed

Lines changed: 1117 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/db': patch
3+
---
4+
5+
Fix nested `toArray()` includes not propagating changes at depth 3+. When a query used nested includes like `toArray(runs) → toArray(texts) → concat(toArray(textDeltas))`, changes to the deepest level (e.g., inserting a textDelta) were silently lost because `flushIncludesState` only drained one level of nested buffers. Also throw a clear error when `toArray()` or `concat(toArray())` is used inside expressions like `coalesce()`, instead of silently producing incorrect results.

packages/db/src/query/builder/functions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,12 +437,14 @@ export const operators = [
437437
export type OperatorName = (typeof operators)[number]
438438

439439
export class ToArrayWrapper<_T = unknown> {
440+
readonly __brand = `ToArrayWrapper` as const
440441
declare readonly _type: `toArray`
441442
declare readonly _result: _T
442443
constructor(public readonly query: QueryBuilder<any>) {}
443444
}
444445

445446
export class ConcatToArrayWrapper<_T = unknown> {
447+
readonly __brand = `ConcatToArrayWrapper` as const
446448
declare readonly _type: `concatToArray`
447449
declare readonly _result: _T
448450
constructor(public readonly query: QueryBuilder<any>) {}

packages/db/src/query/builder/ref-proxy.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,15 +284,31 @@ export function createRefProxyWithSelected<T extends Record<string, any>>(
284284
}
285285

286286
/**
287-
* Converts a value to an Expression
288-
* If it's a RefProxy, creates a Ref, otherwise creates a Value
287+
* Converts a value to an Expression.
288+
* If it's a RefProxy, creates a PropRef. Throws if the value is a
289+
* ToArrayWrapper or ConcatToArrayWrapper (these must be used as direct
290+
* select fields). Otherwise wraps it as a Value.
289291
*/
290292
export function toExpression<T = any>(value: T): BasicExpression<T>
291293
export function toExpression(value: RefProxy<any>): BasicExpression<any>
292294
export function toExpression(value: any): BasicExpression<any> {
293295
if (isRefProxy(value)) {
294296
return new PropRef(value.__path)
295297
}
298+
// toArray() and concat(toArray()) must be used as direct select fields, not inside expressions
299+
if (
300+
value &&
301+
typeof value === `object` &&
302+
(value.__brand === `ToArrayWrapper` ||
303+
value.__brand === `ConcatToArrayWrapper`)
304+
) {
305+
const name =
306+
value.__brand === `ToArrayWrapper` ? `toArray()` : `concat(toArray())`
307+
throw new Error(
308+
`${name} cannot be used inside expressions (e.g., coalesce(), eq(), not()). ` +
309+
`Use ${name} directly as a select field value instead.`,
310+
)
311+
}
296312
// If it's already an Expression (Func, Ref, Value) or Agg, return it directly
297313
if (
298314
value &&

packages/db/src/query/live/collection-config-builder.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1699,6 +1699,30 @@ function flushIncludesState(
16991699
)
17001700
}
17011701
}
1702+
// Finally: entries with deep nested buffer changes (grandchild-or-deeper buffers
1703+
// have pending data, but neither this level nor the immediate child level changed).
1704+
// Without this pass, changes at depth 3+ are stranded because drainNestedBuffers
1705+
// only drains one level and Phase 4 only flushes entries dirty from Phase 2/3.
1706+
const deepBufferDirty = new Set<unknown>()
1707+
if (state.nestedSetups) {
1708+
for (const [correlationKey, entry] of state.childRegistry) {
1709+
if (entriesWithChildChanges.has(correlationKey)) continue
1710+
if (dirtyFromBuffers.has(correlationKey)) continue
1711+
if (
1712+
entry.includesStates &&
1713+
hasPendingIncludesChanges(entry.includesStates)
1714+
) {
1715+
flushIncludesState(
1716+
entry.includesStates,
1717+
entry.collection,
1718+
entry.collection.id,
1719+
null,
1720+
entry.syncMethods,
1721+
)
1722+
deepBufferDirty.add(correlationKey)
1723+
}
1724+
}
1725+
}
17021726

17031727
// For inline materializations: re-emit affected parents with updated snapshots.
17041728
// We mutate items in-place (so collection.get() reflects changes immediately)
@@ -1707,7 +1731,11 @@ function flushIncludesState(
17071731
// deepEquals, but in-place mutation means both sides reference the same
17081732
// object, so the comparison always returns true and suppresses the event.
17091733
const inlineReEmitKeys = materializesInline(state)
1710-
? new Set([...(affectedCorrelationKeys || []), ...dirtyFromBuffers])
1734+
? new Set([
1735+
...(affectedCorrelationKeys || []),
1736+
...dirtyFromBuffers,
1737+
...deepBufferDirty,
1738+
])
17111739
: null
17121740
if (parentSyncMethods && inlineReEmitKeys && inlineReEmitKeys.size > 0) {
17131741
const events: Array<ChangeMessage<any>> = []

0 commit comments

Comments
 (0)