@rindle/client 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/view.ts CHANGED
@@ -18,6 +18,17 @@ export interface ViewTypes {
18
18
  rels: Record<number, ViewTypes>;
19
19
  }
20
20
 
21
+ /** The phase of an {@link ArrayView.onChanges} delivery: the initial hydrate `snapshot` vs a later
22
+ * incremental `batch` — the same distinction a narrator draws (`ChangeEvent` `snapshot`/`batch`). */
23
+ export type ChangePhase = "snapshot" | "batch";
24
+
25
+ /** A per-view change listener ({@link ArrayView.onChanges}): the net `FlatChange[]` this view folded,
26
+ * the {@link ChangePhase} it arrived on, and the view's `WireSchema` (the position→name source for
27
+ * `resolveChange`). This is the DIFF the data channel ({@link ArrayView.subscribe}) discards — the
28
+ * seam a narrator drives off. The `schema` is passed (not closed over) because the first `snapshot`
29
+ * fires synchronously inside `materialize`, before the caller holds the view handle. */
30
+ export type ViewChangeListener = (changes: FlatChange[], phase: ChangePhase, schema: WireSchema) => void;
31
+
21
32
  /** The public ArrayView contract `materialize()` returns. */
22
33
  export interface ArrayView<R> {
23
34
  /** The current materialized result (reference-stable where data is unchanged). */
@@ -39,6 +50,14 @@ export interface ArrayView<R> {
39
50
  /** Subscribe; fires immediately with the current data, then after each applied batch (and after
40
51
  * a {@link resultType} change — re-read `resultType` in the listener). */
41
52
  subscribe(listener: (data: readonly R[]) => void): () => void;
53
+ /** Subscribe to this view's folded CHANGE stream — the `FlatChange[]` it applies, NET of no-op
54
+ * cycles (a rebase's balanced `remove`+`add` / edit round-trip cancels, so a correctly predicted
55
+ * optimistic write delivers nothing here). Carries the diff {@link subscribe} throws away; the
56
+ * per-view seam a narrator rides. Does NOT replay on subscribe — attach via
57
+ * `store.materialize(query, { onChanges })` to catch a synchronous backend's first snapshot.
58
+ * A view with any change listener also enriches its own `remove` ops with the evicted subtree
59
+ * (per-view, no global opt-in). Returns a detach function. */
60
+ onChanges(listener: ViewChangeListener): () => void;
42
61
  /** Tear down + stop receiving updates. */
43
62
  destroy(): void;
44
63
  }
@@ -58,6 +77,10 @@ export interface SingularArrayView<R> {
58
77
  /** Subscribe; fires immediately with the current row, then after each applied batch (and after
59
78
  * a {@link resultType} change). */
60
79
  subscribe(listener: (data: R | null) => void): () => void;
80
+ /** Subscribe to the view's folded CHANGE stream — see {@link ArrayView.onChanges}. The changes
81
+ * are the same positional `FlatChange`s as the plural view (a `.one()` is just the list capped to
82
+ * one), so a narrator resolves them identically. */
83
+ onChanges(listener: ViewChangeListener): () => void;
61
84
  /** Tear down + stop receiving updates. */
62
85
  destroy(): void;
63
86
  }
@@ -91,6 +114,10 @@ export class SingularView<R> implements SingularArrayView<R> {
91
114
  return this.inner.subscribe((d) => listener(d[0] ?? null));
92
115
  }
93
116
 
117
+ onChanges(listener: ViewChangeListener): () => void {
118
+ return this.inner.onChanges(listener);
119
+ }
120
+
94
121
  destroy(): void {
95
122
  this.inner.destroy();
96
123
  }
@@ -161,8 +188,159 @@ function toWireNode(node: Node, schema: WireSchema): WireNode {
161
188
  return { row: node.row, rels };
162
189
  }
163
190
 
164
- function rowsEqual(a: WireValue[], b: WireValue[]): boolean {
165
- return a.length === b.length && a.every((v, i) => Object.is(v, b[i]));
191
+ /** Elementwise equality over positional bare-cell rows — THE row comparator for every JS-side
192
+ * diff (views, aggregate heads, the persistence mirror), exported so no caller grows a drifted
193
+ * private copy. Per cell: `===` keeps `-0 === 0` (the engine's key semantics treat them equal);
194
+ * the `Object.is` arm makes a NaN cell equal itself (under `!==` alone, a NaN-bearing row never
195
+ * matches any copy of itself, so every diff re-emits it as a spurious edit forever). */
196
+ export function rowsEqual(a: WireValue[], b: WireValue[]): boolean {
197
+ if (a.length !== b.length) return false;
198
+ for (let i = 0; i < a.length; i++) {
199
+ if (a[i] !== b[i] && !Object.is(a[i], b[i])) return false;
200
+ }
201
+ return true;
202
+ }
203
+
204
+ /** JSON.stringify for keying a row, except the numbers JSON folds together — NaN/±Infinity (→ null)
205
+ * and -0 (→ 0) — get a sentinel encoding, so two rows that `rowsEqual` (Object.is) distinguishes
206
+ * never share a net key (a `remove [1, Infinity]` must not cancel an `add [1, null]`). */
207
+ function keyRow(row: WireValue[]): string {
208
+ return JSON.stringify(row, (_k, v: unknown) =>
209
+ typeof v === "number" && (!Number.isFinite(v) || Object.is(v, -0)) ? [" num", Object.is(v, -0) ? "-0" : String(v)] : v,
210
+ );
211
+ }
212
+
213
+ /** The key a subtractive net matches an op on: the path (parent locators, one hop per level) plus the
214
+ * row bytes. Two ops with the same key touch the same row at the same tree position. */
215
+ function opRowKey(path: FlatChange["path"], row: WireValue[]): string {
216
+ let k = "";
217
+ for (const seg of path) k += seg.rel + ":" + keyRow(seg.parentRow) + "/";
218
+ return k + "|" + keyRow(row);
219
+ }
220
+
221
+ /** Walk `path` down `schema` to the changed level's `WireSchema` — `null` when a hop crosses a
222
+ * gating (join-only) slot; such ops never fold, so they never reach the net. */
223
+ function levelSchemaAt(schema: WireSchema, path: FlatChange["path"]): WireSchema | null {
224
+ let level: WireSchema = schema;
225
+ for (const seg of path) {
226
+ const child = level.relationships[seg.rel]?.child;
227
+ if (!child) return null;
228
+ level = child;
229
+ }
230
+ return level;
231
+ }
232
+
233
+ /** Deep structural equality of two canonical (maintained-order) subtrees: rows via Object.is,
234
+ * per-slot children pairwise, rc included — a dup-path child only equals an equally-duplicated one. */
235
+ function nodesEqual(a: Node, b: Node): boolean {
236
+ if (a.rc !== b.rc || !rowsEqual(a.row, b.row) || a.rels.length !== b.rels.length) return false;
237
+ for (let s = 0; s < a.rels.length; s++) {
238
+ const ra = a.rels[s];
239
+ const rb = b.rels[s];
240
+ if (ra.length !== rb.length) return false;
241
+ for (let i = 0; i < ra.length; i++) if (!nodesEqual(ra[i], rb[i])) return false;
242
+ }
243
+ return true;
244
+ }
245
+
246
+ /** Cancel no-op cycles in a folded batch SUBTRACTIVELY — the safe half of the removed engine
247
+ * coalescer (OPTIMISTIC-WRITES-DESIGN §3, and `wasm/db.rs` "Step 2 of the removal"). A rebase
248
+ * re-invokes a still-pending prediction, emitting a balanced `remove R`+`add R` (or `edit b→a`+
249
+ * `edit a→b`) that nets to nothing; so a correctly predicted optimistic write delivers ZERO changes
250
+ * to a narrator, and only genuine (mispredicted / other-client) changes survive. We ONLY drop exact
251
+ * inverse pairs — byte-identical row at the same path AND a structurally identical subtree: a row
252
+ * that leaves and re-enters within one batch carrying a CHANGED child set is NOT an inverse (the
253
+ * child never got its own event while the row was out — the re-add is its only carrier), so that
254
+ * pair survives. Survivors pass through UNMODIFIED: we never rewrite a locator, reorder, or
255
+ * reconstruct a gated subtree (the reconstructive netting that shipped four correctness bugs and
256
+ * was pulled from the engine). Because this feeds prose, not a locate-by-value receiver, even an
257
+ * imperfect net is at worst a spurious line, never a corrupt view — and every conservative bail
258
+ * here (unknown subtree, gated level) errs on the spurious-line side, never the missed-line side.
259
+ * Order-preserving for survivors; returns the input array unchanged when nothing cancels. */
260
+ function netChanges(changes: FlatChange[], schema: WireSchema): FlatChange[] {
261
+ if (changes.length < 2) return changes;
262
+ const alive = new Array<boolean>(changes.length).fill(true);
263
+ const removes = new Map<string, number[]>(); // key → indices of not-yet-cancelled removes
264
+ const adds = new Map<string, number[]>(); // key → indices of not-yet-cancelled adds
265
+ const edits = new Map<string, number[]>(); // pathKey+old+new → indices of not-yet-cancelled edits
266
+ // The subtree an add/remove carries, canonicalized to maintained (sorted, dup-folded) form so a
267
+ // shipped-order `add` node and a maintained-order enriched `remove` node compare structurally.
268
+ // Built ONLY when two ops collide on the row key — the common no-collision delivery never builds
269
+ // one — and memoized per op index. `null` ⇒ subtree unknown (a gated level, or a remove that was
270
+ // not enriched); conservative: an unknown subtree never cancels, so we err toward a spurious line.
271
+ const subCache = new Map<number, Node | null>();
272
+ const subtreeOf = (i: number): Node | null => {
273
+ let s = subCache.get(i);
274
+ if (s !== undefined) return s;
275
+ const { path, op } = changes[i];
276
+ const level = levelSchemaAt(schema, path);
277
+ // A remove reaching the net actually evicted its node (only applied ops are delivered) and so was
278
+ // enriched with its subtree on `op.node`; an add always carries `op.node`. Either is canonicalized.
279
+ const wnode = op.tag === "add" ? op.node : op.tag === "remove" ? op.node : undefined;
280
+ s = level && wnode ? buildNode(wnode, level) : null;
281
+ subCache.set(i, s);
282
+ return s;
283
+ };
284
+ // Take the LATEST pending opposite at the same key whose subtree also matches (LIFO, like the
285
+ // plain `take` below, so nested cycles unwind innermost-first).
286
+ const takeInverse = (m: Map<string, number[]>, k: string, i: number): number | undefined => {
287
+ const arr = m.get(k);
288
+ if (!arr || arr.length === 0) return undefined;
289
+ const mine = subtreeOf(i);
290
+ if (mine === null) return undefined;
291
+ for (let x = arr.length - 1; x >= 0; x--) {
292
+ const theirs = subtreeOf(arr[x]);
293
+ if (theirs !== null && nodesEqual(mine, theirs)) {
294
+ const j = arr[x];
295
+ arr.splice(x, 1);
296
+ return j;
297
+ }
298
+ }
299
+ return undefined;
300
+ };
301
+ const take = (m: Map<string, number[]>, k: string): number | undefined => {
302
+ const arr = m.get(k);
303
+ return arr && arr.length ? arr.pop() : undefined;
304
+ };
305
+ const put = (m: Map<string, number[]>, k: string, i: number): void => {
306
+ const arr = m.get(k);
307
+ if (arr) arr.push(i);
308
+ else m.set(k, [i]);
309
+ };
310
+ let cancelled = false;
311
+ for (let i = 0; i < changes.length; i++) {
312
+ const { path, op } = changes[i];
313
+ if (op.tag === "remove") {
314
+ const k = opRowKey(path, op.row);
315
+ const j = takeInverse(adds, k, i);
316
+ if (j !== undefined) {
317
+ alive[i] = alive[j] = false;
318
+ cancelled = true;
319
+ } else put(removes, k, i);
320
+ } else if (op.tag === "add") {
321
+ const k = opRowKey(path, op.node.row);
322
+ const j = takeInverse(removes, k, i);
323
+ if (j !== undefined) {
324
+ alive[i] = alive[j] = false;
325
+ cancelled = true;
326
+ } else put(adds, k, i);
327
+ } else {
328
+ // edit: cancel against a prior INVERSE edit (a→b then b→a) at the same path. The old/new
329
+ // bytes are part of the map key, so the inverse lookup is O(1) instead of a rescan.
330
+ const pk = opRowKey(path, []);
331
+ const oldS = keyRow(op.old);
332
+ const newS = keyRow(op.new);
333
+ const j = take(edits, pk + "" + newS + "" + oldS);
334
+ if (j !== undefined) {
335
+ alive[i] = alive[j] = false;
336
+ cancelled = true;
337
+ } else put(edits, pk + "" + oldS + "" + newS, i);
338
+ }
339
+ }
340
+ if (!cancelled) return changes;
341
+ const out: FlatChange[] = [];
342
+ for (let i = 0; i < changes.length; i++) if (alive[i]) out.push(changes[i]);
343
+ return out;
166
344
  }
167
345
 
168
346
  const EMPTY: readonly never[] = Object.freeze([]);
@@ -177,8 +355,10 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
177
355
  private _schema: WireSchema | null;
178
356
  private types?: ViewTypes;
179
357
  // SSR first-paint seed (SSR-DESIGN.md §6): pre-projected rows installed by `seed`, returned by
180
- // `data` WHILE pending (no live schema yet). Cleared on `reset` — the first live `hello`
181
- // so the maintained tree takes over and the live snapshot reconciles. `null` ⇒ no seed.
358
+ // `data` until the maintained tree's first live SNAPSHOT lands (the Store calls `retireSeed` then).
359
+ // It deliberately SURVIVES `reset` (the `hello`), so a seeded view bridges the `hello`→snapshot gap
360
+ // instead of flashing empty in between (a `hello` sets the schema but its data arrives one round-trip
361
+ // later, on the snapshot). `null` ⇒ no seed.
182
362
  private seeded: readonly R[] | null = null;
183
363
  private top: Node[] = [];
184
364
  private dirty = true;
@@ -188,6 +368,16 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
188
368
  // (the optimistic backend: `unknown` until hydrated, etc.).
189
369
  private rt: ResultType = "complete";
190
370
  private readonly listeners = new Set<(data: readonly R[]) => void>();
371
+ // The per-view CHANGE stream ({@link onChanges}) — the diff a narrator rides, distinct from the
372
+ // data channel above. Empty until a narrator attaches: an idle view pays one `.size` check per fold.
373
+ private readonly changeListeners = new Set<ViewChangeListener>();
374
+ // Changes folded but not yet delivered to `changeListeners` — buffered while the Store defers a
375
+ // commit (mirrors the deferred `notify`), then netted + flushed at the commit boundary. Segmented
376
+ // BY PHASE (not one last-writer-wins phase scalar): a bracket that folds both a `snapshot` and a
377
+ // `batch` into this view keeps them apart, each netted and delivered under its OWN phase, so a
378
+ // snapshot is never mislabeled `batch` (nor a batch dropped as `snapshot`). Consecutive same-phase
379
+ // folds coalesce into one segment so a whole commit's batch nets together.
380
+ private pendingSegments: { phase: ChangePhase; changes: FlatChange[] }[] = [];
191
381
 
192
382
  constructor(schema?: WireSchema, types?: ViewTypes, qid: QueryId = 0) {
193
383
  this._schema = schema ?? null;
@@ -219,25 +409,45 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
219
409
  /** (Re)bind to a schema and clear the tree IN PLACE. The first `hello` (pending → ready)
220
410
  * and a re-hydrate (gap → new epoch — FLAT-CHANGES-DESIGN.md §2.3) both go through here, so
221
411
  * the caller's view reference and its subscribers survive a re-subscribe. Does NOT notify —
222
- * the snapshot that follows (`applyChanges`) does, avoiding an empty-then-filled flicker. */
412
+ * the snapshot that follows (`applyChanges`) does, avoiding an empty-then-filled flicker.
413
+ * KEEPS any SSR `seeded` rows: they are retired only when the first live snapshot lands
414
+ * ({@link retireSeed}, driven by the Store), so `data` shows the seed — not an empty tree —
415
+ * across the whole `hello`→first-`snapshot` gap. */
223
416
  reset(schema: WireSchema, types?: ViewTypes): void {
224
417
  this._schema = schema;
225
418
  this.types = types;
226
- this.seeded = null; // live data takes over; the seed was first-paint only
227
419
  this.top = [];
228
420
  this.cached = null;
229
421
  this.dirty = true;
422
+ // Drop any changes buffered under the OLD epoch: a re-hydrate cuts a new schema, and
423
+ // `deliverChanges` resolves against the current `_schema`, so pre-epoch changes must never
424
+ // survive to be delivered against the new one (mirrors `destroy`). The snapshot that follows
425
+ // this reset re-establishes the buffer for the new epoch.
426
+ this.pendingSegments = [];
230
427
  }
231
428
 
232
429
  /** Install a pre-projected SSR first-paint snapshot (SSR-DESIGN.md §6). The rows are already
233
430
  * in result shape (json columns parsed, relationships nested), so a view with no live backend
234
431
  * (the server one-shot Store) reads them directly, and a browser view shows them until its
235
- * first live `hello` (`reset`) swaps in the maintained tree. Does NOT notify — it is set at
236
- * materialize time, before any subscriber, and the live snapshot that follows notifies. */
432
+ * first live snapshot lands ({@link retireSeed}). Does NOT notify — it is set at materialize
433
+ * time, before any subscriber, and the live snapshot that follows notifies. */
237
434
  seed(rows: readonly R[]): void {
238
435
  this.seeded = rows;
239
436
  }
240
437
 
438
+ /** Retire the SSR first-paint seed — the Store calls this as it folds the maintained tree's first
439
+ * live snapshot, so `data` switches from the seed to the live tree with no empty gap between them
440
+ * (the seed deliberately survived the earlier `reset`/`hello`). Idempotent. Does NOT notify — the
441
+ * snapshot fold it accompanies does; BUT when that fold is empty (a 0-row result, or rows already
442
+ * in `top`) it notifies nothing, so the Store forces a {@link notify} on the strength of the `true`
443
+ * return here — else the view reads the live tree yet never re-renders (a frozen seed). Returns
444
+ * whether a live seed was actually cleared (so the Store knows a forced notify is owed). */
445
+ retireSeed(): boolean {
446
+ if (this.seeded === null) return false;
447
+ this.seeded = null;
448
+ return true;
449
+ }
450
+
241
451
  /** Apply a batch (the hydrate snapshot or one transaction's events) in order, then
242
452
  * notify subscribers once. Order is significant (FLAT-CHANGES-DESIGN.md §5.4). A no-op
243
453
  * while pending (changes never precede the `hello` that resets the schema).
@@ -245,21 +455,61 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
245
455
  * `enrichRemoves` ⇒ before a removed node is dropped, reconstruct its full subtree and attach it
246
456
  * to the `remove` op's `node` (in place, so the same event object the Store fans out to its
247
457
  * change subscribers carries it). Off by default — paid only when a consumer asked for it, and
248
- * only on a real eviction (an rc-decrement that keeps the row leaves `node` absent).
458
+ * only on a real eviction (an rc-decrement that keeps the row leaves `node` absent). A view with
459
+ * an attached {@link onChanges} listener ALSO enriches (per-view, no global opt-in), so a
460
+ * narrator can resolve a removed row's subs whether or not the store-global counter is set.
249
461
  *
250
462
  * `deferNotify` ⇒ fold but do NOT notify; the caller is responsible for calling {@link flush}
251
463
  * later. The Store uses this to fold every view in one commit before notifying any subscriber
252
464
  * (cross-view-atomic notification — `Store.onCommitBoundary`); standalone use leaves it off, so
253
465
  * a bare view still fires its subscribers after each applied batch.
254
466
  *
255
- * Returns whether the batch changed the view (so a deferring caller knows it must be flushed). */
256
- applyChanges(events: FlatChange[], enrichRemoves = false, deferNotify = false): boolean {
467
+ * `phase` tags the {@link onChanges} delivery (`snapshot` for the hydrate, `batch` otherwise); it
468
+ * does not affect the fold. Returns whether the batch changed the view (so a deferring caller
469
+ * knows it must be flushed). */
470
+ applyChanges(events: FlatChange[], enrichRemoves = false, deferNotify = false, phase: ChangePhase = "batch"): boolean {
257
471
  if (this._schema === null) return false;
258
- let changed = false;
259
- for (const e of events) changed = this.applyAt(this.top, this._schema, e.path, e.op, 0, enrichRemoves) || changed;
260
- if (!changed) return false;
472
+ // Enrich this view's removes when a narrator is attached (see the `enrichRemoves` doc above) —
473
+ // the per-view twin of the store's global `removedSubtree` counter.
474
+ const enrich = enrichRemoves || this.changeListeners.size > 0;
475
+ // Deliver ONLY the ops that actually changed the tree: a duplicate-path add (rc bump), an
476
+ // rc-decrement remove that left the row, a no-op edit, and a gated op all return false — none is
477
+ // a real change, so none should reach a narrator. (Enrichment already attached the subtree to a
478
+ // real remove during the fold, so a surviving remove carries its `node`.)
479
+ const applied: FlatChange[] = [];
480
+ for (const e of events) {
481
+ if (this.applyAt(this.top, this._schema, e.path, e.op, 0, enrich)) applied.push(e);
482
+ }
483
+ if (applied.length === 0) return false;
261
484
  this.dirty = true;
262
- if (!deferNotify) this.notify();
485
+ if (deferNotify) {
486
+ if (this.changeListeners.size > 0) {
487
+ const segs = this.pendingSegments;
488
+ const last = segs.length > 0 ? segs[segs.length - 1] : undefined;
489
+ if (last && last.phase === phase) for (const e of applied) last.changes.push(e);
490
+ else segs.push({ phase, changes: applied.slice() });
491
+ }
492
+ return true;
493
+ }
494
+ // Inline (standalone / no commit bracket): notify data subscribers, then deliver the change
495
+ // stream — each isolated, first error re-raised once both have run (mirroring the Store's
496
+ // per-view flush isolation) so a throwing narration listener never starves the data channel,
497
+ // aborts the fold's return, or propagates raw into the engine's push path.
498
+ let firstError: unknown;
499
+ let hasError = false;
500
+ const note = (e: unknown): void => {
501
+ if (!hasError) {
502
+ hasError = true;
503
+ firstError = e;
504
+ }
505
+ };
506
+ try {
507
+ this.notify();
508
+ } catch (e) {
509
+ note(e);
510
+ }
511
+ this.deliverChanges(applied, phase, note);
512
+ if (hasError) throw firstError;
263
513
  return true;
264
514
  }
265
515
 
@@ -268,11 +518,35 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
268
518
  * touched by the same commit has folded — so a subscriber that re-reads a sibling view inside
269
519
  * its callback observes post-commit data, never a torn mid-commit state. */
270
520
  flush(): void {
271
- this.notify();
521
+ // Take the buffer BEFORE delivery so a throwing listener can never leave changes buffered to be
522
+ // re-delivered — merged and cross-phase-netted — into a LATER commit's flush. Notify and every
523
+ // segment's delivery are isolated; the first error is re-raised after all have run, so one bad
524
+ // subscriber starves neither the data channel nor a sibling segment (matching `flushCommit`).
525
+ const segs = this.pendingSegments;
526
+ this.pendingSegments = [];
527
+ let firstError: unknown;
528
+ let hasError = false;
529
+ const note = (e: unknown): void => {
530
+ if (!hasError) {
531
+ hasError = true;
532
+ firstError = e;
533
+ }
534
+ };
535
+ try {
536
+ this.notify();
537
+ } catch (e) {
538
+ note(e);
539
+ }
540
+ for (const seg of segs) this.deliverChanges(seg.changes, seg.phase, note);
541
+ if (hasError) throw firstError;
272
542
  }
273
543
 
274
544
  get data(): readonly R[] {
275
- if (this._schema === null) return this.seeded ?? (EMPTY as readonly R[]);
545
+ // A live SSR seed owns `data` until it is retired on the first live snapshot ({@link retireSeed})
546
+ // this spans pending (no schema) AND the post-`hello`, pre-snapshot window (schema set, tree still
547
+ // empty), so a seeded query never flashes empty during the handoff. `null` seed ⇒ normal behavior.
548
+ if (this.seeded !== null) return this.seeded;
549
+ if (this._schema === null) return EMPTY as readonly R[];
276
550
  if (!this.dirty && this.cached !== null) return this.cached;
277
551
  this.cached = this.top.map((n) => this.project(n, this._schema as WireSchema, this.types)) as R[];
278
552
  this.dirty = false;
@@ -287,8 +561,38 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
287
561
  };
288
562
  }
289
563
 
564
+ onChanges(listener: ViewChangeListener): () => void {
565
+ this.changeListeners.add(listener);
566
+ return () => {
567
+ this.changeListeners.delete(listener);
568
+ };
569
+ }
570
+
571
+ /** Net the folded batch and hand the survivors to the change listeners, AFTER the data `notify`
572
+ * (the order `Store.subscribeChanges` consumers already observe). A batch that nets to nothing —
573
+ * a correctly predicted rebase — makes no call, so a narrator sees only real change. Each listener
574
+ * is isolated: a throwing one reports to `note` (the caller re-raises the first) and the rest still
575
+ * run, so one bad narration template never starves a sibling listener nor corrupts the view. */
576
+ private deliverChanges(events: FlatChange[], phase: ChangePhase, note: (e: unknown) => void): void {
577
+ if (this.changeListeners.size === 0) return;
578
+ // `_schema` is non-null here: `applyChanges` early-returns while pending, and `flush` runs only
579
+ // after a fold — so a listener always has the position→name source in hand.
580
+ const schema = this._schema as WireSchema;
581
+ const net = netChanges(events, schema);
582
+ if (net.length === 0) return;
583
+ for (const l of this.changeListeners) {
584
+ try {
585
+ l(net, phase, schema);
586
+ } catch (e) {
587
+ note(e);
588
+ }
589
+ }
590
+ }
591
+
290
592
  destroy(): void {
291
593
  this.listeners.clear();
594
+ this.changeListeners.clear();
595
+ this.pendingSegments = [];
292
596
  this.top = [];
293
597
  }
294
598
 
@@ -438,7 +742,10 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
438
742
  return obj;
439
743
  }
440
744
 
441
- private notify(): void {
745
+ /** Notify data subscribers with the current {@link data}. Normally driven by a fold ({@link
746
+ * applyChanges}/{@link flush}); the Store also calls it directly to land a seed retirement whose
747
+ * accompanying fold was empty (see {@link retireSeed}). */
748
+ notify(): void {
442
749
  const d = this.data;
443
750
  for (const l of this.listeners) l(d);
444
751
  }