@rindle/client 0.1.6 → 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/README.md +70 -5
- 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 +155 -34
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +213 -8
- 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 +58 -15
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +221 -26
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +41 -2
- 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 +109 -31
- 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 +505 -52
- package/src/resolve.ts +136 -0
- package/src/store.ts +227 -35
- package/src/types.ts +40 -3
- package/src/view.ts +127 -30
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
|
|
@@ -51,6 +51,19 @@ export interface CachedQueryView<Q extends Query<any, any, any>> {
|
|
|
51
51
|
destroy(): void;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
export interface SyncQueryLease {
|
|
55
|
+
readonly resultType: ResultType;
|
|
56
|
+
subscribe(listener: () => void): () => void;
|
|
57
|
+
release(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface SyncLeaseState {
|
|
61
|
+
resultType: ResultType;
|
|
62
|
+
listeners: Set<() => void>;
|
|
63
|
+
released: boolean;
|
|
64
|
+
seedKey?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
54
67
|
/** One live materialized view's read-only summary for a devtools pane (DEBUG-TOOLS-BROWSER-DESIGN
|
|
55
68
|
* §4.2 — "surface, not instrument"). All fields are already held by the {@link Store}; this is the
|
|
56
69
|
* single read-only accessor over the otherwise-private `views`/`asts` maps. */
|
|
@@ -72,18 +85,6 @@ export interface StoreInspect {
|
|
|
72
85
|
queries: QueryInspect[];
|
|
73
86
|
}
|
|
74
87
|
|
|
75
|
-
/** A dev-only passive tap over the Store's per-query streams (DEBUG-TOOLS-BROWSER-DESIGN §6.2).
|
|
76
|
-
* Registered via {@link Store.__attachDevtools}; it ADDS a listener and never displaces the
|
|
77
|
-
* single `backend.onEvent`/`onResultType` handlers the Store already owns, so attaching devtools
|
|
78
|
-
* cannot perturb the app. Both hooks are optional. */
|
|
79
|
-
export interface StoreDevObserver {
|
|
80
|
-
/** The raw per-query {@link ChangeEvent} (hello / snapshot / batch) the Store just routed to its
|
|
81
|
-
* view — the source of the devtools delta stream (§4.3). Fired AFTER the view has folded it. */
|
|
82
|
-
onDelta?(qid: QueryId, ev: ChangeEvent): void;
|
|
83
|
-
/** A per-query {@link ResultType} transition the Store just applied to its view (§4.2). */
|
|
84
|
-
onResultType?(qid: QueryId, rt: ResultType): void;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
88
|
export class Store<S extends ColsMap> {
|
|
88
89
|
/** Type-safe query entry: `store.query.issue.where.closed(false).materialize()`. */
|
|
89
90
|
readonly query: QueryRoot<S>;
|
|
@@ -93,25 +94,66 @@ export class Store<S extends ColsMap> {
|
|
|
93
94
|
private nextId = 1;
|
|
94
95
|
private readonly views = new Map<QueryId, FlatArrayView>();
|
|
95
96
|
private readonly asts = new Map<QueryId, Ast>();
|
|
97
|
+
private readonly syncLeases = new Map<QueryId, SyncLeaseState>();
|
|
96
98
|
// SSR seeds (SSR-DESIGN.md §6), keyed by `viewKey`: a view materialized for one of these is
|
|
97
99
|
// seeded for first paint; a seed is consumed (dropped) the moment its query's first live
|
|
98
100
|
// `hello` lands, so later mounts don't flash stale SSR data.
|
|
99
101
|
private readonly seeds = new Map<string, DehydratedQuery>();
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// routed event and nothing else —
|
|
103
|
-
|
|
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]> = [];
|
|
104
130
|
|
|
105
131
|
constructor(schema: Schema<S>, backend: Backend) {
|
|
106
132
|
this.schema = schema;
|
|
107
133
|
this.backend = backend;
|
|
108
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
|
+
});
|
|
109
145
|
// Route the backend's per-query lifecycle onto its view (`view.resultType`). Backends without a
|
|
110
146
|
// lifecycle omit `onResultType`, leaving every view `complete`. A devtools tap mirrors the
|
|
111
147
|
// transition (it never displaces this single handler).
|
|
112
148
|
this.backend.onResultType?.((qid, rt) => {
|
|
113
149
|
this.views.get(qid)?.setResultType(rt);
|
|
114
|
-
|
|
150
|
+
const sync = this.syncLeases.get(qid);
|
|
151
|
+
if (sync && sync.resultType !== rt) {
|
|
152
|
+
sync.resultType = rt;
|
|
153
|
+
if (rt === "complete" && sync.seedKey !== undefined) this.seeds.delete(sync.seedKey);
|
|
154
|
+
for (const listener of sync.listeners) listener();
|
|
155
|
+
}
|
|
156
|
+
if (this.resultTypeListeners) for (const l of this.resultTypeListeners) l(qid, rt);
|
|
115
157
|
});
|
|
116
158
|
// `store.query` is the LOCAL builder (201-LOCAL-ONLY-TABLES-DESIGN.md §5): it scopes over
|
|
117
159
|
// synced AND local-only tables, so a local query can join the two. Server/named queries use
|
|
@@ -153,6 +195,49 @@ export class Store<S extends ColsMap> {
|
|
|
153
195
|
};
|
|
154
196
|
}
|
|
155
197
|
|
|
198
|
+
/** Retain a named remote query purely for normalized/local-first coverage. This does not
|
|
199
|
+
* register or materialize the query AST locally, so React can keep server sync coverage alive
|
|
200
|
+
* without subscribing to the broad coverage result tree. */
|
|
201
|
+
retainSyncQuery<Q extends Query<any, any, any>>(query: Q): SyncQueryLease {
|
|
202
|
+
if (!this.canRetainRemoteQueries()) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
"store.retainSyncQuery: this backend cannot retain a remote query without a local view.",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const ast = query.ast();
|
|
208
|
+
const remote = typeof query.name === "string" ? { name: query.name, args: query.args } : undefined;
|
|
209
|
+
if (!remote) {
|
|
210
|
+
throw new Error("store.retainSyncQuery: sync-only coverage requires a named query.");
|
|
211
|
+
}
|
|
212
|
+
const qid = this.nextId++;
|
|
213
|
+
const state: SyncLeaseState = { resultType: "unknown", listeners: new Set(), released: false, seedKey: stableKey(ast) };
|
|
214
|
+
this.syncLeases.set(qid, state);
|
|
215
|
+
try {
|
|
216
|
+
this.backend.retainRemoteQuery?.(qid, remote, qid, ast);
|
|
217
|
+
} catch (e) {
|
|
218
|
+
this.syncLeases.delete(qid);
|
|
219
|
+
throw e;
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
get resultType() {
|
|
223
|
+
return state.resultType;
|
|
224
|
+
},
|
|
225
|
+
subscribe: (listener: () => void) => {
|
|
226
|
+
state.listeners.add(listener);
|
|
227
|
+
listener();
|
|
228
|
+
return () => {
|
|
229
|
+
state.listeners.delete(listener);
|
|
230
|
+
};
|
|
231
|
+
},
|
|
232
|
+
release: () => {
|
|
233
|
+
if (state.released) return;
|
|
234
|
+
state.released = true;
|
|
235
|
+
this.syncLeases.delete(qid);
|
|
236
|
+
this.backend.releaseRemoteQuery?.(qid);
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
156
241
|
/** Apply a batch of mutations (object rows → positional). Resolves when the backend has
|
|
157
242
|
* accepted them (local: applied; remote: sent). The resulting view updates flow back via
|
|
158
243
|
* the backend's event stream. */
|
|
@@ -234,6 +319,12 @@ export class Store<S extends ColsMap> {
|
|
|
234
319
|
return this.seeds.get(viewKey);
|
|
235
320
|
}
|
|
236
321
|
|
|
322
|
+
primaryKeyFor(table: string): readonly string[] {
|
|
323
|
+
const meta = this.schema.tables[table];
|
|
324
|
+
if (!meta) throw new Error(`unknown table: ${table}`);
|
|
325
|
+
return meta.primaryKey;
|
|
326
|
+
}
|
|
327
|
+
|
|
237
328
|
/** Convert assembled (nested-by-name) rows (SSR-DESIGN.md §3.3) into the view's projected result
|
|
238
329
|
* shape: spread `cols` (parsing json columns), recurse into each relationship by its alias
|
|
239
330
|
* (plural → array, `.one()` → object/null, `countAs` → bare scalar). */
|
|
@@ -272,8 +363,10 @@ export class Store<S extends ColsMap> {
|
|
|
272
363
|
this.asts.set(qid, ast);
|
|
273
364
|
// Pre-create the view (PENDING) so `materialize` is synchronous for ANY backend — a remote
|
|
274
365
|
// backend's `hello` arrives async (the view reads as `[]` until then). A synchronous backend
|
|
275
|
-
// (wasm/replica) resets it during `registerQuery` below, so it returns already-hydrated.
|
|
276
|
-
|
|
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)!;
|
|
277
370
|
// Wire teardown: `destroy()` must unregister the query from the backend and drop our routing
|
|
278
371
|
// entry. Otherwise the engine keeps emitting events for the destroyed query and the Store
|
|
279
372
|
// routes them to the now-empty view — which throws on the next Child/remove ("parent not
|
|
@@ -310,7 +403,7 @@ export class Store<S extends ColsMap> {
|
|
|
310
403
|
const remote = typeof query.name === "string" ? { name: query.name, args: query.args } : undefined;
|
|
311
404
|
if (!remote || !this.canRetainRemoteQueries()) return () => {};
|
|
312
405
|
const qid = this.nextId++;
|
|
313
|
-
this.backend.retainRemoteQuery?.(qid, remote, localQueryId);
|
|
406
|
+
this.backend.retainRemoteQuery?.(qid, remote, localQueryId, query.ast());
|
|
314
407
|
let released = false;
|
|
315
408
|
return () => {
|
|
316
409
|
if (released) return;
|
|
@@ -328,31 +421,130 @@ export class Store<S extends ColsMap> {
|
|
|
328
421
|
const types = ast ? this.viewTypes(ev.schema, ast) : undefined;
|
|
329
422
|
// Reset the (pre-created or existing) view IN PLACE — first hello OR a re-hydrate (new
|
|
330
423
|
// epoch) — so the materialized reference the caller holds survives a re-subscribe.
|
|
331
|
-
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)!;
|
|
332
425
|
view.reset(ev.schema, types);
|
|
333
426
|
} else if (ev.type === "snapshot") {
|
|
334
|
-
this.
|
|
427
|
+
this.applyAndTrack(qid, ev.adds);
|
|
335
428
|
} else {
|
|
336
|
-
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
|
+
}
|
|
337
444
|
}
|
|
338
|
-
// Mirror the raw delta to any devtools tap, AFTER the view has folded it (so a pane re-reading
|
|
339
|
-
// `__inspect` sees the post-apply state). No-op (one undefined-check) when no pane is attached.
|
|
340
|
-
if (this.devObservers) for (const o of this.devObservers) o.onDelta?.(qid, ev);
|
|
341
445
|
}
|
|
342
446
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
+
}
|
|
346
499
|
|
|
347
|
-
/**
|
|
348
|
-
*
|
|
349
|
-
|
|
350
|
-
|
|
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++;
|
|
351
527
|
return () => {
|
|
352
|
-
this.
|
|
528
|
+
if (this.changeListeners?.delete(listener) && opts?.removedSubtree) this.removedSubtreeWanted--;
|
|
353
529
|
};
|
|
354
530
|
}
|
|
355
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
|
+
|
|
356
548
|
/** A read-only snapshot of every live materialized view (DEBUG-TOOLS-BROWSER-DESIGN §4.2): its
|
|
357
549
|
* qid, AST, {@link ResultType}, row count, and a capped row sample. Built fresh on each call from
|
|
358
550
|
* the live `views`/`asts` maps — never cached, never mutating. `sampleRows` caps the per-query
|
package/src/types.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// Cells cross the boundary BARE (the host has a typed schema, so per-column types are
|
|
4
4
|
// known here, not per-cell). JSON columns cross as their raw JSON string.
|
|
5
5
|
|
|
6
|
+
import type { Ast } from "./ast.ts";
|
|
7
|
+
|
|
6
8
|
/** A bare wire cell. (JSON columns arrive as their raw JSON string.) */
|
|
7
9
|
export type WireValue = number | string | boolean | null;
|
|
8
10
|
|
|
@@ -50,9 +52,14 @@ export interface PathSeg {
|
|
|
50
52
|
parentRow: WireValue[];
|
|
51
53
|
}
|
|
52
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`. */
|
|
53
60
|
export type FlatOp =
|
|
54
61
|
| { tag: "add"; node: WireNode }
|
|
55
|
-
| { tag: "remove"; row: WireValue[] }
|
|
62
|
+
| { tag: "remove"; row: WireValue[]; node?: WireNode }
|
|
56
63
|
| { tag: "edit"; old: WireValue[]; new: WireValue[] };
|
|
57
64
|
|
|
58
65
|
export interface FlatChange {
|
|
@@ -111,6 +118,20 @@ export type NormalizedEvent =
|
|
|
111
118
|
|
|
112
119
|
export type QueryId = number;
|
|
113
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
|
+
|
|
114
135
|
/** A query's SERVER-CHANNEL state, surfaced on its {@link ArrayView} (FOLDED-MUTATIONS-DESIGN §7 —
|
|
115
136
|
* formerly conflated with pending-ness, OPTIMISTIC-WRITES-DESIGN.md §6):
|
|
116
137
|
* - `unknown` — not hydrated: the server has not produced a first result for this query yet;
|
|
@@ -143,8 +164,9 @@ export interface Backend {
|
|
|
143
164
|
unregisterQuery(queryId: QueryId): void;
|
|
144
165
|
/** Optional split path for local-first backends: retain/release a named remote footprint
|
|
145
166
|
* without creating another local materialized view. `localQueryId`, when provided, names
|
|
146
|
-
* the local AST view this remote footprint feeds.
|
|
147
|
-
|
|
167
|
+
* the local AST view this remote footprint feeds. `ast`, when provided, gives the backend
|
|
168
|
+
* the local schema context for sync-only retains that still need aggregate/table setup. */
|
|
169
|
+
retainRemoteQuery?(queryId: QueryId, remote: RemoteQuery, localQueryId?: QueryId, ast?: Ast): void;
|
|
148
170
|
releaseRemoteQuery?(queryId: QueryId): void;
|
|
149
171
|
/** Local: applies now (changes flow back on the stream). Remote: sends to the server. */
|
|
150
172
|
mutate(mutations: Mutation[]): Promise<void>;
|
|
@@ -158,6 +180,21 @@ export interface Backend {
|
|
|
158
180
|
* each to the matching view (so `view.resultType` tracks it). Backends with no lifecycle (the
|
|
159
181
|
* in-process engine) omit it and every view stays `complete`. */
|
|
160
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;
|
|
161
198
|
}
|
|
162
199
|
|
|
163
200
|
/**
|