Expand description
Normalized change events — the second serializer beside the engine’s flat path
(rindle::flatten), for a local-first client that runs its own normalized base
tables + local IVM.
See NORMALIZED-CHANGES-DESIGN.md for the full design. Where rindle::flatten keeps the
root→leaf path and drops a remove’s subtree, NormalizeFold does the opposite:
it drops the path, keeps only a table tag, and keeps the subtree on remove (so
witnesses/children are decremented too, §4.3). The engine does not change — both
serializers consume the same CaughtChange stream the replica already delivers
(writer.rs hands out Vec<(NodeId, Vec<CaughtChange>)> per query).
Why this lives in rindle-wire, not the rindle core or rindle-replica:
the sender set grew. This serializer was originally server-only — the browser receiver
is the TypeScript NormalizedSync, and even in normalized mode the wasm Db is fed
flat changes, never the fold — so it lived in the SQLite-linking replica. But a
room (RINDLE-REALTIME-DESIGN.md §2.4) is a normalized publisher compiled to
wasm, so the fold must link without SQLite and compile for wasm32 — and since the
daemons and the room must speak byte-identical frames, the whole protocol (fold,
envelope, JSON codec) sits in this dedicated wire crate both depend on; the replica
re-exports it from here unchanged. It stays out of the rindle core for the same
reason as ever: the core is the engine, not a wire protocol. (Contrast
rindle::flatten, which the wasm client genuinely uses, so it earns its place in
core.)
This module is Slice 1: the fold itself. It folds one transaction’s CaughtChanges
into a flat per-table set with intra-query refcounts (the footprint) and emits this
tick’s net membership deltas as table-tagged NormalizedOps, deduplicated so a row
touched via several tree paths in one transaction collapses to one op (§4.1/§4.2).
The footprint interns its rows: it holds the Arc-shared OwnedRow the producing
pipeline already owns (no second copy), materializing a WireRow only for the rows it
actually emits. The protocol envelope / wire framing (NormalizedPublisher, the
NormalizedHello) is a later slice.
Structs§
- AggTable
- A synthetic aggregate base table the server ships in place of an aggregate’s child
rows: the reduce’s
(group_key…, value)output, given a base-table home on the client (§3.2). Columns are[child_field…, "count"](mirroring the engine’sagg_relationship_reldef/Reduce::count_byoutput), the leadingkey_lenof which are the group key (and the PK). Derived purely from the AST — there is no such table in the DB — soagg_table_schemasfeeds the publisher’shello+ PK map for it. - Normalize
Fold - The server-side serializer (§4): one per registered normalized query. Fold each
committed transaction’s
CaughtChanges withfoldto get that tick’sNormalizedOps. The footprint persists across ticks; the snapshot is simply the first fold over the hydrate batch (every row 0→1 ⇒ allAdds). - Table
Node - The query’s table tree: each frame’s base table plus, per relationship slot (in the
query-local
RelIdorder), the child frame’s tree. It is the path-free replacement forPathSeg: folding aCaughtChange::Childdescendschildren[rel]to learn the child’s table, then throws the parent row away.
Enums§
- Normalized
Op - One normalized change. Self-identifies by table name — the client routes each row
to a base table directly, with no tree path and no slot map (§3). Rows are positional
(aligned to that table’s column order).
OwnedValueis serializable because the crate enablesrindle’sserdefeature, so the op serializes for the wire / oracle directly. - ReqCols
- What columns this query needs synced for a base table.
All⇒ a'*'frame (noselect) referenced the table, so it must sync every column;Names⇒ the explicit set of column names the query structurally reads for the table (itsrequired_colsat the name level —select∪where-leaf ∪order_by∪start∪ correlation fields). Unioned across every frame that references the same table (anyAllwins).
Functions§
- agg_
table_ name - The synthetic base-table NAME for a relationship
countaggregate (§3.1): a content hash of the aggregate’s definition — child table, kind, the group key (correlation child fields), and the childwherefilter — so two queries with the same definition share one table (cross-query refcount on the client) while a different filter gets a different table (no(table, pk)collision). The parent correlation field is excluded: the count for a given group key is the same whichever parent joins it. - agg_
table_ schemas - Collect a synthetic
AggTablefor every relationshipcountaggregate inast, recursively (a nested aggregate under a materializedrelatedis included). The caller (the replica consumer / the publisher’s schema list) advertises these alongside the real base-table schemas so the client can register + validate them. - reject_
unsupported_ sync_ aggregate - Reject a relationship
sum/avgaggregate anywhere inast’srelatedtree — the precomputed-sync path (synthetic aggregate base tables,AGGREGATE-SYNC-DESIGN.md§3) is count-only: it ships the reduce’s(group…, count)row as a base table and has no wire encoding for asum/avgvalue or its int-vs-real typing. A relationshipsum/avgstill works for a direct engine read (the generic scalar projection); it is only unsupported for sync materialization, so registration surfaces it as a [BuildError] rather than silently minting a count-shaped synthetic table. Call this at each sync-registration entry (the replica’sregister_shared_query, the room’smaterialize) before any oftable_tree/agg_table_schemas/NormalizeFoldwalk the tree — that ordering is what makes theSum/Avgarm ofhash_agg_subqueryunreachable. - required_
columns_ by_ table - The columns each base table in
ast’s footprint needs synced (§5.2). A pure function of the AST (column names, resolved to indices by the caller against the table schema). Mirrorstable_tree’s slot walk + prune (exists_noSyncslots are never synced, so they contribute nothing). The result drives the projected wire schema and project-at-emit. - rewrite_
aggregates - Rewrite an AST for a client engine reading synced aggregate tables — the Rust twin of
TypeScript
rewriteAggregates(packages/normalized/src/agg-table.ts), byte-for-byte in its choice of table name because both callagg_table_name. - rewrite_
aggregates_ with_ local rewrite_aggregates, with the L1 local-table carve-out (201-LOCAL-ONLY-TABLES-DESIGN.md§5.2): a count over a tableis_localaccepts is a native IVM reduce with no server-authoritative__agg_*base, so it is left alone — rewriting it would point the relationship at a synthetic table nothing ever feeds.- table_
tree - Derive the
TableNodetree forast. A pure function of the AST — it needs only table names (all present inline: the rootast.table, each subquery’stable), never the source schemas, so noresolveclosure. Mirrorsview_schema’s slot walk exactly (normalize the frame, take [query_local_slot_names], resolve each slot name to its subquery, recurse) but carries the table where the schema carries the alias.
Type Aliases§
- PkMap
- Table → primary-key column indices (into that table’s row), supplied at construction.