Rindle docs and package mapSkip to main content

rindle/
source_common.rs

1//! Backend-agnostic source machinery, shared by the memory (`BTree`) and SQLite
2//! leaves. **This module references no `BTree`, no `rusqlite`, AND no `Node`** —
3//! it is generic over `S: RowStream` and deals only in **rows** (`OwnedRow`) and
4//! source changes (`SourceChange`). It is the seam the spec calls `source_common`
5//! (`04` §4.6): the two leaves differ only in the concrete `S` they pass in.
6//!
7//! **A source emits rows, not nodes.** The overlay splice, the start/constraint/
8//! filter chain, the k-way merge, and the push/edit-split orchestration here all
9//! operate on rows. A row becomes a [`Node`](crate::change::Node) (row + lazily
10//! attached relationships) only at the **connection boundary** — `Graph::fetch`
11//! on a `SourceConn` wraps each emitted row in a leaf node on the read path, and
12//! the `SourceConn`'s push driver wraps each `SourceChange` into a node-bearing
13//! `Change` on the write path. Downstream **joins** then attach relationships. So
14//! the Node concept lives entirely above this module.
15//!
16//! What lives here (faithful ports of `memory-source.ts`, which is the JS root
17//! even though `05`/`TableSource` consume it verbatim):
18//!
19//! - the epoch-gated overlay splice — ordered ([`generate_with_overlay`]) and
20//!   unordered ([`generate_with_overlay_unordered`]) — plus the pure overlay
21//!   narrowers ([`compute_overlays`], [`overlays_for_constraint`], …);
22//! - the post-scan row chain ([`generate_with_start`], [`generate_with_constraint`],
23//!   [`generate_with_filter`]);
24//! - the k-way [`merge_sorted_streams`] (binary min-heap, `Drop`-clean);
25//! - the predicate-filtered push (`filter_push`, edit-splitting by predicate);
26//! - the eager push orchestration (`gen_push_and_write_with_split_edit`):
27//!   edit-split detection, per-connection fan-out with the live overlay + epoch
28//!   gate, and the deferred write-after-drain.
29//!
30//! The connection/overlay **types** ([`Connection`], [`ConnectionFilters`],
31//! [`Overlay`], [`Overlays`], [`RowPredicate`]) live here too because their shape
32//! is identical across backends (`04` §4.1, §4.2).
33
34use std::cell::{Cell, Ref, RefCell};
35use std::cmp::Ordering;
36use std::marker::PhantomData;
37use std::rc::Rc;
38
39use crate::graph::ConnId;
40
41use crate::change::{
42    constraint_matches, Basis, Constraint, MultiConstraint, OutEdge, RowFlow, SourceChange, Start,
43};
44use crate::error::RindleError;
45use crate::metric_add;
46use crate::push_index::{PushGuard, PushIndex};
47use crate::value::{
48    compare_rows, same_pk, values_equal, ColId, OwnedRow as Row, OwnedValue, RowRef, RowStream,
49    Sort,
50};
51
52// ---------------------------------------------------------------------------
53// Shared connection / overlay types (identical shape across backends — §4.1/§4.2)
54// ---------------------------------------------------------------------------
55
56/// The in-memory predicate compiled from a connection's pushed-down filter
57/// (`createPredicate`, owned by `07`). `Rc` so a fetch can clone it into the
58/// returned lazy stream without holding a `RefCell` borrow on connection state
59/// across the vend (the cardinal rule — foundations §6.2). The memory leaf reads
60/// columns by [`ColId`] index; a bare `&Row` keeps this backend-agnostic.
61pub type RowPredicate = Rc<dyn Fn(&Row) -> bool>;
62
63/// The single in-flight source change, epoch-tagged. Rows are OWNED (overlays are
64/// materialized — foundations §3.3). Mirrors `Overlay` (`memory-source.ts:59`);
65/// IDENTICAL to `05`'s (it lives here).
66#[derive(Clone, Debug)]
67pub struct Overlay {
68    pub epoch: u32,
69    pub change: SourceChange,
70}
71
72/// The narrowed add/remove pair fed to the splice (`Overlays`,
73/// `memory-source.ts:64`). Both owned.
74#[derive(Clone, Default, Debug)]
75pub struct Overlays {
76    pub add: Option<Row>,
77    pub remove: Option<Row>,
78}
79
80impl Overlays {
81    /// No overlay rows survived narrowing — the splice would be an exact
82    /// pass-through, so the stream builders skip it entirely.
83    pub fn is_empty(&self) -> bool {
84        self.add.is_none() && self.remove.is_none()
85    }
86}
87
88/// A filter condition lowered to a **backend-neutral AST** — the input the SQLite
89/// leaf's `build_select_query` walks to emit the SELECT `WHERE` (port of
90/// query-builder.ts `NoSubqueryCondition`, subqueries already stripped). Pure
91/// data, no `rusqlite`: the **memory** backend ignores it entirely (it filters via
92/// [`RowPredicate`]); only the **SQLite** leaf lowers it to SQL text, leaving the
93/// `predicate` to narrow only the (not-in-SQL) overlay rows. Column operands are
94/// already-resolved [`ColId`]s; a literal operand's [`OwnedValue`] variant selects
95/// its SQLite storage form (the JS `getJsType` → `toSQLiteType`).
96#[derive(Clone, Debug)]
97pub enum SqlCondition {
98    Simple {
99        left: Operand,
100        op: SqlOp,
101        right: Operand,
102    },
103    /// AND of zero-or-more conditions. Empty ⇒ the literal `TRUE`
104    /// (query-builder.ts:174-181).
105    And(Vec<SqlCondition>),
106    /// OR of zero-or-more conditions. Empty ⇒ the literal `FALSE`
107    /// (query-builder.ts:182-191).
108    Or(Vec<SqlCondition>),
109}
110
111/// One side of a [`SqlCondition::Simple`]. A `Column` lowers to its quoted
112/// identifier (resolved from the schema at build time); a `Literal` lowers to a
113/// bound `?` param (query-builder.ts `valuePositionToSQL`).
114#[derive(Clone, Debug)]
115pub enum Operand {
116    Column(ColId),
117    Literal(OwnedValue),
118}
119
120/// Comparison / membership operators (query-builder.ts `simpleConditionToSQL`).
121/// Most lower to their raw SQL spelling verbatim; `In`/`NotIn` lower to a
122/// `json_each(?)` subquery over a JSON-array literal (query-builder.ts:196-205);
123/// the `Like` family appends `ESCAPE '\'` and `Ilike` lowers both sides through
124/// `lower(...)` (query-builder.ts `likeConditionToSQL`). `Is`/`IsNot` are the
125/// NULL-aware equality operators in the JS `SimpleOperator` set (ast.ts:37): they
126/// reach the `WHERE` verbatim (a user `.where(c, 'IS', null)`, and the engine's
127/// `NOT EXISTS` → `field IS NOT literal` rewrite, resolve-scalar-subqueries.ts:176).
128/// Distinct from the start-bound's nullable-aware `=`/`IS` choice (§3.7), which is
129/// a SEPARATE code path keyed on column optionality, not a user operator.
130#[derive(Clone, Copy, Debug, PartialEq, Eq)]
131pub enum SqlOp {
132    Eq,
133    Ne,
134    Lt,
135    Le,
136    Gt,
137    Ge,
138    Is,
139    IsNot,
140    In,
141    NotIn,
142    Like,
143    NotLike,
144    Ilike,
145    NotIlike,
146}
147
148/// The fully-pushed-down filter + the precomputed PK constraint. The memory leaf
149/// only evaluates `predicate`; `pk_constraint` is hoisted from the condition at
150/// connect time (the JS recomputes it per fetch via `primaryKeyConstraintFrom
151/// Filters` — we hoist since the condition is fixed per connection). `fully_
152/// applied` mirrors `SourceInput.fullyAppliedFilters` (`memory-source.ts:180`).
153pub struct ConnectionFilters {
154    pub predicate: RowPredicate,
155    pub pk_constraint: Option<Constraint>,
156    pub fully_applied: bool,
157    /// SQLite-only: the filter lowered to a SQL `WHERE` condition. The memory
158    /// backend leaves this `None` (it filters in-memory via `predicate`). When
159    /// present, the SQLite leaf emits it into the `SELECT` so committed rows are
160    /// SQL-filtered, and `predicate` narrows only the overlay (which SQL never
161    /// sees) — faithful to the JS `connection.filters` carrying BOTH `condition`
162    /// and `predicate` (`table-source.ts:256-261`).
163    pub sql_condition: Option<SqlCondition>,
164    /// Equality guard implied by `predicate` — feeds the [`ConnTable`] push index
165    /// (`designs/205-GUARDED-PUSH-FANOUT-DESIGN.md`). Populated for **both** backends
166    /// (unlike `sql_condition`), since the fan-out pruning it drives is backend-neutral.
167    /// `None` ⇒ the connection is unconditionally visited on every push (today's
168    /// behavior); `Some(g)` ⇒ visited only when a changed row's `g.col` cell hits one
169    /// of `g.values`. The exact `predicate` still runs on every candidate, so the guard
170    /// need only never *under*-approximate.
171    pub push_guard: Option<PushGuard>,
172}
173
174/// One connection (one downstream output). Self-joins make two. Mirrors
175/// `Connection` (`memory-source.ts:75`). IDENTICAL shape to `05`'s so this module
176/// stays backend-agnostic. The mutable bits are `Cell` so they bump during a push
177/// without a `&mut` borrow held across a vend (foundations §6.2).
178pub struct Connection {
179    /// Resolved order; always the internal sort (`sort ?? primaryIndexSort`).
180    pub sort: Sort,
181    /// Schema reports `sort: None` when true; internally `sort` is the primary
182    /// index sort, used for overlay PK matching in the unordered path.
183    pub unordered: bool,
184    pub split_edit_keys: Vec<ColId>,
185    pub filters: Option<ConnectionFilters>,
186    /// Gates overlay visibility (the self-join epoch gate, §3.5).
187    pub last_pushed_epoch: Cell<u32>,
188    pub output: Cell<Option<OutEdge>>,
189}
190
191/// A source's connection registry: the [`Connection`]s plus a **generational
192/// free-list**, so a torn-down connection's slot is RECYCLED rather than leaked under
193/// query churn (the source-side analogue of the graph arena's node/storage reuse).
194/// Shared by both [`Source`](crate::graph::Source) backends (the memory `BTree` leaf
195/// and the SQLite `TableSource`) so the reuse logic — and its stale-`ConnId`
196/// fail-fast — lives in exactly one place and the two backends cannot drift apart.
197///
198/// The push fan-out iterates [`borrow`](Self::borrow) directly (no `ConnId`), so it
199/// is unaffected by the generation; a slot freed but not yet reused has
200/// `output == None` and is skipped by the fan-out exactly as before.
201#[derive(Default)]
202pub struct ConnTable {
203    conns: RefCell<Vec<Connection>>,
204    /// Per-slot generation, bumped when the slot is freed; checked on every
205    /// `ConnId`-addressed access so a stale handle to a recycled slot fail-fasts.
206    gens: RefCell<Vec<u32>>,
207    /// Freed slot indices available for reuse (LIFO).
208    free: RefCell<Vec<u32>>,
209    /// Reverse predicate index over the slots — prunes the push fan-out to the
210    /// connections a change could match (`designs/205-GUARDED-PUSH-FANOUT-DESIGN.md`).
211    /// A separate `RefCell` mutated only in [`connect`](Self::connect) /
212    /// [`destroy`](Self::destroy); the push path takes a short immutable borrow to
213    /// compute candidates *before* the drain begins, so it inherits the same
214    /// no-reentrant-connect/destroy-during-push regime as `conns`.
215    ///
216    /// **Boxed** so the index's BTree headers live off the inline `ConnTable` — that
217    /// keeps `SourceLeaf` (which holds a `ConnTable` by value) small enough to avoid
218    /// bloating the arena `Operator` enum, and the box is touched only off the hot
219    /// fetch path (connect/destroy and the once-per-push candidate lookup).
220    index: Box<RefCell<PushIndex>>,
221    /// `true` while a push is fanning out over the connections (the `gen_push` drain
222    /// loop). The dynamic guard mutators assert it is `false`: a family's binding set
223    /// and its guard change only **between** pushes (design 310, impl plan D3), never
224    /// from inside an operator, so the index and the predicate can never disagree
225    /// while either is being read.
226    in_push: Cell<bool>,
227}
228
229/// Clears [`ConnTable::in_push`] on drop — RAII so the flag is released on every exit
230/// of the drain loop (early return, `?`, and unwinding panics).
231struct InPushGuard<'a>(&'a Cell<bool>);
232impl Drop for InPushGuard<'_> {
233    fn drop(&mut self) {
234        self.0.set(false);
235    }
236}
237
238impl ConnTable {
239    pub fn new() -> ConnTable {
240        ConnTable::default()
241    }
242
243    /// Register `conn`, reusing a freed slot if one is available — its generation was
244    /// bumped at free time, so the returned [`ConnId`] is distinct from the slot's prior
245    /// tenant; otherwise grow the table with a fresh generation-0 slot.
246    pub fn connect(&self, conn: Connection) -> ConnId {
247        // Register the slot in the push index before moving `conn` into the vector.
248        let index_slot = |table: &ConnTable, slot: u32, conn: &Connection| {
249            let guard = conn.filters.as_ref().and_then(|f| f.push_guard.as_ref());
250            table
251                .index
252                .borrow_mut()
253                .insert(slot, guard, &conn.split_edit_keys);
254        };
255        if let Some(idx) = self.free.borrow_mut().pop() {
256            let i = idx as usize;
257            index_slot(self, idx, &conn);
258            self.conns.borrow_mut()[i] = conn;
259            ConnId::new(idx, self.gens.borrow()[i])
260        } else {
261            let idx = self.conns.borrow().len() as u32;
262            index_slot(self, idx, &conn);
263            self.conns.borrow_mut().push(conn);
264            self.gens.borrow_mut().push(0);
265            ConnId::new(idx, 0)
266        }
267    }
268
269    /// Disconnect a connection — null its output edge so the push fan-out skips it —
270    /// and free its slot for reuse (bump generation + add to the free-list). Idempotent
271    /// and stale-safe: a non-matching generation (a double teardown / already-freed
272    /// slot) is a no-op, never a panic, since teardown must be forgiving.
273    pub fn destroy(&self, conn: ConnId) {
274        let i = conn.idx as usize;
275        let mut gens = self.gens.borrow_mut();
276        if gens.get(i).copied() != Some(conn.gen) {
277            return;
278        }
279        // Un-index before nulling the output — the connection is still in its slot,
280        // so its own `push_guard` says exactly which buckets to remove it from. The
281        // generation-match guard above keeps a double-destroy a no-op for the index too.
282        if let Some(c) = self.conns.borrow().get(i) {
283            let guard = c.filters.as_ref().and_then(|f| f.push_guard.as_ref());
284            self.index
285                .borrow_mut()
286                .remove(conn.idx, guard, &c.split_edit_keys);
287            c.output.set(None);
288        }
289        gens[i] = gens[i].wrapping_add(1);
290        self.free.borrow_mut().push(i as u32);
291    }
292
293    /// The slot index for `conn`, fail-fasting on a stale generation (the connection
294    /// analogue of [`Graph::node`](crate::graph::Graph)/`storage`). Release-checked.
295    pub fn live_ix(&self, conn: ConnId) -> usize {
296        let i = conn.idx as usize;
297        let cur = self.gens.borrow().get(i).copied();
298        assert!(
299            cur == Some(conn.gen),
300            "stale ConnId {conn:?} (slot generation is {cur:?})"
301        );
302        i
303    }
304
305    /// Set a connection's downstream output edge (gen-checked).
306    pub fn set_output(&self, conn: ConnId, edge: OutEdge) {
307        let i = self.live_ix(conn);
308        self.conns.borrow()[i].output.set(Some(edge));
309    }
310
311    /// A connection's effective sort (gen-checked).
312    pub fn sort(&self, conn: ConnId) -> Sort {
313        let i = self.live_ix(conn);
314        self.conns.borrow()[i].sort.clone()
315    }
316
317    /// Borrow the connection vector — for the push fan-out and per-connection reads
318    /// (callers that already hold a [`live_ix`](Self::live_ix)).
319    pub fn borrow(&self) -> Ref<'_, Vec<Connection>> {
320        self.conns.borrow()
321    }
322
323    /// Total connection slots, **including freed (recyclable) ones**. With slot reuse
324    /// this stays bounded by the peak live-connection count across a source's life — it
325    /// does NOT grow per teardown. (The push fan-out's per-change cost is proportional
326    /// to this, so keeping it bounded is the point of the free-list.)
327    pub fn len(&self) -> usize {
328        self.conns.borrow().len()
329    }
330
331    /// Whether the table has no slots yet (clippy's `len`-without-`is_empty` companion).
332    pub fn is_empty(&self) -> bool {
333        self.conns.borrow().is_empty()
334    }
335
336    /// Number of connections with a live downstream edge (current readers). A destroyed slot
337    /// has its `output` nulled ([`destroy`](Self::destroy)), so this counts only live
338    /// pipelines — used by [`Graph::remove_source`](crate::graph::Graph) to refuse removing a
339    /// source a query still reads.
340    pub fn live_conn_count(&self) -> usize {
341        self.conns
342            .borrow()
343            .iter()
344            .filter(|c| c.output.get().is_some())
345            .count()
346    }
347
348    /// The slots that may satisfy `change` — a **superset** by construction
349    /// (`designs/205-GUARDED-PUSH-FANOUT-DESIGN.md` §safety). Sorted ascending and
350    /// deduped, so the push fan-out visits in slot order exactly as the full-scan
351    /// loop did. Takes a short immutable borrow of the index that is released before
352    /// it returns, so the drain can hold the `conns` borrow without index contention.
353    pub fn push_candidates(&self, change: &SourceChange) -> Vec<u32> {
354        self.index.borrow().push_candidates(change)
355    }
356
357    /// The distinct split-edit keys across all connections (refcounted union). Drives
358    /// the `gen_push_and_write_with_split_edit` trigger check at O(distinct keys)
359    /// instead of O(N·keys).
360    pub fn split_edit_keys(&self) -> Vec<ColId> {
361        self.index.borrow().split_keys().collect()
362    }
363
364    /// `true` iff some connection lists a split-edit key — lets the edit-split path
365    /// skip the value-comparison check entirely when no connection needs it.
366    pub fn has_split_edit_keys(&self) -> bool {
367        self.index.borrow().has_split_keys()
368    }
369
370    /// Add a **dynamic** guard value to `conn`'s push-index registration (design 310
371    /// §4.1 — a family root's binding set growing): the connection becomes a push
372    /// candidate for writes whose guard column carries `value`. The column is the
373    /// connection's own static [`PushGuard::col`] (the family builder registers the
374    /// root with an empty-valued guard on the first parameter column). Gen-checked;
375    /// **only between pushes** (asserted). A connection with no guard column is in the
376    /// always-visited scan list already, so there is nothing to add.
377    pub fn add_guard_value(&self, conn: ConnId, value: OwnedValue) {
378        debug_assert!(
379            !self.in_push.get(),
380            "dynamic guard mutation during a push (design 310, impl plan D3)"
381        );
382        let i = self.live_ix(conn);
383        let Some(col) = self.guard_col(i) else {
384            debug_assert!(
385                false,
386                "add_guard_value on a connection without a guard column"
387            );
388            return;
389        };
390        self.index
391            .borrow_mut()
392            .add_guard_value(conn.idx, col, value);
393    }
394
395    /// Remove one dynamic guard value previously added with
396    /// [`add_guard_value`](Self::add_guard_value). Gen-checked; only between pushes.
397    pub fn remove_guard_value(&self, conn: ConnId, value: &OwnedValue) {
398        debug_assert!(
399            !self.in_push.get(),
400            "dynamic guard mutation during a push (design 310, impl plan D3)"
401        );
402        let i = self.live_ix(conn);
403        let Some(col) = self.guard_col(i) else {
404            return;
405        };
406        self.index
407            .borrow_mut()
408            .remove_guard_value(conn.idx, col, value);
409    }
410
411    fn guard_col(&self, i: usize) -> Option<ColId> {
412        self.conns.borrow()[i]
413            .filters
414            .as_ref()
415            .and_then(|f| f.push_guard.as_ref())
416            .map(|g| g.col)
417    }
418
419    /// The push index's entry count (bucket memberships + scan slots + dynamic values)
420    /// — a churn/leak probe's size signal.
421    pub fn push_index_size(&self) -> usize {
422        self.index.borrow().size()
423    }
424
425    /// Bracket a push drain: sets `in_push` for the returned guard's lifetime.
426    fn enter_push(&self) -> InPushGuard<'_> {
427        self.in_push.set(true);
428        InPushGuard(&self.in_push)
429    }
430}
431
432// ---------------------------------------------------------------------------
433// The one reverse-aware comparator convention (§4.6)
434// ---------------------------------------------------------------------------
435
436/// `reverse=false` ⇒ `compare_rows(sort, a, b)`; `reverse=true` ⇒ its negation.
437/// Defined ONCE so the cursor seek direction, the overlay-add splice position,
438/// the remove-suppress equality, the start gate, and the k-way merge all agree.
439#[inline]
440pub fn compare_rows_rev(sort: &Sort, reverse: bool, a: &Row, b: &Row) -> Ordering {
441    let c = compare_rows(sort, a, b);
442    if reverse {
443        c.reverse()
444    } else {
445        c
446    }
447}
448
449// ---------------------------------------------------------------------------
450// Leaf cursor -> owned-row adaptor (the single forced own at the dyn boundary)
451// ---------------------------------------------------------------------------
452
453/// Adapts a lending [`RowStream`] leaf into an owning `Iterator<Item = OwnedRow>`.
454/// Each row is owned exactly here ([`RowRef::to_owned_row`]) — the one forced
455/// copy on the SQLite leaf, an `Arc` bump on the memory leaf — so from this point
456/// up every value is owned and the lending borrow never escapes. Still a row, not
457/// a node: the node wrapper is added at the connection boundary, not here.
458struct LeafRows<'g, S: RowStream> {
459    rows: S,
460    error_sink: Option<Rc<RefCell<Option<RindleError>>>>,
461    _p: PhantomData<&'g ()>,
462}
463
464impl<S: RowStream> Iterator for LeafRows<'_, S> {
465    type Item = Row;
466    #[inline]
467    fn next(&mut self) -> Option<Row> {
468        self.rows
469            .next_row()
470            .and_then(|r| match r.try_to_owned_row() {
471                Ok(row) => Some(row),
472                Err(err) => {
473                    if let Some(sink) = &self.error_sink {
474                        *sink.borrow_mut() = Some(err);
475                    }
476                    None
477                }
478            })
479    }
480}
481
482// ---------------------------------------------------------------------------
483// Overlay narrowing (pure functions over Overlays) — computeOverlays + helpers
484// ---------------------------------------------------------------------------
485
486/// The initial {add, remove} from the (already epoch-gated) overlay change.
487/// Add ⇒ {add}; Remove ⇒ {remove}; Edit ⇒ {add: new, remove: old}
488/// (`computeOverlays`, `memory-source.ts:734-753`).
489fn overlays_from_change(overlay: Option<&Overlay>) -> Overlays {
490    match overlay.map(|o| &o.change) {
491        Some(SourceChange::Add(r)) => Overlays {
492            add: Some(r.clone()),
493            remove: None,
494        },
495        Some(SourceChange::Remove(r)) => Overlays {
496            add: None,
497            remove: Some(r.clone()),
498        },
499        Some(SourceChange::Edit { row, old }) => Overlays {
500            add: Some(row.clone()),
501            remove: Some(old.clone()),
502        },
503        None => Overlays::default(),
504    }
505}
506
507/// Drop an add/remove that sorts strictly **before** `start_at`
508/// (`overlaysForStartAt`, `memory-source.ts:806`). Reverse-aware.
509///
510/// **Comparator invariant:** `sort` must be the comparator the downstream start gate
511/// ([`generate_with_start`]) applies — the **connection** sort — so "overlay dropped
512/// here" ⇔ "its committed twin would be gated out anyway". Narrowing under the
513/// *constrained index* sort instead is wrong whenever `start_at` does not satisfy the
514/// scan's constraint (a `FlippedJoin` per-IN-entry sub-fetch forwarding a `Take`
515/// bound): the comparison is then decided by the foreign start row's constraint
516/// columns, and the in-flight edit vanishes from the maintenance fetch — the
517/// take-over-flipped-fetch bound loss
518/// (`follow-ups/take-flip-bound-crossing-edit.md`).
519pub fn overlays_for_start_at(o: Overlays, start_at: &Row, sort: &Sort, reverse: bool) -> Overlays {
520    let before = |r: &Row| compare_rows_rev(sort, reverse, r, start_at) == Ordering::Less;
521    Overlays {
522        add: o.add.filter(|r| !before(r)),
523        remove: o.remove.filter(|r| !before(r)),
524    }
525}
526
527/// Drop an add/remove that does not match the constraint (`overlaysForConstraint`,
528/// `memory-source.ts:821`).
529pub fn overlays_for_constraint(o: Overlays, constraint: &Constraint) -> Overlays {
530    Overlays {
531        add: o.add.filter(|r| constraint_matches(r, constraint)),
532        remove: o.remove.filter(|r| constraint_matches(r, constraint)),
533    }
534}
535
536/// Keep an add/remove iff it matches **some** entry of the multiConstraint
537/// (`overlaysForMultiConstraint`, `memory-source.ts:787`).
538pub fn overlays_for_multi_constraint(o: Overlays, mc: &MultiConstraint) -> Overlays {
539    Overlays {
540        add: o
541            .add
542            .filter(|r| mc.iter().any(|c| constraint_matches(r, c))),
543        remove: o
544            .remove
545            .filter(|r| mc.iter().any(|c| constraint_matches(r, c))),
546    }
547}
548
549/// Drop an add/remove that fails the filter predicate
550/// (`overlaysForFilterPredicate`, `memory-source.ts:836`).
551pub(crate) fn overlays_for_filter_predicate(o: Overlays, predicate: &RowPredicate) -> Overlays {
552    Overlays {
553        add: o.add.filter(|r| predicate(r)),
554        remove: o.remove.filter(|r| predicate(r)),
555    }
556}
557
558/// The full ordered narrowing pipeline: startAt → constraint → each non-empty
559/// multiConstraint → filter (`computeOverlays`, `memory-source.ts:722`). Pure —
560/// no leaf access. `gate_sort`/`reverse` are the **connection** comparator — the one
561/// the downstream start gate ([`generate_with_start`]) applies — NOT the scan's
562/// constrained index sort (see [`overlays_for_start_at`]'s comparator invariant; the
563/// splice's interleaving comparator is a separate parameter of
564/// [`generate_with_overlay`]).
565pub fn compute_overlays(
566    start_at: Option<&Row>,
567    constraint: Option<&Constraint>,
568    overlay: Option<&Overlay>,
569    gate_sort: &Sort,
570    reverse: bool,
571    predicate: Option<&RowPredicate>,
572    multi_constraints: &[MultiConstraint],
573) -> Overlays {
574    let mut o = overlays_from_change(overlay);
575    if let Some(s) = start_at {
576        o = overlays_for_start_at(o, s, gate_sort, reverse);
577    }
578    if let Some(c) = constraint {
579        o = overlays_for_constraint(o, c);
580    }
581    for mc in multi_constraints {
582        if !mc.is_empty() {
583            o = overlays_for_multi_constraint(o, mc);
584        }
585    }
586    if let Some(p) = predicate {
587        o = overlays_for_filter_predicate(o, p);
588    }
589    o
590}
591
592// ---------------------------------------------------------------------------
593// The ordered overlay splice (generateWithOverlay + generateWithOverlayInner)
594// ---------------------------------------------------------------------------
595
596/// Splice the pre-narrowed `add`/`remove` into an ORDERED row stream
597/// (`generateWithOverlayInner`, `memory-source.ts:849`): emit `add` the first time
598/// it sorts before the current row (and at end if never emitted); suppress
599/// `remove` the first time it sort-equals a row (sort always includes the full
600/// PK, so sort-equal ⇒ identity — §3.5). Lazy: no materialization.
601struct OverlaySplice<I: Iterator<Item = Row>> {
602    inner: I,
603    add: Option<Row>,
604    remove: Option<Row>,
605    sort: Sort,
606    reverse: bool,
607    add_yielded: bool,
608    remove_skipped: bool,
609    /// A row pulled but deferred because we emitted `add` ahead of it.
610    buffered: Option<Row>,
611}
612
613impl<I: Iterator<Item = Row>> Iterator for OverlaySplice<I> {
614    type Item = Row;
615    fn next(&mut self) -> Option<Row> {
616        loop {
617            let row = match self.buffered.take().or_else(|| self.inner.next()) {
618                Some(r) => r,
619                None => {
620                    // End of stream: flush a never-emitted add (it sorts after all).
621                    if !self.add_yielded {
622                        if let Some(a) = self.add.take() {
623                            self.add_yielded = true;
624                            return Some(a);
625                        }
626                    }
627                    return None;
628                }
629            };
630
631            // Emit the add the first time it sorts before the current row, then
632            // re-process the current row on the next pull.
633            if !self.add_yielded {
634                if let Some(a) = self.add.as_ref() {
635                    if compare_rows_rev(&self.sort, self.reverse, a, &row) == Ordering::Less {
636                        self.add_yielded = true;
637                        let add_row = a.clone();
638                        self.buffered = Some(row);
639                        return Some(add_row);
640                    }
641                }
642            }
643
644            // Suppress the matching remove exactly once.
645            if !self.remove_skipped {
646                if let Some(rem) = self.remove.as_ref() {
647                    if compare_rows_rev(&self.sort, self.reverse, rem, &row) == Ordering::Equal {
648                        self.remove_skipped = true;
649                        continue;
650                    }
651                }
652            }
653
654            return Some(row);
655        }
656    }
657}
658
659/// Splice already-computed `overlays` into an ordered row stream. Exposed for
660/// direct testing (the JS `generateWithOverlayInner` suite). `sort`/`reverse` are
661/// the index comparator.
662pub fn generate_with_overlay_inner<'g, I: Iterator<Item = Row> + 'g>(
663    inner: I,
664    overlays: Overlays,
665    sort: Sort,
666    reverse: bool,
667) -> RowFlow<'g> {
668    // Empty overlays (every hydration — the overlay only exists mid-push, and
669    // narrowing often clears it during a push too): the splice would pass every
670    // row through unchanged, so don't pay its per-row checks.
671    if overlays.is_empty() {
672        return Box::new(inner);
673    }
674    Box::new(OverlaySplice {
675        inner,
676        add: overlays.add,
677        remove: overlays.remove,
678        sort,
679        reverse,
680        add_yielded: false,
681        remove_skipped: false,
682        buffered: None,
683    })
684}
685
686/// Drive a leaf cursor through the epoch-gated, narrowed, ordered overlay splice
687/// (`generateWithOverlay`, `memory-source.ts:697`). Generic over the leaf
688/// `S: RowStream` — the seam both backends feed. Two comparators, one per job:
689/// `sort` is the **index** comparator the splice interleaves under (the
690/// `break`-safety dependency, §3.1 — it must match the scan order of `rows`);
691/// `gate_sort` is the **connection** comparator the `start_at` narrowing compares
692/// under (it must match the downstream [`generate_with_start`] gate — see
693/// [`overlays_for_start_at`]'s comparator invariant). They coincide except on a
694/// constrained scan, where the index sort leads with the constraint columns.
695/// `start_at`, `constraint`, `predicate`, `multi_constraints` narrow the overlay
696/// only (the committed rows are filtered later by [`generate_with_filter`]).
697#[allow(clippy::too_many_arguments)]
698pub fn generate_with_overlay<'g, S: RowStream + 'g>(
699    start_at: Option<&Row>,
700    rows: S,
701    constraint: Option<&Constraint>,
702    overlay: Option<&Overlay>,
703    last_pushed_epoch: u32,
704    sort: &Sort,
705    gate_sort: &Sort,
706    reverse: bool,
707    predicate: Option<&RowPredicate>,
708    multi_constraints: &[MultiConstraint],
709) -> RowFlow<'g> {
710    generate_with_overlay_checked(
711        start_at,
712        rows,
713        constraint,
714        overlay,
715        last_pushed_epoch,
716        sort,
717        gate_sort,
718        reverse,
719        predicate,
720        multi_constraints,
721        None,
722    )
723}
724
725#[allow(clippy::too_many_arguments)]
726pub fn generate_with_overlay_checked<'g, S: RowStream + 'g>(
727    start_at: Option<&Row>,
728    rows: S,
729    constraint: Option<&Constraint>,
730    overlay: Option<&Overlay>,
731    last_pushed_epoch: u32,
732    sort: &Sort,
733    gate_sort: &Sort,
734    reverse: bool,
735    predicate: Option<&RowPredicate>,
736    multi_constraints: &[MultiConstraint],
737    error_sink: Option<Rc<RefCell<Option<RindleError>>>>,
738) -> RowFlow<'g> {
739    let gated = overlay.filter(|o| last_pushed_epoch >= o.epoch);
740    let overlays = compute_overlays(
741        start_at,
742        constraint,
743        gated,
744        gate_sort,
745        reverse,
746        predicate,
747        multi_constraints,
748    );
749    let inner = LeafRows {
750        rows,
751        error_sink,
752        _p: PhantomData,
753    };
754    generate_with_overlay_inner(inner, overlays, sort.clone(), reverse)
755}
756
757// ---------------------------------------------------------------------------
758// The unordered overlay splice (generateWithOverlayUnordered + …InnerUnordered)
759// ---------------------------------------------------------------------------
760
761/// Unordered splice (`generateWithOverlayInnerUnordered`, `memory-source.ts:929`):
762/// inject `add` eagerly at the head (row not yet in storage), suppress `remove`
763/// inline by **PK match** (no comparator available, so explicit PK equality).
764struct OverlayUnordered<I: Iterator<Item = Row>> {
765    inner: I,
766    add: Option<Row>,
767    remove: Option<Row>,
768    primary_key: Vec<ColId>,
769    add_emitted: bool,
770    remove_skipped: bool,
771}
772
773impl<I: Iterator<Item = Row>> Iterator for OverlayUnordered<I> {
774    type Item = Row;
775    fn next(&mut self) -> Option<Row> {
776        if !self.add_emitted {
777            self.add_emitted = true;
778            if let Some(a) = self.add.take() {
779                return Some(a);
780            }
781        }
782        loop {
783            let row = self.inner.next()?;
784            if !self.remove_skipped {
785                if let Some(rem) = self.remove.as_ref() {
786                    if same_pk(&row, rem, &self.primary_key) {
787                        self.remove_skipped = true;
788                        continue;
789                    }
790                }
791            }
792            return Some(row);
793        }
794    }
795}
796
797/// Splice already-computed `overlays` into an unordered row stream. Exposed for
798/// the JS `generateWithOverlayInnerUnordered` suite.
799pub fn generate_with_overlay_inner_unordered<'g, I: Iterator<Item = Row> + 'g>(
800    inner: I,
801    overlays: Overlays,
802    primary_key: Vec<ColId>,
803) -> RowFlow<'g> {
804    // Same pass-through fast path as the ordered splice above.
805    if overlays.is_empty() {
806        return Box::new(inner);
807    }
808    Box::new(OverlayUnordered {
809        inner,
810        add: overlays.add,
811        remove: overlays.remove,
812        primary_key,
813        add_emitted: false,
814        remove_skipped: false,
815    })
816}
817
818/// Unordered variant of [`generate_with_overlay`] (`generateWithOverlayUnordered`,
819/// `memory-source.ts:885`): NO start, add injected eagerly, remove suppressed by
820/// PK. Narrowing is constraint → multiConstraint → predicate (no startAt).
821pub fn generate_with_overlay_unordered<'g, S: RowStream + 'g>(
822    rows: S,
823    constraint: Option<&Constraint>,
824    overlay: Option<&Overlay>,
825    last_pushed_epoch: u32,
826    primary_key: &[ColId],
827    predicate: Option<&RowPredicate>,
828    multi_constraints: &[MultiConstraint],
829) -> RowFlow<'g> {
830    generate_with_overlay_unordered_checked(
831        rows,
832        constraint,
833        overlay,
834        last_pushed_epoch,
835        primary_key,
836        predicate,
837        multi_constraints,
838        None,
839    )
840}
841
842#[allow(clippy::too_many_arguments)]
843pub fn generate_with_overlay_unordered_checked<'g, S: RowStream + 'g>(
844    rows: S,
845    constraint: Option<&Constraint>,
846    overlay: Option<&Overlay>,
847    last_pushed_epoch: u32,
848    primary_key: &[ColId],
849    predicate: Option<&RowPredicate>,
850    multi_constraints: &[MultiConstraint],
851    error_sink: Option<Rc<RefCell<Option<RindleError>>>>,
852) -> RowFlow<'g> {
853    let gated = overlay.filter(|o| last_pushed_epoch >= o.epoch);
854    let mut overlays = overlays_from_change(gated);
855    if let Some(c) = constraint {
856        overlays = overlays_for_constraint(overlays, c);
857    }
858    for mc in multi_constraints {
859        if !mc.is_empty() {
860            overlays = overlays_for_multi_constraint(overlays, mc);
861        }
862    }
863    if let Some(p) = predicate {
864        overlays = overlays_for_filter_predicate(overlays, p);
865    }
866    let inner = LeafRows {
867        rows,
868        error_sink,
869        _p: PhantomData,
870    };
871    generate_with_overlay_inner_unordered(inner, overlays, primary_key.to_vec())
872}
873
874// ---------------------------------------------------------------------------
875// The post-scan row chain (start / constraint / filter)
876// ---------------------------------------------------------------------------
877
878/// Skip rows until the start bound is reached, then pass the rest
879/// (`generateWithStart`, `memory-source.ts:653`). Uses the **connection**
880/// comparator (reverse-aware). `start = None` ⇒ pass-through.
881pub fn generate_with_start<'g>(
882    rows: RowFlow<'g>,
883    start: Option<Start>,
884    sort: Sort,
885    reverse: bool,
886) -> RowFlow<'g> {
887    let Some(start) = start else {
888        return rows;
889    };
890    let mut started = false;
891    Box::new(rows.filter(move |row| {
892        if !started {
893            let c = compare_rows_rev(&sort, reverse, row, &start.row);
894            started = match start.basis {
895                Basis::At => c != Ordering::Less,
896                Basis::After => c == Ordering::Greater,
897            };
898        }
899        started
900    }))
901}
902
903/// Trim trailing rows once the constraint stops matching — a `break`, NOT a
904/// filter (`generateWithConstraint`, `memory-source.ts:505`). Valid because the
905/// chosen index is sorted by the constraint columns first, so once the scan
906/// passes the last matching row every subsequent row fails (§3.1).
907pub fn generate_with_constraint<'g>(
908    rows: RowFlow<'g>,
909    constraint: Option<Constraint>,
910) -> RowFlow<'g> {
911    match constraint {
912        None => rows,
913        Some(c) => Box::new(rows.take_while(move |row| constraint_matches(row, &c))),
914    }
915}
916
917/// Drop rows failing the predicate (`generateWithFilter`,
918/// `memory-source.ts:517`).
919pub fn generate_with_filter<'g>(rows: RowFlow<'g>, predicate: RowPredicate) -> RowFlow<'g> {
920    Box::new(rows.filter(move |row| predicate(row)))
921}
922
923// ---------------------------------------------------------------------------
924// K-way merge of pre-sorted row streams (mergeSortedStreams)
925// ---------------------------------------------------------------------------
926
927struct HeapEntry {
928    row: Row,
929    /// Which stream to refill from after this entry's row is emitted.
930    idx: usize,
931}
932
933/// A binary min-heap merge of pre-sorted row streams (`mergeSortedStreams`,
934/// `memory-source.ts:1051`). O(log K) per emit. Each heap entry holds an OWNED
935/// row so the head survives a refill of its sub-stream (the lending leaf would
936/// invalidate a borrow on the next pull — §4.6). `Drop`-clean for free: the
937/// sub-streams live in `streams` and are dropped (releasing their B+tree
938/// cursors + `Arc` snapshots) when this struct drops — the port of the JS
939/// `finally` → `.return()` on each non-exhausted sub-iterator.
940struct MergeSorted<'g> {
941    streams: Vec<RowFlow<'g>>,
942    heap: Vec<HeapEntry>,
943    sort: Sort,
944    reverse: bool,
945    primed: bool,
946}
947
948impl MergeSorted<'_> {
949    #[inline]
950    fn less(&self, a: usize, b: usize) -> bool {
951        compare_rows_rev(
952            &self.sort,
953            self.reverse,
954            &self.heap[a].row,
955            &self.heap[b].row,
956        ) == Ordering::Less
957    }
958
959    fn sift_up(&mut self, mut i: usize) {
960        while i > 0 {
961            let p = (i - 1) >> 1;
962            if !self.less(i, p) {
963                return;
964            }
965            self.heap.swap(i, p);
966            i = p;
967        }
968    }
969
970    fn sift_down(&mut self, mut i: usize) {
971        let n = self.heap.len();
972        loop {
973            let l = (i << 1) + 1;
974            let r = l + 1;
975            let mut smallest = i;
976            if l < n && self.less(l, smallest) {
977                smallest = l;
978            }
979            if r < n && self.less(r, smallest) {
980                smallest = r;
981            }
982            if smallest == i {
983                return;
984            }
985            self.heap.swap(i, smallest);
986            i = smallest;
987        }
988    }
989
990    fn prime(&mut self) {
991        for i in 0..self.streams.len() {
992            if let Some(row) = self.streams[i].next() {
993                self.heap.push(HeapEntry { row, idx: i });
994                self.sift_up(self.heap.len() - 1);
995            }
996        }
997    }
998}
999
1000impl Iterator for MergeSorted<'_> {
1001    type Item = Row;
1002    fn next(&mut self) -> Option<Row> {
1003        if !self.primed {
1004            self.primed = true;
1005            self.prime();
1006        }
1007        if self.heap.is_empty() {
1008            return None;
1009        }
1010        let idx = self.heap[0].idx;
1011        match self.streams[idx].next() {
1012            Some(new_row) => {
1013                // Refill the root in place and sift down; return the old root row.
1014                let old = std::mem::replace(&mut self.heap[0].row, new_row);
1015                self.sift_down(0);
1016                Some(old)
1017            }
1018            None => {
1019                // Stream exhausted. Move the tail into the root and shrink.
1020                let last = self.heap.pop().unwrap();
1021                if self.heap.is_empty() {
1022                    // `last` WAS the root (single entry); its row is the result.
1023                    Some(last.row)
1024                } else {
1025                    let old_root = std::mem::replace(&mut self.heap[0], last);
1026                    self.sift_down(0);
1027                    Some(old_root.row)
1028                }
1029            }
1030        }
1031    }
1032}
1033
1034/// K-way min-heap merge of pre-sorted row streams under the reverse-aware
1035/// comparator. `Drop`-clean (early close drops every sub-stream — §5.5).
1036pub fn merge_sorted_streams<'g>(
1037    streams: Vec<RowFlow<'g>>,
1038    sort: Sort,
1039    reverse: bool,
1040) -> RowFlow<'g> {
1041    Box::new(MergeSorted {
1042        streams,
1043        heap: Vec::new(),
1044        sort,
1045        reverse,
1046        primed: false,
1047    })
1048}
1049
1050// ---------------------------------------------------------------------------
1051// Predicate-filtered push (filter-push.ts + maybe-split-and-push-edit-change.ts)
1052// ---------------------------------------------------------------------------
1053
1054/// Apply the connection's filter predicate to one source change, then hand the
1055/// (possibly transformed) change to `push` (`filterPush`, `filter-push.ts`).
1056/// Still a `SourceChange` (rows) — the node-bearing downstream `Change` is built
1057/// at the connection boundary, not here. With no predicate the change passes
1058/// verbatim. Add/Remove are dropped if the row fails the predicate. An Edit is
1059/// split by predicate (`maybeSplitAndPushEditChange`): old&new present ⇒ Edit;
1060/// only old ⇒ Remove(old); only new ⇒ Add(new); neither ⇒ dropped.
1061pub(crate) fn filter_push(
1062    change: SourceChange,
1063    predicate: Option<&RowPredicate>,
1064    push: &dyn Fn(SourceChange),
1065) {
1066    let Some(p) = predicate else {
1067        push(change);
1068        return;
1069    };
1070    match change {
1071        SourceChange::Add(r) => {
1072            if p(&r) {
1073                push(SourceChange::Add(r));
1074            }
1075        }
1076        SourceChange::Remove(r) => {
1077            if p(&r) {
1078                push(SourceChange::Remove(r));
1079            }
1080        }
1081        SourceChange::Edit { row, old } => {
1082            let old_present = p(&old);
1083            let new_present = p(&row);
1084            if old_present && new_present {
1085                push(SourceChange::Edit { row, old });
1086            } else if old_present {
1087                push(SourceChange::Remove(old));
1088            } else if new_present {
1089                push(SourceChange::Add(row));
1090            }
1091        }
1092    }
1093}
1094
1095// ---------------------------------------------------------------------------
1096// Eager push orchestration (genPushAndWriteWithSplitEdit / genPushAndWrite / genPush)
1097// ---------------------------------------------------------------------------
1098
1099/// Existence asserts + per-connection fan-out with the live overlay
1100/// (`genPush`, `memory-source.ts:595`). For each connection with a wired output:
1101/// bump its epoch gate FIRST (so a reentrant fetch of a *later* connection won't
1102/// see the overlay yet — the self-join gate), set the overlay, then push the
1103/// (filter-applied) change. Clears the overlay after the drain. `push_one`
1104/// receives a `SourceChange` (rows); the connection boundary turns it into a
1105/// node-bearing downstream `Change`.
1106fn gen_push(
1107    conns: &ConnTable,
1108    change: &SourceChange,
1109    exists: &dyn Fn(&Row) -> bool,
1110    set_overlay: &dyn Fn(Option<Overlay>),
1111    push_epoch: u32,
1112    push_one: &dyn Fn(&Connection, SourceChange),
1113) {
1114    match change {
1115        SourceChange::Add(r) => debug_assert!(!exists(r), "MemorySource: ADD row already exists"),
1116        SourceChange::Remove(r) => debug_assert!(exists(r), "MemorySource: REMOVE row not found"),
1117        SourceChange::Edit { old, .. } => {
1118            debug_assert!(exists(old), "MemorySource: EDIT old row not found")
1119        }
1120    }
1121
1122    let candidates = conns.push_candidates(change);
1123    let connections = conns.borrow();
1124    debug_assert_index_sound(&connections, &candidates, change);
1125    metric_add!(push_visited, candidates.len() as u64);
1126    metric_add!(
1127        push_skipped,
1128        (connections.len() as u64).saturating_sub(candidates.len() as u64)
1129    );
1130
1131    let _in_push = conns.enter_push();
1132    for &slot in &candidates {
1133        let conn = &connections[slot as usize];
1134        if conn.output.get().is_some() {
1135            conn.last_pushed_epoch.set(push_epoch);
1136            set_overlay(Some(Overlay {
1137                epoch: push_epoch,
1138                change: change.clone(),
1139            }));
1140            let predicate = conn.filters.as_ref().map(|f| &f.predicate);
1141            filter_push(change.clone(), predicate, &|sc| push_one(conn, sc));
1142        }
1143    }
1144
1145    set_overlay(None);
1146}
1147
1148/// Debug-only soundness net for the push index (`designs/205` §4 "Debug-mode
1149/// exactness check"). The safety argument is "every skipped connection's predicate
1150/// rejects both rows", so assert exactly that: walk the wired **non**-candidates and
1151/// require both `predicate(old)` and `predicate(new)` to be false. O(N), compiled out
1152/// of release — it turns any guard-extraction or comparator-coarseness bug into an
1153/// immediate assert instead of a silently missing delta, and makes every differential
1154/// / fuzz lane a guard-soundness test.
1155#[inline]
1156fn debug_assert_index_sound(connections: &[Connection], candidates: &[u32], change: &SourceChange) {
1157    #[cfg(debug_assertions)]
1158    {
1159        // `candidates` is sorted ascending (push_candidates), so membership is a
1160        // binary search.
1161        let rejects = |conn: &Connection| -> bool {
1162            let Some(f) = conn.filters.as_ref() else {
1163                // A filterless connection sits in the always-visited scan list, so it
1164                // is ALWAYS a candidate — reaching here means the index dropped it.
1165                return false;
1166            };
1167            let p = &f.predicate;
1168            match change {
1169                SourceChange::Add(r) | SourceChange::Remove(r) => !p(r),
1170                SourceChange::Edit { row, old } => !p(row) && !p(old),
1171            }
1172        };
1173        for (slot, conn) in connections.iter().enumerate() {
1174            if conn.output.get().is_none() || candidates.binary_search(&(slot as u32)).is_ok() {
1175                continue;
1176            }
1177            debug_assert!(
1178                rejects(conn),
1179                "push index skipped connection slot {slot} whose predicate accepts the changed row(s) — a missing delta"
1180            );
1181        }
1182    }
1183    let _ = (connections, candidates, change);
1184}
1185
1186/// Change-consistency check: ADD-not-already-present / REMOVE-present /
1187/// EDIT-old-present (WS02.2).
1188///
1189/// - **strict** (the per-graph `validate_changes` toggle): returns
1190///   `Err(RindleError::ConsistencyViolation)` on a malformed change. This runs in
1191///   **release**, so a misbehaving change producer surfaces a typed error instead of
1192///   silently corrupting index/overlay/view state.
1193/// - **non-strict** (default, hot path): `debug_assert!` only. Release builds return
1194///   before invoking `exists`, matching the memory source's stripped assertion: one
1195///   boolean branch, no storage lookup, and identical behavior for valid streams.
1196fn assert_change_existence_checked(
1197    change: &SourceChange,
1198    exists: &dyn Fn(&Row) -> Result<bool, RindleError>,
1199    strict: bool,
1200) -> Result<(), RindleError> {
1201    // Keep the expensive `exists` callback syntactically outside the stripped
1202    // `debug_assert!` path. Function arguments are evaluated before `check` below;
1203    // without this early return, `check(!exists(r)?, ...)` runs the SQLite SELECT
1204    // even though non-strict release builds subsequently discard the assertion.
1205    #[cfg(not(debug_assertions))]
1206    if !strict {
1207        return Ok(());
1208    }
1209
1210    let check = |ok: bool, kind: &'static str| -> Result<(), RindleError> {
1211        if ok {
1212            return Ok(());
1213        }
1214        if strict {
1215            Err(RindleError::ConsistencyViolation { kind })
1216        } else {
1217            debug_assert!(false, "Source: {kind}");
1218            Ok(())
1219        }
1220    };
1221    match change {
1222        SourceChange::Add(r) => check(!exists(r)?, "ADD row already exists"),
1223        SourceChange::Remove(r) => check(exists(r)?, "REMOVE row not found"),
1224        SourceChange::Edit { old, .. } => check(exists(old)?, "EDIT old row not found"),
1225    }
1226}
1227
1228/// Fallible sibling of [`gen_push`]. It preserves the hot-path infallible helper
1229/// for memory while allowing external backends to surface existence/check errors
1230/// without side channels.
1231fn try_gen_push(
1232    conns: &ConnTable,
1233    change: &SourceChange,
1234    exists: &dyn Fn(&Row) -> Result<bool, RindleError>,
1235    set_overlay: &dyn Fn(Option<Overlay>),
1236    push_epoch: u32,
1237    push_one: &dyn Fn(&Connection, SourceChange),
1238    strict: bool,
1239) -> Result<(), RindleError> {
1240    assert_change_existence_checked(change, exists, strict)?;
1241
1242    let candidates = conns.push_candidates(change);
1243    let connections = conns.borrow();
1244    debug_assert_index_sound(&connections, &candidates, change);
1245    metric_add!(push_visited, candidates.len() as u64);
1246    metric_add!(
1247        push_skipped,
1248        (connections.len() as u64).saturating_sub(candidates.len() as u64)
1249    );
1250
1251    let _in_push = conns.enter_push();
1252    for &slot in &candidates {
1253        let conn = &connections[slot as usize];
1254        if conn.output.get().is_some() {
1255            conn.last_pushed_epoch.set(push_epoch);
1256            set_overlay(Some(Overlay {
1257                epoch: push_epoch,
1258                change: change.clone(),
1259            }));
1260            let predicate = conn.filters.as_ref().map(|f| &f.predicate);
1261            filter_push(change.clone(), predicate, &|sc| push_one(conn, sc));
1262        }
1263    }
1264
1265    set_overlay(None);
1266    Ok(())
1267}
1268
1269/// `genPush` then commit the write (`genPushAndWrite`, `memory-source.ts:581`).
1270/// The write happens AFTER the drain — a reentrant fetch during the drain sees
1271/// the overlay but not the committed row (the ordering invariant, §3.11).
1272#[allow(clippy::too_many_arguments)]
1273fn gen_push_and_write(
1274    conns: &ConnTable,
1275    change: SourceChange,
1276    exists: &dyn Fn(&Row) -> bool,
1277    set_overlay: &dyn Fn(Option<Overlay>),
1278    write: &dyn Fn(&SourceChange),
1279    push_epoch: u32,
1280    push_one: &dyn Fn(&Connection, SourceChange),
1281) {
1282    gen_push(conns, &change, exists, set_overlay, push_epoch, push_one);
1283    write(&change);
1284}
1285
1286#[allow(clippy::too_many_arguments)]
1287fn try_gen_push_and_write(
1288    conns: &ConnTable,
1289    change: SourceChange,
1290    exists: &dyn Fn(&Row) -> Result<bool, RindleError>,
1291    set_overlay: &dyn Fn(Option<Overlay>),
1292    write: &dyn Fn(&SourceChange) -> Result<(), RindleError>,
1293    push_epoch: u32,
1294    push_one: &dyn Fn(&Connection, SourceChange),
1295    strict: bool,
1296) -> Result<(), RindleError> {
1297    try_gen_push(
1298        conns,
1299        &change,
1300        exists,
1301        set_overlay,
1302        push_epoch,
1303        push_one,
1304        strict,
1305    )?;
1306    write(&change)
1307}
1308
1309/// Edit-split detection + per-connection fan-out + deferred write
1310/// (`genPushAndWriteWithSplitEdit`, `memory-source.ts:525`). If the change is an
1311/// Edit and any connection's split-edit key changes value
1312/// (`!values_equal` — null→null DOES split, §3.6 polarity), decompose into
1313/// Remove(old) then Add(row), each its own epoch + push + write.
1314///
1315/// The callbacks abstract the backend: `exists` is the existence check (memory:
1316/// primary-index `has`); `set_overlay`/`next_epoch` mutate source state; `write`
1317/// commits (memory: every index); `push_one` drives one source change to one
1318/// connection's output (→ connection boundary → downstream). No `'yield'`
1319/// (foundations §5).
1320#[allow(clippy::too_many_arguments)]
1321pub(crate) fn gen_push_and_write_with_split_edit(
1322    conns: &ConnTable,
1323    change: SourceChange,
1324    exists: &dyn Fn(&Row) -> bool,
1325    set_overlay: &dyn Fn(Option<Overlay>),
1326    write: &dyn Fn(&SourceChange),
1327    next_epoch: &dyn Fn() -> u32,
1328    push_one: &dyn Fn(&Connection, SourceChange),
1329) {
1330    if let SourceChange::Edit { row, old } = &change {
1331        if should_split_edit(conns, row, old) {
1332            gen_push_and_write(
1333                conns,
1334                SourceChange::Remove(old.clone()),
1335                exists,
1336                set_overlay,
1337                write,
1338                next_epoch(),
1339                push_one,
1340            );
1341            gen_push_and_write(
1342                conns,
1343                SourceChange::Add(row.clone()),
1344                exists,
1345                set_overlay,
1346                write,
1347                next_epoch(),
1348                push_one,
1349            );
1350            return;
1351        }
1352    }
1353    gen_push_and_write(
1354        conns,
1355        change,
1356        exists,
1357        set_overlay,
1358        write,
1359        next_epoch(),
1360        push_one,
1361    );
1362}
1363
1364/// Whether an Edit must be split into Remove(old)+Add(row): true iff some live
1365/// connection's split-edit key changed value. Reads the [`ConnTable`]'s refcounted
1366/// **union** of split keys (`designs/205` §5) — exactly equivalent to today's
1367/// `any`-of-`any` over per-connection key lists (`k` is in the union iff some live
1368/// slot lists it), at O(distinct keys) instead of O(N·keys). Once triggered the split
1369/// is applied table-wide, as before; only the trigger check changed.
1370#[inline]
1371fn should_split_edit(conns: &ConnTable, row: &Row, old: &Row) -> bool {
1372    conns.has_split_edit_keys()
1373        && conns
1374            .split_edit_keys()
1375            .into_iter()
1376            .any(|k| !values_equal(row.col(k), old.col(k)))
1377}
1378
1379#[allow(clippy::too_many_arguments)]
1380pub fn try_gen_push_and_write_with_split_edit(
1381    conns: &ConnTable,
1382    change: SourceChange,
1383    exists: &dyn Fn(&Row) -> Result<bool, RindleError>,
1384    set_overlay: &dyn Fn(Option<Overlay>),
1385    write: &dyn Fn(&SourceChange) -> Result<(), RindleError>,
1386    next_epoch: &dyn Fn() -> u32,
1387    push_one: &dyn Fn(&Connection, SourceChange),
1388    strict: bool,
1389) -> Result<(), RindleError> {
1390    if let SourceChange::Edit { row, old } = &change {
1391        if should_split_edit(conns, row, old) {
1392            try_gen_push_and_write(
1393                conns,
1394                SourceChange::Remove(old.clone()),
1395                exists,
1396                set_overlay,
1397                write,
1398                next_epoch(),
1399                push_one,
1400                strict,
1401            )?;
1402            try_gen_push_and_write(
1403                conns,
1404                SourceChange::Add(row.clone()),
1405                exists,
1406                set_overlay,
1407                write,
1408                next_epoch(),
1409                push_one,
1410                strict,
1411            )?;
1412            return Ok(());
1413        }
1414    }
1415    try_gen_push_and_write(
1416        conns,
1417        change,
1418        exists,
1419        set_overlay,
1420        write,
1421        next_epoch(),
1422        push_one,
1423        strict,
1424    )
1425}
1426
1427#[cfg(test)]
1428mod conn_table_tests {
1429    use super::*;
1430    use crate::change::Port;
1431    use crate::graph::NodeId;
1432    use crate::value::owned_row;
1433
1434    fn minimal_conn() -> Connection {
1435        Connection {
1436            sort: Vec::new(),
1437            unordered: true,
1438            split_edit_keys: Vec::new(),
1439            filters: None,
1440            last_pushed_epoch: Cell::new(0),
1441            output: Cell::new(None),
1442        }
1443    }
1444
1445    /// A connection guarded on `col ∈ values` (with a trivial predicate — the index
1446    /// tests only exercise candidate selection, not `filter_push`).
1447    fn guarded_conn(col: ColId, values: Vec<OwnedValue>) -> Connection {
1448        Connection {
1449            filters: Some(ConnectionFilters {
1450                predicate: Rc::new(|_: &Row| true),
1451                pk_constraint: None,
1452                fully_applied: true,
1453                sql_condition: None,
1454                push_guard: Some(PushGuard { col, values }),
1455            }),
1456            ..minimal_conn()
1457        }
1458    }
1459
1460    fn conn_with_split_keys(keys: Vec<ColId>) -> Connection {
1461        Connection {
1462            split_edit_keys: keys,
1463            ..minimal_conn()
1464        }
1465    }
1466
1467    fn add(cells: Vec<OwnedValue>) -> SourceChange {
1468        SourceChange::Add(owned_row(cells))
1469    }
1470
1471    #[test]
1472    fn non_strict_existence_lookup_only_runs_for_debug_assertions() {
1473        let calls = Cell::new(0);
1474        let change = add(vec![OwnedValue::Int(1)]);
1475        let result = assert_change_existence_checked(
1476            &change,
1477            &|_| {
1478                calls.set(calls.get() + 1);
1479                Ok(false) // valid ADD: the row is absent
1480            },
1481            false,
1482        );
1483
1484        assert!(result.is_ok());
1485        assert_eq!(calls.get(), usize::from(cfg!(debug_assertions)));
1486    }
1487
1488    #[test]
1489    fn strict_existence_lookup_runs_in_every_build_mode() {
1490        let calls = Cell::new(0);
1491        let change = add(vec![OwnedValue::Int(1)]);
1492        let result = assert_change_existence_checked(
1493            &change,
1494            &|_| {
1495                calls.set(calls.get() + 1);
1496                Ok(true) // invalid ADD: the row is already present
1497            },
1498            true,
1499        );
1500
1501        assert_eq!(calls.get(), 1);
1502        assert!(matches!(
1503            result,
1504            Err(RindleError::ConsistencyViolation { kind }) if kind == "ADD row already exists"
1505        ));
1506    }
1507
1508    #[test]
1509    fn connect_indexes_guard_so_push_candidates_prunes() {
1510        let t = ConnTable::new();
1511        let g = t.connect(guarded_conn(0, vec![OwnedValue::Int(42)])); // where col0 = 42
1512        let s = t.connect(minimal_conn()); // no filter -> scan list, always visited
1513
1514        // A write to 42 visits both the guarded match and the scan connection.
1515        let mut hit = t.push_candidates(&add(vec![OwnedValue::Int(42)]));
1516        hit.sort_unstable();
1517        assert_eq!(hit, vec![g.idx, s.idx]);
1518        // A write to 99 visits only the scan connection.
1519        assert_eq!(
1520            t.push_candidates(&add(vec![OwnedValue::Int(99)])),
1521            vec![s.idx]
1522        );
1523    }
1524
1525    #[test]
1526    fn destroy_unindexes_from_push_candidates() {
1527        let t = ConnTable::new();
1528        let g = t.connect(guarded_conn(0, vec![OwnedValue::Int(42)]));
1529        assert_eq!(
1530            t.push_candidates(&add(vec![OwnedValue::Int(42)])),
1531            vec![g.idx]
1532        );
1533        t.destroy(g);
1534        assert!(t
1535            .push_candidates(&add(vec![OwnedValue::Int(42)]))
1536            .is_empty());
1537    }
1538
1539    #[test]
1540    fn recycled_slot_reindexes_under_new_guard() {
1541        let t = ConnTable::new();
1542        let a = t.connect(guarded_conn(0, vec![OwnedValue::Int(10)]));
1543        t.destroy(a);
1544        // Recycle a's slot with a different guard value.
1545        let b = t.connect(guarded_conn(0, vec![OwnedValue::Int(20)]));
1546        assert_eq!(b.idx, a.idx, "slot recycled");
1547        assert!(t
1548            .push_candidates(&add(vec![OwnedValue::Int(10)]))
1549            .is_empty());
1550        assert_eq!(
1551            t.push_candidates(&add(vec![OwnedValue::Int(20)])),
1552            vec![b.idx]
1553        );
1554    }
1555
1556    #[test]
1557    fn split_edit_key_union_maintained_through_conntable() {
1558        let t = ConnTable::new();
1559        assert!(!t.has_split_edit_keys());
1560        let a = t.connect(conn_with_split_keys(vec![2, 5]));
1561        let b = t.connect(conn_with_split_keys(vec![5]));
1562        assert!(t.has_split_edit_keys());
1563        let mut keys = t.split_edit_keys();
1564        keys.sort_unstable();
1565        assert_eq!(keys, vec![2, 5]);
1566        t.destroy(a); // 2 -> 0 (drop), 5 -> 1 (b still lists it)
1567        assert_eq!(t.split_edit_keys(), vec![5]);
1568        t.destroy(b);
1569        assert!(!t.has_split_edit_keys());
1570    }
1571
1572    /// A family root registers with an empty static guard and grows it dynamically
1573    /// (design 310 §4.1): unindexed until a value is added, pruned per value removed,
1574    /// and fully drained by `destroy` so a recycled slot inherits nothing.
1575    #[test]
1576    fn dynamic_guard_values_through_conntable() {
1577        let t = ConnTable::new();
1578        let fam = t.connect(guarded_conn(0, vec![])); // family root: indexed nowhere
1579        let s = t.connect(minimal_conn()); // scan list
1580        assert_eq!(
1581            t.push_candidates(&add(vec![OwnedValue::Int(7)])),
1582            vec![s.idx]
1583        );
1584        t.add_guard_value(fam, OwnedValue::Int(7));
1585        let mut hit = t.push_candidates(&add(vec![OwnedValue::Int(7)]));
1586        hit.sort_unstable();
1587        assert_eq!(hit, vec![fam.idx, s.idx]);
1588        assert_eq!(
1589            t.push_candidates(&add(vec![OwnedValue::Int(8)])),
1590            vec![s.idx]
1591        );
1592        let before = t.push_index_size();
1593        t.add_guard_value(fam, OwnedValue::Int(8));
1594        t.remove_guard_value(fam, &OwnedValue::Int(8));
1595        assert_eq!(t.push_index_size(), before, "add + remove is size-neutral");
1596        t.remove_guard_value(fam, &OwnedValue::Int(7));
1597        assert_eq!(
1598            t.push_candidates(&add(vec![OwnedValue::Int(7)])),
1599            vec![s.idx]
1600        );
1601        // Destroy with values still added: drained, and the recycled slot is clean.
1602        t.add_guard_value(fam, OwnedValue::Int(9));
1603        t.destroy(fam);
1604        let again = t.connect(guarded_conn(0, vec![OwnedValue::Int(1)]));
1605        assert_eq!(again.idx, fam.idx, "slot recycled");
1606        assert_eq!(
1607            t.push_candidates(&add(vec![OwnedValue::Int(9)])),
1608            vec![s.idx],
1609            "the old tenant's dynamic value is gone"
1610        );
1611        assert_eq!(
1612            t.push_index_size(),
1613            2,
1614            "one scan slot + one static bucket entry"
1615        );
1616    }
1617
1618    /// A torn-down connection's slot is RECYCLED (same index, bumped generation), so the
1619    /// table does not grow per teardown — the leak fix.
1620    #[test]
1621    fn destroyed_connection_slot_is_recycled_not_leaked() {
1622        let t = ConnTable::new();
1623        let a = t.connect(minimal_conn());
1624        let b = t.connect(minimal_conn());
1625        assert_eq!(t.len(), 2);
1626        assert_eq!((a.idx, a.gen), (0, 0));
1627        assert_eq!((b.idx, b.gen), (1, 0));
1628
1629        t.set_output(
1630            a,
1631            OutEdge {
1632                node: NodeId::new(9, 0),
1633                port: Port::Single,
1634            },
1635        );
1636        t.destroy(a);
1637
1638        let c = t.connect(minimal_conn());
1639        assert_eq!(t.len(), 2, "slot reused, the table did not grow");
1640        assert_eq!(c.idx, a.idx, "recycled a's slot index");
1641        assert!(c.gen > a.gen, "recycled slot carries a bumped generation");
1642        // The recycled connection starts disconnected (a fresh `Connection`, output None).
1643        assert!(t.borrow()[c.idx as usize].output.get().is_none());
1644    }
1645
1646    /// A stale `ConnId` (the pre-free generation) fail-fasts on any gen-checked access.
1647    #[test]
1648    #[should_panic(expected = "stale ConnId")]
1649    fn stale_conn_id_fails_fast() {
1650        let t = ConnTable::new();
1651        let a = t.connect(minimal_conn());
1652        t.destroy(a);
1653        let _ = t.sort(a);
1654    }
1655
1656    /// `destroy` is idempotent / stale-safe: a second destroy of the same handle is a
1657    /// no-op (the slot is freed exactly once, not pushed onto the free-list twice).
1658    #[test]
1659    fn double_destroy_is_a_noop() {
1660        let t = ConnTable::new();
1661        let a = t.connect(minimal_conn());
1662        t.destroy(a);
1663        t.destroy(a); // stale generation → no-op
1664
1665        let b = t.connect(minimal_conn()); // reuses a's single freed slot
1666        assert_eq!(b.idx, a.idx);
1667        let c = t.connect(minimal_conn()); // free-list now empty → must grow
1668        assert_ne!(c.idx, a.idx);
1669        assert_eq!(t.len(), 2);
1670    }
1671}