Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-nested-includes-propagation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

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.
2 changes: 2 additions & 0 deletions packages/db/src/query/builder/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,12 +437,14 @@ export const operators = [
export type OperatorName = (typeof operators)[number]

export class ToArrayWrapper<_T = unknown> {
readonly __brand = `ToArrayWrapper` as const
declare readonly _type: `toArray`
declare readonly _result: _T
constructor(public readonly query: QueryBuilder<any>) {}
}

export class ConcatToArrayWrapper<_T = unknown> {
readonly __brand = `ConcatToArrayWrapper` as const
declare readonly _type: `concatToArray`
declare readonly _result: _T
constructor(public readonly query: QueryBuilder<any>) {}
Expand Down
20 changes: 18 additions & 2 deletions packages/db/src/query/builder/ref-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,15 +284,31 @@ export function createRefProxyWithSelected<T extends Record<string, any>>(
}

/**
* Converts a value to an Expression
* If it's a RefProxy, creates a Ref, otherwise creates a Value
* Converts a value to an Expression.
* If it's a RefProxy, creates a PropRef. Throws if the value is a
* ToArrayWrapper or ConcatToArrayWrapper (these must be used as direct
* select fields). Otherwise wraps it as a Value.
*/
export function toExpression<T = any>(value: T): BasicExpression<T>
export function toExpression(value: RefProxy<any>): BasicExpression<any>
export function toExpression(value: any): BasicExpression<any> {
if (isRefProxy(value)) {
return new PropRef(value.__path)
}
// toArray() and concat(toArray()) must be used as direct select fields, not inside expressions
if (
value &&
typeof value === `object` &&
(value.__brand === `ToArrayWrapper` ||
value.__brand === `ConcatToArrayWrapper`)
) {
const name =
value.__brand === `ToArrayWrapper` ? `toArray()` : `concat(toArray())`
throw new Error(
`${name} cannot be used inside expressions (e.g., coalesce(), eq(), not()). ` +
`Use ${name} directly as a select field value instead.`,
)
}
// If it's already an Expression (Func, Ref, Value) or Agg, return it directly
if (
value &&
Expand Down
30 changes: 29 additions & 1 deletion packages/db/src/query/live/collection-config-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1699,6 +1699,30 @@ function flushIncludesState(
)
}
}
// Finally: entries with deep nested buffer changes (grandchild-or-deeper buffers
// have pending data, but neither this level nor the immediate child level changed).
// Without this pass, changes at depth 3+ are stranded because drainNestedBuffers
// only drains one level and Phase 4 only flushes entries dirty from Phase 2/3.
const deepBufferDirty = new Set<unknown>()
if (state.nestedSetups) {
for (const [correlationKey, entry] of state.childRegistry) {
if (entriesWithChildChanges.has(correlationKey)) continue
if (dirtyFromBuffers.has(correlationKey)) continue
if (
entry.includesStates &&
hasPendingIncludesChanges(entry.includesStates)
) {
flushIncludesState(
entry.includesStates,
entry.collection,
entry.collection.id,
null,
entry.syncMethods,
)
deepBufferDirty.add(correlationKey)
}
}
}

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