@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/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 {