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}