@rindle/client 0.2.0 → 0.4.2

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.
Files changed (53) hide show
  1. package/dist/ast.d.ts +11 -0
  2. package/dist/ast.d.ts.map +1 -1
  3. package/dist/index.d.ts +6 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +4 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/mutation-ops.d.ts +140 -0
  8. package/dist/mutation-ops.d.ts.map +1 -0
  9. package/dist/mutation-ops.js +60 -0
  10. package/dist/mutation-ops.js.map +1 -0
  11. package/dist/operators.d.ts +5 -0
  12. package/dist/operators.d.ts.map +1 -1
  13. package/dist/operators.js +5 -0
  14. package/dist/operators.js.map +1 -1
  15. package/dist/query.d.ts +65 -9
  16. package/dist/query.d.ts.map +1 -1
  17. package/dist/query.js +56 -1
  18. package/dist/query.js.map +1 -1
  19. package/dist/resolve.d.ts +40 -0
  20. package/dist/resolve.d.ts.map +1 -0
  21. package/dist/resolve.js +109 -0
  22. package/dist/resolve.js.map +1 -0
  23. package/dist/schema.d.ts +53 -5
  24. package/dist/schema.d.ts.map +1 -1
  25. package/dist/schema.js +65 -4
  26. package/dist/schema.js.map +1 -1
  27. package/dist/ssr.d.ts +14 -0
  28. package/dist/ssr.d.ts.map +1 -1
  29. package/dist/ssr.js +15 -0
  30. package/dist/ssr.js.map +1 -1
  31. package/dist/store.d.ts +47 -15
  32. package/dist/store.d.ts.map +1 -1
  33. package/dist/store.js +163 -25
  34. package/dist/store.js.map +1 -1
  35. package/dist/types.d.ts +37 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js.map +1 -1
  38. package/dist/view.d.ts +43 -5
  39. package/dist/view.d.ts.map +1 -1
  40. package/dist/view.js +87 -22
  41. package/dist/view.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/ast.ts +11 -0
  44. package/src/index.ts +29 -1
  45. package/src/mutation-ops.ts +190 -0
  46. package/src/operators.ts +5 -0
  47. package/src/query.ts +142 -13
  48. package/src/resolve.ts +136 -0
  49. package/src/schema.ts +130 -5
  50. package/src/ssr.ts +21 -0
  51. package/src/store.ts +157 -34
  52. package/src/types.ts +35 -1
  53. package/src/view.ts +103 -22
package/src/store.ts CHANGED
@@ -11,7 +11,7 @@ import type { Ast } from "./ast.ts";
11
11
  import { stableKey } from "./key.ts";
12
12
  import { queries, type Query, type QueryRoot } from "./query.ts";
13
13
  import type { ColsMap, RowOf, Schema } from "./schema.ts";
14
- import type { Backend, ChangeEvent, ColType, Mutation, QueryId, RemoteQuery, ResultType, WireSchema, WireValue } from "./types.ts";
14
+ import type { Backend, ChangeEvent, ColType, FlatChange, Mutation, QueryId, RemoteQuery, ResultType, WireSchema, WireValue } from "./types.ts";
15
15
  import { type ArrayView, FlatArrayView, type SingularArrayView, SingularView, type ViewTypes } from "./view.ts";
16
16
 
17
17
  /** One query's SSR snapshot, keyed by its `viewKey` ({@link stableKey} of the AST): the
@@ -85,18 +85,6 @@ export interface StoreInspect {
85
85
  queries: QueryInspect[];
86
86
  }
87
87
 
88
- /** A dev-only passive tap over the Store's per-query streams (DEBUG-TOOLS-BROWSER-DESIGN §6.2).
89
- * Registered via {@link Store.__attachDevtools}; it ADDS a listener and never displaces the
90
- * single `backend.onEvent`/`onResultType` handlers the Store already owns, so attaching devtools
91
- * cannot perturb the app. Both hooks are optional. */
92
- export interface StoreDevObserver {
93
- /** The raw per-query {@link ChangeEvent} (hello / snapshot / batch) the Store just routed to its
94
- * view — the source of the devtools delta stream (§4.3). Fired AFTER the view has folded it. */
95
- onDelta?(qid: QueryId, ev: ChangeEvent): void;
96
- /** A per-query {@link ResultType} transition the Store just applied to its view (§4.2). */
97
- onResultType?(qid: QueryId, rt: ResultType): void;
98
- }
99
-
100
88
  export class Store<S extends ColsMap> {
101
89
  /** Type-safe query entry: `store.query.issue.where.closed(false).materialize()`. */
102
90
  readonly query: QueryRoot<S>;
@@ -111,15 +99,49 @@ export class Store<S extends ColsMap> {
111
99
  // seeded for first paint; a seed is consumed (dropped) the moment its query's first live
112
100
  // `hello` lands, so later mounts don't flash stale SSR data.
113
101
  private readonly seeds = new Map<string, DehydratedQuery>();
114
- // Dev-only devtools taps (DEBUG-TOOLS-BROWSER-DESIGN §6.2). `undefined` until the first
115
- // `__attachDevtools`, so a production app that never attaches a pane pays one undefined-check per
116
- // routed event and nothing else — there is no global singleton and no hot-path instrumentation.
117
- private devObservers?: Set<StoreDevObserver>;
102
+ // Public per-query change subscribers ({@link subscribeChanges}). `undefined` until the first
103
+ // subscription, so an app that never narrates (nor attaches devtools) pays one undefined-check per
104
+ // routed event and nothing else — no global singleton, no hot-path instrumentation. Each listener
105
+ // also receives the post-fold view for the qid (always the plural {@link ArrayView}, even for a
106
+ // `.one()` query — the Store retains the list-shaped view, not the SingularView wrapper).
107
+ private changeListeners?: Set<(qid: QueryId, ev: ChangeEvent, view?: ArrayView<unknown>) => void>;
108
+ // How many active change subscribers asked for removed subtrees ({@link subscribeChanges} opts).
109
+ // `> 0` ⇒ the view reconstructs each evicted subtree onto its `remove` op before fan-out. A plain
110
+ // counter: the cost is paid only while at least one consumer wants it, and only on real evictions.
111
+ private removedSubtreeWanted = 0;
112
+ // Public per-query {@link ResultType} subscribers ({@link subscribeResultType}). `undefined` until
113
+ // the first subscription. Devtools and any status-driven layer ride this instead of a private tap.
114
+ private resultTypeListeners?: Set<(qid: QueryId, rt: ResultType) => void>;
115
+ // Cross-view-atomic notification (the `Backend.onCommitBoundary` contract): while the backend is
116
+ // delivering one commit's coherent multi-query batch, fold every affected view but DEFER each
117
+ // view's subscriber notification, collecting the changed views here; at the commit's `end` flush
118
+ // them all. So when any view's subscriber runs, every sibling view touched by the same commit has
119
+ // already folded — a callback that re-reads another view sees post-commit data. `> 0` ⇒ inside a
120
+ // commit (defer); `0` ⇒ notify inline (the prior behavior, and what a backend with no commit
121
+ // boundary always gets). A depth counter (not a bool) is robust to any future nesting.
122
+ private commitDepth = 0;
123
+ private readonly pendingFlush = new Set<FlatArrayView>();
124
+ // The raw change-stream frames ({@link subscribeChanges}) buffered during a commit, delivered
125
+ // together at the boundary alongside the view flush — so a change listener (narrator/devtools)
126
+ // that re-reads ANY view also sees post-commit state, the same cross-view-atomic guarantee view
127
+ // subscribers get. Only filled while a commit is open AND a change listener exists; otherwise it
128
+ // stays empty and untouched (the no-narrator hot path is one undefined-check, as before).
129
+ private readonly pendingChanges: Array<[QueryId, ChangeEvent]> = [];
118
130
 
119
131
  constructor(schema: Schema<S>, backend: Backend) {
120
132
  this.schema = schema;
121
133
  this.backend = backend;
122
134
  this.backend.onEvent((qid, ev) => this.onEvent(qid, ev));
135
+ // The in-process engine brackets each commit's multi-query delivery so every affected view
136
+ // folds before any subscriber is notified (see `commitDepth`/`pendingFlush`). A backend with no
137
+ // such boundary never enters deferred mode, so its views notify inline exactly as before.
138
+ this.backend.onCommitBoundary?.((phase) => {
139
+ if (phase === "begin") {
140
+ this.commitDepth++;
141
+ } else if (this.commitDepth > 0 && --this.commitDepth === 0) {
142
+ this.flushCommit();
143
+ }
144
+ });
123
145
  // Route the backend's per-query lifecycle onto its view (`view.resultType`). Backends without a
124
146
  // lifecycle omit `onResultType`, leaving every view `complete`. A devtools tap mirrors the
125
147
  // transition (it never displaces this single handler).
@@ -131,7 +153,7 @@ export class Store<S extends ColsMap> {
131
153
  if (rt === "complete" && sync.seedKey !== undefined) this.seeds.delete(sync.seedKey);
132
154
  for (const listener of sync.listeners) listener();
133
155
  }
134
- if (this.devObservers) for (const o of this.devObservers) o.onResultType?.(qid, rt);
156
+ if (this.resultTypeListeners) for (const l of this.resultTypeListeners) l(qid, rt);
135
157
  });
136
158
  // `store.query` is the LOCAL builder (201-LOCAL-ONLY-TABLES-DESIGN.md §5): it scopes over
137
159
  // synced AND local-only tables, so a local query can join the two. Server/named queries use
@@ -341,8 +363,10 @@ export class Store<S extends ColsMap> {
341
363
  this.asts.set(qid, ast);
342
364
  // Pre-create the view (PENDING) so `materialize` is synchronous for ANY backend — a remote
343
365
  // backend's `hello` arrives async (the view reads as `[]` until then). A synchronous backend
344
- // (wasm/replica) resets it during `registerQuery` below, so it returns already-hydrated.
345
- const view = this.views.get(qid) ?? this.views.set(qid, new FlatArrayView()).get(qid)!;
366
+ // (wasm/replica) resets it during `registerQuery` below, so it returns already-hydrated. The
367
+ // view carries its `qid` (exposed as `view.qid`) so a consumer can correlate it with the raw
368
+ // change stream straight off `materialize(query).qid`.
369
+ const view = this.views.get(qid) ?? this.views.set(qid, new FlatArrayView(undefined, undefined, qid)).get(qid)!;
346
370
  // Wire teardown: `destroy()` must unregister the query from the backend and drop our routing
347
371
  // entry. Otherwise the engine keeps emitting events for the destroyed query and the Store
348
372
  // routes them to the now-empty view — which throws on the next Child/remove ("parent not
@@ -397,31 +421,130 @@ export class Store<S extends ColsMap> {
397
421
  const types = ast ? this.viewTypes(ev.schema, ast) : undefined;
398
422
  // Reset the (pre-created or existing) view IN PLACE — first hello OR a re-hydrate (new
399
423
  // epoch) — so the materialized reference the caller holds survives a re-subscribe.
400
- const view = this.views.get(qid) ?? this.views.set(qid, new FlatArrayView()).get(qid)!;
424
+ const view = this.views.get(qid) ?? this.views.set(qid, new FlatArrayView(undefined, undefined, qid)).get(qid)!;
401
425
  view.reset(ev.schema, types);
402
426
  } else if (ev.type === "snapshot") {
403
- this.views.get(qid)?.applyChanges(ev.adds);
427
+ this.applyAndTrack(qid, ev.adds);
404
428
  } else {
405
- this.views.get(qid)?.applyChanges(ev.events);
429
+ this.applyAndTrack(qid, ev.events);
430
+ }
431
+ // Fan the same post-fold frame out to subscribers (narration, devtools, …). Inside a commit
432
+ // bracket the frames are BUFFERED and delivered together at the boundary (after every view has
433
+ // folded), so a listener re-reading ANY view sees post-commit state — the same cross-view-atomic
434
+ // guarantee view subscribers get; outside one they go inline (still after this view's own fold,
435
+ // so the post-fold view passed here — and any re-read of it — reflects the post-apply state, and
436
+ // an opted-in removed subtree the view just attached rides along on the `remove` op).
437
+ // No-op (one undefined-check) when nothing is subscribed.
438
+ if (this.changeListeners) {
439
+ if (this.commitDepth > 0) this.pendingChanges.push([qid, ev]);
440
+ else {
441
+ const view = this.views.get(qid);
442
+ for (const l of this.changeListeners) l(qid, ev, view);
443
+ }
406
444
  }
407
- // Mirror the raw delta to any devtools tap, AFTER the view has folded it (so a pane re-reading
408
- // `__inspect` sees the post-apply state). No-op (one undefined-check) when no pane is attached.
409
- if (this.devObservers) for (const o of this.devObservers) o.onDelta?.(qid, ev);
410
445
  }
411
446
 
412
- // --- dev-only introspection (DEBUG-TOOLS-BROWSER-DESIGN §2/§6.2) --------------
413
- // A single read-only accessor + a passive tap per the design's "surface, not instrument" rule.
414
- // Only ever called by `@rindle/devtools` (imported in dev); inert otherwise.
447
+ /** Fold a batch into its view, then notify now or — inside a commit bracket — defer the view's
448
+ * notification to the commit boundary, so all sibling views fold first (cross-view-atomic
449
+ * notification; see `commitDepth`). */
450
+ private applyAndTrack(qid: QueryId, events: FlatChange[]): void {
451
+ const view = this.views.get(qid);
452
+ if (!view) return;
453
+ const deferring = this.commitDepth > 0;
454
+ if (view.applyChanges(events, this.removedSubtreeWanted > 0, deferring) && deferring) {
455
+ this.pendingFlush.add(view);
456
+ }
457
+ }
458
+
459
+ /** Deliver everything deferred during the just-ended commit, after every affected view has folded
460
+ * (cross-view-atomic notification): view subscribers first, then the raw change stream
461
+ * (narrators/devtools), each frame in arrival order. A throwing listener does not stop the others
462
+ * — the first error is re-raised only once the whole flush completes (mirroring the backend's
463
+ * per-query isolation). View subscribers run before change listeners, preserving the per-event
464
+ * order that held before coalescing (a view's subscribers fired before its change frame). */
465
+ private flushCommit(): void {
466
+ const views = this.pendingFlush.size > 0 ? [...this.pendingFlush] : [];
467
+ if (views.length) this.pendingFlush.clear();
468
+ const changes = this.pendingChanges.length > 0 ? this.pendingChanges.splice(0) : [];
469
+ if (views.length === 0 && changes.length === 0) return;
470
+ let firstError: unknown;
471
+ let hasError = false;
472
+ const note = (e: unknown): void => {
473
+ if (!hasError) {
474
+ hasError = true;
475
+ firstError = e;
476
+ }
477
+ };
478
+ for (const view of views) {
479
+ try {
480
+ view.flush();
481
+ } catch (e) {
482
+ note(e);
483
+ }
484
+ }
485
+ if (this.changeListeners) {
486
+ for (const [qid, ev] of changes) {
487
+ const view = this.views.get(qid);
488
+ for (const l of this.changeListeners) {
489
+ try {
490
+ l(qid, ev, view);
491
+ } catch (e) {
492
+ note(e);
493
+ }
494
+ }
495
+ }
496
+ }
497
+ if (hasError) throw firstError;
498
+ }
415
499
 
416
- /** Register a devtools {@link StoreDevObserver}; returns a detach function. Multiple panes may
417
- * attach. The observer is ADDITIVE it does not touch the backend's single handler slots. */
418
- __attachDevtools(observer: StoreDevObserver): () => void {
419
- (this.devObservers ??= new Set()).add(observer);
500
+ /** Subscribe to the raw per-query {@link ChangeEvent} stream (hello / snapshot / batch) the Store
501
+ * routes to its views, tagged by `qid`. Fired AFTER the event is folded and, for a commit that
502
+ * fans out to several queries (the in-process engine's `onCommitBoundary`), after EVERY view in
503
+ * that commit has folded — so a listener that re-reads ANY view (its own or a sibling) sees
504
+ * post-commit state, never a torn mid-commit one. Frames keep their arrival (engine-dispatch)
505
+ * order, one per affected query. This is the supported way to drive change-derived layers
506
+ * (e.g. {@link resolveChange} → @rindle/narrator, or a devtools pane) off a live store — attach
507
+ * BEFORE `materialize` to catch a synchronous backend's first `hello`+`snapshot`.
508
+ * The per-query view `WireSchema` rides the `hello` frame (also readable via `view.schema`).
509
+ *
510
+ * The third listener arg is the post-fold {@link ArrayView} for this `qid` (so a template wanting
511
+ * list context — current `data`, `schema`, `resultType` — needn't look it up). It is ALWAYS the
512
+ * plural view, even for a top-level `.one()` query: the Store retains the list-shaped view, not the
513
+ * SingularView wrapper handed back from `materialize`. `undefined` only if the view is mid-teardown.
514
+ *
515
+ * `opts.removedSubtree` enriches every `remove` op on this stream with the full removed subtree
516
+ * ({@link FlatOp.node}), so a consumer can resolve a removed row's nested subs exactly as on an
517
+ * `add` (a bare remove carries only the leaving row). It is reconstructed client-side from the
518
+ * view — no wire/engine cost — and paid only on real evictions while at least one subscriber asks.
519
+ *
520
+ * Returns a detach function; multiple listeners may attach. */
521
+ subscribeChanges(
522
+ listener: (qid: QueryId, ev: ChangeEvent, view?: ArrayView<unknown>) => void,
523
+ opts?: { removedSubtree?: boolean },
524
+ ): () => void {
525
+ (this.changeListeners ??= new Set()).add(listener);
526
+ if (opts?.removedSubtree) this.removedSubtreeWanted++;
420
527
  return () => {
421
- this.devObservers?.delete(observer);
528
+ if (this.changeListeners?.delete(listener) && opts?.removedSubtree) this.removedSubtreeWanted--;
422
529
  };
423
530
  }
424
531
 
532
+ /** Subscribe to per-query {@link ResultType} transitions (the server-channel lifecycle the backend
533
+ * pushes — `unknown` → `complete`, etc.), tagged by `qid`. Fired only on a CHANGE (never replayed
534
+ * on attach; read `view.resultType` for the current value). The supported seam for a status-driven
535
+ * layer (a devtools pane). Returns a detach function; multiple listeners may attach. */
536
+ subscribeResultType(listener: (qid: QueryId, rt: ResultType) => void): () => void {
537
+ (this.resultTypeListeners ??= new Set()).add(listener);
538
+ return () => {
539
+ this.resultTypeListeners?.delete(listener);
540
+ };
541
+ }
542
+
543
+ // --- dev-only introspection (DEBUG-TOOLS-BROWSER-DESIGN §2/§6.2) --------------
544
+ // A single read-only accessor per the design's "surface, not instrument" rule. Only ever called
545
+ // by `@rindle/devtools` (imported in dev); inert otherwise. The delta + resultType taps devtools
546
+ // also needs are the SUPPORTED `subscribeChanges` / `subscribeResultType` seams above.
547
+
425
548
  /** A read-only snapshot of every live materialized view (DEBUG-TOOLS-BROWSER-DESIGN §4.2): its
426
549
  * qid, AST, {@link ResultType}, row count, and a capped row sample. Built fresh on each call from
427
550
  * the live `views`/`asts` maps — never cached, never mutating. `sampleRows` caps the per-query
package/src/types.ts CHANGED
@@ -52,9 +52,14 @@ export interface PathSeg {
52
52
  parentRow: WireValue[];
53
53
  }
54
54
 
55
+ /** A positional change at one level of the view tree. A `remove` ships only the leaving `row`
56
+ * (the receiver already holds the subtree and locates it by key) — `node` is NEVER on the wire;
57
+ * it is an OPTIONAL client-side enrichment carrying the full removed subtree, attached by the
58
+ * ArrayView when a change consumer opts in (`Store.subscribeChanges(_, { removedSubtree: true })`),
59
+ * so a narrator can resolve a removed row's nested subs just as it can on an `add`. */
55
60
  export type FlatOp =
56
61
  | { tag: "add"; node: WireNode }
57
- | { tag: "remove"; row: WireValue[] }
62
+ | { tag: "remove"; row: WireValue[]; node?: WireNode }
58
63
  | { tag: "edit"; old: WireValue[]; new: WireValue[] };
59
64
 
60
65
  export interface FlatChange {
@@ -113,6 +118,20 @@ export type NormalizedEvent =
113
118
 
114
119
  export type QueryId = number;
115
120
 
121
+ /** A dev-only authoritative server delta exposed by backends that can distinguish the upstream
122
+ * server stream from their local view/IVM stream. Flat remote backends surface clean
123
+ * {@link ChangeEvent}s; local-first normalized/optimistic backends surface the path-free
124
+ * {@link NormalizedEvent}s before they are folded into the local engine. */
125
+ export type BackendServerDelta =
126
+ | { format: "flat"; event: ChangeEvent }
127
+ | { format: "normalized"; event: NormalizedEvent };
128
+
129
+ /** Dev-only passive tap over a backend's authoritative server stream. Optional because in-process
130
+ * backends have no server stream, and older/custom backends may only expose the Store tap. */
131
+ export interface BackendDevObserver {
132
+ onServerDelta?(qid: QueryId, ev: BackendServerDelta): void;
133
+ }
134
+
116
135
  /** A query's SERVER-CHANNEL state, surfaced on its {@link ArrayView} (FOLDED-MUTATIONS-DESIGN §7 —
117
136
  * formerly conflated with pending-ness, OPTIMISTIC-WRITES-DESIGN.md §6):
118
137
  * - `unknown` — not hydrated: the server has not produced a first result for this query yet;
@@ -161,6 +180,21 @@ export interface Backend {
161
180
  * each to the matching view (so `view.resultType` tracks it). Backends with no lifecycle (the
162
181
  * in-process engine) omit it and every view stays `complete`. */
163
182
  onResultType?(handler: (queryId: QueryId, resultType: ResultType) => void): void;
183
+ /** Optional: brackets one commit's coherent multi-query delivery so the Store can fold every
184
+ * affected view BEFORE notifying any subscriber — cross-view-atomic notification. The
185
+ * in-process engine derives the whole commit's per-query deltas against one consistent post-
186
+ * commit snapshot, then dispatches them one query at a time; without a barrier a subscriber on
187
+ * the first-dispatched view, re-reading a sibling view in its callback, would observe that
188
+ * sibling's PRE-commit state (it has not folded yet). The backend calls the handler with
189
+ * `"begin"` before delivering a commit's per-query `batch` events (via {@link onEvent}) and
190
+ * `"end"` after the last one; between them the Store folds each view but DEFERS its
191
+ * notification, firing every affected view's subscribers together at `"end"`. Backends with no
192
+ * synchronous multi-query commit boundary (a plain remote backend, where each query's frame
193
+ * arrives in its own message) omit it — the Store then notifies per event, exactly as before. */
194
+ onCommitBoundary?(handler: (phase: "begin" | "end") => void): void;
195
+ /** Optional dev-only authoritative server stream tap. It is additive, like
196
+ * `Store.subscribeChanges`, and must not displace the backend's normal event handler. */
197
+ __attachDevtoolsServerDeltas?(observer: BackendDevObserver): () => void;
164
198
  }
165
199
 
166
200
  /**
package/src/view.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // and untouched subtrees keep their object identity (React/Solid memoize on it).
10
10
 
11
11
  import { compareRows } from "./compare.ts";
12
- import type { ColType, FlatChange, FlatOp, ResultType, WireNode, WireSchema, WireValue } from "./types.ts";
12
+ import type { ColType, FlatChange, FlatOp, QueryId, ResultType, WireNode, WireSchema, WireValue } from "./types.ts";
13
13
 
14
14
  /** Per-level column types (parallel to the WireSchema), used to JSON.parse json columns on
15
15
  * projection. Built by the Store from the typed schema; absent ⇒ no parsing (bare values). */
@@ -22,6 +22,15 @@ export interface ViewTypes {
22
22
  export interface ArrayView<R> {
23
23
  /** The current materialized result (reference-stable where data is unchanged). */
24
24
  readonly data: readonly R[];
25
+ /** The engine query id the Store assigned this view (1:1 with the view). The same `qid` the raw
26
+ * change stream ({@link Store.subscribeChanges}) tags each frame with, so a consumer can
27
+ * correlate this query with its `ChangeEvent`s (e.g. to bind a narrator) straight off
28
+ * `materialize(query).qid` — no separate handle needed. */
29
+ readonly qid: QueryId;
30
+ /** The query's view `WireSchema` (the position→name source for {@link resolveChange}), captured
31
+ * from its `hello` frame. `null` while PENDING — a remote backend's `hello` arrives async; an
32
+ * in-process backend (wasm/replica) populates it synchronously during `materialize`. */
33
+ readonly schema: WireSchema | null;
25
34
  /** The query's SERVER-CHANNEL state (`unknown` while loading, `complete` once server-authoritative;
26
35
  * the `error` variant is reserved and currently unproduced). A pending optimistic mutation no
27
36
  * longer moves this — it is a separate axis (FOLDED-MUTATIONS-DESIGN §7). `complete` for backends
@@ -40,6 +49,10 @@ export interface ArrayView<R> {
40
49
  export interface SingularArrayView<R> {
41
50
  /** The single current row, or `null` when the query matches nothing. */
42
51
  readonly data: R | null;
52
+ /** The engine query id — see {@link ArrayView.qid}. */
53
+ readonly qid: QueryId;
54
+ /** The query's view `WireSchema` — see {@link ArrayView.schema}. */
55
+ readonly schema: WireSchema | null;
43
56
  /** The query's lifecycle state — see {@link ArrayView.resultType}. */
44
57
  readonly resultType: ResultType;
45
58
  /** Subscribe; fires immediately with the current row, then after each applied batch (and after
@@ -62,6 +75,14 @@ export class SingularView<R> implements SingularArrayView<R> {
62
75
  return this.inner.data[0] ?? null;
63
76
  }
64
77
 
78
+ get qid(): QueryId {
79
+ return this.inner.qid;
80
+ }
81
+
82
+ get schema(): WireSchema | null {
83
+ return this.inner.schema;
84
+ }
85
+
65
86
  get resultType(): ResultType {
66
87
  return this.inner.resultType;
67
88
  }
@@ -124,6 +145,22 @@ function cloneNode(n: Node): Node {
124
145
  return { row: n.row.slice(), rc: n.rc, rels: n.rels.map((r) => r.map(cloneNode)), out: null };
125
146
  }
126
147
 
148
+ /** Reconstruct a positional {@link WireNode} (row + its populated child slots) from a maintained
149
+ * {@link Node} — the inverse of {@link buildNode}. Used to attach a removed subtree to a `remove`
150
+ * op so a change consumer (a narrator) can resolve its nested subs, which a bare positional remove
151
+ * (just the leaving row) cannot carry. Only in-view (`child !== null`), non-empty slots are emitted,
152
+ * matching how an `add` node ships only populated relationships. */
153
+ function toWireNode(node: Node, schema: WireSchema): WireNode {
154
+ const rels: { rel: number; children: WireNode[] }[] = [];
155
+ for (const rel of schema.relationships) {
156
+ if (rel.child === null) continue;
157
+ const childList = node.rels[rel.slot] ?? [];
158
+ if (childList.length === 0) continue;
159
+ rels.push({ rel: rel.slot, children: childList.map((c) => toWireNode(c, rel.child as WireSchema)) });
160
+ }
161
+ return { row: node.row, rels };
162
+ }
163
+
127
164
  function rowsEqual(a: WireValue[], b: WireValue[]): boolean {
128
165
  return a.length === b.length && a.every((v, i) => Object.is(v, b[i]));
129
166
  }
@@ -131,9 +168,13 @@ function rowsEqual(a: WireValue[], b: WireValue[]): boolean {
131
168
  const EMPTY: readonly never[] = Object.freeze([]);
132
169
 
133
170
  export class FlatArrayView<R = unknown> implements ArrayView<R> {
171
+ // The engine query id (Store-assigned, 1:1 with this view), exposed read-only via {@link qid}.
172
+ // `0` ⇒ unbound (a bare view never registered with a Store); the Store passes it at construction.
173
+ private readonly _qid: QueryId;
134
174
  // `null` ⇒ PENDING (no schema yet): a remote backend's `hello` arrives async, so the view
135
175
  // exists (and reads as `[]`) before its schema lands. Set by `reset` (first hello / re-hydrate).
136
- private schema: WireSchema | null;
176
+ // Exposed read-only via the {@link schema} getter (the position→name source for `resolveChange`).
177
+ private _schema: WireSchema | null;
137
178
  private types?: ViewTypes;
138
179
  // SSR first-paint seed (SSR-DESIGN.md §6): pre-projected rows installed by `seed`, returned by
139
180
  // `data` WHILE pending (no live schema yet). Cleared on `reset` — the first live `hello` —
@@ -148,9 +189,18 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
148
189
  private rt: ResultType = "complete";
149
190
  private readonly listeners = new Set<(data: readonly R[]) => void>();
150
191
 
151
- constructor(schema?: WireSchema, types?: ViewTypes) {
152
- this.schema = schema ?? null;
192
+ constructor(schema?: WireSchema, types?: ViewTypes, qid: QueryId = 0) {
193
+ this._schema = schema ?? null;
153
194
  this.types = types;
195
+ this._qid = qid;
196
+ }
197
+
198
+ get qid(): QueryId {
199
+ return this._qid;
200
+ }
201
+
202
+ get schema(): WireSchema | null {
203
+ return this._schema;
154
204
  }
155
205
 
156
206
  get resultType(): ResultType {
@@ -171,7 +221,7 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
171
221
  * the caller's view reference and its subscribers survive a re-subscribe. Does NOT notify —
172
222
  * the snapshot that follows (`applyChanges`) does, avoiding an empty-then-filled flicker. */
173
223
  reset(schema: WireSchema, types?: ViewTypes): void {
174
- this.schema = schema;
224
+ this._schema = schema;
175
225
  this.types = types;
176
226
  this.seeded = null; // live data takes over; the seed was first-paint only
177
227
  this.top = [];
@@ -190,20 +240,41 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
190
240
 
191
241
  /** Apply a batch (the hydrate snapshot or one transaction's events) in order, then
192
242
  * notify subscribers once. Order is significant (FLAT-CHANGES-DESIGN.md §5.4). A no-op
193
- * while pending (changes never precede the `hello` that resets the schema). */
194
- applyChanges(events: FlatChange[]): void {
195
- if (this.schema === null) return;
243
+ * while pending (changes never precede the `hello` that resets the schema).
244
+ *
245
+ * `enrichRemoves` before a removed node is dropped, reconstruct its full subtree and attach it
246
+ * to the `remove` op's `node` (in place, so the same event object the Store fans out to its
247
+ * 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).
249
+ *
250
+ * `deferNotify` ⇒ fold but do NOT notify; the caller is responsible for calling {@link flush}
251
+ * later. The Store uses this to fold every view in one commit before notifying any subscriber
252
+ * (cross-view-atomic notification — `Store.onCommitBoundary`); standalone use leaves it off, so
253
+ * a bare view still fires its subscribers after each applied batch.
254
+ *
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 {
257
+ if (this._schema === null) return false;
196
258
  let changed = false;
197
- for (const e of events) changed = this.applyAt(this.top, this.schema, e.path, e.op, 0) || changed;
198
- if (!changed) return;
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;
199
261
  this.dirty = true;
262
+ if (!deferNotify) this.notify();
263
+ return true;
264
+ }
265
+
266
+ /** Notify subscribers with the current data. The deferred half of {@link applyChanges} (when
267
+ * `deferNotify` was set): the Store calls this at the commit-notify barrier — after every view
268
+ * touched by the same commit has folded — so a subscriber that re-reads a sibling view inside
269
+ * its callback observes post-commit data, never a torn mid-commit state. */
270
+ flush(): void {
200
271
  this.notify();
201
272
  }
202
273
 
203
274
  get data(): readonly R[] {
204
- if (this.schema === null) return this.seeded ?? (EMPTY as readonly R[]);
275
+ if (this._schema === null) return this.seeded ?? (EMPTY as readonly R[]);
205
276
  if (!this.dirty && this.cached !== null) return this.cached;
206
- this.cached = this.top.map((n) => this.project(n, this.schema as WireSchema, this.types)) as R[];
277
+ this.cached = this.top.map((n) => this.project(n, this._schema as WireSchema, this.types)) as R[];
207
278
  this.dirty = false;
208
279
  return this.cached;
209
280
  }
@@ -223,9 +294,9 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
223
294
 
224
295
  // --- apply -------------------------------------------------------------------
225
296
 
226
- private applyAt(list: Node[], schema: WireSchema, path: FlatChange["path"], op: FlatOp, depth: number): boolean {
297
+ private applyAt(list: Node[], schema: WireSchema, path: FlatChange["path"], op: FlatOp, depth: number, enrichRemoves: boolean): boolean {
227
298
  if (depth === path.length) {
228
- return this.applyOp(list, schema, op);
299
+ return this.applyOp(list, schema, op, enrichRemoves);
229
300
  }
230
301
  const seg = path[depth];
231
302
  const child = schema.relationships[seg.rel]?.child;
@@ -233,14 +304,20 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
233
304
  const { found, index } = binarySearch(list, seg.parentRow, schema.sort);
234
305
  if (!found) throw new Error("flat ArrayView: parent not found at path hop (inconsistent stream)");
235
306
  const node = list[index];
236
- const changed = this.applyAt(node.rels[seg.rel], child, path, op, depth + 1);
307
+ const changed = this.applyAt(node.rels[seg.rel], child, path, op, depth + 1, enrichRemoves);
237
308
  if (changed) node.out = null; // a descendant changed → this node must re-project its rel array
238
309
  return changed;
239
310
  }
240
311
 
241
- private applyOp(list: Node[], schema: WireSchema, op: FlatOp): boolean {
312
+ private applyOp(list: Node[], schema: WireSchema, op: FlatOp, enrichRemoves: boolean): boolean {
242
313
  if (op.tag === "add") return this.applyAdd(list, schema, op.node);
243
- if (op.tag === "remove") return this.applyRemove(list, schema, op.row);
314
+ if (op.tag === "remove") {
315
+ const removed = this.applyRemove(list, schema, op.row);
316
+ // Attach the full removed subtree (in place) for an opted-in consumer; absent when the row
317
+ // only lost a reference (rc > 1) and so did not actually leave the result.
318
+ if (removed && enrichRemoves) op.node = toWireNode(removed, schema);
319
+ return removed !== null;
320
+ }
244
321
  return this.applyEdit(list, schema, op.old, op.new);
245
322
  }
246
323
 
@@ -254,15 +331,19 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
254
331
  return true;
255
332
  }
256
333
 
257
- private applyRemove(list: Node[], schema: WireSchema, row: WireValue[]): boolean {
334
+ /** Drop one path to `row`. Returns the evicted {@link Node} (its full subtree intact) when the
335
+ * last path went away — `null` when another path still holds it (rc decremented, nothing left
336
+ * the result). */
337
+ private applyRemove(list: Node[], schema: WireSchema, row: WireValue[]): Node | null {
258
338
  const at = binarySearch(list, row, schema.sort);
259
339
  if (!at.found) throw new Error("flat ArrayView: remove of a non-existent node");
260
- if (list[at.index].rc === 1) {
340
+ const node = list[at.index];
341
+ if (node.rc === 1) {
261
342
  list.splice(at.index, 1);
262
- return true;
343
+ return node;
263
344
  }
264
- list[at.index].rc -= 1;
265
- return false;
345
+ node.rc -= 1;
346
+ return null;
266
347
  }
267
348
 
268
349
  private applyEdit(list: Node[], schema: WireSchema, oldRow: WireValue[], newRow: WireValue[]): boolean {