Skip to content

RFC: Stabilizing includes / nested materialization #1658

Description

@KyleAMathews

RFC: Stabilizing includes / nested materialization

Status: active — state-aware relationship fuzzing in review; audited issue inventory and transition-history coverage next
Scope: bug fixes and internal refactors only — no new public API surface, no behavior changes beyond fixing verified bugs.
Working model: small PRs from current main; reduced expected-failure seeds land before the architectural slice that fixes them.

Progress:

1. What's happening

The includes system (subquery-in-select, toArray(), materialize()) has produced a steady
stream of correctness bugs: silently misrouted data, dropped children, stale sort order, broken
adapter reactivity, and permanent loading states. Every claim below was verified against current
main with a red/green test (tests live on this branch, in describe('cluster-verification …')
blocks appended to existing test files).

# Claim Verified Evidence
#1454 Duplicate alias in sibling includes silently misroutes data RED — confirmed, worse than reported: the issues include is fully replaced by tag rows, real issues lost, nested comments empty packages/db/tests/query/includes.test.ts (cluster-verification, claim A)
#1444 orderBy in an include ignored after optimistic update on the child collection RED — confirmed: child values propagate, re-sort does not same file, claim B
#1510 Live query stuck loading forever when a subquery's inner collection is cold on-demand and the outer produces zero rows RED — confirmed: allCollectionsReady() never true because per-row lazy loadSubset never fires packages/db/tests/query/includes-lazy-loading.test.ts (#1510 block)
#1533 Progressive sync: nested toArray children skip the fast-path snapshot RED — confirmed: lazy alias ⇒ includeInitialState: false ⇒ the only requestSnapshot fires per parent row, after the progressive buffering window closed same file, #1533 block (paired passing baseline for the direct query)
#1571 (part 1) Solid: toArray include updates never reach the rendered data store RED — confirmed, stronger than reported: even an untracked re-read of data is stale; the state map and underlying collection row do update packages/solid-db/tests/useLiveQuery.test.tsx (#1571 block)
#1571 (part 2) Initially-empty include starts null and never becomes reactive Not reproduced: field is an empty child Collection from first render and populates on insert (caveat: Collection instances aren't Solid-reactive by design) same file
#1495 Sync-confirmed child update misclassified as insert, crashes duplicate-key diagnostics Fixed on main by merged #1600 (has() reclassification + config.utils guard) includes.test.ts, claim C (green)
#1501 3-level nested toArray drops children when correlation keys overlap across parent groups Fixed on main by merged #1607 (fan-out routing + snapshot reseeding) includes.test.ts, claim D (green control)
#1488 On-demand observer reuse loses row ownership; cleanup deletes rows still in use Not reproducible on main: the early-return shape exists, but atomic observer+ownership cleanup and ownership re-registration on subscribers:change compensate; likely fixed since the reported version packages/query-db-collection/tests/query.test.ts (#1488 block, green)

New reports since this RFC was first drafted:

Reviewed but owned elsewhere: #1662 is already evidence E13 in loadSubset RFC #1657; #1698 is
an Electric/sync ingestion defect under RFC #1659; #1708 is a general join-index selection issue;
#1712 asks for a new remote-join feature; and #1721 changes React hook state ownership under the
live-query platform work. They are not gates for this includes RFC.

Status audit (2026-08-12): #1495 and #1501 are correctly closed after merged fixes #1600 and
#1607. #1635 and #1571 correctly remain open because their proposed fixes #1684 and #1604 closed
without merging. #1704 and #1706 correctly remain open while #1705 and #1707 are open. No issue
was closed as part of this audit.

Why these keep happening

The bugs are not independent. The includes system compiles each include into its own child D2
pipeline (sound), but then reconstructs include semantics in a ~2,300-line imperative output layer
(packages/db/src/query/live/collection-config-builder.ts) using alias maps, child collection
registries, correlation routing indexes, pending-change buffers, and in-place parent-row mutation.
Correctness rests on identities that are only implicit:

  1. An alias is not a source identity. Sibling subqueries legitimately reuse lexical names, but
    the compiler flattens all includes aliases into one namespace, so { i: issues } and
    { i: tags } share one D2 input (Duplicate alias in sibling includes silently breaks nested children #1454).
  2. A correlation key is not a parent identity. Multiple parents can subscribe to the same
    correlated child set, and a shared result row's routing metadata must outlive every recursive
    consumer (3-level nested materialize is dropped when the middle level is shared by more than one parent row #1685). A destructively-drained shared buffer can't represent that fan-out
    (3-level nested toArray: shared buffer in createPerEntryIncludesStates drops children when correlation keys overlap across parent groups #1501/fix(db): propagate changes through nested toArray includes at depth 3+ #1457 — patched by fix(db): nested toArray includes drop children when sibling groups share a correlation key (#1501) #1607, but the shared-state design remains).
  3. Differential multiplicity is not CRUD intent. Several differential rows may also map to
    one public key (innerJoin drops a live-query result when one of many children is deleted #1703), so deleting one contributor must not delete the result while another
    remains. A (-1,+1) pair must become one atomic replacement including its order metadata. Today "insert vs update" is decided per call site —
    three near-copies of the accumulator exist (parent/child/nested), and the child copy retained a
    stale orderByIndex (TanStack DB "includes" ignores orderBy after optimistic update #1444). The landed fix: reconcile duplicate live query child inserts #1600 fix decides by checking collection.has(key)
    mid-flush, which works but keeps classification dependent on whatever state exists at flush time.
  4. Object identity is not result revision. flushIncludesState mutates parent rows in place
    and force-emits through changesManager.emitEvents(events, true) to defeat the collection's own
    deepEquals suppression. React's version-bump mostly tolerates this; Solid's reconcile does
    not (Include value updates break with solidjs #1571), Nested includes are undefined on next render after collection.update #1635 suggests React has its own window, and each future adapter needs its own
    workaround.
  5. Source-collection readiness is not query readiness. Readiness is a global boolean over all
    involved collections; lazy children that were never demanded (fix(db): live query stuck loading when subquery-in-select inner is cold on-demand #1510) or progressive children
    whose fast-path window is timing-dependent (Progressive sync: nested toArray subqueries skip the fast-path snapshot #1533) fall through it.

2. Design direction (all internal)

Five internal principles, each of which converts a bug class into an invariant. No public API is
added or changed.

  • P1 — Opaque plan identities. The compiler assigns every include node and source a generated
    ID; user aliases are resolved lexically per subquery scope and never used as runtime keys. Joined
    aliases remain valid correlation targets (materialize() subquery resolves to [] when its correlation predicate targets a joined alias #1704). Invariant: alpha-renaming any subquery alias
    cannot change results.
  • P2 — One transition reducer. A single reduction boundary turns a batch of weighted D2 tuples
    into net per-key transitions {key, countBefore, countAfter, before?, after?, orderBefore?, orderAfter?}. It retains multiplicity when several rows collapse to one public key (innerJoin drops a live-query result when one of many children is deleted #1703),
    publishes membership changes only when the count crosses zero, and applies replacements through
    two idempotent ops (set(key, after, orderAfter) / delete(key)). Value and order tuple live in
    the same versioned entry, so a replacement updates both atomically. Used by root live queries and
    all include levels. Invariant: no code path decides insert-vs-update by inspecting store state
    mid-batch, and one contributor cannot delete a multiply-supported result.
  • P3 — Correlated relation operator. Replace the shared nested buffers / routing indexes /
    cumulative snapshots with one reusable internal structure: buckets keyed by
    (includeNodeId, correlationTuple) holding an ordered keyed relation, plus subscriber edges.
    Child deltas update a bucket once and fan out to every subscribed parent; a newly subscribed
    parent receives the bucket snapshot; removing a parent removes only its edge. Nesting recurses
    through the same operator — depth 3 is not a separate code path from depth 1. Internal child
    Collections (with their gcTime: 0 and config.utils hazards) shrink to this lightweight
    relation, keeping a Collection facade only where the API already promises one (bare
    subquery-in-select).
  • P4 — Publication by replacement. When an include value changes, publish a shallow-copied
    parent row with a new include array/value (structural sharing for unchanged fields) through the
    normal update path. Layered live queries must observe the materialized value on parent-only
    updates (Updating a parent source row empties materialize() fields in live queries #1713), not an internal placeholder. Reference change ⇔ value change, for every adapter.
    Deletes the force-emit hack, the Solid clone shim, and the in-place/deepEquals tension.
  • P5 — Demand-relative readiness. Internally, a live query is ready when every currently
    demanded
    source subset has settled its initial snapshot. An empty outer demands nothing from the
    child, so the child is vacuously ready (fix(db): live query stuck loading when subquery-in-select inner is cold on-demand #1510). A nested progressive child requests its
    correlated subset through the same snapshot path a direct query uses (Progressive sync: nested toArray subqueries skip the fast-path snapshot #1533). This is a
    reorganization of existing readiness bookkeeping, not a new status API.

Explicitly out of scope (would be new features): a query.explain() API, public demand/lease
APIs, per-include loading-status fields, new materialization modes or helpers. Dev-mode internal
assertions (throw on duplicate routing registration, orphaned buffer entries, child writes with no
registered parent) are in scope — today's failure mode is silent data corruption.

3. Proposed PR series

Sequencing rationale — oracle first. The obvious order (fix the five verified bugs, then build
the safety net) repeats the pattern that produced this cluster: each fix validated only by its own
repro test — that is exactly how "fix depth 2 (#1457), discover depth 3 (#1501), fix that (#1607)"
happened. Instead, the oracle harness is the first sequenced work, and the state-correctness bugs
are fixed against it. Writing the naive recompute evaluator also forces the semantics questions
(optimistic child update + orderBy, empty-include representation, optimistic+confirm convergence)
to be settled once, in a reference implementation, rather than implicitly across five PR reviews.

The bugs split into two property classes, which is why there are two tracks:

Track A — parallel, not oracle-gated (community PRs, own regression tests)

Sequenced track

  1. Recompute-oracle property harness — merged in test(db): add includes recompute oracle #1716, hardened in test(db): tighten includes oracle failure checks #1717.
    The landed test-only harness compares real public include queries at depths 1–4 against an
    independent full-recompute model after each generated action. It includes scalar-materialization
    and metamorphic properties plus deterministic known-failure cases for Duplicate alias in sibling includes silently breaks nested children #1454, TanStack DB "includes" ignores orderBy after optimistic update #1444, 3-level nested materialize is dropped when the middle level is shared by more than one parent row #1685,
    confirmed reordering, and parent correlation-key changes.

    Post-merge fuzzing immediately found a generator-normalization counterexample. test(db): tighten includes oracle failure checks #1717 made known
    failures accept only assertion mismatches, normalized every generated or converted put, fixed
    the shared sync mock so rejected mutations do not suppress later optimistic actions, and added a
    repeated-rollback regression. Required CI is green.

    The landed green generator deliberately keeps existing relationship keys and positions stable
    where broader mutation would enter known-broken classes. It also does not guarantee a connected
    observation at every selected depth. Those are explicit limits of the foundation, not claims of
    complete coverage; the next test-only PR below closes them before the transition reducer lands.

Reusable trace runner — merged in #1718. The oracle now separates lifecycle and mutations
(drivers) from observation and recomputation (projections). The runner preserves same-turn
checkpoints for synchronous mutations, awaits genuinely asynchronous hooks, keeps trace failures
primary when cleanup also fails, and rejects async assertions at compile time. Structural-history,
scalar-materialization, and deterministic full-row batch drivers validate the boundary. Extending
the scalar driver immediately found the nested-reference replacement failure recorded above.

State-equivalence driver expansion — merged in #1719. The structural and materialization projections now cover generated full-row and multi-change batches, connected observations at depths 1–4, row reorders, single-row rekeying/reparenting, and flat array/concatenated materialization. Three assertion-specific expected-failure classes guard nested scalar redirects, obsolete shared routes after parent reinsertion, and intra-batch child hand-offs. Fixes turn these seeds green; unrelated runtime errors still fail.

In review — state-aware relationship-transition fuzzing (#1722). The generator now selects existing visible rows, enumerates every query depth and target level, varies identities and old/new correlation keys, and pairs a transition-only baseline with stateful connected scalar activity before and after the transition. Every forced transition must change recomputation at its exact checkpoint. The generated green corpus keeps post-transition activity on the unaffected branch; explicit expected-failure traces pin every observed rekey boundary and every reparented-subtree update-chain boundary.

Controlled readiness and timing drivers — merged in #1720. Deterministic lifecycle/checkpoint drivers now cover an empty outer query with an undemanded lazy child (#1510), progressive nested-child snapshot timing (#1533), and obsolete-demand cancellation. Temporal assertions record readiness and snapshot events at exact checkpoints rather than comparing only converged rows.

Next oracle follow-ups. Once the relationship runtime defects turn the reduced traces green, add an affected-subtree matrix over update level relative to the transitioned row. Separately, add a state-aware transition-history grammar that can issue a second relationship change and interleave inserts/deletes around a visible transition; basic hand-written controls are green, but this broader history space is not yet fuzzed. The next state-oracle slice adds joined-alias correlation (#1704), initially-null correlation with transition controls (#1706), and collapsed join multiplicity (#1703). Later controlled slices cover publication/revision (#1571/#1635/#1713), ownership/lifecycle (#1488/#1631/#1656), and deterministic work counters for #1634 and #1709.

These controlled slices gate the later architectural work that touches each class; they do not block starting the state-correctness refactor once its seed inventory is in place. Narrow production fixes land early only when urgent and independent of machinery the sequenced refactor will replace.
2. Opaque node/source IDs + shared multiplicity-aware transition reducer (fixes #1454, #1444, #1703, and #1704 structurally).

  1. CorrelatedRelation replaces nested buffers/routing (P3). Introduce the operator with
    bucket state, subscriber edges, snapshot-on-subscribe, and non-destructive fan-out. Run in
    shadow mode first (tests compare it against the legacy materializer over the oracle workloads),
    then swap nestedSetups / drainNestedBuffers / updateRoutingIndex /
    createPerEntryIncludesStates over to it and delete the legacy path. Internal child state
    becomes the lightweight relation; the Collection facade remains only for bare
    subquery-in-select includes. Removes the gcTime: 0 workaround and the internal-collection
    config.utils hazard class, plus Fix #1685 #1686's deferred routing-stamp cleanup. Also the likely fix for
    the Poor performance for nested includes #1634 performance report — carry a benchmark based on that repro (deep-nested includes,
    target well under the reported ~100ms).
  2. Copy-on-write publication (P4, fixes Include value updates break with solidjs #1571 structurally; expected to fix Nested includes are undefined on next render after collection.update #1635/Updating a parent source row empties materialize() fields in live queries #1713).
    Shallow-copy parent rows on include change with structural sharing; publish through the normal
    update path; delete emitEvents(events, true) and the hand-cloned prev/next; remove the Solid
    shim from A2 and verify the cross-adapter conformance suite
    (packages/solid-db/tests/conformance.test.tsx et al., from merged test: useLiveQuery conformance suite across all five framework adapters (RFC #1623) #1636) passes for
    React/Solid/Vue/etc. Coordination: if feat(db): shared live-query observer + migrate all five adapters (RFC #1623 step 3) #1642's shared createLiveQueryObserver has landed,
    implement the publication contract at the observer's snapshot boundary — one place instead of
    five adapters; add a red/green repro for Nested includes are undefined on next render after collection.update #1635 first. Perf gate: A/B bench before/after — rows
    are already double-cloned today for the forced event, so this is likely neutral-to-better.
  3. Demand-relative readiness (P5, subsumes A1, fixes Progressive sync: nested toArray subqueries skip the fast-path snapshot #1533's class). Consolidate
    allCollectionsReady, lazy-alias exclusions, isLoadingSubset, and progressive snapshot
    delivery behind one internal demand model: readiness = all currently-demanded subsets settled.
    A1's exclusion list and A3's special-casing collapse into it. The oracle harness gains
    liveness/timing assertions here (bounded readiness; subset-before-full-sync) so this property
    class is fuzzed too, not just unit-tested.

Ongoing

  • Dev-mode invariant assertions land opportunistically inside PRs 2–4 (duplicate routing
    registration, orphaned buckets at flush end, alias-keyed runtime lookups, unbalanced weighted
    batches). Each converts a silent-corruption mode into a thrown error in development builds.

4. Non-goals / rejected approaches

  • No new public APIs (explain, loading-status fields, demand/lease surface, new helpers).
  • No alias mangling (beyond the fix: duplicate alias in sibling includes silently breaks nested children #1455 fallback, if taken), no additional per-depth buffers or
    flush sub-passes, no per-adapter cloning beyond the temporary A2 shim, no growing the
    readiness-exclusion list beyond A1's stopgap. Each of these closes one issue while making the
    state machine harder to reason about — PRs 2–5 exist to delete them.

5. Risks

Appendix: relationship to reviewed PRs

PR Current state and disposition
#1455 (duplicate alias) Open; held for PR 2's structural fix, fallback only if PR 2 stalls
#1496 (orderBy after optimistic update) Open; held for PR 2's reducer under the same fallback rule
#1510 (readiness) Open; may land as A1, then be subsumed by PR 5
#1532 (progressive nested test) Open; fold its tests into A3
#1604 (Solid clone) Closed unmerged; do not revive after merged shared observer #1642
#1642 (shared live-query observer) Merged; PR 4 now targets the shared collection/observer boundary
#1656 (record drop on subset unmount) Closed unmerged; ownership family remains tracked by #1631/#1488
#1660 (gcTime 0 falsy default) Merged; independent fix complete
#1672/#1673/#1681 (ownership lifecycle) Merged narrow fixes/tests; remaining ownership reports still need their own pass
#1684 (pre-commit materialization) Closed unmerged; #1713 is the current canonical repro
#1686 (#1685 routing-stamp lifetime) Open narrow P3 stopgap; removed by PR 3
#1705 (joined-alias correlation) Open narrow P1 fix; retain its regressions and remove the alias assumption in PR 2
#1707 (null correlation key) Open independent semantic fix; retain its regressions in the oracle corpus
#1607/#1600/#1580 Merged; their tests remain gates

Issue #1488 (observer-reuse ownership) did not reproduce on main as reported; however, #1631 and
PR #1656 demonstrate the same ownership-loss failure mode through different mechanisms, so the
query-db-collection ownership/refcount lifecycle deserves its own focused pass (single
acquisition path that always registers ownership; leases over incidental bookkeeping) rather than
a per-symptom fix — tracked separately from this RFC.
Issue #1505 is closed; its underlying concern (include fields transiently unmaterialized, types
don't admit it) is addressed by PR 4's always-attached include values.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions