Rindle

API index and search · Build metadata

Source snapshot

packages/client/src/store.ts

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.
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