Rindle

API index and search · Build metadata

Source snapshot

packages/normalized/src/backend.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// NormalizedBackend — the composition that turns a NORMALIZED server stream into the flat2// `Backend` seam the existing `Store` already drives (NORMALIZED-CHANGES-DESIGN.md §5/§7).3//4// A normalized client runs its OWN local engine and queries over its base tables. This5// backend wires that up by composing three pieces behind one `Backend`:6//   - a local `@rindle/wasm` engine (the base tables + local IVM that materializes results),7//   - `NormalizedSync` (cross-query refcount → net base-table mutations), and8//   - a `NormalizedSource` (the server's per-query normalized stream).9//10// The flow per query: `registerQuery` registers it BOTH on the local engine (→ an empty11// `FlatArrayView`) and on the server (→ its normalized footprint stream). Each normalized12// batch folds through `NormalizedSync` into net base mutations, which are applied to the13// local engine — whose own flat change stream then updates every affected view. Because the14// local engine fans a base write to ALL local queries, a row synced for one query updates15// any other query that reads it (the "local query resolution" the design is built for).16//17// Since this implements `Backend`, `new Store(schema, new NormalizedBackend(...))` reuses the18// whole Store/ArrayView machinery unchanged — the normalized path adds composition, not a19// second materialization layer.2021import { normalizedTableSchemas, Store, tableSpec } from "@rindle/client";22import type {23  Ast,24  Backend,25  BackendDevObserver,26  ChangeEvent,27  ColsMap,28  Mutation,29  NormalizedEvent,30  NormalizedSource,31  NormalizedTableSchema,32  QueryId,33  RemoteQuery,34  ResultType,35  Schema,36} from "@rindle/client";37import { WasmBackend } from "@rindle/wasm";3839import { aggTableSchemas, rewriteAggregates } from "./agg-table.ts";40import { NormalizedSync, type ColCounts, type PkCols } from "./sync.ts";4142// The normalized seam + event/table-schema types live in `@rindle/client` (sibling of `Backend`),43// so a ws (`@rindle/remote`) source and the in-process native source emit the same shape and44// `@rindle/normalized` needs no dependency on `@rindle/remote`. Re-exported for callers.45export type { NormalizedEvent, NormalizedSource, NormalizedTableSchema } from "@rindle/client";4647/** Each table's primary-key column indices, from the typed schema. */48function pkColsFromSchema<S extends ColsMap>(schema: Schema<S>): PkCols {49  const out: PkCols = {};50  for (const name of Object.keys(schema.tables)) out[name] = tableSpec(schema.tables[name]).primaryKey;51  return out;52}5354/** Each table's FULL column count (the union-row width), from the typed schema. */55function colCountsFromSchema<S extends ColsMap>(schema: Schema<S>): ColCounts {56  const out: ColCounts = {};57  for (const name of Object.keys(schema.tables)) out[name] = tableSpec(schema.tables[name]).columns.length;58  return out;59}6061/** Each table's column name → base ColId, from the typed schema (for mapping a projected62 *  hello's columns back to base positions, PROJECTION-SUPPORT-DESIGN.md §5.2). */63function colIndexFromSchema<S extends ColsMap>(schema: Schema<S>): Record<string, Map<string, number>> {64  const out: Record<string, Map<string, number>> = {};65  for (const name of Object.keys(schema.tables)) {66    const cols = tableSpec(schema.tables[name]).columns;67    out[name] = new Map(cols.map((c, i) => [c, i]));68  }69  return out;70}7172export class NormalizedBackend<S extends ColsMap> implements Backend {73  private readonly local: WasmBackend<S>;74  private readonly sync: NormalizedSync;75  private readonly source: NormalizedSource;76  private handler: (qid: QueryId, ev: ChangeEvent) => void = () => {};77  /** The Store's commit-boundary handler ({@link Backend.onCommitBoundary}), forwarded from the78   *  local engine's `dispatch` brackets so the Store folds every affected view before notifying any79   *  subscriber (cross-view-atomic notification). A normalized batch becomes net base mutations80   *  applied to the local engine in one commit, which fans to every view reading those rows — this81   *  carries that commit's boundary up so all those views notify together. */82  private boundaryHandler: (phase: "begin" | "end") => void = () => {};83  private readonly devObservers = new Set<BackendDevObserver>();84  private readonly remoteSubs = new Map<string, RemoteSub>();85  private readonly localToRemote = new Map<QueryId, string>();86  private readonly remoteRetainToLocal = new Map<QueryId, QueryId | undefined>();87  private readonly sourceToRemote = new Map<QueryId, string>();88  private readonly resultTypes = new Map<QueryId, ResultType>();89  private readonly hydrated = new Set<QueryId>();90  /** Local qids that are JUST hydrating on the in-flight `onNormalized` snapshot — set only for the91   *  duration of its `local.mutate`, so the local-event forwarder stamps their fold `catchUp`: the92   *  whole first result set arrives as a `batch` (we hydrate the view by mutating the embedded engine,93   *  which speaks in batches — there is no second `snapshot`), and `catchUp` is the flag that tells the94   *  Store this batch IS hydration (retire the SSR seed, phase it as a snapshot for narration). The95   *  optimistic backend does the identical thing across its reconcile cycle. `null` outside a hydrate. */96  private catchUpQids: Set<QueryId> | null = null;97  private resultTypeHandler: (qid: QueryId, rt: ResultType) => void = () => {};98  /** The client's OWN typed per-table schemas (for CRIT#4 validation), fixed at construction. */99  private readonly clientTables: NormalizedTableSchema[];100  /** Synthetic aggregate tables (`__agg_*`) registered so far, by name (§3.3). Per aggregate101   *  DEFINITION (not per query), so two queries over the same count share one table. */102  private readonly synthetic = new Map<string, NormalizedTableSchema>();103  /** table → (column name → base ColId), for mapping a projected hello to base positions. */104  private readonly colIndex: Record<string, Map<string, number>>;105  /** table → full column count, to detect whether a hello's table is projected. */106  private readonly colCounts: ColCounts;107  /** Synthetic table name → how many registered queries reference it. Materialized on `0→1`,108   *  reclaimed (engine source + refcount layer) on `1→0` — so aggregate state is not permanent (§4). */109  private readonly syntheticRefs = new Map<string, number>();110  /** queryId → the synthetic tables it referenced at registration, to decrement on teardown. */111  private readonly queryAggTables = new Map<QueryId, string[]>();112113  constructor(schema: Schema<S>, source: NormalizedSource) {114    this.local = new WasmBackend(schema);115    // The local engine's flat stream IS this backend's stream upward (→ the Store's views). A116    // just-hydrating query's first result set arrives here as a `batch` (we fold it by mutating the117    // engine); stamp it `catchUp` so the Store treats it as hydration (retire the SSR seed, phase it118    // as a snapshot) rather than an incremental change — see {@link catchUpQids}.119    this.local.onEvent((qid, ev) => {120      const stamped = ev.type === "batch" && this.catchUpQids?.has(qid) ? { ...ev, catchUp: true } : ev;121      this.handler(qid, stamped);122    });123    // Forward the local engine's commit brackets up to the Store (cross-view-atomic notification):124    // a normalized batch's net base mutations commit to `this.local` in one transaction, so its125    // commit boundary is ours — every view those rows touch then notifies together.126    this.local.onCommitBoundary((phase) => this.boundaryHandler(phase));127    this.colIndex = colIndexFromSchema(schema);128    this.colCounts = colCountsFromSchema(schema);129    this.sync = new NormalizedSync(pkColsFromSchema(schema), this.colCounts);130    this.source = source;131    // Hand the source our OWN typed schema so it validates each server hello against it and132    // rejects a column-order / PK skew instead of silently transposing positional cells (CRIT#4).133    this.clientTables = normalizedTableSchemas(schema);134    this.source.expectClientSchema?.(this.clientTables);135    this.source.onNormalized((qid, ev) => this.onNormalized(qid, ev));136  }137138  registerQuery(qid: QueryId, ast: Ast, remote?: RemoteQuery): void {139    // A relationship aggregate (`count(child)`) is DISPLAYED from a server-authoritative140    // synthetic base table, not recomputed locally (AGGREGATE-SYNC-DESIGN.md §3.3): register141    // that table (engine + refcount layer + hello validation), then drive the local engine142    // off a rewritten AST whose `count` relationships read it with a plain projected join.143    this.ensureSyntheticTables(qid, ast);144    // Local first: builds the (empty) view synchronously. Then the server stream hydrates it.145    this.local.registerQuery(qid, rewriteAggregates(ast));146    if (remote) {147      this.retainRemote(qid, remote, qid);148    } else {149      this.hydrated.add(qid);150      this.setResultType(qid, "complete");151    }152  }153154  /** Register every synthetic aggregate table `ast` needs that we haven't seen yet: on the155   *  local engine (so it can join to it), on `NormalizedSync` (so its rows refcount/GC by156   *  group key), and into the source's expected-schema set (so the server's `hello` — which157   *  advertises the same table — passes CRIT#4 validation, the client deriving the schema158   *  identically). Idempotent across queries that share an aggregate definition. */159  private ensureSyntheticTables(qid: QueryId, ast: Ast): void {160    if (this.queryAggTables.has(qid)) return; // idempotent per qid161    let added = false;162    const names: string[] = [];163    for (const t of aggTableSchemas(ast)) {164      names.push(t.name);165      const prev = this.syntheticRefs.get(t.name) ?? 0;166      this.syntheticRefs.set(t.name, prev + 1);167      if (prev > 0) continue; // another query already materialized it — just refcount168      this.synthetic.set(t.name, t);169      this.local.registerTable(t.name, { columns: t.columns, primaryKey: t.primaryKey });170      this.sync.registerTable(t.name, t.primaryKey);171      added = true;172    }173    if (names.length) this.queryAggTables.set(qid, names);174    if (added) this.source.expectClientSchema?.([...this.clientTables, ...this.synthetic.values()]);175  }176177  /** Decrement each synthetic table query `qid` referenced; remove the ones that reach 0 (no178   *  reader left) from the engine + refcount layer — aggregate state reclaimed, not permanent179   *  (§4). Runs AFTER `local.unregisterQuery(qid)` so the source has no live connection when180   *  `unregisterTable` frees it. */181  private releaseSyntheticTables(qid: QueryId): void {182    const names = this.queryAggTables.get(qid);183    if (!names) return;184    this.queryAggTables.delete(qid);185    let removed = false;186    for (const name of names) {187      const next = (this.syntheticRefs.get(name) ?? 1) - 1;188      if (next > 0) {189        this.syntheticRefs.set(name, next);190        continue;191      }192      this.syntheticRefs.delete(name);193      this.local.unregisterTable(name);194      this.sync.unregisterTable(name);195      this.synthetic.delete(name);196      removed = true;197    }198    if (removed) this.source.expectClientSchema?.([...this.clientTables, ...this.synthetic.values()]);199  }200201  unregisterQuery(qid: QueryId): void {202    const remoteQid = this.releaseRemote(qid);203    if (remoteQid !== undefined) {204      // Drop this remote footprint's refcounts; rows referenced by no other named query are205      // GC'd from the local base tables (→ other views shrink if they shared them).206      const muts = this.sync.dropQuery(remoteQid);207      if (muts.length) void this.local.mutate(muts);208    }209    this.local.unregisterQuery(qid);210    // Pipeline gone (no live conn) + `__agg` rows GC'd above → free any now-unread synthetic table.211    this.releaseSyntheticTables(qid);212    this.resultTypes.delete(qid);213    this.hydrated.delete(qid);214  }215216  retainRemoteQuery(qid: QueryId, remote: RemoteQuery, localQueryId?: QueryId, ast?: Ast): void {217    if (ast) this.ensureSyntheticTables(qid, ast);218    this.retainRemote(qid, remote, localQueryId);219  }220221  releaseRemoteQuery(qid: QueryId): void {222    const remoteQid = this.releaseRemote(qid);223    this.resultTypes.delete(qid);224    this.hydrated.delete(qid);225    this.releaseSyntheticTables(qid);226    if (remoteQid === undefined) return;227    const muts = this.sync.dropQuery(remoteQid);228    if (muts.length) void this.local.mutate(muts);229  }230231  /** Writes are authoritative-only here: send to the server, the stream reconciles locally.232   *  (Optimistic local apply + rebase is Slice 6.) */233  mutate(mutations: Mutation[]): Promise<void> {234    return this.source.mutate(mutations);235  }236237  onEvent(handler: (qid: QueryId, ev: ChangeEvent) => void): void {238    this.handler = handler;239  }240241  onCommitBoundary(handler: (phase: "begin" | "end") => void): void {242    this.boundaryHandler = handler;243  }244245  onResultType(handler: (qid: QueryId, rt: ResultType) => void): void {246    this.resultTypeHandler = handler;247  }248249  __attachDevtoolsServerDeltas(observer: BackendDevObserver): () => void {250    this.devObservers.add(observer);251    return () => {252      this.devObservers.delete(observer);253    };254  }255256  private onNormalized(qid: QueryId, ev: NormalizedEvent): void {257    this.emitServerDelta(qid, ev);258    // The slim hello carries table schemas + fingerprint; envelope validation (epoch/seq/gap)259    // is the source's job (the ws Subscriber, §5.3) — it precedes every (re)hydrate snapshot.260    if (ev.type === "hello") {261      // Learn this query's per-table column map (PROJECTION-SUPPORT-DESIGN.md §5.2): map each262      // advertised column to its base ColId BY NAME. The hello may carry FEWER columns than the263      // client's schema (a projection) or MORE (an EXPANDED server table mid an264      // `expand-then-contract` migration) — a column the client lacks maps to `-1`, a DROP265      // sentinel the sync layer discards. Register the map so the sync layer scatters the rows266      // into the shared full-width union; a table whose map is an in-order, drop-free full width267      // IS '*' (a verbatim full row) and is left unregistered. Idempotent across re-hydrate epochs.268      for (const t of ev.tables) {269        const full = this.colCounts[t.name];270        if (full === undefined) continue; // unknown/synthetic table → '*' (full presence)271        const index = this.colIndex[t.name];272        const cols = t.columns.map((name) => index?.get(name) ?? -1);273        // Register a non-trivial map; otherwise revert to '*' — and CLEAR any stale map a prior274        // epoch left (a server that expanded then contracted back), so the now-exact rows don't275        // scatter through a `-1`-bearing layout (silent cell corruption).276        if (cols.length !== full || cols.some((c, i) => c !== i)) this.sync.registerProjection(qid, t.name, cols);277        else this.sync.unregisterProjection(qid, t.name);278      }279      return;280    }281    // A `snapshot` is the seq-0 baseline — initial OR a re-hydrate under a new epoch (§5.3). Both282    // go through `rehydrate` (a footprint DIFF): for the first one the footprint is empty so it283    // degenerates to all-adds; for a re-hydrate it removes rows that left during the gap. A284    // `batch` is an incremental delta → `applyBatch`.285    const muts = ev.type === "snapshot" ? this.sync.rehydrate(qid, ev.ops) : this.sync.applyBatch(qid, ev.ops);286    if (ev.type !== "snapshot") {287      if (muts.length) void this.local.mutate(muts); // incremental delta → local engine → views288      return;289    }290    // A snapshot is the query's hydration point. Flip its views to `complete` BEFORE folding the rows291    // (the Store retires the SSR seed only once the query is authoritative), and capture the local292    // qids that transition into hydrated on THIS snapshot so the forwarder stamps their fold `catchUp`293    // — the first result set is delivered by mutating the engine, i.e. as a `batch`, not a `snapshot`.294    // (An already-live view reading the same rows sees them as a genuine incremental add, so it is NOT295    // in this set and folds a normal batch.) Order mirrors the optimistic backend (hydrate → complete,296    // THEN the catch-up delta).297    const wasHydrated = new Set(this.hydrated);298    this.markSubHydrated(qid);299    let newly: Set<QueryId> | null = null;300    for (const localQid of this.hydrated) {301      if (!wasHydrated.has(localQid)) (newly ??= new Set()).add(localQid);302    }303    this.catchUpQids = newly;304    try {305      if (muts.length) void this.local.mutate(muts); // → local engine → flat stream → views306    } finally {307      this.catchUpQids = null;308    }309    // A hydration can fold NOTHING: a 0-row result, or one whose rows are already present in the engine310    // (fully covered by an already-hydrated sibling → the shared rows dedup to 0 net muts). Then no311    // batch was emitted above, so the Store never saw the hydration point and the SSR seed would stay312    // stuck. Signal it explicitly with an EMPTY catch-up per just-hydrated qid — the Store retires the313    // seed and reveals whatever is already in the view's tree.314    if (!muts.length && newly) {315      for (const localQid of newly) this.handler(localQid, { type: "batch", events: [], catchUp: true });316    }317  }318319  private emitServerDelta(sourceQid: QueryId, ev: NormalizedEvent): void {320    if (!this.devObservers.size) return;321    for (const qid of this.localQidsForSource(sourceQid)) {322      for (const o of this.devObservers) o.onServerDelta?.(qid, { format: "normalized", event: ev });323    }324  }325326  private localQidsForSource(sourceQid: QueryId): QueryId[] {327    const key = this.sourceToRemote.get(sourceQid);328    const sub = key ? this.remoteSubs.get(key) : undefined;329    if (!sub) return [sourceQid];330    const localQids = [...sub.localQids.keys()];331    return localQids.length ? localQids : [sourceQid];332  }333334  private retainRemote(retainQid: QueryId, remote: RemoteQuery, localQueryId: QueryId | undefined = retainQid): void {335    const key = remoteKey(remote);336    let sub = this.remoteSubs.get(key);337    let isNew = false;338    if (!sub) {339      sub = { sourceQid: retainQid, remote, refCount: 0, localQids: new Map(), hydrated: false };340      this.remoteSubs.set(key, sub);341      this.sourceToRemote.set(sub.sourceQid, key);342      isNew = true;343    }344    sub.refCount++;345    if (localQueryId !== undefined) {346      sub.localQids.set(localQueryId, (sub.localQids.get(localQueryId) ?? 0) + 1);347      if (sub.hydrated) this.hydrated.add(localQueryId);348      else this.hydrated.delete(localQueryId);349      this.recomputeResultType(localQueryId);350    }351    this.localToRemote.set(retainQid, key);352    this.remoteRetainToLocal.set(retainQid, localQueryId);353    if (isNew) this.source.registerQuery(sub.sourceQid, remote);354  }355356  private releaseRemote(retainQid: QueryId): QueryId | undefined {357    const key = this.localToRemote.get(retainQid);358    if (!key) return undefined;359    this.localToRemote.delete(retainQid);360    const localQueryId = this.remoteRetainToLocal.get(retainQid);361    this.remoteRetainToLocal.delete(retainQid);362    const sub = this.remoteSubs.get(key);363    if (!sub) return undefined;364    sub.refCount--;365    if (localQueryId !== undefined) {366      const refs = (sub.localQids.get(localQueryId) ?? 0) - 1;367      if (refs > 0) {368        sub.localQids.set(localQueryId, refs);369      } else {370        sub.localQids.delete(localQueryId);371        if (!this.hasRemoteDependency(localQueryId)) {372          this.hydrated.add(localQueryId);373          this.recomputeResultType(localQueryId);374        }375      }376    }377    if (sub.refCount > 0) return undefined;378    this.source.unregisterQuery(sub.sourceQid);379    this.sourceToRemote.delete(sub.sourceQid);380    this.remoteSubs.delete(key);381    return sub.sourceQid;382  }383384  private markSubHydrated(sourceQid: QueryId): void {385    const key = this.sourceToRemote.get(sourceQid);386    if (!key) return;387    const sub = this.remoteSubs.get(key);388    if (!sub || sub.hydrated) return;389    sub.hydrated = true;390    for (const localQid of sub.localQids.keys()) {391      this.hydrated.add(localQid);392      this.recomputeResultType(localQid);393    }394  }395396  private hasRemoteDependency(localQid: QueryId): boolean {397    for (const sub of this.remoteSubs.values()) {398      if (sub.localQids.has(localQid)) return true;399    }400    return false;401  }402403  private setResultType(qid: QueryId, rt: ResultType): void {404    if (this.resultTypes.get(qid) === rt) return;405    this.resultTypes.set(qid, rt);406    this.resultTypeHandler(qid, rt);407  }408409  private recomputeResultType(qid: QueryId): void {410    this.setResultType(qid, this.hydrated.has(qid) ? "complete" : "unknown");411  }412}413414interface RemoteSub {415  sourceQid: QueryId;416  remote: RemoteQuery;417  refCount: number;418  localQids: Map<QueryId, number>;419  hydrated: boolean;420}421422function remoteKey(remote: RemoteQuery): string {423  return stableJson([remote.name, remote.args]);424}425426function stableJson(value: unknown): string {427  if (value === null || typeof value !== "object") return JSON.stringify(value);428  if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;429  const obj = value as Record<string, unknown>;430  return `{${Object.keys(obj)431    .sort()432    .map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`)433    .join(",")}}`;434}435436/** A local-first {@link Store} whose base tables are fed by a server's normalized stream.437 *  The returned Store is the ordinary `@rindle/client` Store — `store.query…materialize()` and438 *  `store.write(…)` work as always; only the backend composition differs. */439export function createNormalizedStore<S extends ColsMap>(440  schema: Schema<S>,441  source: NormalizedSource,442): Store<S> {443  return new Store(schema, new NormalizedBackend(schema, source));444}445