Rindle docs and package mapSkip to main content

rindle/op/
flipped_join.rs

1//! `FlippedJoin` (`ivm/flipped-join.ts`, spec `06` §3.2) — the **flipped EXISTS**
2//! inner join: child-driven, batched, outputs **parent** rows that have ≥1 related
3//! child.
4//!
5//! Where [`Join`](crate::graph::Join) is parent-driven (fetch parents, attach a
6//! lazy child relationship to each), `FlippedJoin` is child-driven: it fetches
7//! **all children first**, groups them by their join key, batch-fetches the
8//! matching parents in one `multi_constraints` request (the source does the k-way
9//! IN-fanout merge), and yields each returned parent **with its related children
10//! attached** — but only parents some child pointed at. That inner-join gate (drop
11//! a parent with no related child) is exactly the `where EXISTS(rel)` semantics, so
12//! the builder lowers a *flipped* EXISTS condition to a bare `FlippedJoin` (no
13//! separate [`Exists`](crate::op::Exists) gate).
14//!
15//! ## What it reuses from `Join` (the re-materialize overlay model)
16//!
17//! The relationship `FlippedJoin` attaches to each parent is built **identically**
18//! to `Join`'s — [`Graph::attach_child_rel`](crate::graph::Graph::attach_child_rel)
19//! with the same by-value [`JoinOverlay`] + `splice_join_overlay` (Primitive #5).
20//! So `FlippedJoin` inherits `Join`'s proven, View-`re-materialize` overlay
21//! behavior verbatim. On top of it sits the spec's `{change, position}` in-flight
22//! suppress model (`#inprogressChildChange`/`#inprogressChildChangePosition`,
23//! `flipped-join.ts:103`) — the flipped twin of `Graph::join_overlay_for`,
24//! see [`FlippedJoin::inprogress_overlay`] and `inflight_fetch_action`. Without
25//! it, a child change fanning out to SEVERAL parents (a non-unique parent join
26//! key) lets every mid-fan-out maintenance fetch see the FINAL post-change world,
27//! so a downstream `Take`'s bound jumps instead of cascading and it emits phantom
28//! removes (`follow-ups/take-flip-bound-crossing-edit.md`, defect #2).
29//!
30//! ## What is `FlippedJoin`-specific
31//!
32//! - **fetch** (child-first): translate the request constraint parent→child, fetch
33//!   all children, group by [`canonical_key`], one batched parent fetch, drop a
34//!   returned parent whose key no child produced (the inner-join gate + the
35//!   pass-through filter for chained flipped joins).
36//! - **push child port**: the changed child is re-projected onto its parents; for
37//!   each, decide whether the parent still has *another* related child (`exists`).
38//!   If yes → a `Child` change (the parent stays, its relationship changed); if no →
39//!   the changed child is the parent's **sole** child, so its existence flips:
40//!   `Add(parent)` on a child add, `Remove(parent)` on a child remove.
41//! - **push parent port**: forward the change with the relationship flipped on,
42//!   **unless** the parent has no related child (inner-join drop).
43//!
44//! ## Fully implemented (formerly deferred)
45//!
46//! - **Chunked fetch** (`computed_multi.len() > chunk_size`): slices the IN-batch and
47//!   node-level-merges the chunk streams via
48//!   [`merge_node_streams`](crate::op::merge_node_streams) (default `chunk_size` 256;
49//!   the `chunk_size` field lets a test drive it).
50//! - **Nested `Change::Child` on the child port** (a grandchild change under the
51//!   flipped child): carried up as a full `Change` and rebuilt per parent, exactly as
52//!   [`Join`](crate::graph)'s child port — see `push_child`/`push_child_change`.
53//! - **The `{change, position}` in-flight suppress model** (see above): published by
54//!   `push_child_change` for the duration of the fan-out (RAII-cleared), consulted
55//!   by the fetch path. (A nested `Change::Child` publishes no overlay — it never
56//!   changes membership; same reduced scope as the non-flipped twin, see
57//!   [`JoinOverlay::for_child_change`].)
58
59use std::cell::{Cell, RefCell};
60use std::cmp::Ordering;
61use std::collections::HashSet;
62
63use crate::change::{
64    build_join_constraint, constraints_are_compatible, materialize_change, rebuild_change,
65    rebuild_node, Change, Constraint, FetchRequest, JoinOverlay, MultiConstraint, Node, NodeStream,
66    OutEdge, OwnedChange, OwnedNode, Port, Relationship, SourceChange,
67};
68use crate::graph::{Graph, NodeId, OverlayPolarity};
69use crate::value::{compare_rows, ColId, OwnedRow as Row, RelId, Sort};
70
71use super::join_util::{
72    canonical_key, canonical_key_of_constraint, is_join_match, row_equals_for_compound_key,
73    CanonKey,
74};
75
76/// `MULTI_CONSTRAINT_CHUNK_SIZE` (`flipped-join.ts:55`): the IN-batch is sliced
77/// into windows of this many constraints, each its own `parent.fetch`, merged.
78pub const DEFAULT_CHUNK_SIZE: usize = 256;
79
80#[cfg(any(test, feature = "testkit"))]
81thread_local! {
82    /// Test-only override of [`DEFAULT_CHUNK_SIZE`], read at [`FlippedJoin::new`]
83    /// time (the `setMultiConstraintChunkSizeForTest` seam, `flipped-join.ts:57`).
84    static CHUNK_SIZE_OVERRIDE: std::cell::Cell<Option<usize>> =
85        const { std::cell::Cell::new(None) };
86}
87
88/// Override `DEFAULT_CHUNK_SIZE` for every `FlippedJoin` built after this call on
89/// the current thread (`None` restores the default) — the
90/// `setMultiConstraintChunkSizeForTest` seam (`flipped-join.ts:57`). Lets a test or
91/// fuzz lane force the chunked k-way merge path (`merge_node_streams`) on small
92/// fixtures, whose distinct child keys never approach the production 256.
93/// Build-time only: an already-built graph keeps the `chunk_size` it was
94/// constructed with.
95#[cfg(any(test, feature = "testkit"))]
96pub fn set_multi_constraint_chunk_size_for_test(n: Option<usize>) {
97    CHUNK_SIZE_OVERRIDE.set(n);
98}
99
100/// [`DEFAULT_CHUNK_SIZE`], unless a test override is active on this thread.
101fn default_chunk_size() -> usize {
102    #[cfg(any(test, feature = "testkit"))]
103    {
104        CHUNK_SIZE_OVERRIDE.get().unwrap_or(DEFAULT_CHUNK_SIZE)
105    }
106    #[cfg(not(any(test, feature = "testkit")))]
107    DEFAULT_CHUNK_SIZE
108}
109
110/// How an in-flight child fan-out reshapes one parent's place in a mid-push
111/// maintenance fetch (`FlippedJoin::inflight_fetch_action`).
112enum InflightAction {
113    /// No in-flight change touches this parent, or the change was already
114    /// delivered for it and the post-change fetch is right as-is.
115    Keep,
116    /// The parent must not appear in this fetch at all.
117    Drop,
118    /// Keep the parent, with its relationship pulled back to the PRE-change view.
119    KeepPre(JoinOverlay),
120}
121
122/// `FlippedJoin`: child-driven inner join (spec `06` §3.2). Two input ports
123/// ([`Port::JoinParent`]/[`Port::JoinChild`]) like `Join`;
124/// the **child** drives the fetch but the **parent** is the output row, so
125/// `rel_slot` resolves against the parent schema and `input_schema` is the parent's.
126pub struct FlippedJoin {
127    /// The output side (parent rows). The attached relationship hangs here.
128    pub parent: NodeId,
129    /// The driving side: children are fetched first and grouped by join key.
130    pub child: NodeId,
131    pub parent_key: Vec<ColId>,
132    pub child_key: Vec<ColId>,
133    /// Relationship slot (resolved from the alias against the **parent** schema at
134    /// build time, like `Join`).
135    pub rel_slot: RelId,
136    /// IN-batch chunking threshold (test/seam; default `DEFAULT_CHUNK_SIZE`). A
137    /// batch above this needs the node-level merge (deferred — see module docs).
138    pub chunk_size: usize,
139    /// The single downstream edge as a port-carrying [`OutEdge`] (like
140    /// `Join`): `Port::Single` to a terminal sink, or a join
141    /// port when this flipped join feeds another join.
142    pub output: Cell<Option<OutEdge>>,
143    /// The in-flight child change being fanned out by `push_child_change` — the
144    /// flipped twin of `Join::inprogress_overlay` (Zero's `#inprogressChildChange`,
145    /// `flipped-join.ts:103`). Consulted by the reentrant maintenance fetch a
146    /// downstream `Take` triggers mid-push: [`FlippedJoin::fetch`] splices an
147    /// in-flight REMOVEd child back into the child enumeration (so parents whose
148    /// SOLE child it was are enumerated at all), and `fetch_batched` gates each
149    /// parent's membership + relationship content on the fan-out position via
150    /// `inflight_fetch_action`. `None` outside a child-push.
151    pub(crate) inprogress_overlay: RefCell<Option<JoinOverlay>>,
152    /// Zero's `#inprogressChildChangePosition` (`flipped-join.ts:104`): the row of
153    /// the parent currently being delivered by the fan-out. Parents at-or-before it
154    /// (in the parent's effective per-query order — the fan-out order) have had the
155    /// change delivered and must see the POST-change world; parents strictly after
156    /// must still see PRE-change. `None` until the fan-out reaches its first
157    /// parent.
158    pub(crate) inprogress_position: RefCell<Option<Row>>,
159}
160
161impl FlippedJoin {
162    pub fn new(
163        parent: NodeId,
164        child: NodeId,
165        parent_key: Vec<ColId>,
166        child_key: Vec<ColId>,
167        rel_slot: RelId,
168    ) -> FlippedJoin {
169        FlippedJoin {
170            parent,
171            child,
172            parent_key,
173            child_key,
174            rel_slot,
175            chunk_size: default_chunk_size(),
176            output: Cell::new(None),
177            inprogress_overlay: RefCell::new(None),
178            inprogress_position: RefCell::new(None),
179        }
180    }
181
182    /// Override the IN-batch chunk size (the `setMultiConstraintChunkSizeForTest`
183    /// seam, `flipped-join.ts:57`). Used by the chunking-equivalence test once the
184    /// chunked fetch path lands.
185    pub fn with_chunk_size(mut self, chunk_size: usize) -> FlippedJoin {
186        self.chunk_size = chunk_size;
187        self
188    }
189
190    // ---------------------------------------------------------------------
191    // FETCH (child-driven, batched)
192    // ---------------------------------------------------------------------
193
194    /// Lazy pull (`flipped-join.ts:161`): translate the request constraint onto the
195    /// child key, fetch **all** children, then `fetch_batched`.
196    pub fn fetch<'g>(&'g self, g: &'g Graph, req: &FetchRequest) -> NodeStream<'g> {
197        let child_constraint = self.translate_constraint(req.constraint.as_ref());
198        let child_req = FetchRequest {
199            constraint: child_constraint,
200            ..Default::default()
201        };
202        let mut children: Vec<Node<'g>> = g.fetch(self.child, &child_req).collect();
203        // `flipped-join.ts:187-202`: during an in-flight child REMOVE the child
204        // source already shows the row gone, so a parent whose SOLE child it was
205        // would not be enumerated at all — and would silently vanish from a
206        // mid-push maintenance fetch even though the fan-out has not delivered its
207        // remove yet. Splice the removed child back into the enumeration; the
208        // per-parent disposition in `fetch_batched` re-hides it for parents the
209        // fan-out has already reached. (Append instead of Zero's sorted insert:
210        // `children` only seeds the IN-batch keys here — the relationship content
211        // is a re-fetch thunk, not this list.)
212        if let Some(ov) = self.inprogress_overlay.borrow().as_ref() {
213            if let SourceChange::Remove(row) = &ov.change {
214                children.push(Node::leaf(row.clone()));
215            }
216        }
217        self.fetch_batched(g, req, children)
218    }
219
220    /// `#fetchBatched` (`flipped-join.ts:230`): build the deduped IN-batch of
221    /// child-derived parent constraints + the set of parent keys that some child
222    /// produced, then one batched `parent.fetch`. Each returned parent whose key a
223    /// child produced is yielded with the join relationship attached; a parent whose
224    /// key no child produced is dropped (the inner-join gate, and the pass-through
225    /// filter for a chained flipped join's `multi_constraints`).
226    fn fetch_batched<'g>(
227        &'g self,
228        g: &'g Graph,
229        req: &FetchRequest,
230        children: Vec<Node<'g>>,
231    ) -> NodeStream<'g> {
232        let mut seen: HashSet<CanonKey> = HashSet::with_capacity(children.len());
233        let mut computed_multi: MultiConstraint = Vec::new();
234        for child in &children {
235            let c = match build_join_constraint(&child.row, &self.child_key, &self.parent_key) {
236                Some(c) => c,
237                None => continue, // null child key cannot join
238            };
239            // Drop a child-derived constraint that contradicts the incoming request
240            // constraint — it could never match, so it must not enter the batch.
241            if let Some(rc) = req.constraint.as_ref() {
242                if !constraints_are_compatible(&c, rc) {
243                    continue;
244                }
245            }
246            let k = canonical_key_of_constraint(&c);
247            if seen.insert(k) {
248                // First sight of this key → one IN-batch entry (dedup).
249                computed_multi.push(c);
250            }
251        }
252        if computed_multi.is_empty() {
253            return Box::new(std::iter::empty());
254        }
255
256        // Build the parent stream: one batched fetch when the IN-batch fits a single
257        // chunk, else one fetch per `chunk_size` window, k-way-merged in parent order
258        // (`#fetchChunked`, `flipped-join.ts:311`). The windows are disjoint key-sets
259        // so the merge does NOT dedup. The source turns each request's
260        // `multi_constraints` into the per-entry IN-fanout + merge.
261        let parent_stream: NodeStream<'g> = if computed_multi.len() <= self.chunk_size {
262            g.fetch(self.parent, &self.parent_batch_req(req, computed_multi))
263        } else {
264            // Each chunk fetch streams in the parent's **resolved per-query order**
265            // (`input_sort` — e.g. `order by Milliseconds`), NOT the base table's PK
266            // sort (`input_schema().sort`). The k-way merge must compare on that same
267            // resolved order or it interleaves the chunks wrongly and a downstream
268            // `Take` keeps the wrong top-N. (Only bites above `chunk_size` distinct
269            // child keys, so the mini fixtures never hit it — the chinook scale sweep
270            // did.) See `input_sort` vs `input_schema` in `graph.rs`.
271            let parent_sort = g.input_sort(self.parent);
272            let chunks: Vec<NodeStream<'g>> = computed_multi
273                .chunks(self.chunk_size)
274                .map(|window| g.fetch(self.parent, &self.parent_batch_req(req, window.to_vec())))
275                .collect();
276            crate::op::merge_node_streams(chunks, parent_sort, req.reverse, None)
277        };
278
279        // Capture by value for the `'g` filter_map closure (`self` is `&'g` too —
280        // the in-flight disposition below re-reads the live fields through it).
281        let parent_key = self.parent_key.clone();
282        let child_key = self.child_key.clone();
283        let child_id = self.child;
284        let rel_slot = self.rel_slot;
285        // The fan-out position gate compares in the parent's effective per-query
286        // order; resolving it walks (and allocates) the operator chain, so it is
287        // cached once per fetch here, not recomputed per yielded parent.
288        let mut parent_sort: Option<Sort> = None;
289        Box::new(parent_stream.filter_map(move |pnode| {
290            let k = canonical_key(&pnode.row, &parent_key);
291            // Miss ⇒ no child produced this parent's key ⇒ drop it (inner-join gate
292            // / chained-flip pass-through filter, `flipped-join.ts:295`).
293            if !seen.contains(&k) {
294                return None;
295            }
296            // While a child change is being fanned out, membership and relationship
297            // content are position-dependent (Zero `#yieldParentWithOverlay`,
298            // `flipped-join.ts:332`) — the flipped twin of `Graph::join_overlay_for`.
299            let overlay = match self.inflight_fetch_action(g, &pnode.row, &mut parent_sort) {
300                InflightAction::Keep => None,
301                InflightAction::Drop => return None,
302                InflightAction::KeepPre(ov) => Some((ov, OverlayPolarity::Pre)),
303            };
304            Some(g.attach_child_rel(child_id, &parent_key, &child_key, rel_slot, pnode, overlay))
305        }))
306    }
307
308    /// Decide, at yield time, how the in-flight child change reshapes `parent_row`'s
309    /// place in a mid-push maintenance fetch — the flipped twin of
310    /// `Graph::join_overlay_for`, porting Zero's `#yieldParentWithOverlay`
311    /// (`flipped-join.ts:332-383`) to the re-fetch relationship model.
312    ///
313    /// A parent at-or-before the fan-out position (in the parent's effective
314    /// per-query order — the order `push_child_change` delivers) has had the change
315    /// delivered: the post-change world is correct, but an in-flight REMOVE needs
316    /// its membership re-checked (the removed child was spliced back into the
317    /// enumeration by `fetch`, so a sole-child parent would otherwise reappear). A
318    /// parent strictly after the position must still see the PRE-change world:
319    /// membership (an in-flight ADD that is its only child does not exist for it
320    /// yet) and relationship content (the `Pre`-polarity overlay splice).
321    fn inflight_fetch_action(
322        &self,
323        g: &Graph,
324        parent_row: &Row,
325        parent_sort: &mut Option<Sort>,
326    ) -> InflightAction {
327        // Gate on a BORROWED overlay: the common outcomes (no in-flight change, an
328        // unrelated parent, already-delivered) must not pay the two-row overlay
329        // clone — only the `KeepPre` arms clone. Holding the `Ref` across the
330        // fetches below is fine: fetch paths only ever `borrow()` these fields;
331        // the sole `borrow_mut` writers are in `push_child_change`, outside any
332        // fetch.
333        let ov_ref = self.inprogress_overlay.borrow();
334        let Some(ov) = ov_ref.as_ref() else {
335            return InflightAction::Keep;
336        };
337        if !is_join_match(
338            parent_row,
339            &self.parent_key,
340            ov.change.row(),
341            &self.child_key,
342        ) {
343            return InflightAction::Keep;
344        }
345        let delivered = match self.inprogress_position.borrow().as_ref() {
346            // Same comparator note as `Graph::join_overlay_for`: the fan-out
347            // delivers parents in the parent's EFFECTIVE per-query order, so the
348            // "already delivered?" gate must compare in that order (resolved once
349            // per fetch — the caller-held cache).
350            Some(pos) => {
351                let sort = parent_sort.get_or_insert_with(|| g.input_sort(self.parent));
352                compare_rows(sort, parent_row, pos) != Ordering::Greater
353            }
354            // The fan-out has not reached its first parent — nothing delivered.
355            None => false,
356        };
357        match (&ov.change, delivered) {
358            // Delivered REMOVE: post-change is right, but `fetch` spliced the
359            // removed child back into the enumeration — re-gate membership on the
360            // post-change truth so a sole-child parent stays gone.
361            (SourceChange::Remove(_), true) => {
362                if self.has_related_child(g, parent_row) {
363                    InflightAction::Keep
364                } else {
365                    InflightAction::Drop
366                }
367            }
368            // Pending REMOVE: pre-change world — the child is still related (the
369            // splice keeps it in the enumeration; the Pre overlay re-adds it to the
370            // relationship refetch).
371            (SourceChange::Remove(_), false) => InflightAction::KeepPre(ov.clone()),
372            // Pending ADD: pre-change world — the added child does not exist yet
373            // for this parent; if it is the parent's ONLY child, the parent was not
374            // in the pre-change output at all.
375            (SourceChange::Add(row), false) => {
376                let child_pk = g.input_schema(self.child).primary_key.clone();
377                if self.parent_has_other_child(g, parent_row, row, &child_pk) {
378                    InflightAction::KeepPre(ov.clone())
379                } else {
380                    InflightAction::Drop
381                }
382            }
383            // Delivered ADD: the post-change fetch (child present) is correct.
384            (SourceChange::Add(_), true) => InflightAction::Keep,
385            // EDIT never changes membership (the join key is immutable, asserted on
386            // entry); a pending parent still sees the OLD child content.
387            (SourceChange::Edit { .. }, false) => InflightAction::KeepPre(ov.clone()),
388            (SourceChange::Edit { .. }, true) => InflightAction::Keep,
389        }
390    }
391
392    /// Build the parent fetch request for one IN-batch (the whole `computed_multi`
393    /// or a single chunk window): the incoming constraint ANDed with
394    /// `[...incoming_multi, batch]` (a chained flipped join above prepends its own
395    /// `multi_constraints`), carrying the request's `start`/`reverse`.
396    fn parent_batch_req(&self, req: &FetchRequest, batch: MultiConstraint) -> FetchRequest {
397        let mut multi = req.multi_constraints.clone();
398        multi.push(batch);
399        FetchRequest {
400            constraint: req.constraint.clone(),
401            multi_constraints: multi,
402            start: req.start.clone(),
403            reverse: req.reverse,
404        }
405    }
406
407    /// Translate the parent-key columns of an incoming request constraint to the
408    /// corresponding child-key columns (`flipped-join.ts:164`). Columns that aren't
409    /// parent-key columns are dropped; if none translate, the child is fetched
410    /// unconstrained (`None`).
411    fn translate_constraint(&self, c: Option<&Constraint>) -> Option<Constraint> {
412        let c = c?;
413        let mut out = Constraint::new();
414        for (col, val) in c {
415            if let Some(i) = self.parent_key.iter().position(|pk| pk == col) {
416                out.push((self.child_key[i], val.clone()));
417            }
418        }
419        if out.is_empty() {
420            None
421        } else {
422            Some(out)
423        }
424    }
425
426    // ---------------------------------------------------------------------
427    // PUSH (two ports)
428    // ---------------------------------------------------------------------
429
430    pub fn push<'g>(&'g self, g: &'g Graph, change: Change<'g>, port: Port) {
431        match port {
432            Port::JoinChild => self.push_child(g, change),
433            Port::JoinParent => self.push_parent(g, change),
434            Port::Single => panic!("FlippedJoin received Single port"),
435        }
436    }
437
438    /// Child port (`flipped-join.ts:385`): the changed child is re-projected onto
439    /// its parents with the existence decision. EDIT/CHILD can't flip a parent's
440    /// existence, so they pre-set `exists = true`. A key edit is rejected (the
441    /// source split it, like [`Join`](crate::graph)).
442    fn push_child<'g>(&'g self, g: &'g Graph, change: Change<'g>) {
443        let exists_pre = match &change {
444            Change::Add(_) | Change::Remove(_) => false,
445            Change::Edit { node, old } => {
446                assert!(
447                    row_equals_for_compound_key(&old.row, &node.row, &self.child_key),
448                    "flipped child edit must not change the join relationship key"
449                );
450                // A non-key edit cannot make a parent appear/disappear → forward a
451                // Child change (existence is unchanged).
452                true
453            }
454            // A nested `Change::Child` (a grandchild change) leaves the direct child
455            // present, so the parent's EXISTS-membership is unchanged → forward it as
456            // a `Child`, carried verbatim. `exists_pre = true` skips the membership
457            // re-check; without it, `parent_has_other_child` (which excludes the
458            // *changed* child) could see "no other child" and wrongly emit an
459            // Add/Remove of the parent for what is only a grandchild edit.
460            Change::Child { .. } => true,
461        };
462        self.push_child_change(g, change, exists_pre);
463    }
464
465    /// `#pushChildChange` (`flipped-join.ts:409`), the reentrant crux. Build the
466    /// parent constraint from the changed child, re-enter `parent.fetch`, and for
467    /// each parent decide existence (does it have *another* related child?). Yes →
468    /// `Child`; no → the child is the parent's sole child, so the parent's existence
469    /// flips (`Add` on child-add / `Remove` on child-remove).
470    fn push_child_change<'g>(&'g self, g: &'g Graph, child_change: Change<'g>, exists_pre: bool) {
471        let out = self.output.get().expect("FlippedJoin output not wired");
472        let key_row = child_change.primary_row().clone();
473        let constraint = match build_join_constraint(&key_row, &self.child_key, &self.parent_key) {
474            Some(c) => c,
475            None => return, // null child key cannot join
476        };
477        let overlay = JoinOverlay::for_child_change(&child_change);
478        let child_pk = g.input_schema(self.child).primary_key.clone();
479        let is_add = matches!(child_change, Change::Add(_));
480        // Materialize once (drains thunks now), rebuild a fresh `Change<'g>` per
481        // parent in the `exists` branch — a child can join to many parents.
482        let owned = materialize_change(child_change);
483
484        // Publish the in-flight child change LIVE for the duration of the fan-out
485        // (Zero `flipped-join.ts:410`); the guard clears both fields on every exit
486        // (the JS `try/finally`, `flipped-join.ts:485` — under `panic = "abort"` a
487        // panic aborts instead and `Drop` is skipped, see WS02).
488        *self.inprogress_overlay.borrow_mut() = overlay.clone();
489        let _guard = InprogressGuard { j: self };
490
491        // Collect the reentrant parent fetch (relationships preserved) so its stream
492        // borrow is dropped before the per-parent existence fetch + downstream push.
493        let parents: Vec<Node> = g
494            .fetch(self.parent, &FetchRequest::with_constraint(constraint))
495            .collect();
496
497        // `exists` is sticky across the parent loop (the JS captures it in method
498        // scope, `flipped-join.ts:436`): once any parent has another child, later
499        // parents stay in the `Child` branch. Moot in the common 1:1 case.
500        let mut exists = exists_pre;
501        // The flipped twin of the unbounded join fan-out (RUNAWAY-PUSH-FINDINGS §3), with a
502        // per-parent existence fetch on top — deadline-checkpointed like the non-flipped loop
503        // (FOLLOWER-LAG-SHED §6.6).
504        for pnode in parents {
505            if g.push_deadline_exceeded() {
506                g.park_runtime_error(crate::error::RindleError::PushDeadlineExceeded {
507                    site: "flipped-join child fan-out",
508                });
509                break; // torn is fine — the host discards the engine
510            }
511            // Advance the fan-out position to the parent now being delivered
512            // (`flipped-join.ts:427`) BEFORE the downstream push: a maintenance
513            // fetch it triggers must see this parent as delivered, and every
514            // later parent as still pre-change.
515            *self.inprogress_position.borrow_mut() = Some(pnode.row.clone());
516            if !exists {
517                exists = self.parent_has_other_child(g, &pnode.row, &key_row, &child_pk);
518            }
519            if exists {
520                // Parent stays; its relationship changed → a Child change. (Both the
521                // View and Catch read only the parent row on a `Child` and recurse
522                // into the carried sub-change; the overlay is `None` for a nested
523                // Child and a leaf-row splice otherwise.)
524                let pn = g.attach_child_rel(
525                    self.child,
526                    &self.parent_key,
527                    &self.child_key,
528                    self.rel_slot,
529                    pnode,
530                    overlay.clone().map(|ov| (ov, OverlayPolarity::Post)),
531                );
532                let child = Box::new(rebuild_change(owned.clone()));
533                g.push(
534                    out.node,
535                    Change::Child {
536                        node: pn,
537                        rel: self.rel_slot,
538                        child,
539                    },
540                    out.port,
541                );
542            } else {
543                // The changed child is the parent's SOLE related child → existence
544                // flips. The relationship is exactly that one child (JS `[change[NODE]]`,
545                // `flipped-join.ts:474`) — NOT a re-fetch (which, on a remove, would
546                // show the child already gone). This makes the emitted Add/Remove
547                // carry the changed child, matching the `Exists`-gate oracle.
548                //
549                // `[change[NODE]]` is the change's NODE — the child *with its own
550                // relationships* — not its row. Passing the bare row here truncated the
551                // child's own witness subtree, which the `View`, the cluster snapshot and
552                // the d2s oracle all discard anyway (none projects a `where`-EXISTS slot)
553                // but the normalized wire fold refcounts in full: a nested EXISTS witness
554                // dropped here never enters the client's footprint, so its re-derivation
555                // fails the inner gate and drops the root. `owned` is the same
556                // materialization the `Child` arm above forwards.
557                let child_owned: OwnedNode = match &owned {
558                    OwnedChange::Add(n) | OwnedChange::Remove(n) => n.clone(),
559                    // Unreachable: `push_child` pre-sets `exists = true` for both, so
560                    // they never reach the flip branch. Matched for totality.
561                    OwnedChange::Edit { node, .. } | OwnedChange::Child { node, .. } => {
562                        node.clone()
563                    }
564                };
565                let pn = self.parent_with_only_child(pnode, child_owned);
566                let flipped = if is_add {
567                    Change::Add(pn)
568                } else {
569                    Change::Remove(pn)
570                };
571                g.push(out.node, flipped, out.port);
572            }
573        }
574    }
575
576    /// Does `parent_row` have a related child whose PK differs from the changed
577    /// child (`change.node`)? Reentrant `child.fetch` (the child source overlay is
578    /// active, so a child add is visible / a child remove is gone), then look for any
579    /// child that is **not** the changed one — a PK match (the robust port of the JS
580    /// `compareRows(child, change.node) !== 0`, `flipped-join.ts:441`; spec OQ-7).
581    fn parent_has_other_child(
582        &self,
583        g: &Graph,
584        parent_row: &Row,
585        changed_child: &Row,
586        child_pk: &[ColId],
587    ) -> bool {
588        let Some(cc) = build_join_constraint(parent_row, &self.parent_key, &self.child_key) else {
589            return false;
590        };
591        for cn in g.fetch(self.child, &FetchRequest::with_constraint(cc)) {
592            if !row_equals_for_compound_key(&cn.row, changed_child, child_pk) {
593                return true;
594            }
595        }
596        false
597    }
598
599    /// Parent port (`flipped-join.ts:490`): forward the change with the relationship
600    /// flipped on — **unless** the parent has no related child, in which case it is
601    /// not in the (inner-join) output and the change is dropped.
602    fn push_parent<'g>(&'g self, g: &'g Graph, change: Change<'g>) {
603        let out = self.output.get().expect("FlippedJoin output not wired");
604        match change {
605            Change::Add(node) => {
606                if !self.has_related_child(g, &node.row) {
607                    return;
608                }
609                let pn = self.flip(g, node);
610                g.push(out.node, Change::Add(pn), out.port);
611            }
612            Change::Remove(node) => {
613                if !self.has_related_child(g, &node.row) {
614                    return;
615                }
616                let pn = self.flip(g, node);
617                g.push(out.node, Change::Remove(pn), out.port);
618            }
619            Change::Edit { node, old } => {
620                assert!(
621                    row_equals_for_compound_key(&old.row, &node.row, &self.parent_key),
622                    "flipped parent edit must not change the join key"
623                );
624                // Key unchanged ⇒ old and new have the same related children; gate on
625                // either. No children ⇒ the parent isn't in the output ⇒ drop.
626                if !self.has_related_child(g, &node.row) {
627                    return;
628                }
629                let pn = self.flip(g, node);
630                let po = self.flip(g, old);
631                g.push(out.node, Change::Edit { node: pn, old: po }, out.port);
632            }
633            Change::Child { node, rel, child } => {
634                // Passthrough of a relationship above this flipped join.
635                if !self.has_related_child(g, &node.row) {
636                    return;
637                }
638                let pn = self.flip(g, node);
639                g.push(
640                    out.node,
641                    Change::Child {
642                        node: pn,
643                        rel,
644                        child,
645                    },
646                    out.port,
647                );
648            }
649        }
650    }
651
652    /// The inner-join gate: does `parent_row` have ≥1 related child? (`child.fetch`
653    /// of the correlation constraint has a first row.)
654    fn has_related_child(&self, g: &Graph, parent_row: &Row) -> bool {
655        match build_join_constraint(parent_row, &self.parent_key, &self.child_key) {
656            Some(c) => g
657                .fetch(self.child, &FetchRequest::with_constraint(c))
658                .next()
659                .is_some(),
660            None => false,
661        }
662    }
663
664    /// Attach the join's child relationship to a parent node (the re-materialize
665    /// thunk, no overlay — the parent-port push has no in-flight child of its own).
666    fn flip<'g>(&self, g: &'g Graph, node: Node<'g>) -> Node<'g> {
667        g.attach_child_rel(
668            self.child,
669            &self.parent_key,
670            &self.child_key,
671            self.rel_slot,
672            node,
673            None,
674        )
675    }
676
677    /// Attach a relationship that yields exactly the one given child — the parent's
678    /// sole related child on an existence flip (JS `[change[NODE]]`). The child is an
679    /// [`OwnedNode`], not a row: it carries its own relationships (a nested EXISTS
680    /// witness, a `related` subtree), and every consumer that walks the whole subtree
681    /// — the normalized wire fold above all — needs them.
682    fn parent_with_only_child<'g>(&self, mut parent: Node<'g>, child: OwnedNode) -> Node<'g> {
683        parent.rels.push(Relationship {
684            slot: self.rel_slot,
685            thunk: Box::new(move || Box::new(std::iter::once(rebuild_node(child.clone())))),
686        });
687        parent
688    }
689}
690
691/// RAII for the flipped join's in-flight child fan-out (`flipped-join.ts:485-487`
692/// `finally`): clears [`FlippedJoin::inprogress_overlay`]/
693/// [`FlippedJoin::inprogress_position`] when `push_child_change` returns —
694/// normally, via `?`, or unwinding (the latter only under `panic = "unwind"`;
695/// under the shipping `panic = "abort"` profile a panic aborts and `Drop` is
696/// skipped — see WS02). A stale overlay would corrupt every later fetch's
697/// membership, so the clear is not optional. The twin of `graph.rs`'s
698/// `InprogressGuard` for the non-flipped `Join`.
699struct InprogressGuard<'a> {
700    j: &'a FlippedJoin,
701}
702
703impl Drop for InprogressGuard<'_> {
704    fn drop(&mut self) {
705        *self.j.inprogress_overlay.borrow_mut() = None;
706        *self.j.inprogress_position.borrow_mut() = None;
707    }
708}