Rindle docs and package mapSkip to main content

rindle_wire/
normalize.rs

1//! Normalized change events — the **second serializer** beside the engine's flat path
2//! (`rindle::flatten`), for a local-first client that runs its own normalized base
3//! tables + local IVM.
4//!
5//! See `NORMALIZED-CHANGES-DESIGN.md` for the full design. Where `rindle::flatten` keeps the
6//! root→leaf **path** and drops a remove's subtree, [`NormalizeFold`] does the opposite:
7//! it **drops the path, keeps only a table tag**, and **keeps the subtree on remove** (so
8//! witnesses/children are decremented too, §4.3). The engine does not change — both
9//! serializers consume the same `CaughtChange` stream the replica already delivers
10//! (`writer.rs` hands out `Vec<(NodeId, Vec<CaughtChange>)>` per query).
11//!
12//! **Why this lives in `rindle-wire`, not the `rindle` core or `rindle-replica`:**
13//! the sender set grew. This serializer was originally server-only — the browser receiver
14//! is the TypeScript `NormalizedSync`, and even in normalized mode the wasm `Db` is fed
15//! *flat* changes, never the fold — so it lived in the SQLite-linking replica. But a
16//! *room* (`RINDLE-REALTIME-DESIGN.md` §2.4) is a normalized **publisher compiled to
17//! wasm**, so the fold must link without SQLite and compile for `wasm32` — and since the
18//! daemons and the room must speak byte-identical frames, the whole protocol (fold,
19//! envelope, JSON codec) sits in this dedicated wire crate both depend on; the replica
20//! re-exports it from here unchanged. It stays out of the `rindle` core for the same
21//! reason as ever: the core is the engine, not a wire protocol. (Contrast
22//! `rindle::flatten`, which the wasm client genuinely uses, so it earns its place in
23//! core.)
24//!
25//! This module is Slice 1: the fold itself. It folds one transaction's `CaughtChange`s
26//! into a flat **per-table set with intra-query refcounts** (the footprint) and emits this
27//! tick's net membership deltas as table-tagged [`NormalizedOp`]s, deduplicated so a row
28//! touched via several tree paths in one transaction collapses to **one** op (§4.1/§4.2).
29//! The footprint **interns** its rows: it holds the `Arc`-shared `OwnedRow` the producing
30//! pipeline already owns (no second copy), materializing a `WireRow` only for the rows it
31//! actually emits. The protocol envelope / wire framing (`NormalizedPublisher`, the
32//! `NormalizedHello`) is a later slice.
33
34use std::cmp::Ordering;
35use std::collections::{BTreeMap, BTreeSet, HashMap};
36
37use rindle::value::{compare_values, values_identical, ColId, OwnedRow, OwnedValue};
38use rindle::{normalize_pipeline_ast, query_local_slot_names};
39use rindle::{
40    Aggregate, Ast, BuildError, CaughtChange, CaughtNode, Condition, CorrelatedSubquery,
41    CorrelatedSubqueryCondition, Correlation, ExistsOp, Lit, Op, System, ValuePosition, WireRow,
42};
43
44// ---------------------------------------------------------------------------
45// The wire op (§3): table-tagged, path-free row deltas.
46// ---------------------------------------------------------------------------
47
48/// One normalized change. Self-identifies by **table name** — the client routes each row
49/// to a base table directly, with no tree path and no slot map (§3). Rows are positional
50/// (aligned to that table's column order). `OwnedValue` is serializable because the crate
51/// enables `rindle`'s `serde` feature, so the op serializes for the wire / oracle directly.
52#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
53pub enum NormalizedOp {
54    /// A row entered this query's footprint.
55    Add { table: Box<str>, row: WireRow },
56    /// A row left this query's footprint (the row carries ≥ its PK; a full row is fine).
57    Remove { table: Box<str>, row: WireRow },
58    /// A footprinted row's value changed (PK stable; `old` and `new` share a PK).
59    Edit {
60        table: Box<str>,
61        old: WireRow,
62        new: WireRow,
63    },
64}
65
66// ---------------------------------------------------------------------------
67// The table tree (§4.1): slot → child table, derived from the AST.
68// ---------------------------------------------------------------------------
69
70/// The query's **table tree**: each frame's base table plus, per relationship slot (in the
71/// query-local `RelId` order), the child frame's tree. It is the path-free replacement for
72/// [`PathSeg`](rindle::PathSeg): folding a `CaughtChange::Child` descends `children[rel]` to
73/// learn the child's table, then throws the parent row away.
74///
75/// `children[i]` corresponds to slot `i` of [`query_local_slot_names`] — materialized
76/// `related` first (in `ast.related` order), then EXISTS gating aliases in `where`-tree
77/// pre-order. This is the same slot layout the dataflow and [`view_schema`] use, so a
78/// `CaughtChange::Child`'s `rel` indexes straight into `children` by construction.
79///
80/// A slot is `None` when it is **pruned** (`exists_noSync`, `EXISTS-NOSYNC-DESIGN.md` §4): a
81/// `system: Permissions` EXISTS gate whose witnesses must not enter the footprint (and so are
82/// never synced). The slot **keeps its position** — dropping it would shift later indices out
83/// of alignment with the dataflow `RelId`s — but the fold neither descends into it nor counts
84/// its rows.
85///
86/// [`view_schema`]: rindle::view_schema
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub struct TableNode {
89    /// This frame's base table name.
90    pub table: Box<str>,
91    /// One subtree per query-local relationship slot, in slot order; `None` ⇒ a pruned
92    /// (`exists_noSync`) slot whose witnesses are excluded from the footprint.
93    pub children: Vec<Option<TableNode>>,
94}
95
96/// Derive the [`TableNode`] tree for `ast`. A pure function of the AST — it needs only
97/// table **names** (all present inline: the root `ast.table`, each subquery's `table`),
98/// never the source schemas, so no `resolve` closure. Mirrors [`view_schema`]'s slot walk
99/// exactly (normalize the frame, take [`query_local_slot_names`], resolve each slot name to
100/// its subquery, recurse) but carries the **table** where the schema carries the alias.
101///
102/// [`view_schema`]: rindle::view_schema
103pub fn table_tree(ast: &Ast) -> TableNode {
104    // Frame-local normalization, exactly as the engine's `build_pipeline_internal` /
105    // `view_schema` do per frame: it uniquifies this frame's EXISTS aliases so
106    // `query_local_slot_names` reads the same names the dataflow `RelId`s resolve against.
107    let ast = normalize_pipeline_ast(ast);
108    let children = query_local_slot_names(&ast)
109        .iter()
110        .map(|name| {
111            let name: &str = name;
112            let (cs, origin) = slot_backing(&ast, name).unwrap_or_else(|| {
113                panic!("rindle-replica internal invariant: slot {name:?} has no subquery")
114            });
115            if cs.system == Some(System::Permissions) {
116                // `exists_noSync` (§4): a permission-provenance slot is pruned — `None` keeps
117                // its position but excludes its witnesses from the footprint (never synced).
118                None
119            } else if cs.subquery.aggregate.is_some() && origin == SlotOrigin::WhereExists {
120                // A `having_count` parent gate (`PARENT-AGGREGATE-FILTER-DESIGN.md` §3): the
121                // aggregate is lowered to an EXISTS over a HAVING-filtered reduce, so THIS slot
122                // holds the gate's witness child rows — not the reduce's `(group…, count)`
123                // output. Re-tagging them `agg_table_name` (the `related` arm below) would ship
124                // full-width child rows under the synthetic table's 2-column schema, and the
125                // client's width check rejects the batch. Prune like `exists_noSync`: the count
126                // the client needs is already synced by the DISPLAY `count_as` this gate was
127                // cloned from (`Query::having_count` requires one to exist), and since
128                // `agg_table_name` hashes neither `alias` nor `having`, both resolve to the SAME
129                // `__agg_*` table — so the client evaluates the gate off those rows once
130                // `rewriteAggregates` points it there (@rindle/normalized).
131                None
132            } else if cs.subquery.aggregate.is_some() {
133                // A relationship **aggregate** (`AGGREGATE-SYNC-DESIGN.md` §3.2): the child
134                // rows are never synced — the server ships the reduce's synthetic
135                // `(group_key…, count)` row as a SYNTHETIC base table. Re-tag the slot to that
136                // table (a leaf: the aggregate row carries no relationships) and do **not**
137                // recurse into the child table. The fold then keys the synthetic row by the
138                // group columns under this name with no special-casing — its PK rides the
139                // publisher's advertised schema (`agg_table_schemas`), like any base table.
140                Some(TableNode {
141                    table: agg_table_name(cs),
142                    children: vec![],
143                })
144            } else {
145                Some(table_tree(&cs.subquery))
146            }
147        })
148        .collect();
149    TableNode {
150        table: ast.table.clone(),
151        children,
152    }
153}
154
155// ---------------------------------------------------------------------------
156// Synthetic aggregate tables (`AGGREGATE-SYNC-DESIGN.md` §3.1/§3.2)
157// ---------------------------------------------------------------------------
158
159/// A synthetic aggregate base table the server ships in place of an aggregate's child
160/// rows: the reduce's `(group_key…, value)` output, given a base-table home on the client
161/// (§3.2). Columns are `[child_field…, "count"]` (mirroring the engine's
162/// `agg_relationship_reldef` / `Reduce::count_by` output), the leading `key_len` of which
163/// are the group key (and the PK). Derived purely from the AST — there is no such table in
164/// the DB — so [`agg_table_schemas`] feeds the publisher's `hello` + PK map for it.
165#[derive(Clone, Debug, PartialEq, Eq)]
166pub struct AggTable {
167    /// The content-addressed table name (see [`agg_table_name`]).
168    pub name: Box<str>,
169    /// `[child_field…, "count"]` — the synthetic row's columns, positional.
170    pub columns: Vec<Box<str>>,
171    /// How many leading columns form the group key (and the primary key): `0..key_len`.
172    pub key_len: usize,
173}
174
175/// Reject a relationship `sum`/`avg` aggregate anywhere in `ast`'s `related` tree — the
176/// precomputed-sync path (synthetic aggregate base tables, `AGGREGATE-SYNC-DESIGN.md` §3)
177/// is **count-only**: it ships the reduce's `(group…, count)` row as a base table and has
178/// no wire encoding for a `sum`/`avg` value or its int-vs-real typing. A relationship
179/// `sum`/`avg` still works for a **direct** engine read (the generic scalar projection);
180/// it is only unsupported for sync materialization, so registration surfaces it as a
181/// [`BuildError`] rather than silently minting a count-shaped synthetic table. Call this at
182/// each sync-registration entry (the replica's `register_shared_query`, the room's
183/// `materialize`) before any of [`table_tree`] / [`agg_table_schemas`] / [`NormalizeFold`]
184/// walk the tree — that ordering is what makes the `Sum`/`Avg` arm of `hash_agg_subquery`
185/// unreachable.
186pub fn reject_unsupported_sync_aggregate(ast: &Ast) -> Result<(), BuildError> {
187    for r in &ast.related {
188        match &r.subquery.aggregate {
189            Some(Aggregate::Sum(_)) | Some(Aggregate::Avg(_)) => {
190                return Err(BuildError::Unsupported(
191                    "relationship sum/avg aggregates are not supported for precomputed sync \
192                     (count only) — read them directly instead",
193                ));
194            }
195            // A relationship aggregate is a leaf (the builder forbids nested `related` under
196            // one); only an ordinary materialized `related` recurses.
197            Some(Aggregate::Count) => {}
198            None => reject_unsupported_sync_aggregate(&r.subquery)?,
199        }
200    }
201    Ok(())
202}
203
204/// Collect a synthetic [`AggTable`] for **every** relationship `count` aggregate in `ast`,
205/// recursively (a nested aggregate under a materialized `related` is included). The caller
206/// (the replica consumer / the publisher's schema list) advertises these alongside the real
207/// base-table schemas so the client can register + validate them.
208///
209/// The `where` tree is deliberately NOT walked. An aggregate does reach it — a `having_count`
210/// parent gate is an EXISTS over a HAVING-filtered reduce — but that gate is a CLONE of a
211/// materialized `related` `count_as` (`Query::having_count` requires one), and
212/// [`agg_table_name`] hashes neither `alias` nor `having`, so it names the very table the
213/// display aggregate already contributes here. Walking `where` would only re-derive a
214/// duplicate. The gate's own slot ships nothing: [`table_tree`] prunes it, because a
215/// `where`-EXISTS slot holds witness child rows rather than the reduce's output.
216pub fn agg_table_schemas(ast: &Ast) -> Vec<AggTable> {
217    let mut out = Vec::new();
218    collect_agg_tables(ast, &mut out);
219    out
220}
221
222fn collect_agg_tables(ast: &Ast, out: &mut Vec<AggTable>) {
223    for r in &ast.related {
224        if r.subquery.aggregate.is_some() {
225            let mut columns: Vec<Box<str>> = r.correlation.child_field.clone();
226            columns.push("count".into());
227            out.push(AggTable {
228                name: agg_table_name(r),
229                columns,
230                key_len: r.correlation.child_field.len(),
231            });
232        } else {
233            collect_agg_tables(&r.subquery, out);
234        }
235    }
236}
237
238/// Rewrite an AST for a **client** engine reading synced aggregate tables — the Rust twin of
239/// TypeScript `rewriteAggregates` (`packages/normalized/src/agg-table.ts`), byte-for-byte in
240/// its choice of table name because both call [`agg_table_name`].
241///
242/// Each relationship `count` becomes a precomputed, source-backed singular relationship over
243/// its synthetic table: read the server's count with a plain join + the same scalar projection
244/// (`aggregate_precomputed`), never a `reduce`, which would recount already-aggregated rows.
245/// Non-aggregate relationships recurse (a nested aggregate is rewritten too); the parent's
246/// frame is otherwise untouched, so the view-schema slot order is preserved.
247///
248/// The `where` tree is rewritten the SAME way (`PARENT-AGGREGATE-FILTER-DESIGN.md` §3): a
249/// `having_count` parent gate lowers to an EXISTS whose subquery CLONES the display `count_as`
250/// and adds a post-aggregation `HAVING`. That is a relationship `count` like any other, so the
251/// premise applies to it too — the client never recomputes a count, it lacks the child rows.
252/// Left un-rewritten the gate reduces over child rows the server (rightly) does not sync, and
253/// every parent fails it. Since [`agg_table_name`] hashes neither `alias` nor `having`, the
254/// gate resolves to the SAME `__agg_*` table the display `count_as` already registers: the
255/// rewrite costs no extra table and no extra rows. Its counterpart is the server pruning the
256/// gate's witnesses from the footprint ([`table_tree`]) — that prune is sound only BECAUSE of
257/// this rewrite.
258///
259/// # Why a Rust twin exists
260/// The client rewrite used to live only in TypeScript, which meant no wire-bearing test tier
261/// could **value**-grade an aggregate: grading re-runs the oracle over the reconstructed
262/// footprint, and the footprint deliberately holds no child rows, so a recomputed `count` is
263/// always 0. Every such tier therefore ran with aggregate generation switched off — the
264/// blind spot both `having_count` bugs (the wire width collision and the planner flip) sat in.
265/// With this, a harness can reconstruct the client's own AST and grade the answer.
266pub fn rewrite_aggregates(ast: &Ast) -> Ast {
267    rewrite_aggregates_with_local(ast, &|_| false)
268}
269
270/// [`rewrite_aggregates`], with the L1 local-table carve-out
271/// (`201-LOCAL-ONLY-TABLES-DESIGN.md` §5.2): a count over a table `is_local` accepts is a
272/// native IVM reduce with no server-authoritative `__agg_*` base, so it is left alone —
273/// rewriting it would point the relationship at a synthetic table nothing ever feeds.
274pub fn rewrite_aggregates_with_local(ast: &Ast, is_local: &dyn Fn(&str) -> bool) -> Ast {
275    let mut out = ast.clone();
276    out.r#where = ast
277        .r#where
278        .as_ref()
279        .map(|c| rewrite_condition_aggregates(c, is_local));
280    out.related = ast
281        .related
282        .iter()
283        .map(|r| rewrite_relationship(r, is_local))
284        .collect();
285    out
286}
287
288/// The `where`-tree half: an EXISTS gate whose subquery carries an aggregate (the
289/// `having_count` lowering) is rewritten by the very same [`rewrite_relationship`]; every
290/// other condition recurses structurally. A `Simple` leaf holds no subquery.
291fn rewrite_condition_aggregates(cond: &Condition, is_local: &dyn Fn(&str) -> bool) -> Condition {
292    match cond {
293        Condition::Simple(_) => cond.clone(),
294        Condition::And { conditions } => Condition::And {
295            conditions: conditions
296                .iter()
297                .map(|c| rewrite_condition_aggregates(c, is_local))
298                .collect(),
299        },
300        Condition::Or { conditions } => Condition::Or {
301            conditions: conditions
302                .iter()
303                .map(|c| rewrite_condition_aggregates(c, is_local))
304                .collect(),
305        },
306        Condition::CorrelatedSubquery(csq) => {
307            Condition::CorrelatedSubquery(CorrelatedSubqueryCondition {
308                related: rewrite_relationship(&csq.related, is_local),
309                ..csq.clone()
310            })
311        }
312    }
313}
314
315fn rewrite_relationship(
316    csq: &CorrelatedSubquery,
317    is_local: &dyn Fn(&str) -> bool,
318) -> CorrelatedSubquery {
319    if csq.subquery.aggregate.is_some() && !is_local(&csq.subquery.table) {
320        let mut subquery = Ast::new(&agg_table_name(csq));
321        subquery.aggregate = csq.subquery.aggregate.clone();
322        subquery.aggregate_precomputed = true;
323        // The correlation is unchanged: the synthetic table's group columns are named after
324        // the child correlation fields, so `child_field` still resolves against
325        // `[child_field…, count]`.
326        subquery.alias = csq.subquery.alias.clone();
327        // The post-aggregation `HAVING` SURVIVES the rewrite — it is the gate's whole
328        // predicate (`count > n`) and it addresses the reduce's OUTPUT column, which is
329        // exactly what the synthetic row carries. A display `count_as` never has one, so
330        // this is inert there.
331        subquery.having = csq.subquery.having.clone();
332        return CorrelatedSubquery {
333            correlation: csq.correlation.clone(),
334            subquery: Box::new(subquery),
335            system: csq.system,
336        };
337    }
338    CorrelatedSubquery {
339        correlation: csq.correlation.clone(),
340        subquery: Box::new(rewrite_aggregates_with_local(&csq.subquery, is_local)),
341        system: csq.system,
342    }
343}
344
345/// The synthetic base-table NAME for a relationship `count` aggregate (§3.1): a content
346/// hash of the aggregate's **definition** — child table, kind, the group key (correlation
347/// **child** fields), and the child `where` filter — so two queries with the *same*
348/// definition share one table (cross-query refcount on the client) while a different filter
349/// gets a *different* table (no `(table, pk)` collision). The **parent** correlation field
350/// is excluded: the count for a given group key is the same whichever parent joins it.
351///
352/// # Byte protocol (the cross-language contract the TS client must reproduce, §3.1)
353/// FNV-1a-64 (offset `0xcbf29ce484222325`, prime `0x100000001b3`); integers little-endian;
354/// every string length-prefixed (`u32` byte-length + UTF-8). In order:
355/// `u32(child_field.len)` then each `s(child_field[i])`; then the aggregate sub-AST identity
356/// (`hash_agg_subquery`) — `s(table)`, `byte(kind)` (`count` = 1), the `where`
357/// (`byte(0)` if absent, else `byte(1)` + `hash_condition`), and `u32(related.len)` with
358/// each child's correlation + recursion. Rendered `"__agg_"` + 16 lowercase hex digits.
359/// Only `count` reaches here — a relationship `sum`/`avg` is rejected before the sync path
360/// ([`reject_unsupported_sync_aggregate`]).
361pub fn agg_table_name(csq: &CorrelatedSubquery) -> Box<str> {
362    let mut f = Fnv::new();
363    f.u32(csq.correlation.child_field.len() as u32);
364    for cf in &csq.correlation.child_field {
365        f.s(cf);
366    }
367    hash_agg_subquery(&mut f, &csq.subquery);
368    format!("__agg_{:016x}", f.0).into()
369}
370
371/// Hash the parts of an aggregate (sub)query that determine the count VALUE for a group:
372/// the table, the aggregate kind, the `where` filter, and any nested materialized
373/// `related` (a `where`-EXISTS witness can carry one). Deliberately excludes `alias`,
374/// `select`, `order_by`, `start`, `limit`, `one` — none change a `count(*)` over the
375/// (filtered) rows (the oracle rejects `start`/`limit`/nested-`related` in a count anyway).
376///
377/// Count-only: the synthetic-aggregate-table path has no encoding for a `sum`/`avg` value,
378/// so [`reject_unsupported_sync_aggregate`] fails registration before a `sum`/`avg`
379/// relationship aggregate can reach here (the `Sum`/`Avg` arms below are that invariant).
380fn hash_agg_subquery(f: &mut Fnv, ast: &Ast) {
381    f.s(&ast.table);
382    f.byte(match ast.aggregate {
383        Some(Aggregate::Count) => 1,
384        None => 0,
385        Some(Aggregate::Sum(_)) | Some(Aggregate::Avg(_)) => unreachable!(
386            "relationship sum/avg is rejected before the sync path by \
387             reject_unsupported_sync_aggregate"
388        ),
389    });
390    match &ast.r#where {
391        None => f.byte(0),
392        Some(c) => {
393            f.byte(1);
394            hash_condition(f, c);
395        }
396    }
397    f.u32(ast.related.len() as u32);
398    for r in &ast.related {
399        hash_correlation(f, &r.correlation);
400        hash_agg_subquery(f, &r.subquery);
401    }
402}
403
404fn hash_correlation(f: &mut Fnv, c: &Correlation) {
405    f.u32(c.parent_field.len() as u32);
406    for p in &c.parent_field {
407        f.s(p);
408    }
409    f.u32(c.child_field.len() as u32);
410    for c2 in &c.child_field {
411        f.s(c2);
412    }
413}
414
415fn hash_condition(f: &mut Fnv, cond: &Condition) {
416    match cond {
417        Condition::Simple(sc) => {
418            f.byte(0);
419            f.s(op_str(sc.op));
420            hash_value_position(f, &sc.left);
421            hash_value_position(f, &sc.right);
422        }
423        Condition::And { conditions } => {
424            f.byte(1);
425            f.u32(conditions.len() as u32);
426            for c in conditions {
427                hash_condition(f, c);
428            }
429        }
430        Condition::Or { conditions } => {
431            f.byte(2);
432            f.u32(conditions.len() as u32);
433            for c in conditions {
434                hash_condition(f, c);
435            }
436        }
437        Condition::CorrelatedSubquery(cs) => {
438            f.byte(3);
439            f.byte(match cs.op {
440                ExistsOp::Exists => 1,
441                ExistsOp::NotExists => 0,
442            });
443            // A permission vs client witness gates different rows ⇒ a different count.
444            f.byte(match cs.related.system {
445                Some(System::Permissions) => 1,
446                Some(System::Client) => 2,
447                Some(System::Test) => 3,
448                None => 0,
449            });
450            hash_correlation(f, &cs.related.correlation);
451            hash_agg_subquery(f, &cs.related.subquery);
452        }
453    }
454}
455
456fn hash_value_position(f: &mut Fnv, vp: &ValuePosition) {
457    match vp {
458        ValuePosition::Column { name } => {
459            f.byte(0);
460            f.s(name);
461        }
462        ValuePosition::Literal { value } => {
463            f.byte(1);
464            hash_lit(f, value);
465        }
466    }
467}
468
469fn hash_lit(f: &mut Fnv, lit: &Lit) {
470    match lit {
471        Lit::Null => f.byte(0),
472        Lit::Bool(b) => {
473            f.byte(1);
474            f.byte(*b as u8);
475        }
476        // A distinct tag + exact le-bytes: two distinct big i64s must never share a
477        // hash (a false-shared query identity corrupts; a missed dedup against the
478        // same value spelled `Number` is merely a second registration).
479        Lit::Int(i) => {
480            f.byte(5);
481            for b in i.to_le_bytes() {
482                f.byte(b);
483            }
484        }
485        Lit::Number(n) => {
486            f.byte(2);
487            for b in n.to_le_bytes() {
488                f.byte(b);
489            }
490        }
491        Lit::Str(s) => {
492            f.byte(3);
493            f.s(s);
494        }
495        Lit::Array(xs) => {
496            f.byte(4);
497            f.u32(xs.len() as u32);
498            for x in xs {
499                hash_lit(f, x);
500            }
501        }
502    }
503}
504
505/// The wire string for a comparison operator (matches `Op`'s serde rename — the bytes the
506/// TS twin hashes). Kept here, not on `Op`, so the cross-language contract lives beside the
507/// hash that depends on it.
508fn op_str(op: Op) -> &'static str {
509    match op {
510        Op::Eq => "=",
511        Op::Ne => "!=",
512        Op::Lt => "<",
513        Op::Le => "<=",
514        Op::Gt => ">",
515        Op::Ge => ">=",
516        Op::Is => "IS",
517        Op::IsNot => "IS NOT",
518        Op::Like => "LIKE",
519        Op::NotLike => "NOT LIKE",
520        Op::ILike => "ILIKE",
521        Op::NotILike => "NOT ILIKE",
522        Op::In => "IN",
523        Op::NotIn => "NOT IN",
524    }
525}
526
527/// FNV-1a-64 over the length-prefixed byte protocol that names a synthetic aggregate table
528/// ([`agg_table_name`]) — a local twin of `normalize_protocol::Fnv` / `wire_schema::Fnv`.
529struct Fnv(u64);
530
531impl Fnv {
532    fn new() -> Fnv {
533        Fnv(0xcbf2_9ce4_8422_2325) // FNV-1a 64 offset basis
534    }
535    fn byte(&mut self, b: u8) {
536        self.0 ^= b as u64;
537        self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3); // FNV-1a 64 prime
538    }
539    fn u32(&mut self, v: u32) {
540        for b in v.to_le_bytes() {
541            self.byte(b);
542        }
543    }
544    /// Length-prefixed string — disambiguates concatenations (`"ab"+"c"` ≠ `"a"+"bc"`).
545    fn s(&mut self, s: &str) {
546        self.u32(s.len() as u32);
547        for &b in s.as_bytes() {
548            self.byte(b);
549        }
550    }
551}
552
553/// Resolve a query-local slot name to the [`CorrelatedSubquery`] that backs it, with the same
554/// precedence [`query_local_slot_names`] uses: a materialized `related` alias first
555/// (last-writer-wins on a duplicate, mirroring `view_schema`'s `.rev().find`), else an EXISTS
556/// gating subquery in the `where` tree. `ast` must already be [`normalize_pipeline_ast`]-
557/// normalized. The returned `CorrelatedSubquery` carries both the child `subquery` and its
558/// `system` provenance (the prune signal), so `table_tree` reads them off one value.
559///
560/// This is a by-**name** lookup, so it does not duplicate the builder's slot *ordering*
561/// (that rides on the public [`query_local_slot_names`], the single source of truth): the
562/// EXISTS aliases are unique after uniquify, so any traversal returns the same match.
563fn slot_backing<'a>(ast: &'a Ast, name: &str) -> Option<(&'a CorrelatedSubquery, SlotOrigin)> {
564    if let Some(c) = ast
565        .related
566        .iter()
567        .rev()
568        .find(|c| c.subquery.alias.as_deref() == Some(name))
569    {
570        return Some((c, SlotOrigin::Related));
571    }
572    ast.r#where
573        .as_ref()
574        .and_then(|w| find_exists_csq(w, name))
575        .map(|c| (c, SlotOrigin::WhereExists))
576}
577
578/// Which half of the frame a slot's backing subquery came from. The two are indistinguishable
579/// by shape — `having_count` builds its gate by CLONING a materialized `related` aggregate —
580/// so the provenance has to ride along with the lookup ([`slot_backing`]) rather than be
581/// re-derived from the subquery.
582#[derive(Clone, Copy, Debug, PartialEq, Eq)]
583enum SlotOrigin {
584    /// A materialized `related` relationship: its rows ARE the slot's dataflow contents.
585    Related,
586    /// An EXISTS gate in the `where` tree: the slot's dataflow contents are the gate's
587    /// **witness child rows**, never a materialized projection of the subquery.
588    WhereExists,
589}
590
591/// Search a `where` tree for the EXISTS [`CorrelatedSubquery`] aliased `name` (skipping a
592/// `limit 0` gate, which claims no slot — though such an alias never reaches here, since
593/// [`query_local_slot_names`] already excludes it).
594fn find_exists_csq<'a>(cond: &'a Condition, name: &str) -> Option<&'a CorrelatedSubquery> {
595    match cond {
596        Condition::CorrelatedSubquery(c) => {
597            let cs = &c.related;
598            (cs.subquery.limit != Some(0) && cs.subquery.alias.as_deref() == Some(name))
599                .then_some(cs)
600        }
601        Condition::And { conditions } | Condition::Or { conditions } => {
602            conditions.iter().find_map(|c| find_exists_csq(c, name))
603        }
604        Condition::Simple(_) => None,
605    }
606}
607
608// ---------------------------------------------------------------------------
609// Projection (PROJECTION-SUPPORT-DESIGN.md §5.2): the columns a query needs synced
610// per base table, for project-at-emit.
611// ---------------------------------------------------------------------------
612
613/// What columns this query needs synced for a base table. `All` ⇒ a `'*'` frame (no
614/// `select`) referenced the table, so it must sync every column; `Names` ⇒ the explicit
615/// set of column names the query structurally reads for the table (its `required_cols`
616/// at the name level — `select` ∪ `where`-leaf ∪ `order_by` ∪ `start` ∪ correlation
617/// fields). Unioned across every frame that references the same table (any `All` wins).
618#[derive(Clone, Debug, PartialEq, Eq)]
619pub enum ReqCols {
620    All,
621    Names(BTreeSet<Box<str>>),
622}
623
624impl ReqCols {
625    fn merge(&mut self, other: ReqCols) {
626        match (self, other) {
627            (ReqCols::All, _) => {}
628            (slot @ ReqCols::Names(_), ReqCols::All) => *slot = ReqCols::All,
629            (ReqCols::Names(a), ReqCols::Names(b)) => a.extend(b),
630        }
631    }
632}
633
634/// The columns each base table in `ast`'s footprint needs synced (§5.2). A pure function of
635/// the AST (column **names**, resolved to indices by the caller against the table schema).
636/// Mirrors [`table_tree`]'s slot walk + prune (`exists_noSync` slots are never synced, so
637/// they contribute nothing). The result drives the projected wire schema and project-at-emit.
638pub fn required_columns_by_table(ast: &Ast) -> BTreeMap<Box<str>, ReqCols> {
639    let mut out = BTreeMap::new();
640    collect_required(ast, &[], &mut out);
641    out
642}
643
644fn collect_required(ast: &Ast, inherited: &[Box<str>], out: &mut BTreeMap<Box<str>, ReqCols>) {
645    let ast = normalize_pipeline_ast(ast);
646    let mut req = match &ast.select {
647        // A `'*'` frame needs every column of its table.
648        None => ReqCols::All,
649        Some(sel) => ReqCols::Names(sel.iter().cloned().collect()),
650    };
651    if let ReqCols::Names(names) = &mut req {
652        // Correlation child fields this frame is keyed on (it is a child of some parent).
653        for c in inherited {
654            names.insert(c.clone());
655        }
656        // `where`-leaf columns.
657        if let Some(w) = &ast.r#where {
658            where_leaf_names(w, names);
659        }
660        // `order_by` fields.
661        for op in &ast.order_by {
662            names.insert(op.field().into());
663        }
664        // `start`-bound columns.
665        if let Some(b) = &ast.start {
666            for cell in &b.row {
667                names.insert(cell.0.clone());
668            }
669        }
670    }
671    // Merge this frame's requirement for its table.
672    match out.get_mut(&ast.table) {
673        Some(existing) => existing.merge(req),
674        None => {
675            out.insert(ast.table.clone(), req);
676        }
677    }
678    // Walk the relationship slots (same layout + prune as `table_tree`): a parent needs each
679    // child's `parent_field`; the child frame needs its `child_field` (inherited).
680    for name in query_local_slot_names(&ast).iter() {
681        let name: &str = name;
682        let Some((cs, origin)) = slot_backing(&ast, name) else {
683            continue;
684        };
685        if cs.system == Some(System::Permissions) {
686            continue; // pruned (`exists_noSync`) — never synced
687        }
688        if cs.subquery.aggregate.is_some() && origin == SlotOrigin::WhereExists {
689            continue; // pruned (`having_count` gate witnesses, see `table_tree`) — never synced
690        }
691        if let Some(ReqCols::Names(names)) = out.get_mut(&ast.table) {
692            for pf in &cs.correlation.parent_field {
693                names.insert(pf.clone());
694            }
695        }
696        collect_required(&cs.subquery, &cs.correlation.child_field, out);
697    }
698}
699
700/// Collect the leaf column names a `where` tree references (either side of a `Simple`;
701/// recursing `and`/`or`). An EXISTS subquery's correlation fields are gathered by the slot
702/// walk, not here.
703fn where_leaf_names(cond: &Condition, out: &mut BTreeSet<Box<str>>) {
704    match cond {
705        Condition::Simple(sc) => {
706            for vp in [&sc.left, &sc.right] {
707                if let rindle::ValuePosition::Column { name } = vp {
708                    out.insert(name.clone());
709                }
710            }
711        }
712        Condition::And { conditions } | Condition::Or { conditions } => {
713            for c in conditions {
714                where_leaf_names(c, out);
715            }
716        }
717        Condition::CorrelatedSubquery(_) => {}
718    }
719}
720
721// ---------------------------------------------------------------------------
722// The fold (§4): tree → table-tagged deltas with intra-query refcounts.
723// ---------------------------------------------------------------------------
724
725/// Table → primary-key column indices (into that table's row), supplied at construction.
726pub type PkMap = HashMap<Box<str>, Vec<ColId>>;
727
728/// One footprint entry: the latest row plus its **intra-query** refcount — the number of
729/// tree positions this source row currently occupies within this one query (§4.2). The row
730/// is the `Arc`-shared [`OwnedRow`] the pipeline already holds (interned, not re-copied).
731#[derive(Clone, Debug)]
732struct FootEntry {
733    row: OwnedRow,
734    rc: u32,
735}
736
737/// `table → (pk → entry)`. Persistent across ticks. Sorted maps so the emitted op order is
738/// deterministic (useful for the differential oracle); the client applies a batch into one
739/// transaction where per-(table,pk) collapse means order is not load-bearing.
740type Footprint = BTreeMap<Box<str>, BTreeMap<PkKey, FootEntry>>;
741
742/// This tick's touched rows, each carrying the row **as it was at tick start** (`None` ⇒
743/// absent), captured lazily on first touch (an `Arc` clone — no copy). Diffing this against
744/// the post-tick footprint yields the net per-row op (§4.1).
745type Tick = BTreeMap<Box<str>, BTreeMap<PkKey, Option<OwnedRow>>>;
746
747/// A primary key as a map key. [`OwnedValue`] deliberately has no derived `Ord`/`Eq` (it
748/// would invite the wrong comparator); a PK is a homogeneous-per-column tuple, so we key
749/// the maps by the engine's own [`compare_values`] total order — never panicking, since
750/// within one table+column the cell types match.
751#[derive(Clone, Debug)]
752struct PkKey(Vec<OwnedValue>);
753
754impl PartialEq for PkKey {
755    fn eq(&self, o: &Self) -> bool {
756        self.cmp(o) == Ordering::Equal
757    }
758}
759impl Eq for PkKey {}
760impl PartialOrd for PkKey {
761    fn partial_cmp(&self, o: &Self) -> Option<Ordering> {
762        Some(self.cmp(o))
763    }
764}
765impl Ord for PkKey {
766    fn cmp(&self, o: &Self) -> Ordering {
767        for (a, b) in self.0.iter().zip(o.0.iter()) {
768            match compare_values(a.as_ref(), b.as_ref()) {
769                Ordering::Equal => {}
770                ord => return ord,
771            }
772        }
773        self.0.len().cmp(&o.0.len())
774    }
775}
776
777/// The server-side serializer (§4): **one per registered normalized query**. Fold each
778/// committed transaction's `CaughtChange`s with [`fold`](NormalizeFold::fold) to get that
779/// tick's [`NormalizedOp`]s. The footprint persists across ticks; the snapshot is simply
780/// the first fold over the hydrate batch (every row 0→1 ⇒ all `Add`s).
781#[derive(Debug)]
782pub struct NormalizeFold {
783    tree: TableNode,
784    pk: PkMap,
785    footprint: Footprint,
786}
787
788impl NormalizeFold {
789    /// Build a fold for the query `ast`, with `pk` giving each table's primary-key column
790    /// indices (the server derives this from its source schemas; tests pass it inline).
791    /// Panics if `pk` omits a table the query's tree can surface — a build-time config
792    /// invariant, not a data-reachable error.
793    pub fn new(ast: &Ast, pk: PkMap) -> NormalizeFold {
794        let tree = table_tree(ast);
795        validate_pk(&tree, &pk);
796        // Pre-register every table the tree can surface with an empty per-table map. The fold
797        // hot path then keys the footprint with `get_mut(&str)` (no `Box<str>` allocation per
798        // touch) instead of `entry(table.into())`, which would allocate a fresh key on every
799        // call even on a hit. Coverage matches `validate_pk`'s walk, so the lookups are
800        // infallible. An empty per-table map is indistinguishable from an absent one to the
801        // snapshot / emit readers (both yield no rows).
802        let mut footprint: Footprint = BTreeMap::new();
803        register_tables(&tree, &mut footprint);
804        NormalizeFold {
805            tree,
806            pk,
807            footprint,
808        }
809    }
810
811    /// The query's table tree (slot → child table), derived from the AST.
812    pub fn tree(&self) -> &TableNode {
813        &self.tree
814    }
815
816    /// The current footprint as a seq-0 snapshot: one [`NormalizedOp::Add`] per live row,
817    /// in deterministic (sorted by table, then PK) order. This baselines a **late
818    /// subscriber** attaching to an already-hydrated *shared* query from the cached
819    /// footprint, without re-running the engine pipeline (`RINDLE-SERVER-DESIGN.md` §11) —
820    /// the membership is binary on the wire, so the intra-query refcount is not surfaced.
821    pub fn footprint_snapshot(&self) -> Vec<NormalizedOp> {
822        let mut ops = Vec::new();
823        for (table, rows) in &self.footprint {
824            for entry in rows.values() {
825                ops.push(NormalizedOp::Add {
826                    table: table.clone(),
827                    row: entry.row.to_value_vec(),
828                });
829            }
830        }
831        ops
832    }
833
834    /// Fold one committed transaction (or the hydrate snapshot) into its net
835    /// [`NormalizedOp`]s, updating the persistent footprint. An empty / no-membership-
836    /// change tick returns `[]`.
837    pub fn fold(&mut self, changes: &[CaughtChange]) -> Vec<NormalizedOp> {
838        // Disjoint field borrows: the tree + pk map are read-only, the footprint mutates.
839        let tree = &self.tree;
840        let pk = &self.pk;
841        let fp = &mut self.footprint;
842        let mut tick: Tick = BTreeMap::new();
843        for c in changes {
844            fold_change(c, tree, pk, fp, Some(&mut tick));
845        }
846        emit(&tick, fp)
847    }
848
849    /// Update the footprint for one transaction **without** producing wire ops — the
850    /// allocation-light path for a query nobody is currently receiving (a pinned, zero-
851    /// subscriber materialization) and for the hydrate fold (whose ops the drain discards,
852    /// baselining late subscribers off [`footprint_snapshot`](Self::footprint_snapshot)
853    /// instead). It skips the per-tick diff map (the `Tick`), the tick-start row capture, and
854    /// the `emit` pass entirely — exactly the allocations the wiki-demo RSS profiling traced
855    /// the glibc retention to. The footprint it leaves is byte-identical to what
856    /// [`fold`](Self::fold) would leave, so a later subscriber's snapshot is unaffected.
857    pub fn fold_footprint_only(&mut self, changes: &[CaughtChange]) {
858        let tree = &self.tree;
859        let pk = &self.pk;
860        let fp = &mut self.footprint;
861        for c in changes {
862            fold_change(c, tree, pk, fp, None);
863        }
864    }
865}
866
867/// Pre-register every table in `node`'s subtree with an empty per-table map (see
868/// [`NormalizeFold::new`]). Mirrors [`validate_pk`]'s walk; pruned (`None`) slots carry no
869/// table and are skipped.
870fn register_tables(node: &TableNode, fp: &mut Footprint) {
871    fp.entry(node.table.clone()).or_default();
872    for c in node.children.iter().flatten() {
873        register_tables(c, fp);
874    }
875}
876
877/// Every table the tree can surface must have a registered PK (else its rows could not be
878/// keyed). Validated once at construction so the fold's PK lookups are infallible.
879fn validate_pk(node: &TableNode, pk: &PkMap) {
880    assert!(
881        pk.contains_key(&*node.table),
882        "rindle-replica internal invariant: no primary key registered for table {:?}",
883        node.table
884    );
885    // Pruned slots (`None`) carry no table — a permission table the client never sees need
886    // not have a PK registered, so skip them.
887    for c in node.children.iter().flatten() {
888        validate_pk(c, pk);
889    }
890}
891
892/// Direction of a subtree fold: a `CaughtChange::Add` increments every row it carries, a
893/// `CaughtChange::Remove` decrements (the subtree **is** kept here — §4.3).
894#[derive(Clone, Copy)]
895enum Delta {
896    Inc,
897    Dec,
898}
899
900/// Fold one change: descend a `CaughtChange::Child` (dropping its parent row), or apply the
901/// op at the reached table. `tick` is `Some` on the emitting path (capture tick-start rows
902/// for the diff) and `None` on the footprint-only path (see
903/// [`NormalizeFold::fold_footprint_only`]).
904fn fold_change(
905    c: &CaughtChange,
906    tnode: &TableNode,
907    pk: &PkMap,
908    fp: &mut Footprint,
909    tick: Option<&mut Tick>,
910) {
911    match c {
912        CaughtChange::Child { rel, change, .. } => {
913            // Descend the table tree; an unknown slot (shouldn't happen) or a pruned
914            // (`None`, `exists_noSync`) slot is dropped — its witnesses never enter the
915            // footprint.
916            if let Some(Some(child)) = tnode.children.get(rel.ix()) {
917                fold_change(change, child, pk, fp, tick);
918            }
919        }
920        CaughtChange::Add(n) => fold_subtree(n, tnode, pk, fp, tick, Delta::Inc),
921        CaughtChange::Remove(n) => fold_subtree(n, tnode, pk, fp, tick, Delta::Dec),
922        CaughtChange::Edit { old, row } => {
923            // CONTRACT (#13): an `Edit` is **pk-stable**. A pk change is row identity changing,
924            // so it is split into Remove(old)+Add(new) at the engine ingress
925            // (`ReplicaEngine::push_and_drain`) before the fold ever sees it. `set_row` keys by
926            // pk, so a pk-changing Edit would key the NEW pk, miss the entry under the OLD pk,
927            // and silently drop the change (leaking the footprint entry). Assert the invariant
928            // in debug/tests rather than corrupt; the split upstream is what keeps it true.
929            debug_assert!(
930                pk_of(old, pk_cols(pk, &tnode.table)) == pk_of(row, pk_cols(pk, &tnode.table)),
931                "NormalizeFold received a pk-changing Edit for table {:?} — contract violation \
932                 (a pk change must arrive as Remove+Add; see ReplicaEngine::push_and_drain)",
933                tnode.table,
934            );
935            let _ = old; // referenced only by the (debug-only) contract assertion above
936            set_row(&tnode.table, row, pk, fp, tick);
937        }
938    }
939}
940
941/// inc/dec this node's row, then recurse into every materialized relationship — the whole
942/// subtree, so an `Add` counts each descendant in and a `Remove` counts each out
943/// (witnesses included, §4.3).
944fn fold_subtree(
945    n: &CaughtNode,
946    tnode: &TableNode,
947    pk: &PkMap,
948    fp: &mut Footprint,
949    mut tick: Option<&mut Tick>,
950    delta: Delta,
951) {
952    let cols = pk_cols(pk, &tnode.table);
953    match delta {
954        Delta::Inc => inc(&tnode.table, &n.row, cols, fp, tick.as_deref_mut()),
955        Delta::Dec => dec(&tnode.table, &n.row, cols, fp, tick.as_deref_mut()),
956    }
957    for (slot, children) in &n.relationships {
958        // A pruned (`None`, `exists_noSync`) slot's witnesses are excluded from the footprint.
959        if let Some(Some(child_tnode)) = tnode.children.get(slot.ix()) {
960            for child in children {
961                fold_subtree(child, child_tnode, pk, fp, tick.as_deref_mut(), delta);
962            }
963        }
964    }
965}
966
967/// `inc(t, row)` (§4.1): membership refcount up; `0→1` is recorded for emission via the
968/// captured tick-start state. The footprint **interns** the pipeline's `Arc` row — `clone`
969/// is a refcount bump, not a deep copy. The per-table map is pre-registered
970/// ([`NormalizeFold::new`]), so `get_mut` is infallible and allocates no `Box<str>` key.
971fn inc(
972    table: &str,
973    row: &OwnedRow,
974    pk_cols: &[ColId],
975    fp: &mut Footprint,
976    tick: Option<&mut Tick>,
977) {
978    let key = pk_of(row, pk_cols);
979    let tbl = fp_table_mut(fp, table);
980    if let Some(tick) = tick {
981        capture_before(tick, table, &key, tbl);
982    }
983    let e = tbl.entry(key).or_insert_with(|| FootEntry {
984        row: row.clone(),
985        rc: 0,
986    });
987    e.rc += 1;
988    e.row = row.clone();
989}
990
991/// `dec(t, row)` (§4.1): membership refcount down; `1→0` drops the entry. A `dec` of a row
992/// not in the footprint is an inconsistent stream — defensively ignored.
993fn dec(
994    table: &str,
995    row: &OwnedRow,
996    pk_cols: &[ColId],
997    fp: &mut Footprint,
998    tick: Option<&mut Tick>,
999) {
1000    let key = pk_of(row, pk_cols);
1001    let Some(tbl) = fp.get_mut(table) else {
1002        return;
1003    };
1004    if let Some(tick) = tick {
1005        capture_before(tick, table, &key, tbl);
1006    }
1007    if let Some(e) = tbl.get_mut(&key) {
1008        e.rc -= 1; // entries are never stored at rc 0, so this can't underflow
1009        if e.rc == 0 {
1010            tbl.remove(&key);
1011        }
1012    }
1013}
1014
1015/// `set(table, new)` (§4.1): a footprinted row's value changed in place — PK is stable, so
1016/// `new`'s PK keys the existing entry; rc is untouched. An edit of an untracked row is an
1017/// inconsistent stream — defensively ignored (no membership synthesized).
1018fn set_row(table: &str, new: &OwnedRow, pk: &PkMap, fp: &mut Footprint, tick: Option<&mut Tick>) {
1019    let key = pk_of(new, pk_cols(pk, table));
1020    let Some(tbl) = fp.get_mut(table) else {
1021        return;
1022    };
1023    if let Some(tick) = tick {
1024        capture_before(tick, table, &key, tbl);
1025    }
1026    if let Some(e) = tbl.get_mut(&key) {
1027        e.row = new.clone();
1028    }
1029}
1030
1031/// The per-table footprint map, keyed without allocating a `Box<str>` on the hot path: every
1032/// table the tree can surface is pre-registered at construction, so a `get_mut` hit is the
1033/// rule; the `entry(table.into())` fallback (one `Box<str>`) only fires for a table the walk
1034/// somehow missed (defensive — it never should).
1035fn fp_table_mut<'a>(fp: &'a mut Footprint, table: &str) -> &'a mut BTreeMap<PkKey, FootEntry> {
1036    if fp.contains_key(table) {
1037        fp.get_mut(table).expect("present")
1038    } else {
1039        fp.entry(table.into()).or_default()
1040    }
1041}
1042
1043/// On the first touch of `(table, key)` this tick, record the row as it stood at tick start
1044/// (`None` ⇒ absent), as an `Arc` clone. Later touches don't overwrite it. Only called on the
1045/// emitting path (`tick` is `Some`).
1046fn capture_before(tick: &mut Tick, table: &str, key: &PkKey, tbl: &BTreeMap<PkKey, FootEntry>) {
1047    // Avoid a `Box<str>` table-key allocation when this tick already has the table (the
1048    // common case — a tick touches few tables, each repeatedly).
1049    let rows = if tick.contains_key(table) {
1050        tick.get_mut(table).expect("present")
1051    } else {
1052        tick.entry(table.into()).or_default()
1053    };
1054    rows.entry(key.clone())
1055        .or_insert_with(|| tbl.get(key).map(|e| e.row.clone()));
1056}
1057
1058/// Diff each touched row's tick-start state against the post-tick footprint into the net op
1059/// for the row (§4.1): appeared ⇒ `Add`, vanished ⇒ `Remove`, present-but-changed ⇒
1060/// `Edit`, otherwise nothing. The interned `Arc` rows are materialized to a wire `WireRow`
1061/// only **here**, for the (small) set of rows actually emitted. Iterates the sorted maps,
1062/// so the op order is deterministic.
1063fn emit(tick: &Tick, fp: &Footprint) -> Vec<NormalizedOp> {
1064    let mut ops = Vec::new();
1065    for (table, rows) in tick {
1066        for (key, before) in rows {
1067            let after = fp.get(table).and_then(|t| t.get(key)).map(|e| &e.row);
1068            match (before.as_ref(), after) {
1069                (None, Some(new)) => ops.push(NormalizedOp::Add {
1070                    table: table.clone(),
1071                    row: new.to_value_vec(),
1072                }),
1073                (Some(old), None) => ops.push(NormalizedOp::Remove {
1074                    table: table.clone(),
1075                    row: old.to_value_vec(),
1076                }),
1077                (Some(old), Some(new)) => {
1078                    if !rows_equal(old, new) {
1079                        ops.push(NormalizedOp::Edit {
1080                            table: table.clone(),
1081                            old: old.to_value_vec(),
1082                            new: new.to_value_vec(),
1083                        });
1084                    }
1085                }
1086                (None, None) => {}
1087            }
1088        }
1089    }
1090    ops
1091}
1092
1093/// Extract a row's primary key as a [`PkKey`].
1094fn pk_of(row: &OwnedRow, pk_cols: &[ColId]) -> PkKey {
1095    PkKey(pk_cols.iter().map(|&c| row.col(c).to_owned()).collect())
1096}
1097
1098/// A table's PK columns. Infallible — [`validate_pk`] guarantees coverage at construction.
1099fn pk_cols<'a>(pk: &'a PkMap, table: &str) -> &'a [ColId] {
1100    pk.get(table)
1101        .map(Vec::as_slice)
1102        .unwrap_or_else(|| panic!("rindle-replica internal invariant: no PK for table {table:?}"))
1103}
1104
1105/// Whole-row value equality (total, non-panicking, `null == null`): did the row change?
1106fn rows_equal(a: &OwnedRow, b: &OwnedRow) -> bool {
1107    a.len() == b.len()
1108        && a.cells()
1109            .zip(b.cells())
1110            .all(|(x, y)| values_identical(x, y))
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115    use super::*;
1116    use rindle::value::{owned_row, OwnedValue as V, RelId};
1117    use rindle::{
1118        CorrelatedSubquery, CorrelatedSubqueryCondition, Correlation, ExistsOp, SimpleCondition,
1119    };
1120
1121    // ---- AST builders (only the fields `table_tree`/the fold read) ----
1122
1123    fn correlation(parent: &str, child: &str) -> Correlation {
1124        Correlation {
1125            parent_field: vec![parent.into()],
1126            child_field: vec![child.into()],
1127        }
1128    }
1129
1130    /// A materialized `related` child: table `table`, relationship alias `alias`.
1131    fn related(alias: &str, table: &str) -> CorrelatedSubquery {
1132        CorrelatedSubquery {
1133            correlation: correlation("id", "parent_id"),
1134            subquery: Box::new(Ast {
1135                table: table.into(),
1136                alias: Some(alias.into()),
1137                ..Default::default()
1138            }),
1139            system: None,
1140        }
1141    }
1142
1143    /// A bare top-level `EXISTS(<table> as <alias>)` where-clause.
1144    fn exists_where(alias: &str, table: &str) -> Condition {
1145        Condition::CorrelatedSubquery(CorrelatedSubqueryCondition {
1146            related: related(alias, table),
1147            op: ExistsOp::Exists,
1148            flip: None,
1149            scalar: None,
1150            plan_id: None,
1151        })
1152    }
1153
1154    // ---- CaughtChange builders ----
1155
1156    fn cnode(row: Vec<V>, rels: Vec<(RelId, Vec<CaughtNode>)>) -> CaughtNode {
1157        CaughtNode {
1158            row: owned_row(row),
1159            relationships: rels.into_iter().collect(),
1160        }
1161    }
1162    fn leaf(row: Vec<V>) -> CaughtNode {
1163        cnode(row, vec![])
1164    }
1165
1166    /// A simple PK map keyed on column 0 for the given tables.
1167    fn pk_col0(tables: &[&str]) -> PkMap {
1168        tables.iter().map(|t| ((*t).into(), vec![0usize])).collect()
1169    }
1170
1171    // ---- helpers to read the emitted ops ----
1172
1173    fn op_summary(op: &NormalizedOp) -> (String, &'static str, i64) {
1174        // (table, kind, pk-as-int) — every test row has an Int PK in column 0.
1175        let int = |r: &WireRow| match &r[0] {
1176            V::Int(i) => *i,
1177            o => panic!("expected Int pk, got {o:?}"),
1178        };
1179        match op {
1180            NormalizedOp::Add { table, row } => (table.to_string(), "add", int(row)),
1181            NormalizedOp::Remove { table, row } => (table.to_string(), "remove", int(row)),
1182            NormalizedOp::Edit { table, new, .. } => (table.to_string(), "edit", int(new)),
1183        }
1184    }
1185    fn summarize(ops: &[NormalizedOp]) -> Vec<(String, &'static str, i64)> {
1186        ops.iter().map(op_summary).collect()
1187    }
1188
1189    // -------------------------------------------------------------------
1190    // table_tree derivation
1191    // -------------------------------------------------------------------
1192
1193    #[test]
1194    fn table_tree_orders_related_before_exists() {
1195        // issue { comments } WHERE EXISTS(labels): slot 0 = related "comments" (table
1196        // "comment"), slot 1 = EXISTS gate "labels" (table "label"). The tree carries the
1197        // TABLE at each slot, not the alias.
1198        let ast = Ast {
1199            table: "issue".into(),
1200            related: vec![related("comments", "comment")],
1201            r#where: Some(exists_where("labels", "label")),
1202            ..Default::default()
1203        };
1204        let tree = table_tree(&ast);
1205        assert_eq!(
1206            tree,
1207            TableNode {
1208                table: "issue".into(),
1209                children: vec![
1210                    Some(TableNode {
1211                        table: "comment".into(),
1212                        children: vec![]
1213                    }),
1214                    Some(TableNode {
1215                        table: "label".into(),
1216                        children: vec![]
1217                    }),
1218                ],
1219            }
1220        );
1221    }
1222
1223    #[test]
1224    fn table_tree_prunes_permission_exists_slot() {
1225        // `exists_noSync` (§4): issue WHERE EXISTS(labels) [client] AND
1226        // EXISTS_permissions(acl). The client `label` slot folds; the permission `acl` slot
1227        // is pruned to `None` (its witnesses never enter the footprint) but KEEPS its slot
1228        // position, so later slots stay aligned with the dataflow `RelId`s.
1229        let mut perm = related("acl_rel", "acl");
1230        perm.system = Some(rindle::System::Permissions);
1231        let ast = Ast {
1232            table: "issue".into(),
1233            r#where: Some(Condition::And {
1234                conditions: vec![
1235                    exists_where("labels", "label"),
1236                    Condition::CorrelatedSubquery(CorrelatedSubqueryCondition {
1237                        related: perm,
1238                        op: ExistsOp::Exists,
1239                        flip: None,
1240                        scalar: None,
1241                        plan_id: None,
1242                    }),
1243                ],
1244            }),
1245            ..Default::default()
1246        };
1247        let tree = table_tree(&ast);
1248        assert_eq!(
1249            tree,
1250            TableNode {
1251                table: "issue".into(),
1252                children: vec![
1253                    Some(TableNode {
1254                        table: "label".into(),
1255                        children: vec![]
1256                    }),
1257                    None, // the permission `acl` slot is pruned, position preserved
1258                ],
1259            }
1260        );
1261    }
1262
1263    #[test]
1264    fn table_tree_prunes_permission_exists_under_or() {
1265        // A primary permission shape: `issue WHERE (EXISTS(labels) OR EXISTS_permissions(acl))`.
1266        // Under OR the slots are gathered in where-tree pre-order, so slot 0 = client `label`
1267        // (folded), slot 1 = permission `acl` (pruned to `None`). The pruning is keyed on the
1268        // by-name match `query_local_slot_names` also uses, so the `None` lands on the acl slot
1269        // and the client `label` slot is untouched — no mis-alignment, no leak, no corruption.
1270        let mut perm = related("acl_rel", "acl");
1271        perm.system = Some(rindle::System::Permissions);
1272        let ast = Ast {
1273            table: "issue".into(),
1274            r#where: Some(Condition::Or {
1275                conditions: vec![
1276                    exists_where("labels", "label"),
1277                    Condition::CorrelatedSubquery(CorrelatedSubqueryCondition {
1278                        related: perm,
1279                        op: ExistsOp::Exists,
1280                        flip: None,
1281                        scalar: None,
1282                        plan_id: None,
1283                    }),
1284                ],
1285            }),
1286            ..Default::default()
1287        };
1288        let tree = table_tree(&ast);
1289        assert_eq!(
1290            tree,
1291            TableNode {
1292                table: "issue".into(),
1293                children: vec![
1294                    Some(TableNode {
1295                        table: "label".into(),
1296                        children: vec![]
1297                    }),
1298                    None, // permission `acl` slot pruned, position preserved
1299                ],
1300            }
1301        );
1302    }
1303
1304    #[test]
1305    fn table_tree_recurses_into_nested_related() {
1306        // issue { comments { authors } }: nested child tables resolved depth-first.
1307        let mut comments = related("comments", "comment");
1308        comments.subquery.related = vec![related("authors", "author")];
1309        let ast = Ast {
1310            table: "issue".into(),
1311            related: vec![comments],
1312            ..Default::default()
1313        };
1314        let tree = table_tree(&ast);
1315        assert_eq!(tree.table.as_ref(), "issue");
1316        assert_eq!(tree.children.len(), 1);
1317        let comment = tree.children[0].as_ref().unwrap();
1318        assert_eq!(comment.table.as_ref(), "comment");
1319        assert_eq!(comment.children.len(), 1);
1320        assert_eq!(
1321            comment.children[0].as_ref().unwrap().table.as_ref(),
1322            "author"
1323        );
1324    }
1325
1326    // -------------------------------------------------------------------
1327    // The fold
1328    // -------------------------------------------------------------------
1329
1330    /// playlist { tracks } — the canonical m2m shape: one track can sit under several
1331    /// playlists, so the same base `track` row is reached via multiple tree paths.
1332    fn playlist_tracks_ast() -> Ast {
1333        Ast {
1334            table: "playlist".into(),
1335            related: vec![related("tracks", "track")],
1336            ..Default::default()
1337        }
1338    }
1339
1340    #[test]
1341    fn snapshot_emits_every_footprint_row_once() {
1342        let mut f = NormalizeFold::new(&playlist_tracks_ast(), pk_col0(&["playlist", "track"]));
1343        // Hydrate: playlist 1 with track 100.
1344        let ops = f.fold(&[CaughtChange::Add(cnode(
1345            vec![V::Int(1)],
1346            vec![(RelId(0), vec![leaf(vec![V::Int(100), V::str("a")])])],
1347        ))]);
1348        assert_eq!(
1349            summarize(&ops),
1350            vec![("playlist".into(), "add", 1), ("track".into(), "add", 100),]
1351        );
1352    }
1353
1354    #[test]
1355    fn multi_path_add_collapses_to_one_add_with_rc2() {
1356        // §4.2: track 100 under BOTH playlist 1 and playlist 2 in one tick → ONE track add.
1357        let mut f = NormalizeFold::new(&playlist_tracks_ast(), pk_col0(&["playlist", "track"]));
1358        let track = || leaf(vec![V::Int(100), V::str("a")]);
1359        let ops = f.fold(&[
1360            CaughtChange::Add(cnode(vec![V::Int(1)], vec![(RelId(0), vec![track()])])),
1361            CaughtChange::Add(cnode(vec![V::Int(2)], vec![(RelId(0), vec![track()])])),
1362        ]);
1363        assert_eq!(
1364            summarize(&ops),
1365            vec![
1366                ("playlist".into(), "add", 1),
1367                ("playlist".into(), "add", 2),
1368                ("track".into(), "add", 100), // only once, despite two paths
1369            ]
1370        );
1371        // Footprint refcount collapsed the two paths.
1372        assert_eq!(f.footprint["track"].values().next().unwrap().rc, 2);
1373    }
1374
1375    #[test]
1376    fn multi_path_remove_keeps_row_until_last_ref() {
1377        // Hydrate track 100 under playlists 1 and 2 (rc 2).
1378        let mut f = NormalizeFold::new(&playlist_tracks_ast(), pk_col0(&["playlist", "track"]));
1379        let track = || leaf(vec![V::Int(100), V::str("a")]);
1380        f.fold(&[
1381            CaughtChange::Add(cnode(vec![V::Int(1)], vec![(RelId(0), vec![track()])])),
1382            CaughtChange::Add(cnode(vec![V::Int(2)], vec![(RelId(0), vec![track()])])),
1383        ]);
1384
1385        // Tick 2: playlist 1 drops the track (Child→Remove) → rc 2→1, NO track remove.
1386        let ops = f.fold(&[CaughtChange::Child {
1387            row: owned_row(vec![V::Int(1)]),
1388            rel: RelId(0),
1389            change: Box::new(CaughtChange::Remove(track())),
1390        }]);
1391        assert_eq!(summarize(&ops), vec![] as Vec<(String, &str, i64)>);
1392        assert_eq!(f.footprint["track"].values().next().unwrap().rc, 1);
1393
1394        // Tick 3: playlist 2 drops it too → rc 1→0 → track remove emitted.
1395        let ops = f.fold(&[CaughtChange::Child {
1396            row: owned_row(vec![V::Int(2)]),
1397            rel: RelId(0),
1398            change: Box::new(CaughtChange::Remove(track())),
1399        }]);
1400        assert_eq!(summarize(&ops), vec![("track".into(), "remove", 100)]);
1401        assert!(!f.footprint.contains_key("track") || f.footprint["track"].is_empty());
1402    }
1403
1404    #[test]
1405    fn remove_carries_the_subtree() {
1406        // §4.3: removing a parent decrements its children/witnesses too. Hydrate
1407        // playlist 1 { track 100 }, then Remove the whole playlist subtree.
1408        let mut f = NormalizeFold::new(&playlist_tracks_ast(), pk_col0(&["playlist", "track"]));
1409        let subtree = || {
1410            cnode(
1411                vec![V::Int(1)],
1412                vec![(RelId(0), vec![leaf(vec![V::Int(100)])])],
1413            )
1414        };
1415        f.fold(&[CaughtChange::Add(subtree())]);
1416
1417        let ops = f.fold(&[CaughtChange::Remove(subtree())]);
1418        assert_eq!(
1419            summarize(&ops),
1420            vec![
1421                ("playlist".into(), "remove", 1),
1422                ("track".into(), "remove", 100), // the subtree row went too
1423            ]
1424        );
1425    }
1426
1427    #[test]
1428    fn edit_dedups_across_multi_path() {
1429        // track 100 under playlists 1 and 2 (rc 2). An edit of its body arrives once per
1430        // ref (two Child→Edit). They collapse to ONE normalized edit; rc unchanged.
1431        let mut f = NormalizeFold::new(&playlist_tracks_ast(), pk_col0(&["playlist", "track"]));
1432        let v1 = || leaf(vec![V::Int(100), V::str("v1")]);
1433        f.fold(&[
1434            CaughtChange::Add(cnode(vec![V::Int(1)], vec![(RelId(0), vec![v1()])])),
1435            CaughtChange::Add(cnode(vec![V::Int(2)], vec![(RelId(0), vec![v1()])])),
1436        ]);
1437
1438        let child_edit = |parent: i64| CaughtChange::Child {
1439            row: owned_row(vec![V::Int(parent)]),
1440            rel: RelId(0),
1441            change: Box::new(CaughtChange::Edit {
1442                old: owned_row(vec![V::Int(100), V::str("v1")]),
1443                row: owned_row(vec![V::Int(100), V::str("v2")]),
1444            }),
1445        };
1446        let ops = f.fold(&[child_edit(1), child_edit(2)]);
1447        assert_eq!(summarize(&ops), vec![("track".into(), "edit", 100)]);
1448        // The emitted edit carries old=v1, new=v2; rc still 2.
1449        match ops.into_iter().next().unwrap() {
1450            NormalizedOp::Edit { old, new, .. } => {
1451                assert!(values_identical(old[1].as_ref(), V::str("v1").as_ref()));
1452                assert!(values_identical(new[1].as_ref(), V::str("v2").as_ref()));
1453            }
1454            o => panic!("expected edit, got {o:?}"),
1455        }
1456        assert_eq!(f.footprint["track"].values().next().unwrap().rc, 2);
1457    }
1458
1459    #[test]
1460    fn transient_add_then_remove_in_one_tick_is_a_noop() {
1461        // A row added and removed within the same transaction nets to nothing.
1462        let mut f = NormalizeFold::new(&playlist_tracks_ast(), pk_col0(&["playlist", "track"]));
1463        let ops = f.fold(&[
1464            CaughtChange::Add(cnode(vec![V::Int(1)], vec![])),
1465            CaughtChange::Remove(cnode(vec![V::Int(1)], vec![])),
1466        ]);
1467        assert_eq!(summarize(&ops), vec![] as Vec<(String, &str, i64)>);
1468        assert!(!f.footprint.contains_key("playlist") || f.footprint["playlist"].is_empty());
1469    }
1470
1471    #[test]
1472    fn edit_unchanged_value_emits_nothing() {
1473        let mut f = NormalizeFold::new(&playlist_tracks_ast(), pk_col0(&["playlist", "track"]));
1474        f.fold(&[CaughtChange::Add(cnode(
1475            vec![V::Int(1), V::str("x")],
1476            vec![],
1477        ))]);
1478        // Edit that does not change any cell → no op.
1479        let ops = f.fold(&[CaughtChange::Edit {
1480            old: owned_row(vec![V::Int(1), V::str("x")]),
1481            row: owned_row(vec![V::Int(1), V::str("x")]),
1482        }]);
1483        assert_eq!(summarize(&ops), vec![] as Vec<(String, &str, i64)>);
1484    }
1485
1486    // -------------------------------------------------------------------
1487    // Adversarial-review HIGH #13: pk-changing edits.
1488    //
1489    // The fold ASSUMES pk-stable edits by contract — a pk change arrives as Remove+Add because
1490    // the engine ingress (`ReplicaEngine::push_and_drain`) splits it before the fold sees one.
1491    // That end-to-end split (a real `UPDATE … SET <pk> = …` → Remove(old)+Add(new) in the
1492    // change stream) is covered by `tests/pk_edit_split.rs`. Here we only pin the pk-stable
1493    // path the fold actually handles.
1494    // -------------------------------------------------------------------
1495
1496    #[test]
1497    fn pk_stable_edit_still_sets_in_place() {
1498        // The pk-stable path is unchanged: a value-only edit is one in-place Edit, rc untouched.
1499        let ast = Ast {
1500            table: "issue".into(),
1501            ..Default::default()
1502        };
1503        let mut f = NormalizeFold::new(&ast, pk_col0(&["issue"]));
1504        f.fold(&[CaughtChange::Add(leaf(vec![V::Int(1), V::str("a")]))]);
1505        let ops = f.fold(&[CaughtChange::Edit {
1506            old: owned_row(vec![V::Int(1), V::str("a")]),
1507            row: owned_row(vec![V::Int(1), V::str("b")]),
1508        }]);
1509        assert_eq!(summarize(&ops), vec![("issue".into(), "edit", 1)]);
1510        assert_eq!(f.footprint["issue"].values().next().unwrap().rc, 1);
1511    }
1512
1513    #[test]
1514    fn normalized_op_serde_round_trips() {
1515        let op = NormalizedOp::Edit {
1516            table: "track".into(),
1517            old: vec![
1518                V::Int(1),
1519                V::str("v1"),
1520                V::Null,
1521                V::Bool(true),
1522                V::Float(1.5),
1523            ],
1524            new: vec![
1525                V::Int(1),
1526                V::str("v2"),
1527                V::Null,
1528                V::Bool(true),
1529                V::Float(1.5),
1530            ],
1531        };
1532        let json = serde_json::to_value(&op).expect("serialize");
1533        let back: NormalizedOp = serde_json::from_value(json.clone()).expect("deserialize");
1534        let json2 = serde_json::to_value(&back).expect("re-serialize");
1535        assert_eq!(json, json2);
1536    }
1537
1538    // -------------------------------------------------------------------
1539    // Relationship aggregates → synthetic base tables (AGGREGATE-SYNC-DESIGN.md §3.2)
1540    // -------------------------------------------------------------------
1541
1542    /// `issue { commentCount: count(comments) }` — comment.issue ↔ issue.id.
1543    fn issue_count_ast() -> Ast {
1544        let mut count = related("commentCount", "comment");
1545        count.subquery.aggregate = Some(Aggregate::Count);
1546        Ast {
1547            table: "issue".into(),
1548            related: vec![count],
1549            ..Default::default()
1550        }
1551    }
1552
1553    fn issue_agg_ast(agg: Aggregate) -> Ast {
1554        let mut rel = related("commentAgg", "comment");
1555        rel.subquery.aggregate = Some(agg);
1556        Ast {
1557            table: "issue".into(),
1558            related: vec![rel],
1559            ..Default::default()
1560        }
1561    }
1562
1563    #[test]
1564    fn reject_unsupported_sync_aggregate_gates_sum_and_avg() {
1565        // Count is the only sync-supported relationship aggregate; sum/avg are rejected at
1566        // registration (they work for a direct read but have no synthetic-table encoding).
1567        assert!(reject_unsupported_sync_aggregate(&issue_count_ast()).is_ok());
1568        assert!(reject_unsupported_sync_aggregate(&Ast {
1569            table: "issue".into(),
1570            ..Default::default()
1571        })
1572        .is_ok());
1573        assert!(
1574            reject_unsupported_sync_aggregate(&issue_agg_ast(Aggregate::Sum("score".into())))
1575                .is_err()
1576        );
1577        assert!(
1578            reject_unsupported_sync_aggregate(&issue_agg_ast(Aggregate::Avg("score".into())))
1579                .is_err()
1580        );
1581
1582        // A sum nested under an ordinary materialized `related` is caught by the recursion.
1583        let mut parent = related("comments", "comment");
1584        parent.subquery.related = vec![{
1585            let mut inner = related("replyAvg", "reply");
1586            inner.subquery.aggregate = Some(Aggregate::Avg("len".into()));
1587            inner
1588        }];
1589        let nested = Ast {
1590            table: "issue".into(),
1591            related: vec![parent],
1592            ..Default::default()
1593        };
1594        assert!(reject_unsupported_sync_aggregate(&nested).is_err());
1595    }
1596
1597    #[test]
1598    fn table_tree_retags_aggregate_slot_to_a_synthetic_table() {
1599        // The aggregate slot is re-tagged to the synthetic table — the child `comment` rows
1600        // are NOT surfaced (they are never synced), and the node is a leaf.
1601        let ast = issue_count_ast();
1602        let synthetic = agg_table_name(&ast.related[0]);
1603        let tree = table_tree(&ast);
1604        assert_eq!(tree.table.as_ref(), "issue");
1605        assert_eq!(tree.children.len(), 1);
1606        let child = tree.children[0].as_ref().unwrap();
1607        assert_eq!(child.table, synthetic);
1608        assert!(child.table.starts_with("__agg_"));
1609        assert_ne!(child.table.as_ref(), "comment");
1610        assert!(child.children.is_empty());
1611    }
1612
1613    /// The `having_count` lowering (`PARENT-AGGREGATE-FILTER-DESIGN.md` §3): the display
1614    /// `count_as` stays a materialized `related`, and a CLONE of it — re-aliased, carrying the
1615    /// post-aggregation `HAVING` — rides the `where` tree as an EXISTS gate.
1616    fn issue_having_count_ast() -> Ast {
1617        let mut ast = issue_count_ast();
1618        let mut gate = ast.related[0].clone();
1619        gate.subquery.alias = Some("__having_commentCount".into());
1620        gate.subquery.having = Some(Condition::Simple(SimpleCondition {
1621            op: Op::Gt,
1622            left: ValuePosition::Column {
1623                name: "count".into(),
1624            },
1625            right: ValuePosition::Literal { value: Lit::Int(0) },
1626        }));
1627        ast.r#where = Some(Condition::CorrelatedSubquery(CorrelatedSubqueryCondition {
1628            related: gate,
1629            op: ExistsOp::Exists,
1630            flip: None,
1631            scalar: None,
1632            plan_id: None,
1633        }));
1634        ast
1635    }
1636
1637    #[test]
1638    fn rewrite_aggregates_points_a_display_count_at_its_synthetic_table() {
1639        // The §3.3 rewrite: a relationship `count` becomes a precomputed singular join over the
1640        // synthetic table, NOT a reduce (which would recount already-aggregated rows). Alias
1641        // survives so the view slot keeps its name; the correlation is untouched because the
1642        // synthetic columns are named after the child correlation fields.
1643        let ast = issue_count_ast();
1644        let out = rewrite_aggregates(&ast);
1645        assert_eq!(out.related.len(), 1);
1646        let rel = &out.related[0];
1647        assert_eq!(rel.subquery.table, agg_table_name(&ast.related[0]));
1648        assert_eq!(rel.subquery.aggregate, Some(Aggregate::Count));
1649        assert!(rel.subquery.aggregate_precomputed);
1650        assert_eq!(rel.subquery.alias.as_deref(), Some("commentCount"));
1651        assert_eq!(rel.correlation, ast.related[0].correlation);
1652        // The child table's own filter/relationships are GONE — they are baked into the
1653        // synthetic table's identity (the name is a hash of them), not re-evaluated locally.
1654        assert!(rel.subquery.r#where.is_none());
1655        assert!(rel.subquery.related.is_empty());
1656    }
1657
1658    #[test]
1659    fn rewrite_aggregates_rewrites_a_having_count_gate_in_the_where_tree() {
1660        // The half `rewriteAggregates` used to miss (and the whole reason `/thoughts/movies`
1661        // went blank): the gate is a relationship `count` like any other, so it must be
1662        // rewritten too. Left alone it would reduce over child rows the server deliberately
1663        // does not sync, and every parent would fail it.
1664        let ast = issue_having_count_ast();
1665        let out = rewrite_aggregates(&ast);
1666        let Some(Condition::CorrelatedSubquery(gate)) = out.r#where.as_ref() else {
1667            panic!("expected the gate EXISTS to survive the rewrite");
1668        };
1669        assert!(gate.related.subquery.table.starts_with("__agg_"));
1670        assert!(gate.related.subquery.aggregate_precomputed);
1671        // The HAVING is the gate's WHOLE predicate and addresses the reduce's output column,
1672        // which is exactly what the synthetic row carries — so it must survive.
1673        assert!(
1674            gate.related.subquery.having.is_some(),
1675            "dropping the HAVING turns the gate into a bare EXISTS"
1676        );
1677        // Same table as the display aggregate ⇒ the rewrite costs no extra sync.
1678        assert_eq!(gate.related.subquery.table, out.related[0].subquery.table);
1679    }
1680
1681    #[test]
1682    fn rewrite_aggregates_is_a_cross_language_twin() {
1683        // The Rust half of the twin contract. `packages/normalized/test/agg-table.test.ts`
1684        // pins the SAME table name for the SAME aggregate definition through the TypeScript
1685        // `rewriteAggregates`; the two must agree byte-for-byte or a synced client reads a
1686        // table the server never fills. Same vector as
1687        // `agg_table_name_cross_language_vector`, reached through the rewrite instead of the
1688        // hash directly, so a rewrite that names the table some OTHER way is caught here.
1689        let ast = Ast {
1690            table: "issue".into(),
1691            related: vec![CorrelatedSubquery {
1692                correlation: correlation("id", "issue_id"),
1693                subquery: Box::new(Ast {
1694                    table: "comment".into(),
1695                    alias: Some("commentCount".into()),
1696                    aggregate: Some(Aggregate::Count),
1697                    ..Default::default()
1698                }),
1699                system: None,
1700            }],
1701            ..Default::default()
1702        };
1703        let out = rewrite_aggregates(&ast);
1704        assert_eq!(&*out.related[0].subquery.table, "__agg_725f6575e4f86856");
1705    }
1706
1707    #[test]
1708    fn rewrite_aggregates_leaves_a_local_count_as_a_native_reduce() {
1709        // L1 (`201-LOCAL-ONLY-TABLES-DESIGN.md` §5.2): a count over a LOCAL child has no
1710        // server-authoritative `__agg_*` base, so rewriting it would point the relationship at
1711        // a synthetic table nothing ever feeds — empty forever.
1712        let ast = issue_count_ast();
1713        let out = rewrite_aggregates_with_local(&ast, &|t| t == "comment");
1714        assert_eq!(out.related[0].subquery.table.as_ref(), "comment");
1715        assert!(!out.related[0].subquery.aggregate_precomputed);
1716    }
1717
1718    #[test]
1719    fn table_tree_prunes_a_having_count_gate_slot() {
1720        // The gate is an EXISTS over a HAVING-filtered reduce, so its slot holds WITNESS child
1721        // rows, not the reduce's `(group…, count)` output. Re-tagging them to the synthetic
1722        // table would ship full-width `comment` rows under its 2-column schema (the client's
1723        // width check rejects the batch). It is pruned to `None` — position preserved — while
1724        // the DISPLAY aggregate's slot still carries the synthetic table.
1725        let ast = issue_having_count_ast();
1726        let tree = table_tree(&ast);
1727        assert_eq!(tree.table.as_ref(), "issue");
1728        assert_eq!(tree.children.len(), 2, "display slot + gate slot");
1729        let display = tree.children[0].as_ref().expect("display slot survives");
1730        assert!(display.table.starts_with("__agg_"));
1731        assert_eq!(tree.children[1], None, "the gate slot is pruned");
1732    }
1733
1734    #[test]
1735    fn a_having_count_gate_names_the_display_aggregate_table() {
1736        // Load-bearing for the prune: the client reads the gate off the DISPLAY aggregate's
1737        // rows, which only works because `agg_table_name` hashes neither `alias` nor `having`
1738        // — so the clone resolves to the very same synthetic table. If this ever diverges, the
1739        // pruned gate would have no rows to evaluate against and every parent would drop out.
1740        let ast = issue_having_count_ast();
1741        let Some(Condition::CorrelatedSubquery(gate)) = ast.r#where.as_ref() else {
1742            panic!("expected the gate EXISTS");
1743        };
1744        assert_eq!(
1745            agg_table_name(&gate.related),
1746            agg_table_name(&ast.related[0])
1747        );
1748    }
1749
1750    #[test]
1751    fn a_having_count_gate_widens_no_projection() {
1752        // `collect_required` mirrors `table_tree`'s prune, so the gate contributes NOTHING to
1753        // the advertised projection: adding `.having(count > 0)` to a query must not widen the
1754        // footprint by one column. (`comment` is still requested — by the DISPLAY aggregate,
1755        // which recurses into its child; the gate just adds nothing on top.)
1756        assert_eq!(
1757            required_columns_by_table(&issue_having_count_ast()),
1758            required_columns_by_table(&issue_count_ast()),
1759        );
1760    }
1761
1762    #[test]
1763    fn agg_table_schemas_derives_the_synthetic_schema() {
1764        let ast = issue_count_ast();
1765        let tables = agg_table_schemas(&ast);
1766        assert_eq!(tables.len(), 1);
1767        let t = &tables[0];
1768        assert_eq!(t.name, agg_table_name(&ast.related[0]));
1769        // `[child_field…, "count"]` (the `related` helper correlates on `parent_id`), group
1770        // key = the leading `child_field`.
1771        assert_eq!(
1772            t.columns.iter().map(|c| &**c).collect::<Vec<_>>(),
1773            vec!["parent_id", "count"]
1774        );
1775        assert_eq!(t.key_len, 1);
1776    }
1777
1778    #[test]
1779    fn agg_table_name_cross_language_vector() {
1780        // The cross-language contract (AGGREGATE-SYNC-DESIGN.md §3.1): a canonical aggregate
1781        // definition must hash identically in Rust and the TS client twin (`@rindle/normalized`
1782        // `aggTableName`, pinned in `agg-table.test.ts`). A drift in either FNV byte protocol
1783        // breaks routing — client and server would name the synthetic table differently.
1784        // `count(comment)` grouped by `comment.issue_id`, no filter (the parent table is not
1785        // part of the hash, so the parent's table name is irrelevant).
1786        let csq = CorrelatedSubquery {
1787            correlation: correlation("id", "issue_id"),
1788            subquery: Box::new(Ast {
1789                table: "comment".into(),
1790                alias: Some("commentCount".into()),
1791                aggregate: Some(Aggregate::Count),
1792                ..Default::default()
1793            }),
1794            system: None,
1795        };
1796        assert_eq!(&*agg_table_name(&csq), "__agg_725f6575e4f86856");
1797    }
1798
1799    #[test]
1800    fn agg_table_name_cross_language_vector_integer_literal_filter() {
1801        // The exact-int plane of the literal hash (design 226 Stage B): an integral
1802        // JSON literal deserializes as `Lit::Int` and hashes tag 5 + exact i64 LE
1803        // bytes, and the TS twin mirrors serde's integer-token rule (`agg-table.test.ts`
1804        // pins these SAME names). A drift here leaves the server advertising a
1805        // different synthetic table from the one the client rewrite reads — the local
1806        // aggregate would simply stay empty.
1807        let filtered = |value: Lit| CorrelatedSubquery {
1808            correlation: correlation("id", "issue_id"),
1809            subquery: Box::new(Ast {
1810                table: "comment".into(),
1811                aggregate: Some(Aggregate::Count),
1812                r#where: Some(Condition::Simple(SimpleCondition {
1813                    op: Op::Eq,
1814                    left: ValuePosition::Column {
1815                        name: "score".into(),
1816                    },
1817                    right: ValuePosition::Literal { value },
1818                })),
1819                ..Default::default()
1820            }),
1821            system: None,
1822        };
1823        // `score = 5` — the canonical small-integer literal every JS client produces.
1824        assert_eq!(
1825            &*agg_table_name(&filtered(Lit::Int(5))),
1826            "__agg_386c48c5334e7707"
1827        );
1828        // What a JS client's `2 ** 60` ACTUALLY arrives as: JS serializes the shortest
1829        // decimal that round-trips — the token "1152921504606847000", NOT the binary
1830        // value 1152921504606846976 — and serde parses that exact token as the i64.
1831        // The TS twin therefore hashes the wire token, and pins THIS name for `2 ** 60`.
1832        assert_eq!(
1833            &*agg_table_name(&filtered(Lit::Int(1152921504606847000))),
1834            "__agg_8eeccc486bcc3c3a"
1835        );
1836        // The binary-exact 2^60 is only producible by a bigint-capable producer (the
1837        // Rust fluent API); it is a DISTINCT identity from the JS token above — no TS
1838        // twin pin exists for it.
1839        assert_eq!(
1840            &*agg_table_name(&filtered(Lit::Int(1 << 60))),
1841            "__agg_3908e94b423e01c2"
1842        );
1843        // A non-integral literal stays the f64 plane (tag 2) on both sides.
1844        assert_eq!(
1845            &*agg_table_name(&filtered(Lit::Number(2.5))),
1846            "__agg_e504b86c532033e7"
1847        );
1848        // The property the int plane exists for: two adjacent i64s above 2^53 round to
1849        // the SAME f64 — under f64 hashing they'd share a synthetic table.
1850        assert_ne!(
1851            agg_table_name(&filtered(Lit::Int(1 << 60))),
1852            agg_table_name(&filtered(Lit::Int((1 << 60) + 1)))
1853        );
1854    }
1855
1856    #[test]
1857    fn agg_table_name_is_stable_and_definition_sensitive() {
1858        let n1 = agg_table_name(&issue_count_ast().related[0]);
1859        // Stable across calls (same definition → same name → cross-query sharing).
1860        assert_eq!(n1, agg_table_name(&issue_count_ast().related[0]));
1861        assert!(n1.starts_with("__agg_"));
1862
1863        // A different child filter → a different table (else issue 5's filtered and
1864        // unfiltered counts would collide on `(table, pk)` across two queries).
1865        let mut filtered = issue_count_ast();
1866        filtered.related[0].subquery.r#where = Some(Condition::Simple(SimpleCondition {
1867            op: Op::Ne,
1868            left: ValuePosition::Column {
1869                name: "body".into(),
1870            },
1871            right: ValuePosition::Literal {
1872                value: Lit::Str("x".into()),
1873            },
1874        }));
1875        assert_ne!(agg_table_name(&filtered.related[0]), n1);
1876
1877        // A different group key (correlation child) → a different table.
1878        let mut regrouped = issue_count_ast();
1879        regrouped.related[0].correlation.child_field = vec!["author".into()];
1880        assert_ne!(agg_table_name(&regrouped.related[0]), n1);
1881
1882        // A different child table → a different table.
1883        let mut other = issue_count_ast();
1884        other.related[0].subquery.table = "reaction".into();
1885        assert_ne!(agg_table_name(&other.related[0]), n1);
1886
1887        // The PARENT correlation field is excluded — the count for a group key is the same
1888        // whichever parent joins it, so two parents share one synthetic table.
1889        let mut other_parent = issue_count_ast();
1890        other_parent.related[0].correlation.parent_field = vec!["other_id".into()];
1891        assert_eq!(agg_table_name(&other_parent.related[0]), n1);
1892    }
1893
1894    #[test]
1895    fn fold_ships_the_aggregate_row_under_the_synthetic_table_not_the_child() {
1896        let ast = issue_count_ast();
1897        let synthetic = agg_table_name(&ast.related[0]);
1898        // The synthetic table's PK is the group column; `comment` is never registered.
1899        let pk: PkMap = [
1900            ("issue".into(), vec![0usize]),
1901            (synthetic.clone(), vec![0usize]),
1902        ]
1903        .into_iter()
1904        .collect();
1905        let mut f = NormalizeFold::new(&ast, pk);
1906
1907        // Hydrate: issue 1 carries its reduce-output `(issue=1, count=2)` row as a Child.
1908        let ops = f.fold(&[CaughtChange::Add(cnode(
1909            vec![V::Int(1)],
1910            vec![(RelId(0), vec![leaf(vec![V::Int(1), V::Int(2)])])],
1911        ))]);
1912        // The synthetic table sorts before "issue" ('_' < 'i'); the agg row is tagged the
1913        // SYNTHETIC table, never "comment".
1914        assert_eq!(
1915            summarize(&ops),
1916            vec![
1917                (synthetic.to_string(), "add", 1),
1918                ("issue".into(), "add", 1),
1919            ]
1920        );
1921
1922        // A later count shift for issue 1 (2 → 3): the reduce ships a pk-stable Edit of the
1923        // synthetic row (the count is the value, the group key is the PK).
1924        let ops = f.fold(&[CaughtChange::Child {
1925            row: owned_row(vec![V::Int(1)]),
1926            rel: RelId(0),
1927            change: Box::new(CaughtChange::Edit {
1928                old: owned_row(vec![V::Int(1), V::Int(2)]),
1929                row: owned_row(vec![V::Int(1), V::Int(3)]),
1930            }),
1931        }]);
1932        assert_eq!(summarize(&ops), vec![(synthetic.to_string(), "edit", 1)]);
1933
1934        // The group dies (last comment removed): a Remove of the synthetic row.
1935        let ops = f.fold(&[CaughtChange::Child {
1936            row: owned_row(vec![V::Int(1)]),
1937            rel: RelId(0),
1938            change: Box::new(CaughtChange::Remove(leaf(vec![V::Int(1), V::Int(3)]))),
1939        }]);
1940        assert_eq!(summarize(&ops), vec![(synthetic.to_string(), "remove", 1)]);
1941    }
1942}