@tanstack/db
Version:
A reactive client store for building super fast apps on sync
570 lines (459 loc) • 25.9 kB
Markdown
# Live-query materialization architecture
This document defines the architecture for correlated live-query
materialization in `@tanstack/db`. It follows
[RFC #1658](https://github.com/TanStack/db/issues/1658).
The central rule is simple:
> Keep relation contents, routes, nested materialization, and propagation in
> one D2 graph. Use custom state only at asynchronous source and public
> Collection boundaries.
The correlated-materialization oracle suites listed below are green behavioral
contracts for this design. Suites for adjacent planner and query-db ownership
boundaries may also contain exact classifiers for defects outside this graph.
## Scope
This architecture covers:
- compiled identities for sources, relations, and materialization edges;
- weighted contributions and public-key reduction;
- correlated routes and ordered bucket contents;
- nested inline and Collection-valued materialization;
- lazy and progressive source demand;
- coherent publication to public Collections;
- the boundaries with query-db ownership and physical query planning.
It does not define new public APIs. Optimistic transactions are another source
of weighted input changes; they do not have a separate routing model.
## One relational graph
Correlated materialization is part of the compiled D2 graph, not a second
incremental engine around its output.
```text
raw weighted query rows
|
v
public-key reduction
|
v
CanonicalRow(base row, order, outgoing parameters)
|
+------------------------------+
| |
v |
Route(bucket, cell) |
| |
+--> distinct --> ActiveBucket-+--> async demand adapter
| | |
| v |
| child rows --> BucketValue
| |
+------------------------------+
|
v
CellValue(cell, value)
|
v
CanonicalRow + outgoing CellValues
|
v
MaterializedRow
|
v
one normal root Collection transaction
```
Canonical base rows flow down to derive correlation routes and source demand.
Fully materialized child rows flow up into their parents. Because the include
graph is acyclic, these streams form one acyclic D2 graph even though demand
and results move in opposite conceptual directions.
The graph owns the data plane. A small adapter owns asynchronous demand. The
normal Collection transaction boundary owns public publication.
## Concrete implementation map
The relation and identity names in this document describe the graph's logical
model. They are not a second set of runtime objects, nor does every name need a
matching TypeScript type. The implementation maps this model onto existing D2
operators and a few boundary adapters:
| Architectural role | Concrete implementation |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Compile relation IDs and demand plans | `packages/db/src/query/compiler/index.ts`, `packages/db/src/query/compiler/joins.ts` |
| Reduce public keys and build routes | `packages/db/src/query/live/materialized-pipeline.ts` |
| Run the graph and publish root rows | `packages/db/src/query/live/collection-config-builder.ts` |
| Publish Collection-valued buckets | `packages/db/src/query/live/bucket-facade-adapter.ts` |
| Start and release asynchronous demand | `packages/db/src/query/live/subset-demand-controller.ts`, `packages/db/src/collection/subscription.ts` |
Queries without includes keep the original compiled pipeline and do not pay
for facade state. The one exception is a joined query with a custom public-key
function: its possible duplicate contributors still pass through the keyed
reduction that enforces public-key congruence and multiplicity.
## Identity
Aliases are lexical query-language names. They are not runtime identities. The
query builder requires collection aliases to be unique across one query tree,
including includes; shadowing is rejected before compilation. Compilation then
assigns opaque IDs to the accepted plan:
```ts
type SourceId = Brand<string, 'SourceId'>
type RelationNodeId = Brand<string, 'RelationNodeId'>
type MaterializationEdgeId = Brand<string, 'MaterializationEdgeId'>
```
Alias text may remain as debug metadata. Renaming an accepted alias to another
unused name cannot change the compiled graph or its result.
A `CanonicalCorrelationKey` is the canonical tuple of every evaluated
parent-dependent value that can affect the child plan. This includes values
used by filters, joins, ordering, limits, and nullable predicates, not only the
obvious foreign-key equality.
A bucket key identifies one such correlated partition at one relation node:
```ts
type BucketKey = readonly [
relationNodeId: RelationNodeId,
correlationKey: CanonicalCorrelationKey,
]
```
Correlation equality must use the same value semantics as query predicates.
Implementations use canonical values, interned handles, or nested maps; they do
not reconstruct array or object keys and expect JavaScript `Map` identity to
match.
A materialization cell identifies one include field on one parent-row
occurrence:
```ts
type MaterializationCellId = readonly [
containingBucket: BucketKey | 'root',
parentPublicKey: PublicKey,
edgeId: MaterializationEdgeId,
]
```
The containing bucket prevents equal child keys in separate correlated
contexts from colliding.
Only work that crosses an asynchronous boundary needs a generation token. A
live-query graph generation invalidates work from an old graph. A demand
generation invalidates an old load for the same bucket. Synchronous route rows
inside D2 do not need their own lifecycle objects or generations.
## Weighted relations and public keys
D2 multisets are the source of truth. A row with positive weight contributes;
a row with negative weight retracts the same contribution.
Internal contribution identity is independent of the user-visible Collection
key. When several internal rows collapse to one public key, a keyed D2
reduction retains all contributors and derives at most one canonical row:
```ts
type CanonicalRow<Row> = {
publicKey: PublicKey
value: Row
order: OrderKey | undefined
outgoingParameters: ReadonlyMap<
MaterializationEdgeId,
CanonicalCorrelationKey
>
}
```
```text
raw weighted rows
-> reduce by [containing bucket, public key]
-> CanonicalRow
+-> derive route rows
+-> compose with materialized include values
```
For every affected public key, the reduction compares its complete before and
after state and emits no change, one replacement, or one removal. It does not
infer the previous state from `collection.has()`.
Routes derive only from canonical rows. Raw contributors never create routes
that must later be reconciled. The same boundary applies recursively: roots
reduce by root public key, while child rows reduce by their containing bucket
and child public key.
All positive contributors collapsed under one public key must be congruent on:
- the visible value;
- the total order key;
- every outgoing correlation input.
Query aggregation occurs upstream in the query graph. This reduction only
preserves multiplicity while collapsing congruent contributors under the
public Collection key. Incongruent contributors are a duplicate-key invariant
error; flush order never chooses a winner. A zero aggregate removes the public
row. A negative aggregate is an invariant violation.
This is a specialized use of the existing D2 keyed reduction. It is not a
separate contribution-ledger subsystem.
## Routes and buckets are relations
For each materialization edge, the compiler produces these keyed relations:
```ts
type RouteRow = readonly [bucketKey: BucketKey, cellId: MaterializationCellId]
type ActiveBucket = readonly [bucketKey: BucketKey]
type BucketRow<Row> = readonly [
bucketKey: BucketKey,
child: readonly [publicKey: PublicKey, row: Row, order: OrderKey | undefined],
]
type BucketValue<Value> = readonly [bucketKey: BucketKey, value: Value]
type CellValue<Value> = readonly [cellId: MaterializationCellId, value: Value]
```
A route move is an ordinary weighted batch:
```text
-1 [old bucket, cell]
+1 [new bucket, cell]
```
Distinct route keys produce `ActiveBucket`. For inline modes, child rows are
ordered and reduced once per active bucket into exactly one `BucketValue`.
Routes then join with bucket values to fan the same immutable logical value out
as a `CellValue`:
```text
Route(bucket, cell) -> distinct -> ActiveBucket(bucket)
|
ActiveBucket + BucketRow -> reduce ------+-> BucketValue(bucket, value)
|
Route(bucket, cell) -------------------------------+
v
CellValue(cell, value)
```
The bucket-value reduction belongs to the materialization edge because two
edges may apply different materialization modes to the same child relation.
Computing it before fan-out means ordering and materialization happen once per
unique bucket rather than once per parent.
`ActiveBucket` also seeds the empty value. Every active inline bucket therefore
has exactly one value even when it has no child rows:
- `array`: `[]`;
- `singleton`: `undefined`;
- `concat`: `""`.
A null or otherwise unsatisfiable correlation may route to an active empty
bucket without creating source demand. This preserves the materialization
mode's empty value instead of relying on a placeholder or a missing join path.
D2's retained join indexes provide the required lifecycle behavior:
- adding a route joins it with the bucket's existing value;
- removing a route retracts only that cell's value;
- moving a route retracts the old rows and adds the new rows in one graph run;
- several cells may consume one bucket without recomputing its value;
- changing a bucket value reaches every current route;
- a departed route receives no later value changes.
Root rows and nested rows use the same relation shape and operators. There is
no special root routing path.
The implementation must not recreate these semantics with route registries,
reverse indexes, drained buffers, or per-depth snapshots outside D2. Existing
retained operator state is the first implementation choice. Add a reusable
arrangement only if counters show that the compiler duplicates indexes or
state; arrangements are a physical optimization, not part of correctness.
The total-materialization law is:
> Every active inline materialization cell has exactly one canonical value,
> including when its bucket contains no rows.
## Nested materialization
The compiler builds each include from the materialized output relation of its
child:
```text
child base rows
+ child include values
-> child materialized rows
-> rows in the parent's bucket relation
-> parent include value
```
A descendant update therefore becomes an ordinary change to the child's
materialized row and propagates through the same joins and reductions at every
depth. There are no depth-specific flush passes, dirty-cell registries, or
manual relation revisions.
Inline modes are reductions over the rows in one active bucket:
- `array`: total-order the rows and return their values;
- `singleton`: choose the first row under the total order;
- `concat`: total-order the rows and concatenate their scalar values.
A total order is the query's order keys followed by a deterministic stable
tie-breaker, normally the child public key. An order-only change is a
bucket-value change for arrays, singletons, concatenation, and Collection
layout.
A bare child query is a Collection-valued include. It exposes one stable public
Collection facade per active bucket in that edge:
```text
ActiveBucket + BucketRow -> ActiveBucketRow -> BucketFacade(bucket, Collection)
Route + BucketFacade -> CellValue(cell, Collection)
```
Parents sharing a bucket share its facade. Child changes update that Collection
without re-emitting every parent, and moving a route changes the parent field to
the destination bucket's facade. A facade is never retargeted to another
bucket. The D2 join retains inactive bucket rows and emits their current
snapshot when the bucket becomes active; the facade adapter does not buffer
discarded deltas. The adapter retains a facade only while at least one parent
route uses its bucket. When the last route leaves, it retracts the facade's rows
and drops its strong reference. An external holder may keep that empty
Collection alive, but a later active interval gets a new facade. Inline modes
do not create child Collections.
Composition is pure. It constructs a new result along changed paths and does
not mutate a previously published row or use public routing metadata:
```ts
compose(
baseRow: BaseRow,
includeValues: ReadonlyMap<MaterializationEdgeId, unknown>,
): MaterializedRow
```
## Demand plane
Demand is derived from data, but it performs asynchronous side effects outside
D2:
```text
ActiveBucket(bucket, demand parameters)
-> group by [source, parameterized child plan]
-> current demanded parameter set
-> demand adapter
-> source loadSubset / release
-> source deltas return to D2 inputs
```
The adapter treats demand as coverage, not as one request per bucket:
```ts
type DemandPlanId = Brand<string, 'DemandPlanId'>
type DemandSet = readonly [
planId: DemandPlanId,
parameters: CanonicalSet<CanonicalCorrelationKey>,
]
```
One request may cover many buckets, and the adapter may coalesce or reuse
requests according to the compiled demand plan. A coalesced request has one
shared abort lease. If one owner releases its lease, the source request remains
active while another owner still needs its coverage. The source signal aborts
only after every attached owner has released it.
Its semantic contract is:
> Every active, satisfiable bucket must be covered by a settled current demand
> request before initial preload completes.
A request may remain in flight after some covered buckets become inactive.
Those buckets no longer participate in readiness and cannot receive rows
through routes that no longer exist. Sharing source work never merges the route
rows themselves.
The source contract stays abstract: a demand request eventually establishes
one coherent baseline and identifies when that baseline is complete. Each
request receives an `AbortSignal`. Cancellation is cooperative at this source
boundary. Core guarantees that an obsolete request cannot settle current
readiness; the source must honor the signal immediately before installing a
baseline or later request-scoped result. Core cannot prevent an arbitrary
adapter from writing after it ignores that signal. Buffering, snapshot tokens,
shape offsets, Collection transactions, and local indexes are source-specific
ways to satisfy that contract; they are not materializer state.
This project uses a single graph-run order rather than multi-dimensional
timely-dataflow frontiers. Do not introduce a general timestamp or frontier
framework unless a source contract proves that the generation and up-to-date
protocol cannot express its ordering.
**Initial readiness:** preload is complete when every demand currently
reachable from the initial query graph is covered by a settled request. Demand
that is no longer reachable does not block completion. An empty outer relation
has no child demand, but its root demand must still settle. Later readiness
transitions follow the existing Collection contract until an executable test
defines another public behavior.
Pending demand does not hide the parent row. An active empty bucket gives it
the current canonical bucket value, and available partial source rows produce
the current partial materialization when the source supports progressive
delivery. Later source rows enter D2 as ordinary deltas and recompute the
parent. “Fully composed” means that every include field has its canonical value
for the graph's current input state; it does not mean that asynchronous demand
has settled.
## Coherent publication
D2 runs until the whole materialization graph has no pending synchronous work
for its currently available inputs. Only fully materialized canonical root
deltas cross into the public Collection.
For each scheduled graph turn:
1. enqueue all currently committed input deltas into their D2 inputs;
2. run D2 until it has no pending synchronous work;
3. consolidate the already canonical final-output deltas;
4. install child-facade state through normal Collection transactions while
deferring their subscriber delivery;
5. apply direct root insert, update, and delete writes through one normal
Collection transaction;
6. release the deferred child-facade events after every synchronous read can
see the complete root and facade state;
7. allow dependent live-query graphs to run through the existing
transaction-scoped scheduler.
The Collection boundary performs no identity reconciliation, routing,
materialization, or multiplicity interpretation. The canonical root relation
has already done that work.
The public Collection is an output, never scratch state. Placeholder rows,
in-place include repair, and forced secondary events are forbidden.
Installed state, synchronous reads, change-event payloads, and downstream
queries must all observe the same fully materialized commit. The facade adapter
may defer event delivery across its Collection transactions, but it must not
defer state or index installation. Routing and identity remain inside D2.
## External boundaries
### Query-db ownership
Row ownership in `@tanstack/query-db-collection` is separate. Eager retention,
active query acquisition, and persisted retention are distinct owner tokens.
The live-query graph publishes coherent rows but does not own query-db cache or
listener lifetime.
### Physical planning and work
Correct relation state does not prove efficient work. When an applicable index
exists, irrelevant correlated rows must not cause scans of unrelated rows or
activate unrelated downstream routes. Relation rows, indexed keys, active
demands, materialization cells, and public facades are the relevant space
units. Queries without includes retain their original pipeline unless a joined
custom-key query needs contributor reduction. Inline materialization must not
create recursive Collection machinery.
## Normative laws
1. **Alpha-renaming:** changing any accepted alias to another unused name cannot
change results; alias shadowing within a query tree is rejected.
2. **Contribution conservation:** a public row exists exactly when its reduced
supporting weight and collision policy produce one.
3. **Batch partition:** equivalent valid split and atomic deliveries converge.
4. **Route relation:** current route rows joined with current bucket values
equal current materialization-cell values.
5. **Total materialization:** every active inline cell has exactly one value,
including its mode's empty value when its bucket has no rows.
6. **Stale demand:** an obsolete graph or demand generation cannot settle
current readiness, and a conforming source cannot publish its request-scoped
rows after cancellation.
7. **Nested propagation:** every materialized relation consumes the fully
materialized output relation of its children.
8. **Publication:** reads, events, and downstream queries observe the same
complete graph result.
9. **Initial demand:** preload completes when every initially reachable demand
is covered; obsolete demand does not block it.
10. **Ownership:** a query-db row exists exactly while an explicit owner
remains.
11. **Work:** irrelevant rows do not cause unrelated scans or activate unrelated
routes when an applicable index exists.
12. **Space:** state scales with retained D2 relation/index rows, active demands,
materialization cells, visible rows, and required Collection facades.
## Glossary
- **Relation:** an internal weighted multiset maintained by D2, not a public
TanStack Collection.
- **Weighted delta:** a positive or negative change to a relation row.
- **Data plane:** the D2 graph that joins, reduces, orders, and materializes
relations.
- **Demand plane:** the async adapter that starts and releases source loads.
- **Bucket key:** the canonical identity of one correlated child partition.
- **Bucket relation:** child rows partitioned by bucket key.
- **Active bucket:** a bucket referenced by at least one current route; it seeds
empty materialization values and contributes to source demand.
- **Bucket value:** the one inline value reduced from an active bucket's rows.
- **Route relation:** weighted links from bucket keys to materialization cells.
- **Materialization cell:** one include field on one parent-row occurrence.
- **Arrangement:** retained relation state indexed for efficient keyed access
and reuse.
- **Reduction:** deriving one visible value from weighted rows sharing a key.
- **Hydration:** establishing an initial snapshot before forwarding later
changes.
- **Generation:** a token that rejects obsolete asynchronous work.
- **Collection facade:** a stable public Collection view shared by the parents
routed to one active bucket.
- **Coherent commit:** one publication in which state, events, and consumers see
the same fully materialized result.
## Executable contracts
| Contract | Test suite |
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| State equivalence, route lifecycle, transition history, and batch partition | `packages/db/tests/query/includes-oracle.property.test.ts` |
| Joined multiplicity, alias identity, and null-key normalization | `packages/db/tests/query/includes-query-shape-oracle.test.ts` |
| Demand, cancellation, and progressive timing | `packages/db/tests/query/includes-temporal-oracle.test.ts` |
| Optimistic confirmation, rollback, and later reactivity | `packages/db/tests/query/includes-optimistic-oracle.property.test.ts` |
| Coherent layered publication | `packages/db/tests/query/includes-publication-oracle.test.ts` |
| Collection facades, event coherence, and route activation | `packages/db/tests/query/includes-collection-oracle.property.test.ts` |
| Correlated physical work | `packages/db/tests/query/includes-work-counter-oracle.test.ts` |
| Query-db ownership | `packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts` |
| Reachable nested shape | `packages/query-db-collection/tests/includes-work-counter-oracle.test.ts` |
Each oracle identifies the first divergent checkpoint and compares either the
whole result or one exact structural difference. Correlated-materialization
scenarios use direct assertions. A boundary suite may retain an exact
expected-failure guard for a planner or ownership defect that this graph does
not own.
Run the DB oracle set with `pnpm test:oracles` from `packages/db`. Broad
properties use FastCheck's random seed, while structural matrices keep fixed
seeds so each run covers the same named cells. Increase both corpora with
`TANSTACK_DB_ORACLE_RUNS_MULTIPLIER=10 pnpm test:oracles`. Preserve FastCheck's
reported seed and shrink path while reducing a failure. Replay a broad
campaign with `TANSTACK_DB_ORACLE_SEED=<seed> pnpm test:oracles`, then add the
smallest case as a deterministic regression trace.
The broad relationship history changes correlation keys rather than freezing
them. Set `TANSTACK_DB_ORACLE_STATISTICS=1` to print its generated depth,
relationship-change, optimistic, and delete distribution. Collection-valued,
array, and materialized includes are checked together for every Collection
scenario instead of relying on a random mode sample. A separate metamorphic
oracle compares nested includes with a flat join, fresh per-parent queries, and
three-valued predicate partitioning.
## Implementation discipline
- Express relation state with existing D2 inputs, joins, reductions, grouping,
ordering, and consolidation before adding custom state.
- Keep route and bucket rows in the same graph as parent and child query rows.
- Add a reusable indexed D2 primitive only when existing operators cannot share
or expose required retained state.
- Keep asynchronous demand state outside D2 and make its generation boundary
explicit.
- Never use a public Collection, emitted event, or materialized row as internal
routing or contribution state.
- Add a reduced oracle trace before adding any special lifecycle branch.
- Measure retained relation rows, active demands, and public facades. Preserve
the no-includes fast path and verify any claimed space improvement with those
counters.