Skip to content

RFC: Stabilizing includes / nested materialization #1658

Description

@KyleAMathews

RFC: Stabilizing includes / nested materialization

Status: production implementation complete in PR #1740; awaiting merge
Scope: bug fixes and internal refactors only — no new public API surface, no behavior changes beyond fixing verified bugs.
Working model: one coherent D2 graph implementation, backed by direct oracle assertions and narrow boundary adapters.

Progress:

The detailed issue evidence below records the pre-implementation state. Section 3 and live GitHub issue status are authoritative for the completed implementation.

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 Fixed on current main: the exact optimistic child-reorder regression is green and retained in the recompute oracle; #1444 closed and PR #1496 closed unmerged as superseded packages/db/tests/query/includes-oracle.property.test.ts (regression seed: optimistic child reorder matches recomputation)
#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-17): #1716#1739 are merged. PR #1740 contains the production graph rewrite and the final oracle expansion. Its directly owned gates are green; related adapter, ownership, and performance reports remain scoped as stated in the PR body.

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. Revised design direction

The completed oracle suite keeps the five original principles but changes their boundaries:

Principle Decision Revised boundary
P1 — Opaque plan identities Keep Land first and independently. Use source, relation-node, include-node, and materialization-edge IDs; aliases remain lexical names and debug labels.
P2 — One transition reducer Strengthen Use a persistent contribution ledger. Public Collection state cannot safely stand in for contributor multiplicity across batches.
P3 — Correlated relation Decompose Separate ordered bucket contents, subscriber routing, and route demand. Root and nested materialization cells use the same protocol.
P4 — Publication by replacement Keep, move upstream Compose immutable, fully materialized rows before the public Collection commit. Observers are consumers, not a repair boundary.
P5 — Demand-relative readiness Keep Acquire and retire demand leases through the same route-lifecycle operations that manage subscribers.

The target data path is:

ID-keyed compiled D2 plan
        |
        v
weighted contribution deltas
        |
        v
ContributionLedger
        |
        v
ordered root/bucket relations
        |
        +-------- RouteLedger -------- DemandRegistry
        |               |
        v               v
dirty materialization cells
        |
        v
immutable MaterializationComposer
        |
        v
one coherent Collection commit
        |
        v
downstream live queries and adapters

CorrelatedRelation is therefore a protocol implemented by several small stateful components, not one object that owns contents, subscribers, demand, and publication. Rebuilding all of those responsibilities inside one operator would recreate CollectionConfigBuilder under a new name.

Seven architecture axes

  1. Compiled identity graph. The compiler assigns branded internal IDs to sources, relation nodes, include nodes, and materialization edges. Runtime maps are ID-keyed. Aliases resolve only inside their lexical query scope. A bucket is identified by an include-node ID plus a canonical structural correlation tuple, never by a flattened alias or JSON.stringify of mutable context.
  2. Persistent contribution accounting. Each public key retains its weighted internal contributors across batches. A graph run applies every delta, compares the aggregate before and after, then emits at most one visible set/delete transition. Root and include output use the same ledger.
  3. Ordered bucket relations. Correlated child contents live in lightweight ordered stores with stable keys and monotonically increasing revisions. Relation contents do not own subscribers or demand.
  4. Route/subscriber lifecycle. A subscriber is a materialization cell: output relation ID + parent public key + materialization edge ID. Subscribe returns the exact current bucket snapshot; unsubscribe removes only that edge; move is atomic; generations reject stale updates and snapshots. Roots and nested rows use this same protocol.
  5. Coherent immutable publication. A pure composer creates new values only along changed paths. A private materialization commit stages base-row, bucket, route, composition, delete, and order changes. The public Collection receives one normal commit only after every affected row is fully materialized.
  6. Route-coupled demand. Lazy and progressive subsets are represented by generation-scoped leases. Readiness means every active lease in the current route graph has settled. Empty outer results create no child leases; retired generations cannot publish or settle current demand.
  7. Independent ownership and physical work. Query-db row ownership belongs in a typed ownership ledger, not the includes materializer. Correlated-plan work and internal footprint have their own counters and acceptance criteria rather than being inferred from correct results.

Stable defect catalog

The expected-failure tests use exact classifiers and checkpoints. The RFC names each class so it remains visible outside test code:

Stable class Evidence / owner
Duplicate sibling-alias collision #1454; compiled identities
Joined-alias correlation loss #1704; compiled identities
Collapsed-contributor premature delete #1703; contribution ledger
Initial null-key placeholder leak #1706; composer normalization
Nested scalar redirect retention #1718; route ledger
Reinserted-parent obsolete route #1719; route ledger
Intra-batch child hand-off staleness #1719; contribution + route transaction
Deep-rekey detachment #1722/#1735; route ledger
Moved-subtree descendant staleness #1722; route ledger
Sequential retired-route resurrection #1733; route ledger
Intra-batch retired-route resurrection #1733; route ledger
Departed shared-route subscriber update #1733; explicit edge removal
Moved-child replacement snapshot loss #1733; staged relation application
Root live-route snapshot miss #1734; universal snapshot-on-subscribe
Root retired-route resubscription leak #1734; subscriber identity + generations
Split new-ID handoff missing grandchild #1739; atomic route/replacement transaction
Layered null publication #1713/#1736; coherent pre-commit composition
Eager-owner row loss #1631/#1737; ownership ledger
Persisted-owner transaction loss PR #1656/#1737; ownership ledger
Correlated-join excess work #1709/#1738; physical planning

The optimistic suite does not define a separate architecture axis: its only relationship failure is the existing deep-rekey class reached through another producer. Optimistic changes remain weighted inputs to the same ledger and route protocol.

The batch suite also argues against replacing the scheduler or D2 graph-run boundary. Atomic delivery, reverse order, same-ID replacement, and fresh-route mirrors are green. The narrow split/new-ID/handed-off-route defect belongs in route ownership and staged application.

Normative laws

These are permanent contracts even if component names change:

  1. Alpha-renaming: lexical alias names cannot change results.
  2. Contribution conservation: a public row exists iff its net supporting contribution is positive.
  3. Batch partition: equivalent valid split and atomic deliveries converge.
  4. Subscription: subscribe receives the current snapshot exactly once; unsubscribe receives no later changes.
  5. Route generation: a retired generation can neither publish nor settle demand.
  6. Publication: an event payload, synchronous Collection read, and downstream live query observe the same fully materialized revision.
  7. Demand: readiness is equivalent to settlement of the active demand graph.
  8. Ownership: a row exists iff at least one explicit owner token remains.
  9. Work: irrelevant correlated rows do not increase examined work on an indexed physical path.
  10. Space: internal state scales with relations, routes, edges, and visible rows—not recursive full Collection machinery.

3. Implementation outcome

PR #1740 implements the architecture as one coherent change rather than the earlier staged cutover. The landed oracle work made that safe: every known defect has an exact checkpoint or structural classifier, and the production branch removes those classifiers instead of weakening them.

The runtime now uses opaque compiled source identity and D2 weighted relations for contribution, route, ordering, and nested propagation. BucketFacadeAdapter owns only public Collection identity and subscription. SubsetDemandController owns only asynchronous demand coverage, cancellation, and readiness. Coherent publication installs child facades and root rows before observers run. The legacy alias maps, route registries, reverse indexes, recursive internal Collections, depth buffers, and imperative snapshot drains are removed.

The final testing strategy combines deterministic regression traces, 172 exhaustive micro-domain cases, random route-changing FastCheck histories, Collection/array/materialized comparison, scheduled demand interleavings, and cross-formulation nested/flat/per-parent/TLP equivalence. TANSTACK_DB_ORACLE_SEED replays random campaigns, TANSTACK_DB_ORACLE_RUNS_MULTIPLIER scales them, and TANSTACK_DB_ORACLE_STATISTICS=1 reports generated coverage. The default eight-file gate passes 219/219. A 100x run completed all assertions without a semantic counterexample; Vitest emitted one post-run worker-RPC timeout, after which the normal gate exited cleanly.

Generic DBSP laws are deliberately separate because they belong to @tanstack/db-ivm, not the includes facade. Follow-up #1741 tracks Q(x + Δ) = Q(x) + Q^Δ(x, Δ) coverage for core differential operators.

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

  • The change is large, so the direct oracle gate, fixed regression corpus, random replay controls, and full DB/adapter suites are merge gates.
  • Collection facades and lazy demand remain stateful boundaries. Tests require them not to recreate relation state or publish partial revisions.
  • LoadSubsetOptions.signal is optional for compatibility; adapters that fetch asynchronously should honor it before installing rows.
  • The semantic work/shape tests do not replace allocation, heap, or elapsed-time benchmarks for Poor performance for nested includes #1634.
  • Nightly high-run CI and a full Stryker audit are not part of this PR. The multiplier, replay seed, and focused mutation audit make those follow-ups possible.

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) Closed unmerged on 2026-08-14 as superseded; current main passes the retained exact regression and #1444 is closed
#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; #1737 reproduces its persisted-owner failure through production transactional metadata
#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
#1733 (transition histories) Merged; relationship-history and order-sensitive expected-failure inventory landed
#1734 (route lifecycle) Merged; topology/destination lifecycle plus two exact root snapshot/resubscription failures
#1735 (optimistic relationships) Merged; optimistic visibility, confirmation, rollback, and settlement coverage
#1736 (layered publication) Merged; full sibling × Q1 × Q2 publication matrix for #1713
#1737 (ownership lifecycle) Merged; #1488 green/non-repro, #1631 and #1656 exact expected failures
#1738 (work and shape) Merged; exact #1709 work failure plus #1634 preload source delivery and reachable shape
#1739 (batch shapes) Merged; full valid relationship batch-shape matrix and exact known failure boundary
#1607/#1600/#1580 Merged; their tests remain gates

Issue #1488's reported observer-reuse path does not reproduce on current main, as pinned by #1737. The same oracle reproduces #1631 and PR #1656 through different ownership-loss mechanisms, so the ownership/refcount lifecycle remains a required gate rather than being dismissed with #1488's green result.
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