@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.
- package/dist/ast.d.ts +11 -0
- package/dist/ast.d.ts.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/mutation-ops.d.ts +140 -0
- package/dist/mutation-ops.d.ts.map +1 -0
- package/dist/mutation-ops.js +60 -0
- package/dist/mutation-ops.js.map +1 -0
- package/dist/operators.d.ts +5 -0
- package/dist/operators.d.ts.map +1 -1
- package/dist/operators.js +5 -0
- package/dist/operators.js.map +1 -1
- package/dist/query.d.ts +65 -9
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +56 -1
- package/dist/query.js.map +1 -1
- package/dist/resolve.d.ts +40 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +109 -0
- package/dist/resolve.js.map +1 -0
- package/dist/schema.d.ts +53 -5
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +65 -4
- package/dist/schema.js.map +1 -1
- package/dist/ssr.d.ts +14 -0
- package/dist/ssr.d.ts.map +1 -1
- package/dist/ssr.js +15 -0
- package/dist/ssr.js.map +1 -1
- package/dist/store.d.ts +47 -15
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +163 -25
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +37 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/view.d.ts +43 -5
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +87 -22
- package/dist/view.js.map +1 -1
- package/package.json +1 -1
- package/src/ast.ts +11 -0
- package/src/index.ts +29 -1
- package/src/mutation-ops.ts +190 -0
- package/src/operators.ts +5 -0
- package/src/query.ts +142 -13
- package/src/resolve.ts +136 -0
- package/src/schema.ts +130 -5
- package/src/ssr.ts +21 -0
- package/src/store.ts +157 -34
- package/src/types.ts +35 -1
- 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
|
-
//
|
|
115
|
-
//
|
|
116
|
-
// routed event and nothing else —
|
|
117
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
427
|
+
this.applyAndTrack(qid, ev.adds);
|
|
404
428
|
} else {
|
|
405
|
-
this.
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
/**
|
|
417
|
-
*
|
|
418
|
-
|
|
419
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
195
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
340
|
+
const node = list[at.index];
|
|
341
|
+
if (node.rc === 1) {
|
|
261
342
|
list.splice(at.index, 1);
|
|
262
|
-
return
|
|
343
|
+
return node;
|
|
263
344
|
}
|
|
264
|
-
|
|
265
|
-
return
|
|
345
|
+
node.rc -= 1;
|
|
346
|
+
return null;
|
|
266
347
|
}
|
|
267
348
|
|
|
268
349
|
private applyEdit(list: Node[], schema: WireSchema, oldRow: WireValue[], newRow: WireValue[]): boolean {
|