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(®rouped.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}