Rindle

API index and search · Build metadata

Source snapshot

packages/client/src/ssr.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// SSR client integration (SSR-DESIGN.md §6). Server-side render is synchronous, so the data2// must already be in the cache when the render reads it. The seam is the BACKEND, not the hook:3//4//   - Browser: the normal ws-backed Store — retain → lease → subscribe → live.5//   - Server : a Store over a one-shot REST backend ({@link OneShotBackend}) that never streams.6//              The route loader `preload`s each query through an authorized one-shot read, then7//              `dehydrate` serializes those first-paint snapshots into the HTML; the8//              browser `store.hydrate(...)`s them and its live `subscribe` reconciles (§5).9//10// `useQuery` is byte-for-byte identical in both — only the injected backend (and whether a live11// subscribe ever happens) differs.1213import { assertNoLocalTables, type Query } from "./query.ts";14import type { ColsMap, Schema } from "./schema.ts";15import { type AssembledNode, type DehydratedState, Store } from "./store.ts";16import type { Backend, ChangeEvent, Mutation, QueryId, RemoteQuery } from "./types.ts";1718/** An authorized one-shot read result, as far as the server Store needs it: the19 *  assembled rows plus the `cvMin` baseline they reflect. (Matches `@rindle/daemon-client`'s20 *  `QueryOnceOutput`, but `@rindle/client` stays dependency-free — inject the call.) */21export interface OneShotResult {22  rows: AssembledNode[];23  cvMin?: number;24}2526/** The one-shot read the server Store calls to preload a query. Two topologies inject different27 *  fns (SSR-DESIGN.md §6):28 *29 *   - **Direct to the daemon** (the trusted tier holds the daemon token): use `ast` —30 *     `(i) => daemon.query(i)`.31 *   - **Through the application's API tier** (the authority resolves names → ASTs, the loader never32 *     sends a raw AST): use `name`/`args` — `(i) => fetch('/api/rindle/read', {name: i.name, args: i.args})`.33 *34 *  `ast` is always present (the Store seeds the local view by its `viewKey`); `name`/`args` are35 *  present when the preloaded query came from `defineQuery`. This Store does not authenticate36 *  users or filter rows. The injected function must enforce access: either call the authenticated37 *  API route, or authorize and construct the AST before a privileged direct daemon read. */38export type OneShotQueryFn = (39  input: { ast: unknown; name?: string; args?: unknown; visibilityKey?: string; ttlMs?: number },40) => Promise<OneShotResult>;4142/**43 * A no-op live backend for SSR (SSR-DESIGN.md §6.1): it opens no transport and never streams.44 * `registerQuery` is inert, `mutate` rejects, and no `ChangeEvent` is pushed. Every view stays45 * PENDING — reading its SSR {@link Store.seedAssembled seed} — and, lacking `onResultType`,46 * reports `complete` (a backend with no server lifecycle leaves every view authoritative).47 */48export class OneShotBackend implements Backend {49  registerQuery(_qid: QueryId, _ast: unknown, _remote?: RemoteQuery): void {}50  unregisterQuery(_qid: QueryId): void {}51  mutate(_mutations: Mutation[]): Promise<void> {52    return Promise.reject(new Error("the SSR one-shot backend is read-only; mutate on the browser store"));53  }54  onEvent(_handler: (queryId: QueryId, event: ChangeEvent) => void): void {}55}5657export interface ServerStoreOptions {58  /** Performs an authorized one-shot read. See {@link OneShotQueryFn} for access ownership. */59  query: OneShotQueryFn;60  /** Optional namespace for query deduplication, forwarded to every preload. Different keys61   *  prevent pipeline sharing for the same AST. This key does not authorize access or filter rows;62   *  the query must already contain the required visibility predicates. */63  visibilityKey?: string;64  /** Optional idle TTL (ms) the warm pipeline is left at, forwarded to every preload (SSR-DESIGN.md65   *  §3.4). The TTL is NOT part of the dedup key, so a shared materialization keeps the LONGEST TTL66   *  any caller requested (max-wins) — `ttlMs` can extend a query's warm-handoff window, never67   *  shrink it; absent ⇒ the daemon's default idle TTL. */68  ttlMs?: number;69}7071/**72 * The server-side Store wrapper (SSR-DESIGN.md §6.2). Wraps a {@link Store} over a73 * {@link OneShotBackend} and adds the loader-phase `preload` plus `dehydrate`. Pass `.store` to74 * the React `<Rindle>` provider for the synchronous render; return `.dehydrate()` from the loader.75 * Create one instance per request, with an authorized read function for that request's principal.76 * Its snapshots seed views; they do not persist synced rows in the browser database.77 */78export class ServerStore<S extends ColsMap> {79  readonly store: Store<S>;80  private readonly schema: Schema<S>;81  private readonly opts: ServerStoreOptions;8283  constructor(schema: Schema<S>, opts: ServerStoreOptions) {84    this.store = new Store(schema, new OneShotBackend());85    this.schema = schema;86    this.opts = opts;87  }8889  /** Run the one-shot read for `query` and seed its first-paint snapshot (SSR-DESIGN.md §6.2).90   *  Call once per query in the route loader, before the synchronous render. */91  async preload(query: Query<any, any, any>): Promise<void> {92    const ast = query.ast();93    // E3 backstop: a local-only table must never be forwarded to the daemon. The `OneShotBackend`'s94    // `registerQuery` is a no-op (no engine-side E3 check runs on the SSR path), and a query built95    // from the LOCAL builder permits local tables — so the AST is re-checked here regardless of how96    // it was built (201-LOCAL-ONLY-TABLES-DESIGN.md E3).97    assertNoLocalTables(ast, this.schema);98    // Forward the AST (a direct-to-daemon backend reads it) plus the named identity when this came99    // from `defineQuery` (an API-tier backend resolves `(name, args)` → AST itself, never trusting100    // a client AST). The seed is keyed by the AST's `viewKey` either way, so the browser's101    // `getServerSnapshot` finds it (SSR-DESIGN.md §6.2).102    const named = typeof query.name === "string" ? { name: query.name, args: query.args } : undefined;103    const result = await this.opts.query({104      ast,105      ...named,106      visibilityKey: this.opts.visibilityKey,107      ttlMs: this.opts.ttlMs,108    });109    this.store.seedAssembled(ast, result.rows, result.cvMin ?? 0);110  }111112  /** The dehydrated first-paint cache for every preloaded query — embed in the HTML, then113   *  `store.hydrate(...)` it in the browser. */114  dehydrate(): DehydratedState {115    return this.store.dehydrate();116  }117118  /**119   * Loader-phase convenience over {@link preload} + {@link dehydrate}: preload EVERY query (reads run120   * concurrently) and return the dehydrated first-paint cache. Composition keeps this to one read per121   * composed root query — no request waterfall (SSR-DESIGN.md §6.2).122   *123   * A failed read produces no seed for that query and calls `onError`, if provided. Other reads124   * can still supply seeds. The browser must establish its own successful live subscription to125   * obtain the missing data; hydration alone does not retry this read. Without `onError`, the126   * preload failure is silent. An `onError` callback that throws rejects the batch.127   */128  async preloadAll(129    queries: Array<Query<any, any, any>>,130    opts: { onError?: (query: Query<any, any, any>, err: unknown) => void } = {},131  ): Promise<DehydratedState> {132    await Promise.all(133      queries.map((query) => this.preload(query).catch((err) => opts.onError?.(query, err))),134    );135    return this.dehydrate();136  }137}138139/** Construct a {@link ServerStore} — the one-shot REST Store for server-side rendering. */140export function createServerStore<S extends ColsMap>(141  schema: Schema<S>,142  opts: ServerStoreOptions,143): ServerStore<S> {144  return new ServerStore(schema, opts);145}146