@rindle/client 0.2.0 → 0.3.1

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/resolve.ts ADDED
@@ -0,0 +1,136 @@
1
+ // Positional → named change resolution: the inverse of the wire encoder.
2
+ //
3
+ // A backend ships per-query `FlatChange`s (types.ts): `{ path: PathSeg[], op: add|remove|edit }`,
4
+ // where `path` indexes into the view's relationship tree and rows are POSITIONAL cells. The view
5
+ // (view.ts) folds those into the materialized tree positionally; this module instead LIFTS one
6
+ // change out of positional/indexed wire form into NAMED rows, resolving it against the query's OWN
7
+ // `WireSchema` (the per-subscription view schema the engine ships once on its `hello` frame): per
8
+ // level the column NAMES in wire order, and the relationships in SLOT order (a gating exists/notExists
9
+ // slot is `child: null`; a `countAs` slot carries a `project` annotation). So `path[i].rel` indexes
10
+ // `schema.relationships[i]` directly, and a positional `row` names against `schema.columns` — both
11
+ // authoritative, straight from the engine.
12
+ //
13
+ // Resolving against the hello schema (rather than re-deriving positions from the query `Ast`) means
14
+ // we assume NOTHING about slot ordering and NOTHING about column order: it stays correct under
15
+ // `.select()` projections and any future slot layout. The result — named rows + an aggregate's exact
16
+ // new value — is what a higher layer (e.g. @rindle/narrator) renders into prose, but it is broadly
17
+ // useful to anything consuming a `FlatChange` (logging, devtools, change-driven overlays).
18
+ //
19
+ // NOTE on aggregates (`countAs`): the slot's `project.col` names the EXACT count cell in the child
20
+ // row, so a count change reports the exact new value (and previous, on an edit) — not a best-effort
21
+ // guess. A remove of the aggregate row means the count fell to its identity (`project.identity`).
22
+
23
+ import type { FlatChange, PathSeg, WireNode, WireRel, WireSchema, WireValue } from "./types.ts";
24
+
25
+ /** A row named against its level's wire columns. */
26
+ export type NamedRow = Record<string, WireValue>;
27
+
28
+ /** One resolved change: a `FlatChange` lifted out of positional/indexed wire form into names,
29
+ * using the query's `WireSchema` (from `hello`) as the sole position→name source. */
30
+ export interface ResolvedChange {
31
+ /** Relationship-alias chain from the query root to the changed level (`[]` ⇒ the root rows). */
32
+ aliasChain: string[];
33
+ /** The alias of the changed level (`""` ⇒ root), i.e. the last of `aliasChain`. */
34
+ alias: string;
35
+ op: "add" | "remove" | "edit";
36
+ /** The affected row, named. For `edit` this is the NEW row; see `old` for the prior one. */
37
+ row: NamedRow;
38
+ /** The prior row, named (present only for `edit`). */
39
+ old?: NamedRow;
40
+ /** The PARENT row (named), for a nested/aggregate change — e.g. the `ticket_type` whose `sold`
41
+ * count moved. Taken from the path's last `parentRow`; absent for a root-level change. */
42
+ parent?: NamedRow;
43
+ /** Set when the changed level is a `countAs`/aggregate slot. The value is EXACT — read from the
44
+ * slot's projected count column (`WireRel.project.col`). */
45
+ aggregate?: { alias: string; value: WireValue; previous?: WireValue };
46
+ /** The raw node whose children a consumer can dig a named sub-row out of (via {@link subRow}). On
47
+ * an `add` the engine always ships it; on a `remove` it is present only when the consumer opted
48
+ * into the removed subtree (see the `op` mapping below). */
49
+ node?: WireNode;
50
+ /** The changed level's `WireSchema` — used by {@link subRow} to resolve a named sub of `node`. */
51
+ levelSchema: WireSchema;
52
+ }
53
+
54
+ /** Name positional `cells` against a level's wire `columns` (insertion = wire order). */
55
+ function nameRow(cells: WireValue[] | undefined, cols: string[]): NamedRow {
56
+ const out: NamedRow = {};
57
+ if (!cells) return out;
58
+ for (let i = 0; i < cols.length; i++) out[cols[i]] = cells[i] ?? null;
59
+ return out;
60
+ }
61
+
62
+ /** Walk a `path` from the root `WireSchema` down the relationship tree. Returns the reached level,
63
+ * the alias chain, and the LAST relationship traversed (its `project` marks an aggregate slot).
64
+ * `null` if a hop addresses an unknown or gating (`child: null`) slot. */
65
+ function descend(
66
+ root: WireSchema,
67
+ path: PathSeg[],
68
+ ): { level: WireSchema; lastRel: WireRel | null; aliasChain: string[] } | null {
69
+ let level = root;
70
+ let lastRel: WireRel | null = null;
71
+ const aliasChain: string[] = [];
72
+ for (const seg of path) {
73
+ const rel = level.relationships[seg.rel];
74
+ if (!rel || !rel.child) return null; // unknown / gating slot — not a materialized level
75
+ lastRel = rel;
76
+ level = rel.child;
77
+ aliasChain.push(rel.name);
78
+ }
79
+ return { level, lastRel, aliasChain };
80
+ }
81
+
82
+ /** Lift one `FlatChange` into a {@link ResolvedChange} against the query's `WireSchema`, or `null`
83
+ * if its path doesn't resolve to a materialized level. */
84
+ export function resolveChange(schema: WireSchema, change: FlatChange): ResolvedChange | null {
85
+ const here = descend(schema, change.path);
86
+ if (!here) return null;
87
+ const { level, lastRel, aliasChain } = here;
88
+ const cols = level.columns;
89
+ const alias = aliasChain.length ? aliasChain[aliasChain.length - 1] : "";
90
+ const base: Omit<ResolvedChange, "op" | "row"> = { aliasChain, alias, levelSchema: level };
91
+ // The parent row (for a nested/aggregate change) sits in the last path seg, named against the
92
+ // level one hop up.
93
+ if (change.path.length) {
94
+ const up = descend(schema, change.path.slice(0, -1));
95
+ const parentCells = change.path[change.path.length - 1].parentRow;
96
+ if (up) base.parent = nameRow(parentCells, up.level.columns);
97
+ }
98
+
99
+ // A `countAs` slot carries a scalar `project` annotation on the relationship we descended through:
100
+ // `project.col` is the exact count cell, `project.identity` the empty value (0 for count).
101
+ const proj = lastRel?.project ?? null;
102
+ const agg = (cells: WireValue[] | undefined): WireValue => (proj && cells ? (cells[proj.col] ?? null) : null);
103
+
104
+ const op = change.op;
105
+ if (op.tag === "add") {
106
+ const r: ResolvedChange = { ...base, op: "add", row: nameRow(op.node.row, cols), node: op.node };
107
+ if (proj) r.aggregate = { alias, value: agg(op.node.row) };
108
+ return r;
109
+ }
110
+ if (op.tag === "remove") {
111
+ const r: ResolvedChange = { ...base, op: "remove", row: nameRow(op.row, cols) };
112
+ // The removed subtree rides along only when the consumer opted into it (the ArrayView attaches
113
+ // it client-side — `Store.subscribeChanges(_, { removedSubtree: true })`). When present, `subRow`
114
+ // resolves a removed row's nested subs exactly as on an `add`; absent, a remove is row-only.
115
+ if (op.node) r.node = op.node;
116
+ if (proj) r.aggregate = { alias, value: proj.identity };
117
+ return r;
118
+ }
119
+ // edit
120
+ const r: ResolvedChange = { ...base, op: "edit", row: nameRow(op.new, cols), old: nameRow(op.old, cols) };
121
+ if (proj) r.aggregate = { alias, value: agg(op.new), previous: agg(op.old) };
122
+ return r;
123
+ }
124
+
125
+ /** Read a named sub-row off a change's `node` by relationship alias (e.g. the `guest` under an
126
+ * `rsvp`) — the `add` node, or a `remove`'s subtree when the consumer opted into it. The alias →
127
+ * wire slot mapping comes from the changed level's `WireSchema`. `null` when no node rode along. */
128
+ export function subRow(rc: ResolvedChange, alias: string): NamedRow | null {
129
+ if (!rc.node) return null;
130
+ const rel = rc.levelSchema.relationships.find((r) => r.name === alias);
131
+ if (!rel || !rel.child) return null;
132
+ const slot = rc.node.rels.find((s) => s.rel === rel.slot);
133
+ const child = slot?.children[0];
134
+ if (!child) return null;
135
+ return nameRow(child.row, rel.child.columns);
136
+ }
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
  /**