Rindle docs and package mapSkip to main content

rindle/
view.rs

1//! The materialization sink — the production `ArrayView` (`09`).
2//!
3//! Ports `packages/zql/src/ivm/array-view.ts` (the sink shell) **plus**
4//! `packages/zql/src/ivm/view-apply-change.ts` (the 916-line immutable tree
5//! differ `applyChange`). This module holds:
6//!
7//! - the **`Arc`-shared entry tree** ([`Entry`]/[`EntryList`]) with
8//!   per-entry [`rc`](Entry::rc) refcounts and a transaction generation stamp
9//!   ([`Entry::created`]);
10//! - the immutable, **reference-stable** [`apply_change`] (add/remove/child/edit;
11//!   plural at every level);
12//! - the transaction-scoped **copy-on-write** machinery ([`Mutate`]/[`TxnDirty`]/
13//!   [`TxnGen`]) — the Rust analogue of the JS `#txnDirty` `WeakSet`, replaced by
14//!   a per-object generation stamp (`09` §4.8 approach A);
15//! - the [`View`] shell (`Arc` root, [`Schema`], listeners, [`ResultType`],
16//!   flush) — its `hydrate`/`push` graph-touching halves live in `graph.rs`.
17//!
18//! ## Reference stability (the headline property, `09` §1.1)
19//!
20//! `apply_change` is *immutable*: it produces a new root that **preserves the
21//! `Arc` pointer identity of every subtree it did not touch**, so a UI framework
22//! can skip unchanged subtrees via a cheap [`Arc::ptr_eq`](std::sync::Arc::ptr_eq). Off-spine siblings are
23//! shared (an `Arc` clone is a refcount bump, not a deep copy); only the changed
24//! ancestor path is rebuilt.
25//!
26//! ## The recursion shape (deviation from the JS, called out)
27//!
28//! The JS recursion is `applyChangeInternal(parentEntry, …) -> Entry` (returns the
29//! new-or-same object). Rust cannot mutate an `Arc` in place through a shared
30//! `&Arc`, so the recursion here threads **`&mut Arc<Entry>`** and returns a
31//! `bool changed` (the [`Arc::ptr_eq`](std::sync::Arc::ptr_eq) short-circuit of the JS `newExisting ===
32//! existing`). The COW decision is driven by the generation stamp, not
33//! `Arc::strong_count` (`09` §4.8 rejects the strong-count approach): a *committed*
34//! object (`created < dirty.gen`) is **always cloned** on first touch (never
35//! `Arc::make_mut`, which would mutate a count-1 deep committed node in place and
36//! corrupt a listener's snapshot); an *owned* object (`created == dirty.gen`, or
37//! [`Mutate::InPlace`]) is mutated via [`Arc::make_mut`](std::sync::Arc::make_mut) (in place when uniquely
38//! held — which the push/hydrate take-out guarantees for the spine).
39
40use std::cell::{Cell, RefCell};
41use std::sync::{Arc, OnceLock};
42
43use crate::change::Node;
44use crate::value::{compare_rows, ColId, OwnedRow, RelId, Schema, Sort, Value};
45use std::cmp::Ordering;
46
47/// The synthetic root reaches the top-level result (`root[""]`) through its one
48/// relationship slot. Mirrors the JS `''` relationship (`array-view.ts:88`).
49pub const REL_ROOT: RelId = RelId(0);
50
51// ---------------------------------------------------------------------------
52// Transaction / copy-on-write machinery (`09` §4.8)
53// ---------------------------------------------------------------------------
54
55/// Per-transaction "created this txn" marker — the Rust analogue of the JS
56/// `#txnDirty` `WeakSet`. A monotonically increasing stamp; bumped on `flush` so
57/// all prior marks go stale "for free" (no `WeakSet::clear`).
58#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
59pub struct TxnGen(pub u64);
60
61/// The COW tracker threaded (by `&`) through one `apply_change` call. `owns(x) ⇔
62/// x.created == gen`.
63#[derive(Clone, Copy)]
64pub struct TxnDirty {
65    pub gen: TxnGen,
66}
67
68/// Update strategy passed down the recursion. Ports the JS `mutate` argument.
69#[derive(Clone, Copy, PartialEq, Eq, Debug)]
70pub enum Mutate {
71    /// Fully immutable: always path-copy (JS `false`). Used by `NO_MUTATE` tests.
72    Immutable,
73    /// Mutate everything in place: only safe when the tree is unobserved
74    /// (hydration). (JS `true`.)
75    InPlace,
76    /// Transaction-scoped copy-on-write (JS `WeakSet`). Copy on first touch of a
77    /// committed object, in place after.
78    Cow,
79}
80
81/// `mutate || owns(x)` (`view-apply-change.ts:588` etc): may we mutate the object
82/// whose creation stamp is `created` in place?
83#[inline]
84fn can_mut(created: TxnGen, mode: Mutate, dirty: &TxnDirty) -> bool {
85    match mode {
86        Mutate::Immutable => false,
87        Mutate::InPlace => true,
88        Mutate::Cow => created == dirty.gen,
89    }
90}
91
92// ---------------------------------------------------------------------------
93// The entry tree (`09` §4.2)
94// ---------------------------------------------------------------------------
95
96/// The stable id: the JSON-stringified PK (`makeID`, `view-apply-change.ts:847`).
97/// Present iff `with_ids`. `ArrayView` always passes `with_ids=false`, so this is
98/// dead for it; kept so a future `SolidView` reuses the same differ (`09` §1.2).
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub enum EntryId {
101    /// `JSON.stringify(row[pk0])` — the single-column PK fast path.
102    Scalar(Box<str>),
103    /// `JSON.stringify([row[pk0], …])` — compound PK.
104    Compound(Box<str>),
105}
106
107/// One materialized node in the view tree (ports `MetaEntry`). `Arc`-shared so
108/// unchanged subtrees keep pointer identity across an immutable [`apply_change`].
109#[derive(Clone, Debug)]
110pub struct Entry {
111    /// The row's columns, index-addressed (never a `HashMap<String,_>`).
112    pub row: OwnedRow,
113    /// How many query paths reach this row within its containing relationship.
114    /// Add increments, remove decrements; physical removal at `rc == 0`.
115    pub rc: u32,
116    /// "Created/cloned this transaction" stamp (`09` §4.8-A). `owns(e) ⇔
117    /// e.created == dirty.gen`. Bumped-stale on every flush.
118    pub created: TxnGen,
119    /// Stable identity (present iff `with_ids`). Mirrors `idSymbol`. Boxed: the
120    /// field is dead for `ArrayView` (`with_ids=false` everywhere today), so it
121    /// costs 8 bytes in-line instead of 24; a future `SolidView` pays one alloc
122    /// per entry for it, in the same breath as its id string allocation.
123    pub id: Option<Box<EntryId>>,
124    /// Child relationships, **index-addressed by [`RelId`]** (slot = position),
125    /// in the same order as `Schema::relationships`. Each slot is a sorted list:
126    /// the view is plural at every level (singular `.one()` is applied at the
127    /// presentation boundary, not in the materialized tree).
128    pub rels: Box<[EntryList]>,
129}
130
131/// A sorted list of child entries (ports `MetaEntryList`). Carries its own
132/// `created` stamp so `owns(list)` works under COW (the JS tracks arrays in
133/// `#txnDirty` exactly like entries). The LIST has stable identity when
134/// unchanged, independent of its elements.
135#[derive(Clone, Debug)]
136pub struct EntryListInner {
137    pub created: TxnGen,
138    pub items: Vec<Arc<Entry>>,
139}
140
141/// The reference-counted, copy-on-write child list.
142pub type EntryList = Arc<EntryListInner>;
143
144/// The top-level result the consumer sees (`root[""]`): the sorted list of root
145/// entries. An `Arc`-shared [`EntryList`], so a consumer can `Arc::ptr_eq` the
146/// snapshot to detect an unchanged top level.
147///
148/// The view is **plural at every level** — which relationships appear in the view
149/// is carried by the hierarchical [`Schema`] (`rel_child(slot).is_some()` ⇔
150/// in-view); a join-only / gating slot has no child schema and is excluded.
151pub type ViewData = EntryList;
152
153// ---------------------------------------------------------------------------
154// ViewChange (`09` §4.5) — the View-local change shape
155// ---------------------------------------------------------------------------
156
157/// View-local change (ports `ViewChange`, `view-apply-change.ts:61-92`). `Add`/
158/// `Remove` keep the full [`Node`] (their relationship thunks are consumed on a
159/// plural/singular insert, `09` §4.5);
160/// `Child`/`Edit` are row-only (their relationships are never consumed).
161pub enum ViewChange<'g> {
162    Add {
163        node: Node<'g>,
164    },
165    Remove {
166        node: Node<'g>,
167    },
168    Child {
169        row: OwnedRow,
170        rel: RelId,
171        change: Box<ViewChange<'g>>,
172    },
173    Edit {
174        row: OwnedRow,
175        old: OwnedRow,
176    },
177}
178
179impl<'g> ViewChange<'g> {
180    /// Convert a dataflow [`Change`](crate::change::Change) into a `ViewChange`
181    /// (ports `changeToViewChange`, `array-view.ts:15-37`). Strips relationships
182    /// from the `Child`/`Edit` nodes the View never consumes; recurses for nested
183    /// child changes.
184    pub fn from_change(c: crate::change::Change<'g>) -> ViewChange<'g> {
185        use crate::change::Change;
186        match c {
187            Change::Add(node) => ViewChange::Add { node },
188            Change::Remove(node) => ViewChange::Remove { node },
189            Change::Edit { node, old } => ViewChange::Edit {
190                row: node.row,
191                old: old.row,
192            },
193            Change::Child { node, rel, child } => ViewChange::Child {
194                row: node.row,
195                rel,
196                change: Box::new(ViewChange::from_change(*child)),
197            },
198        }
199    }
200}
201
202// ---------------------------------------------------------------------------
203// COW accessors: in-place if owned, else clone-and-stamp (`09` §6.3)
204// ---------------------------------------------------------------------------
205
206/// Get `&mut Entry`, copy-on-writing as needed. A committed object (`!can_mut`)
207/// is **always cloned** (never `make_mut`) so a count-1 deep committed node is
208/// never mutated in place (which would corrupt a listener's snapshot).
209#[inline]
210fn entry_cow<'a>(arc: &'a mut Arc<Entry>, mode: Mutate, dirty: &TxnDirty) -> &'a mut Entry {
211    if can_mut(arc.created, mode, dirty) {
212        let e = Arc::make_mut(arc); // in place (count 1) or clones a transient alias
213        e.created = dirty.gen;
214        e
215    } else {
216        let mut new = (**arc).clone();
217        new.created = dirty.gen;
218        *arc = Arc::new(new);
219        Arc::get_mut(arc).expect("fresh Arc is uniquely held")
220    }
221}
222
223/// Get `&mut EntryListInner`, copy-on-writing as needed (mirror of [`entry_cow`]).
224#[inline]
225fn list_cow<'a>(arc: &'a mut EntryList, mode: Mutate, dirty: &TxnDirty) -> &'a mut EntryListInner {
226    if can_mut(arc.created, mode, dirty) {
227        let l = Arc::make_mut(arc);
228        l.created = dirty.gen;
229        l
230    } else {
231        let mut new = (**arc).clone();
232        new.created = dirty.gen;
233        *arc = Arc::new(new);
234        Arc::get_mut(arc).expect("fresh Arc is uniquely held")
235    }
236}
237
238/// `&mut` the (plural) list slot of an entry.
239#[inline]
240fn list_slot(e: &mut Entry, rel: RelId) -> &mut EntryList {
241    &mut e.rels[rel.ix()]
242}
243
244#[inline]
245fn binary_search(items: &[Arc<Entry>], row: &OwnedRow, sort: &Sort) -> Result<usize, usize> {
246    items.binary_search_by(|e| compare_rows(sort, &e.row, row))
247}
248
249// ---------------------------------------------------------------------------
250// Entry construction
251// ---------------------------------------------------------------------------
252
253/// Encode a cell as `JSON.stringify` would (for [`make_id`]).
254fn json_value(v: Value<'_>) -> String {
255    match v {
256        // A view row's PK is always present; mirror JS `JSON.stringify(undefined)`-in-array
257        // (→ `null`) for totality should an `Absent` ever reach here.
258        Value::Absent => "null".to_string(),
259        Value::Null => "null".to_string(),
260        Value::Bool(b) => {
261            if b {
262                "true".to_string()
263            } else {
264                "false".to_string()
265            }
266        }
267        Value::Int(i) => i.to_string(),
268        Value::Float(f) => {
269            // JSON has no NaN/Inf; JS JSON.stringify emits `null` for them.
270            if f.is_finite() {
271                let mut s = f.to_string();
272                if s == "-0" {
273                    s = "0".to_string();
274                }
275                s
276            } else {
277                "null".to_string()
278            }
279        }
280        Value::Str(b) | Value::Json(b) => {
281            // Row text is UTF-8-validated at construction.
282            let s = String::from_utf8_lossy(b);
283            let mut out = String::with_capacity(s.len() + 2);
284            out.push('"');
285            for ch in s.chars() {
286                match ch {
287                    '"' => out.push_str("\\\""),
288                    '\\' => out.push_str("\\\\"),
289                    '\n' => out.push_str("\\n"),
290                    '\r' => out.push_str("\\r"),
291                    '\t' => out.push_str("\\t"),
292                    c => out.push(c),
293                }
294            }
295            out.push('"');
296            out
297        }
298    }
299}
300
301/// `makeID` (`view-apply-change.ts:847`): the JSON-stringified PK.
302fn make_id(row: &OwnedRow, pk: &[ColId]) -> EntryId {
303    if pk.len() == 1 {
304        EntryId::Scalar(json_value(row.col(pk[0])).into_boxed_str())
305    } else {
306        let parts: Vec<String> = pk.iter().map(|&c| json_value(row.col(c))).collect();
307        EntryId::Compound(format!("[{}]", parts.join(",")).into_boxed_str())
308    }
309}
310
311/// The one shared empty [`EntryList`]: every empty relationship slot is a
312/// refcount bump on this, not a private 48-byte heap block. Safe to share:
313/// its `created` stamp is permanently stale ("committed"), and both COW
314/// branches ([`list_cow`]) physically clone a shared `Arc` before mutating —
315/// the `OnceLock` keeps the count ≥ 2 forever, so no path can mutate it.
316fn empty_entry_list() -> EntryList {
317    static EMPTY: OnceLock<EntryList> = OnceLock::new();
318    EMPTY
319        .get_or_init(|| {
320            Arc::new(EntryListInner {
321                created: TxnGen::default(),
322                items: Vec::new(),
323            })
324        })
325        .clone()
326}
327
328/// Build a fresh entry with the given rc, all relationship slots empty (plural,
329/// the shared empty list). Stamped `created = dirty.gen` (owned this txn).
330/// Mirrors `makeNewMetaEntry` (`view-apply-change.ts:830`) + the empty-slot init.
331fn make_new_entry(
332    row: OwnedRow,
333    schema: &Schema,
334    with_ids: bool,
335    rc: u32,
336    dirty: &TxnDirty,
337) -> Entry {
338    let id = if with_ids {
339        Some(Box::new(make_id(&row, &schema.primary_key)))
340    } else {
341        None
342    };
343    let rels = (0..schema.relationships.len())
344        .map(|_| empty_entry_list())
345        .collect::<Vec<_>>()
346        .into_boxed_slice();
347    Entry {
348        row,
349        rc,
350        created: dirty.gen,
351        id,
352        rels,
353    }
354}
355
356/// The synthetic root entry: one relationship slot (`""`) holding the top-level
357/// result list. Mirrors `#root = {'': []}` (`array-view.ts:88`).
358fn synthetic_root() -> Entry {
359    let slot = Arc::new(EntryListInner {
360        created: TxnGen::default(),
361        items: Vec::new(),
362    });
363    Entry {
364        row: OwnedRow::empty(),
365        rc: 0,
366        created: TxnGen::default(),
367        id: None,
368        rels: Box::new([slot]),
369    }
370}
371
372/// Read the [`ViewData`] out of a root entry (the `data` getter, `array-view.ts:111`).
373fn view_data(root: &Arc<Entry>) -> ViewData {
374    root.rels[REL_ROOT.ix()].clone()
375}
376
377// ---------------------------------------------------------------------------
378// The differ (`09` §5.4-§5.10)
379// ---------------------------------------------------------------------------
380
381/// Immutable view update. Mutates `*root` to fold in `change` (`*root` becomes the
382/// new — possibly same — root `Arc`); unchanged subtrees keep `Arc` identity.
383/// Ports `applyChange` (`view-apply-change.ts:184`). `rel` is the relationship by
384/// which `*root` reaches the level the change targets ([`REL_ROOT`] at the top).
385#[allow(clippy::too_many_arguments)]
386pub fn apply_change(
387    root: &mut Arc<Entry>,
388    change: &ViewChange<'_>,
389    schema: &Schema,
390    rel: RelId,
391    with_ids: bool,
392    mode: Mutate,
393    dirty: &TxnDirty,
394) {
395    apply(root, change, schema, rel, with_ids, mode, dirty);
396}
397
398/// The recursive core (`applyChangeInternal`, `view-apply-change.ts:212`). Returns
399/// `true` iff the relationship slot's content changed (the [`Arc::ptr_eq`]
400/// short-circuit). On the owned/`InPlace` path the parent `Arc` keeps its pointer
401/// (uniquely held → in place) even when this returns `false`.
402#[allow(clippy::too_many_arguments)]
403fn apply(
404    parent: &mut Arc<Entry>,
405    change: &ViewChange<'_>,
406    schema: &Schema,
407    rel: RelId,
408    with_ids: bool,
409    mode: Mutate,
410    dirty: &TxnDirty,
411) -> bool {
412    match change {
413        ViewChange::Add { node } => apply_add(parent, node, schema, rel, with_ids, mode, dirty),
414        ViewChange::Remove { node } => apply_remove(parent, &node.row, schema, rel, mode, dirty),
415        ViewChange::Child {
416            row,
417            rel: child_rel,
418            change,
419        } => apply_child(
420            parent, row, *child_rel, change, schema, rel, with_ids, mode, dirty,
421        ),
422        ViewChange::Edit { row, old } => {
423            apply_edit_change(parent, row, old, schema, rel, with_ids, mode, dirty)
424        }
425    }
426}
427
428#[allow(clippy::too_many_arguments)]
429fn apply_add(
430    parent: &mut Arc<Entry>,
431    node: &Node<'_>,
432    schema: &Schema,
433    rel: RelId,
434    with_ids: bool,
435    mode: Mutate,
436    dirty: &TxnDirty,
437) -> bool {
438    let pos = binary_search(&parent.rels[rel.ix()].items, &node.row, &schema.sort);
439    match pos {
440        Ok(p) => {
441            let pe = entry_cow(parent, mode, dirty);
442            let list = list_cow(list_slot(pe, rel), mode, dirty);
443            entry_cow(&mut list.items[p], mode, dirty).rc += 1;
444        }
445        Err(ins) => {
446            let mut e = make_new_entry(node.row.clone(), schema, with_ids, 1, dirty);
447            init_rels_for_new_entry(&mut e, node, schema, with_ids, dirty);
448            let pe = entry_cow(parent, mode, dirty);
449            let list = list_cow(list_slot(pe, rel), mode, dirty);
450            list.items.insert(ins, Arc::new(e));
451        }
452    }
453    true
454}
455
456#[allow(clippy::too_many_arguments)]
457fn apply_remove(
458    parent: &mut Arc<Entry>,
459    row: &OwnedRow,
460    schema: &Schema,
461    rel: RelId,
462    mode: Mutate,
463    dirty: &TxnDirty,
464) -> bool {
465    let pos = match binary_search(&parent.rels[rel.ix()].items, row, &schema.sort) {
466        Ok(p) => p,
467        Err(_) => panic!("node does not exist"),
468    };
469    let rc = parent.rels[rel.ix()].items[pos].rc;
470    let pe = entry_cow(parent, mode, dirty);
471    let list = list_cow(list_slot(pe, rel), mode, dirty);
472    if rc == 1 {
473        list.items.remove(pos);
474    } else {
475        entry_cow(&mut list.items[pos], mode, dirty).rc -= 1;
476    }
477    true
478}
479
480#[allow(clippy::too_many_arguments)]
481fn apply_child(
482    parent: &mut Arc<Entry>,
483    row: &OwnedRow,
484    child_rel: RelId,
485    change: &ViewChange<'_>,
486    schema: &Schema,
487    rel: RelId,
488    with_ids: bool,
489    mode: Mutate,
490    dirty: &TxnDirty,
491) -> bool {
492    // The in-view gate — the JS `format.relationships[relationship] === undefined`
493    // check (`view-apply-change.ts:360`) — is now carried by the hierarchical schema:
494    // a slot with no child schema (`rel_child` is `None`) is declared join-only / out
495    // of the view shape, so it is ignored with the parent unchanged *before* anything
496    // else. This matters for an out-of-view relationship that still delivers `Child`
497    // changes: a non-flipped `Exists` gate forwards a `Child` on its own (gating,
498    // out-of-view) slot when membership does not flip (`exists.ts` `#pushWithFilter`).
499    // Checking `rel_child` first short-circuits before the (absent) child lookup —
500    // matching the JS, which never reaches the lookup because the format check returns
501    // early. (The builder makes in-view ⇔ child-schema-present by construction:
502    // `RelDef::related` for a `related` alias, `RelDef::new` for a gating slot.)
503    let child_schema = match schema.rel_child(child_rel) {
504        Some(s) => s,
505        None => return false, // relationship not in view → parent unchanged
506    };
507
508    let pos = match binary_search(&parent.rels[rel.ix()].items, row, &schema.sort) {
509        Ok(p) => p,
510        Err(_) => panic!("node does not exist"),
511    };
512    // Gate on the LIST's mutability, NOT the parent's. The danger case is an
513    // *owned* parent whose target list is still *committed* (it reached the
514    // parent un-touched while a sibling slot was COW'd earlier this txn): the
515    // in-place branch would `list_cow`-clone that committed list **before**
516    // recursing, losing its `Arc` identity even if the recursion is a no-op.
517    // When the list is mutable-in-place (`InPlace`, or a `Cow`-owned list) we
518    // recurse on the real slot (the JS in-place identity contract — §9.3 #4);
519    // when it is committed we recurse on a clone and only commit (clone the
520    // list) if the child actually changed (the JS `newExisting === existing`
521    // short-circuit, `view-apply-change.ts:396-398`).
522    let list_owned = can_mut(parent.rels[rel.ix()].created, mode, dirty);
523    if list_owned {
524        let pe = entry_cow(parent, mode, dirty);
525        let list = list_cow(list_slot(pe, rel), mode, dirty);
526        apply(
527            &mut list.items[pos],
528            change,
529            child_schema,
530            child_rel,
531            with_ids,
532            mode,
533            dirty,
534        )
535    } else {
536        let mut child = parent.rels[rel.ix()].items[pos].clone();
537        let changed = apply(
538            &mut child,
539            change,
540            child_schema,
541            child_rel,
542            with_ids,
543            mode,
544            dirty,
545        );
546        if !changed {
547            return false;
548        }
549        let pe = entry_cow(parent, mode, dirty);
550        let list = list_cow(list_slot(pe, rel), mode, dirty);
551        list.items[pos] = child;
552        true
553    }
554}
555
556#[allow(clippy::too_many_arguments)]
557fn apply_edit_change(
558    parent: &mut Arc<Entry>,
559    new_row: &OwnedRow,
560    old_row: &OwnedRow,
561    schema: &Schema,
562    rel: RelId,
563    with_ids: bool,
564    mode: Mutate,
565    dirty: &TxnDirty,
566) -> bool {
567    let sort = &schema.sort;
568    if compare_rows(sort, old_row, new_row) != Ordering::Equal {
569        // Sort key changed → the row may move.
570        let (old_pos, raw, old_rc) = {
571            let items = &parent.rels[rel.ix()].items;
572            let old_pos = match binary_search(items, old_row, sort) {
573                Ok(p) => p,
574                Err(_) => panic!("old node does not exist"),
575            };
576            let raw = binary_search(items, new_row, sort);
577            (old_pos, raw, items[old_pos].rc)
578        };
579        let found = raw.is_ok();
580        let pos = raw.unwrap_or_else(|e| e);
581
582        // Fast path: rc==1 and the row lands in the same slot after removing old.
583        if old_rc == 1 && (pos == old_pos || pos.checked_sub(1) == Some(old_pos)) {
584            let pe = entry_cow(parent, mode, dirty);
585            let list = list_cow(list_slot(pe, rel), mode, dirty);
586            apply_edit(
587                &mut list.items[old_pos],
588                new_row,
589                old_row,
590                schema,
591                with_ids,
592                mode,
593                dirty,
594            );
595            return true;
596        }
597
598        // General move (rc may be > 1).
599        let pe = entry_cow(parent, mode, dirty);
600        let list = list_cow(list_slot(pe, rel), mode, dirty);
601        let old_entry = list.items[old_pos].clone(); // capture original before mutating
602        let new_rc = old_rc - 1;
603        let adjusted_pos;
604        if new_rc == 0 {
605            list.items.remove(old_pos);
606            adjusted_pos = if old_pos < pos { pos - 1 } else { pos };
607        } else {
608            entry_cow(&mut list.items[old_pos], mode, dirty).rc = new_rc; // ghost
609            adjusted_pos = pos;
610        }
611        if found {
612            // Merge into the existing entry at the destination, bump its rc.
613            let existing_rc = list.items[adjusted_pos].rc;
614            apply_edit(
615                &mut list.items[adjusted_pos],
616                new_row,
617                old_row,
618                schema,
619                with_ids,
620                mode,
621                dirty,
622            );
623            entry_cow(&mut list.items[adjusted_pos], mode, dirty).rc = existing_rc + 1;
624        } else {
625            // Move: edit the (captured) old entry, set rc=1, insert at the new pos.
626            let mut moved = old_entry;
627            apply_edit(&mut moved, new_row, old_row, schema, with_ids, mode, dirty);
628            entry_cow(&mut moved, mode, dirty).rc = 1;
629            list.items.insert(adjusted_pos, moved);
630        }
631        true
632    } else {
633        // Sort key unchanged → edit in place at the located position.
634        let pos = match binary_search(&parent.rels[rel.ix()].items, old_row, sort) {
635            Ok(p) => p,
636            Err(_) => panic!("node does not exist"),
637        };
638        let pe = entry_cow(parent, mode, dirty);
639        let list = list_cow(list_slot(pe, rel), mode, dirty);
640        apply_edit(
641            &mut list.items[pos],
642            new_row,
643            old_row,
644            schema,
645            with_ids,
646            mode,
647            dirty,
648        );
649        true
650    }
651}
652
653/// `applyEdit` (`view-apply-change.ts:578`): field-merge in place when allowed and
654/// the sort key is unchanged, else clone-and-track. A PK/sort change always forces
655/// a fresh entry (so identity tracks the new key). Recomputes `id` (unconditional,
656/// matching the JS) when `with_ids`.
657#[allow(clippy::too_many_arguments)]
658fn apply_edit(
659    existing: &mut Arc<Entry>,
660    new_row: &OwnedRow,
661    old_row: &OwnedRow,
662    schema: &Schema,
663    with_ids: bool,
664    mode: Mutate,
665    dirty: &TxnDirty,
666) {
667    let can = can_mut(existing.created, mode, dirty);
668    if can && compare_rows(&schema.sort, old_row, new_row) == Ordering::Equal {
669        let e = entry_cow(existing, mode, dirty);
670        e.row = new_row.clone(); // edits carry the full row (`09` §5.8 / §12-Q6)
671        if with_ids {
672            e.id = Some(Box::new(make_id(new_row, &schema.primary_key)));
673        }
674    } else {
675        let mut e = (**existing).clone();
676        e.row = new_row.clone();
677        e.created = dirty.gen;
678        if with_ids {
679            e.id = Some(Box::new(make_id(new_row, &schema.primary_key)));
680        }
681        *existing = Arc::new(e);
682    }
683}
684
685/// Build a freshly-added entry's children **in place** (it is unobserved).
686/// Ports `initializeRelationshipsForNewEntryIfAny` (`view-apply-change.ts:624`).
687/// Drains each present relationship thunk; each plural child is binary-search-
688/// inserted directly. A slot with no child schema is join-only / out of the view
689/// shape and is skipped.
690fn init_rels_for_new_entry(
691    entry: &mut Entry,
692    node: &Node<'_>,
693    schema: &Schema,
694    with_ids: bool,
695    dirty: &TxnDirty,
696) {
697    for r in &node.rels {
698        let slot = r.slot;
699        let child_schema = match schema.rel_child(slot) {
700            Some(s) => s,
701            None => continue, // join-only / out of view
702        };
703
704        // Plural: build the sorted list directly.
705        let mut items: Vec<Arc<Entry>> = Vec::new();
706        for child in (r.thunk)() {
707            match binary_search(&items, &child.row, &child_schema.sort) {
708                Ok(p) => {
709                    Arc::make_mut(&mut items[p]).rc += 1;
710                }
711                Err(ins) => {
712                    let mut ce =
713                        make_new_entry(child.row.clone(), child_schema, with_ids, 1, dirty);
714                    init_rels_for_new_entry(&mut ce, &child, child_schema, with_ids, dirty);
715                    items.insert(ins, Arc::new(ce));
716                }
717            }
718        }
719        // A drained-empty slot keeps the shared empty list from `make_new_entry`
720        // (no per-slot allocation); only a populated slot gets its own list.
721        if !items.is_empty() {
722            entry.rels[slot.ix()] = Arc::new(EntryListInner {
723                created: dirty.gen,
724                items,
725            });
726        }
727    }
728}
729
730/// A throwaway entry for a `std::mem::replace` take-then-put-back (the View flush).
731fn placeholder_entry() -> Entry {
732    Entry {
733        row: OwnedRow::empty(),
734        rc: 0,
735        created: TxnGen::default(),
736        id: None,
737        rels: Box::new([]),
738    }
739}
740
741// ---------------------------------------------------------------------------
742// ResultType / listeners (`09` §4.7)
743// ---------------------------------------------------------------------------
744
745/// The query's completion state (`typed-view.ts`).
746#[derive(Clone, Copy, PartialEq, Eq, Debug)]
747pub enum ResultType {
748    Unknown,
749    Complete,
750    Error,
751}
752
753/// A flush listener (`Listener`, `typed-view.ts:9`). Fired on flush and once
754/// immediately on registration.
755pub type Listener = Box<dyn FnMut(&ViewData, ResultType)>;
756
757// ---------------------------------------------------------------------------
758// The View sink (`09` §4.1) — the arena operator
759// ---------------------------------------------------------------------------
760
761/// The materialization sink (ports `ArrayView`). Lives in the arena as
762/// `Operator::View`; its graph-touching halves (`hydrate`/`view_push`) are
763/// `Graph` methods that delegate the pure folding to [`apply_change`].
764pub struct View {
765    /// Upstream operator we fetch/receive pushes from.
766    pub input: crate::graph::NodeId,
767    /// Output schema of `input` (hierarchical; names resolved to `ColId`/`RelId`).
768    /// This is also the view shape: a relationship slot is in-view iff its `RelDef`
769    /// carries a child schema (`rel_child(slot).is_some()`).
770    pub schema: Arc<Schema>,
771    /// Always `false` for `ArrayView` (`09` §1.2); kept for a future `SolidView`.
772    pub with_ids: bool,
773    /// The synthetic root. `root[""]` is the top-level result; `Arc`-shared
774    /// subtrees give reference stability.
775    pub root: RefCell<Arc<Entry>>,
776    listeners: RefCell<Vec<Listener>>,
777    dirty: Cell<bool>,
778    result_type: Cell<ResultType>,
779    /// Transaction generation (the `#txnDirty` analogue, bumped on flush).
780    txn: Cell<TxnGen>,
781}
782
783impl View {
784    /// Construct a View over `input` with the given hierarchical `schema` (which
785    /// also carries the view shape). The caller (`Graph::add_array_view`) then
786    /// `hydrate`s it.
787    pub fn new(
788        input: crate::graph::NodeId,
789        schema: Schema,
790        with_ids: bool,
791        result_type: ResultType,
792    ) -> View {
793        let root = synthetic_root();
794        View {
795            input,
796            schema: Arc::new(schema),
797            with_ids,
798            root: RefCell::new(Arc::new(root)),
799            listeners: RefCell::new(Vec::new()),
800            dirty: Cell::new(false),
801            result_type: Cell::new(result_type),
802            txn: Cell::new(TxnGen::default()),
803        }
804    }
805
806    /// The top-level result snapshot (`data` getter). Cheap `Arc` clone.
807    pub fn data(&self) -> ViewData {
808        view_data(&self.root.borrow())
809    }
810
811    /// Current result type.
812    pub fn result_type(&self) -> ResultType {
813        self.result_type.get()
814    }
815
816    /// Set the result type (the async `queryComplete` resolution, `09` §3.8). Fires
817    /// listeners out of band (it does not touch the txn dirty state).
818    pub fn set_result_type(&self, rt: ResultType) {
819        self.result_type.set(rt);
820        self.fire_listeners();
821    }
822
823    /// Register a listener; fires it once immediately with the current snapshot
824    /// (`addListener`, `array-view.ts:115`). Returns the index (for removal).
825    pub fn add_listener(&self, mut l: Listener) -> usize {
826        // Fire once immediately (no View borrow held across the call).
827        let (data, rt) = (self.data(), self.result_type.get());
828        l(&data, rt);
829        let mut ls = self.listeners.borrow_mut();
830        ls.push(l);
831        ls.len() - 1
832    }
833
834    /// Number of registered listeners (test/inspection helper).
835    pub fn listener_count(&self) -> usize {
836        self.listeners.borrow().len()
837    }
838
839    /// Mark the view dirty (a push happened). `view_push` (in `graph.rs`) calls
840    /// this before folding the change.
841    pub fn mark_dirty(&self) {
842        self.dirty.set(true);
843    }
844
845    /// `flush` (`array-view.ts:173`): if dirty, fire listeners with the snapshot,
846    /// then bump the txn generation (so the next txn copy-on-writes again — the
847    /// fresh-`WeakSet` analogue). No-op when not dirty.
848    pub fn flush(&self) {
849        if !self.dirty.get() {
850            return;
851        }
852        self.dirty.set(false);
853        self.fire_listeners();
854        self.txn.set(TxnGen(self.txn.get().0 + 1));
855    }
856
857    /// Fire listeners with the current snapshot. Holds **no** `RefCell` borrow
858    /// across a listener call (a listener may re-enter the View): snapshot the
859    /// data, `take` the listener vec, fire, then splice the vec back.
860    fn fire_listeners(&self) {
861        let data = self.data();
862        let rt = self.result_type.get();
863        let mut ls = std::mem::take(&mut *self.listeners.borrow_mut());
864        for l in ls.iter_mut() {
865            l(&data, rt);
866        }
867        // Re-attach (a listener may have registered more during the fire).
868        self.listeners.borrow_mut().splice(0..0, ls);
869    }
870
871    /// The current transaction generation (used by `view_push`/`hydrate`).
872    pub fn txn_gen(&self) -> TxnGen {
873        self.txn.get()
874    }
875
876    // --- graph-driven hydrate / push (the `array-view.ts` halves) ---------
877
878    /// Take the root `Arc` out (replacing it with a placeholder), so the returned
879    /// local is the **sole** strong holder — letting `InPlace`/owned `make_mut`
880    /// genuinely mutate in place (`09` §5.1). No `RefCell` borrow is held across
881    /// the subsequent fetch/`apply_change`.
882    fn take_root(&self) -> Arc<Entry> {
883        std::mem::replace(&mut *self.root.borrow_mut(), Arc::new(placeholder_entry()))
884    }
885    fn put_root(&self, root: Arc<Entry>) {
886        *self.root.borrow_mut() = root;
887    }
888
889    /// Hydrate the tree from the input's `fetch` stream (the caller supplies the
890    /// already-fetched node iterator so the `Graph` borrow stays with the caller).
891    /// Builds `InPlace` (the root is unobserved), then flushes once
892    /// (`array-view.ts:140-157`).
893    pub fn hydrate_from<'g>(&self, nodes: impl Iterator<Item = Node<'g>>) {
894        self.mark_dirty();
895        let mut root = self.take_root();
896        let dirty = TxnDirty {
897            gen: self.txn.get(),
898        };
899        for node in nodes {
900            apply_change(
901                &mut root,
902                &ViewChange::Add { node },
903                &self.schema,
904                REL_ROOT,
905                self.with_ids,
906                Mutate::InPlace,
907                &dirty,
908            );
909        }
910        self.put_root(root);
911        self.flush();
912    }
913
914    /// Fold one dataflow [`Change`](crate::change::Change) into the tree
915    /// (`array-view.ts:159-171`). Transaction-scoped COW: takes the root out (so
916    /// owned spine objects are uniquely held and mutate in place), applies, writes
917    /// back. Does **not** flush — the caller flushes at the transaction boundary
918    /// (the legacy `source_push` tests read the tree directly without flushing,
919    /// which is fine: the change is applied to `root` synchronously).
920    pub fn push_change<'g>(&self, change: crate::change::Change<'g>) {
921        self.mark_dirty();
922        let vc = ViewChange::from_change(change);
923        let mut root = self.take_root();
924        let dirty = TxnDirty {
925            gen: self.txn.get(),
926        };
927        apply_change(
928            &mut root,
929            &vc,
930            &self.schema,
931            REL_ROOT,
932            self.with_ids,
933            Mutate::Cow,
934            &dirty,
935        );
936        self.put_root(root);
937    }
938
939    // --- test/inspection readback (the spike `dump_view` shape) -----------
940
941    /// `[(col0_id, [child_col0_id, …]), …]` — top rows + their children's col-0
942    /// ids, children flattened across all relationship slots in slot order (the
943    /// spike `dump_view` contract). Assumes col 0 is `Int`.
944    pub fn dump_col0(&self) -> Vec<(i64, Vec<i64>)> {
945        fn id_of(row: &OwnedRow) -> i64 {
946            match row.col(0) {
947                Value::Int(i) => i,
948                other => panic!("dump_view expects Int id in col 0, got {other:?}"),
949            }
950        }
951        self.top_entries()
952            .iter()
953            .map(|e| {
954                let mut kids = Vec::new();
955                for r in e.rels.iter() {
956                    kids.extend(r.items.iter().map(|c| id_of(&c.row)));
957                }
958                (id_of(&e.row), kids)
959            })
960            .collect()
961    }
962
963    /// `[(full_int_row, [child_full_int_row, …]), …]` — like [`View::dump_col0`]
964    /// but every column as `i64` (so an edit's non-key value is observable).
965    pub fn dump_rows(&self) -> Vec<(Vec<i64>, Vec<Vec<i64>>)> {
966        fn ints(row: &OwnedRow) -> Vec<i64> {
967            row.cells()
968                .map(|v| match v {
969                    Value::Int(i) => i,
970                    other => panic!("dump_view_rows expects Int columns, got {other:?}"),
971                })
972                .collect()
973        }
974        self.top_entries()
975            .iter()
976            .map(|e| {
977                let mut kids = Vec::new();
978                for r in e.rels.iter() {
979                    kids.extend(r.items.iter().map(|c| ints(&c.row)));
980                }
981                (ints(&e.row), kids)
982            })
983            .collect()
984    }
985
986    /// Recursive col-0 dump: each entry's col-0 id plus its children's (recursively,
987    /// across all relationship slots in slot order). The deep counterpart of
988    /// [`View::dump_col0`] — surfaces grandchildren, so a nested relationship
989    /// (`issue{comments{reactions}}`) is fully observable. Assumes col 0 is `Int`.
990    pub fn dump_col0_deep(&self) -> Vec<Col0Node> {
991        fn id_of(row: &OwnedRow) -> i64 {
992            match row.col(0) {
993                Value::Int(i) => i,
994                other => panic!("dump_col0_deep expects Int id in col 0, got {other:?}"),
995            }
996        }
997        fn walk(e: &Entry) -> Col0Node {
998            let mut children = Vec::new();
999            for r in e.rels.iter() {
1000                children.extend(r.items.iter().map(|c| walk(c)));
1001            }
1002            Col0Node {
1003                id: id_of(&e.row),
1004                children,
1005            }
1006        }
1007        self.top_entries().iter().map(|e| walk(e)).collect()
1008    }
1009
1010    /// The top-level entries (`root[""]`), collected into an owned `Vec` of `Arc`
1011    /// clones so the `RefCell` borrow is released. Used by the dump helpers.
1012    fn top_entries(&self) -> Vec<Arc<Entry>> {
1013        self.root.borrow().rels[REL_ROOT.ix()].items.clone()
1014    }
1015}
1016
1017/// A node of [`View::dump_col0_deep`]: a col-0 id and its recursive children.
1018#[derive(Debug, PartialEq, Eq)]
1019pub struct Col0Node {
1020    pub id: i64,
1021    pub children: Vec<Col0Node>,
1022}
1023
1024#[cfg(test)]
1025mod tests;