Rindle docs and package mapSkip to main content

rindle/
graph.rs

1//! Primitive #1: the operator graph as an **arena + `NodeId`**, NOT
2//! `Rc<RefCell<dyn Operator>>`.
3//!
4//! Why this is the make-or-break decision (verified against `join.ts`):
5//! `#pushChildChange` sets in-progress state then re-enters `parent.fetch()`
6//! *while a push is in flight*, and self-joins put the same source in the graph
7//! twice. The "obvious" `Rc<RefCell<dyn Operator>>` translation would hit a
8//! runtime `BorrowMutError` on exactly that pattern. The arena fixes it: the
9//! whole pipeline is one `Vec<Operator>` addressed by `NodeId`, every
10//! fetch/push takes `&self` (a *shared* borrow of the graph), and per-operator
11//! mutable state lives behind `Cell`/`RefCell` scoped to a single statement —
12//! never held across a vend. Reentrant fetch-during-push is then just another
13//! shared borrow. No cycles-of-owners, no borrow conflict.
14//!
15//! The leaf [`MemorySource`] (the connection/read/COW machinery) lives in
16//! `memory_source.rs`; this module hosts the operator graph that wires it up
17//! (`SourceConn`, `Join`, `View`, `Collector`) and drives the eager push fan-out
18//! ([`Graph::source_push`](crate::graph::Graph::source_push)), which is inherently graph-level (it must reach
19//! downstream operators by `NodeId`).
20
21use std::cell::{Cell, RefCell};
22use std::rc::Rc;
23
24use crate::canon::CanonKey;
25use crate::change::{
26    build_join_constraint, materialize_change_edit_old_row_only,
27    materialize_change_preserving_node, rebuild_change, Change, ChangeType, Constraint,
28    FetchRequest, JoinOverlay, Node, NodeStream, OutEdge, Port, Relationship, RowFlow,
29    SourceChange,
30};
31use crate::error::RindleError;
32use crate::family::{BindingSet, FamilyPipeline};
33use crate::memory_source::MemorySource;
34use crate::op::join_util::{canonical_key_of_constraint, Frontier, ParentKeySet, Probe};
35use crate::op::{is_join_match, row_equals_for_compound_key};
36use crate::predicate::CompiledPredicate;
37use crate::source_common::{Connection, ConnectionFilters};
38use crate::storage::{Storage, StorageFactory};
39use crate::{metric_change_kind, metric_changes_inc, metric_inc, metric_timer};
40// `Row` aliases the canonical owned row; the operator graph is all owned values
41// (it buffers, overlays, and materializes rows), so no `Value<'a>` appears here.
42use crate::value::{
43    compare_rows, compare_values, same_pk, ColId, OwnedRow as Row, OwnedValue, RelId, Schema, Sort,
44    SourceSchema,
45};
46
47/// How many fan-out loop iterations pass between wall-clock reads at a push-deadline
48/// checkpoint ([`Graph::push_deadline_exceeded`](crate::graph::Graph::push_deadline_exceeded), FOLLOWER-LAG-SHED §6.6): amortizes the
49/// ~tens-of-ns clock read to nothing while still bailing within O(1024 · per-iteration cost)
50/// of the deadline.
51const DEADLINE_CHECK_EVERY: u32 = 1024;
52
53/// A handle to an operator slot in the [`Graph`] arena. `idx` is the slot index;
54/// `gen` is the slot's **generation**, bumped every time the slot is freed (a
55/// pipeline teardown — [`Graph::destroy_pipeline`](crate::graph::Graph::destroy_pipeline)). The generation is what makes
56/// slot REUSE safe: a stale handle to a torn-down pipeline carries the old `gen`, so
57/// it fails the generation check in `Graph::node` instead of silently aliasing the
58/// new tenant of a recycled slot. Distinct from physical removal (which would shift
59/// indices and is impossible here) — the slot keeps its index across reuse.
60#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
61pub struct NodeId {
62    pub idx: u32,
63    pub gen: u32,
64}
65
66impl NodeId {
67    /// Construct a raw handle from a slot index and generation. The graph vends
68    /// `NodeId`s from `add`; this is for the few call sites that wire a known slot
69    /// directly (the builder/testkit and integration tests that reference a
70    /// freshly-added node at generation 0).
71    pub fn new(idx: u32, gen: u32) -> NodeId {
72        NodeId { idx, gen }
73    }
74    fn ix(self) -> usize {
75        self.idx as usize
76    }
77}
78
79/// Index into the graph's **parallel storage arena** (foundations §6.4). A
80/// stateful operator (`Take`/`Cap`/`Exists`, spec `07`) is handed one of these at
81/// build time and reads/writes its scratch state via `Graph::storage`. The
82/// operator never owns its store: the indirection is what lets the **client** keep
83/// it in RAM ([`MemoryStorage`](crate::storage::MemoryStorage)) while the
84/// **server** later spills it to SQLite (`10` §1.1) without touching operator code.
85/// Distinct newtype from [`NodeId`] so the two index spaces can't be confused.
86/// Generational for the same reason (storage slots are freed + reused on teardown).
87#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
88pub struct StorageId {
89    pub idx: u32,
90    pub gen: u32,
91}
92
93impl StorageId {
94    fn ix(self) -> usize {
95        self.idx as usize
96    }
97}
98
99// ---------------------------------------------------------------------------
100// The Source trait — the connection/read seam both backends implement
101// ---------------------------------------------------------------------------
102
103/// A connection handle into a source's connection table. Generational `{idx, gen}`
104/// (like [`NodeId`]/[`StorageId`]) so a source can RECYCLE a torn-down connection's
105/// slot — the [`ConnTable`](crate::source_common::ConnTable) bumps the generation on
106/// free and checks it on every access, so a stale handle to a recycled slot fail-fasts.
107/// Distinct from [`NodeId`]. Same role as `05`'s `ConnId`.
108#[derive(Clone, Copy, PartialEq, Eq, Debug)]
109pub struct ConnId {
110    pub idx: u32,
111    pub gen: u32,
112}
113
114impl ConnId {
115    pub fn new(idx: u32, gen: u32) -> ConnId {
116        ConnId { idx, gen }
117    }
118}
119
120/// The connection + read contract a leaf backend owns locally (`04` §4.7 /
121/// `05` §4.8). [`MemorySource`] implements it; the SQLite `TableSource` implements
122/// the *same* trait, so `connect`/`fetch`/`schema`/`destroy` — and everything
123/// downstream of the `NodeStream` they vend — are backend-identical.
124///
125/// The *eager push* fan-out is inherently graph-level (it drives downstream
126/// operators by `NodeId`), so it lives in [`Graph::source_push`](crate::graph::Graph::source_push) + the source's
127/// [`MemorySource::push`]; the trait scopes to what a source answers from `&self`.
128pub trait Source {
129    /// Register a new connection (one downstream output). `sort = None` ⇒
130    /// unordered. Self-joins call this twice. Builds the [`Connection`] from the
131    /// (07-compiled) filter spec + split-edit keys, and asserts the ordering
132    /// includes the PK when ordered. Mirrors `connect` (`memory-source.ts:162`).
133    fn connect(
134        &self,
135        sort: Option<Sort>,
136        filters: Option<ConnectionFilters>,
137        split_edit_keys: Vec<ColId>,
138    ) -> ConnId;
139
140    /// Lazy pull for `conn`: a stream of **rows** in (reverse-aware) sort order,
141    /// overlay-spliced, start-gated, constraint-trimmed, filtered. A source emits
142    /// rows; the `SourceConn` operator wraps them in leaf nodes (`Graph::fetch`).
143    fn fetch<'g>(&'g self, conn: ConnId, req: &FetchRequest) -> RowFlow<'g>;
144
145    /// The effective sort for a connection. This can differ from the table schema's
146    /// default/primary sort, and ordered downstream operators must use this.
147    fn conn_sort(&self, conn: ConnId) -> Sort;
148
149    /// The schema of rows this source vends.
150    fn schema(&self) -> &Schema;
151
152    /// Drop a connection's downstream edge so it stops receiving pushes. Does NOT
153    /// delete the backing indexes (§3.10).
154    fn destroy(&self, conn: ConnId);
155
156    /// Open-cursor count — `0` ⇒ no cursor is mid-iteration and the connection is
157    /// free for a write.
158    fn cursors_open(&self) -> i64;
159
160    /// Eager fallible push: fan `change` to every connection (overlay live,
161    /// epoch-gated), clear the overlay, then write. `push_one` is the graph's
162    /// downstream driver. When `strict`, a malformed change returns a typed
163    /// [`RindleError`] instead of a `debug_assert`. (A backend may keep a faster
164    /// infallible `push` as an inherent method; this is the object-safe seam the
165    /// graph drives.)
166    fn try_push(
167        &self,
168        change: SourceChange,
169        push_one: &dyn Fn(&Connection, SourceChange),
170        strict: bool,
171    ) -> Result<(), RindleError>;
172
173    /// Take any error a cursor parked during the last drained fetch (`05` §4.5);
174    /// `None` if it drained cleanly. Infallible backends always return `None`.
175    fn take_error(&self) -> Option<RindleError>;
176
177    /// Wire a connection's downstream output edge (mirrors `input.setOutput`).
178    fn set_conn_output(&self, conn: ConnId, edge: OutEdge);
179
180    /// Add a **dynamic** push-index guard value to `conn` (design 310 §4.1 — a family
181    /// root's binding set growing; see `ConnTable::add_guard_value`). Required, not
182    /// defaulted: a decorator that forgot to forward it would leave the family root
183    /// unindexed and silently drop its deltas.
184    fn add_guard_value(&self, conn: ConnId, value: OwnedValue);
185
186    /// Remove one dynamic guard value added with [`add_guard_value`](Self::add_guard_value).
187    fn remove_guard_value(&self, conn: ConnId, value: &OwnedValue);
188}
189
190/// The source leaf stored in the arena.
191///
192/// The in-memory backend keeps its own concrete [`Memory`](SourceLeaf::Memory) variant
193/// so the client/wasm hot path stays statically dispatched. Any other backend — e.g. the
194/// SQLite `TableSource` from the `rindle-sqlite` crate — is erased behind the object-safe
195/// [`Source`] trait via [`Dyn`](SourceLeaf::Dyn). The vtable hop is per connect/fetch,
196/// never per row (`fetch` already returns a boxed [`RowFlow`]).
197///
198// The `Memory` variant is deliberately unboxed (the static client/wasm hot path);
199// `Dyn` is a fat pointer. The size asymmetry is intentional and harmless — there is
200// one `SourceLeaf` per source node (a handful per graph), so boxing `Memory` would
201// only add a pointless heap indirection on the hot path.
202#[allow(clippy::large_enum_variant)]
203pub(crate) enum SourceLeaf {
204    Memory(MemorySource),
205    Dyn(Box<dyn Source>),
206}
207
208impl Source for SourceLeaf {
209    fn connect(
210        &self,
211        sort: Option<Sort>,
212        filters: Option<ConnectionFilters>,
213        split_edit_keys: Vec<ColId>,
214    ) -> ConnId {
215        match self {
216            SourceLeaf::Memory(s) => s.connect(sort, filters, split_edit_keys),
217            SourceLeaf::Dyn(s) => s.connect(sort, filters, split_edit_keys),
218        }
219    }
220
221    fn fetch<'g>(&'g self, conn: ConnId, req: &FetchRequest) -> RowFlow<'g> {
222        match self {
223            SourceLeaf::Memory(s) => s.fetch(conn, req),
224            SourceLeaf::Dyn(s) => s.fetch(conn, req),
225        }
226    }
227
228    fn conn_sort(&self, conn: ConnId) -> Sort {
229        match self {
230            SourceLeaf::Memory(s) => s.conn_sort(conn),
231            SourceLeaf::Dyn(s) => s.conn_sort(conn),
232        }
233    }
234
235    fn schema(&self) -> &Schema {
236        match self {
237            SourceLeaf::Memory(s) => s.schema(),
238            SourceLeaf::Dyn(s) => s.schema(),
239        }
240    }
241
242    fn destroy(&self, conn: ConnId) {
243        match self {
244            SourceLeaf::Memory(s) => s.destroy(conn),
245            SourceLeaf::Dyn(s) => s.destroy(conn),
246        }
247    }
248
249    fn cursors_open(&self) -> i64 {
250        match self {
251            SourceLeaf::Memory(s) => s.cursors_open(),
252            SourceLeaf::Dyn(s) => s.cursors_open(),
253        }
254    }
255
256    fn try_push(
257        &self,
258        change: SourceChange,
259        push_one: &dyn Fn(&Connection, SourceChange),
260        strict: bool,
261    ) -> Result<(), RindleError> {
262        match self {
263            // Non-strict memory keeps the infallible hot-path `push` verbatim; strict
264            // routes through the fallible sibling so a bad change returns a typed error.
265            SourceLeaf::Memory(s) => {
266                if strict {
267                    s.try_push(change, push_one, true)
268                } else {
269                    s.push(change, push_one);
270                    Ok(())
271                }
272            }
273            SourceLeaf::Dyn(s) => s.try_push(change, push_one, strict),
274        }
275    }
276
277    fn take_error(&self) -> Option<RindleError> {
278        match self {
279            SourceLeaf::Memory(_) => None,
280            SourceLeaf::Dyn(s) => s.take_error(),
281        }
282    }
283
284    fn set_conn_output(&self, conn: ConnId, edge: OutEdge) {
285        match self {
286            SourceLeaf::Memory(s) => s.set_conn_output(conn, edge),
287            SourceLeaf::Dyn(s) => s.set_conn_output(conn, edge),
288        }
289    }
290
291    fn add_guard_value(&self, conn: ConnId, value: OwnedValue) {
292        match self {
293            SourceLeaf::Memory(s) => Source::add_guard_value(s, conn, value),
294            SourceLeaf::Dyn(s) => s.add_guard_value(conn, value),
295        }
296    }
297
298    fn remove_guard_value(&self, conn: ConnId, value: &OwnedValue) {
299        match self {
300            SourceLeaf::Memory(s) => Source::remove_guard_value(s, conn, value),
301            SourceLeaf::Dyn(s) => s.remove_guard_value(conn, value),
302        }
303    }
304}
305
306// ---------------------------------------------------------------------------
307// CountingSource — a diagnostic decorator (the `analyze query` emit counter)
308// ---------------------------------------------------------------------------
309
310/// A shared per-source counter of rows a source emitted into the pipeline. One cell
311/// per wrapped source node (so the count is already keyed per `NodeId`); a
312/// child-driven join that re-fetches the same leaf under many constraints accumulates
313/// across those fetches into the same cell. `Rc<Cell<..>>` (not atomic) — a `Graph`
314/// is `!Send`, single-threaded by construction.
315pub type EmitCounter = Rc<Cell<u64>>;
316
317/// A [`Source`] decorator that counts the rows crossing the connection boundary — the
318/// **one new measurement** the `analyze query` diagnostic needs
319/// (`designs-implemented/ANALYZE-QUERY-DESIGN.md` §3.4). It wraps any object-safe [`Source`] and
320/// delegates every method to `inner` unchanged, except [`fetch`](Source::fetch), whose
321/// returned `RowFlow` is wrapped in a lazy `.inspect` that bumps `emitted` once per row
322/// **as the row is pulled**. Because it counts `inner.fetch()`'s *final* stream — after
323/// the backend's overlay/start/constraint/filter chain and across both the ordered and
324/// unordered fetch paths — it sees exactly what the pipeline sees, on either backend
325/// (SQLite or memory), and a downstream `Take` that abandons the stream early counts
326/// only what it consumed (the correct "emitted into the pipeline" semantics).
327///
328/// This is installed **only** on the throwaway pipeline `analyze_query` builds; the
329/// live `Graph::fetch` and every live source stay byte-for-byte unchanged — there is no
330/// branch or wrapper on any steady-state fetch or push. The only per-analyze cost is
331/// one vtable hop plus one `Cell` increment per emitted row.
332pub struct CountingSource {
333    inner: Box<dyn Source>,
334    emitted: EmitCounter,
335}
336
337impl CountingSource {
338    /// Wrap `inner`; rows it emits are tallied into `emitted` (a cell the caller keeps a
339    /// clone of, to read the total back after hydration).
340    pub fn new(inner: Box<dyn Source>, emitted: EmitCounter) -> CountingSource {
341        CountingSource { inner, emitted }
342    }
343}
344
345impl Source for CountingSource {
346    fn connect(
347        &self,
348        sort: Option<Sort>,
349        filters: Option<ConnectionFilters>,
350        split_edit_keys: Vec<ColId>,
351    ) -> ConnId {
352        self.inner.connect(sort, filters, split_edit_keys)
353    }
354
355    fn fetch<'g>(&'g self, conn: ConnId, req: &FetchRequest) -> RowFlow<'g> {
356        let n = self.emitted.clone();
357        Box::new(
358            self.inner
359                .fetch(conn, req)
360                .inspect(move |_| n.set(n.get() + 1)),
361        )
362    }
363
364    fn conn_sort(&self, conn: ConnId) -> Sort {
365        self.inner.conn_sort(conn)
366    }
367
368    fn schema(&self) -> &Schema {
369        self.inner.schema()
370    }
371
372    fn destroy(&self, conn: ConnId) {
373        self.inner.destroy(conn)
374    }
375
376    fn cursors_open(&self) -> i64 {
377        self.inner.cursors_open()
378    }
379
380    fn try_push(
381        &self,
382        change: SourceChange,
383        push_one: &dyn Fn(&Connection, SourceChange),
384        strict: bool,
385    ) -> Result<(), RindleError> {
386        self.inner.try_push(change, push_one, strict)
387    }
388
389    fn take_error(&self) -> Option<RindleError> {
390        self.inner.take_error()
391    }
392
393    fn set_conn_output(&self, conn: ConnId, edge: OutEdge) {
394        self.inner.set_conn_output(conn, edge)
395    }
396
397    fn add_guard_value(&self, conn: ConnId, value: OwnedValue) {
398        self.inner.add_guard_value(conn, value)
399    }
400
401    fn remove_guard_value(&self, conn: ConnId, value: &OwnedValue) {
402        self.inner.remove_guard_value(conn, value)
403    }
404}
405
406// ---------------------------------------------------------------------------
407// Operators
408// ---------------------------------------------------------------------------
409
410/// Hierarchical join (parent rows get a lazily-streamed `rel_name` relationship
411/// of matching child rows). Mirrors `join.ts`.
412pub(crate) struct Join {
413    pub parent: NodeId,
414    pub child: NodeId,
415    pub parent_key: Vec<ColId>,
416    pub child_key: Vec<ColId>,
417    /// The relationship slot this join attaches (resolved from its name against the
418    /// parent schema at build time — `08`). Index-addressed on the hot path
419    /// (foundations §3.4); the name is recoverable via the parent
420    /// `Schema::relationships[rel_slot]`.
421    pub rel_slot: RelId,
422    /// The downstream edge as a **port-carrying** [`OutEdge`] (like a
423    /// [`SourceConn`]'s output), so a join can feed another join: `Port::Single`
424    /// to a terminal sink, `Port::JoinParent` when this join is the *parent* of the
425    /// next (sibling relationships stacked on one row), or `Port::JoinChild` when
426    /// this join is a *nested child* feeding the parent join's child port. Both
427    /// `join_push` and `push_child_change` forward on `out.port`.
428    pub output: Cell<Option<OutEdge>>,
429    /// The in-flight child change being fanned out by `Graph::push_child_change`
430    /// — the LIVE port of Zero's `Join.#inprogressChildChange` (`join.ts:61`). Read
431    /// by the child-relationship thunk on EVERY fetch (including the reentrant
432    /// refetch a downstream `Take`/`Exists` triggers mid-push), so a not-yet-
433    /// processed parent's relationship reflects PRE-change (stale) membership — the
434    /// cascade the depth-2 EXISTS+top-N+push shapes need. Owned [`JoinOverlay`]
435    /// (a row-level `SourceChange`, no `'g`) so it lives in the arena. `None` outside
436    /// a child-push. See `Graph::join_overlay_for`.
437    pub inprogress_overlay: RefCell<Option<JoinOverlay>>,
438    /// The row of the parent currently being processed in the child-push fan-out —
439    /// Zero's `#inprogressChildChangePosition` (`join.ts:62`). The overlay applies
440    /// only to parents sorting strictly AFTER this (`compareRows(parent, pos) > 0`):
441    /// parents at-or-before it have already had the change delivered. `None` until
442    /// the first parent of a fan-out is reached.
443    pub inprogress_position: RefCell<Option<Row>>,
444    /// The join membership pre-check's counted parent-key set (design 311): the parent
445    /// correlation keys the parent input holds within `[-∞, frontier]` of its sort order.
446    /// Probed at the top of [`Graph::push_child_change`](crate::graph::Graph::push_child_change) —
447    /// a miss ends the push in one hash probe instead of the reentrant parent fetch.
448    /// Populated by **observing** `join_fetch` (never a build fetch), maintained on the
449    /// `JoinParent` arms of `join_push`, and gated to `Disabled` (never approximate) on
450    /// overflow or an ineligible chain. `Unbuilt` while the graph knob is off.
451    pub precheck: RefCell<ParentKeySet>,
452    /// The cached root-chain eligibility walk (§2.4), computed on the first observation.
453    pub precheck_eligible: Cell<Option<bool>>,
454}
455
456/// A connection (one of a source's outputs). Self-joins create two of these over
457/// the same source. Holds the full generational [`ConnId`] (not a bare index) so a
458/// teardown disconnects the exact connection slot and a recycled slot can never be
459/// mistaken for it.
460pub(crate) struct SourceConn {
461    pub source: NodeId,
462    pub conn: ConnId,
463    /// Set when this connection is the **root of a parameterized query family**
464    /// (design 310 §4.1): the membership test the fetch path applies to committed rows
465    /// (the leaf's `sql_condition` carries only the residual predicate — membership is
466    /// never lowered to SQL, §4.4) and the marker behind the constrained-fetch check.
467    /// Boxed so the common connection stays two words.
468    pub family: Option<Box<FamilyRootConn>>,
469    /// A mirror of the source-side output edge (`Connection::output`), kept here so the
470    /// graph can read a connection's downstream without a `Source` trait getter — the
471    /// family unbind path injects its synthetic removes at a spine node's out edge, and
472    /// that node may be the root connection itself.
473    pub output: Cell<Option<OutEdge>>,
474}
475
476/// The family-root half of a [`SourceConn`]: the binding-set handle and the partition
477/// key (parameter columns), shared with the connection's predicate.
478pub(crate) struct FamilyRootConn {
479    pub bindings: Rc<BindingSet>,
480    pub param_cols: Vec<ColId>,
481}
482
483/// A change recorded by a `Collector` — the spike's analogue of `Catch`'s
484/// `pushes`. Lets a test assert the exact change sequence a source fans out.
485/// Equality compares rows cell-by-cell via [`compare_values`] (null == null) —
486/// `OwnedValue` deliberately has no derived `PartialEq` (it would invite the
487/// wrong comparator), so we spell the row equality out.
488#[derive(Clone, Debug)]
489pub enum CollectedChange {
490    Add(Row),
491    Remove(Row),
492    Edit { row: Row, old: Row },
493}
494
495/// Wrap a source `SourceChange` (rows) into a node-bearing downstream `Change`
496/// (`makeAddChange`/`makeRemoveChange`/`makeEditChange`). Leaf nodes (no rels) —
497/// this IS the row→node conversion, localized to the `SourceConn` write boundary.
498/// Takes the change by value: the rows move into the nodes, no clone.
499/// `pub(crate)`: the out-of-file [`FlippedJoin`](crate::op::FlippedJoin) builds the
500/// nested `child` node of a forwarded `Change::Child` the same way.
501pub(crate) fn source_change_to_node<'g>(change: SourceChange) -> Change<'g> {
502    match change {
503        SourceChange::Add(r) => Change::Add(Node::leaf(r)),
504        SourceChange::Remove(r) => Change::Remove(Node::leaf(r)),
505        SourceChange::Edit { row, old } => Change::Edit {
506            node: Node::leaf(row),
507            old: Node::leaf(old),
508        },
509    }
510}
511
512/// Cell-by-cell row equality for test assertions (`compare_values`, null == null).
513fn full_row_eq(a: &Row, b: &Row) -> bool {
514    a.len() == b.len()
515        && (0..a.len()).all(|i| compare_values(a.col(i), b.col(i)) == std::cmp::Ordering::Equal)
516}
517
518impl PartialEq for CollectedChange {
519    fn eq(&self, other: &Self) -> bool {
520        use CollectedChange::*;
521        match (self, other) {
522            (Add(a), Add(b)) | (Remove(a), Remove(b)) => full_row_eq(a, b),
523            (Edit { row: r1, old: o1 }, Edit { row: r2, old: o2 }) => {
524                full_row_eq(r1, r2) && full_row_eq(o1, o2)
525            }
526            _ => false,
527        }
528    }
529}
530
531/// A leaf sink that records every change pushed to it (and, optionally, the rows
532/// of a reentrant fetch of its input taken *during* the push — to observe the
533/// epoch-gated overlay, as in the JS `fetch during push` tests). Stands in for
534/// `Catch` (`catch.ts`).
535pub(crate) struct Collector {
536    pub input: NodeId,
537    changes: RefCell<Vec<CollectedChange>>,
538    fetch_on_push: Cell<bool>,
539    fetched: RefCell<Vec<Vec<Row>>>,
540    /// When set (via [`Graph::add_change_sink`](crate::graph::Graph::add_change_sink)), every push also records the
541    /// fully-materialized [`CaughtChange`](crate::changes::CaughtChange) tree (nested
542    /// relationships drained) into `caught` — the production change-stream sink the
543    /// `rindle-replica` wrapper consumes. Off for a plain testkit `Collector`, so its
544    /// flat `changes` recording is byte-for-byte unchanged.
545    capture_caught: Cell<bool>,
546    caught: RefCell<Vec<crate::changes::CaughtChange>>,
547}
548
549// ---------------------------------------------------------------------------
550// The multi-port chassis: the Filter sub-graph (`filter-operators.ts`) + the OR
551// fan (`fan-out.ts`/`fan-in.ts`). This is the skeleton **five** operators share
552// — `FilterStart`, `Filter`, `FanOut`, `FanIn`, `FilterEnd` (and, later, `Exists`
553// and `FilterProbe`) — proven once here so spec `07`'s Filter/Exists and spec
554// `06`'s fan land on a known-good frame.
555//
556// Two structural patterns are at stake, neither of which the single-output `Join`
557// exercised:
558//
559//  1. **The `begin_filter`/`filter`/`end_filter` lifecycle** (a `FilterOperator`
560//     *gates* — `filter(node) -> bool` — it does not vend its own rows; the one
561//     row scan happens in `FilterStart::fetch`). The lifecycle brackets that scan
562//     so a stateful link (Exists's per-loop cache) can cache for the loop and
563//     clear after — and `end_filter` MUST run on early stream drop / early
564//     return, which is the RAII (`Drop`) contract (Primitive #2; `07` §6.4). It
565//     also runs on panic ONLY in unwinding builds (`release-server`, tests);
566//     under the shipping `panic = "abort"` client profile a panic aborts and
567//     `Drop` is skipped. See WS02. Dispatched
568//     by `NodeId` through the arena (`07` §4.3 "not a `dyn FilterChain` chain"),
569//     exactly like `fetch`/`push`.
570//
571//  2. **Multi-port wiring**: `FanOut` has one input and **N outputs** (branches);
572//     `FanIn` has **N inputs** and one output. The OR push fans one change to
573//     every branch, then collapses the branches' forwarded changes back to
574//     exactly one (`push_accumulated_changes` — the dedup that stops an OR from
575//     double-counting, `06` §3.5).
576//
577// **Why the accumulation lives on the stack, not in a `FanIn` field.** The JS
578// `FanIn` buffers branch pushes in a `#accumulatedPushes: Change[]` *instance
579// field*. A `Change<'g>` borrows the graph (its relationship thunks are
580// `+ 'g`), so it cannot live in a `'static` `Graph` field without a
581// self-reference. Instead [`Graph::chain_push`](crate::graph::Graph::chain_push) threads the accumulation through
582// the call stack (lifetime `'g`) and `FanIn` is just the branch terminator that
583// hands its change up to the owning `FanOut`. This mirrors Primitive #5's move
584// from JS's live `#inprogressChildChange` field to a by-value capture: the Rust
585// port makes the accumulation lifetime explicit instead of relying on GC.
586
587/// `FilterStart` (`filter-operators.ts:61-104`): adapts a normal `Input` → the
588/// filter chain. Its `fetch` is a NORMAL operator fetch that brackets the input
589/// scan with `begin_filter`/`end_filter` and yields each node iff the chain's
590/// `filter(node)` is true. `chain_head` is the single `FilterOutput` edge — used
591/// for both `filter` (fetch) and `push`; do not split it (`07` §4.3).
592pub(crate) struct FilterStart {
593    pub input: NodeId,
594    pub chain_head: Cell<Option<NodeId>>,
595}
596
597/// `FilterEnd` (`filter-operators.ts:106-146`): adapts the chain → a normal
598/// `Input`. Its `filter()` is always `true` (the chain terminator); its `fetch`
599/// delegates to `start.fetch`; its `push` forwards to the normal downstream
600/// `output`. `start` is the matched [`FilterStart`].
601pub(crate) struct FilterEnd {
602    pub start: NodeId,
603    /// The post-chain downstream **edge** — port-bearing (the `Skip`/`Take`/`Cap`
604    /// template), so a `FilterEnd` can feed a relationship join's `JoinParent` port
605    /// (a `related`/sibling join over a `where` sub-graph) as well as a plain `Single`
606    /// sink. Wired `Single` via [`Graph::set_output`](crate::graph::Graph::set_output), port-bearing via
607    /// [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge).
608    pub output: Cell<Option<OutEdge>>,
609}
610
611/// `FanOut` (`fan-out.ts`): one input, **N outputs** (the OR branches). On
612/// `filter` it is OR (true on the first branch that passes, short-circuit). On
613/// `push` it replays the change to every branch then collapses what they forward
614/// (via the paired `FanIn`'s continuation). A `FilterOperator`, so it has no
615/// `fetch` of its own.
616pub(crate) struct FanOut {
617    pub input: NodeId,
618    /// The branch heads, wired late (two-phase) — interior-mutable.
619    outputs: RefCell<Vec<NodeId>>,
620    /// The paired [`FanIn`] (set with the branches via [`Graph::set_fan`](crate::graph::Graph::set_fan)).
621    fan_in: Cell<Option<NodeId>>,
622}
623
624/// `FanIn` (`fan-in.ts`): **N inputs** (the branch tails all point here), one
625/// output. In the JS it buffers branch pushes; here it is the branch *terminator*
626/// (its `chain_push` hands the change up to the owning `FanOut`, which owns the
627/// accumulation — see the chassis note above). `output` is the post-fan
628/// continuation (the next `FilterOutput`, e.g. `FilterEnd`).
629pub(crate) struct FanIn {
630    pub fan_out: NodeId,
631    output: Cell<Option<NodeId>>,
632}
633
634/// A `Filter` link (`filter.ts`): a stateless predicate gate. `filter(node)` =
635/// `pred(row) && downstream.filter(node)`; `push` is the predicate-gated
636/// `filter_push` with the Edit split (`07` §3.4). The single `output` edge serves
637/// both paths.
638pub(crate) struct Filter {
639    pub input: NodeId,
640    pub pred: CompiledPredicate,
641    output: Cell<Option<NodeId>>,
642}
643
644/// Proof instrumentation: a `FilterChain` link that records its `begin_filter` /
645/// `filter` / `end_filter` calls and passes every node/change through unchanged.
646/// It stands in for a *stateful* filter (the role `Exists` will fill — `Exists`
647/// caches on `begin`, clears on `end`) so the chassis can prove the lifecycle
648/// fires in order AND that `end_filter` runs even when the fetch stream is
649/// abandoned early (the `Drop` guard, `07` §6.4). Not a real query operator.
650pub(crate) struct FilterProbe {
651    pub input: NodeId,
652    output: Cell<Option<NodeId>>,
653    begin_count: Cell<usize>,
654    filter_count: Cell<usize>,
655    end_count: Cell<usize>,
656}
657
658pub(crate) enum Operator {
659    /// A **freed** slot (a torn-down pipeline's operator, [`Graph::destroy_pipeline`](crate::graph::Graph::destroy_pipeline)).
660    /// The slot keeps its arena index (so no other id shifts) but its generation has
661    /// been bumped, so any live access goes through the generation check in
662    /// `Graph::node` and fail-fasts before reaching here; the dispatch panic arms are
663    /// a belt-and-suspenders net. Replacing the old `Operator` with this variant is what
664    /// drops the torn-down operator's heavy state (View tree, Collector buffers,
665    /// compiled predicates, …).
666    Tombstone,
667    Source(SourceLeaf),
668    SourceConn(SourceConn),
669    Join(Join),
670    /// The production materialization sink (`09`): the `Arc`-shared, reference-stable
671    /// `ArrayView` + immutable tree differ. Logic lives in `crate::view`.
672    View(crate::view::View),
673    Collector(Collector),
674    FilterStart(FilterStart),
675    FilterEnd(FilterEnd),
676    FanOut(FanOut),
677    FanIn(FanIn),
678    Filter(Filter),
679    FilterProbe(FilterProbe),
680    // --- spec 06/07 operators, implemented out-of-file (the fan-out seam,
681    // `crate::op`). Each variant's logic lives in its own `op/<name>.rs`; only the
682    // thin wiring (this variant + delegating match arms + an `add_<name>` builder)
683    // is here, so isolated operator agents don't collide in graph.rs. ---
684    Skip(crate::op::Skip),
685    Take(crate::op::Take),
686    Cap(crate::op::Cap),
687    /// A `where`-EXISTS gate (a `FilterChain` link; logic in `op/exists.rs`).
688    Exists(crate::op::Exists),
689    /// The flipped (child-driven) inner join (logic in `op/flipped_join.rs`).
690    FlippedJoin(crate::op::FlippedJoin),
691    /// The node-level OR fan over the source (`op/union.rs`): `UnionFanOut`
692    /// broadcasts a source change to the OR branches; `UnionFanIn` k-way-merges their
693    /// fetches (with PK dedup) and collapses their pushes back to one change.
694    UnionFanOut(crate::op::UnionFanOut),
695    UnionFanIn(crate::op::UnionFanIn),
696    /// A global, invertible aggregate (`op/reduce.rs`, `REDUCE-DESIGN.md`): collapses
697    /// its input into one synthetic aggregate row (v1: `count(*)`).
698    Reduce(crate::op::Reduce),
699    // --- spec 11 test harness sinks/taps, logic out-of-file in `crate::testkit`
700    // (same seam shape as `crate::op`). `Catch` is the output oracle (generalizes
701    // `Collector` to a full nested tree + Child); `Snitch` is the message-log tap. ---
702    #[cfg(any(test, feature = "testkit"))]
703    Catch(crate::testkit::Catch),
704    #[cfg(any(test, feature = "testkit"))]
705    Snitch(crate::testkit::Snitch),
706}
707
708// ---------------------------------------------------------------------------
709// Graph
710// ---------------------------------------------------------------------------
711
712/// The exact arena ids one `build_pipeline` (+ its sink) created — captured by the
713/// graph's recording mode ([`Graph::begin_recording`](crate::graph::Graph::begin_recording)/[`Graph::take_recording`](crate::graph::Graph::take_recording)) so
714/// the pipeline can be torn down precisely with [`Graph::destroy_pipeline`](crate::graph::Graph::destroy_pipeline).
715///
716/// It is a *recorded id list*, not a contiguous range: with slot reuse a query's
717/// nodes can land on scattered recycled slots. The `(source, ConnId)` pairs to
718/// disconnect are recovered from the recorded `SourceConn` nodes, so they need not
719/// be stored separately. Shared `Source` nodes are pre-registered (outside any build),
720/// so they never appear here and are never torn down.
721#[derive(Clone, Debug, Default)]
722pub struct PipelineManifest {
723    nodes: Vec<NodeId>,
724    storage: Vec<StorageId>,
725}
726
727impl PipelineManifest {
728    /// The node ids this pipeline owns (test/inspection access).
729    pub fn nodes(&self) -> &[NodeId] {
730        &self.nodes
731    }
732    /// The storage ids this pipeline owns.
733    pub fn storage(&self) -> &[StorageId] {
734        &self.storage
735    }
736}
737
738/// The two bounds of the join membership pre-check (design 311 §2.5), host-settable via
739/// [`Graph::set_join_precheck_bounds`](crate::graph::Graph::set_join_precheck_bounds).
740///
741/// Every approximation the pre-check makes errs toward *fetching*: a join whose parent
742/// key set would cross `per_join` distinct keys, or whose keys would push the graph's
743/// total past `per_graph`, drops its set and falls back to today's fetch for the rest of
744/// its life. So the bounds cap **memory**, never correctness — O(registered regions), not
745/// O(data) (§6).
746#[derive(Clone, Copy, Debug, PartialEq, Eq)]
747pub struct JoinPrecheckBounds {
748    /// Distinct parent keys one join may track; `None` turns the pre-check **off**
749    /// graph-wide (no observation, no maintenance, no probe — today's path byte for byte).
750    pub per_join: Option<usize>,
751    /// Total distinct keys tracked across every join in the graph. Many joins each near
752    /// their own bound cannot compound past this.
753    pub per_graph: usize,
754}
755
756impl JoinPrecheckBounds {
757    /// The per-join bound the design suggests for the daemon (~400 KB worst case per join).
758    pub const DEFAULT_PER_JOIN: usize = 4096;
759    /// The per-graph budget the design suggests for the daemon (~6.5 MB worst case).
760    pub const DEFAULT_PER_GRAPH: usize = 65_536;
761    /// Pre-check off — today's path byte for byte (no observation, no maintenance, no
762    /// probe). The S1 default of every `Graph` until S2 flipped it (design 311 §9); the
763    /// daemon's `joinPrecheckPerJoin: 0` and the test/testkit/debug-build
764    /// `RINDLE_JOIN_PRECHECK=off` both land here.
765    pub const OFF: JoinPrecheckBounds = JoinPrecheckBounds {
766        per_join: None,
767        per_graph: Self::DEFAULT_PER_GRAPH,
768    };
769    /// Pre-check on at the design's suggested bounds (4096 / 65 536).
770    pub const ON: JoinPrecheckBounds = JoinPrecheckBounds {
771        per_join: Some(Self::DEFAULT_PER_JOIN),
772        per_graph: Self::DEFAULT_PER_GRAPH,
773    };
774}
775
776impl Default for JoinPrecheckBounds {
777    /// [`JoinPrecheckBounds::ON`] — design 311 §9 S2 (2026-09-02): every `Graph` starts
778    /// with the pre-check on at the daemon bounds, so every operator, parity, oracle and
779    /// fuzz lane exercises it without naming it. [`OFF`](Self::OFF) is one
780    /// [`Graph::set_join_precheck_bounds`] call away (the daemon's `joinPrecheckPerJoin: 0`).
781    fn default() -> Self {
782        JoinPrecheckBounds::ON
783    }
784}
785
786/// Per-graph counters for the join membership pre-check (design 311 §8) — the
787/// always-on, per-`Graph` twin of the build-gated process-global `EngineMetrics`
788/// counters (`join_probe_hit` / `join_probe_miss` / `join_precheck_disabled_*`). Plain
789/// `Cell` adds on a `!Send` graph, so they cost nothing measurable.
790#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
791pub struct JoinPrecheckStats {
792    /// Probes that found the key in the region (the fetch ran, as today).
793    pub probe_hits: u64,
794    /// Probes that missed — parent fetches skipped.
795    pub probe_misses: u64,
796    /// Joins disabled because the eligibility walk found a non-root chain (§2.4).
797    pub disabled_ineligible: u64,
798    /// Joins disabled because a bound was crossed while observing a fetch (§2.5).
799    pub disabled_observation: u64,
800    /// Joins disabled because a bound was crossed (or a count went inconsistent) on a
801    /// parent-side push (§2.5).
802    pub disabled_maintenance: u64,
803}
804
805/// A read-only snapshot of one join's parent-key set (`op::join_util::ParentKeySet`) for
806/// inspection and tests ([`Graph::join_precheck_state`](crate::graph::Graph::join_precheck_state)).
807#[derive(Clone, Debug, PartialEq, Eq)]
808pub enum JoinPrecheckState {
809    /// Nothing observed yet, or the pre-check is off.
810    Unbuilt,
811    /// A live region: how many distinct keys it holds, and whether it covers the whole
812    /// parent input (`complete`) or ends at a finite frontier.
813    Active {
814        distinct_keys: usize,
815        complete: bool,
816    },
817    /// Gate tripped or chain ineligible; the join fetches for the rest of its life.
818    Disabled,
819}
820
821#[cfg(any(test, feature = "testkit"))]
822thread_local! {
823    /// Test-only default for every `Graph` built on this thread after the call
824    /// (`set_join_precheck_bounds_for_test`) — the join pre-check analogue of the
825    /// `FlippedJoin` chunk-size seam, so the testkit runners and the fuzz lanes can force
826    /// the pre-check on at a chosen bound without a graph handle.
827    static JOIN_PRECHECK_OVERRIDE: Cell<Option<JoinPrecheckBounds>> = const { Cell::new(None) };
828}
829
830/// Set the [`JoinPrecheckBounds`] every `Graph` constructed on the current thread starts
831/// with (`None` restores the documented default, off). Build-time only: an existing graph
832/// keeps its bounds — use [`Graph::set_join_precheck_bounds`](crate::graph::Graph::set_join_precheck_bounds)
833/// for that. The seam the testkit parity lanes and `rindle-fuzz` use to sweep the bound
834/// (design 311 §7).
835#[cfg(any(test, feature = "testkit"))]
836pub fn set_join_precheck_bounds_for_test(bounds: Option<JoinPrecheckBounds>) {
837    JOIN_PRECHECK_OVERRIDE.set(bounds);
838}
839
840/// Process-wide default from the environment (test, testkit, and **debug** builds only —
841/// never a release artifact): with `RINDLE_JOIN_PRECHECK=<per_join>[,<per_graph>]` set,
842/// every `Graph` on every thread starts with the pre-check at those bounds unless a
843/// thread-local override is active; `RINDLE_JOIN_PRECHECK=off` forces it OFF. Since S2
844/// the unset default is already [`JoinPrecheckBounds::ON`], so this is how an ENTIRE
845/// existing suite — every operator, parity, and fuzz lane — runs at a *stressed* bound
846/// (`RINDLE_JOIN_PRECHECK=1 cargo test --workspace --features testkit`: overflow on nearly
847/// every join, the §2.6 debug soundness net armed on every miss) or on the pre-S2 path
848/// (`RINDLE_JOIN_PRECHECK=off`), without a single test naming the feature. (Debug builds
849/// are included so an integration test binary, which links the non-`cfg(test)` library,
850/// honours it too.)
851#[cfg(any(test, feature = "testkit", debug_assertions))]
852fn join_precheck_env_default() -> JoinPrecheckBounds {
853    static ENV: std::sync::OnceLock<JoinPrecheckBounds> = std::sync::OnceLock::new();
854    *ENV.get_or_init(|| {
855        let Ok(raw) = std::env::var("RINDLE_JOIN_PRECHECK") else {
856            return JoinPrecheckBounds::default();
857        };
858        if raw.trim().eq_ignore_ascii_case("off") {
859            return JoinPrecheckBounds::OFF;
860        }
861        let mut parts = raw.split(',').map(|p| p.trim().parse::<usize>());
862        let per_join = parts.next().and_then(Result::ok);
863        let per_graph = parts
864            .next()
865            .and_then(Result::ok)
866            .unwrap_or(JoinPrecheckBounds::DEFAULT_PER_GRAPH);
867        JoinPrecheckBounds {
868            per_join,
869            per_graph,
870        }
871    })
872}
873
874/// The graph's pre-check knob cell, whose `Default` is the documented default — or, in
875/// test/testkit builds, the thread-local override — so `Graph` keeps `#[derive(Default)]`.
876struct PrecheckKnob(Cell<JoinPrecheckBounds>);
877
878impl Default for PrecheckKnob {
879    fn default() -> Self {
880        #[cfg(any(test, feature = "testkit"))]
881        let bounds = JOIN_PRECHECK_OVERRIDE
882            .get()
883            .unwrap_or_else(join_precheck_env_default);
884        #[cfg(all(not(any(test, feature = "testkit")), debug_assertions))]
885        let bounds = join_precheck_env_default();
886        #[cfg(not(any(test, feature = "testkit", debug_assertions)))]
887        let bounds = JoinPrecheckBounds::default();
888        PrecheckKnob(Cell::new(bounds))
889    }
890}
891
892#[derive(Default)]
893pub struct Graph {
894    nodes: Vec<Operator>,
895    /// Per-slot **generation**, parallel to `nodes`. Bumped when a slot is freed
896    /// ([`Graph::destroy_pipeline`](crate::graph::Graph::destroy_pipeline)); [`Graph::node`](crate::graph::Graph::node) checks it on every access so a
897    /// stale [`NodeId`] to a recycled slot fail-fasts instead of aliasing the new
898    /// tenant.
899    node_gens: Vec<u32>,
900    /// Freed node-slot indices available for reuse (a LIFO free-list). `add` pops one
901    /// before growing `nodes`, so a long-lived graph with query churn recycles slots
902    /// rather than growing without bound.
903    free_nodes: Vec<u32>,
904    /// Parallel arena of per-operator scratch state (foundations §6.4), addressed
905    /// by [`StorageId`]. Held here — not inside the operators — so a stateful
906    /// operator reads its state through a *shared* `&Graph` borrow (like fetch/
907    /// push), and so the backend (memory vs spilled-SQLite) is a graph-level
908    /// choice. Empty for a graph with no stateful operators.
909    storage: Vec<Box<dyn Storage>>,
910    /// Per-storage-slot generation (the `node_gens` analogue for [`StorageId`]).
911    storage_gens: Vec<u32>,
912    /// Freed storage-slot indices available for reuse (the `free_nodes` analogue).
913    free_storage: Vec<u32>,
914    /// When `Some`, every [`Graph::add`](crate::graph::Graph::add)/[`Graph::alloc_storage`](crate::graph::Graph::alloc_storage) appends the id it
915    /// minted here — capturing the exact node/storage ids one `build_pipeline` created
916    /// into a [`PipelineManifest`], so the pipeline can later be torn down precisely.
917    /// Bracketed by [`Graph::begin_recording`](crate::graph::Graph::begin_recording)/[`Graph::take_recording`](crate::graph::Graph::take_recording); `None`
918    /// (off) on the hot path.
919    recording: Option<PipelineManifest>,
920    storage_factory: StorageFactory,
921    /// Opt-in strict change-consistency validation (WS02.2). When `true`, a malformed
922    /// change (ADD of an existing row, REMOVE/EDIT of an absent row) returns a
923    /// [`RindleError::ConsistencyViolation`] from [`Graph::try_source_push`](crate::graph::Graph::try_source_push) — runnable
924    /// in **release**, unlike the default `debug_assert!`. Off by default to preserve
925    /// hot-path parity; a server that doesn't fully trust its change producer turns
926    /// it on. `Cell` so it is settable on a built `&Graph`.
927    validate_changes: Cell<bool>,
928    /// Graph-level runtime-error sink (WS02.2 / the WS02.3 prelude). A failure raised
929    /// deep in the push fan-out — where the call stack cannot thread a `Result` back
930    /// up — parks here (e.g. a strict join-key consistency violation); the existing
931    /// [`take_runtime_error`](Self::take_runtime_error) drains it at the mutation
932    /// boundary (`try_source_push`/`try_hydrate` already call it). `Rc<RefCell<…>>` so
933    /// a future `OpStorage` (WS02.3) can hold a clone of the same sink.
934    runtime_error: Rc<RefCell<Option<RindleError>>>,
935    /// Host-armed wall-clock deadline for the CURRENT push (FOLLOWER-LAG-SHED §6.6 —
936    /// the runaway-push bail). The unbounded fan-out loops (join child fan-out, take
937    /// backfill/refill) test it every [`DEADLINE_CHECK_EVERY`] iterations and park a
938    /// [`RindleError::PushDeadlineExceeded`] on expiry — converting today's
939    /// stuck-worker detach-and-leak into a clean, prompt, single-push fault. `None`
940    /// (never armed) on the wasm client, whose checkpoints therefore never call
941    /// `Instant::now()` (which traps on wasm32-unknown-unknown) — no feature gate
942    /// needed, this crate stays std-only. `Cell` so the host arms it per push on
943    /// a built `&Graph` (mirrors `validate_changes`).
944    push_deadline: Cell<Option<std::time::Instant>>,
945    /// Shared amortization counter for the armed deadline (§6.6). The fan-out loops (join child
946    /// fan-out, take backfill/refill) increment THIS on every iteration and consult the clock only
947    /// every [`DEADLINE_CHECK_EVERY`]-th tick — so the deadline bounds the WHOLE armed derive (a
948    /// batch's many changes, each with its own small fan-out loop), not just one loop in isolation.
949    /// Reset to 0 by [`set_push_deadline`](Self::set_push_deadline) each time the host (re)arms.
950    /// A prior revision counted in a fan-out-loop-LOCAL `u32` reset per call, so the checkpoint
951    /// never accumulated across the many small changes of a bulk write — the clock was never
952    /// consulted and the deadline silently never fired for that shape. `Cell` (single-thread
953    /// engine) so it is bumped through a shared `&Graph` borrow deep in the reentrant fan-out.
954    push_deadline_ticks: Cell<u32>,
955    /// The join membership pre-check's bounds (design 311 §2.5 / §8). `per_join == None`
956    /// (the default) is OFF: no join observes, maintains, or probes. Settable on a built
957    /// `&Graph` like `validate_changes`; a change resets every join's set to `Unbuilt`.
958    join_precheck: PrecheckKnob,
959    /// Distinct parent keys currently tracked across every join (the `per_graph` budget's
960    /// live total). Charged on a set's first sight of a key, released at zero count, on
961    /// `Disabled`, on a knob reset, and on pipeline teardown.
962    join_precheck_tracked: Cell<usize>,
963    /// Per-graph pre-check counters ([`JoinPrecheckStats`]).
964    join_precheck_stats: Cell<JoinPrecheckStats>,
965}
966
967impl Graph {
968    pub fn new() -> Graph {
969        Graph::default()
970    }
971
972    pub fn with_storage_factory(storage_factory: StorageFactory) -> Graph {
973        Graph {
974            storage_factory,
975            ..Graph::default()
976        }
977    }
978
979    /// Enable/disable strict change-consistency validation (WS02.2). Off by default.
980    /// When on, [`try_source_push`](Self::try_source_push) returns a
981    /// [`RindleError::ConsistencyViolation`] (instead of a release-stripped
982    /// `debug_assert!`) on a malformed change stream.
983    pub fn set_validate_changes(&self, on: bool) {
984        self.validate_changes.set(on);
985    }
986
987    /// Whether strict change validation is currently enabled.
988    pub fn validate_changes(&self) -> bool {
989        self.validate_changes.get()
990    }
991
992    /// Arm (or clear) the wall-clock deadline for the CURRENT push (FOLLOWER-LAG-SHED §6.6).
993    /// A host that bounds per-push derive time arms `now + P` around each push and clears it
994    /// after; on expiry the fan-out checkpoints park a
995    /// [`RindleError::PushDeadlineExceeded`] and stop iterating — the push surfaces `Err`
996    /// from [`try_source_push`](Self::try_source_push) with torn operator state, which the
997    /// host discards (the same abort≙epoch-rehydrate contract a panic uses). Never arm this
998    /// on wasm: the expiry check calls `Instant::now()`, which traps there.
999    pub fn set_push_deadline(&self, deadline: Option<std::time::Instant>) {
1000        self.push_deadline.set(deadline);
1001        // Reset the amortization counter so the checkpoint interval is measured from THIS arm and
1002        // ticks accumulate across the whole armed derive — not per fan-out loop (see the field doc).
1003        self.push_deadline_ticks.set(0);
1004    }
1005
1006    /// Amortized deadline checkpoint for the unbounded fan-out loops (§6.6): counts every call in
1007    /// the graph-level [`push_deadline_ticks`](Self::push_deadline_ticks) — SHARED across the whole
1008    /// armed window, so a batch of many small-fan-out changes accumulates toward the checkpoint
1009    /// instead of resetting per loop — and consults the clock only every [`DEADLINE_CHECK_EVERY`]-th
1010    /// call, and only when a deadline is armed, so the wasm client (which never arms one) never
1011    /// reaches `Instant::now()`.
1012    #[inline]
1013    pub(crate) fn push_deadline_exceeded(&self) -> bool {
1014        let ticks = self.push_deadline_ticks.get().wrapping_add(1);
1015        self.push_deadline_ticks.set(ticks);
1016        if !ticks.is_multiple_of(DEADLINE_CHECK_EVERY) {
1017            return false;
1018        }
1019        match self.push_deadline.get() {
1020            None => false,
1021            Some(deadline) => std::time::Instant::now() >= deadline,
1022        }
1023    }
1024
1025    fn add(&mut self, op: Operator) -> NodeId {
1026        // Reuse a freed slot if one is available (its generation was already bumped at
1027        // free time, so the returned id is distinct from the slot's prior tenant);
1028        // otherwise grow the arena with a fresh generation-0 slot.
1029        let id = if let Some(idx) = self.free_nodes.pop() {
1030            let i = idx as usize;
1031            self.nodes[i] = op;
1032            NodeId::new(idx, self.node_gens[i])
1033        } else {
1034            let idx = self.nodes.len() as u32;
1035            self.nodes.push(op);
1036            self.node_gens.push(0);
1037            NodeId::new(idx, 0)
1038        };
1039        if let Some(rec) = self.recording.as_mut() {
1040            rec.nodes.push(id);
1041        }
1042        id
1043    }
1044
1045    /// Begin capturing a [`PipelineManifest`]: subsequent `Graph::add` /
1046    /// [`Graph::alloc_storage`](crate::graph::Graph::alloc_storage) record the ids they mint. Bracket one `build_pipeline`
1047    /// (+ its sink) with this and [`Graph::take_recording`](crate::graph::Graph::take_recording) to get the exact id set to
1048    /// hand [`Graph::destroy_pipeline`](crate::graph::Graph::destroy_pipeline). # Panics (debug) if recording is already on.
1049    pub fn begin_recording(&mut self) {
1050        debug_assert!(self.recording.is_none(), "recording already in progress");
1051        self.recording = Some(PipelineManifest::default());
1052    }
1053
1054    /// Stop recording and return the captured [`PipelineManifest`] (empty if recording
1055    /// was not active).
1056    pub fn take_recording(&mut self) -> PipelineManifest {
1057        self.recording.take().unwrap_or_default()
1058    }
1059
1060    /// Allocate a fresh scratch-state slot for a stateful operator and return its
1061    /// [`StorageId`] (the builder, `08` §5, calls this and embeds the id in the
1062    /// `Take`/`Cap`/`Exists` struct). On the client/test path a fresh
1063    /// [`MemoryStorage`](crate::storage::MemoryStorage) *is* the namespace (`10`
1064    /// §3.3 / §4.5): each slot is a disjoint keyspace because it is a distinct
1065    /// store. On the SQLite server path, a graph constructed with
1066    /// `StorageFactory::sqlite` vends an `op_id`-namespaced `OpStorage` instead
1067    /// (`10` §4.4).
1068    pub fn alloc_storage(&mut self) -> StorageId {
1069        // Reuse a freed storage slot if one is available — its store was `clear`ed at
1070        // free time, so it is already an empty namespace ready for the new operator;
1071        // otherwise grow the arena. Thread the graph's runtime-error sink into a
1072        // freshly-created store so a SQLite operator-storage failure parks a `RindleError`
1073        // (drained by `take_runtime_error`) instead of `.expect`-aborting (WS02.3).
1074        let id = if let Some(idx) = self.free_storage.pop() {
1075            let i = idx as usize;
1076            StorageId {
1077                idx,
1078                gen: self.storage_gens[i],
1079            }
1080        } else {
1081            let idx = self.storage.len() as u32;
1082            self.storage.push(
1083                self.storage_factory
1084                    .create_storage_with_sink(self.runtime_error.clone()),
1085            );
1086            self.storage_gens.push(0);
1087            StorageId { idx, gen: 0 }
1088        };
1089        if let Some(rec) = self.recording.as_mut() {
1090            rec.storage.push(id);
1091        }
1092        id
1093    }
1094
1095    /// Borrow a stateful operator's scratch store (the read side of
1096    /// [`Graph::alloc_storage`](crate::graph::Graph::alloc_storage)). `&dyn Storage` so operator code is
1097    /// backend-agnostic; the store's own methods take `&self` (interior
1098    /// mutability), so this composes with the reentrant shared-borrow push.
1099    // `allow(dead_code)`: consumed by the stateful operators (`Take`/`Cap`/
1100    // `Exists`, spec `07`) once they land; exercised today by the arena test.
1101    #[allow(dead_code)]
1102    pub(crate) fn storage(&self, id: StorageId) -> &dyn Storage {
1103        let i = id.ix();
1104        assert!(
1105            self.storage_gens[i] == id.gen,
1106            "stale StorageId {id:?} (slot generation is {})",
1107            self.storage_gens[i]
1108        );
1109        &*self.storage[i]
1110    }
1111
1112    /// The generation-checked operator lookup — the single read path into the node
1113    /// arena. Every dispatch/accessor goes through here, so a stale [`NodeId`] (an old
1114    /// generation for a slot a torn-down pipeline freed and a newer one recycled) is a
1115    /// loud fail-fast, never a silent alias. Checked in release too (the stale-handle
1116    /// safety net is the whole point of the generation).
1117    #[inline]
1118    fn node(&self, id: NodeId) -> &Operator {
1119        let i = id.ix();
1120        assert!(
1121            self.node_gens[i] == id.gen,
1122            "stale NodeId {id:?} (slot generation is {})",
1123            self.node_gens[i]
1124        );
1125        &self.nodes[i]
1126    }
1127
1128    /// Snapshot **all** operator scratch storage, one entry per [`StorageId`] in
1129    /// allocation order, each a full ascending `scan("")` of `(key, value)` pairs.
1130    /// The test-only sink-independence probe (spec `11` §3.1.1): two graphs built
1131    /// from the *same* `Ast` allocate storage in the same order, so snapshot index
1132    /// `i` denotes the same operator's store in both — letting
1133    /// `run_push_test_ast_view` assert
1134    /// `Catch`-sink storage equals `View`-sink storage (operator state is identical
1135    /// regardless of sink). `StorageValue` has no `PartialEq`, so the comparison is
1136    /// spelled out at the testkit boundary.
1137    pub fn storage_snapshot(&self) -> Vec<Vec<(Box<str>, crate::storage::StorageValue)>> {
1138        self.storage.iter().map(|s| s.scan("").collect()).collect()
1139    }
1140
1141    /// The number of `(key, value)` entries in the operator scratch storage **one
1142    /// pipeline** owns — the leak probe (`take_partition_leak.rs`'s assertion, and the
1143    /// family unbind's "no zombie slot" check of design 310 impl plan D5), keyed by the
1144    /// [`PipelineManifest`] captured when the pipeline was built rather than by the
1145    /// whole arena, so a graph hosting several pipelines can be probed one at a time.
1146    pub fn storage_entries_of(&self, manifest: &PipelineManifest) -> usize {
1147        manifest
1148            .storage()
1149            .iter()
1150            .map(|&sid| self.storage(sid).scan("").count())
1151            .sum()
1152    }
1153
1154    /// Every `(slot, key, value)` of one pipeline's operator scratch storage — the
1155    /// diagnostic twin of [`storage_entries_of`](Self::storage_entries_of), for the
1156    /// failure message when a leak probe finds entries it did not expect. `slot` indexes
1157    /// the manifest's storage list (a stable, build-order name for the operator's store).
1158    pub fn storage_dump_of(&self, manifest: &PipelineManifest) -> Vec<(usize, String, String)> {
1159        manifest
1160            .storage()
1161            .iter()
1162            .enumerate()
1163            .flat_map(|(i, &sid)| {
1164                self.storage(sid)
1165                    .scan("")
1166                    .map(move |(k, v)| (i, k.to_string(), format!("{v:?}")))
1167                    .collect::<Vec<_>>()
1168            })
1169            .collect()
1170    }
1171
1172    /// Total node slots in the arena, **including tombstoned (freed) slots**. A
1173    /// teardown does not shrink this; a subsequent build reuses freed slots, so this
1174    /// staying flat across a destroy+rebuild is the observable proof of slot reuse.
1175    pub fn node_count(&self) -> usize {
1176        self.nodes.len()
1177    }
1178
1179    /// Total storage slots in the arena, including freed (cleared) ones (the
1180    /// [`node_count`](Self::node_count) analogue for [`StorageId`]).
1181    pub fn storage_count(&self) -> usize {
1182        self.storage.len()
1183    }
1184
1185    /// Tear down one pipeline, identified by the [`PipelineManifest`] captured when it
1186    /// was built (recording mode). Two passes:
1187    ///
1188    /// 1. **Disconnect** every `SourceConn` the pipeline owns via
1189    ///    [`Source::destroy`], which nulls that connection's output edge. Because the
1190    ///    push fan-out only drives connections whose output is `Some`
1191    ///    ([`Graph::try_source_push`](crate::graph::Graph::try_source_push)), this orphans the whole subgraph from every
1192    ///    future source push. The shared [`Source`] itself — pre-registered, outside
1193    ///    the manifest — is untouched: its indexes and connection slots persist (§3.10).
1194    /// 2. **Reclaim** each storage slot (`clear` its contents) and each node slot
1195    ///    (replace the operator with `Operator::Tombstone`, dropping its heavy state:
1196    ///    View tree, Collector buffers, compiled predicates, …). Both bump the slot's
1197    ///    generation and push it onto the free-list, so the slot is recycled by the
1198    ///    next `add`/`alloc_storage` while every *other* pipeline's ids stay valid.
1199    ///
1200    /// Idempotency / safety: each slot's current generation is asserted to match the
1201    /// manifest before freeing, so a double teardown (or a corrupted manifest) is a
1202    /// loud fail-fast rather than silent corruption. Runs under `&mut self`, so it can
1203    /// only be called between transactions, never mid-push.
1204    ///
1205    /// **Known limitation (deferred):** the shared source's per-connection slot is
1206    /// *nulled* (step 1) but not freed — `ConnId`s are not yet generational, so a
1207    /// source's `conns` Vec grows by one small record per teardown. Under sustained
1208    /// build/destroy churn against a long-lived source this is a slow, bounded-per-query
1209    /// leak (no correctness impact: the fan-out skips nulled connections). Reclaiming it
1210    /// needs a generational `ConnId` + per-source free-list — a planned fast-follow.
1211    pub fn destroy_pipeline(&mut self, manifest: &PipelineManifest) {
1212        // Pass 1: collect the (source, connection) pairs first — pass 2 tombstones the
1213        // SourceConn nodes, which would lose this information.
1214        let conns: Vec<(NodeId, ConnId)> = manifest
1215            .nodes
1216            .iter()
1217            .filter_map(|&nid| match self.node(nid) {
1218                Operator::SourceConn(sc) => Some((sc.source, sc.conn)),
1219                _ => None,
1220            })
1221            .collect();
1222        for (source, conn) in conns {
1223            self.source(source).destroy(conn);
1224        }
1225
1226        // Pass 2a: clear + free each storage slot. The generation guard is a release
1227        // `assert!` (not `debug_assert!`): freeing a slot pushes it onto the free-list,
1228        // so a stale/double-freed id here would silently recycle a live store into two
1229        // owners. This runs once per slot at teardown (off any per-row path), so the
1230        // check is free; it must hold in release to match the docstring's guarantee.
1231        for &sid in &manifest.storage {
1232            let i = sid.ix();
1233            assert!(
1234                self.storage_gens[i] == sid.gen,
1235                "destroy_pipeline: stale StorageId {sid:?} in manifest (double free?)"
1236            );
1237            self.storage[i].clear();
1238            self.storage_gens[i] = self.storage_gens[i].wrapping_add(1);
1239            self.free_storage.push(i as u32);
1240        }
1241
1242        // Pass 2b: tombstone + free each node slot (same release-checked guard).
1243        for &nid in &manifest.nodes {
1244            let i = nid.ix();
1245            assert!(
1246                self.node_gens[i] == nid.gen,
1247                "destroy_pipeline: stale NodeId {nid:?} in manifest (double free?)"
1248            );
1249            // A torn-down join gives its tracked parent keys back to the graph-wide
1250            // pre-check budget (design 311 §2.5) before its set is dropped with the operator.
1251            if let Operator::Join(j) = &self.nodes[i] {
1252                j.precheck.borrow_mut().reset(&self.join_precheck_tracked);
1253            }
1254            self.nodes[i] = Operator::Tombstone;
1255            self.node_gens[i] = self.node_gens[i].wrapping_add(1);
1256            self.free_nodes.push(i as u32);
1257        }
1258    }
1259
1260    // --- construction helpers (mirror builder.ts wiring: two-phase, by id) ---
1261
1262    /// Add an in-memory source. # Panics if a row's width does not match the schema;
1263    /// prefer [`try_add_source`](Self::try_add_source) in production (WS02.6).
1264    pub fn add_source(&mut self, schema: SourceSchema, initial: Vec<Row>) -> NodeId {
1265        self.add(Operator::Source(SourceLeaf::Memory(MemorySource::new(
1266            schema.into_schema(),
1267            initial,
1268        ))))
1269    }
1270
1271    /// Fallible [`add_source`](Self::add_source): validates ingest row widths and
1272    /// returns a [`RindleError::SchemaViolation`] instead of panicking (WS02.4).
1273    pub fn try_add_source(
1274        &mut self,
1275        schema: SourceSchema,
1276        initial: Vec<Row>,
1277    ) -> Result<NodeId, RindleError> {
1278        let source = MemorySource::try_new(schema.into_schema(), initial)?;
1279        Ok(self.add(Operator::Source(SourceLeaf::Memory(source))))
1280    }
1281
1282    pub fn add_memory_source(&mut self, source: MemorySource) -> NodeId {
1283        self.add(Operator::Source(SourceLeaf::Memory(source)))
1284    }
1285
1286    /// Add an external [`Source`] erased behind a trait object — e.g. the SQLite
1287    /// `TableSource` from the `rindle-sqlite` crate. The in-memory backend uses the
1288    /// concrete [`add_source`](Self::add_source)/[`add_memory_source`](Self::add_memory_source).
1289    pub fn add_dyn_source(&mut self, source: Box<dyn Source>) -> NodeId {
1290        self.add(Operator::Source(SourceLeaf::Dyn(source)))
1291    }
1292
1293    /// Remove an in-memory source added by [`add_source`](Self::add_source) /
1294    /// [`try_add_source`](Self::try_add_source): tombstone its node and free the slot (bump
1295    /// generation + free-list) — the single-node analogue of [`destroy_pipeline`](Self::destroy_pipeline).
1296    /// The inverse of `add_source`, for a synthetic table whose last reading query is gone
1297    /// (`AGGREGATE-SYNC-DESIGN.md` §4). Errors with a [`RindleError`] if `source` is not an
1298    /// in-memory source node, or if it still has a live connection — every pipeline reading it
1299    /// must be torn down first (`destroy_pipeline`), which the JS backend guarantees by
1300    /// refcounting readers.
1301    pub fn remove_source(&mut self, source: NodeId) -> Result<(), RindleError> {
1302        let live = match self.node(source) {
1303            Operator::Source(SourceLeaf::Memory(ms)) => ms.live_conn_count(),
1304            Operator::Source(SourceLeaf::Dyn(_)) => {
1305                return Err(RindleError::schema_violation(
1306                    "remove_source: external (dyn) sources are not removable".to_string(),
1307                ))
1308            }
1309            _ => {
1310                return Err(RindleError::schema_violation(format!(
1311                    "remove_source: {source:?} is not a source node"
1312                )))
1313            }
1314        };
1315        if live != 0 {
1316            return Err(RindleError::schema_violation(format!(
1317                "remove_source: {source:?} still has {live} live connection(s) — destroy readers first"
1318            )));
1319        }
1320        let i = source.ix();
1321        assert!(
1322            self.node_gens[i] == source.gen,
1323            "remove_source: stale NodeId {source:?} (slot generation is {})",
1324            self.node_gens[i]
1325        );
1326        self.nodes[i] = Operator::Tombstone;
1327        self.node_gens[i] = self.node_gens[i].wrapping_add(1);
1328        self.free_nodes.push(i as u32);
1329        Ok(())
1330    }
1331
1332    /// Create a new connection (output) on a source and return its `SourceConn`
1333    /// node id. Self-joins call this twice on the same source.
1334    pub fn connect(
1335        &mut self,
1336        source: NodeId,
1337        sort: Option<Sort>,
1338        filters: Option<ConnectionFilters>,
1339        split_edit_keys: Vec<ColId>,
1340    ) -> NodeId {
1341        let conn = self.source(source).connect(sort, filters, split_edit_keys);
1342        self.add(Operator::SourceConn(SourceConn {
1343            source,
1344            conn,
1345            family: None,
1346            output: Cell::new(None),
1347        }))
1348    }
1349
1350    /// Mark `conn` (a `SourceConn`) as the root connection of a parameterized query
1351    /// family (design 310 §4.1): the fetch path then filters committed rows by
1352    /// membership in `bindings` on `param_cols` and checks that every fetch is
1353    /// constrained (§4.4). Called by the family builder right after `connect`.
1354    pub(crate) fn set_family_root(
1355        &mut self,
1356        conn: NodeId,
1357        bindings: Rc<BindingSet>,
1358        param_cols: Vec<ColId>,
1359    ) {
1360        let i = conn.ix();
1361        assert!(
1362            self.node_gens[i] == conn.gen,
1363            "stale NodeId {conn:?} (slot generation is {})",
1364            self.node_gens[i]
1365        );
1366        match &mut self.nodes[i] {
1367            Operator::SourceConn(sc) => {
1368                sc.family = Some(Box::new(FamilyRootConn {
1369                    bindings,
1370                    param_cols,
1371                }))
1372            }
1373            _ => panic!("set_family_root: {conn:?} is not a SourceConn"),
1374        }
1375    }
1376
1377    /// Wire a hierarchical join with an **already-resolved** [`RelId`] slot. The builder
1378    /// (`08`) resolves the relationship name against its *query-local* slot tree —
1379    /// computed from the query AST, not the shared source schema's declared
1380    /// relationships (which a query can't pre-declare for synthesized EXISTS-gate
1381    /// aliases like `comments_0`) — and passes the slot directly, so the join is not
1382    /// re-resolved against `input_schema(parent)`.
1383    pub fn add_join_slot(
1384        &mut self,
1385        parent: NodeId,
1386        child: NodeId,
1387        parent_key: Vec<ColId>,
1388        child_key: Vec<ColId>,
1389        rel_slot: RelId,
1390    ) -> NodeId {
1391        self.add(Operator::Join(Join {
1392            parent,
1393            child,
1394            parent_key,
1395            child_key,
1396            rel_slot,
1397            output: Cell::new(None),
1398            inprogress_overlay: RefCell::new(None),
1399            inprogress_position: RefCell::new(None),
1400            precheck: RefCell::new(ParentKeySet::Unbuilt),
1401            precheck_eligible: Cell::new(None),
1402        }))
1403    }
1404
1405    /// The flipped, child-driven inner join with an **already-resolved** [`RelId`]
1406    /// slot — the flipped analogue of [`Graph::add_join_slot`](crate::graph::Graph::add_join_slot), for the builder's
1407    /// query-local slot resolution. The flipped join outputs parent rows with the
1408    /// child relationship attached; wire its parent/child inputs with
1409    /// [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge) (`JoinParent`/`JoinChild`).
1410    pub fn add_flipped_join_slot(
1411        &mut self,
1412        parent: NodeId,
1413        child: NodeId,
1414        parent_key: Vec<ColId>,
1415        child_key: Vec<ColId>,
1416        rel_slot: RelId,
1417    ) -> NodeId {
1418        self.add(Operator::FlippedJoin(crate::op::FlippedJoin::new(
1419            parent, child, parent_key, child_key, rel_slot,
1420        )))
1421    }
1422
1423    /// [`Graph::add_flipped_join_slot`](crate::graph::Graph::add_flipped_join_slot) with an explicit IN-batch chunk size — the
1424    /// test seam analogous to the JS `setMultiConstraintChunkSizeForTest`
1425    /// (`flipped-join.ts:57`). A small size forces the chunked fetch path (per-window
1426    /// fetch + node-level k-way merge) so it can be diffed against the unchunked path.
1427    pub fn add_flipped_join_slot_with_chunk_size(
1428        &mut self,
1429        parent: NodeId,
1430        child: NodeId,
1431        parent_key: Vec<ColId>,
1432        child_key: Vec<ColId>,
1433        rel_slot: RelId,
1434        chunk_size: usize,
1435    ) -> NodeId {
1436        self.add(Operator::FlippedJoin(
1437            crate::op::FlippedJoin::new(parent, child, parent_key, child_key, rel_slot)
1438                .with_chunk_size(chunk_size),
1439        ))
1440    }
1441
1442    /// Add a production [`View`](crate::view::View) over `input` with the default
1443    /// `ResultType::Complete` (the server/test default). For explicit `with_ids` or a
1444    /// pending result type use [`Graph::add_view_with`](crate::graph::Graph::add_view_with). The view shape is carried by
1445    /// `schema` (a relationship is in-view iff its `RelDef` has a child schema).
1446    pub fn add_view(&mut self, input: NodeId, schema: Schema) -> NodeId {
1447        self.add(Operator::View(crate::view::View::new(
1448            input,
1449            schema,
1450            false,
1451            crate::view::ResultType::Complete,
1452        )))
1453    }
1454
1455    /// Add a production [`View`](crate::view::View) with explicit `with_ids` and initial
1456    /// [`ResultType`](crate::view::ResultType). The view shape is carried by `schema`.
1457    pub fn add_view_with(
1458        &mut self,
1459        input: NodeId,
1460        schema: Schema,
1461        with_ids: bool,
1462        result_type: crate::view::ResultType,
1463    ) -> NodeId {
1464        self.add(Operator::View(crate::view::View::new(
1465            input,
1466            schema,
1467            with_ids,
1468            result_type,
1469        )))
1470    }
1471
1472    /// Add an out-of-file operator (the fan-out seam). The builder takes a
1473    /// fully-constructed [`Skip`](crate::op::Skip), so growing `Skip`'s fields
1474    /// never touches this method. Wire its downstream with [`Graph::set_output`](crate::graph::Graph::set_output).
1475    /// (`Take`/`Cap`/`Exists`/`FlippedJoin`/`Union*` get an identical `add_*`.)
1476    pub fn add_skip(&mut self, skip: crate::op::Skip) -> NodeId {
1477        self.add(Operator::Skip(skip))
1478    }
1479
1480    /// Add a [`Take`](crate::op::Take) (the `LIMIT` operator). Like
1481    /// [`Graph::add_skip`](crate::graph::Graph::add_skip), takes a fully-constructed value (the builder hands it a
1482    /// [`StorageId`] from [`Graph::alloc_storage`](crate::graph::Graph::alloc_storage)); wire its downstream with
1483    /// [`Graph::set_output`](crate::graph::Graph::set_output) (terminal sink) or [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge) (feeding a
1484    /// relationship join's parent port).
1485    pub fn add_take(&mut self, take: crate::op::Take) -> NodeId {
1486        self.add(Operator::Take(take))
1487    }
1488
1489    /// Add a [`Cap`](crate::op::Cap) (the unordered EXISTS-child limiter). Same
1490    /// shape as [`Graph::add_take`](crate::graph::Graph::add_take): a fully-constructed value carrying its
1491    /// [`StorageId`]; wire its downstream with [`Graph::set_output`](crate::graph::Graph::set_output) /
1492    /// [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge).
1493    pub fn add_cap(&mut self, cap: crate::op::Cap) -> NodeId {
1494        self.add(Operator::Cap(cap))
1495    }
1496
1497    /// Add a [`Reduce`](crate::op::Reduce) (an invertible aggregate, `REDUCE-DESIGN.md`).
1498    /// Same shape as [`Graph::add_take`](crate::graph::Graph::add_take): a fully-constructed value carrying its
1499    /// [`StorageId`]. Unlike `Take`, `Reduce` reshapes its row (output is a synthetic
1500    /// aggregate row), so it carries its own output schema. Wire its downstream with
1501    /// [`Graph::set_output`](crate::graph::Graph::set_output).
1502    pub fn add_reduce(&mut self, reduce: crate::op::Reduce) -> NodeId {
1503        self.add(Operator::Reduce(reduce))
1504    }
1505
1506    /// Add an [`Exists`](crate::op::Exists) gate (a `FilterChain` link). Wire its
1507    /// upstream `FilterStart`'s chain head to it ([`Graph::set_chain_head`](crate::graph::Graph::set_chain_head)) and its
1508    /// downstream `FilterOutput` with [`Graph::set_output`](crate::graph::Graph::set_output).
1509    pub fn add_exists(&mut self, exists: crate::op::Exists) -> NodeId {
1510        self.add(Operator::Exists(exists))
1511    }
1512
1513    pub fn add_collector(&mut self, input: NodeId) -> NodeId {
1514        self.add(Operator::Collector(Collector {
1515            input,
1516            changes: RefCell::new(Vec::new()),
1517            fetch_on_push: Cell::new(false),
1518            fetched: RefCell::new(Vec::new()),
1519            capture_caught: Cell::new(false),
1520            caught: RefCell::new(Vec::new()),
1521        }))
1522    }
1523
1524    /// Add a **change-stream sink**: a terminal sink that records the
1525    /// fully-materialized [`CaughtChange`](crate::changes::CaughtChange) tree of every
1526    /// change pushed to it (nested relationships drained eagerly). Wire it with
1527    /// [`set_sink_edge`](Self::set_sink_edge); drain per-transaction events with
1528    /// [`take_sink_changes`](Self::take_sink_changes) and get the initial hydration set
1529    /// with [`try_hydrate_change_sink`](Self::try_hydrate_change_sink). Implemented as a
1530    /// `Collector` with caught-capture enabled, so no new operator variant (and no
1531    /// change to the dispatch/fetch/schema match arms) is required.
1532    pub fn add_change_sink(&mut self, input: NodeId) -> NodeId {
1533        self.add(Operator::Collector(Collector {
1534            input,
1535            changes: RefCell::new(Vec::new()),
1536            fetch_on_push: Cell::new(false),
1537            fetched: RefCell::new(Vec::new()),
1538            capture_caught: Cell::new(true),
1539            caught: RefCell::new(Vec::new()),
1540        }))
1541    }
1542
1543    // --- spec 11 testkit sinks/taps (the seam, `crate::testkit`) ---
1544
1545    /// The output oracle [`Catch`](crate::testkit::Catch). Sink it on the operator
1546    /// under test, then wire the push edge with [`Graph::set_sink_edge`](crate::graph::Graph::set_sink_edge) (the
1547    /// runner does both). `fetch_on_push` records a re-fetch alongside each push.
1548    #[cfg(any(test, feature = "testkit"))]
1549    pub fn add_catch(&mut self, input: NodeId, fetch_on_push: bool) -> NodeId {
1550        self.add(Operator::Catch(crate::testkit::Catch::new(
1551            input,
1552            fetch_on_push,
1553        )))
1554    }
1555
1556    /// The message-log tap [`Snitch`](crate::testkit::Snitch) over `input`. Wire
1557    /// its single downstream with [`Graph::set_output`](crate::graph::Graph::set_output); read the log with
1558    /// [`Graph::snitch_log`](crate::graph::Graph::snitch_log).
1559    #[cfg(any(test, feature = "testkit"))]
1560    pub fn add_snitch(&mut self, input: NodeId, name: impl Into<String>) -> NodeId {
1561        self.add(Operator::Snitch(crate::testkit::Snitch::new(input, name)))
1562    }
1563
1564    // --- the multi-port chassis: filter sub-graph + OR fan ---
1565
1566    /// A `FilterStart` over `input` (the upstream normal `Input`). Wire its single
1567    /// chain edge with [`Graph::set_chain_head`](crate::graph::Graph::set_chain_head).
1568    pub fn add_filter_start(&mut self, input: NodeId) -> NodeId {
1569        self.add(Operator::FilterStart(FilterStart {
1570            input,
1571            chain_head: Cell::new(None),
1572        }))
1573    }
1574
1575    /// A `FilterEnd` paired with `start`. Wire its downstream with
1576    /// [`Graph::set_output`](crate::graph::Graph::set_output).
1577    pub fn add_filter_end(&mut self, start: NodeId) -> NodeId {
1578        self.add(Operator::FilterEnd(FilterEnd {
1579            start,
1580            output: Cell::new(None),
1581        }))
1582    }
1583
1584    /// A `FanOut` over `input`. Wire its branches + paired `FanIn` with
1585    /// [`Graph::set_fan`](crate::graph::Graph::set_fan).
1586    pub fn add_fan_out(&mut self, input: NodeId) -> NodeId {
1587        self.add(Operator::FanOut(FanOut {
1588            input,
1589            outputs: RefCell::new(Vec::new()),
1590            fan_in: Cell::new(None),
1591        }))
1592    }
1593
1594    /// A `FanIn` paired with `fan_out`. Wire its post-fan continuation with
1595    /// [`Graph::set_output`](crate::graph::Graph::set_output).
1596    pub fn add_fan_in(&mut self, fan_out: NodeId) -> NodeId {
1597        self.add(Operator::FanIn(FanIn {
1598            fan_out,
1599            output: Cell::new(None),
1600        }))
1601    }
1602
1603    /// A `UnionFanOut` over `input` (the node-level OR fan-out). Wire its branch
1604    /// broadcast edges + paired `UnionFanIn` with [`Graph::set_union_fan`](crate::graph::Graph::set_union_fan).
1605    pub fn add_union_fan_out(&mut self, input: NodeId) -> NodeId {
1606        self.add(Operator::UnionFanOut(crate::op::UnionFanOut::new(input)))
1607    }
1608
1609    /// A `UnionFanIn` paired with `fan_out` over the branch tails `inputs`, carrying
1610    /// the merged branch `schema` (it owns the output schema; the `sort` must be
1611    /// defined) and the per-branch **pushable constraints** (parallel to `inputs`) the
1612    /// fan-in merges into each branch fetch. Wire its post-fan continuation with
1613    /// [`Graph::set_output`](crate::graph::Graph::set_output).
1614    pub fn add_union_fan_in(
1615        &mut self,
1616        fan_out: NodeId,
1617        inputs: Vec<NodeId>,
1618        branch_constraints: Vec<crate::change::Constraint>,
1619        schema: Schema,
1620    ) -> NodeId {
1621        self.add(Operator::UnionFanIn(crate::op::UnionFanIn::new(
1622            fan_out,
1623            inputs,
1624            branch_constraints,
1625            schema,
1626        )))
1627    }
1628
1629    /// A `Filter` link over `input` with predicate `pred`. Wire its single
1630    /// `FilterOutput` with [`Graph::set_output`](crate::graph::Graph::set_output).
1631    pub fn add_filter(&mut self, input: NodeId, pred: CompiledPredicate) -> NodeId {
1632        self.add(Operator::Filter(Filter {
1633            input,
1634            pred,
1635            output: Cell::new(None),
1636        }))
1637    }
1638
1639    /// A `FilterProbe` link over `input` (proof instrumentation; see the struct).
1640    pub fn add_filter_probe(&mut self, input: NodeId) -> NodeId {
1641        self.add(Operator::FilterProbe(FilterProbe {
1642            input,
1643            output: Cell::new(None),
1644            begin_count: Cell::new(0),
1645            filter_count: Cell::new(0),
1646            end_count: Cell::new(0),
1647        }))
1648    }
1649
1650    /// Wire a `FilterStart`'s single chain edge (`#output`, the chain head).
1651    pub fn set_chain_head(&self, filter_start: NodeId, head: NodeId) {
1652        self.filter_start_op(filter_start)
1653            .chain_head
1654            .set(Some(head));
1655    }
1656
1657    /// Wire a `FanOut`'s branch outputs and its paired `FanIn`.
1658    pub fn set_fan(&self, fan_out: NodeId, branches: Vec<NodeId>, fan_in: NodeId) {
1659        let fo = self.fan_out_op(fan_out);
1660        *fo.outputs.borrow_mut() = branches;
1661        fo.fan_in.set(Some(fan_in));
1662    }
1663
1664    /// Wire a `UnionFanOut`'s branch broadcast edges (each branch head + the port to
1665    /// push it on — `JoinParent` for a flipped branch, `Single` for a filter branch)
1666    /// and its paired `UnionFanIn`.
1667    pub fn set_union_fan(&self, fan_out: NodeId, branches: Vec<OutEdge>, fan_in: NodeId) {
1668        self.union_fan_out_op(fan_out).set_fan(branches, fan_in);
1669    }
1670
1671    /// Wire a source connection's output edge (mirrors `input.setOutput`).
1672    pub fn set_conn_output(&self, conn: NodeId, edge: OutEdge) {
1673        let sc = self.source_conn(conn);
1674        sc.output.set(Some(edge));
1675        self.source(sc.source).set_conn_output(sc.conn, edge);
1676    }
1677
1678    /// The port-bearing downstream edge of a spine node, if wired: the read side of
1679    /// [`set_out_edge`](Self::set_out_edge) / [`set_conn_output`](Self::set_conn_output)
1680    /// for the operators that carry an `OutEdge`. `None` for the multi-output chassis
1681    /// links and the sinks.
1682    pub(crate) fn out_edge(&self, node: NodeId) -> Option<OutEdge> {
1683        match self.node(node) {
1684            Operator::SourceConn(sc) => sc.output.get(),
1685            Operator::Join(j) => j.output.get(),
1686            Operator::FlippedJoin(fj) => fj.output.get(),
1687            Operator::Skip(s) => s.output.get(),
1688            Operator::Take(t) => t.output.get(),
1689            Operator::Cap(c) => c.output.get(),
1690            Operator::Reduce(r) => r.output.get(),
1691            Operator::FilterEnd(fe) => fe.output.get(),
1692            Operator::UnionFanIn(u) => u.output_edge(),
1693            _ => None,
1694        }
1695    }
1696
1697    /// Wire a single-output operator's downstream. Covers the join plus every
1698    /// single-`FilterOutput` chassis link (`FilterEnd`, `FanIn`, `Filter`,
1699    /// `FilterProbe`). `FilterStart` uses [`Graph::set_chain_head`](crate::graph::Graph::set_chain_head) and `FanOut`
1700    /// uses [`Graph::set_fan`](crate::graph::Graph::set_fan) — those are not single-output.
1701    pub fn set_output(&self, op: NodeId, downstream: NodeId) {
1702        match self.node(op) {
1703            // A join's single downstream lands on the sink's sole input port. A
1704            // join feeding *another join* (sibling/nested relationships) needs an
1705            // explicit port and is wired by the builder via [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge).
1706            Operator::Join(j) => j.output.set(Some(OutEdge {
1707                node: downstream,
1708                port: Port::Single,
1709            })),
1710            // Like a Join, a FlippedJoin's terminal-sink downstream lands on the
1711            // sink's sole input port; feeding another join's port uses `set_out_edge`.
1712            Operator::FlippedJoin(fj) => fj.output.set(Some(OutEdge {
1713                node: downstream,
1714                port: Port::Single,
1715            })),
1716            Operator::FilterEnd(fe) => fe.output.set(Some(OutEdge {
1717                node: downstream,
1718                port: Port::Single,
1719            })),
1720            Operator::FanIn(fi) => fi.output.set(Some(downstream)),
1721            // UnionFanIn has a single post-fan output (UnionFanOut is wired via
1722            // `set_union_fan`, not here).
1723            Operator::UnionFanIn(u) => u.set_output(OutEdge {
1724                node: downstream,
1725                port: Port::Single,
1726            }),
1727            Operator::Filter(f) => f.output.set(Some(downstream)),
1728            Operator::FilterProbe(p) => p.output.set(Some(downstream)),
1729            Operator::Exists(e) => e.output.set(Some(downstream)),
1730            // Like a Join, a Skip's downstream on a terminal sink lands on the
1731            // sink's sole input port; a Skip feeding a relationship join's parent
1732            // port is wired by the builder via [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge).
1733            Operator::Skip(s) => s.output.set(Some(OutEdge {
1734                node: downstream,
1735                port: Port::Single,
1736            })),
1737            // Like a Skip, a Take's terminal-sink downstream lands on the sink's
1738            // sole input port; feeding a relationship join's parent port uses
1739            // [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge).
1740            Operator::Take(t) => t.output.set(Some(OutEdge {
1741                node: downstream,
1742                port: Port::Single,
1743            })),
1744            Operator::Cap(c) => c.output.set(Some(OutEdge {
1745                node: downstream,
1746                port: Port::Single,
1747            })),
1748            // Reduce has a single downstream landing on the sink's sole input port
1749            // (its synthetic aggregate row); it is not a relationship parent.
1750            Operator::Reduce(r) => r.output.set(Some(OutEdge {
1751                node: downstream,
1752                port: Port::Single,
1753            })),
1754            #[cfg(any(test, feature = "testkit"))]
1755            Operator::Snitch(s) => s.set_output(downstream),
1756            #[cfg(any(test, feature = "testkit"))]
1757            Operator::Catch(_) => panic!(
1758                "set_output: Catch is a terminal sink (no downstream); attach it with \
1759                 add_catch + set_sink_edge (or route its upstream to it)"
1760            ),
1761            _ => panic!("set_output: not a single-output operator"),
1762        }
1763    }
1764
1765    /// Wire the final edge from a built pipeline's last operator into a terminal
1766    /// sink (a view, change sink, or testkit `Catch`) or a forwarding testkit `Snitch`.
1767    /// Routes a
1768    /// `SourceConn` through [`Graph::set_conn_output`](crate::graph::Graph::set_conn_output) (a bare `source → sink`
1769    /// pipeline) and any single-output operator — including a transparent tap —
1770    /// through [`Graph::set_output`](crate::graph::Graph::set_output). Used by the testkit runners after the build
1771    /// closure returns the op feeding the sink.
1772    pub fn set_sink_edge(&self, upstream: NodeId, sink: NodeId) {
1773        match self.node(upstream) {
1774            Operator::SourceConn(_) => self.set_conn_output(
1775                upstream,
1776                OutEdge {
1777                    node: sink,
1778                    port: Port::Single,
1779                },
1780            ),
1781            _ => self.set_output(upstream, sink),
1782        }
1783    }
1784
1785    /// Wire an upstream operator's output edge with an **explicit port** — the
1786    /// port-aware generalization of [`Graph::set_output`](crate::graph::Graph::set_output) (which always wires
1787    /// `Port::Single`). The builder (`08`) uses this to chain joins: a
1788    /// `SourceConn` routes through the source's [`Graph::set_conn_output`](crate::graph::Graph::set_conn_output); a
1789    /// `Join` sets its [`OutEdge`] cell directly. The port says how the
1790    /// *downstream* receives — `JoinParent` when `upstream` is the next join's
1791    /// parent (sibling relationships), `JoinChild` when it is a nested child top.
1792    pub fn set_out_edge(&self, upstream: NodeId, edge: OutEdge) {
1793        match self.node(upstream) {
1794            Operator::SourceConn(_) => self.set_conn_output(upstream, edge),
1795            Operator::Join(j) => j.output.set(Some(edge)),
1796            Operator::FlippedJoin(fj) => fj.output.set(Some(edge)),
1797            Operator::Skip(s) => s.output.set(Some(edge)),
1798            Operator::Take(t) => t.output.set(Some(edge)),
1799            Operator::Cap(c) => c.output.set(Some(edge)),
1800            // A grouped `Reduce` is the top of a relationship-aggregate child subtree:
1801            // it feeds a parent join's `JoinChild` port, which lifts each per-group
1802            // `Add`/`Edit`/`Remove` into a `Change::Child` on the parent (the §9 Tier-1
1803            // singular-relationship attachment, `REDUCE-DESIGN.md`). It already forwards
1804            // on `out.port`, so it carries a port-bearing edge like any join child.
1805            Operator::Reduce(r) => r.output.set(Some(edge)),
1806            // The filter-chain / union-fan tails now carry a port-bearing edge, so a
1807            // relationship join (`related`/sibling) can sit over a `where` sub-graph.
1808            Operator::FilterEnd(fe) => fe.output.set(Some(edge)),
1809            Operator::UnionFanIn(u) => u.set_output(edge),
1810            _ => panic!(
1811                "set_out_edge: not a source-conn, join, flipped-join, skip, take, cap, \
1812                 reduce, filter-end, or union-fan-in ({upstream:?})"
1813            ),
1814        }
1815    }
1816
1817    /// True iff `node` can carry a port-bearing [`OutEdge`] via [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge).
1818    /// Covers the port-aware ops (`SourceConn`/`Join`/`FlippedJoin`/`Skip`/`Take`/`Cap`/
1819    /// `Reduce`) **and** the filter-chain / union-fan tails `FilterEnd`/`UnionFanIn`, which
1820    /// now carry a port-bearing `OutEdge` (Gap B) — so a `related`/sibling relationship join
1821    /// can sit over a `where` sub-graph (an OR, a nested AND-OR, or a flipped EXISTS).
1822    /// `Reduce` is here as the top of a relationship-aggregate child subtree (§9). `FanIn`
1823    /// is excluded: it returns up the filter-chain stack, it is not a downstream-pushing tail.
1824    pub fn output_port_capable(&self, node: NodeId) -> bool {
1825        matches!(
1826            self.node(node),
1827            Operator::SourceConn(_)
1828                | Operator::Join(_)
1829                | Operator::FlippedJoin(_)
1830                | Operator::Skip(_)
1831                | Operator::Take(_)
1832                | Operator::Cap(_)
1833                | Operator::Reduce(_)
1834                | Operator::FilterEnd(_)
1835                | Operator::UnionFanIn(_)
1836        )
1837    }
1838
1839    /// Wire a `Port::Single` forward edge from `upstream` to `downstream`, tolerant of a
1840    /// non-port-aware tail. A `SourceConn` routes through [`Graph::set_conn_output`](crate::graph::Graph::set_conn_output);
1841    /// every other single-output operator — port-aware ops (via their `Single` fallback)
1842    /// AND the filter-chain / union-fan tails `FilterEnd`/`FanIn`/`UnionFanIn` — routes
1843    /// through [`Graph::set_output`](crate::graph::Graph::set_output). Unlike [`Graph::set_out_edge`](crate::graph::Graph::set_out_edge), this accepts a
1844    /// `FilterEnd`/`UnionFanIn` tail, so a `Take`/`Cap` (a root or EXISTS-child `limit`)
1845    /// can sit above a `where`-subgraph / union-fan end without panicking.
1846    pub fn wire_single(&self, upstream: NodeId, downstream: NodeId) {
1847        match self.node(upstream) {
1848            Operator::SourceConn(_) => self.set_conn_output(
1849                upstream,
1850                OutEdge {
1851                    node: downstream,
1852                    port: Port::Single,
1853                },
1854            ),
1855            _ => self.set_output(upstream, downstream),
1856        }
1857    }
1858
1859    // --- typed accessors ---
1860    //
1861    // PANIC-CLASS: internal — every `_ => panic!("expected X at {id:?}")` arm below
1862    // fires only if the *builder* hands an accessor a NodeId of the wrong operator
1863    // kind. That is an engine wiring bug, never reachable from data/push/AST, so it
1864    // is a correct fail-fast panic (foundations §10; WS02.1). Do NOT convert to
1865    // `RindleError` — these are unconditional catch-alls, not conditional invariants.
1866
1867    fn source(&self, id: NodeId) -> &SourceLeaf {
1868        match self.node(id) {
1869            Operator::Source(s) => s,
1870            _ => panic!("expected Source at {id:?}"),
1871        }
1872    }
1873    /// The in-memory source behind `id`, or `None` if the node is a `SourceLeaf::Dyn`
1874    /// backend (or not a source). The optimistic fork/rebase loop reaches through this
1875    /// to fork the live primary tree (`OPTIMISTIC-WRITES-DESIGN.md` §1) — memory
1876    /// sources only, by design: the loop is the wasm client's, not a SQL backend's.
1877    pub fn memory_source(&self, id: NodeId) -> Option<&MemorySource> {
1878        match self.node(id) {
1879            Operator::Source(SourceLeaf::Memory(s)) => Some(s),
1880            _ => None,
1881        }
1882    }
1883    fn source_conn(&self, id: NodeId) -> &SourceConn {
1884        match self.node(id) {
1885            Operator::SourceConn(s) => s,
1886            _ => panic!("expected SourceConn at {id:?}"),
1887        }
1888    }
1889    fn join(&self, id: NodeId) -> &Join {
1890        match self.node(id) {
1891            Operator::Join(j) => j,
1892            _ => panic!("expected Join at {id:?}"),
1893        }
1894    }
1895    fn view(&self, id: NodeId) -> &crate::view::View {
1896        match self.node(id) {
1897            Operator::View(v) => v,
1898            _ => panic!("expected View at {id:?}"),
1899        }
1900    }
1901    fn collector(&self, id: NodeId) -> &Collector {
1902        match self.node(id) {
1903            Operator::Collector(c) => c,
1904            _ => panic!("expected Collector at {id:?}"),
1905        }
1906    }
1907    #[cfg(any(test, feature = "testkit"))]
1908    fn catch_op(&self, id: NodeId) -> &crate::testkit::Catch {
1909        match self.node(id) {
1910            Operator::Catch(c) => c,
1911            _ => panic!("expected Catch at {id:?}"),
1912        }
1913    }
1914    #[cfg(any(test, feature = "testkit"))]
1915    fn snitch_op(&self, id: NodeId) -> &crate::testkit::Snitch {
1916        match self.node(id) {
1917            Operator::Snitch(s) => s,
1918            _ => panic!("expected Snitch at {id:?}"),
1919        }
1920    }
1921    fn filter_start_op(&self, id: NodeId) -> &FilterStart {
1922        match self.node(id) {
1923            Operator::FilterStart(s) => s,
1924            _ => panic!("expected FilterStart at {id:?}"),
1925        }
1926    }
1927    fn fan_out_op(&self, id: NodeId) -> &FanOut {
1928        match self.node(id) {
1929            Operator::FanOut(f) => f,
1930            _ => panic!("expected FanOut at {id:?}"),
1931        }
1932    }
1933    fn fan_in_op(&self, id: NodeId) -> &FanIn {
1934        match self.node(id) {
1935            Operator::FanIn(f) => f,
1936            _ => panic!("expected FanIn at {id:?}"),
1937        }
1938    }
1939    fn union_fan_out_op(&self, id: NodeId) -> &crate::op::UnionFanOut {
1940        match self.node(id) {
1941            Operator::UnionFanOut(u) => u,
1942            _ => panic!("expected UnionFanOut at {id:?}"),
1943        }
1944    }
1945    /// `pub(crate)`: [`UnionFanOut::push`](crate::op::UnionFanOut) drives its paired
1946    /// fan-in's accumulate/drain through this (the fan-out seam).
1947    pub(crate) fn union_fan_in_op(&self, id: NodeId) -> &crate::op::UnionFanIn {
1948        match self.node(id) {
1949            Operator::UnionFanIn(u) => u,
1950            _ => panic!("expected UnionFanIn at {id:?}"),
1951        }
1952    }
1953    fn filter_op(&self, id: NodeId) -> &Filter {
1954        match self.node(id) {
1955            Operator::Filter(f) => f,
1956            _ => panic!("expected Filter at {id:?}"),
1957        }
1958    }
1959    fn filter_probe_op(&self, id: NodeId) -> &FilterProbe {
1960        match self.node(id) {
1961            Operator::FilterProbe(p) => p,
1962            _ => panic!("expected FilterProbe at {id:?}"),
1963        }
1964    }
1965
1966    /// Schema of an input node's output rows (for resolving child PK/sort inside
1967    /// relationship thunks). Recurses through joins to the parent source.
1968    ///
1969    /// `pub(crate)`: operators in [`crate::op`] resolve their input's schema
1970    /// through this (the fan-out seam, `op/mod.rs`).
1971    pub(crate) fn input_schema(&self, id: NodeId) -> &Schema {
1972        match self.node(id) {
1973            Operator::SourceConn(sc) => self.source(sc.source).schema(),
1974            Operator::Join(j) => self.input_schema(j.parent),
1975            // FlippedJoin outputs parent rows (with the child relationship attached).
1976            Operator::FlippedJoin(fj) => self.input_schema(fj.parent),
1977            // UnionFanOut passes its input through; UnionFanIn owns the merged schema.
1978            Operator::UnionFanOut(u) => self.input_schema(u.input),
1979            Operator::UnionFanIn(u) => u.schema(),
1980            Operator::Source(s) => s.schema(),
1981            // `View::schema` is `Arc<Schema>`; hand back the inner `&Schema`.
1982            Operator::View(v) => v.schema.as_ref(),
1983            Operator::Collector(c) => self.input_schema(c.input),
1984            // The filter sub-graph never reshapes rows (`07` §3.1), so every link's
1985            // output schema is its input's. Recurse to the upstream source side.
1986            Operator::FilterStart(s) => self.input_schema(s.input),
1987            Operator::FilterEnd(e) => self.input_schema(e.start),
1988            Operator::FanOut(f) => self.input_schema(f.input),
1989            Operator::FanIn(f) => self.input_schema(self.fan_out_op(f.fan_out).input),
1990            Operator::Filter(f) => self.input_schema(f.input),
1991            Operator::FilterProbe(p) => self.input_schema(p.input),
1992            Operator::Exists(e) => self.input_schema(e.input),
1993            // Skip/Take/Cap/Exists never reshape rows → output schema = input's.
1994            Operator::Skip(s) => self.input_schema(s.input),
1995            Operator::Take(t) => self.input_schema(t.input),
1996            Operator::Cap(c) => self.input_schema(c.input),
1997            // Reduce DOES reshape: its output is the synthetic aggregate row, so it
1998            // carries (and returns) its own schema rather than delegating upstream.
1999            Operator::Reduce(r) => &r.schema,
2000            // Testkit sinks/taps never reshape rows → output schema = input's.
2001            #[cfg(any(test, feature = "testkit"))]
2002            Operator::Catch(c) => self.input_schema(c.input),
2003            #[cfg(any(test, feature = "testkit"))]
2004            Operator::Snitch(s) => self.input_schema(s.input),
2005            Operator::Tombstone => panic!("input_schema on a torn-down node {id:?}"),
2006        }
2007    }
2008
2009    /// Effective output ordering for `id`. This is distinct from
2010    /// [`Self::input_schema`]: a source connection can be ordered by a per-query sort
2011    /// that is not the table schema's primary sort.
2012    pub(crate) fn input_sort(&self, id: NodeId) -> Sort {
2013        match self.node(id) {
2014            Operator::SourceConn(sc) => self.source(sc.source).conn_sort(sc.conn),
2015            Operator::Join(j) => self.input_sort(j.parent),
2016            Operator::FlippedJoin(fj) => self.input_sort(fj.parent),
2017            Operator::UnionFanOut(u) => self.input_sort(u.input),
2018            Operator::UnionFanIn(u) => u.schema().sort.clone(),
2019            Operator::Source(s) => s.schema().sort.clone(),
2020            Operator::View(v) => v.schema.sort.clone(),
2021            Operator::Collector(c) => self.input_sort(c.input),
2022            Operator::FilterStart(s) => self.input_sort(s.input),
2023            Operator::FilterEnd(e) => self.input_sort(e.start),
2024            Operator::FanOut(f) => self.input_sort(f.input),
2025            Operator::FanIn(f) => self.input_sort(self.fan_out_op(f.fan_out).input),
2026            Operator::Filter(f) => self.input_sort(f.input),
2027            Operator::FilterProbe(p) => self.input_sort(p.input),
2028            Operator::Exists(e) => self.input_sort(e.input),
2029            Operator::Skip(s) => self.input_sort(s.input),
2030            Operator::Take(t) => t.sort.clone(),
2031            Operator::Cap(c) => self.input_sort(c.input),
2032            // Reduce reshapes to its synthetic row: its order is its own (empty) sort
2033            // (the global aggregate is a single row), not the input's.
2034            Operator::Reduce(r) => r.schema.sort.clone(),
2035            #[cfg(any(test, feature = "testkit"))]
2036            Operator::Catch(c) => self.input_sort(c.input),
2037            #[cfg(any(test, feature = "testkit"))]
2038            Operator::Snitch(s) => self.input_sort(s.input),
2039            Operator::Tombstone => panic!("input_sort on a torn-down node {id:?}"),
2040        }
2041    }
2042
2043    pub fn cursors_open(&self, source: NodeId) -> i64 {
2044        self.source(source).cursors_open()
2045    }
2046
2047    // -------------------------------------------------------------------
2048    // FETCH (pull). Every fetch is a *shared* `&'g self` borrow -> reentrancy
2049    // composes for free.
2050    // -------------------------------------------------------------------
2051
2052    pub fn fetch<'g>(&'g self, id: NodeId, req: &FetchRequest) -> NodeStream<'g> {
2053        match self.node(id) {
2054            // The connection boundary: a source emits rows; the SourceConn wraps
2055            // each in a leaf node. Downstream joins then attach relationships.
2056            Operator::SourceConn(sc) => {
2057                let rows = self.source(sc.source).fetch(sc.conn, req);
2058                match sc.family.as_deref() {
2059                    None => Box::new(rows.map(Node::leaf)),
2060                    // A family root (design 310 §4.1/§4.4): membership is never lowered
2061                    // to SQL, so the committed rows a leaf vends are filtered HERE — the
2062                    // memory leaf already applied the connection predicate (which
2063                    // includes membership) and re-checks for nothing; the SQLite leaf
2064                    // applied only the residual `sql_condition` and needs this. Every
2065                    // family-root fetch is constrained by construction; an unconstrained
2066                    // one would be a whole-table scan filtered in-engine — correct, but
2067                    // exactly the cost the design forbids, so it is asserted.
2068                    Some(f) => {
2069                        if req.constraint.is_none() && !req.has_multi() {
2070                            // Never reached by construction (asserted); answered exactly
2071                            // in release: the bound values of the first parameter column
2072                            // as an IN-batch — O(bindings) seeks / a native `IN` — never
2073                            // a scan, and never the empty static guard's "no rows".
2074                            self.check_family_root_fetch(req);
2075                            drop(rows);
2076                            return self.fetch_family_root_unconstrained(sc, f, req);
2077                        }
2078                        Box::new(
2079                            rows.filter(move |r| f.bindings.contains_row(r, &f.param_cols))
2080                                .map(Node::leaf),
2081                        )
2082                    }
2083                }
2084            }
2085            Operator::Join(_) => self.join_fetch(id, req),
2086            // Out-of-file operators delegate to their own module (the fan-out seam).
2087            Operator::FlippedJoin(fj) => fj.fetch(self, req),
2088            // The union fan IS fetchable (unlike the filter fan): UnionFanOut
2089            // delegates to its input; UnionFanIn k-way-merges the branch fetches.
2090            Operator::UnionFanOut(u) => u.fetch(self, req),
2091            Operator::UnionFanIn(u) => u.fetch(self, req),
2092            Operator::Skip(s) => s.fetch(self, req),
2093            Operator::Take(t) => t.fetch(self, req),
2094            Operator::Cap(c) => c.fetch(self, req),
2095            Operator::Reduce(r) => r.fetch(self, req),
2096            // Testkit tap: Snitch logs the fetch and vends the upstream stream
2097            // wrapped to log a fetchCount on Drop (`crate::testkit`).
2098            #[cfg(any(test, feature = "testkit"))]
2099            Operator::Snitch(s) => s.fetch(self, req),
2100            // The filter sub-graph's two fetch boundaries: `FilterStart` runs the
2101            // bracketed scan; `FilterEnd` delegates to it (`filter-operators.ts:118`).
2102            Operator::FilterStart(_) => self.filter_start_fetch(id, req),
2103            Operator::FilterEnd(e) => self.fetch(e.start, req),
2104            Operator::Source(_) => panic!("fetch a SourceConn, not the Source directly"),
2105            Operator::View(_) => panic!("Views are sinks, not fetched"),
2106            Operator::Collector(_) => panic!("Collectors are sinks, not fetched"),
2107            #[cfg(any(test, feature = "testkit"))]
2108            Operator::Catch(_) => panic!("Catch is a sink, not fetched; use catch_fetch ({id:?})"),
2109            // FilterOperators gate (`filter(node)`), they do not vend — the scan is
2110            // owned by `FilterStart::fetch` (`07` §3.2).
2111            Operator::FanOut(_)
2112            | Operator::FanIn(_)
2113            | Operator::Filter(_)
2114            | Operator::FilterProbe(_)
2115            | Operator::Exists(_) => {
2116                panic!("filter-chain links have no fetch; fetch the FilterStart/End ({id:?})")
2117            }
2118            Operator::Tombstone => panic!("fetch on a torn-down node {id:?}"),
2119        }
2120    }
2121
2122    /// Eagerly drain `fetch` and surface any runtime error a backend parked during
2123    /// the scan. The lazy [`Graph::fetch`](crate::graph::Graph::fetch) API remains infallible for iterator
2124    /// composition; production callers that need error propagation should use this
2125    /// owned-drain boundary.
2126    pub fn try_fetch_all<'g>(
2127        &'g self,
2128        id: NodeId,
2129        req: &FetchRequest,
2130    ) -> Result<Vec<Node<'g>>, RindleError> {
2131        let out: Vec<Node<'g>> = self.fetch(id, req).collect();
2132        self.take_runtime_error()?;
2133        Ok(out)
2134    }
2135
2136    fn join_fetch<'g>(&'g self, jid: NodeId, req: &FetchRequest) -> NodeStream<'g> {
2137        let j = self.join(jid);
2138        // Design 311 §2.4: an unconstrained forward enumeration of the parent input is
2139        // the pre-check's ONLY source of membership — decide before the parent fetch
2140        // whether this request may build or advance the join's region.
2141        let observe = self.join_precheck_observation(jid, req);
2142        let parent_stream = self.fetch(j.parent, req);
2143        let parent_stream: NodeStream<'g> = if observe {
2144            Box::new(ObservedParents {
2145                g: self,
2146                jid,
2147                inner: parent_stream,
2148                done: false,
2149            })
2150        } else {
2151            parent_stream
2152        };
2153        Box::new(parent_stream.map(move |pnode| {
2154            // No overlay during a plain fetch.
2155            self.process_parent_node(jid, pnode)
2156        }))
2157    }
2158
2159    /// Attach this join's child relationship to a parent node as a thunk that reads
2160    /// the join's **LIVE** in-flight child overlay on every evaluation (`join.ts:252`
2161    /// `#processParentNode`), **preserving the parent node's existing relationships**
2162    /// (`join.ts:298` `{...parentNodeRelations, [relName]: childStream}`). Unlike the
2163    /// by-value [`Graph::attach_child_rel`](crate::graph::Graph::attach_child_rel) (kept for [`FlippedJoin`]), the thunk here
2164    /// re-reads [`Join::inprogress_overlay`]/[`Join::inprogress_position`] each time it
2165    /// is drained — so a reentrant refetch a downstream `Take`/`Exists` triggers
2166    /// mid-push sees PRE-change membership for not-yet-processed parents (the depth-2
2167    /// EXISTS+top-N+push cascade). Everything is captured BY VALUE except `self` (the
2168    /// shared `&'g Graph`), through which the live fields are re-read.
2169    fn process_parent_node<'g>(&'g self, jid: NodeId, mut parent_node: Node<'g>) -> Node<'g> {
2170        let j = self.join(jid);
2171        let child_id = j.child;
2172        let parent_key = j.parent_key.clone();
2173        let child_key = j.child_key.clone();
2174        let rel_slot = j.rel_slot;
2175        let child_schema = self.input_schema(child_id);
2176        let child_pk = child_schema.primary_key.clone();
2177        let child_sort: Sort = child_schema.sort.clone();
2178        let pr = parent_node.row.clone();
2179
2180        let thunk: Box<dyn Fn() -> NodeStream<'g> + 'g> = Box::new(move || {
2181            let base: NodeStream<'g> = match build_join_constraint(&pr, &parent_key, &child_key) {
2182                Some(c) => self.fetch(child_id, &FetchRequest::with_constraint(c)),
2183                None => Box::new(std::iter::empty()),
2184            };
2185            // Live, gated read of the in-flight child overlay (lazy `#inprogress…`).
2186            match self.join_overlay_for(jid, &pr) {
2187                Some(ov) => splice_join_overlay_pre(base, &ov, &child_pk, &child_sort),
2188                None => base,
2189            }
2190        });
2191
2192        parent_node.rels.push(Relationship {
2193            slot: rel_slot,
2194            thunk,
2195        });
2196        parent_node
2197    }
2198
2199    /// Walk a relationship join's child subtree from `node`, deleting partitioned
2200    /// limiter (`Take`/`Cap`) slots that match `constraint` (the parent→child
2201    /// correlation key of the parent that just left a bounded parent view). Recurses
2202    /// through the row-preserving passthrough operators between the join and its
2203    /// limiter(s); stops at the source connection and at a nested `Join` (a grandchild
2204    /// relationship is keyed by a *different* correlation, so this constraint does not
2205    /// identify its partitions — a deeper-nesting residual, not a correctness issue).
2206    fn evict_child_partitions(&self, node: NodeId, constraint: &Constraint) {
2207        match self.node(node) {
2208            Operator::Take(t) => {
2209                t.evict_partition(self, constraint);
2210                // A Skip/Filter could sit between the Take and the source; recurse so a
2211                // limited-with-offset child still reaches its limiter chain.
2212                self.evict_child_partitions(t.input, constraint);
2213            }
2214            Operator::Cap(c) => {
2215                c.evict_partition(self, constraint);
2216                self.evict_child_partitions(c.input, constraint);
2217            }
2218            Operator::Skip(s) => self.evict_child_partitions(s.input, constraint),
2219            Operator::FilterStart(s) => self.evict_child_partitions(s.input, constraint),
2220            Operator::FilterEnd(e) => self.evict_child_partitions(e.start, constraint),
2221            // A relationship aggregate's lazy grouped `Reduce` keeps one slot per parent
2222            // it ever folded and deletes none of them itself (the death arm is `eager`-
2223            // only, by design). The walk does NOT continue below it: the aggregate's
2224            // child is uncapped by construction, and the constraint is in the reduce's
2225            // OUTPUT coordinates, which do not address anything underneath.
2226            Operator::Reduce(r) => r.evict_partition(self, constraint),
2227            // Anything else (SourceConn, a nested Join, a union reshape) is not a
2228            // partitioned child limiter keyed by THIS constraint — stop the walk.
2229            _ => {}
2230        }
2231    }
2232
2233    /// Walk **upstream** from a limiter's input, evicting each relationship join's
2234    /// child partition for `row` — the mirror of
2235    /// [`evict_child_partitions`](Self::evict_child_partitions), which walks *down* a
2236    /// join's child subtree when a parent leaves a bounded PARENT view.
2237    ///
2238    /// The two exist because the two lowerings put the join on opposite sides of the
2239    /// limiter. A `related` join sits AFTER the root `Take`
2240    /// (`build_pipeline_internal`: "limit → Take … before `related`"), so a parent
2241    /// falling out of the top-N arrives at that join as a `JoinParent` `Remove` and the
2242    /// downstream walk reclaims its child state. A **spine EXISTS/aggregate join** sits
2243    /// BEFORE it (`csq_conditions` loop → `applyWhere` gates → `Take`), so a parent
2244    /// leaving the window is never a `Remove` at the join: nothing tore its child
2245    /// partition down, and the `Cap`/`Reduce` under the gate kept one slot per distinct
2246    /// parent that ever passed through — operator state proportional to the parent
2247    /// table, not to the (bounded) view. See `follow-ups/04-cap-partition-leak.md`.
2248    ///
2249    /// **Safe only for a row outside a FULL window** — the sole caller,
2250    /// [`Take::evict_upstream_if_full`](crate::op::Take), carries that guard. Such a row
2251    /// cannot re-enter by a push: it sorts at-or-after the
2252    /// bound, and a child change cannot move a parent's sort position. It can only
2253    /// return through a **refill fetch**, and every fetch re-hydrates what it evicts —
2254    /// `Cap`/`Take` on `get_state == None` run `initial_fetch`, and `Reduce::fetch`
2255    /// re-folds. A parent whose gate could still admit it (an unfilled window) keeps its
2256    /// state: that slot is the subscription the `Exists` gate flips on, so evicting it
2257    /// would silently drop the child `Add` that should have pulled the parent in.
2258    ///
2259    /// Per join, evicts only when the correlation is the parent's **primary key**, so the
2260    /// partition belongs to exactly this one parent — the same condition the downstream
2261    /// walk's caller applies. Recurses through the row-preserving operators between the
2262    /// limiter and the joins (`FilterEnd`/`FilterStart` bracket the gate chain;
2263    /// `Skip` carries a `start`), and stops at anything else — a source connection, a
2264    /// union reshape, a fan-in — so an unrecognized shape keeps its state rather than
2265    /// guessing.
2266    pub(crate) fn evict_upstream_child_partitions(&self, node: NodeId, row: &Row) {
2267        match self.node(node) {
2268            Operator::Join(j) => {
2269                if self.input_schema(j.parent).primary_key == j.parent_key {
2270                    if let Some(c) = build_join_constraint(row, &j.parent_key, &j.child_key) {
2271                        self.evict_child_partitions(j.child, &c);
2272                    }
2273                }
2274                self.evict_upstream_child_partitions(j.parent, row);
2275            }
2276            Operator::FilterEnd(e) => self.evict_upstream_child_partitions(e.start, row),
2277            Operator::FilterStart(s) => self.evict_upstream_child_partitions(s.input, row),
2278            Operator::Skip(s) => self.evict_upstream_child_partitions(s.input, row),
2279            _ => {}
2280        }
2281    }
2282
2283    /// The gated read of a [`Join`]'s LIVE in-flight child overlay for `parent_row`
2284    /// (Zero `join.ts:264-277`). Returns the overlay iff (a) the in-flight child joins
2285    /// to this parent (`isJoinMatch`) and (b) the parent sorts STRICTLY AFTER the
2286    /// parent currently being fanned out (`compareRows(parent, position) > 0`) — i.e.
2287    /// the change has not yet been delivered for this parent, so its relationship must
2288    /// still reflect the PRE-change child. `None` outside a child-push, for an
2289    /// unrelated parent, or for an at-or-before parent.
2290    fn join_overlay_for(&self, jid: NodeId, parent_row: &Row) -> Option<JoinOverlay> {
2291        let j = self.join(jid);
2292        let overlay = j.inprogress_overlay.borrow().clone()?;
2293        let position = j.inprogress_position.borrow().clone()?;
2294        if !is_join_match(
2295            parent_row,
2296            &j.parent_key,
2297            overlay.change.row(),
2298            &j.child_key,
2299        ) {
2300            return None;
2301        }
2302        // The fan-out delivers parents in the parent's EFFECTIVE output order (the
2303        // per-query sort, e.g. `orderBy`), NOT the table's primary-key sort — so the
2304        // "already delivered?" position gate must compare in that same order
2305        // (`input_sort`, not `input_schema(...).sort`). Zero uses the join schema's
2306        // `compareRows`, which for a memory connection is the resolved query order.
2307        let parent_sort = self.input_sort(j.parent);
2308        if compare_rows(&parent_sort, parent_row, &position) == std::cmp::Ordering::Greater {
2309            Some(overlay)
2310        } else {
2311            None
2312        }
2313    }
2314
2315    /// The field-based core of [`Graph::process_parent_node`](crate::graph::Graph::process_parent_node): attach a join child
2316    /// relationship (`parent_key → child_key` fetch, optionally spliced with an
2317    /// in-progress child `overlay` in the given [`OverlayPolarity`]) to
2318    /// `parent_node`, **appending** so the parent's existing relationships survive.
2319    /// Captures everything the thunk needs BY VALUE (Primitive #5), so the thunk
2320    /// owns its inputs and never reads a live operator field. `pub(crate)` so the
2321    /// out-of-file [`FlippedJoin`](crate::op::FlippedJoin) — which shares `Join`'s
2322    /// relationship + overlay model — builds the same thunk.
2323    ///
2324    /// **Drain discipline (the frozen-overlay hazard):** `overlay` (and its
2325    /// polarity) is frozen BY VALUE at yield time, derived from the fan-out state
2326    /// — in-flight change + position — at the moment the parent was yielded.
2327    /// `Join`'s own thunks ([`Graph::process_parent_node`]) instead re-read the
2328    /// live gate (`join_overlay_for`) at drain time and go inert once the guard
2329    /// clears. A frozen overlay has no such fallback: it is correct only while
2330    /// the fan-out state it was derived from still holds, so the thunk must be
2331    /// drained (or dropped) before the fan-out advances its position past this
2332    /// parent or clears the in-flight overlay. Every current consumer drains
2333    /// synchronously within the push frame; a consumer that holds the `Node`
2334    /// across a delivery boundary would re-materialize a stale PRE-change
2335    /// relationship after the change was already delivered.
2336    pub(crate) fn attach_child_rel<'g>(
2337        &'g self,
2338        child_id: NodeId,
2339        parent_key: &[ColId],
2340        child_key: &[ColId],
2341        rel_slot: RelId,
2342        mut parent_node: Node<'g>,
2343        overlay: Option<(JoinOverlay, OverlayPolarity)>,
2344    ) -> Node<'g> {
2345        let parent_key = parent_key.to_vec();
2346        let child_key = child_key.to_vec();
2347        let child_schema = self.input_schema(child_id);
2348        let child_pk = child_schema.primary_key.clone();
2349        let child_sort: Sort = child_schema.sort.clone();
2350        let pr = parent_node.row.clone();
2351
2352        let thunk: Box<dyn Fn() -> NodeStream<'g> + 'g> = Box::new(move || {
2353            let base: NodeStream<'g> = match build_join_constraint(&pr, &parent_key, &child_key) {
2354                Some(c) => self.fetch(child_id, &FetchRequest::with_constraint(c)),
2355                None => Box::new(std::iter::empty()),
2356            };
2357            match &overlay {
2358                None => base,
2359                Some((ov, OverlayPolarity::Post)) => splice_join_overlay(
2360                    base,
2361                    ov,
2362                    &pr,
2363                    &parent_key,
2364                    &child_key,
2365                    &child_pk,
2366                    &child_sort,
2367                ),
2368                Some((ov, OverlayPolarity::Pre)) => {
2369                    splice_join_overlay_pre(base, ov, &child_pk, &child_sort)
2370                }
2371            }
2372        });
2373
2374        // Append (don't replace) so the parent's pre-existing relationships survive.
2375        parent_node.rels.push(Relationship {
2376            slot: rel_slot,
2377            thunk,
2378        });
2379        parent_node
2380    }
2381
2382    // -------------------------------------------------------------------
2383    // The Filter sub-graph: bracketed fetch + the begin/filter/end lifecycle.
2384    // -------------------------------------------------------------------
2385
2386    /// `FilterStart::fetch` (`filter-operators.ts:86-103`). The single place the
2387    /// row scan happens for a `where`: pull each node from the upstream `Input`,
2388    /// gate it through the chain (`chain_filter`), yield iff it passes. The scan
2389    /// is bracketed by `begin_filter` (before) / `end_filter` (after) so a stateful
2390    /// link can cache for the loop. `end_filter` runs via an [`EndFilterGuard`]
2391    /// MOVED INTO the returned stream's closure — so dropping the stream early (a
2392    /// downstream `Take` break, `first()`) runs it. Unwinding also runs it, but
2393    /// only in unwinding builds; under the shipping `panic = "abort"` client
2394    /// profile a panic aborts the process and `Drop` does not run. This is the
2395    /// JS `try/finally` rendered as RAII (Primitive #2; `07` §6.4). See WS02.
2396    fn filter_start_fetch<'g>(&'g self, id: NodeId, req: &FetchRequest) -> NodeStream<'g> {
2397        // PANIC-CLASS: build-time — every `.expect("… not wired")` in this file
2398        // (FilterStart chain head, Filter/FilterProbe/FilterEnd/FanOut/join output)
2399        // asserts an edge the *builder* must have set before any fetch/push runs. An
2400        // unwired edge is an engine wiring bug, never data-reachable (WS02.1). Grep
2401        // the keep-bucket with `rg '"[^"]* not wired"' src/`.
2402        let chain = self
2403            .filter_start_op(id)
2404            .chain_head
2405            .get()
2406            .expect("FilterStart chain head not wired");
2407        let input = self.filter_start_op(id).input;
2408
2409        // `begin_filter` runs before the guard exists — matching the JS, where
2410        // `beginFilter()` sits *outside* the `try/finally` (a begin-time throw
2411        // skips `endFilter` there too). The guard is built *before* the upstream
2412        // fetch and moved into the stream, so every panic on the actual scan path
2413        // (the data-driven one) is covered; `begin_filter` itself is infallible on
2414        // a correctly-wired graph (its only panic is the mis-wired-node arm).
2415        self.begin_filter(chain);
2416        let guard = EndFilterGuard { g: self, chain };
2417        let upstream = self.fetch(input, req);
2418
2419        Box::new(upstream.filter_map(move |node| {
2420            // `guard` is owned by this closure → its Drop (end_filter) fires
2421            // exactly when the stream is dropped, however that happens.
2422            let _hold = &guard;
2423            if self.chain_filter(chain, &node) {
2424                Some(node)
2425            } else {
2426                None
2427            }
2428        }))
2429    }
2430
2431    /// Begin a fetch loop for the chain rooted at `link` (`beginFilter()`,
2432    /// `filter-operators.ts:37`). Walks the same topology as [`chain_filter`]:
2433    /// every stateful link gets one `begin` per loop. No-op for stateless links.
2434    ///
2435    /// A `Filter`/`FilterProbe` link is never a chain terminator, so its `output`
2436    /// must be wired (`.expect`) — same strict contract as the push path
2437    /// ([`Graph::chain_push`](crate::graph::Graph::chain_push)). Only a `FanOut`'s post-fan continuation is
2438    /// legitimately optional.
2439    pub(crate) fn begin_filter(&self, link: NodeId) {
2440        match self.node(link) {
2441            Operator::Filter(f) => {
2442                self.begin_filter(f.output.get().expect("Filter output not wired"))
2443            }
2444            Operator::FilterProbe(p) => {
2445                p.begin_count.set(p.begin_count.get() + 1);
2446                self.begin_filter(p.output.get().expect("FilterProbe output not wired"));
2447            }
2448            Operator::FanOut(fo) => {
2449                let branches = fo.outputs.borrow().clone();
2450                let cont = self.fan_continuation(link);
2451                for b in branches {
2452                    self.begin_filter(b);
2453                }
2454                if let Some(c) = cont {
2455                    self.begin_filter(c);
2456                }
2457            }
2458            // Branch terminator / chain terminator: the continuation past a FanIn
2459            // is begun by the owning FanOut; FilterEnd ends the chain.
2460            Operator::Exists(e) => e.begin(self),
2461            Operator::FanIn(_) | Operator::FilterEnd(_) => {}
2462            _ => panic!("begin_filter: not a filter-chain link ({link:?})"),
2463        }
2464    }
2465
2466    /// `end_filter` (`endFilter()`, `filter-operators.ts:37`) — the mirror of
2467    /// [`Graph::begin_filter`](crate::graph::Graph::begin_filter). Runs from the [`EndFilterGuard`]'s `Drop`.
2468    pub(crate) fn end_filter(&self, link: NodeId) {
2469        match self.node(link) {
2470            Operator::Filter(f) => {
2471                self.end_filter(f.output.get().expect("Filter output not wired"))
2472            }
2473            Operator::FilterProbe(p) => {
2474                p.end_count.set(p.end_count.get() + 1);
2475                self.end_filter(p.output.get().expect("FilterProbe output not wired"));
2476            }
2477            Operator::FanOut(fo) => {
2478                let branches = fo.outputs.borrow().clone();
2479                let cont = self.fan_continuation(link);
2480                for b in branches {
2481                    self.end_filter(b);
2482                }
2483                if let Some(c) = cont {
2484                    self.end_filter(c);
2485                }
2486            }
2487            Operator::Exists(e) => e.end(self),
2488            Operator::FanIn(_) | Operator::FilterEnd(_) => {}
2489            _ => panic!("end_filter: not a filter-chain link ({link:?})"),
2490        }
2491    }
2492
2493    /// Walk the filter chain for `node`, returning whether it passes ALL links
2494    /// (`FilterOutput::filter`, `filter-operators.ts:37`). Each link is `pred &&
2495    /// downstream`; `FanOut` is OR-over-branches AND the post-fan continuation;
2496    /// `FanIn`/`FilterEnd` terminate the walk with `true`.
2497    ///
2498    /// `Filter`/`FilterProbe` outputs are `.expect`-wired (a link is never a
2499    /// terminator — same as the push path); only a `FanOut`'s continuation is
2500    /// optional. Never holds a `RefCell` borrow across a recursive call (the
2501    /// cardinal rule, `01` §6): `FanOut`'s branch list is cloned to a local first.
2502    pub(crate) fn chain_filter(&self, link: NodeId, node: &Node) -> bool {
2503        match self.node(link) {
2504            Operator::Filter(f) => {
2505                f.pred.eval(&node.row)
2506                    && self.chain_filter(f.output.get().expect("Filter output not wired"), node)
2507            }
2508            Operator::FilterProbe(p) => {
2509                p.filter_count.set(p.filter_count.get() + 1);
2510                self.chain_filter(p.output.get().expect("FilterProbe output not wired"), node)
2511            }
2512            Operator::FanOut(fo) => {
2513                let branches = fo.outputs.borrow().clone();
2514                // OR: short-circuit on the first branch that passes.
2515                let any = branches.iter().any(|&b| self.chain_filter(b, node));
2516                let cont = self.fan_continuation(link);
2517                any && cont.is_none_or(|c| self.chain_filter(c, node))
2518            }
2519            // Branch terminator (a branch's `filter` ends at its FanIn) and chain
2520            // terminator both return true.
2521            Operator::Exists(e) => e.filter(self, node),
2522            Operator::FanIn(_) | Operator::FilterEnd(_) => true,
2523            _ => panic!("chain_filter: not a filter-chain link ({link:?})"),
2524        }
2525    }
2526
2527    /// The post-fan continuation of a `FanOut`: its paired `FanIn`'s downstream
2528    /// output (the link *after* the fan). `None` until wired.
2529    fn fan_continuation(&self, fan_out: NodeId) -> Option<NodeId> {
2530        let fin = self
2531            .fan_out_op(fan_out)
2532            .fan_in
2533            .get()
2534            .expect("FanOut fan_in not wired");
2535        self.fan_in_op(fin).output.get()
2536    }
2537
2538    // -------------------------------------------------------------------
2539    // PUSH (eager). A whole push completes before the next (load-bearing).
2540    // `&self` shared borrow throughout -> reentrant fetch is legal.
2541    // -------------------------------------------------------------------
2542
2543    /// `pub(crate)`: operators in [`crate::op`] drive their downstream edge
2544    /// through this (the fan-out seam). Single-output operators pass
2545    /// [`Port::Single`].
2546    pub(crate) fn push<'g>(&'g self, id: NodeId, change: Change<'g>, port: Port) {
2547        match self.node(id) {
2548            Operator::Join(_) => self.join_push(id, change, port),
2549            Operator::FlippedJoin(fj) => fj.push(self, change, port),
2550            // The union fan (reached on `Port::Single`): UnionFanOut broadcasts to
2551            // the branches; UnionFanIn accumulates/forwards.
2552            Operator::UnionFanOut(u) => u.push(self, change),
2553            Operator::UnionFanIn(u) => u.push(self, change),
2554            Operator::Skip(s) => s.push(self, change),
2555            Operator::Take(t) => t.push(self, change),
2556            Operator::Cap(c) => c.push(self, change),
2557            Operator::Reduce(r) => r.push(self, change),
2558            Operator::View(_) => self.view_push(id, change),
2559            Operator::Collector(_) => self.collector_push(id, change),
2560            // Testkit: Snitch logs + forwards on the arriving port (transparent);
2561            // Catch records the expanded change tree (the sink terminus).
2562            #[cfg(any(test, feature = "testkit"))]
2563            Operator::Snitch(s) => s.push(self, change, port),
2564            #[cfg(any(test, feature = "testkit"))]
2565            Operator::Catch(c) => {
2566                // Catch is a terminus: it only ever sits on a single-output edge
2567                // (a non-Single port means it was mis-wired before a join port).
2568                // Guard symmetrically with the fetch-path panic above.
2569                assert!(
2570                    matches!(port, Port::Single),
2571                    "Catch is a sink; expected Port::Single, got {port:?} ({id:?})"
2572                );
2573                c.record(self, &change)
2574            }
2575            // The filter sub-graph's push entry: push into the chain head. The
2576            // chain transforms/gates and side-effects the real downstream push at
2577            // its `FilterEnd`; nothing should bubble back up here.
2578            Operator::FilterStart(s) => {
2579                let head = s
2580                    .chain_head
2581                    .get()
2582                    .expect("FilterStart chain head not wired");
2583                let leftover = self.chain_push(head, change);
2584                debug_assert!(
2585                    leftover.is_empty(),
2586                    "filter sub-graph push did not terminate at a FilterEnd"
2587                );
2588            }
2589            _ => panic!("cannot push to {id:?}"),
2590        }
2591    }
2592
2593    /// Push a change *through* a filter-chain link, returning the change(s) it
2594    /// hands back to its caller. The filter sub-graph is a **pure transform
2595    /// region**: links gate/split rows and never touch sink state (the
2596    /// side-effecting sink lives downstream of `FilterEnd`). So a link RETURNS its
2597    /// output rather than pushing it onward — which is what lets the OR fan
2598    /// accumulate branch results **on the call stack** (lifetime `'g`) instead of
2599    /// in a self-referential `FanIn` field (see the chassis note on the operators).
2600    ///
2601    /// Returns a non-empty vec ONLY at a `FanIn` (a branch terminator handing its
2602    /// change up to the owning `FanOut`). `FilterEnd` side-effects the real
2603    /// downstream push and returns empty; `Filter`/`FilterProbe`/`FanOut` recurse.
2604    pub(crate) fn chain_push<'g>(&'g self, link: NodeId, change: Change<'g>) -> Vec<Change<'g>> {
2605        match self.node(link) {
2606            Operator::Filter(_) => self.filter_chain_push(link, change),
2607            Operator::FilterProbe(p) => {
2608                // Pass-through gate (proof instrumentation has no predicate).
2609                let out = p.output.get().expect("FilterProbe output not wired");
2610                self.chain_push(out, change)
2611            }
2612            Operator::FanOut(_) => self.fan_out_push(link, change),
2613            Operator::Exists(e) => e.push_chain(self, change),
2614            // Branch terminator: hand the change up to the owning FanOut, which
2615            // owns the accumulation + collapse.
2616            Operator::FanIn(_) => vec![change],
2617            // Chain terminator: exit the sub-graph — the real downstream push.
2618            Operator::FilterEnd(e) => {
2619                let out = e.output.get().expect("FilterEnd output not wired");
2620                self.push(out.node, change, out.port);
2621                Vec::new()
2622            }
2623            _ => panic!("chain_push: not a filter-chain link ({link:?})"),
2624        }
2625    }
2626
2627    /// `Filter` link push: `filterPush` + the Edit split (`07` §3.4,
2628    /// `maybe-split-and-push-edit-change.ts`). Add/Remove/Child pass through iff
2629    /// the predicate holds; an Edit splits on `(pred(old), pred(new))`.
2630    fn filter_chain_push<'g>(&'g self, link: NodeId, change: Change<'g>) -> Vec<Change<'g>> {
2631        let out = self
2632            .filter_op(link)
2633            .output
2634            .get()
2635            .expect("Filter output not wired");
2636        match change {
2637            Change::Add(n) | Change::Remove(n) if !self.filter_op(link).pred.eval(&n.row) => {
2638                let _ = n; // predicate failed → drop
2639                Vec::new()
2640            }
2641            Change::Add(n) => self.chain_push(out, Change::Add(n)),
2642            Change::Remove(n) => self.chain_push(out, Change::Remove(n)),
2643            Change::Edit { node, old } => {
2644                let pred = &self.filter_op(link).pred;
2645                match (pred.eval(&old.row), pred.eval(&node.row)) {
2646                    (true, true) => self.chain_push(out, Change::Edit { node, old }),
2647                    (true, false) => self.chain_push(out, Change::Remove(old)),
2648                    (false, true) => self.chain_push(out, Change::Add(node)),
2649                    (false, false) => Vec::new(),
2650                }
2651            }
2652            Change::Child { node, rel, child } => {
2653                // `filterPush` (`filter-push.ts:26`): a Child passes through unchanged
2654                // iff the predicate holds on the change's node row; else it is dropped.
2655                // (The nested sub-change rides along — a leaf Filter gates the parent
2656                // row, it never reshapes the child.)
2657                if self.filter_op(link).pred.eval(&node.row) {
2658                    self.chain_push(out, Change::Child { node, rel, child })
2659                } else {
2660                    Vec::new()
2661                }
2662            }
2663        }
2664    }
2665
2666    /// `FanOut` push (`fan-out.ts:74`): replay the change to every branch, then
2667    /// collapse what the branches forwarded into exactly one change per type
2668    /// (`push_accumulated_changes`, `06` §3.5) and continue down the post-fan
2669    /// chain. The collapse is the dedup that stops an OR from double-counting a
2670    /// row that several branches keep.
2671    fn fan_out_push<'g>(&'g self, link: NodeId, change: Change<'g>) -> Vec<Change<'g>> {
2672        // Clone the branch list to a local — never hold the RefCell borrow across
2673        // the reentrant branch pushes (the cardinal rule, `01` §6).
2674        let branches = self.fan_out_op(link).outputs.borrow().clone();
2675        let fan_out_type = change.change_type();
2676
2677        // Materialize the change once, rebuild a fresh copy per branch (the owned-change
2678        // model — a `Change<'g>` is neither `Clone` nor `'static`). The broadcast
2679        // **preserves relationships for every change type**: an `Exists` branch counts
2680        // its gated relationship off the change's node — on an Add/Remove/Edit (a
2681        // parent membership-test) as well as a Child (a relationship-size flip) — so
2682        // every branch's copy must carry it. A leaf-`Filter` branch reads only
2683        // `node.row` and ignores the rest; a leaf node (a pure-predicate fan with no
2684        // join above) materializes back to a leaf, so this stays cheap there.
2685        let owned = materialize_change_preserving_node(change);
2686        let mut accumulated: Vec<Change<'g>> = Vec::new();
2687        for b in &branches {
2688            accumulated.extend(self.chain_push(*b, rebuild_change(owned.clone())));
2689        }
2690
2691        let collapsed = collapse_accumulated(accumulated, fan_out_type);
2692
2693        let cont = self.fan_continuation(link);
2694        let mut leftover = Vec::new();
2695        for c in collapsed {
2696            match cont {
2697                Some(next) => leftover.extend(self.chain_push(next, c)),
2698                None => leftover.push(c),
2699            }
2700        }
2701        leftover
2702    }
2703
2704    fn join_push<'g>(&'g self, jid: NodeId, change: Change<'g>, port: Port) {
2705        let j = self.join(jid);
2706        let out = j.output.get().expect("join output not wired");
2707        let parent_key = j.parent_key.clone();
2708        let child_key = j.child_key.clone();
2709        let parent = j.parent;
2710        let child = j.child;
2711        match port {
2712            // Parent side (`join.ts#pushParent`): re-process the parent node (which
2713            // re-attaches this join's child relationship) and forward, preserving
2714            // change type.
2715            Port::JoinParent => match change {
2716                Change::Add(node) => {
2717                    // Design 311 §2.3: membership maintenance at the entry of the arm.
2718                    self.join_precheck_parent_add(jid, &node.row);
2719                    let pn = self.process_parent_node(jid, node);
2720                    self.push(out.node, Change::Add(pn), out.port);
2721                }
2722                Change::Remove(node) => {
2723                    self.join_precheck_parent_remove(jid, &node.row);
2724                    // Keep the parent row to evict its child partition AFTER the
2725                    // downstream has consumed the removed subtree (which drains the
2726                    // child thunk against the still-present partition).
2727                    let key_row = node.row.clone();
2728                    let pn = self.process_parent_node(jid, node);
2729                    self.push(out.node, Change::Remove(pn), out.port);
2730                    // The parent left a (bounded) parent view. Evict the child
2731                    // limiter partition it hydrated so partitioned child Takes/Caps do
2732                    // not leak one slot per distinct parent that ever passed through the
2733                    // view — but ONLY when the parent correlation key is the parent's
2734                    // primary key, so the evicted partition belongs to exactly this one
2735                    // parent (a non-unique parent key could share a child partition with
2736                    // a still-live sibling parent). See `Take::evict_partition`.
2737                    if self.input_schema(parent).primary_key == parent_key {
2738                        if let Some(c) = build_join_constraint(&key_row, &parent_key, &child_key) {
2739                            self.evict_child_partitions(child, &c);
2740                        }
2741                    }
2742                }
2743                Change::Edit { node, old } => {
2744                    // A parent edit MUST NOT change the join key (`join.ts:167`);
2745                    // otherwise the relationship membership would change and this
2746                    // would not be a simple edit. Data-reachable (a malformed change
2747                    // stream): strict ⇒ park a `ConsistencyViolation` and skip the
2748                    // push (the boundary `take_runtime_error` re-raises it); non-strict
2749                    // ⇒ `debug_assert!` (WS02.2).
2750                    if !row_equals_for_compound_key(&old.row, &node.row, &parent_key) {
2751                        if self.validate_changes.get() {
2752                            self.park_runtime_error(RindleError::ConsistencyViolation {
2753                                kind: "parent edit changed the join key",
2754                            });
2755                            return;
2756                        }
2757                        debug_assert!(
2758                            false,
2759                            "parent edit must not change the join relationship key"
2760                        );
2761                    }
2762                    // A key never changes (enforced above under strict validation); what
2763                    // an Edit CAN change is a sort column — the row's position relative to
2764                    // the region (§2.3). The non-strict key-changing case is covered too.
2765                    self.join_precheck_parent_edit(jid, &old.row, &node.row);
2766                    let pn = self.process_parent_node(jid, node);
2767                    let po = self.process_parent_node(jid, old);
2768                    self.push(out.node, Change::Edit { node: pn, old: po }, out.port);
2769                }
2770                Change::Child { node, rel, child } => {
2771                    // Passthrough (`join.ts:154`): a relationship *above* this join
2772                    // changed; re-process the parent (re-attaching our rel) and
2773                    // forward the original child change untouched.
2774                    let pn = self.process_parent_node(jid, node);
2775                    self.push(
2776                        out.node,
2777                        Change::Child {
2778                            node: pn,
2779                            rel,
2780                            child,
2781                        },
2782                        out.port,
2783                    );
2784                }
2785            },
2786            // Child side (`join.ts#pushChild`): re-enter the parent fetch with the
2787            // child's key and emit a Child change per matching parent. The full child
2788            // change is carried up — Add/Remove/Edit carry the (re-derivable) child
2789            // node, and a **nested `Change::Child`** (the *deepest* push, e.g. a
2790            // reaction under `issue{comments{reactions}}`) carries the inner change
2791            // verbatim so the View/Catch recurse into it. [`push_child_change`]
2792            // materializes the change once and rebuilds a fresh copy per parent.
2793            Port::JoinChild => {
2794                if let Change::Edit { node, old } = &change {
2795                    // Same join-key-immutability contract as the parent-edit check
2796                    // above (WS02.2): strict ⇒ park a `ConsistencyViolation` + skip;
2797                    // non-strict ⇒ `debug_assert!`.
2798                    if !row_equals_for_compound_key(&old.row, &node.row, &child_key) {
2799                        if self.validate_changes.get() {
2800                            self.park_runtime_error(RindleError::ConsistencyViolation {
2801                                kind: "child edit changed the join key",
2802                            });
2803                            return;
2804                        }
2805                        debug_assert!(
2806                            false,
2807                            "child edit must not change the join relationship key"
2808                        );
2809                    }
2810                }
2811                self.push_child_change(jid, change);
2812            }
2813            // PANIC-CLASS: internal — a Join is only ever dispatched on JoinParent /
2814            // JoinChild; a Single port here is an engine dispatch/wiring bug, not data.
2815            Port::Single => panic!("join received Single port"),
2816        }
2817    }
2818
2819    /// The reentrant crux (`join.ts#pushChildChange`): build a parent constraint
2820    /// from the changed child's row, re-enter `parent.fetch` *mid-push*, and for each
2821    /// matching parent emit a `Change::Child` carrying the child change.
2822    ///
2823    /// `child_change` is the **full** downstream change on this join's child input —
2824    /// `Add`/`Remove`/`Edit` of a child row, or a nested `Change::Child` (the
2825    /// *deepest* push: a relationship under this child changed). A child can join to
2826    /// many parents, so the change is materialized **once** (its thunks drained now,
2827    /// under the active source overlay) and a fresh `Change<'g>` is rebuilt per
2828    /// parent. The row-level overlay (`Add`/`Remove`/`Edit` only) is published on the
2829    /// join as a LIVE field ([`Join::inprogress_overlay`]) so a reentrant refetch
2830    /// during the downstream push sees PRE-change membership for not-yet-processed
2831    /// parents (Zero `join.ts#pushChildChange`); a nested `Child` carries no overlay
2832    /// (see [`JoinOverlay::for_child_change`]).
2833    fn push_child_change<'g>(&'g self, jid: NodeId, child_change: Change<'g>) {
2834        let j = self.join(jid);
2835        let out = j.output.get().expect("join output not wired");
2836        let rel_slot = j.rel_slot;
2837        let parent = j.parent;
2838        // The key row is the changed-child's row (an Edit keeps the same join key,
2839        // asserted on entry; a nested Child carries the unchanged child row), so the
2840        // constraint and the per-parent overlay agree.
2841        let key_row = child_change.primary_row().clone();
2842        let constraint = match build_join_constraint(&key_row, &j.child_key, &j.parent_key) {
2843            Some(c) => c,
2844            None => return, // null child key cannot join
2845        };
2846        // The join membership pre-check (design 311 §2.2): one hash probe against the
2847        // counted parent-key region. A miss means every parent this child could join to
2848        // is either absent (complete region) or sorts beyond the frontier, where nothing
2849        // downstream can observe it (§3.2) — so the whole fan-out is skipped. A hit, an
2850        // unbuilt or a disabled set all run today's path untouched.
2851        if self.join_precheck_probe(jid, &constraint) == Probe::Miss {
2852            self.debug_assert_join_precheck_miss_sound(jid, &constraint);
2853            return;
2854        }
2855        let overlay = JoinOverlay::for_child_change(&child_change);
2856        // An Edit's `old` subtree is consumed by no downstream (View reduces Edit→rows;
2857        // Exists gates only the new node), and the join-key-immutability contract makes
2858        // it identical to the already-materialized one anyway — so materialize it
2859        // row-only, skipping the reentrant leaf refetch of the whole old child subtree.
2860        let owned = materialize_change_edit_old_row_only(child_change);
2861
2862        // Publish the in-flight child overlay LIVE; the guard clears it on every exit
2863        // (the JS `try/finally`, `join.ts:222-249`).
2864        *self.join(jid).inprogress_overlay.borrow_mut() = overlay;
2865        let _guard = InprogressGuard { g: self, jid };
2866
2867        // Collect the reentrant parent fetch (with relationships preserved) so we
2868        // don't hold its `&self` stream borrow across the downstream push.
2869        let parents: Vec<Node> = self
2870            .fetch(parent, &FetchRequest::with_constraint(constraint))
2871            .collect();
2872
2873        // The unbounded join fan-out (RUNAWAY-PUSH-FINDINGS §3): one child change pushes
2874        // one `Change::Child` per matching parent, O(N) with no downstream-limit awareness
2875        // — THE loop a runaway push spins in, so it carries a deadline checkpoint (§6.6). The
2876        // tick counter is graph-level (shared across the whole armed batch), so many small
2877        // changes accumulate toward the checkpoint too — not just one huge single-change fan-out.
2878        for pnode in parents {
2879            if self.push_deadline_exceeded() {
2880                self.park_runtime_error(RindleError::PushDeadlineExceeded {
2881                    site: "join child fan-out",
2882                });
2883                break; // torn is fine — the host discards the engine (abort≙epoch-rehydrate)
2884            }
2885            // Advance the in-flight position to the parent now being delivered; the
2886            // overlay applies only to parents sorting strictly after it.
2887            *self.join(jid).inprogress_position.borrow_mut() = Some(pnode.row.clone());
2888            let pn = self.process_parent_node(jid, pnode);
2889            let child = Box::new(rebuild_change(owned.clone()));
2890            self.push(
2891                out.node,
2892                Change::Child {
2893                    node: pn,
2894                    rel: rel_slot,
2895                    child,
2896                },
2897                out.port,
2898            );
2899        }
2900    }
2901
2902    // -------------------------------------------------------------------
2903    // The join membership pre-check (design 311): knobs, observation,
2904    // maintenance, probe. The set itself is `op::join_util::ParentKeySet`.
2905    // -------------------------------------------------------------------
2906
2907    /// Set the join membership pre-check's bounds (design 311 §2.5 / §8): the per-join
2908    /// distinct-key bound (`None` = off, the default) and the per-graph key budget.
2909    /// Host-settable on a built `&Graph` like [`set_validate_changes`](Self::set_validate_changes).
2910    ///
2911    /// A change **resets every join's set to `Unbuilt`** (and the tracked total to 0): a
2912    /// set built under a looser bound could be over the new one, and a set left `Active`
2913    /// while the feature is off would silently go stale (maintenance stops with it) and
2914    /// lie once it is turned back on. Rebuilding happens by observation on the next
2915    /// unconstrained enumeration — never by a fetch of its own — so flipping the knob on
2916    /// a live graph is safe at any time.
2917    pub fn set_join_precheck_bounds(&self, per_join: Option<usize>, per_graph: usize) {
2918        let bounds = JoinPrecheckBounds {
2919            per_join,
2920            per_graph,
2921        };
2922        if self.join_precheck.0.get() == bounds {
2923            return;
2924        }
2925        self.join_precheck.0.set(bounds);
2926        for op in &self.nodes {
2927            if let Operator::Join(j) = op {
2928                j.precheck.borrow_mut().reset(&self.join_precheck_tracked);
2929            }
2930        }
2931        debug_assert_eq!(
2932            self.join_precheck_tracked.get(),
2933            0,
2934            "every tracked key belongs to some live join's set"
2935        );
2936    }
2937
2938    /// The current pre-check bounds.
2939    pub fn join_precheck_bounds(&self) -> JoinPrecheckBounds {
2940        self.join_precheck.0.get()
2941    }
2942
2943    /// The per-graph pre-check counters (probe hits/misses, disables by reason).
2944    pub fn join_precheck_stats(&self) -> JoinPrecheckStats {
2945        self.join_precheck_stats.get()
2946    }
2947
2948    /// Distinct parent keys tracked across every join right now (the live charge
2949    /// against the `per_graph` budget).
2950    pub fn join_precheck_tracked_keys(&self) -> usize {
2951        self.join_precheck_tracked.get()
2952    }
2953
2954    /// Inspect one join's set. # Panics if `join` is not a live `Join` node.
2955    pub fn join_precheck_state(&self, join: NodeId) -> JoinPrecheckState {
2956        match &*self.join(join).precheck.borrow() {
2957            ParentKeySet::Unbuilt => JoinPrecheckState::Unbuilt,
2958            set @ ParentKeySet::Active { frontier, .. } => JoinPrecheckState::Active {
2959                distinct_keys: set.distinct_keys(),
2960                complete: matches!(frontier, Frontier::Complete),
2961            },
2962            ParentKeySet::Disabled => JoinPrecheckState::Disabled,
2963        }
2964    }
2965
2966    /// The pre-check state of every live `Join` among `nodes` (a pipeline manifest's node
2967    /// list), in the order given; non-join and stale ids are skipped. The per-query
2968    /// inspection hook a host with several pipelines on one graph uses (the replica
2969    /// worker's `join_precheck_report`). Inspection-only: linear in the graph's joins per
2970    /// manifest node.
2971    pub fn join_precheck_states_of(&self, nodes: &[NodeId]) -> Vec<JoinPrecheckState> {
2972        let all = self.join_precheck_states();
2973        nodes
2974            .iter()
2975            .filter_map(|id| all.iter().find(|(j, _)| j == id).map(|(_, s)| s.clone()))
2976            .collect()
2977    }
2978
2979    /// Every live `Join` node with its pre-check state, in arena order — the inspection
2980    /// hook for a pipeline built by `build_pipeline` (whose join ids are not handed back).
2981    pub fn join_precheck_states(&self) -> Vec<(NodeId, JoinPrecheckState)> {
2982        self.nodes
2983            .iter()
2984            .enumerate()
2985            .filter(|(_, op)| matches!(op, Operator::Join(_)))
2986            .map(|(i, _)| {
2987                let id = NodeId::new(i as u32, self.node_gens[i]);
2988                (id, self.join_precheck_state(id))
2989            })
2990            .collect()
2991    }
2992
2993    fn join_precheck_stat(&self, f: impl FnOnce(&mut JoinPrecheckStats)) {
2994        let mut st = self.join_precheck_stats.get();
2995        f(&mut st);
2996        self.join_precheck_stats.set(st);
2997    }
2998
2999    /// The **eligibility walk** (design 311 §2.4), computed once per join and cached: the
3000    /// join must sit on the **root chain** — its parent side reaches the source through
3001    /// row-preserving nodes only, and its output reaches the sink through the same
3002    /// passthroughs plus at most one unpartitioned `Take`. Anything else (a partitioned
3003    /// limiter, a union fan, a reduce, a nested join whose enumeration is per outer
3004    /// parent) means the parent enumeration this join sees is never a sorted prefix of
3005    /// anything, so the region argument of §3.2 does not apply — the join is `Disabled`.
3006    fn join_precheck_eligible(&self, jid: NodeId) -> bool {
3007        let j = self.join(jid);
3008        if let Some(e) = j.precheck_eligible.get() {
3009            return e;
3010        }
3011        let eligible = self.join_precheck_on_root_chain(jid);
3012        j.precheck_eligible.set(Some(eligible));
3013        if !eligible {
3014            j.precheck.borrow_mut().disable(&self.join_precheck_tracked);
3015            self.join_precheck_stat(|s| s.disabled_ineligible += 1);
3016            metric_inc!(join_precheck_disabled_ineligible);
3017        }
3018        eligible
3019    }
3020
3021    fn join_precheck_on_root_chain(&self, jid: NodeId) -> bool {
3022        let j = self.join(jid);
3023        // Upstream: from the parent input toward the source.
3024        let mut cur = j.parent;
3025        loop {
3026            match self.node(cur) {
3027                Operator::SourceConn(_) => break,
3028                Operator::Skip(s) => cur = s.input,
3029                Operator::FilterStart(s) => cur = s.input,
3030                Operator::FilterEnd(e) => cur = e.start,
3031                // A limiter above the join is fine while it is a single global window:
3032                // the join then sees exactly that window, exhaustively. A partitioned one
3033                // enumerates per outer parent — never a sorted prefix.
3034                Operator::Take(t) if t.partition_key.is_none() => cur = t.input,
3035                Operator::Cap(c) if c.partition_key.is_none() => cur = c.input,
3036                // A sibling relationship join feeding our parent port is row-preserving
3037                // on the parent stream (its own child pushes arrive here as `Child`).
3038                Operator::Join(pj) => cur = pj.parent,
3039                #[cfg(any(test, feature = "testkit"))]
3040                Operator::Snitch(s) => cur = s.input,
3041                _ => return false,
3042            }
3043        }
3044        // Downstream: from the output edge to the sink.
3045        let mut edge = j.output.get();
3046        let mut limiters = 0u8;
3047        loop {
3048            let Some(e) = edge else {
3049                return false; // unwired — never happens on a built pipeline
3050            };
3051            match e.port {
3052                // This join is a nested child top: its parent enumeration is constrained
3053                // per outer parent (§4, "nested joins").
3054                Port::JoinChild => return false,
3055                Port::JoinParent => {
3056                    edge = match self.node(e.node) {
3057                        Operator::Join(nj) => nj.output.get(),
3058                        _ => return false,
3059                    };
3060                }
3061                Port::Single => match self.node(e.node) {
3062                    Operator::View(_) | Operator::Collector(_) => return true,
3063                    #[cfg(any(test, feature = "testkit"))]
3064                    Operator::Catch(_) => return true,
3065                    Operator::Skip(s) => edge = s.output.get(),
3066                    Operator::Take(t) if t.partition_key.is_none() && limiters == 0 => {
3067                        limiters += 1;
3068                        edge = t.output.get();
3069                    }
3070                    // The `where` sub-graph: every chain link is a stateless gate (an
3071                    // `Exists` forwards or converts a `Child`, never persists it), so walk
3072                    // through to the `FilterEnd`'s downstream edge.
3073                    Operator::FilterStart(fs) => edge = self.filter_chain_tail(fs),
3074                    #[cfg(any(test, feature = "testkit"))]
3075                    Operator::Snitch(s) => {
3076                        edge = s.output().map(|node| OutEdge {
3077                            node,
3078                            port: Port::Single,
3079                        })
3080                    }
3081                    _ => return false,
3082                },
3083            }
3084        }
3085    }
3086
3087    /// Follow a `FilterStart`'s chain to its `FilterEnd` and return that end's downstream
3088    /// edge (`None` if the chain does not terminate in a wired `FilterEnd`).
3089    fn filter_chain_tail(&self, fs: &FilterStart) -> Option<OutEdge> {
3090        let mut link = fs.chain_head.get()?;
3091        loop {
3092            link = match self.node(link) {
3093                Operator::Filter(f) => f.output.get()?,
3094                Operator::FilterProbe(p) => p.output.get()?,
3095                Operator::Exists(e) => e.output.get()?,
3096                Operator::FanOut(_) => self.fan_continuation(link)?,
3097                Operator::FanIn(f) => f.output.get()?,
3098                Operator::FilterEnd(fe) => return fe.output.get(),
3099                _ => return None,
3100            };
3101        }
3102    }
3103
3104    /// Should this fetch through join `jid` be **observed** (design 311 §2.4)? Only an
3105    /// unconstrained, forward request can build or advance a region:
3106    ///
3107    /// - `Unbuilt`: start a fresh region iff the request has no `start` (its enumeration
3108    ///   begins at −∞, so the region it leaves behind is `[-∞, last yielded]`). A request
3109    ///   that carries a `start` cannot be told apart from a downstream `Take`'s refill
3110    ///   scan, and starting a region from one would leave the window below that start
3111    ///   uncovered — so it never starts one (see the design's §12 implementation note).
3112    /// - `Active` at a finite frontier: advance iff the request is forward and starts at
3113    ///   or before the frontier (or has no start) — rows are then yielded contiguously
3114    ///   from the start, so nothing between the old and new frontier is skipped.
3115    /// - `Complete` or `Disabled`: nothing to learn.
3116    fn join_precheck_observation(&self, jid: NodeId, req: &FetchRequest) -> bool {
3117        if self.join_precheck.0.get().per_join.is_none() {
3118            return false;
3119        }
3120        if req.constraint.is_some() || !req.multi_constraints.is_empty() || req.reverse {
3121            return false;
3122        }
3123        let j = self.join(jid);
3124        // Read the state under a short borrow; the eligibility walk below touches other
3125        // nodes (never this cell), but the cardinal rule is no borrow across a walk.
3126        let decision = match &*j.precheck.borrow() {
3127            ParentKeySet::Unbuilt => None,
3128            ParentKeySet::Disabled => Some(false),
3129            ParentKeySet::Active {
3130                frontier: Frontier::Complete,
3131                ..
3132            } => Some(false),
3133            ParentKeySet::Active {
3134                frontier: Frontier::At(f),
3135                sort,
3136                ..
3137            } => Some(
3138                req.start
3139                    .as_ref()
3140                    .is_none_or(|s| compare_rows(sort, &s.row, f) != std::cmp::Ordering::Greater),
3141            ),
3142        };
3143        match decision {
3144            Some(d) => d,
3145            None => req.start.is_none() && self.join_precheck_eligible(jid),
3146        }
3147    }
3148
3149    /// One parent row yielded by an observed enumeration (§2.4): the transition is
3150    /// [`ParentKeySet::observe_row`] (walked exhaustively in `join_util.rs`'s tests); this arm
3151    /// resolves the parent sort for an `Unbuilt` set — before borrowing the set, since
3152    /// that walks the operator chain — and counts a trip.
3153    fn join_precheck_observe_row(&self, jid: NodeId, row: &Row) {
3154        let bounds = self.join_precheck.0.get();
3155        let Some(per_join) = bounds.per_join else {
3156            return;
3157        };
3158        let j = self.join(jid);
3159        let sort = matches!(&*j.precheck.borrow(), ParentKeySet::Unbuilt)
3160            .then(|| self.input_sort(j.parent));
3161        let tripped = j
3162            .precheck
3163            .borrow_mut()
3164            .observe_row(
3165                row,
3166                &j.parent_key,
3167                sort,
3168                per_join,
3169                bounds.per_graph,
3170                &self.join_precheck_tracked,
3171            )
3172            .is_err();
3173        if tripped {
3174            self.join_precheck_stat(|s| s.disabled_observation += 1);
3175            metric_inc!(join_precheck_disabled_observation);
3176        }
3177    }
3178
3179    /// An observed enumeration ran the parent input to exhaustion: the region is the
3180    /// whole input ([`ParentKeySet::observe_exhausted`]).
3181    fn join_precheck_observe_exhausted(&self, jid: NodeId) {
3182        if self.join_precheck.0.get().per_join.is_none() {
3183            return;
3184        }
3185        let j = self.join(jid);
3186        let sort = matches!(&*j.precheck.borrow(), ParentKeySet::Unbuilt)
3187            .then(|| self.input_sort(j.parent));
3188        j.precheck.borrow_mut().observe_exhausted(sort);
3189    }
3190
3191    /// Maintenance (§2.3), `Add` on the parent port ([`ParentKeySet::parent_add`]).
3192    fn join_precheck_parent_add(&self, jid: NodeId, row: &Row) {
3193        let bounds = self.join_precheck.0.get();
3194        let Some(per_join) = bounds.per_join else {
3195            return;
3196        };
3197        let j = self.join(jid);
3198        let tripped = j
3199            .precheck
3200            .borrow_mut()
3201            .parent_add(
3202                row,
3203                &j.parent_key,
3204                per_join,
3205                bounds.per_graph,
3206                &self.join_precheck_tracked,
3207            )
3208            .is_err();
3209        if tripped {
3210            self.join_precheck_stat(|s| s.disabled_maintenance += 1);
3211            metric_inc!(join_precheck_disabled_maintenance);
3212        }
3213    }
3214
3215    /// Maintenance (§2.3), `Remove` on the parent port ([`ParentKeySet::parent_remove`]).
3216    fn join_precheck_parent_remove(&self, jid: NodeId, row: &Row) {
3217        if self.join_precheck.0.get().per_join.is_none() {
3218            return;
3219        }
3220        let j = self.join(jid);
3221        let tripped = j
3222            .precheck
3223            .borrow_mut()
3224            .parent_remove(row, &j.parent_key, &self.join_precheck_tracked)
3225            .is_err();
3226        if tripped {
3227            self.join_precheck_stat(|s| s.disabled_maintenance += 1);
3228            metric_inc!(join_precheck_disabled_maintenance);
3229        }
3230    }
3231
3232    /// Maintenance (§2.3), `Edit` on the parent port ([`ParentKeySet::parent_edit`]).
3233    fn join_precheck_parent_edit(&self, jid: NodeId, old: &Row, new: &Row) {
3234        let bounds = self.join_precheck.0.get();
3235        let Some(per_join) = bounds.per_join else {
3236            return;
3237        };
3238        let j = self.join(jid);
3239        let tripped = j
3240            .precheck
3241            .borrow_mut()
3242            .parent_edit(
3243                old,
3244                new,
3245                &j.parent_key,
3246                per_join,
3247                bounds.per_graph,
3248                &self.join_precheck_tracked,
3249            )
3250            .is_err();
3251        if tripped {
3252            self.join_precheck_stat(|s| s.disabled_maintenance += 1);
3253            metric_inc!(join_precheck_disabled_maintenance);
3254        }
3255    }
3256
3257    /// The probe (§2.2): is the constraint's parent key inside the region?
3258    fn join_precheck_probe(&self, jid: NodeId, constraint: &Constraint) -> Probe {
3259        if self.join_precheck.0.get().per_join.is_none() {
3260            return Probe::Unknown;
3261        }
3262        let j = self.join(jid);
3263        let set = j.precheck.borrow();
3264        let probe = match &*set {
3265            ParentKeySet::Active { .. } => set.probe(&canonical_key_of_constraint(constraint)),
3266            // A join that is probed before it was ever observed unconstrained is
3267            // classified now (cached), so a nested join — whose enumeration is always
3268            // constrained and so is never observed — still lands in `Disabled` and the
3269            // `ineligible` counter, rather than lingering as `Unbuilt`.
3270            ParentKeySet::Unbuilt => {
3271                drop(set);
3272                self.join_precheck_eligible(jid);
3273                return Probe::Unknown;
3274            }
3275            ParentKeySet::Disabled => return Probe::Unknown,
3276        };
3277        drop(set);
3278        match probe {
3279            Probe::Hit => {
3280                self.join_precheck_stat(|s| s.probe_hits += 1);
3281                metric_inc!(join_probe_hit);
3282            }
3283            Probe::Miss => {
3284                self.join_precheck_stat(|s| s.probe_misses += 1);
3285                metric_inc!(join_probe_miss);
3286            }
3287            Probe::Unknown => {}
3288        }
3289        probe
3290    }
3291
3292    /// The debug soundness net (§2.6), in the style of 205's `debug_assert_index_sound`:
3293    /// on a probe **miss**, run the fetch the probe skipped and assert every parent it
3294    /// returns sorts **outside** the region — `[-∞, frontier]` — i.e. that skipping it
3295    /// dropped no delta. Compiled out of release.
3296    fn debug_assert_join_precheck_miss_sound(&self, jid: NodeId, constraint: &Constraint) {
3297        #[cfg(debug_assertions)]
3298        {
3299            let j = self.join(jid);
3300            let (frontier, sort) = match &*j.precheck.borrow() {
3301                ParentKeySet::Active { frontier, sort, .. } => (frontier.clone(), sort.clone()),
3302                _ => return,
3303            };
3304            let parents: Vec<Row> = self
3305                .fetch(j.parent, &FetchRequest::with_constraint(constraint.clone()))
3306                .map(|n| n.row)
3307                .collect();
3308            for p in &parents {
3309                match &frontier {
3310                    Frontier::Complete => debug_assert!(
3311                        false,
3312                        "join pre-check missed on a complete region but a matching parent exists — a dropped delta"
3313                    ),
3314                    Frontier::At(f) => debug_assert!(
3315                        compare_rows(&sort, p, f) == std::cmp::Ordering::Greater,
3316                        "join pre-check missed but a matching parent sorts inside the region — a dropped delta"
3317                    ),
3318                }
3319            }
3320        }
3321        #[cfg(not(debug_assertions))]
3322        {
3323            let _ = (jid, constraint);
3324        }
3325    }
3326
3327    // -------------------------------------------------------------------
3328    // Source push: fan the change out to every connection (overlay active),
3329    // then clear the overlay, then commit the write. The orchestration
3330    // (edit-split, existence asserts, write-after-drain) lives in
3331    // `source_common`; the graph only supplies the downstream driver.
3332    // -------------------------------------------------------------------
3333
3334    /// Infallible push (tests / prototyping).
3335    ///
3336    /// # Panics
3337    /// On any `RindleError` (SQLite I/O, a strict consistency violation, …). Prefer
3338    /// [`try_source_push`](Self::try_source_push) in production so the error is
3339    /// handled, not aborted (WS02.6 fault model).
3340    pub fn source_push(&self, src_id: NodeId, change: SourceChange) {
3341        self.try_source_push(src_id, change)
3342            .expect("source push failed")
3343    }
3344
3345    pub fn try_source_push(&self, src_id: NodeId, change: SourceChange) -> Result<(), RindleError> {
3346        // NOT timed here. The apply path pushes a batch's rows one at a time, so a
3347        // per-call timer was a per-ROW clock pair (~15ns each) whose samples all landed in
3348        // the histogram's first bucket anyway. Latency is measured per BATCH by the caller
3349        // that knows its own batch boundary — see `rindle::metrics::ApplyBatch`. The
3350        // per-change counter below stays: it is one relaxed add with no clock read, and it
3351        // is what keeps mean per-row cost recoverable from the batch histogram.
3352        let s = self.source(src_id);
3353        let strict = self.validate_changes.get();
3354        // Classify before `change` is moved into the push (compiled out when the
3355        // `metrics` feature is off); count `rindle.changes.processed` only after the
3356        // push succeeds below — a rejected change was not processed.
3357        let _change_kind = metric_change_kind!(&change);
3358        // The connection boundary on the write path: the source fans out a
3359        // `SourceChange` (rows); the `SourceConn` wraps it into a node-bearing
3360        // downstream `Change` (leaf nodes — joins attach relationships later).
3361        s.try_push(
3362            change,
3363            &|conn: &Connection, sc: SourceChange| {
3364                if let Some(edge) = conn.output.get() {
3365                    self.push(edge.node, source_change_to_node(sc), edge.port);
3366                }
3367            },
3368            strict,
3369        )?;
3370        metric_changes_inc!(_change_kind);
3371        self.take_runtime_error()
3372    }
3373
3374    /// Server fault boundary (WS02.5): run one mutation under `catch_unwind` so a
3375    /// *residual* — unconverted, genuinely-unexpected — panic during this push does
3376    /// not abort the whole process (which, serving many connections, would take them
3377    /// all down). A caught panic becomes a `RindleError::Storage("internal panic: …")`.
3378    ///
3379    /// Effective only under `panic = "unwind"` (the `release-server` profile, D2);
3380    /// under `panic = "abort"` (the default/wasm client) a panic aborts regardless
3381    /// and this just forwards the result. It is the *last-resort net* **after** the
3382    /// WS02.1–02.4 conversions of the expected data-reachable panics — not a
3383    /// substitute for them.
3384    ///
3385    /// **Recovery contract: fail the connection, do not retry in place.** A panic
3386    /// mid-mutation can leave operator/view state logically torn (a `RefCell` is
3387    /// released on unwind — *not* poisoned, so the graph is still *usable*, just
3388    /// possibly inconsistent). The owning connection MUST discard and re-hydrate the
3389    /// view from source before serving further reads (WS09.6), never reuse it.
3390    pub fn source_push_isolated(
3391        &self,
3392        src_id: NodeId,
3393        change: SourceChange,
3394    ) -> Result<(), RindleError> {
3395        use std::panic::{catch_unwind, AssertUnwindSafe};
3396        match catch_unwind(AssertUnwindSafe(|| self.try_source_push(src_id, change))) {
3397            Ok(result) => result,
3398            Err(payload) => {
3399                // The containment fired: count it (208.5) so a rising `mutation_panics_total`
3400                // flags a bad mutation loose in the graph. No-op with `metrics` off.
3401                metric_inc!(mutation_panics);
3402                let msg = if let Some(s) = payload.downcast_ref::<&str>() {
3403                    (*s).to_string()
3404                } else if let Some(s) = payload.downcast_ref::<String>() {
3405                    s.clone()
3406                } else {
3407                    "unknown panic".to_string()
3408                };
3409                Err(RindleError::Storage(format!(
3410                    "internal panic during push: {msg}"
3411                )))
3412            }
3413        }
3414    }
3415
3416    // -------------------------------------------------------------------
3417    // View: hydrate / push / flush / listeners (the `array-view.ts` halves;
3418    // the pure folding lives in `crate::view::apply_change`).
3419    // -------------------------------------------------------------------
3420
3421    /// Build the initial view by draining the input pipeline (`#hydrate`,
3422    /// `array-view.ts:140`). The fetch runs here (the `Graph` borrow stays with the
3423    /// caller); the [`View`](crate::view::View) folds each node `InPlace` and flushes
3424    /// once. No `RefCell` borrow is held across the fetch (the root is taken out
3425    /// inside `hydrate_from`, after this iterator is constructed).
3426    /// Infallible hydrate (tests / prototyping).
3427    ///
3428    /// # Panics
3429    /// On any `RindleError` from the initial fetch. Prefer
3430    /// [`try_hydrate`](Self::try_hydrate) in production (WS02.6 fault model).
3431    pub fn hydrate(&self, view_id: NodeId) {
3432        self.try_hydrate(view_id).expect("hydrate failed")
3433    }
3434
3435    pub fn try_hydrate(&self, view_id: NodeId) -> Result<(), RindleError> {
3436        // Subscription cold-start latency (208.2): the one-time hydrate cost.
3437        let _timer = metric_timer!(hydrate);
3438        let input = self.view(view_id).input;
3439        let nodes: Vec<Node> = self.fetch(input, &FetchRequest::all()).collect();
3440        self.take_runtime_error()?;
3441        self.view(view_id).hydrate_from(nodes.into_iter());
3442        self.take_runtime_error()
3443    }
3444
3445    /// Fold a downstream `Change` into the view (the pipeline's terminal `Output`).
3446    fn view_push<'g>(&'g self, view_id: NodeId, change: Change<'g>) {
3447        self.view(view_id).push_change(change);
3448    }
3449
3450    /// Fire the view's listeners with the current snapshot and close the
3451    /// transaction (`flush`, `array-view.ts:173`).
3452    pub fn flush_view(&self, view_id: NodeId) {
3453        self.view(view_id).flush();
3454    }
3455
3456    pub fn take_runtime_error(&self) -> Result<(), RindleError> {
3457        // The graph-level sink first (parked deep in the fan-out), then per-source
3458        // parked errors (the SQLite leaf fetch path).
3459        if let Some(err) = self.runtime_error.borrow_mut().take() {
3460            return Err(err);
3461        }
3462        for op in &self.nodes {
3463            match op {
3464                Operator::Source(source) => {
3465                    if let Some(err) = source.take_error() {
3466                        return Err(err);
3467                    }
3468                }
3469                // The §5.3 integer-sum overflow check (design 226) is state-based:
3470                // it raises while any stored group's all-integer set total is
3471                // outside i64 (SQLite's result contract — a fresh `SELECT sum(…)`
3472                // there errors every time too), and clears the moment the state
3473                // shrinks back into range. A delta-order transient corrected
3474                // before this boundary is consulted therefore never errors.
3475                Operator::Reduce(r) if r.overflow_count() > 0 => {
3476                    return Err(RindleError::UnsupportedValue(
3477                        "integer sum overflow: an aggregate's set total is outside \
3478                         the i64 range (design 226 §5.3)"
3479                            .into(),
3480                    ));
3481                }
3482                _ => {}
3483            }
3484        }
3485        Ok(())
3486    }
3487
3488    /// Park a runtime error on the graph-level sink (set-if-empty, so the first
3489    /// failure in a push wins). Drained by [`take_runtime_error`](Self::take_runtime_error)
3490    /// at the mutation boundary. Used where a failure arises deep in the push
3491    /// fan-out and cannot be returned up the stack (WS02.2 join-key check; WS02.3).
3492    pub(crate) fn park_runtime_error(&self, err: RindleError) {
3493        let mut slot = self.runtime_error.borrow_mut();
3494        if slot.is_none() {
3495            *slot = Some(err);
3496        }
3497    }
3498
3499    /// Register a flush listener on the view; fires once immediately
3500    /// (`addListener`, `array-view.ts:115`). Returns its index.
3501    pub fn view_add_listener(&self, view_id: NodeId, l: crate::view::Listener) -> usize {
3502        self.view(view_id).add_listener(l)
3503    }
3504
3505    /// The view's current [`ResultType`](crate::view::ResultType).
3506    pub fn view_result_type(&self, view_id: NodeId) -> crate::view::ResultType {
3507        self.view(view_id).result_type()
3508    }
3509
3510    /// Resolve a pending query's result type (the async `queryComplete` path,
3511    /// `09` §3.8); fires listeners out of band.
3512    pub fn set_view_result_type(&self, view_id: NodeId, rt: crate::view::ResultType) {
3513        self.view(view_id).set_result_type(rt);
3514    }
3515
3516    // -------------------------------------------------------------------
3517    // Collector: record pushes (+ optional fetch-during-push)
3518    // -------------------------------------------------------------------
3519
3520    fn collector_push(&self, id: NodeId, change: Change) {
3521        // Change-stream sink capture (opt-in via `add_change_sink`): record the full
3522        // nested `CaughtChange` tree. Done first, because the flat path below early-
3523        // returns on `Change::Child` — but a change sink must see Child events too.
3524        if self.collector(id).capture_caught.get() {
3525            let caught = crate::changes::expand_change(&change);
3526            self.collector(id).caught.borrow_mut().push(caught);
3527            // A production change-sink (the `rindle-replica` seam) is drained ONLY through
3528            // `take_sink_changes` (the `caught` buffer); it never reads the flat testkit
3529            // `changes`/`fetched` recordings. Accumulating them here would grow `changes` by
3530            // one entry on every push, FOREVER — a per-commit leak invisible to
3531            // `storage_snapshot` (it scans operator storage, not collector buffers) that only
3532            // frees on teardown. So a change-sink stops here. (`capture_caught` is set only by
3533            // `add_change_sink`; a plain testkit `Collector` keeps the flat recording below.)
3534            return;
3535        }
3536        let (collected, input, fetch_on_push) = {
3537            let c = self.collector(id);
3538            let collected = match &change {
3539                Change::Add(n) => CollectedChange::Add(n.row.clone()),
3540                Change::Remove(n) => CollectedChange::Remove(n.row.clone()),
3541                Change::Edit { node, old } => CollectedChange::Edit {
3542                    row: node.row.clone(),
3543                    old: old.row.clone(),
3544                },
3545                Change::Child { .. } => return, // not exercised by source-level tests
3546            };
3547            c.changes.borrow_mut().push(collected.clone());
3548            (collected, c.input, c.fetch_on_push.get())
3549        };
3550        let _ = collected;
3551        if fetch_on_push {
3552            // Reentrant fetch of the source connection mid-push: observes the
3553            // epoch-gated overlay (this connection's epoch was bumped before its
3554            // own push, so it DOES see the in-flight change).
3555            let rows: Vec<Row> = self
3556                .fetch(input, &FetchRequest::all())
3557                .map(|n| n.row)
3558                .collect();
3559            self.collector(id).fetched.borrow_mut().push(rows);
3560        }
3561    }
3562
3563    /// Enable reentrant fetch-during-push capture on a collector.
3564    pub fn set_collector_fetch_on_push(&self, id: NodeId, on: bool) {
3565        self.collector(id).fetch_on_push.set(on);
3566    }
3567
3568    /// The changes a collector has received, in order.
3569    pub fn collector_changes(&self, id: NodeId) -> Vec<CollectedChange> {
3570        self.collector(id).changes.borrow().clone()
3571    }
3572
3573    /// The row-sets captured by reentrant fetch-during-push (one per change).
3574    pub fn collector_fetched(&self, id: NodeId) -> Vec<Vec<Row>> {
3575        self.collector(id).fetched.borrow().clone()
3576    }
3577
3578    // --- production change-stream sink (the `rindle-replica` seam) ---
3579
3580    /// Drain (take) the change events a change-sink (see [`add_change_sink`](Self::add_change_sink))
3581    /// has accumulated since the last call, in arrival order. The buffer is left empty,
3582    /// so this is the per-transaction delta after a push batch + `flush`.
3583    pub fn take_sink_changes(&self, sink: NodeId) -> Vec<crate::changes::CaughtChange> {
3584        std::mem::take(&mut *self.collector(sink).caught.borrow_mut())
3585    }
3586
3587    /// Materialize the pipeline feeding a change-sink as the initial set of
3588    /// [`CaughtChange::Add`](crate::changes::CaughtChange) events — the hydration
3589    /// snapshot (one `Add` per top-level node, relationships drained). Does not touch
3590    /// the push buffer. Mirrors `Catch.fetch` (`testkit.rs`) without the testkit gate.
3591    ///
3592    /// Fallible like [`try_hydrate`](Self::try_hydrate), and for the same reason: the
3593    /// cold drain is a `try_*` boundary. A leaf error parked mid-fetch (an unsafe
3594    /// integer, a failed `sqlite3_step`) ended the stream early, and folding reduce
3595    /// groups during the drain can arm the §5.3 integer-sum overflow state (design
3596    /// 226) — an already-out-of-range set total must surface HERE as the typed error,
3597    /// not register as a success whose aggregate cell is `NULL` while every later
3598    /// write on the shared graph fails the overflow check. Callers tear the partial
3599    /// pipeline down on `Err` (the registration error path), which also clears the
3600    /// overflow state the drain armed.
3601    pub fn try_hydrate_change_sink(
3602        &self,
3603        sink: NodeId,
3604    ) -> Result<Vec<crate::changes::CaughtChange>, RindleError> {
3605        self.try_hydrate_change_sink_with(sink, &FetchRequest::all())
3606    }
3607
3608    /// [`try_hydrate_change_sink`](Self::try_hydrate_change_sink) under an explicit
3609    /// [`FetchRequest`] — the constrained form a parameterized query family's
3610    /// per-partition hydrate uses (design 310 §4.4): the request's constraint reaches
3611    /// the leaf unchanged through the spine (joins forward it to the parent, `Skip`
3612    /// keeps it, the root `Take` recognizes it as the partition and hydrates only that
3613    /// partition's window).
3614    pub fn try_hydrate_change_sink_with(
3615        &self,
3616        sink: NodeId,
3617        req: &FetchRequest,
3618    ) -> Result<Vec<crate::changes::CaughtChange>, RindleError> {
3619        let input = self.collector(sink).input;
3620        let initial: Vec<crate::changes::CaughtChange> = self
3621            .fetch(input, req)
3622            .map(|n| crate::changes::CaughtChange::Add(crate::changes::expand_node(&n)))
3623            .collect();
3624        self.take_runtime_error()?;
3625        Ok(initial)
3626    }
3627
3628    // -------------------------------------------------------------------
3629    // Parameterized query families (design 310 §4.4): bind / unbind / hydrate.
3630    // Host commands, never operator side effects — mutate the binding set only
3631    // between pushes (impl plan D3).
3632    // -------------------------------------------------------------------
3633
3634    /// Bind one partition of a family (design 310 §4.4 "bind"): add `binding` to the
3635    /// family's binding set, then hydrate **only that partition** — a fetch of the
3636    /// pipeline top constrained on the parameter columns, returned as the partition's
3637    /// initial `Add` set (the new subscriber's snapshot). Existing partitions are
3638    /// untouched. Errors if `binding` is already bound, or if the hydrate fails (the
3639    /// binding is then rolled back, so the set never names a partition that was not
3640    /// hydrated).
3641    pub fn bind_family_partition(
3642        &self,
3643        fam: &FamilyPipeline,
3644        sink: NodeId,
3645        binding: &CanonKey,
3646    ) -> Result<Vec<crate::changes::CaughtChange>, RindleError> {
3647        metric_inc!(family_binds);
3648        let _timer = metric_timer!(family_partition_hydrate);
3649        let constraint = fam.constraint_for(binding);
3650        if fam.bindings.contains(binding) {
3651            return Err(RindleError::schema_violation(format!(
3652                "family binding {binding:?} is already bound"
3653            )));
3654        }
3655        // The root limiter must hold NO slot for this partition before the hydrate
3656        // below fills it. Unbind evicts the slot (step 5 of `unbind_family_partition`),
3657        // but a slot can reappear while the partition is unbound: a `related` join's
3658        // child-side push fetches its parents through the spine, and a fetch through a
3659        // partitioned `Take` for a key it has no state for hydrates that key from the
3660        // input — which, with the root connection rejecting the unbound partition, is an
3661        // EMPTY window the limiter then caches as this partition's state. Left in place,
3662        // the constrained hydrate at bind would read that stale empty slot instead of
3663        // the base table, and the partition would re-bind to nothing (found by the
3664        // fuzz lane's rebind twist: a child edit landing while the graded partition was
3665        // unbound, `FamilySut`).
3666        if let Some(t) = fam.root_take {
3667            if let Operator::Take(t) = self.node(t) {
3668                t.evict_partition(self, &constraint);
3669            }
3670        }
3671        // Guard add, THEN set insert (design §4.1's ordering discipline): the push
3672        // index is a superset of the predicate at every instant. Both happen between
3673        // pushes (impl plan D3 — the `ConnTable` asserts it).
3674        let sc = self.source_conn(fam.root_conn);
3675        let guard_value = binding[0].to_owned_value();
3676        self.source(sc.source)
3677            .add_guard_value(sc.conn, guard_value.clone());
3678        fam.bindings.insert(binding.clone());
3679        match self.try_hydrate_change_sink_with(sink, &FetchRequest::with_constraint(constraint)) {
3680            Ok(initial) => Ok(initial),
3681            Err(err) => {
3682                fam.bindings.remove(binding);
3683                self.source(sc.source)
3684                    .remove_guard_value(sc.conn, &guard_value);
3685                Err(err)
3686            }
3687        }
3688    }
3689
3690    /// Unbind one partition of a family (design 310 §4.4 "unbind", made concrete by
3691    /// impl plan D5 — a synthetic drain, not a slot delete). In order:
3692    ///
3693    /// 1. fetch the partition's current view rows through the pipeline top (bounded by
3694    ///    the root limiter) while it is still bound;
3695    /// 2. drop it from the binding set — the root connection now rejects the partition;
3696    /// 3. inject `Change::Remove` for each row at the spine tail's output edge, **above**
3697    ///    the root limiter and the gates the rows already passed, so every `related`
3698    ///    join runs its ordinary parent-left cleanup (child limiter partitions,
3699    ///    pre-check membership) and nothing is refilled from a connection that would now
3700    ///    reject it; the sink's caught output for these is discarded;
3701    /// 4. evict the spine EXISTS joins' per-parent child partitions for those rows (the
3702    ///    synthetic removes entered above them);
3703    /// 5. evict the root `Take`'s partition slot (it kept a size-0 slot, impl plan D4).
3704    ///
3705    /// Nothing is emitted to surviving partitions. Cost O(partition rows) — the same
3706    /// order a singleton's teardown pays.
3707    pub fn unbind_family_partition(
3708        &self,
3709        fam: &FamilyPipeline,
3710        sink: NodeId,
3711        binding: &CanonKey,
3712    ) -> Result<(), RindleError> {
3713        metric_inc!(family_unbinds);
3714        if !fam.bindings.contains(binding) {
3715            return Err(RindleError::schema_violation(format!(
3716                "family binding {binding:?} is not bound"
3717            )));
3718        }
3719        let constraint = fam.constraint_for(binding);
3720        let req = FetchRequest::with_constraint(constraint.clone());
3721        // 1. The partition's view rows, before membership goes away (a fetch after step
3722        //    2 would find the connection rejecting the whole partition).
3723        let window: Vec<Row> = self.fetch(fam.top, &req).map(|n| n.row).collect();
3724        self.take_runtime_error()?;
3725        // 1b. EVERY root row of the partition, not just the window — but only when the
3726        //    spine carries EXISTS joins. Their gates hydrate a per-parent child partition
3727        //    for every row they *evaluate*: a pushed row the root limiter then drops, the
3728        //    one row past the bound a bounded fetch pulls before it stops. Those parents
3729        //    never reached the view, so the window cannot name them; the base table can.
3730        //    O(partition rows in the base table), a constrained scan at the root
3731        //    connection, once per unbind.
3732        let everyone: Vec<Row> = if fam.spine_joins.is_empty() {
3733            Vec::new()
3734        } else {
3735            let rows = self.fetch(fam.root_conn, &req).map(|n| n.row).collect();
3736            self.take_runtime_error()?;
3737            rows
3738        };
3739        // 2. Membership: set remove, THEN guard remove (the index stays a superset of
3740        //    the predicate at every instant, design §4.1).
3741        fam.bindings.remove(binding);
3742        {
3743            let sc = self.source_conn(fam.root_conn);
3744            self.source(sc.source)
3745                .remove_guard_value(sc.conn, &binding[0].to_owned_value());
3746        }
3747        // 3. Synthetic removes above the root limiter.
3748        if let Some(edge) = self.out_edge(fam.spine_tail) {
3749            for row in &window {
3750                self.push(
3751                    edge.node,
3752                    Change::Remove(Node::leaf(row.clone())),
3753                    edge.port,
3754                );
3755            }
3756        }
3757        let _discarded = self.take_sink_changes(sink);
3758        // 4. The spine EXISTS joins' child partitions (the `JoinParent` Remove arm's
3759        //    cleanup, minus the downstream forwarding the rows never needed).
3760        for &jid in &fam.spine_joins {
3761            for row in &everyone {
3762                self.evict_join_child_partitions_for(jid, row);
3763            }
3764        }
3765        // 5. The root limiter's slot.
3766        if let Some(t) = fam.root_take {
3767            if let Operator::Take(t) = self.node(t) {
3768                t.evict_partition(self, &constraint);
3769            }
3770        }
3771        self.take_runtime_error()
3772    }
3773
3774    /// Every bound partition's rows, concatenated in binding order — the family form
3775    /// of a change-sink hydrate (`read_snapshot`).
3776    pub fn hydrate_family(
3777        &self,
3778        fam: &FamilyPipeline,
3779        sink: NodeId,
3780    ) -> Result<Vec<crate::changes::CaughtChange>, RindleError> {
3781        let mut out = Vec::new();
3782        for b in fam.bindings.snapshot() {
3783            let req = FetchRequest::with_constraint(fam.constraint_for(&b));
3784            out.extend(self.try_hydrate_change_sink_with(sink, &req)?);
3785        }
3786        Ok(out)
3787    }
3788
3789    /// The design 310 §4.4 constrained-fetch invariant on a family root: membership is
3790    /// never lowered to SQL, so an unconstrained fetch would scan the whole table and
3791    /// filter in-engine. Every fetch a family pipeline issues at its root is constrained
3792    /// by construction (the per-partition hydrate, the root `Take`'s partition-scoped
3793    /// re-fetches, a join child-push's key-constrained parent fetch, the flipped-join /
3794    /// EXISTS probes); a `debug_assert!` pins that, and strict mode parks a typed error.
3795    fn check_family_root_fetch(&self, req: &FetchRequest) {
3796        debug_assert!(
3797            req.constraint.is_some() || req.has_multi(),
3798            "family root fetched without a constraint (design 310 §4.4)"
3799        );
3800    }
3801
3802    /// The exact answer to an unconstrained family-root request: the connection's rows
3803    /// whose first parameter column is one of the bound values (an IN-batch, so the
3804    /// leaf seeks per value — the memory leaf's `fetch_multi`, a native `IN` on
3805    /// SQLite), then the full membership test. Zero bindings ⇒ no rows.
3806    fn fetch_family_root_unconstrained<'g>(
3807        &'g self,
3808        sc: &'g SourceConn,
3809        f: &'g FamilyRootConn,
3810        req: &FetchRequest,
3811    ) -> NodeStream<'g> {
3812        let multi: crate::change::MultiConstraint = f
3813            .bindings
3814            .snapshot()
3815            .iter()
3816            .map(|b| vec![(f.param_cols[0], b[0].to_owned_value())])
3817            .collect();
3818        if multi.is_empty() {
3819            return Box::new(std::iter::empty());
3820        }
3821        let batched = FetchRequest {
3822            multi_constraints: vec![multi],
3823            ..req.clone()
3824        };
3825        Box::new(
3826            self.source(sc.source)
3827                .fetch(sc.conn, &batched)
3828                .filter(move |r| f.bindings.contains_row(r, &f.param_cols))
3829                .map(Node::leaf),
3830        )
3831    }
3832
3833    /// The parent-left cleanup of `join_push`'s `JoinParent` `Remove` arm for one parent
3834    /// `row` of `jid`, without forwarding anything downstream: evict the child limiter
3835    /// partition the parent hydrated (only when the parent key is the parent's primary
3836    /// key, so the partition belongs to exactly this parent) and keep the join
3837    /// membership pre-check exact.
3838    fn evict_join_child_partitions_for(&self, jid: NodeId, row: &Row) {
3839        let j = self.join(jid);
3840        self.join_precheck_parent_remove(jid, row);
3841        if self.input_schema(j.parent).primary_key == j.parent_key {
3842            if let Some(c) = build_join_constraint(row, &j.parent_key, &j.child_key) {
3843                self.evict_child_partitions(j.child, &c);
3844            }
3845        }
3846    }
3847
3848    // --- spec 11 testkit: Catch / Snitch readback ---
3849
3850    /// `Catch.fetch` (`catch.ts:64-66`): materialize the pipeline feeding the
3851    /// catch into a comparable [`CaughtNode`](crate::testkit::CaughtNode) tree,
3852    /// eagerly draining every relationship thunk. Does not record into `pushes`.
3853    #[cfg(any(test, feature = "testkit"))]
3854    pub fn catch_fetch(
3855        &self,
3856        catch: NodeId,
3857        req: &FetchRequest,
3858    ) -> Vec<crate::testkit::CaughtNode> {
3859        let input = self.catch_op(catch).input;
3860        self.fetch(input, req)
3861            .map(|n| crate::testkit::expand_node(&n))
3862            .collect()
3863    }
3864
3865    /// The downstream change stream a [`Catch`](crate::testkit::Catch) recorded.
3866    #[cfg(any(test, feature = "testkit"))]
3867    pub fn catch_pushes(&self, catch: NodeId) -> Vec<crate::testkit::CaughtChange> {
3868        self.catch_op(catch).pushes()
3869    }
3870
3871    /// The `(change, fetch)` pairs recorded when the catch has `fetch_on_push` set.
3872    #[cfg(any(test, feature = "testkit"))]
3873    pub fn catch_pushes_with_fetch(
3874        &self,
3875        catch: NodeId,
3876    ) -> Vec<(
3877        crate::testkit::CaughtChange,
3878        Vec<crate::testkit::CaughtNode>,
3879    )> {
3880        self.catch_op(catch).pushes_with_fetch()
3881    }
3882
3883    /// The message log a [`Snitch`](crate::testkit::Snitch) recorded, in order.
3884    #[cfg(any(test, feature = "testkit"))]
3885    pub fn snitch_log(&self, snitch: NodeId) -> Vec<crate::testkit::SnitchMessage> {
3886        self.snitch_op(snitch).log()
3887    }
3888
3889    /// Clear every `Snitch`'s log (the `clearLog` of `fetch-and-push-tests.ts:81`
3890    /// — the runner calls this after hydrate so the log reflects only the pushes).
3891    #[cfg(any(test, feature = "testkit"))]
3892    pub fn clear_all_snitch_logs(&self) {
3893        for op in &self.nodes {
3894            if let Operator::Snitch(s) = op {
3895                s.clear_log();
3896            }
3897        }
3898    }
3899
3900    /// Test/proof entry: inject a change at `id` (e.g. a `FilterStart`) exactly as
3901    /// a `SourceConn`'s output edge would deliver it. Drives the filter sub-graph
3902    /// in isolation; the full source push path is covered by the source tests.
3903    pub fn push_at<'g>(&'g self, id: NodeId, change: Change<'g>, port: Port) {
3904        self.push(id, change, port);
3905    }
3906
3907    /// `(begin_filter, filter, end_filter)` call counts recorded by a
3908    /// `FilterProbe` — lets a test assert the lifecycle fired, and balanced.
3909    pub fn probe_counts(&self, id: NodeId) -> (usize, usize, usize) {
3910        let p = self.filter_probe_op(id);
3911        (p.begin_count.get(), p.filter_count.get(), p.end_count.get())
3912    }
3913
3914    /// The view's current top-level result snapshot (the `data` getter). A cheap
3915    /// `Arc` clone of the materialized tree. With the `testkit` feature,
3916    /// `testkit::view_data_to_caught` converts it to a comparable [`crate::CaughtNode`] tree.
3917    pub fn view_data(&self, view_id: NodeId) -> crate::view::ViewData {
3918        self.view(view_id).data()
3919    }
3920
3921    /// Test/inspection helper: `[(pk_id, [child_pk_id, ...]), ...]` using column 0.
3922    /// Children are flattened across all relationship slots in slot order (the
3923    /// production [`View`](crate::view::View)'s `dump_col0`).
3924    pub fn dump_view(&self, view_id: NodeId) -> Vec<(i64, Vec<i64>)> {
3925        self.view(view_id).dump_col0()
3926    }
3927
3928    /// Recursive col-0 dump (the deep counterpart of [`Graph::dump_view`](crate::graph::Graph::dump_view)): surfaces
3929    /// grandchildren so a nested relationship (`issue{comments{reactions}}`) is fully
3930    /// observable. See [`crate::view::View::dump_col0_deep`].
3931    pub fn dump_view_deep(&self, view_id: NodeId) -> Vec<crate::view::Col0Node> {
3932        self.view(view_id).dump_col0_deep()
3933    }
3934
3935    /// Test helper: each top row's FULL columns (as `i64`) + its children's full
3936    /// columns. Unlike [`Graph::dump_view`](crate::graph::Graph::dump_view) (column-0 ids only) this surfaces
3937    /// edited non-key columns, so an edit's *value* change is observable. Assumes
3938    /// all columns are `Int`.
3939    pub fn dump_view_rows(&self, view_id: NodeId) -> Vec<(Vec<i64>, Vec<Vec<i64>>)> {
3940        self.view(view_id).dump_rows()
3941    }
3942}
3943
3944// ---------------------------------------------------------------------------
3945// Join overlay splice (node-level)
3946// ---------------------------------------------------------------------------
3947
3948/// Which way [`Graph::attach_child_rel`](crate::graph::Graph::attach_child_rel)'s by-value overlay pushes the
3949/// refetched child stream. `Post` — the spike re-materialize model: make the
3950/// stream reflect the POST-change world ([`splice_join_overlay`]), used for the
3951/// `Change::Child` a child-push emits (the View re-materializes children).
3952/// `Pre` — Zero's `#yieldParentWithOverlay`/`generateWithOverlay` polarity: pull
3953/// the (already post-change) stream BACK to the PRE-change view a not-yet-
3954/// delivered parent must see during a mid-push maintenance fetch
3955/// ([`splice_join_overlay_pre`]), used by [`FlippedJoin`](crate::op::FlippedJoin)'s
3956/// in-flight suppress model (the by-value twin of the live
3957/// [`Graph::join_overlay_for`] path).
3958#[derive(Clone, Copy)]
3959pub(crate) enum OverlayPolarity {
3960    Post,
3961    Pre,
3962}
3963
3964/// Splice the by-value join overlay into a parent's (committed) child stream
3965/// (Primitive #5). The spike's view re-materializes each parent's children, so the
3966/// overlay **adds** the in-progress child (`Add` / the new row of an `Edit`) and
3967/// **removes** the gone child (`Remove` / the old row of an `Edit`) — the opposite
3968/// polarity from `join.ts`'s suppress-and-deliver model, which is co-designed with
3969/// the production `ArrayView` (see [`JoinOverlay`](crate::change::JoinOverlay)).
3970/// Named distinctly from [`crate::source_common`]'s row-level
3971/// `generate_with_overlay` (the node-vs-row collision the audit flagged).
3972///
3973/// Collect + re-sort (rather than a streaming splice) is fine: child relationship
3974/// streams are small, and the output is identical to an in-order insert.
3975fn splice_join_overlay<'g>(
3976    base: NodeStream<'g>,
3977    overlay: &JoinOverlay,
3978    parent_row: &Row,
3979    parent_key: &[ColId],
3980    child_key: &[ColId],
3981    child_pk: &[ColId],
3982    child_sort: &Sort,
3983) -> NodeStream<'g> {
3984    let mut nodes: Vec<Node<'g>> = base.collect();
3985
3986    // Add `add_row` iff it joins to this parent and isn't already present.
3987    let add_if_matches = |nodes: &mut Vec<Node<'g>>, add_row: &Row| {
3988        if is_join_match(parent_row, parent_key, add_row, child_key)
3989            && !nodes.iter().any(|n| same_pk(&n.row, add_row, child_pk))
3990        {
3991            nodes.push(Node::leaf(add_row.clone()));
3992        }
3993    };
3994
3995    match &overlay.change {
3996        SourceChange::Add(row) => add_if_matches(&mut nodes, row),
3997        SourceChange::Remove(row) => nodes.retain(|n| !same_pk(&n.row, row, child_pk)),
3998        SourceChange::Edit { row, old } => {
3999            nodes.retain(|n| !same_pk(&n.row, old, child_pk));
4000            add_if_matches(&mut nodes, row);
4001        }
4002    }
4003
4004    nodes.sort_by(|a, b| compare_rows(child_sort, &a.row, &b.row));
4005    Box::new(nodes.into_iter())
4006}
4007
4008/// Splice the LIVE in-flight child overlay into a parent's child stream with Zero's
4009/// `join-utils.generateWithOverlay` **pull-to-pre** polarity — the production model
4010/// (`change.rs` [`JoinOverlay`] doc), used by the live-field Join path
4011/// ([`Graph::process_parent_node`](crate::graph::Graph::process_parent_node)). The base child stream is already POST-change
4012/// (the cap commits its state synchronously; the source overlay is epoch-gated to
4013/// the post state), so this pulls it back to the PRE-change view a not-yet-processed
4014/// parent must see during a mid-push refetch: a **Remove** re-adds the gone child, an
4015/// **Add** suppresses the just-added child, an **Edit** re-adds `old` and suppresses
4016/// the new row. Identity is by PK (the sort always includes the full PK, so PK
4017/// equality matches Zero's `compareRows == 0`); the result is re-sorted so a
4018/// materialized relationship stays ordered (for an unordered EXISTS child only the
4019/// count matters). This is the EXACT OPPOSITE polarity of [`splice_join_overlay`]
4020/// (the spike push-to-post model, retained for [`FlippedJoin`](crate::op::FlippedJoin)).
4021fn splice_join_overlay_pre<'g>(
4022    base: NodeStream<'g>,
4023    overlay: &JoinOverlay,
4024    child_pk: &[ColId],
4025    child_sort: &Sort,
4026) -> NodeStream<'g> {
4027    let mut nodes: Vec<Node<'g>> = base.collect();
4028
4029    // Re-add the in-flight gone/old child (it is absent from the post-change base),
4030    // unless a row with its PK is somehow still present (no double-add).
4031    let readd_if_absent = |nodes: &mut Vec<Node<'g>>, row: &Row| {
4032        if !nodes.iter().any(|n| same_pk(&n.row, row, child_pk)) {
4033            nodes.push(Node::leaf(row.clone()));
4034        }
4035    };
4036    // Suppress the in-flight added/new child (present in the post-change base).
4037    let suppress = |nodes: &mut Vec<Node<'g>>, row: &Row| {
4038        if let Some(i) = nodes.iter().position(|n| same_pk(&n.row, row, child_pk)) {
4039            nodes.remove(i);
4040        }
4041    };
4042
4043    match &overlay.change {
4044        SourceChange::Add(row) => suppress(&mut nodes, row),
4045        SourceChange::Remove(row) => readd_if_absent(&mut nodes, row),
4046        SourceChange::Edit { row, old } => {
4047            suppress(&mut nodes, row);
4048            readd_if_absent(&mut nodes, old);
4049        }
4050    }
4051
4052    nodes.sort_by(|a, b| compare_rows(child_sort, &a.row, &b.row));
4053    Box::new(nodes.into_iter())
4054}
4055
4056// ---------------------------------------------------------------------------
4057// Filter sub-graph free helpers
4058// ---------------------------------------------------------------------------
4059
4060/// RAII for the Filter sub-graph fetch lifecycle. Holds a *shared* graph borrow
4061/// and the chain head; its [`Drop`] runs `end_filter(chain)`. It is moved into
4062/// the fetch stream's closure, so it fires exactly when the stream is dropped —
4063/// early break, `?`-propagation, or early return — reproducing the JS
4064/// `try/finally` (`filter-operators.ts:98-102`; Primitive #2 / `07` §6.4). A
4065/// panic unwinding through it also fires it, but only in unwinding builds; under
4066/// the shipping `panic = "abort"` client profile a panic aborts and `Drop` is
4067/// skipped. See WS02.
4068struct EndFilterGuard<'g> {
4069    g: &'g Graph,
4070    chain: NodeId,
4071}
4072
4073impl Drop for EndFilterGuard<'_> {
4074    fn drop(&mut self) {
4075        self.g.end_filter(self.chain);
4076    }
4077}
4078
4079/// RAII for a [`Join`]'s in-flight child-change fan-out (`join.ts:222-249`
4080/// `try/finally`): clears [`Join::inprogress_overlay`]/[`Join::inprogress_position`]
4081/// when [`Graph::push_child_change`](crate::graph::Graph::push_child_change) returns — normally, via `?`, or unwinding (the
4082/// latter only under `panic = "unwind"`; under the shipping `panic = "abort"` profile
4083/// a panic aborts and `Drop` is skipped — see WS02). Leaving a stale overlay set
4084/// would corrupt every later fetch's membership, so the clear is not optional.
4085struct InprogressGuard<'g> {
4086    g: &'g Graph,
4087    jid: NodeId,
4088}
4089
4090impl Drop for InprogressGuard<'_> {
4091    fn drop(&mut self) {
4092        let j = self.g.join(self.jid);
4093        *j.inprogress_overlay.borrow_mut() = None;
4094        *j.inprogress_position.borrow_mut() = None;
4095    }
4096}
4097
4098/// The join membership pre-check's **observer** (design 311 §2.4): wraps a join's
4099/// parent enumeration and records each yielded parent's key into the join's region as
4100/// it streams — the ONLY way a region is populated (there is no build fetch). Exhaustion
4101/// (`inner` yields `None`) marks the region complete; a consumer that drops the cursor
4102/// first (a root `Take` whose window filled) leaves the frontier at the last row yielded,
4103/// which is exactly that `Take`'s bound. Nothing to do on `Drop`: the frontier moves
4104/// per row, so an abandoned stream is already exact.
4105struct ObservedParents<'g> {
4106    g: &'g Graph,
4107    jid: NodeId,
4108    inner: NodeStream<'g>,
4109    done: bool,
4110}
4111
4112impl<'g> Iterator for ObservedParents<'g> {
4113    type Item = Node<'g>;
4114
4115    fn next(&mut self) -> Option<Node<'g>> {
4116        if self.done {
4117            return None;
4118        }
4119        match self.inner.next() {
4120            Some(node) => {
4121                self.g.join_precheck_observe_row(self.jid, &node.row);
4122                Some(node)
4123            }
4124            None => {
4125                self.done = true;
4126                self.g.join_precheck_observe_exhausted(self.jid);
4127                None
4128            }
4129        }
4130    }
4131}
4132
4133/// `push_accumulated_changes` (`push-accumulated.ts:87`), the minimal dedup core.
4134/// Each branch of an OR forwarded 0..1 change; collapse the lot into **exactly
4135/// one** output change, keyed by the FAN-OUT's original change type. Dedup uses
4136/// the `Identity` relationship merge (`fan-in.ts:91`): keep the FIRST change of
4137/// each type and drop the later ones (the JS `identity(existing, _) = existing`).
4138///
4139/// The subtle case is `Edit`: a filter that *included* the old row but *excludes*
4140/// the new turns the edit into a `Remove(old)`; a sibling filter that did the
4141/// reverse turns it into an `Add(new)`. When both survive, they **reconstruct**
4142/// the `Edit{node: new, old}` (`push-accumulated.ts:202-213`) — otherwise the
4143/// view would see a bare Add or Remove and break its "remove-only-present /
4144/// add-only-absent" invariant.
4145fn collapse_accumulated<'g>(acc: Vec<Change<'g>>, fan_out_type: ChangeType) -> Vec<Change<'g>> {
4146    let mut add: Option<Node<'g>> = None;
4147    let mut remove: Option<Node<'g>> = None;
4148    let mut edit: Option<Change<'g>> = None;
4149    let mut child: Option<Change<'g>> = None;
4150    for c in acc {
4151        match c {
4152            Change::Add(n) => {
4153                add.get_or_insert(n);
4154            }
4155            Change::Remove(n) => {
4156                remove.get_or_insert(n);
4157            }
4158            Change::Edit { .. } => {
4159                edit.get_or_insert(c);
4160            }
4161            // A branch preserved the Child (a leaf Filter passes it through). Keep
4162            // the FIRST (the `Identity` merge — `fan-in.ts` passes `identity`).
4163            Change::Child { .. } => {
4164                child.get_or_insert(c);
4165            }
4166        }
4167    }
4168    match fan_out_type {
4169        // An Add entering the fan can only stay an Add or be dropped per branch —
4170        // a stateless Filter never manufactures a Remove/Edit from an Add.
4171        ChangeType::Add => {
4172            debug_assert!(
4173                remove.is_none() && edit.is_none() && child.is_none(),
4174                "Add fan-out collapsed a non-Add branch change"
4175            );
4176            add.map(Change::Add).into_iter().collect()
4177        }
4178        ChangeType::Remove => {
4179            debug_assert!(
4180                add.is_none() && edit.is_none() && child.is_none(),
4181                "Remove fan-out collapsed a non-Remove branch change"
4182            );
4183            remove.map(Change::Remove).into_iter().collect()
4184        }
4185        ChangeType::Edit => {
4186            debug_assert!(
4187                child.is_none(),
4188                "Edit fan-out produced a Child branch change"
4189            );
4190            if let Some(e) = edit {
4191                // An Edit survived a branch unsplit → it supersedes (the minimal
4192                // chassis has no relationships to merge in).
4193                vec![e]
4194            } else {
4195                match (add, remove) {
4196                    (Some(node), Some(old)) => vec![Change::Edit { node, old }], // reconstruct
4197                    (Some(node), None) => vec![Change::Add(node)],
4198                    (None, Some(old)) => vec![Change::Remove(old)],
4199                    (None, None) => Vec::new(),
4200                }
4201            }
4202        }
4203        // CHILD precedence (`push-accumulated.ts` CHILD case): a Child entering the
4204        // fan reaches each branch, which either **preserves** it (a leaf Filter) or
4205        // **converts** it to an Add/Remove (an `Exists` gate, when the relationship
4206        // change flips the parent's membership 0↔1). If any branch kept the Child,
4207        // it takes precedence over all else. Otherwise exactly one of Add/Remove
4208        // survives (the relationship is unique to one exists check, so the converters
4209        // can't disagree). `edit` is impossible here.
4210        ChangeType::Child => {
4211            debug_assert!(
4212                edit.is_none(),
4213                "Child fan-out produced an Edit branch change"
4214            );
4215            if let Some(c) = child {
4216                vec![c]
4217            } else {
4218                debug_assert!(
4219                    !(add.is_some() && remove.is_some()),
4220                    "Child fan-out: expected at most one of add/remove"
4221                );
4222                match (add, remove) {
4223                    (Some(node), _) => vec![Change::Add(node)],
4224                    (None, Some(old)) => vec![Change::Remove(old)],
4225                    (None, None) => Vec::new(),
4226                }
4227            }
4228        }
4229    }
4230}
4231
4232#[cfg(test)]
4233mod tests {
4234    use super::*;
4235    use crate::change::{Basis, Start};
4236    use crate::op::Skip;
4237    use crate::storage::StorageValue;
4238    use crate::value::{owned_row, OwnedValue};
4239
4240    /// The operator **fan-out seam** (`op/mod.rs`): an operator whose struct +
4241    /// logic live in their own file (`op/skip.rs`) integrates into the arena and is
4242    /// reached by the graph's dispatch. We assert the *wiring* — the variant, the
4243    /// `add_skip` builder, `set_output`, and that `input_schema` resolves THROUGH
4244    /// the new variant to the source — without invoking the (stub) `fetch`/`push`.
4245    #[test]
4246    fn skip_operator_wires_into_arena() {
4247        let mut g = Graph::new();
4248        let src = g.add_source(
4249            SourceSchema::new(vec!["id"], vec![0], vec![(0, true)]),
4250            Vec::new(),
4251        );
4252        let conn = g.connect(src, Some(vec![(0, true)]), None, Vec::new());
4253        let skip = g.add_skip(Skip::new(
4254            conn,
4255            Start {
4256                row: owned_row(vec![OwnedValue::Int(0)]),
4257                basis: Basis::After,
4258            },
4259        ));
4260        let coll = g.add_collector(skip);
4261        g.set_output(skip, coll);
4262
4263        // Dispatch reaches the out-of-file operator: schema resolves through it.
4264        assert_eq!(self_columns(&g, skip), vec!["id"]);
4265    }
4266
4267    fn self_columns(g: &Graph, id: NodeId) -> Vec<String> {
4268        g.input_schema(id)
4269            .columns
4270            .iter()
4271            .map(|c| c.to_string())
4272            .collect()
4273    }
4274
4275    /// [`Graph::remove_source`](crate::graph::Graph::remove_source) (the inverse of `add_source`, for a synthetic aggregate table
4276    /// whose last query is gone): it must REFUSE while a reader is wired, SUCCEED once the
4277    /// pipeline is torn down, and recycle the freed slot (generation bumped). The
4278    /// optimistic/normalized backend leans on the refusal to keep `unregisterTable` safe.
4279    #[test]
4280    fn remove_source_refuses_a_live_reader_then_frees_the_slot() {
4281        let mut g = Graph::new();
4282        let src = g.add_source(
4283            SourceSchema::new(vec!["id"], vec![0], vec![(0, true)]),
4284            Vec::new(),
4285        );
4286
4287        // A live reader: connect + wire its downstream. remove_source must refuse.
4288        g.begin_recording();
4289        let conn = g.connect(src, Some(vec![(0, true)]), None, Vec::new());
4290        let coll = g.add_collector(conn);
4291        g.set_sink_edge(conn, coll);
4292        let manifest = g.take_recording();
4293        assert!(
4294            g.remove_source(src).is_err(),
4295            "a source with a live reader must not be removed"
4296        );
4297
4298        // Tear the reader down → 0 live connections → remove_source succeeds.
4299        g.destroy_pipeline(&manifest);
4300        assert!(g.remove_source(src).is_ok());
4301
4302        // The freed source slot is recycled with a bumped generation (so a stale id is distinct).
4303        let src2 = g.add_source(
4304            SourceSchema::new(vec!["id"], vec![0], vec![(0, true)]),
4305            Vec::new(),
4306        );
4307        assert_eq!(src2.ix(), src.ix(), "freed source slot is reused");
4308        assert_ne!(src2.gen, src.gen, "generation bumped on free");
4309
4310        // A non-source node is rejected (not silently freed).
4311        let conn2 = g.connect(src2, Some(vec![(0, true)]), None, Vec::new());
4312        assert!(g.remove_source(conn2).is_err());
4313    }
4314
4315    // --- collapse_accumulated: the CHILD-precedence arm (spec-06 push-accumulated) ---
4316    //
4317    // Directly exercises the collapse for a `Child` fan-out — including the
4318    // `Exists`-branch conversion (Child → Add/Remove on a membership flip), which a
4319    // hand-wired leaf-only fan can't produce.
4320
4321    fn leaf_node(id: i64) -> Node<'static> {
4322        Node::leaf(owned_row(vec![OwnedValue::Int(id)]))
4323    }
4324    fn child_chg(parent: i64) -> Change<'static> {
4325        Change::Child {
4326            node: leaf_node(parent),
4327            rel: crate::value::RelId(0),
4328            child: Box::new(Change::Add(leaf_node(parent * 10))),
4329        }
4330    }
4331    fn col0(c: &Change) -> i64 {
4332        let row = match c {
4333            Change::Add(n) | Change::Remove(n) => &n.row,
4334            Change::Edit { node, .. } | Change::Child { node, .. } => &node.row,
4335        };
4336        match row.col(0) {
4337            crate::value::Value::Int(i) => i,
4338            o => panic!("{o:?}"),
4339        }
4340    }
4341
4342    #[test]
4343    fn collapse_child_precedence_keeps_the_child() {
4344        // One branch preserved the Child; another (an Exists gate) converted it to an
4345        // Add. The preserved Child takes precedence — the Add is dropped.
4346        let out = collapse_accumulated(
4347            vec![child_chg(1), Change::Add(leaf_node(1))],
4348            ChangeType::Child,
4349        );
4350        assert_eq!(out.len(), 1);
4351        assert!(matches!(out[0], Change::Child { .. }));
4352        assert_eq!(col0(&out[0]), 1);
4353    }
4354
4355    #[test]
4356    fn collapse_two_preserved_children_dedup_to_one() {
4357        // Two branches preserved the same Child → keep-first dedups to one.
4358        let out = collapse_accumulated(vec![child_chg(1), child_chg(1)], ChangeType::Child);
4359        assert_eq!(out.len(), 1);
4360        assert!(matches!(out[0], Change::Child { .. }));
4361    }
4362
4363    #[test]
4364    fn collapse_child_to_add_on_membership_flip() {
4365        // No branch preserved the Child; an Exists gate flipped it to an Add (the
4366        // parent gained its first matching child).
4367        let out = collapse_accumulated(vec![Change::Add(leaf_node(7))], ChangeType::Child);
4368        assert_eq!(out.len(), 1);
4369        assert!(matches!(out[0], Change::Add(_)));
4370        assert_eq!(col0(&out[0]), 7);
4371    }
4372
4373    #[test]
4374    fn collapse_child_to_remove_on_membership_flip() {
4375        let out = collapse_accumulated(vec![Change::Remove(leaf_node(7))], ChangeType::Child);
4376        assert_eq!(out.len(), 1);
4377        assert!(matches!(out[0], Change::Remove(_)));
4378        assert_eq!(col0(&out[0]), 7);
4379    }
4380
4381    #[test]
4382    fn collapse_child_dropped_when_no_branch_passes() {
4383        assert!(collapse_accumulated(Vec::new(), ChangeType::Child).is_empty());
4384    }
4385
4386    /// The storage arena (foundations §6.4): each `alloc_storage` hands back a
4387    /// fresh, disjoint keyspace, and an operator reads/writes it through a *shared*
4388    /// `&Graph` borrow (the same borrow shape as a reentrant push).
4389    #[test]
4390    fn storage_arena_allocates_disjoint_namespaces() {
4391        let mut g = Graph::new();
4392        let a = g.alloc_storage();
4393        let b = g.alloc_storage();
4394        assert_ne!(a, b, "each slot is a distinct id");
4395
4396        // Same key in two slots → two independent values (disjoint namespaces).
4397        g.storage(a).set(
4398            "k",
4399            StorageValue::Take {
4400                size: 1,
4401                bound: None,
4402            },
4403        );
4404        g.storage(b).set(
4405            "k",
4406            StorageValue::Take {
4407                size: 2,
4408                bound: None,
4409            },
4410        );
4411        let read_size = |id| match g.storage(id).get("k").unwrap() {
4412            StorageValue::Take { size, .. } => size,
4413            other => panic!("unexpected {other:?}"),
4414        };
4415        assert_eq!(read_size(a), 1);
4416        assert_eq!(read_size(b), 2);
4417        assert!(g.storage(a).get("absent").is_none());
4418    }
4419
4420    fn id_schema() -> SourceSchema {
4421        SourceSchema::new(vec!["id"], vec![0], vec![(0, true)])
4422    }
4423
4424    /// Build a minimal recorded "pipeline" over `src`: a connection, a scratch-storage
4425    /// slot, and a collector sink, wired source→sink. Returns the manifest + the ids.
4426    fn build_recorded(g: &mut Graph, src: NodeId) -> (PipelineManifest, NodeId, StorageId, NodeId) {
4427        g.begin_recording();
4428        let conn = g.connect(src, Some(vec![(0, true)]), None, Vec::new());
4429        let store = g.alloc_storage();
4430        let sink = g.add_collector(conn);
4431        g.set_sink_edge(conn, sink);
4432        (g.take_recording(), conn, store, sink)
4433    }
4434
4435    /// `destroy_pipeline` reclaims a torn-down pipeline's storage (clears it) and node
4436    /// slots (tombstones them) WITHOUT growing the arena, and a later build REUSES those
4437    /// freed slots — same index, bumped generation, empty store.
4438    #[test]
4439    fn destroy_pipeline_reclaims_and_reuses_slots() {
4440        let mut g = Graph::new();
4441        let src = g.add_source(id_schema(), Vec::new()); // shared, NOT recorded
4442
4443        let (m, _conn, store, _sink) = build_recorded(&mut g, src);
4444        // Put state in the operator-storage slot so we can prove it is cleared.
4445        g.storage(store).set(
4446            "k",
4447            StorageValue::Take {
4448                size: 9,
4449                bound: None,
4450            },
4451        );
4452        assert!(g.storage(store).get("k").is_some());
4453
4454        let nodes = g.node_count();
4455        let stores = g.storage_count();
4456
4457        g.destroy_pipeline(&m);
4458        // Tombstoned, not removed: the arena does not shrink.
4459        assert_eq!(g.node_count(), nodes);
4460        assert_eq!(g.storage_count(), stores);
4461
4462        // A fresh build reuses the freed slots — no arena growth.
4463        let (_m2, conn2, store2, sink2) = build_recorded(&mut g, src);
4464        assert_eq!(g.node_count(), nodes, "node slots were recycled, not grown");
4465        assert_eq!(g.storage_count(), stores, "storage slot was recycled");
4466
4467        // The reused storage slot: same index, bumped generation, and EMPTY (cleared).
4468        assert_eq!(store2.idx, store.idx);
4469        assert!(store2.gen > store.gen);
4470        assert!(
4471            g.storage(store2).get("k").is_none(),
4472            "a recycled storage slot must start empty"
4473        );
4474
4475        // The reused node slots are the freed indices with bumped generations.
4476        let freed: std::collections::HashSet<u32> = m.nodes().iter().map(|n| n.idx).collect();
4477        assert!(freed.contains(&conn2.idx) && conn2.gen > 0);
4478        assert!(freed.contains(&sink2.idx) && sink2.gen > 0);
4479
4480        // The SHARED source's connection slot was recycled too: the destroy+rebuild did
4481        // not grow its connection table (the conn-slot leak fix). One pipeline = one
4482        // connection, so the table holds exactly one slot before and after.
4483        let conn_slots = match &g.nodes[src.ix()] {
4484            Operator::Source(SourceLeaf::Memory(ms)) => ms.conn_count(),
4485            _ => unreachable!(),
4486        };
4487        assert_eq!(
4488            conn_slots, 1,
4489            "the source connection slot was recycled, not leaked"
4490        );
4491    }
4492
4493    /// After teardown, a stale `NodeId` (the pre-free generation) is a loud fail-fast on
4494    /// any arena access — never a silent alias of whatever recycles the slot.
4495    #[test]
4496    #[should_panic(expected = "stale NodeId")]
4497    fn stale_node_id_after_teardown_fails_fast() {
4498        let mut g = Graph::new();
4499        let src = g.add_source(id_schema(), Vec::new());
4500        let (m, _conn, _store, sink) = build_recorded(&mut g, src);
4501        g.destroy_pipeline(&m);
4502        // `sink` carries the old generation; the slot's generation was bumped on free.
4503        let _ = g.input_schema(sink);
4504    }
4505
4506    /// A stale `StorageId` likewise fails fast (the storage arena is generational too).
4507    #[test]
4508    #[should_panic(expected = "stale StorageId")]
4509    fn stale_storage_id_after_teardown_fails_fast() {
4510        let mut g = Graph::new();
4511        let src = g.add_source(id_schema(), Vec::new());
4512        let (m, _conn, store, _sink) = build_recorded(&mut g, src);
4513        g.destroy_pipeline(&m);
4514        let _ = g.storage(store).get("k");
4515    }
4516}