API index and search · Build metadata
Source snapshot
packages/client/src/store.ts
1// The Store — backend-agnostic glue (WASM-CLIENT-DESIGN.md §2). Holds the typed schema +2// a `Backend`, exposes `store.query.<table>…materialize()` and `store.write(tx => …)`, and3// routes the backend's per-query `ChangeEvent` stream into per-query `ArrayView`s.4//5// It never knows whether the backend is the in-process WASM engine or a remote server —6// both speak `registerQuery` / `mutate` / `onEvent`. The Store owns: query-id assignment,7// building each `ArrayView` (typed from the schema, so json columns parse), dispatching8// hello/snapshot/batch events, and turning object-shaped writes into positional mutations.910import type { Ast } from "./ast.ts";11import { stableKey } from "./key.ts";12import { queries, type Query, type QueryRoot } from "./query.ts";13import type { ColsMap, InsertOf, RowOf, Schema } from "./schema.ts";14import type { Backend, ChangeEvent, ColType, FlatChange, Mutation, QueryId, RemoteQuery, ResultType, WireSchema, WireValue } from "./types.ts";15import { type ArrayView, type ChangePhase, FlatArrayView, type SingularArrayView, SingularView, type ViewChangeListener, type ViewTypes } from "./view.ts";1617/** One query's SSR snapshot, keyed by its `viewKey` ({@link stableKey} of the AST): the18 * pre-projected first-paint `rows` plus the `cvMin` watermark they reflect (SSR-DESIGN.md §6.2).19 * Serializable as-is into the HTML — `rows` are already JSON values (json columns parsed). */20export interface DehydratedQuery {21 rows: unknown[];22 cvMin: number;23}2425/** The whole dehydrated cache: every preloaded query's snapshot, keyed by `viewKey`. The server26 * builds it with {@link Store.dehydrate}; the browser seeds it with {@link Store.hydrate}. */27export type DehydratedState = Record<string, DehydratedQuery>;2829/** A single assembled (nested-by-name) row from `POST /query` (SSR-DESIGN.md §3.3): the cells30 * under `cols`, each in-view relationship inlined by its alias (a nested array / object, or a31 * scalar for a `countAs` aggregate). {@link Store.assembleSnapshot} converts these to the32 * view's projected result shape. */33export interface AssembledNode {34 cols: Record<string, WireValue>;35 [rel: string]: unknown;36}3738/** The write transaction handed to `store.write(tx => …)`. Rows are objects keyed by column;39 * the Store positionalizes them (and stringifies json columns) before the backend sees them.40 *41 * `add` takes an {@link InsertOf} row — a nullable column may be omitted (it is filled with `null`,42 * design 206 §7). `remove`/`edit` take a full {@link RowOf} row: they identify an EXISTING row, so43 * every column (nullable ones as their actual `T | null` value) must be present. */44export interface WriteTx<S extends ColsMap> {45 add<N extends keyof S & string>(table: N, row: InsertOf<S[N]>): void;46 remove<N extends keyof S & string>(table: N, row: RowOf<S[N]>): void;47 edit<N extends keyof S & string>(table: N, oldRow: RowOf<S[N]>, newRow: RowOf<S[N]>): void;48}4950export interface CachedQueryView<Q extends Query<any, any, any>> {51 readonly view: ReturnType<Q["materialize"]>;52 /** Retain this query's named remote footprint and release it later. Ad-hoc local queries53 * return a no-op release function. */54 retain(query: Q): () => void;55 destroy(): void;56}5758export interface SyncQueryLease {59 readonly resultType: ResultType;60 subscribe(listener: () => void): () => void;61 release(): void;62}6364interface SyncLeaseState {65 resultType: ResultType;66 listeners: Set<() => void>;67 released: boolean;68 seedKey?: string;69}7071/** One live materialized view's read-only summary for a devtools pane (DEBUG-TOOLS-BROWSER-DESIGN72 * §4.2 — "surface, not instrument"). All fields are already held by the {@link Store}; this is the73 * single read-only accessor over the otherwise-private `views`/`asts` maps. */74export interface QueryInspect {75 /** The Store-assigned query id (also the backend's qid — the Store passes it straight through). */76 qid: QueryId;77 /** The query's AST (`Store.asts`), for the inspector's pretty-print / table-derivation. */78 ast: Ast;79 /** The view's SERVER-CHANNEL state (`unknown` while loading, `complete` once authoritative). */80 resultType: ResultType;81 /** Current materialized row count (`view.data.length`). */82 rowCount: number;83 /** A capped peek at the projected rows (reference-stable objects off the live view). */84 sample: readonly unknown[];85}8687/** A frozen snapshot of the Store's live query state for a devtools pane ({@link Store.__inspect}). */88export interface StoreInspect {89 queries: QueryInspect[];90}9192export class Store<S extends ColsMap> {93 /** Type-safe query entry: `store.query.issue.where.closed(false).materialize()`. */94 readonly query: QueryRoot<S>;9596 private readonly schema: Schema<S>;97 private readonly backend: Backend;98 private nextId = 1;99 private readonly views = new Map<QueryId, FlatArrayView>();100 private readonly asts = new Map<QueryId, Ast>();101 private readonly syncLeases = new Map<QueryId, SyncLeaseState>();102 // SSR seeds (SSR-DESIGN.md §6), keyed by `viewKey`: a view materialized for one of these is103 // seeded for first paint; a seed is consumed (dropped) the moment its query's first live104 // SNAPSHOT lands — NOT its `hello` — so the seed bridges the `hello`→snapshot gap (the live105 // data arrives on the snapshot, a round-trip after the hello) and later mounts of a now-live106 // query don't re-seed stale SSR data.107 private readonly seeds = new Map<string, DehydratedQuery>();108 // Public per-query change subscribers ({@link subscribeChanges}). `undefined` until the first109 // subscription, so an app that never narrates (nor attaches devtools) pays one undefined-check per110 // routed event and nothing else — no global singleton, no hot-path instrumentation. Each listener111 // also receives the post-fold view for the qid (always the plural {@link ArrayView}, even for a112 // `.one()` query — the Store retains the list-shaped view, not the SingularView wrapper).113 private changeListeners?: Set<(qid: QueryId, ev: ChangeEvent, view?: ArrayView<unknown>) => void>;114 // How many active change subscribers asked for removed subtrees ({@link subscribeChanges} opts).115 // `> 0` ⇒ the view reconstructs each evicted subtree onto its `remove` op before fan-out. A plain116 // counter: the cost is paid only while at least one consumer wants it, and only on real evictions.117 private removedSubtreeWanted = 0;118 // Public per-query {@link ResultType} subscribers ({@link subscribeResultType}). `undefined` until119 // the first subscription. Devtools and any status-driven layer ride this instead of a private tap.120 private resultTypeListeners?: Set<(qid: QueryId, rt: ResultType) => void>;121 // Whether the backend drives a per-query resultType lifecycle (`onResultType`). When it does, a122 // REMOTE query is marked PENDING (`unknown`) at register (`registerMaterialized`) so its synchronous123 // pre-sync snapshot — the optimistic/wasm backend fetches local, not-yet-synced state INSIDE124 // `registerQuery` — can't be mistaken for the authoritative answer and retire the SSR seed. When it125 // doesn't, every view stays `complete` and the first snapshot IS authoritative (unchanged behavior).126 private readonly hasResultTypeLifecycle: boolean;127 // Cross-view-atomic notification (the `Backend.onCommitBoundary` contract): while the backend is128 // delivering one commit's coherent multi-query batch, fold every affected view but DEFER each129 // view's subscriber notification, collecting the changed views here; at the commit's `end` flush130 // them all. So when any view's subscriber runs, every sibling view touched by the same commit has131 // already folded — a callback that re-reads another view sees post-commit data. `> 0` ⇒ inside a132 // commit (defer); `0` ⇒ notify inline (the prior behavior, and what a backend with no commit133 // boundary always gets). A depth counter (not a bool) is robust to any future nesting.134 private commitDepth = 0;135 private readonly pendingFlush = new Set<FlatArrayView>();136 // The raw change-stream frames ({@link subscribeChanges}) buffered during a commit, delivered137 // together at the boundary alongside the view flush — so a change listener (narrator/devtools)138 // that re-reads ANY view also sees post-commit state, the same cross-view-atomic guarantee view139 // subscribers get. Only filled while a commit is open AND a change listener exists; otherwise it140 // stays empty and untouched (the no-narrator hot path is one undefined-check, as before).141 private readonly pendingChanges: Array<[QueryId, ChangeEvent]> = [];142143 constructor(schema: Schema<S>, backend: Backend) {144 this.schema = schema;145 this.backend = backend;146 this.hasResultTypeLifecycle = typeof backend.onResultType === "function";147 this.backend.onEvent((qid, ev) => this.onEvent(qid, ev));148 // The in-process engine brackets each commit's multi-query delivery so every affected view149 // folds before any subscriber is notified (see `commitDepth`/`pendingFlush`). A backend with no150 // such boundary never enters deferred mode, so its views notify inline exactly as before.151 this.backend.onCommitBoundary?.((phase) => {152 if (phase === "begin") {153 this.commitDepth++;154 } else if (this.commitDepth > 0 && --this.commitDepth === 0) {155 this.flushCommit();156 }157 });158 // Route the backend's per-query lifecycle onto its view (`view.resultType`). Backends without a159 // lifecycle omit `onResultType`, leaving every view `complete`. A devtools tap mirrors the160 // transition (it never displaces this single handler).161 this.backend.onResultType?.((qid, rt) => {162 this.views.get(qid)?.setResultType(rt);163 const sync = this.syncLeases.get(qid);164 if (sync && sync.resultType !== rt) {165 sync.resultType = rt;166 if (rt === "complete" && sync.seedKey !== undefined) this.seeds.delete(sync.seedKey);167 for (const listener of sync.listeners) listener();168 }169 if (this.resultTypeListeners) for (const l of this.resultTypeListeners) l(qid, rt);170 });171 // `store.query` is the LOCAL builder (201-LOCAL-ONLY-TABLES-DESIGN.md §5): it scopes over172 // synced AND local-only tables, so a local query can join the two. Server/named queries use173 // the synced-only `newQueryBuilder`, which excludes local tables.174 this.query = queries(this.schema, (query) => this.materialize(query), { includeLocal: true }) as QueryRoot<S>;175 }176177 /** Materialize any fluent query object. Named queries subscribe remotely by `(name,args)`;178 * ad-hoc builder queries are local-only for local-first backends.179 *180 * `opts.onChanges` binds a narrator to this view's DIFF stream ({@link ArrayView.onChanges}) — the181 * per-view seam that replaces filtering the store-global {@link subscribeChanges} by `qid`. It is182 * wired BEFORE the backend registers the query, so a synchronous backend's first `snapshot` (fired183 * inside `registerQuery`, before this returns) is delivered too. */184 materialize<Q extends Query<any, any, any>>(185 query: Q,186 opts?: { onChanges?: ViewChangeListener },187 ): ReturnType<Q["materialize"]> {188 const remote = typeof query.name === "string" ? { name: query.name, args: query.args } : undefined;189 return this.registerMaterialized(query.ast(), remote, opts?.onChanges).view as ReturnType<Q["materialize"]>;190 }191192 /** One-shot AUTHORITATIVE read: materialize `query`, wait until its result is server-authoritative193 * ({@link ResultType} `"complete"`), read the data once, then destroy the view — resolving with the194 * plain result rather than a live subscription. Rejects if the query enters the `"error"` state. Use195 * it for exports, imports, undo snapshots — anywhere that wants the current answer as a value.196 *197 * A synchronous local-first backend (wasm/replica) has already delivered the first snapshot inside198 * {@link materialize}, so the view is `"complete"` on entry and this settles on the next microtask199 * without ever attaching a listener; a remote backend settles when the first live snapshot lands.200 * The query is NEVER left subscribed — the view is destroyed before the promise settles either way.201 * (A remote query that never completes leaves the promise pending, exactly as a `resultType` poll202 * would; race a timeout at the call site if you need one.) */203 readOnce<Q extends Query<any, any, any>>(query: Q): Promise<ReturnType<Q["materialize"]>["data"]> {204 type Data = ReturnType<Q["materialize"]>["data"];205 const view = this.materialize(query);206 return new Promise<Data>((resolve, reject) => {207 let settled = false;208 let detach: (() => void) | undefined;209 const settle = (): boolean => {210 const rt = view.resultType;211 if (rt !== "complete" && rt !== "error") return false;212 settled = true;213 detach?.();214 if (rt === "error") {215 view.destroy();216 reject(new Error("store.readOnce: query entered the error result state"));217 } else {218 const data = view.data as Data;219 view.destroy();220 resolve(data);221 }222 return true;223 };224 // A synchronous backend is already `complete` here — settle now, never attaching a listener.225 // (`subscribeResultType` never replays on attach, so the pre-check is what covers this case.)226 if (settle()) return;227 detach = this.subscribeResultType((qid) => {228 if (!settled && qid === view.qid) settle();229 });230 });231 }232233 /** True when the backend can retain a remote named query independently from the local234 * materialized AST view. React uses this to keep one local view per AST while still sending235 * every mounted `(name,args)` lease through the backend. */236 canRetainRemoteQueries(): boolean {237 return (238 typeof this.backend.retainRemoteQuery === "function" &&239 typeof this.backend.releaseRemoteQuery === "function"240 );241 }242243 /** Build one local AST view, with remote syncing retained separately through the returned244 * handle. This is a lower-level API for UI bindings; ordinary app code should keep using245 * `materialize(query)`. */246 createCachedQueryView<Q extends Query<any, any, any>>(query: Q): CachedQueryView<Q> {247 const { qid, view } = this.registerMaterialized(query.ast(), undefined);248 let destroyed = false;249 return {250 view: view as ReturnType<Q["materialize"]>,251 retain: (nextQuery: Q) => this.retainRemote(nextQuery, qid),252 destroy: () => {253 if (destroyed) return;254 destroyed = true;255 view.destroy();256 },257 };258 }259260 /** Retain a named remote query purely for normalized/local-first coverage. This does not261 * register or materialize the query AST locally, so React can keep server sync coverage alive262 * without subscribing to the broad coverage result tree. */263 retainSyncQuery<Q extends Query<any, any, any>>(query: Q): SyncQueryLease {264 if (!this.canRetainRemoteQueries()) {265 throw new Error(266 "store.retainSyncQuery: this backend cannot retain a remote query without a local view.",267 );268 }269 const ast = query.ast();270 const remote = typeof query.name === "string" ? { name: query.name, args: query.args } : undefined;271 if (!remote) {272 throw new Error("store.retainSyncQuery: sync-only coverage requires a named query.");273 }274 const qid = this.nextId++;275 const state: SyncLeaseState = { resultType: "unknown", listeners: new Set(), released: false, seedKey: stableKey(ast) };276 this.syncLeases.set(qid, state);277 try {278 this.backend.retainRemoteQuery?.(qid, remote, qid, ast);279 } catch (e) {280 this.syncLeases.delete(qid);281 throw e;282 }283 return {284 get resultType() {285 return state.resultType;286 },287 subscribe: (listener: () => void) => {288 state.listeners.add(listener);289 listener();290 return () => {291 state.listeners.delete(listener);292 };293 },294 release: () => {295 if (state.released) return;296 state.released = true;297 this.syncLeases.delete(qid);298 this.backend.releaseRemoteQuery?.(qid);299 },300 };301 }302303 /** Apply a batch of mutations (object rows → positional). Resolves when the backend has304 * accepted them (local: applied; remote: sent). The resulting view updates flow back via305 * the backend's event stream. */306 write(fn: (tx: WriteTx<S>) => void): Promise<void> {307 return Promise.resolve(this.backend.mutate(this.collectMutations(fn)));308 }309310 /** Direct-commit write to LOCAL-only tables (`201-LOCAL-ONLY-TABLES-DESIGN.md` §6): the311 * client-authoritative path for selection state, draft text, view prefs, scratch rows. It312 * bypasses the optimistic pending stack entirely — a local table is untracked, so it never313 * rebases, reverts, or waits on a server confirmation (it "moves on its own").314 *315 * Rejects a synced/tracked table (M2): a direct write to one would be un-applied on the very316 * next server rewind. Local writes also must NOT live inside a replayable mutator (M1) — the317 * server runs the mutator from `args` alone and cannot see local tables; use this instead.318 * Same keyed `WriteTx` shape as {@link write}. `async` so the no-seam / M2 guards surface as a319 * REJECTED promise (the `Promise<void>` contract) rather than a synchronous throw that escapes a320 * caller's `.catch` and crashes the event handler / render frame. */321 async writeLocal(fn: (tx: WriteTx<S>) => void): Promise<void> {322 if (!this.backend.writeLocal) {323 throw new Error(324 "store.writeLocal: this backend has no local-write seam (local-only tables need the wasm/optimistic backend).",325 );326 }327 const muts = this.collectMutations(fn);328 // Defense in depth (M2): a clear Store-level rejection of any non-local table, before the329 // backend's own chokepoint guard. Local writes are authoritative-for-themselves only.330 for (const m of muts) {331 if (!this.schema.tables[m.table]?.local) {332 throw new Error(333 `store.writeLocal: "${m.table}" is not a local-only table — use store.write or a named mutator (M2).`,334 );335 }336 }337 this.backend.writeLocal(muts);338 }339340 /** Drain a keyed `WriteTx` callback into positional {@link Mutation}s (shared by341 * {@link write} / {@link writeLocal}; json columns stringified, rows in column order). */342 private collectMutations(fn: (tx: WriteTx<S>) => void): Mutation[] {343 const muts: Mutation[] = [];344 const tx = {345 add: (t: string, row: Record<string, unknown>) =>346 muts.push({ op: "add", table: t, row: this.positionalize(t, row) }),347 remove: (t: string, row: Record<string, unknown>) =>348 muts.push({ op: "remove", table: t, row: this.positionalize(t, row) }),349 edit: (t: string, o: Record<string, unknown>, n: Record<string, unknown>) =>350 muts.push({ op: "edit", table: t, old: this.positionalize(t, o), new: this.positionalize(t, n) }),351 } as unknown as WriteTx<S>;352 fn(tx);353 return muts;354 }355356 // --- SSR (SSR-DESIGN.md §6) ---------------------------------------------------357358 /** Seed a query's first-paint snapshot from a `POST /query` response (server side): convert the359 * assembled rows to the view's projected shape and stash them by `viewKey`. A view materialized360 * for this AST (during the synchronous render) reads the seed; {@link dehydrate} serializes it. */361 seedAssembled(ast: Ast, rows: AssembledNode[], cvMin: number): void {362 this.seeds.set(stableKey(ast), { rows: this.assembleSnapshot(ast, rows), cvMin });363 }364365 /** The dehydrated first-paint cache for every preloaded query — embed it in the HTML and pass it366 * to {@link hydrate} in the browser (SSR-DESIGN.md §6.2). */367 dehydrate(): DehydratedState {368 return Object.fromEntries(this.seeds);369 }370371 /** Seed the browser store from the server's {@link dehydrate} output (SSR-DESIGN.md §6.2): each372 * view materialized for a hydrated AST shows these rows until its first live `hello` reconciles. */373 hydrate(state: DehydratedState): void {374 for (const [key, snap] of Object.entries(state)) this.seeds.set(key, snap);375 }376377 /** A query's hydrated first-paint snapshot, by `viewKey` — what React's `getServerSnapshot`378 * reads so an SSR render (and the matching client hydration pass) sees the seeded rows without379 * opening a subscription. */380 seedSnapshot(viewKey: string): DehydratedQuery | undefined {381 return this.seeds.get(viewKey);382 }383384 primaryKeyFor(table: string): readonly string[] {385 const meta = this.schema.tables[table];386 if (!meta) throw new Error(`unknown table: ${table}`);387 return meta.primaryKey;388 }389390 /** Convert assembled (nested-by-name) rows (SSR-DESIGN.md §3.3) into the view's projected result391 * shape: spread `cols` (parsing json columns), recurse into each relationship by its alias392 * (plural → array, `.one()` → object/null, `countAs` → bare scalar). */393 assembleSnapshot(ast: Ast, rows: AssembledNode[]): unknown[] {394 return rows.map((row) => this.assembleNode(ast, row));395 }396397 private assembleNode(ast: Ast, node: AssembledNode): Record<string, unknown> {398 const cols = this.columns(ast.table);399 const out: Record<string, unknown> = {};400 for (const [name, v] of Object.entries(node.cols ?? {})) {401 out[name] = cols[name]?.type === "json" && typeof v === "string" ? JSON.parse(v) : v;402 }403 for (const sub of ast.related ?? []) {404 const alias = sub.subquery.alias;405 if (alias === undefined || !(alias in node)) continue;406 const child = node[alias];407 if (Array.isArray(child)) {408 out[alias] = child.map((c) => this.assembleNode(sub.subquery, c as AssembledNode));409 } else if (child !== null && typeof child === "object") {410 out[alias] = this.assembleNode(sub.subquery, child as AssembledNode); // .one() singular411 } else {412 out[alias] = child; // a `countAs` scalar aggregate, or a null singular relationship413 }414 }415 return out;416 }417418 // --- internals ---------------------------------------------------------------419420 private registerMaterialized(421 ast: Ast,422 remote?: RemoteQuery,423 onChanges?: ViewChangeListener,424 ): { qid: QueryId; view: ArrayView<unknown> | SingularArrayView<unknown> } {425 const qid: QueryId = this.nextId++;426 this.asts.set(qid, ast);427 // Pre-create the view (PENDING) so `materialize` is synchronous for ANY backend — a remote428 // backend's `hello` arrives async (the view reads as `[]` until then). A synchronous backend429 // (wasm/replica) resets it during `registerQuery` below, so it returns already-hydrated. The430 // view carries its `qid` (exposed as `view.qid`) so a consumer can correlate it with the raw431 // change stream straight off `materialize(query).qid`.432 const view = this.views.get(qid) ?? this.views.set(qid, new FlatArrayView(undefined, undefined, qid)).get(qid)!;433 // Wire teardown: `destroy()` must unregister the query from the backend and drop our routing434 // entry. Otherwise the engine keeps emitting events for the destroyed query and the Store435 // routes them to the now-empty view — which throws on the next Child/remove ("parent not436 // found") and, because dispatch is a single loop, aborts delivery to sibling queries too.437 // This bites whenever a query is re-materialized (e.g. a changed limit/filter rebuilds it).438 const baseDestroy = view.destroy.bind(view);439 view.destroy = () => {440 if (this.views.delete(qid)) {441 this.asts.delete(qid);442 this.backend.unregisterQuery(qid);443 }444 baseDestroy();445 };446 // Bind the narrator BEFORE `registerQuery` fires (a synchronous backend dispatches this query's447 // first `hello`+`snapshot` inside it) so the initial snapshot reaches the change listener too.448 if (onChanges) view.onChanges(onChanges);449 // SSR first paint (SSR-DESIGN.md §6): seed the view — and, for a lifecycle-backed REMOTE query,450 // mark it PENDING (`unknown`) — BEFORE `registerQuery`, because a synchronous optimistic/wasm451 // backend fires this query's first `hello`+`snapshot` from LOCAL, not-yet-synced state INSIDE that452 // call. Doing both here first means (a) that pre-sync snapshot finds the seed already applied (so453 // `data` shows it, not an empty tree) and (b) the view already reads `unknown`, so `retireSeedIfLive`454 // KEEPS the seed through it — on the optimistic backend the real hydration point is the later455 // `catchUp` batch (which flips the query to `complete` FIRST, then folds the authoritative rows).456 // A LOCAL query is authoritative at register (its local snapshot IS the answer), so it keeps the457 // default `complete`; a lifecycle-LESS backend has no `onResultType`, so every view stays `complete`458 // and its first snapshot retires the seed exactly as before.459 const seed = this.seeds.get(stableKey(ast));460 if (seed) view.seed(seed.rows);461 if (remote !== undefined && this.hasResultTypeLifecycle) view.setResultType("unknown");462 // If the backend rejects the registration (E3: a remote query naming a local-only table), roll463 // back the per-qid state we just created — otherwise the view + ast entry leak (the caller never464 // gets a handle to `destroy()` them, since the throw aborts before we return).465 try {466 this.backend.registerQuery(qid, ast, remote);467 } catch (e) {468 this.views.delete(qid);469 this.asts.delete(qid);470 throw e;471 }472 // A top-level `.one()` (engine-capped to limit 1) unwraps at the result boundary.473 return { qid, view: ast.one ? new SingularView(view) : view };474 }475476 private retainRemote<Q extends Query<any, any, any>>(query: Q, localQueryId: QueryId): () => void {477 const remote = typeof query.name === "string" ? { name: query.name, args: query.args } : undefined;478 if (!remote || !this.canRetainRemoteQueries()) return () => {};479 const qid = this.nextId++;480 this.backend.retainRemoteQuery?.(qid, remote, localQueryId, query.ast());481 let released = false;482 return () => {483 if (released) return;484 released = true;485 this.backend.releaseRemoteQuery?.(qid);486 };487 }488489 private onEvent(qid: QueryId, ev: ChangeEvent): void {490 if (ev.type === "hello") {491 const ast = this.asts.get(qid);492 const types = ast ? this.viewTypes(ev.schema, ast) : undefined;493 // Reset the (pre-created or existing) view IN PLACE — first hello OR a re-hydrate (new494 // epoch) — so the materialized reference the caller holds survives a re-subscribe. The SSR495 // seed is KEPT across the reset and retired on the first `snapshot` below (the live data496 // lands then, not on the hello), so a seeded query bridges the gap instead of flashing empty.497 const view = this.views.get(qid) ?? this.views.set(qid, new FlatArrayView(undefined, undefined, qid)).get(qid)!;498 view.reset(ev.schema, types);499 } else if (ev.type === "snapshot") {500 // A snapshot is a hydration point — retire the SSR seed and fold, gated on the query being501 // AUTHORITATIVE. {@link foldHydration} handles the empty-fold case (a re-hydrate to nothing).502 this.foldHydration(qid, ev.adds, "snapshot");503 } else if (ev.catchUp) {504 // A `catchUp` batch is a query's initial hydration delivered as a delta — on the optimistic /505 // normalized backend THIS (not the earlier pre-sync snapshot) is the real hydration point,506 // arriving right after the query flips to `complete`. Retire the seed + fold, phased as a507 // `snapshot` so a narrator's "what CHANGED" default ignores the initial rows.508 this.foldHydration(qid, ev.events, "snapshot");509 } else {510 // A plain (non-catchUp) batch is a post-hydration delta — the seed is long gone by then, and511 // this is the incremental hot path, so it does no seed work at all.512 this.applyAndTrack(qid, ev.events, "batch");513 }514 // Fan the same post-fold frame out to subscribers (narration, devtools, …). Inside a commit515 // bracket the frames are BUFFERED and delivered together at the boundary (after every view has516 // folded), so a listener re-reading ANY view sees post-commit state — the same cross-view-atomic517 // guarantee view subscribers get; outside one they go inline (still after this view's own fold,518 // so the post-fold view passed here — and any re-read of it — reflects the post-apply state, and519 // an opted-in removed subtree the view just attached rides along on the `remove` op).520 // No-op (one undefined-check) when nothing is subscribed.521 if (this.changeListeners) {522 if (this.commitDepth > 0) this.pendingChanges.push([qid, ev]);523 else {524 const view = this.views.get(qid);525 for (const l of this.changeListeners) l(qid, ev, view);526 }527 }528 }529530 /** Retire a view's SSR seed — from the view (so `data` switches from the seed to the maintained531 * tree) AND from the seeds map (so no later mount re-seeds a now-live query) — but ONLY once the532 * query is AUTHORITATIVE (`resultType === "complete"`). Called BEFORE the fold it accompanies, so533 * that fold's notify already reflects the live tree with no empty gap. Idempotent.534 *535 * The gate is the fix for the synchronous optimistic/wasm backend: it fires a query's FIRST snapshot536 * from LOCAL, not-yet-synced state while the query is still `unknown` (`registerMaterialized` marks a537 * lifecycle-backed remote view `unknown` up front for exactly this), then delivers the authoritative538 * rows one event later as a `catchUp` batch — having already flipped the query to `complete`. So the539 * seed survives the pre-sync snapshot (`unknown` ⇒ skip) and retires on the catch-up (`complete` ⇒540 * retire). A lifecycle-LESS backend (pure wasm, the SSR one-shot, tests) is `complete` from creation,541 * so its first snapshot retires the seed exactly as before this gate existed. */542 private retireSeedIfLive(qid: QueryId): boolean {543 const view = this.views.get(qid);544 if (!view || view.resultType !== "complete") return false;545 const retired = view.retireSeed(); // idempotent — false once the seed is already retired546 // Drop the map entry (so no later mount re-seeds a now-live query) only when we actually retired,547 // and only pay the `stableKey` hash while a seed is outstanding (the map is empty on a no-SSR app).548 if (retired && this.seeds.size > 0) {549 const ast = this.asts.get(qid);550 if (ast) this.seeds.delete(stableKey(ast));551 }552 return retired;553 }554555 /** Retire the SSR seed (if authoritative) and fold the accompanying hydration delta — BEFORE the556 * fold so its notify already reflects the live tree with no empty gap. The subtlety: a hydration557 * can fold NOTHING — a 0-row authoritative result, or one whose rows are already present in `top`558 * (a query whose result is fully covered by an already-hydrated sibling: the shared rows dedup to559 * zero net base mutations). Then {@link FlatArrayView.applyChanges} notifies nothing, so the560 * seed→tree switch would never reach subscribers and the view freezes on the stale seed. Guard561 * against that: if the seed retired but the fold was a no-op, force the handoff notify (inline, or562 * via the commit-boundary flush). Flash-safe — the forced notify only fires when there was nothing563 * to fold, so `data` is already the correct live tree by then. */564 private foldHydration(qid: QueryId, events: FlatChange[], phase: ChangePhase): void {565 const retired = this.retireSeedIfLive(qid);566 const view = this.views.get(qid);567 if (!view) return;568 const deferring = this.commitDepth > 0;569 const changed = view.applyChanges(events, this.removedSubtreeWanted > 0, deferring, phase);570 if (deferring) {571 // Flush at the boundary if the fold changed the tree OR a retire is owed a notify (flush()572 // always notifies, even with no buffered segments).573 if (changed || retired) this.pendingFlush.add(view);574 } else if (retired && !changed) {575 view.notify(); // the fold notified nothing but the seed retired — land the handoff576 }577 }578579 /** Fold a batch into its view, then notify now or — inside a commit bracket — defer the view's580 * notification to the commit boundary, so all sibling views fold first (cross-view-atomic581 * notification; see `commitDepth`). */582 private applyAndTrack(qid: QueryId, events: FlatChange[], phase: ChangePhase): void {583 const view = this.views.get(qid);584 if (!view) return;585 const deferring = this.commitDepth > 0;586 if (view.applyChanges(events, this.removedSubtreeWanted > 0, deferring, phase) && deferring) {587 this.pendingFlush.add(view);588 }589 }590591 /** Deliver everything deferred during the just-ended commit, after every affected view has folded592 * (cross-view-atomic notification): view subscribers first, then the raw change stream593 * (narrators/devtools), each frame in arrival order. A throwing listener does not stop the others594 * — the first error is re-raised only once the whole flush completes (mirroring the backend's595 * per-query isolation). View subscribers run before change listeners, preserving the per-event596 * order that held before coalescing (a view's subscribers fired before its change frame). */597 private flushCommit(): void {598 const views = this.pendingFlush.size > 0 ? [...this.pendingFlush] : [];599 if (views.length) this.pendingFlush.clear();600 const changes = this.pendingChanges.length > 0 ? this.pendingChanges.splice(0) : [];601 if (views.length === 0 && changes.length === 0) return;602 let firstError: unknown;603 let hasError = false;604 const note = (e: unknown): void => {605 if (!hasError) {606 hasError = true;607 firstError = e;608 }609 };610 for (const view of views) {611 try {612 view.flush();613 } catch (e) {614 note(e);615 }616 }617 if (this.changeListeners) {618 for (const [qid, ev] of changes) {619 const view = this.views.get(qid);620 for (const l of this.changeListeners) {621 try {622 l(qid, ev, view);623 } catch (e) {624 note(e);625 }626 }627 }628 }629 if (hasError) throw firstError;630 }631632 /** Subscribe to the raw per-query {@link ChangeEvent} stream (hello / snapshot / batch) the Store633 * routes to its views, tagged by `qid`. Fired AFTER the event is folded — and, for a commit that634 * fans out to several queries (the in-process engine's `onCommitBoundary`), after EVERY view in635 * that commit has folded — so a listener that re-reads ANY view (its own or a sibling) sees636 * post-commit state, never a torn mid-commit one. Frames keep their arrival (engine-dispatch)637 * order, one per affected query. This is the supported way to drive change-derived layers638 * (e.g. {@link resolveChange} → @rindle/narrator, or a devtools pane) off a live store — attach639 * BEFORE `materialize` to catch a synchronous backend's first `hello`+`snapshot`.640 * The per-query view `WireSchema` rides the `hello` frame (also readable via `view.schema`).641 *642 * The third listener arg is the post-fold {@link ArrayView} for this `qid` (so a template wanting643 * list context — current `data`, `schema`, `resultType` — needn't look it up). It is ALWAYS the644 * plural view, even for a top-level `.one()` query: the Store retains the list-shaped view, not the645 * SingularView wrapper handed back from `materialize`. `undefined` only if the view is mid-teardown.646 *647 * `opts.removedSubtree` enriches every `remove` op on this stream with the full removed subtree648 * ({@link FlatOp.node}), so a consumer can resolve a removed row's nested subs exactly as on an649 * `add` (a bare remove carries only the leaving row). It is reconstructed client-side from the650 * view — no wire/engine cost — and paid only on real evictions while at least one subscriber asks.651 *652 * Returns a detach function; multiple listeners may attach. */653 subscribeChanges(654 listener: (qid: QueryId, ev: ChangeEvent, view?: ArrayView<unknown>) => void,655 opts?: { removedSubtree?: boolean },656 ): () => void {657 (this.changeListeners ??= new Set()).add(listener);658 if (opts?.removedSubtree) this.removedSubtreeWanted++;659 return () => {660 if (this.changeListeners?.delete(listener) && opts?.removedSubtree) this.removedSubtreeWanted--;661 };662 }663664 /** Subscribe to per-query {@link ResultType} transitions (the server-channel lifecycle the backend665 * pushes — `unknown` → `complete`, etc.), tagged by `qid`. Fired only on a CHANGE (never replayed666 * on attach; read `view.resultType` for the current value). The supported seam for a status-driven667 * layer (a devtools pane). Returns a detach function; multiple listeners may attach. */668 subscribeResultType(listener: (qid: QueryId, rt: ResultType) => void): () => void {669 (this.resultTypeListeners ??= new Set()).add(listener);670 return () => {671 this.resultTypeListeners?.delete(listener);672 };673 }674675 // --- dev-only introspection (DEBUG-TOOLS-BROWSER-DESIGN §2/§6.2) --------------676 // A single read-only accessor per the design's "surface, not instrument" rule. Only ever called677 // by `@rindle/devtools` (imported in dev); inert otherwise. The delta + resultType taps devtools678 // also needs are the SUPPORTED `subscribeChanges` / `subscribeResultType` seams above.679680 /** A read-only snapshot of every live materialized view (DEBUG-TOOLS-BROWSER-DESIGN §4.2): its681 * qid, AST, {@link ResultType}, row count, and a capped row sample. Built fresh on each call from682 * the live `views`/`asts` maps — never cached, never mutating. `sampleRows` caps the per-query683 * peek (default 50) so a large view doesn't bloat the snapshot. */684 __inspect(sampleRows = 50): StoreInspect {685 const queries: QueryInspect[] = [];686 for (const [qid, view] of this.views) {687 const ast = this.asts.get(qid);688 if (!ast) continue; // a view mid-teardown (ast already dropped) — skip689 const data = view.data;690 queries.push({691 qid,692 ast,693 resultType: view.resultType,694 rowCount: data.length,695 sample: sampleRows >= data.length ? data : data.slice(0, sampleRows),696 });697 }698 return { queries };699 }700701 private columns(table: string): Record<string, { type: ColType }> {702 const meta = this.schema.tables[table];703 if (!meta) throw new Error(`unknown table: ${table}`);704 return meta.columns as unknown as Record<string, { type: ColType }>;705 }706707 /** An object row → a positional cell array in the table's column order (json → string). */708 private positionalize(table: string, obj: Record<string, unknown>): WireValue[] {709 const cols = this.columns(table);710 return Object.keys(cols).map((name) => {711 const v = obj[name];712 if (cols[name].type === "json" && v != null && typeof v === "object") return JSON.stringify(v);713 return (v ?? null) as WireValue;714 });715 }716717 /** The per-level column types parallel to the WireSchema, so the view parses json columns. */718 private viewTypes(ws: WireSchema, ast: Ast): ViewTypes {719 const cols = this.columns(ast.table);720 const columnTypes = ws.columns.map((name) => cols[name]?.type ?? "string");721 const rels: Record<number, ViewTypes> = {};722 for (const rel of ws.relationships) {723 if (!rel.child) continue;724 const sub = (ast.related ?? []).find((r) => r.subquery.alias === rel.name);725 if (sub) rels[rel.slot] = this.viewTypes(rel.child, sub.subquery);726 }727 return { columnTypes, rels };728 }729}730