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;