@rindle/client 0.4.2 → 0.4.4
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/README.md +31 -161
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/mutation-ops.d.ts +73 -13
- package/dist/mutation-ops.d.ts.map +1 -1
- package/dist/mutation-ops.js +22 -3
- package/dist/mutation-ops.js.map +1 -1
- package/dist/schema.d.ts +130 -22
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +61 -6
- package/dist/schema.js.map +1 -1
- package/dist/store.d.ts +17 -6
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +34 -14
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +8 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/view.d.ts +56 -7
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +319 -18
- package/dist/view.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +6 -1
- package/src/mutation-ops.ts +98 -24
- package/src/schema.ts +189 -37
- package/src/store.ts +43 -16
- package/src/types.ts +13 -3
- package/src/view.ts +316 -17
package/src/store.ts
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
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
|
-
import type { ColsMap, RowOf, Schema } from "./schema.ts";
|
|
13
|
+
import type { ColsMap, InsertOf, RowOf, Schema } from "./schema.ts";
|
|
14
14
|
import type { Backend, ChangeEvent, ColType, FlatChange, Mutation, QueryId, RemoteQuery, ResultType, WireSchema, WireValue } from "./types.ts";
|
|
15
|
-
import { type ArrayView, FlatArrayView, type SingularArrayView, SingularView, type ViewTypes } from "./view.ts";
|
|
15
|
+
import { type ArrayView, type ChangePhase, FlatArrayView, type SingularArrayView, SingularView, type ViewChangeListener, type ViewTypes } from "./view.ts";
|
|
16
16
|
|
|
17
17
|
/** One query's SSR snapshot, keyed by its `viewKey` ({@link stableKey} of the AST): the
|
|
18
18
|
* pre-projected first-paint `rows` plus the `cvMin` watermark they reflect (SSR-DESIGN.md §6.2).
|
|
@@ -36,9 +36,13 @@ export interface AssembledNode {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/** The write transaction handed to `store.write(tx => …)`. Rows are objects keyed by column;
|
|
39
|
-
* the Store positionalizes them (and stringifies json columns) before the backend sees them.
|
|
39
|
+
* the Store positionalizes them (and stringifies json columns) before the backend sees them.
|
|
40
|
+
*
|
|
41
|
+
* `add` takes an {@link InsertOf} row — a nullable column may be omitted (it is filled with `null`,
|
|
42
|
+
* design 206 §7). `remove`/`edit` take a full {@link RowOf} row: they identify an EXISTING row, so
|
|
43
|
+
* every column (nullable ones as their actual `T | null` value) must be present. */
|
|
40
44
|
export interface WriteTx<S extends ColsMap> {
|
|
41
|
-
add<N extends keyof S & string>(table: N, row:
|
|
45
|
+
add<N extends keyof S & string>(table: N, row: InsertOf<S[N]>): void;
|
|
42
46
|
remove<N extends keyof S & string>(table: N, row: RowOf<S[N]>): void;
|
|
43
47
|
edit<N extends keyof S & string>(table: N, oldRow: RowOf<S[N]>, newRow: RowOf<S[N]>): void;
|
|
44
48
|
}
|
|
@@ -97,7 +101,9 @@ export class Store<S extends ColsMap> {
|
|
|
97
101
|
private readonly syncLeases = new Map<QueryId, SyncLeaseState>();
|
|
98
102
|
// SSR seeds (SSR-DESIGN.md §6), keyed by `viewKey`: a view materialized for one of these is
|
|
99
103
|
// seeded for first paint; a seed is consumed (dropped) the moment its query's first live
|
|
100
|
-
// `hello`
|
|
104
|
+
// SNAPSHOT lands — NOT its `hello` — so the seed bridges the `hello`→snapshot gap (the live
|
|
105
|
+
// data arrives on the snapshot, a round-trip after the hello) and later mounts of a now-live
|
|
106
|
+
// query don't re-seed stale SSR data.
|
|
101
107
|
private readonly seeds = new Map<string, DehydratedQuery>();
|
|
102
108
|
// Public per-query change subscribers ({@link subscribeChanges}). `undefined` until the first
|
|
103
109
|
// subscription, so an app that never narrates (nor attaches devtools) pays one undefined-check per
|
|
@@ -162,10 +168,18 @@ export class Store<S extends ColsMap> {
|
|
|
162
168
|
}
|
|
163
169
|
|
|
164
170
|
/** Materialize any fluent query object. Named queries subscribe remotely by `(name,args)`;
|
|
165
|
-
* ad-hoc builder queries are local-only for local-first backends.
|
|
166
|
-
|
|
171
|
+
* ad-hoc builder queries are local-only for local-first backends.
|
|
172
|
+
*
|
|
173
|
+
* `opts.onChanges` binds a narrator to this view's DIFF stream ({@link ArrayView.onChanges}) — the
|
|
174
|
+
* per-view seam that replaces filtering the store-global {@link subscribeChanges} by `qid`. It is
|
|
175
|
+
* wired BEFORE the backend registers the query, so a synchronous backend's first `snapshot` (fired
|
|
176
|
+
* inside `registerQuery`, before this returns) is delivered too. */
|
|
177
|
+
materialize<Q extends Query<any, any, any>>(
|
|
178
|
+
query: Q,
|
|
179
|
+
opts?: { onChanges?: ViewChangeListener },
|
|
180
|
+
): ReturnType<Q["materialize"]> {
|
|
167
181
|
const remote = typeof query.name === "string" ? { name: query.name, args: query.args } : undefined;
|
|
168
|
-
return this.registerMaterialized(query.ast(), remote).view as ReturnType<Q["materialize"]>;
|
|
182
|
+
return this.registerMaterialized(query.ast(), remote, opts?.onChanges).view as ReturnType<Q["materialize"]>;
|
|
169
183
|
}
|
|
170
184
|
|
|
171
185
|
/** True when the backend can retain a remote named query independently from the local
|
|
@@ -358,6 +372,7 @@ export class Store<S extends ColsMap> {
|
|
|
358
372
|
private registerMaterialized(
|
|
359
373
|
ast: Ast,
|
|
360
374
|
remote?: RemoteQuery,
|
|
375
|
+
onChanges?: ViewChangeListener,
|
|
361
376
|
): { qid: QueryId; view: ArrayView<unknown> | SingularArrayView<unknown> } {
|
|
362
377
|
const qid: QueryId = this.nextId++;
|
|
363
378
|
this.asts.set(qid, ast);
|
|
@@ -380,6 +395,9 @@ export class Store<S extends ColsMap> {
|
|
|
380
395
|
}
|
|
381
396
|
baseDestroy();
|
|
382
397
|
};
|
|
398
|
+
// Bind the narrator BEFORE `registerQuery` fires (a synchronous backend dispatches this query's
|
|
399
|
+
// first `hello`+`snapshot` inside it) so the initial snapshot reaches the change listener too.
|
|
400
|
+
if (onChanges) view.onChanges(onChanges);
|
|
383
401
|
// If the backend rejects the registration (E3: a remote query naming a local-only table), roll
|
|
384
402
|
// back the per-qid state we just created — otherwise the view + ast entry leak (the caller never
|
|
385
403
|
// gets a handle to `destroy()` them, since the throw aborts before we return).
|
|
@@ -415,18 +433,27 @@ export class Store<S extends ColsMap> {
|
|
|
415
433
|
private onEvent(qid: QueryId, ev: ChangeEvent): void {
|
|
416
434
|
if (ev.type === "hello") {
|
|
417
435
|
const ast = this.asts.get(qid);
|
|
418
|
-
// First live hello for this view ⇒ retire its SSR seed: the maintained tree now owns the
|
|
419
|
-
// result, so no future mount of the same query should flash the stale first-paint rows.
|
|
420
|
-
if (ast) this.seeds.delete(stableKey(ast));
|
|
421
436
|
const types = ast ? this.viewTypes(ev.schema, ast) : undefined;
|
|
422
437
|
// Reset the (pre-created or existing) view IN PLACE — first hello OR a re-hydrate (new
|
|
423
|
-
// epoch) — so the materialized reference the caller holds survives a re-subscribe.
|
|
438
|
+
// epoch) — so the materialized reference the caller holds survives a re-subscribe. The SSR
|
|
439
|
+
// seed is KEPT across the reset and retired on the first `snapshot` below (the live data
|
|
440
|
+
// lands then, not on the hello), so a seeded query bridges the gap instead of flashing empty.
|
|
424
441
|
const view = this.views.get(qid) ?? this.views.set(qid, new FlatArrayView(undefined, undefined, qid)).get(qid)!;
|
|
425
442
|
view.reset(ev.schema, types);
|
|
426
443
|
} else if (ev.type === "snapshot") {
|
|
427
|
-
|
|
444
|
+
// The first live snapshot IS the query's hydration point (even an empty one — 0 rows is an
|
|
445
|
+
// authoritative answer): retire the SSR seed now, from the view (so `data` switches to the
|
|
446
|
+
// maintained tree) AND from the seeds map (so no later mount re-seeds a now-live query),
|
|
447
|
+
// BEFORE folding so the fold's notify already reflects the live tree with no empty gap.
|
|
448
|
+
const ast = this.asts.get(qid);
|
|
449
|
+
if (ast) this.seeds.delete(stableKey(ast));
|
|
450
|
+
this.views.get(qid)?.retireSeed();
|
|
451
|
+
this.applyAndTrack(qid, ev.adds, "snapshot");
|
|
428
452
|
} else {
|
|
429
|
-
|
|
453
|
+
// A `catchUp` batch is a query's initial hydration delivered as a delta (the optimistic
|
|
454
|
+
// backend hydrates through its reconcile cycle), so phase it as a `snapshot` — a narrator's
|
|
455
|
+
// "what CHANGED" default then ignores the initial rows instead of narrating each as an add.
|
|
456
|
+
this.applyAndTrack(qid, ev.events, ev.catchUp ? "snapshot" : "batch");
|
|
430
457
|
}
|
|
431
458
|
// Fan the same post-fold frame out to subscribers (narration, devtools, …). Inside a commit
|
|
432
459
|
// bracket the frames are BUFFERED and delivered together at the boundary (after every view has
|
|
@@ -447,11 +474,11 @@ export class Store<S extends ColsMap> {
|
|
|
447
474
|
/** Fold a batch into its view, then notify now or — inside a commit bracket — defer the view's
|
|
448
475
|
* notification to the commit boundary, so all sibling views fold first (cross-view-atomic
|
|
449
476
|
* notification; see `commitDepth`). */
|
|
450
|
-
private applyAndTrack(qid: QueryId, events: FlatChange[]): void {
|
|
477
|
+
private applyAndTrack(qid: QueryId, events: FlatChange[], phase: ChangePhase): void {
|
|
451
478
|
const view = this.views.get(qid);
|
|
452
479
|
if (!view) return;
|
|
453
480
|
const deferring = this.commitDepth > 0;
|
|
454
|
-
if (view.applyChanges(events, this.removedSubtreeWanted > 0, deferring) && deferring) {
|
|
481
|
+
if (view.applyChanges(events, this.removedSubtreeWanted > 0, deferring, phase) && deferring) {
|
|
455
482
|
this.pendingFlush.add(view);
|
|
456
483
|
}
|
|
457
484
|
}
|
package/src/types.ts
CHANGED
|
@@ -75,7 +75,12 @@ export interface FlatChange {
|
|
|
75
75
|
export type ChangeEvent =
|
|
76
76
|
| { type: "hello"; schema: WireSchema; comparatorVersion: number }
|
|
77
77
|
| { type: "snapshot"; adds: FlatChange[]; last: boolean }
|
|
78
|
-
|
|
78
|
+
// `catchUp` marks a batch that is really a query's INITIAL hydration delivered as a delta — the
|
|
79
|
+
// optimistic backend hydrates a fresh query through its reconcile cycle (a `serverBatchEnd`), so
|
|
80
|
+
// the whole first result set arrives as a `batch`, not a `snapshot`. The Store maps a catch-up
|
|
81
|
+
// batch to the `snapshot` change-phase so a narrator's "what CHANGED" default ignores the initial
|
|
82
|
+
// rows instead of narrating every one as a fresh add. A normal incremental batch omits it.
|
|
83
|
+
| { type: "batch"; events: FlatChange[]; catchUp?: boolean };
|
|
79
84
|
|
|
80
85
|
export type Mutation =
|
|
81
86
|
| { op: "add"; table: string; row: WireValue[] }
|
|
@@ -173,8 +178,13 @@ export interface Backend {
|
|
|
173
178
|
/** Optional DIRECT-COMMIT path for LOCAL-only tables (`201-LOCAL-ONLY-TABLES-DESIGN.md` §6):
|
|
174
179
|
* applies the writes straight to the engine, OUTSIDE the optimistic pending stack (a local
|
|
175
180
|
* table is untracked, so it never rebases). The backend rejects any synced/tracked table (M2).
|
|
176
|
-
* Backends with no local-table support (a plain remote sync backend) omit it.
|
|
177
|
-
|
|
181
|
+
* Backends with no local-table support (a plain remote sync backend) omit it.
|
|
182
|
+
*
|
|
183
|
+
* `onCommitted` (207 §5.1) runs after the engine commit is applied but BEFORE subscriber
|
|
184
|
+
* delivery: a subscriber throwing during delivery re-raises out of this call, and a caller
|
|
185
|
+
* that must stay coherent with the engine (the persistence tap) cannot tell that throw from
|
|
186
|
+
* a pre-commit rejection — the callback can, because it fires exactly when the commit is in. */
|
|
187
|
+
writeLocal?(mutations: Mutation[], onCommitted?: () => void): void;
|
|
178
188
|
onEvent(handler: (queryId: QueryId, event: ChangeEvent) => void): void;
|
|
179
189
|
/** Optional: the backend pushes per-query {@link ResultType} changes here and the core routes
|
|
180
190
|
* each to the matching view (so `view.resultType` tracks it). Backends with no lifecycle (the
|
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
|
-
|
|
165
|
-
|
|
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`
|
|
181
|
-
//
|
|
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,40 @@ 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
|
|
236
|
-
*
|
|
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; a no-op once retired.
|
|
441
|
+
* Does NOT notify — the snapshot fold it accompanies does. */
|
|
442
|
+
retireSeed(): void {
|
|
443
|
+
this.seeded = null;
|
|
444
|
+
}
|
|
445
|
+
|
|
241
446
|
/** Apply a batch (the hydrate snapshot or one transaction's events) in order, then
|
|
242
447
|
* notify subscribers once. Order is significant (FLAT-CHANGES-DESIGN.md §5.4). A no-op
|
|
243
448
|
* while pending (changes never precede the `hello` that resets the schema).
|
|
@@ -245,21 +450,61 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
|
|
|
245
450
|
* `enrichRemoves` ⇒ before a removed node is dropped, reconstruct its full subtree and attach it
|
|
246
451
|
* to the `remove` op's `node` (in place, so the same event object the Store fans out to its
|
|
247
452
|
* 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).
|
|
453
|
+
* only on a real eviction (an rc-decrement that keeps the row leaves `node` absent). A view with
|
|
454
|
+
* an attached {@link onChanges} listener ALSO enriches (per-view, no global opt-in), so a
|
|
455
|
+
* narrator can resolve a removed row's subs whether or not the store-global counter is set.
|
|
249
456
|
*
|
|
250
457
|
* `deferNotify` ⇒ fold but do NOT notify; the caller is responsible for calling {@link flush}
|
|
251
458
|
* later. The Store uses this to fold every view in one commit before notifying any subscriber
|
|
252
459
|
* (cross-view-atomic notification — `Store.onCommitBoundary`); standalone use leaves it off, so
|
|
253
460
|
* a bare view still fires its subscribers after each applied batch.
|
|
254
461
|
*
|
|
255
|
-
*
|
|
256
|
-
|
|
462
|
+
* `phase` tags the {@link onChanges} delivery (`snapshot` for the hydrate, `batch` otherwise); it
|
|
463
|
+
* does not affect the fold. Returns whether the batch changed the view (so a deferring caller
|
|
464
|
+
* knows it must be flushed). */
|
|
465
|
+
applyChanges(events: FlatChange[], enrichRemoves = false, deferNotify = false, phase: ChangePhase = "batch"): boolean {
|
|
257
466
|
if (this._schema === null) return false;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
467
|
+
// Enrich this view's removes when a narrator is attached (see the `enrichRemoves` doc above) —
|
|
468
|
+
// the per-view twin of the store's global `removedSubtree` counter.
|
|
469
|
+
const enrich = enrichRemoves || this.changeListeners.size > 0;
|
|
470
|
+
// Deliver ONLY the ops that actually changed the tree: a duplicate-path add (rc bump), an
|
|
471
|
+
// rc-decrement remove that left the row, a no-op edit, and a gated op all return false — none is
|
|
472
|
+
// a real change, so none should reach a narrator. (Enrichment already attached the subtree to a
|
|
473
|
+
// real remove during the fold, so a surviving remove carries its `node`.)
|
|
474
|
+
const applied: FlatChange[] = [];
|
|
475
|
+
for (const e of events) {
|
|
476
|
+
if (this.applyAt(this.top, this._schema, e.path, e.op, 0, enrich)) applied.push(e);
|
|
477
|
+
}
|
|
478
|
+
if (applied.length === 0) return false;
|
|
261
479
|
this.dirty = true;
|
|
262
|
-
if (
|
|
480
|
+
if (deferNotify) {
|
|
481
|
+
if (this.changeListeners.size > 0) {
|
|
482
|
+
const segs = this.pendingSegments;
|
|
483
|
+
const last = segs.length > 0 ? segs[segs.length - 1] : undefined;
|
|
484
|
+
if (last && last.phase === phase) for (const e of applied) last.changes.push(e);
|
|
485
|
+
else segs.push({ phase, changes: applied.slice() });
|
|
486
|
+
}
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
// Inline (standalone / no commit bracket): notify data subscribers, then deliver the change
|
|
490
|
+
// stream — each isolated, first error re-raised once both have run (mirroring the Store's
|
|
491
|
+
// per-view flush isolation) so a throwing narration listener never starves the data channel,
|
|
492
|
+
// aborts the fold's return, or propagates raw into the engine's push path.
|
|
493
|
+
let firstError: unknown;
|
|
494
|
+
let hasError = false;
|
|
495
|
+
const note = (e: unknown): void => {
|
|
496
|
+
if (!hasError) {
|
|
497
|
+
hasError = true;
|
|
498
|
+
firstError = e;
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
try {
|
|
502
|
+
this.notify();
|
|
503
|
+
} catch (e) {
|
|
504
|
+
note(e);
|
|
505
|
+
}
|
|
506
|
+
this.deliverChanges(applied, phase, note);
|
|
507
|
+
if (hasError) throw firstError;
|
|
263
508
|
return true;
|
|
264
509
|
}
|
|
265
510
|
|
|
@@ -268,11 +513,35 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
|
|
|
268
513
|
* touched by the same commit has folded — so a subscriber that re-reads a sibling view inside
|
|
269
514
|
* its callback observes post-commit data, never a torn mid-commit state. */
|
|
270
515
|
flush(): void {
|
|
271
|
-
|
|
516
|
+
// Take the buffer BEFORE delivery so a throwing listener can never leave changes buffered to be
|
|
517
|
+
// re-delivered — merged and cross-phase-netted — into a LATER commit's flush. Notify and every
|
|
518
|
+
// segment's delivery are isolated; the first error is re-raised after all have run, so one bad
|
|
519
|
+
// subscriber starves neither the data channel nor a sibling segment (matching `flushCommit`).
|
|
520
|
+
const segs = this.pendingSegments;
|
|
521
|
+
this.pendingSegments = [];
|
|
522
|
+
let firstError: unknown;
|
|
523
|
+
let hasError = false;
|
|
524
|
+
const note = (e: unknown): void => {
|
|
525
|
+
if (!hasError) {
|
|
526
|
+
hasError = true;
|
|
527
|
+
firstError = e;
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
try {
|
|
531
|
+
this.notify();
|
|
532
|
+
} catch (e) {
|
|
533
|
+
note(e);
|
|
534
|
+
}
|
|
535
|
+
for (const seg of segs) this.deliverChanges(seg.changes, seg.phase, note);
|
|
536
|
+
if (hasError) throw firstError;
|
|
272
537
|
}
|
|
273
538
|
|
|
274
539
|
get data(): readonly R[] {
|
|
275
|
-
|
|
540
|
+
// A live SSR seed owns `data` until it is retired on the first live snapshot ({@link retireSeed}) —
|
|
541
|
+
// this spans pending (no schema) AND the post-`hello`, pre-snapshot window (schema set, tree still
|
|
542
|
+
// empty), so a seeded query never flashes empty during the handoff. `null` seed ⇒ normal behavior.
|
|
543
|
+
if (this.seeded !== null) return this.seeded;
|
|
544
|
+
if (this._schema === null) return EMPTY as readonly R[];
|
|
276
545
|
if (!this.dirty && this.cached !== null) return this.cached;
|
|
277
546
|
this.cached = this.top.map((n) => this.project(n, this._schema as WireSchema, this.types)) as R[];
|
|
278
547
|
this.dirty = false;
|
|
@@ -287,8 +556,38 @@ export class FlatArrayView<R = unknown> implements ArrayView<R> {
|
|
|
287
556
|
};
|
|
288
557
|
}
|
|
289
558
|
|
|
559
|
+
onChanges(listener: ViewChangeListener): () => void {
|
|
560
|
+
this.changeListeners.add(listener);
|
|
561
|
+
return () => {
|
|
562
|
+
this.changeListeners.delete(listener);
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Net the folded batch and hand the survivors to the change listeners, AFTER the data `notify`
|
|
567
|
+
* (the order `Store.subscribeChanges` consumers already observe). A batch that nets to nothing —
|
|
568
|
+
* a correctly predicted rebase — makes no call, so a narrator sees only real change. Each listener
|
|
569
|
+
* is isolated: a throwing one reports to `note` (the caller re-raises the first) and the rest still
|
|
570
|
+
* run, so one bad narration template never starves a sibling listener nor corrupts the view. */
|
|
571
|
+
private deliverChanges(events: FlatChange[], phase: ChangePhase, note: (e: unknown) => void): void {
|
|
572
|
+
if (this.changeListeners.size === 0) return;
|
|
573
|
+
// `_schema` is non-null here: `applyChanges` early-returns while pending, and `flush` runs only
|
|
574
|
+
// after a fold — so a listener always has the position→name source in hand.
|
|
575
|
+
const schema = this._schema as WireSchema;
|
|
576
|
+
const net = netChanges(events, schema);
|
|
577
|
+
if (net.length === 0) return;
|
|
578
|
+
for (const l of this.changeListeners) {
|
|
579
|
+
try {
|
|
580
|
+
l(net, phase, schema);
|
|
581
|
+
} catch (e) {
|
|
582
|
+
note(e);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
290
587
|
destroy(): void {
|
|
291
588
|
this.listeners.clear();
|
|
589
|
+
this.changeListeners.clear();
|
|
590
|
+
this.pendingSegments = [];
|
|
292
591
|
this.top = [];
|
|
293
592
|
}
|
|
294
593
|
|