API index and search · Build metadata
Source snapshot
packages/client/src/ssr.ts
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