@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/dist/ast.d.ts +11 -0
- package/dist/ast.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- 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/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 +5 -0
- package/src/operators.ts +5 -0
- package/src/query.ts +142 -13
- package/src/resolve.ts +136 -0
- package/src/store.ts +157 -34
- package/src/types.ts +35 -1
- package/src/view.ts +103 -22
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
|
-
//
|
|
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
|
/**
|