diff --git a/README.md b/README.md
index ed23bcd5..99073c45 100644
--- a/README.md
+++ b/README.md
@@ -270,6 +270,43 @@ import { bindSqlJsStore } from '@sleeperhq/react-native-cellar/sqljs';
initSqlJs({ locateFile: (file) => `/static/${file}` }).then((SQL) => bindSqlJsStore('items', SQL, itemStore));
```
+On a server that renders with React, such as Next.js, each store binds to a `node:sqlite` database of its own. A worker
+thread writes it, so parsing and writing a fetched body never holds up a render. The process also says it renders on
+the server:
+
+```ts
+import { configureCellar, reactQueryRuntime } from '@sleeperhq/react-native-cellar';
+import { bindNodeStore } from '@sleeperhq/react-native-cellar/node';
+
+configureCellar({ errors, query: reactQueryRuntime({ client: () => serverQueryClient, useQuery, useQueries }), serverRendering: true });
+bindNodeStore('items', itemStore);
+```
+
+With `serverRendering`, hooks never read a store or fetch: they answer from a snapshot, so the HTML is what the browser
+renders first, before its own stores are bound. A page's server code captures the reads its components will make and
+hands the snapshot to `CellarHydrate`:
+
+```tsx
+const snapshot = await dehydrateReads([
+ readCall(ItemService.Hooks.useGroupItems, { groupId }),
+ readCall(ScoreService.Hooks.useLiveScore, { gameId }, { live: true }),
+]);
+return ;
+```
+
+`dehydrateReads` fetches each read's partitions unless they are fresh, waits for them, and returns each read's value as
+plain data, filed under the read and its args. In the browser a read answers from it while its store has no rows for the
+partition, and doesn't fetch the partition, so a page whose server already sent what it shows downloads none of it
+again. `live: true` is for a value that keeps changing: the browser fetches the partition and moves to the store's value
+once it lands. A call the snapshot doesn't hold renders as it would without one, `empty` and loading, on the server and
+in the browser's first render alike.
+
+The server's query client is one per process, not per request, so a partition stays fresh across requests for its
+`staleTime` and two requests that need it share one fetch. The database is a file in the OS temp directory unless
+`path` names one, and since it is only a cache, binding deletes whatever was there. The `./node` entry is CommonJS,
+because its writer is a file beside it; a bundler should leave the package external (`serverExternalPackages` in
+Next.js).
+
`useQuery` and `useQueries` are passed in rather than imported, so an app keeps its own fetch policy — focus
gating, retries, whatever it already does. `reactQueryRuntime` takes React Query v4's hooks, or an app's wrappers of
them, with whatever generics they declare. Until `configureCellar` runs Cellar is inert: reads answer
@@ -426,6 +463,8 @@ with nothing to compare against, its rows go straight in and every entity counts
| --- | --- |
| `configureCellar(services)` | where an error report goes, and the React Query runtime an ingest mounts on |
| `reactQueryRuntime({ client, useQuery, useQueries })` | an app's React Query v4 client and hooks as that runtime, typed without casts at the call site |
+| `dehydrateReads(calls)`, `readCall(read, params, { live })` | a server's snapshot of what a page's reads answer, as plain data |
+| `CellarHydrate` | hands a snapshot to the reads under it, which answer from it until their store has the rows |
| `createBoundedLru(max)` | the LRU map every cache here keeps its entries in, for an app's own bounded memo |
| `reportStoreDegradation`, `createOnceGuard` | how Cellar reports a silent slowdown, and warn-once guards a test can reset |
@@ -436,6 +475,7 @@ with nothing to compare against, its rows go straight in and every entity counts
| `@sleeperhq/react-native-cellar` | everything above: what a store, a service or a screen writes against |
| `…/nitro` | `openNitroConnection`, `bindSqliteStore` and `retrySqliteStores`, over `react-native-nitro-sqlite` — the only part that touches native code — and `dumpSqliteStores`, which copies every store's database into one file for a desktop SQLite browser |
| `…/sqljs` | `bindSqlJsStore` and `openSqlJsConnection`, over a sql.js module the app loads — what the web runs on |
+| `…/node` | `bindNodeStore` and `openNodeConnection`, over `node:sqlite`, with writes on a worker thread — what a server that renders runs on. CommonJS only |
| `…/testing` | sql.js off-device (`createTestRowTable`, `createSqlJsConnection`), an in-process version atom, the host services as spies, and the internals only a test reaches for |
| `…/diagnostics` | `getIngestTimings` and `rollupIngestTimings`, for a developer surface; no shipping screen reads these |
| `…/inspector` | every declared store (`inspectedStores`) with its schema, where it runs, its partitions and the entities they changed lately, one entity (an id within a partition) with its rows and cache entries, its caches with their hits, misses, evictions, estimated heap and entries, and read-only SQL over its database a page at a time; and a log of what the stores did — writes, moves between databases, fetches, degradation reports with their numbers and callsite — with a listener for each new event (`onInspectorEvent`). What the DevTools plugin reads; the log is recorded in development builds only |
diff --git a/docs/internals.md b/docs/internals.md
index db1efc7e..b4b3a126 100644
--- a/docs/internals.md
+++ b/docs/internals.md
@@ -439,6 +439,7 @@ follow the trip a row takes: it lands in a `table/`, gets there through `write/`
| `push_ingest.ts` | the push counterpart: buffer → dedupe → chunked write → bump, with per-partition holds for an in-flight fetch |
| `shred_columns.ts` | co-located shred column table (each column's `sql` + its `js` twin), and `defineShredColumns`, which binds it to everything derived from it — `names`, `columnDefs`, `row`, `decode` (a stored row read back into JS values), and, once every column declares an `op`, `namedOps` and `ops` |
| `shred_spec.ts` | the native shred op language + its JS reference interpreter |
+| `js_shred.ts` | a native shred run in JS: the statements a shred command writes, built by the reference interpreter — what sql.js in tests and the Node writer run |
| `read/` — how rows come out | |
| ------------------------------------------------ | ------------------------------------------------------------------------ |
@@ -447,6 +448,7 @@ follow the trip a row takes: it lands in a `table/`, gets there through `write/`
| `row_shaping.ts` | `rowsOf`: a query, then rows → list / ordered list / record / groups, each with the stable empty |
| `derived_values.ts` | `byEntity` caches: a value per entity, built from the entity's rows by `fromRows` (usually a view model), kept until a write changes that entity, so a bump rebuilds only the entities that moved and every other reader gets the same reference back |
| `facade.ts` | what a service is written against: `pairRead`, so it exposes the hook and the imperative read together, plus the types its methods are spelled in (`ReadOptions`, `MaybeId`, `Loose`) |
+| `hydration.ts` | read snapshots for a server render: `dehydrateReads` and `readCall` capture what reads answer, `CellarHydrate` hands it to the reads under it, and the lookup a hook makes before it reads its store. The context and the capture symbol are shared through `Symbol.for`, so a process loading two copies of Cellar still has one of each |
| `windowed_list.ts` | windowed list reads (fetch a page, keep the rest in SQLite), and the per-row `useWindowedDetail` that indexes back into one |
| `reactivity/` — how a write reaches a component | |
@@ -472,6 +474,11 @@ follow the trip a row takes: it lands in a `table/`, gets there through `write/`
| ------------------------------------------------ | ------------------------------------------------------------------------ |
| `nitro_connection.ts` | the `SqliteConnection` over `react-native-nitro-sqlite`: pragmas, param coercion, the native shred sentinel, and the binds that degrade rather than throw |
+| `node/` — a server that renders | |
+| ------------------------------------------------ | ------------------------------------------------------------------------ |
+| `node_connection.ts` | the `SqliteConnection` over `node:sqlite`: a reader on the main thread, and every write handed to the writer thread in order. A blocking call (`execute`, `executeBatch`) waits on a shared signal for its answer, which only happens inside a write's turn in the queue, where the writer is idle; WAL lets the reader run alongside a write |
+| `writer.ts` | the worker thread that owns the database for writing: one ordered request port, a prepared-statement cache, and shred commands run through `js_shred.ts`, so parsing a body and writing its rows never land on the thread that renders |
+
Two more entries ship beside the core one. `./diagnostics` holds what a developer surface dumps —
`getIngestTimings` and `rollupIngestTimings` — kept out of the core entry because a shipping screen has no
business reading them. `./testing` is what a store's tests are written against: `sqljs_connection.ts` (a real
diff --git a/node/package.json b/node/package.json
new file mode 100644
index 00000000..5a7c4a9e
--- /dev/null
+++ b/node/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@sleeperhq/react-native-cellar/node",
+ "private": true,
+ "main": "../lib/commonjs/node/index.js",
+ "types": "../lib/typescript/commonjs/node/index.d.ts"
+}
diff --git a/package.json b/package.json
index 07d708f3..3acb9578 100644
--- a/package.json
+++ b/package.json
@@ -95,6 +95,16 @@
"default": "./lib/commonjs/redux/index.js"
}
},
+ "./node": {
+ "import": {
+ "types": "./lib/typescript/commonjs/node/index.d.ts",
+ "default": "./lib/commonjs/node/index.js"
+ },
+ "require": {
+ "types": "./lib/typescript/commonjs/node/index.d.ts",
+ "default": "./lib/commonjs/node/index.js"
+ }
+ },
"./package.json": "./package.json"
},
"files": [
@@ -106,6 +116,7 @@
"diagnostics/package.json",
"inspector/package.json",
"redux/package.json",
+ "node/package.json",
"!**/tests",
"!**/*.test.ts",
"!**/*.probe.ts"
diff --git a/src/index.ts b/src/index.ts
index 55d3e9ed..783c750a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -55,6 +55,8 @@ export type { ShredOp, ShredSpec, NativeShredSpec } from './write/shred_spec';
export { rowsOf } from './read/row_shaping';
export type { MaybeId, ReadOptions, Loose, PairedRead, PairedReadAcross } from './read/facade';
export { pairRead } from './read/facade';
+export type { Capturable, Captured, CellarHydrateProps, ReadCall, ReadSnapshot, SnapshotEntry } from './read/hydration';
+export { CAPTURE, CellarHydrate, dehydrateReads, readCall } from './read/hydration';
export type { EachOf, ReadAcross } from './read/surface';
export type { Coverage, CoverageProviderProps } from './read/coverage';
export { createCoverage } from './read/coverage';
diff --git a/src/node/index.ts b/src/node/index.ts
new file mode 100644
index 00000000..ca23e81f
--- /dev/null
+++ b/src/node/index.ts
@@ -0,0 +1,11 @@
+/**
+ * Cellar on a Node server: each store bound to a `node:sqlite` database of its own, written by a worker thread so
+ * ingesting a fetched body never holds up a render. Pair it with `configureCellar({ serverRendering: true })` and the
+ * snapshot surface (`dehydrateReads`, `CellarHydrate`) in the core entry.
+ */
+
+const g = globalThis as { __DEV__?: boolean };
+g.__DEV__ ??= process.env.NODE_ENV !== 'production';
+
+export type { NodeConnection, NodeConnectionOptions } from './node_connection';
+export { bindNodeStore, openNodeConnection } from './node_connection';
diff --git a/src/node/node_connection.ts b/src/node/node_connection.ts
new file mode 100644
index 00000000..f818bf12
--- /dev/null
+++ b/src/node/node_connection.ts
@@ -0,0 +1,175 @@
+/**
+ * The {@linkcode SqliteConnection} for Node, over `node:sqlite`, for a server that renders a store's reads. A worker
+ * thread ({@linkcode ./writer}) holds the database for writing, so a fetched body is parsed and written off the thread
+ * that renders; the main thread reads through a connection of its own to the same file, which WAL lets run alongside a
+ * write.
+ */
+
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { MessageChannel, receiveMessageOnPort, Worker } from 'node:worker_threads';
+
+import type { BindOptions } from '../define_sqlite_store';
+import { reportStoreDegradation } from '../diagnostics/telemetry';
+import type { BatchCommand, PinnedConnection, QueryExecResult, ShredCommand, SqliteConnection } from '../table/connection';
+import { bindable, nodeSqlite, statementCache, type Param, type WriterReply, type WriterRequest } from './writer';
+
+/** How long a call the main thread blocks on may wait behind the writes queued ahead of it. */
+const SYNC_TIMEOUT_MS = 30_000;
+
+export interface NodeConnectionOptions {
+ /** The database file. A new file in the OS temp directory by default; whatever is at the path is deleted first. */
+ path?: string;
+ /** The writer's script, for a build that moves it; `writer.js` beside this module by default. */
+ writerFile?: string;
+}
+
+/** A connection over `node:sqlite`, with the means to shut its writer down. */
+export interface NodeConnection extends SqliteConnection {
+ /** The database file. */
+ readonly path: string;
+ /** Stops the writer and closes both handles. Statements after this throw. */
+ close: () => Promise;
+}
+
+function removeDatabase(file: string): void {
+ for (const suffix of ['', '-wal', '-shm']) fs.rmSync(`${file}${suffix}`, { force: true });
+}
+
+function errorOf(reply: WriterReply): Error {
+ const error = new Error(reply.error?.message ?? 'cellar node writer: unknown error') as Error & { code?: string };
+ if (reply.error?.code) error.code = reply.error.code;
+ return error;
+}
+
+/**
+ * Opens a database for one store: its writer thread, and the main thread's reader over the same file. Blocks until the
+ * writer has opened the file, which is at startup, when a store binds.
+ */
+export function openNodeConnection(options: NodeConnectionOptions = {}): NodeConnection {
+ const file = options.path ?? path.join(os.tmpdir(), `cellar-${process.pid}-${Math.random().toString(36).slice(2)}.db`);
+ removeDatabase(file);
+
+ const requests = new MessageChannel();
+ const replies = new MessageChannel();
+ const signal = new SharedArrayBuffer(4);
+ const waiting = new Int32Array(signal);
+ const worker = new Worker(options.writerFile ?? path.join(__dirname, 'writer.js'), {
+ workerData: { cellarWriter: { path: file, requests: requests.port2, replies: replies.port2, signal } },
+ transferList: [requests.port2, replies.port2],
+ });
+ // A store's writer lives as long as the process; it must not be what keeps the process alive.
+ worker.unref();
+ requests.port1.unref();
+
+ let nextId = 0;
+ const pending = new Map void; reject: (error: Error) => void }>();
+ requests.port1.on('message', (reply: WriterReply) => {
+ const waiter = pending.get(reply.id);
+ if (!waiter) return;
+ pending.delete(reply.id);
+ if (reply.error) waiter.reject(errorOf(reply));
+ else waiter.resolve(reply.rows ?? []);
+ });
+ let stopped: Error | undefined;
+ const stop = (error: Error): void => {
+ stopped ??= error;
+ for (const waiter of pending.values()) waiter.reject(stopped);
+ pending.clear();
+ };
+ worker.on('error', (error: unknown) => stop(error instanceof Error ? error : new Error(String(error))));
+ worker.on('exit', (code) => stop(new Error(`cellar node writer: the writer exited (code ${code})`)));
+
+ type Request = WriterRequest extends infer R ? (R extends { id: number; sync: boolean } ? Omit : never) : never;
+
+ const callSync = (request: Request): unknown[] => {
+ if (stopped) throw stopped;
+ Atomics.store(waiting, 0, 0);
+ nextId += 1;
+ requests.port1.postMessage({ ...request, id: nextId, sync: true });
+ if (Atomics.wait(waiting, 0, 0, SYNC_TIMEOUT_MS) === 'timed-out') {
+ throw new Error(`cellar node writer: no answer in ${SYNC_TIMEOUT_MS}ms`);
+ }
+ const reply = receiveMessageOnPort(replies.port1)?.message as WriterReply | undefined;
+ if (!reply) throw new Error('cellar node writer: the writer signalled but sent no reply');
+ if (reply.error) throw errorOf(reply);
+ return reply.rows ?? [];
+ };
+
+ const callAsync = (request: Request): Promise =>
+ new Promise((resolve, reject) => {
+ if (stopped) {
+ reject(stopped);
+ return;
+ }
+ nextId += 1;
+ pending.set(nextId, { resolve, reject });
+ requests.port1.postMessage({ ...request, id: nextId, sync: false });
+ });
+
+ callSync({ kind: 'ready' });
+
+ const { DatabaseSync } = nodeSqlite();
+ const db = new DatabaseSync(file);
+ db.exec('PRAGMA busy_timeout = 5000;');
+ const prepare = statementCache(db);
+ const result = (rows: unknown[]): QueryExecResult => ({ rows: { _array: rows } });
+
+ const reader: PinnedConnection = {
+ execute: (sql, params) => result(prepare(sql).all(...bindable(params as readonly Param[] | undefined))),
+ reader: undefined,
+ };
+
+ return {
+ path: file,
+ reader,
+ execute: (sql, params) => result(callSync({ kind: 'execute', sql, params })),
+ executeAsync: async (sql, params) => result(await callAsync({ kind: 'execute', sql, params })),
+ executeBatch: (commands) => {
+ callSync({ kind: 'batch', commands });
+ },
+ executeBatchAsync: async (commands) => {
+ await callAsync({ kind: 'batch', commands });
+ },
+ shredBatchAsync: async (commands: ReadonlyArray) => {
+ await callAsync({ kind: 'batch', commands });
+ },
+ close: async () => {
+ db.close();
+ requests.port1.close();
+ replies.port1.close();
+ await worker.terminate();
+ },
+ };
+}
+
+/** A store, as far as binding it needs. */
+interface BindableStore {
+ bindSqlite: (conn: SqliteConnection, options?: BindOptions) => void;
+}
+
+/**
+ * Binds `store` to a database of its own on this machine, written by a worker thread. A failure is reported, not
+ * thrown, and the store's reads stay empty.
+ */
+export function bindNodeStore(label: string, store: BindableStore, options?: NodeConnectionOptions): NodeConnection | undefined {
+ try {
+ const conn = openNodeConnection(options);
+ store.bindSqlite(conn, { database: `node:${label}` });
+ return conn;
+ } catch (error) {
+ reportStoreDegradation({
+ scope: `node.bind.${label}`,
+ group: 'node.bind',
+ context: 'failed to bind the store to node:sqlite; its reads are empty for the life of the process',
+ error,
+ extra: { label },
+ });
+ return undefined;
+ }
+}
+
+// Exported so the built declaration files keep these names in scope for the doc links above; an import that only a
+// doc comment uses is dropped from them.
+export type { SqliteConnection };
diff --git a/src/node/writer.ts b/src/node/writer.ts
new file mode 100644
index 00000000..0bc85062
--- /dev/null
+++ b/src/node/writer.ts
@@ -0,0 +1,110 @@
+/**
+ * The Node writer: a worker thread that owns a store's database for writing, so parsing a fetched body and writing its
+ * rows happen off the thread that renders. It takes requests over one port, in order, and answers each on that port,
+ * or, for a call the main thread is blocked on, on the sync port with a signal it waits on.
+ */
+
+import type { DatabaseSync, StatementSync } from 'node:sqlite';
+import { workerData, type MessagePort } from 'node:worker_threads';
+
+import { shredCommandStatements } from '../write/js_shred';
+import type { BatchCommand, ShredCommand } from '../table/connection';
+
+/** What the main thread hands the writer when it starts. */
+export interface WriterData {
+ path: string;
+ requests: MessagePort;
+ replies: MessagePort;
+ signal: SharedArrayBuffer;
+}
+
+export type Param = string | number | null | undefined;
+
+export type WriterRequest =
+ | { id: number; sync: boolean; kind: 'execute'; sql: string; params?: readonly Param[] }
+ | { id: number; sync: boolean; kind: 'batch'; commands: ReadonlyArray }
+ | { id: number; sync: true; kind: 'ready' };
+
+export type WriterReply = { id: number; rows?: unknown[]; error?: { message: string; code?: string } };
+
+/**
+ * `node:sqlite`, loaded at run time rather than imported: a bundler that doesn't know the module yet would turn a static
+ * import into a reference it can't resolve, and fail the build of the server that bundles this.
+ */
+export function nodeSqlite(): typeof import('node:sqlite') {
+ return process.getBuiltinModule('node:sqlite') as typeof import('node:sqlite');
+}
+
+/** Prepared statements kept per connection; a store runs a few dozen distinct ones over and over. */
+const STATEMENT_CACHE_MAX = 256;
+
+/** A connection's statements by SQL, most recently used last, so the oldest is the first evicted. */
+export function statementCache(db: DatabaseSync): (sql: string) => StatementSync {
+ const statements = new Map();
+ return (sql) => {
+ let statement = statements.get(sql);
+ if (statement) {
+ statements.delete(sql);
+ } else {
+ statement = db.prepare(sql);
+ if (statements.size >= STATEMENT_CACHE_MAX) statements.delete(statements.keys().next().value as string);
+ }
+ statements.set(sql, statement);
+ return statement;
+ };
+}
+
+/** `undefined` binds as NULL, the way every other connection takes it. */
+export function bindable(params: readonly Param[] | undefined): Array {
+ return params ? params.map((param) => (param === undefined ? null : param)) : [];
+}
+
+function startWriter({ path, requests, replies, signal }: WriterData): void {
+ const g = globalThis as { __DEV__?: boolean };
+ g.__DEV__ ??= process.env.NODE_ENV !== 'production';
+
+ const { DatabaseSync: Database } = nodeSqlite();
+ const db = new Database(path);
+ db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = OFF; PRAGMA busy_timeout = 5000;');
+ const prepare = statementCache(db);
+ const run = (sql: string, params?: readonly Param[]): unknown[] => prepare(sql).all(...bindable(params));
+
+ const batch = (commands: ReadonlyArray): void => {
+ db.exec('BEGIN;');
+ try {
+ for (const command of commands) {
+ if (Array.isArray(command)) run(command[0], command[1]);
+ else for (const [sql, params] of shredCommandStatements(command as ShredCommand)) run(sql, params);
+ }
+ db.exec('COMMIT;');
+ } catch (error) {
+ db.exec('ROLLBACK;');
+ throw error;
+ }
+ };
+
+ const waiting = new Int32Array(signal);
+ requests.on('message', (request: WriterRequest) => {
+ let reply: WriterReply;
+ try {
+ if (request.kind === 'execute') reply = { id: request.id, rows: run(request.sql, request.params) };
+ else if (request.kind === 'batch') {
+ batch(request.commands);
+ reply = { id: request.id };
+ } else reply = { id: request.id };
+ } catch (error) {
+ const { message, code } = error as { message?: string; code?: string };
+ reply = { id: request.id, error: { message: message ?? String(error), code } };
+ }
+ if (!request.sync) {
+ requests.postMessage(reply);
+ return;
+ }
+ replies.postMessage(reply);
+ Atomics.store(waiting, 0, 1);
+ Atomics.notify(waiting, 0);
+ });
+}
+
+const data = workerData as { cellarWriter?: WriterData } | null;
+if (data?.cellarWriter) startWriter(data.cellarWriter);
diff --git a/src/read/facade.ts b/src/read/facade.ts
index 1b939c33..0fcc4b81 100644
--- a/src/read/facade.ts
+++ b/src/read/facade.ts
@@ -7,6 +7,7 @@ import type { EachOf, Read, ReadAcross, ReadCallOptions } from './surface';
import type { DataResult } from '../store_result';
import type { CommonDef } from './surface';
import type { RawQuery } from '../write/fetch_ingest';
+import { CAPTURE, type Capturable, type Captured, type readCall } from './hydration';
/**
* An id a caller may not have yet, such as a route param still loading. A published read accepts it as is, and returns
@@ -34,7 +35,7 @@ export type Loose = { [K in keyof T]: T[K] | null | undefined };
/**
* A store read as a service publishes it (from {@linkcode pairRead}): a hook for components and a getter for other
* code, which return the same value. The hook is named with `use` so React Compiler treats it as a hook rather than
- * memoizing the call away.
+ * memoizing the call away. Either can be handed to {@linkcode readCall} to capture the read for a server render.
*/
export interface PairedRead {
/**
@@ -42,26 +43,28 @@ export interface PairedRead {
* state as a {@linkcode DataResult}, and re-renders the component when the value changes. Returns
* {@linkcode CommonDef.empty | empty}, and fetches nothing, until every field it was passed has a value.
*/
- useValue: (args: {
+ useValue: ((args: {
/**
* The read's args. Every value is nullable; the read returns {@linkcode CommonDef.empty | empty} until each has
* one, apart from the read's {@linkcode CommonDef.optionalArgs | optionalArgs}.
*/
params: Params;
- } & ReadOptions) => DataResult;
+ } & ReadOptions) => DataResult) &
+ Capturable;
/**
* The read's current value, for code outside a component. Starts a fetch if the partition has never been fetched (but
* doesn't refetch a stale one), and returns {@linkcode CommonDef.empty | empty} until it has rows. Tracked: inside a
* tracking scope (a {@linkcode PairedRead.useValue | useValue} read, `useTrackedStores`, a tracked selector), the
* scope re-runs when the value changes.
*/
- getValue: (args: {
+ getValue: ((args: {
/**
* The read's args. Every value is nullable; the read returns {@linkcode CommonDef.empty | empty} until each has
* one, apart from the read's {@linkcode CommonDef.optionalArgs | optionalArgs}.
*/
params: Params;
- }) => T;
+ }) => T) &
+ Capturable;
}
/**
@@ -83,23 +86,24 @@ export function pairRead(read: () => Read): PairedRead(read: () => Read | ReadAcross): PairedRead, T> | PairedReadAcross, T> {
// Sound because a read runs nothing until every value it was passed is in hand.
const argsOf = (params: Loose): Args => params as unknown as Args;
+ const capturable = { [CAPTURE]: (params: Loose, live: boolean): Promise => read().capture(argsOf(params), live) };
return {
- useValue: (args) => read().useValue(argsOf(args.params), args.options),
- getValue: (args) => read().getValue(argsOf(args.params)),
- useEach: (args) => {
+ useValue: Object.assign((args: { params: Loose } & ReadOptions) => read().useValue(argsOf(args.params), args.options), capturable),
+ getValue: Object.assign((args: { params: Loose }) => read().getValue(argsOf(args.params)), capturable),
+ useEach: Object.assign((args: { params: Loose } & ReadOptions) => {
const current = read();
if (!('useEach' in current)) throw new Error('useEach is only on a read across several partitions (defineReadAcross)');
return current.useEach(argsOf(args.params), args.options);
- },
+ }, capturable),
};
}
/** A read across several partitions as a service publishes it: {@linkcode PairedRead}, plus its per-partition hook. */
export interface PairedReadAcross extends PairedRead {
/** One `DataResult` per partition the args name, in order: {@linkcode ReadAcross.useEach}. */
- useEach: (args: { params: Params } & ReadOptions) => readonly DataResult>[];
+ useEach: ((args: { params: Params } & ReadOptions) => readonly DataResult>[]) & Capturable;
}
// Exported so the built declaration files keep these names in scope for the doc links above; an import that only a
// doc comment uses is dropped from them.
-export type { CommonDef, DataResult, RawQuery, Read };
+export type { CommonDef, DataResult, RawQuery, Read, readCall };
diff --git a/src/read/hydration.ts b/src/read/hydration.ts
new file mode 100644
index 00000000..0ed69cf6
--- /dev/null
+++ b/src/read/hydration.ts
@@ -0,0 +1,135 @@
+/**
+ * Read snapshots, for an app that renders on a server: the server works out what a page's reads answer
+ * ({@linkcode dehydrateReads}), and the browser renders those values ({@linkcode CellarHydrate}) until its own store
+ * holds the rows, so the HTML and the browser's first render agree while the store is still being bound.
+ */
+
+import React, { useContext, useMemo } from 'react';
+
+import { KEY_SEP } from '../key';
+import { reportStoreDegradation } from '../diagnostics/telemetry';
+
+/** One read's value as the server rendered it. */
+export interface SnapshotEntry {
+ /** The read's value. */
+ readonly value: unknown;
+ /** For a read across several partitions, which of them held rows, so `useEach` reports each one's state. */
+ readonly present?: readonly boolean[];
+ /** Set when the browser should fetch the read's partitions and move to live values once they land. */
+ readonly live?: true;
+}
+
+/** What {@linkcode dehydrateReads} hands a page: each captured read's value, by the read and its args. Plain data. */
+export interface ReadSnapshot {
+ readonly reads: Readonly>;
+}
+
+/** What capturing one read produces: its snapshot key and entry, or nothing when the read had nothing to answer. */
+export type Captured = readonly [key: string, entry: SnapshotEntry] | undefined;
+
+/** How a read's hook and getter expose the capture {@linkcode dehydrateReads} runs. Shared across copies of Cellar. */
+export const CAPTURE: unique symbol = Symbol.for('cellar.read.capture') as never;
+
+/** A read's hook or getter, as {@linkcode pairRead} publishes it, carrying its capture. */
+export interface Capturable {
+ readonly [CAPTURE]?: (params: Params, live: boolean) => Promise;
+}
+
+/** One read to capture: its hook or getter, the params a component will call it with, and whether to go live. */
+export interface ReadCall {
+ readonly capture: () => Promise;
+ readonly label: string;
+}
+
+/** The key a snapshot files a read's value under: the read's name in its store, then the read's args key. */
+export function snapshotKeyOf(readId: string, argsKey: string): string {
+ return `${readId}${KEY_SEP}${argsKey}`;
+}
+
+/**
+ * Names one read for {@linkcode dehydrateReads}: a service's hook or getter for it (`PlayerService.Hooks.usePlayer`),
+ * and the params a component on the page will pass it. With `live`, the browser still fetches the read's partitions
+ * and moves to the live value once they land; without it, the snapshot's value is the read's answer until something
+ * else fetches the partition.
+ */
+export function readCall(
+ read: ((args: { params: Params }) => unknown) & Capturable,
+ params: Params,
+ options?: { live?: boolean },
+): ReadCall {
+ const capture = read[CAPTURE];
+ if (!capture) throw new Error('readCall: pass a read published with pairRead (a service hook or getter)');
+ return { capture: () => capture(params, options?.live ?? false), label: read.name || 'a read' };
+}
+
+/**
+ * Fetches each read's partitions if needed, waits for them, and returns what each read answers, as plain data a server
+ * passes to {@linkcode CellarHydrate}. A read whose fetch fails is left out and reported, so its component renders as it
+ * would with no snapshot: loading, then fetching in the browser.
+ */
+export async function dehydrateReads(calls: readonly ReadCall[]): Promise {
+ const settled = await Promise.allSettled(calls.map((call) => call.capture()));
+ const reads: Record = {};
+ settled.forEach((result, index) => {
+ if (result.status === 'fulfilled') {
+ if (result.value) reads[result.value[0]] = result.value[1];
+ return;
+ }
+ reportStoreDegradation({
+ scope: 'hydration.capture',
+ context: 'a read could not be captured for a server render, so the browser fetches it instead',
+ error: result.reason,
+ extra: { read: calls[index].label },
+ });
+ });
+ return { reads };
+}
+
+type Entries = ReadonlyMap;
+
+const CONTEXT = Symbol.for('cellar.read.hydration');
+const registry = globalThis as unknown as Record | undefined>;
+
+/**
+ * One context however many copies of Cellar a server bundle loads, so a provider from one reaches a read from another.
+ * Made on first use: a React Server Component imports this module under a React build with no `createContext`.
+ */
+function hydrationContext(): React.Context {
+ let context = registry[CONTEXT];
+ if (!context) {
+ context = React.createContext(undefined);
+ context.displayName = 'CellarHydrate';
+ registry[CONTEXT] = context;
+ }
+ return context;
+}
+
+export interface CellarHydrateProps {
+ /** What {@linkcode dehydrateReads} returned on the server. */
+ snapshot: ReadSnapshot | undefined;
+ children?: React.ReactNode;
+}
+
+/**
+ * Makes a server's read snapshot available to the reads under it. A read finds its value here while its store has no
+ * rows for it, on the server always, and in the browser until its partition lands. Nested providers add to the one
+ * above, an inner entry winning.
+ */
+export function CellarHydrate({ snapshot, children }: CellarHydrateProps): React.ReactElement {
+ const HydrationContext = hydrationContext();
+ const outer = useContext(HydrationContext);
+ const entries = useMemo(() => {
+ const own = Object.entries(snapshot?.reads ?? {});
+ if (!own.length) return outer;
+ const merged = new Map(outer);
+ for (const [key, entry] of own) merged.set(key, entry);
+ return merged;
+ }, [outer, snapshot]);
+ return React.createElement(HydrationContext.Provider, { value: entries }, children);
+}
+
+/** The snapshot entry for one read call, if a {@linkcode CellarHydrate} above holds one; `readId` undefined looks up nothing. */
+export function useHydrated(readId: string | undefined, argsKey: string): SnapshotEntry | undefined {
+ const entries = useContext(hydrationContext());
+ return readId && entries ? entries.get(snapshotKeyOf(readId, argsKey)) : undefined;
+}
diff --git a/src/read/surface.ts b/src/read/surface.ts
index 0d2272cd..0500e211 100644
--- a/src/read/surface.ts
+++ b/src/read/surface.ts
@@ -32,7 +32,8 @@ import type { shallowEqualStruct } from '../caches';
import type { byEntity } from './derived_values';
import type { StoreSurface } from '../define_sqlite_store';
import type { PrimeIntent } from '../write/fetch_ingest';
-import { queryRuntime, type QueryRuntime } from '../runtime';
+import { isServerRendering, queryRuntime, type QueryRuntime } from '../runtime';
+import { snapshotKeyOf, useHydrated, type Captured, type SnapshotEntry } from './hydration';
/**
* The parts of a store's fetch ingest that its reads use: the hooks that fetch partitions, and imperative fetch starts.
@@ -49,6 +50,8 @@ export interface FetchOwner {
ensure: (key: Key) => void;
/** Fetches a partition again now, however recently it was fetched. */
refetch: (key: Key) => void;
+ /** Fetches a partition unless it is fresh, resolving once its rows are written; a server capture waits on it. */
+ prefetch?: (key: Key) => Promise;
}
/**
@@ -246,6 +249,11 @@ export interface Read {
* fetch's state as a {@linkcode DataResult}, and re-renders the component when the value changes.
*/
useValue: (args: Args | undefined, options?: ReadCallOptions) => DataResult;
+ /**
+ * Fetches the read's partitions unless they are fresh, waits for them, and returns the read's value as a snapshot
+ * entry. What `dehydrateReads` runs, through the facade.
+ */
+ capture: (args: Args | undefined, live: boolean) => Promise;
}
/** The element type of a read's value when that value is a list, one entry per partition the read spans. */
@@ -382,13 +390,32 @@ function readGates(primes: () => boolean, addressable: boolean, primeWanted: boo
};
}
+/**
+ * Where a hook's value comes from beyond its store: the snapshot entry for its call, if any, and, when the process
+ * renders on the server, whether the browser's first render of it will be loading.
+ */
+interface Hydration {
+ entry: SnapshotEntry | undefined;
+ server: boolean;
+ serverLoading: boolean;
+}
+
+/** A hook with no snapshot entry, in a browser or on a device. */
+const UNHYDRATED: Hydration = Object.freeze({ entry: undefined, server: false, serverLoading: false });
+
/**
* The status and {@linkcode DataResult} every read ends with; `hasData` is a thunk, called once the read is known
- * enabled.
+ * enabled. A snapshot entry answers where the store has no rows for the read, and always on the server.
*/
-function useReadTail(data: T, enabled: boolean, hasData: () => boolean, prime: PrimeState, doRefetch: () => void): DataResult {
- const status = runSubscribed(() => readStatus(enabled, enabled && hasData(), prime));
- return useResult(data, status, prime.isFetching, doRefetch);
+function useReadTail(data: T, enabled: boolean, hasData: () => boolean, prime: PrimeState, doRefetch: () => void, hydration = UNHYDRATED): DataResult {
+ const present = runSubscribed(() => enabled && hasData());
+ const { entry, server } = hydration;
+ const fromSnapshot = entry !== undefined && (server || !present);
+ let status: DataStatus;
+ if (fromSnapshot) status = 'success';
+ else if (server) status = hydration.serverLoading ? 'loading' : 'success';
+ else status = readStatus(enabled, present, prime);
+ return useResult(fromSnapshot ? (entry.value as T) : data, status, prime.isFetching, doRefetch);
}
/** What a read's `select` returned when it stopped on an arg its caller didn't pass. */
@@ -518,6 +545,11 @@ export function createReadSurface(kernel: ReadSurfaceKernel) {
enabled: (args: object, enabled: ((args: never) => boolean) | undefined): boolean => predicate(args, enabled),
/** The read's name in its store, for a warning or report to say which read it was. */
label,
+ /** What a snapshot files the read under, its store and its name; undefined for a read no store has named. */
+ id: (): string | undefined => {
+ const name = self && readLabels.get(self);
+ return name ? `${store}.${name}` : undefined;
+ },
/** Whether `prime` lets the read fetch; a `prime` that reads an arg its caller didn't pass doesn't. */
primes: (args: object, prime: boolean | ((args: never) => boolean) | undefined): boolean =>
typeof prime === 'function' ? predicate(args, prime) : prime ?? true,
@@ -580,12 +612,17 @@ export function createReadSurface(kernel: ReadSurfaceKernel) {
const call = resolve(args);
const key = call?.key;
const parts = call ? call.parts : NO_PARTS;
- const gates = gatesFor(args as Args, parts, (options?.enabled ?? true) && call !== undefined, options?.prime ?? true);
- const prime = usePriming(key, gates.prime, intentWith(call?.intent, options?.meta, runner.label));
const argsKey = call ? argsKeyOf(parts, args as object) : NO_ARGS_KEY;
- if (__DEV__ && gates.read) noteRead(store, argsKey, batchSizeOf(args as object));
+ const hydrated = useHydrated(call ? runner.id() : undefined, argsKey);
+ const server = isServerRendering();
+ // A snapshot that isn't live is the read's answer until something else fetches the partition.
+ const primeWanted = (options?.prime ?? true) && !(hydrated && !hydrated.live);
+ const gates = gatesFor(args as Args, parts, (options?.enabled ?? true) && call !== undefined, primeWanted);
+ const live = gates.read && !server;
+ const prime = usePriming(key, gates.prime && !server, intentWith(call?.intent, options?.meta, runner.label));
+ if (__DEV__ && live) noteRead(store, argsKey, batchSizeOf(args as object));
const data = useTrackedValue(() => (hasOne(key as Key, parts) ? run(args as Args, key as Key, parts, () => argsKey) : def.empty), [argsKey], {
- enabled: gates.read,
+ enabled: live,
isEqual: def.isEqual ?? shallowEqualValue,
empty: def.empty,
bypassGate: bypassesGates(options?.meta),
@@ -593,10 +630,25 @@ export function createReadSurface(kernel: ReadSurfaceKernel) {
const doRefetch = useCallback(() => {
if (key !== undefined) ingest?.refetch(key);
}, [argsKey]); // eslint-disable-line react-hooks/exhaustive-deps -- `argsKey` covers `key`
- return useReadTail(data, gates.read, () => addressesPartition(parts) && hasOne(key as Key, parts), prime, doRefetch);
+ const hydration = hydrated || server ? { entry: hydrated, server, serverLoading: gates.read && gates.prime } : UNHYDRATED;
+ return useReadTail(data, live, () => addressesPartition(parts) && hasOne(key as Key, parts), prime, doRefetch, hydration);
+ }
+
+ async function capture(args: Args | undefined, live: boolean): Promise {
+ const id = runner.id();
+ if (!id) throw new Error(`${store}_store: only a read declared on a store can be captured for a snapshot`);
+ const call = resolve(args);
+ if (!call || !addressesPartition(call.parts)) return undefined;
+ const { key, parts } = call;
+ const gates = gatesFor(args as Args, parts, true);
+ if (!gates.read) return undefined;
+ if (gates.prime && ingest?.prefetch) await ingest.prefetch(key);
+ const argsKey = argsKeyOf(parts, args as object);
+ const value = hasOne(key, parts) ? runTracked(() => run(args as Args, key, parts, () => argsKey)).value : def.empty;
+ return [snapshotKeyOf(id, argsKey), live ? { value, live: true } : { value }];
}
- const read: Read = { getValue, useValue };
+ const read: Read = { getValue, useValue, capture };
runner.bind(read);
return read;
}
@@ -649,26 +701,36 @@ export function createReadSurface(kernel: ReadSurfaceKernel) {
return run(args as Args, call.keys, call.partitions, () => partitionsArgsKey(call.partitions, args as object));
}
+ /** A call's snapshot entry, whether it renders on the server, and the gates that follow from both. */
+ const useCallGates = (args: Args | undefined, call: ReturnType, argsKey: string, options: ReadCallOptions | undefined) => {
+ const hydrated = useHydrated(call ? runner.id() : undefined, argsKey);
+ const server = isServerRendering();
+ const primeWanted = (options?.prime ?? true) && !(hydrated && !hydrated.live);
+ const gates = gatesFor(args as Args, call ? call.partitions : NO_PARTITIONS, (options?.enabled ?? true) && call !== undefined, primeWanted);
+ const hydration: Hydration = hydrated || server ? { entry: hydrated, server, serverLoading: gates.read && gates.prime } : UNHYDRATED;
+ return { live: gates.read && !server, prime: gates.prime && !server, hydration };
+ };
+
function useValue(args: Args | undefined, options?: ReadCallOptions): DataResult {
const call = resolve(args);
const keys = call ? call.keys : (NO_KEYS as readonly Key[]);
const entries = call ? call.entries : [];
const partitions = call ? call.partitions : NO_PARTITIONS;
- const gates = gatesFor(args as Args, partitions, (options?.enabled ?? true) && call !== undefined, options?.prime ?? true);
- const prime = usePrimingAll(keys, gates.prime, intentWith(call?.intent, options?.meta, runner.label));
const argsKey = call ? partitionsArgsKey(partitions, args as object) : NO_ARGS_KEY;
+ const gates = useCallGates(args, call, argsKey, options);
+ const prime = usePrimingAll(keys, gates.prime, intentWith(call?.intent, options?.meta, runner.label));
const data = useTrackedValue(
() => {
trackPresence(partitions);
return hasAny(entries) ? run(args as Args, keys, partitions, () => argsKey) : def.empty;
},
[argsKey],
- { enabled: gates.read, isEqual: def.isEqual ?? shallowEqualValue, empty: def.empty, bypassGate: bypassesGates(options?.meta) },
+ { enabled: gates.live, isEqual: def.isEqual ?? shallowEqualValue, empty: def.empty, bypassGate: bypassesGates(options?.meta) },
);
const doRefetch = useCallback(() => {
for (const key of keys) ingest?.refetch(key);
}, [argsKey]); // eslint-disable-line react-hooks/exhaustive-deps -- `argsKey` covers `keys`
- return useReadTail(data, gates.read, () => hasAny(entries), prime, doRefetch);
+ return useReadTail(data, gates.live, () => hasAny(entries), prime, doRefetch, gates.hydration);
}
function useEach(args: Args | undefined, options?: ReadCallOptions): readonly DataResult>[] {
@@ -676,9 +738,9 @@ export function createReadSurface(kernel: ReadSurfaceKernel) {
const keys = call ? call.keys : (NO_KEYS as readonly Key[]);
const entries = call ? call.entries : [];
const partitions = call ? call.partitions : NO_PARTITIONS;
- const gates = gatesFor(args as Args, partitions, (options?.enabled ?? true) && call !== undefined, options?.prime ?? true);
- const prime = usePrimingAll(keys, gates.prime, intentWith(call?.intent, options?.meta, runner.label));
const argsKey = call ? partitionsArgsKey(partitions, args as object) : NO_ARGS_KEY;
+ const gates = useCallGates(args, call, argsKey, options);
+ const prime = usePrimingAll(keys, gates.prime, intentWith(call?.intent, options?.meta, runner.label));
const isEqual = def.isEqual ?? shallowEqualValue;
// Which partitions hold rows is part of the tracked value, so one landing re-renders even when the value it adds
// compares equal to the empty one it replaces.
@@ -690,7 +752,7 @@ export function createReadSurface(kernel: ReadSurfaceKernel) {
},
[argsKey],
{
- enabled: gates.read,
+ enabled: gates.live,
isEqual: (left, right) => isEqual(left.value, right.value) && shallowEqualArray(left.present, right.present),
empty: emptyEach,
bypassGate: bypassesGates(options?.meta),
@@ -699,21 +761,46 @@ export function createReadSurface(kernel: ReadSurfaceKernel) {
const doRefetch = useCallback(() => {
for (const key of keys) ingest?.refetch(key);
}, [argsKey]); // eslint-disable-line react-hooks/exhaustive-deps -- `argsKey` covers `keys`
+ const { entry: hydrated, server, serverLoading } = gates.hydration;
+ const fromSnapshot = hydrated !== undefined && (server || !tracked.present.some(Boolean));
return useMemo(
() =>
entries.map((entry, index) => {
- const live = gates.read && addressesPartition(entry.parts);
- const status = runSubscribed(() => readStatus(live, live && !!tracked.present[index], prime));
- const items = tracked.value as unknown as readonly EachOf[] | undefined;
+ const addressed = addressesPartition(entry.parts);
+ const items = (fromSnapshot ? hydrated?.value : tracked.value) as unknown as readonly EachOf[] | undefined;
+ let status: DataStatus;
+ if (fromSnapshot) status = 'success';
+ else if (server) status = serverLoading && addressed ? 'loading' : 'success';
+ else {
+ const live = gates.live && addressed;
+ status = runSubscribed(() => readStatus(live, live && !!tracked.present[index], prime));
+ }
return makeResult(items?.[index] as EachOf, status, { isFetching: prime.isFetching, refetch: doRefetch });
}),
// `argsKey` covers `entries`; the prime state's fields, not its object, since the object is rebuilt each render.
// eslint-disable-next-line react-hooks/exhaustive-deps
- [tracked, argsKey, gates.read, prime.isInitialLoading, prime.isError, prime.isFetching, doRefetch],
+ [tracked, argsKey, gates.live, fromSnapshot, hydrated, server, serverLoading, prime.isInitialLoading, prime.isError, prime.isFetching, doRefetch],
);
}
- const read: ReadAcross = { getValue, useValue, useEach };
+ async function capture(args: Args | undefined, live: boolean): Promise {
+ const id = runner.id();
+ if (!id) throw new Error(`${store}_store: only a read declared on a store can be captured for a snapshot`);
+ const call = resolve(args);
+ if (!call || !call.partitions.some(addressesPartition)) return undefined;
+ const gates = gatesFor(args as Args, call.partitions, true);
+ if (!gates.read) return undefined;
+ if (gates.prime && ingest?.prefetch) {
+ const prefetch = ingest.prefetch;
+ await Promise.all(call.entries.filter((entry) => addressesPartition(entry.parts)).map((entry) => prefetch(entry.key)));
+ }
+ const argsKey = partitionsArgsKey(call.partitions, args as object);
+ const present = call.entries.map((entry) => addressesPartition(entry.parts) && hasOne(entry.key, entry.parts));
+ const value = present.some(Boolean) ? runTracked(() => run(args as Args, call.keys, call.partitions, () => argsKey)).value : def.empty;
+ return [snapshotKeyOf(id, argsKey), live ? { value, present, live: true } : { value, present }];
+ }
+
+ const read: ReadAcross = { getValue, useValue, useEach, capture };
runner.bind(read);
return read;
}
diff --git a/src/runtime.ts b/src/runtime.ts
index 7458425d..6d7ad725 100644
--- a/src/runtime.ts
+++ b/src/runtime.ts
@@ -175,6 +175,13 @@ export interface CellarRuntime {
query: QueryRuntime;
/** The app's policy for when reads are live (subscribed to their data) and when they keep their last value. */
gate: ReadGateRuntime;
+ /**
+ * Set in a process that renders on the server. Hooks answer only from the snapshot a `CellarHydrate` above them holds,
+ * never from a store, and never fetch, so the HTML is what the browser renders first, before its stores are bound.
+ * `getValue` and a store's lifecycle still read and fetch, which is how a server fills a snapshot with
+ * `dehydrateReads`.
+ */
+ serverRendering: boolean;
}
const unconfigured = createOnceGuard();
@@ -238,7 +245,11 @@ export const INERT_GATE: ReadGateRuntime = {
useReadGate: () => ALWAYS_LIVE,
};
-let runtime: CellarRuntime = { errors: INERT_ERRORS, query: INERT_QUERY, gate: INERT_GATE };
+const SLOT = Symbol.for('cellar.runtime');
+const slots = globalThis as unknown as Record;
+// One configuration however many copies of Cellar a process loads: a server can reach the core through its ESM build and
+// through the CommonJS `./node` entry.
+const slot = (slots[SLOT] ??= { runtime: { errors: INERT_ERRORS, query: INERT_QUERY, gate: INERT_GATE, serverRendering: false } });
/**
* The React Query hooks and client an app already has, as Cellar's {@linkcode QueryRuntime}. The hooks can be React
@@ -266,26 +277,33 @@ export function reactQueryRuntime(hooks: {
* configure them from different places, and a test can set one and leave the others as defaults.
*/
export function configureCellar(next: Partial): void {
- runtime = {
- errors: next.errors ?? runtime.errors,
- query: next.query ?? runtime.query,
- gate: next.gate ?? runtime.gate,
+ const current = slot.runtime;
+ slot.runtime = {
+ errors: next.errors ?? current.errors,
+ query: next.query ?? current.query,
+ gate: next.gate ?? current.gate,
+ serverRendering: next.serverRendering ?? current.serverRendering,
};
}
+/** Whether this process renders on the server; see {@linkcode CellarRuntime.serverRendering}. */
+export function isServerRendering(): boolean {
+ return slot.runtime.serverRendering;
+}
+
/** The configured error sink. */
export function errorSink(): ErrorSink {
- return runtime.errors;
+ return slot.runtime.errors;
}
/** The configured query runtime. */
export function queryRuntime(): QueryRuntime {
- return runtime.query;
+ return slot.runtime.query;
}
/** The configured read gate runtime. */
export function readGateRuntime(): ReadGateRuntime {
- return runtime.gate;
+ return slot.runtime.gate;
}
/**
@@ -293,7 +311,7 @@ export function readGateRuntime(): ReadGateRuntime {
* either way, so a call switching between the two keeps its hooks in the same order.
*/
export function useReadGateFor(bypass: boolean | undefined): ReadGate {
- const gate = runtime.gate.useReadGate();
+ const gate = slot.runtime.gate.useReadGate();
return bypass ? ALWAYS_LIVE : gate;
}
diff --git a/src/testing/sqljs_connection.ts b/src/testing/sqljs_connection.ts
index f2b4625f..a39c8d31 100644
--- a/src/testing/sqljs_connection.ts
+++ b/src/testing/sqljs_connection.ts
@@ -2,8 +2,7 @@
import path from 'path';
-import { evalShredSpec } from '../write/shred_spec';
-import type { ShredSpec } from '../write/shred_spec';
+import { shredCommandStatements } from '../write/js_shred';
import { BatchCommand, PinnedConnection, QueryExecResult, ShredCommand, SqliteConnection } from '../table/connection';
interface SqlJsStatement {
@@ -75,26 +74,6 @@ function sanitize(params?: ReadonlyArray): Array (param === undefined ? null : param));
}
-function shredSql(spec: ShredSpec, rows: ReadonlyArray>, binds: ReadonlyArray) {
- const cmds: Array<[string, Array]> = [];
- const deleteWhere = spec.deleteWhere ?? [];
- if (deleteWhere.length) {
- const where = deleteWhere.map((column) => `${column.column} = ?`).join(' AND ');
- cmds.push([`DELETE FROM ${spec.table} WHERE ${where};`, deleteWhere.map((column) => binds[column.bindIndex] ?? null)]);
- }
- const placeholders = spec.columns.map(() => '?').join(', ');
- const insert = `${spec.insertVerb} INTO ${spec.table} (${spec.columns.join(', ')}) VALUES (${placeholders});`;
- for (const row of rows) cmds.push([insert, spec.columns.map((column) => row[column] ?? null)]);
- return cmds;
-}
-
-/** The statements a native shred runs for one command, which parse the body in JS here. */
-function shredCommandSql({ shred: spec, rawJson, binds }: ShredCommand) {
- const parsed = JSON.parse(rawJson) as unknown;
- const elements = spec.source === 'objectValues' ? Object.values(parsed as Record) : (parsed as unknown[]);
- return shredSql(spec, evalShredSpec(spec, elements, binds), binds);
-}
-
/** Creates a connection to a new in-memory sql.js database. Requires {@linkcode initSqlJs} to have finished. */
export function createSqlJsConnection(options: SqlJsConnectionOptions = {}): SqlJsConnection {
if (!SqlModule) throw new Error('createSqlJsConnection: call `await initSqlJs()` in beforeAll first');
@@ -189,7 +168,7 @@ export function createSqlJsConnection(options: SqlJsConnectionOptions = {}): Sql
try {
for (const command of commands) {
if (Array.isArray(command)) run(...(command as BatchCommand));
- else for (const [sql, params] of shredCommandSql(command as ShredCommand)) run(sql, params);
+ else for (const [sql, params] of shredCommandStatements(command as ShredCommand)) run(sql, params);
}
run('COMMIT;');
} catch (error) {
diff --git a/src/tests/node/node_connection.test.ts b/src/tests/node/node_connection.test.ts
new file mode 100644
index 00000000..df119f18
--- /dev/null
+++ b/src/tests/node/node_connection.test.ts
@@ -0,0 +1,238 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import ts from 'typescript';
+
+import { defineSqliteStore } from '../../define_sqlite_store';
+import { byEntity } from '../../read/derived_values';
+import { pairRead } from '../../read/facade';
+import { dehydrateReads, readCall } from '../../read/hydration';
+import { openNodeConnection, type NodeConnection } from '../../node/node_connection';
+import { readRows } from '../../table/connection';
+import type { ShredCommand } from '../../table/connection';
+import { StoreTableSchema } from '../../table/partitioned';
+import { installTestRuntime } from '../../testing/runtime';
+import { createSqlJsConnection } from '../../testing/sqljs_connection';
+import type { ShredSpec } from '../../write/shred_spec';
+
+jest.mock('../../diagnostics/telemetry', () => ({ reportStoreDegradation: jest.fn() }));
+
+installTestRuntime();
+
+const SRC = path.join(__dirname, '..', '..');
+
+/** The writer and what it imports, compiled to CommonJS: a worker runs plain Node, which can't load this TypeScript. */
+function buildWriter(): string {
+ const out = fs.mkdtempSync(path.join(os.tmpdir(), 'cellar-writer-'));
+ for (const file of ['node/writer.ts', 'write/js_shred.ts', 'write/shred_spec.ts']) {
+ const { outputText } = ts.transpileModule(fs.readFileSync(path.join(SRC, file), 'utf8'), {
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
+ });
+ const dest = path.join(out, file.replace(/\.ts$/, '.js'));
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ fs.writeFileSync(dest, outputText);
+ }
+ return path.join(out, 'node', 'writer.js');
+}
+
+const writerFile = buildWriter();
+const open: NodeConnection[] = [];
+const connect = (): NodeConnection => {
+ const conn = openNodeConnection({ writerFile });
+ open.push(conn);
+ return conn;
+};
+
+afterEach(async () => {
+ await Promise.all(open.splice(0).map((conn) => conn.close()));
+});
+
+describe('openNodeConnection', () => {
+ it('writes on the writer and reads what it committed on the main thread', async () => {
+ const conn = connect();
+ conn.execute('CREATE TABLE things (id TEXT PRIMARY KEY, n INTEGER);');
+ await conn.executeBatchAsync?.([
+ ['INSERT INTO things VALUES (?, ?);', ['a', 1]],
+ ['INSERT INTO things VALUES (?, ?);', ['b', 2]],
+ ]);
+
+ expect(readRows(conn, 'SELECT id, n FROM things ORDER BY id;')).toEqual([
+ { id: 'a', n: 1 },
+ { id: 'b', n: 2 },
+ ]);
+ });
+
+ it('keeps the writer’s temp tables across its calls, where a store stages a write', async () => {
+ const conn = connect();
+ conn.execute('CREATE TEMP TABLE stage (id TEXT);');
+ await conn.executeAsync?.('INSERT INTO stage VALUES (?);', ['x']);
+
+ expect(conn.execute('SELECT id FROM temp.stage;').rows?._array).toEqual([{ id: 'x' }]);
+ });
+
+ it('answers a call in the order it was made, behind the writes queued before it', async () => {
+ const conn = connect();
+ conn.execute('CREATE TABLE things (id TEXT);');
+ const queued = conn.executeBatchAsync?.([['INSERT INTO things VALUES (?);', ['a']]]);
+
+ expect(conn.execute('SELECT COUNT(*) AS n FROM things;').rows?._array).toEqual([{ n: 1 }]);
+ await queued;
+ });
+
+ it('rolls a failed batch back whole, and rejects it', async () => {
+ const conn = connect();
+ conn.execute('CREATE TABLE things (id TEXT PRIMARY KEY);');
+
+ await expect(
+ conn.executeBatchAsync?.([
+ ['INSERT INTO things VALUES (?);', ['a']],
+ ['INSERT INTO things VALUES (?);', ['a']],
+ ]),
+ ).rejects.toThrow(/UNIQUE/);
+ expect(readRows(conn, 'SELECT id FROM things;')).toEqual([]);
+ });
+
+ it('throws a failing statement on a blocking call', () => {
+ const conn = connect();
+
+ expect(() => conn.execute('SELECT * FROM nowhere;')).toThrow(/no such table/);
+ });
+
+ it('shreds a body into the rows the reference interpreter builds', async () => {
+ const spec: ShredSpec = {
+ version: 1,
+ table: 'players',
+ insertVerb: 'INSERT OR REPLACE',
+ source: 'objectValues',
+ deleteWhere: [{ column: 'sport', bindIndex: 0 }],
+ columns: ['sport', 'player_id', 'team'],
+ ops: [
+ { op: 'bind', index: 0 },
+ { op: 'text', path: 'player_id' },
+ { op: 'text', path: 'team' },
+ ],
+ };
+ const command: ShredCommand = { shred: spec, rawJson: JSON.stringify({ p1: { player_id: 'p1', team: 'SF' }, p2: { player_id: 'p2' } }), binds: ['nfl'] };
+ const ddl = 'CREATE TABLE players (sport TEXT, player_id TEXT PRIMARY KEY, team TEXT);';
+ const node = connect();
+ const reference = createSqlJsConnection({ capabilities: 'full' });
+ node.execute(ddl);
+ reference.execute(ddl);
+
+ await node.shredBatchAsync?.([command]);
+ await reference.shredBatchAsync?.([command]);
+
+ const select = 'SELECT sport, player_id, team FROM players ORDER BY player_id;';
+ expect(readRows(node, select)).toEqual(readRows(reference, select));
+ expect(readRows(node, select)).toHaveLength(2);
+ });
+});
+
+type Player = { sport: string; player_id: string; team: string | null };
+
+const PLAYERS: StoreTableSchema = {
+ table: 'players',
+ columns: { sport: { type: 'TEXT', notNull: true }, player_id: { type: 'TEXT', notNull: true }, team: { type: 'TEXT' } },
+ uniqueBy: ['sport', 'player_id'],
+ entityId: 'player_id',
+};
+
+const PLAYERS_NATIVE: Record = {
+ all: {
+ version: 1,
+ table: 'players',
+ insertVerb: 'INSERT OR REPLACE',
+ source: 'objectValues',
+ columns: ['sport', 'player_id', 'team'],
+ ops: [
+ { op: 'bind', index: 1 },
+ { op: 'text', path: 'player_id' },
+ { op: 'text', path: 'team' },
+ ],
+ },
+};
+
+function playerStore(body: () => string) {
+ const store = defineSqliteStore({
+ name: 'node_players',
+ schema: PLAYERS,
+ partition: ({ sport }: { sport?: string | null }) => (sport ? { sport } : null),
+ fetch: ({ sport }: { sport: string }) => ({
+ query: { queryFn: async () => ({ data: body() }) },
+ toRows: (raw: string) => Object.values(JSON.parse(raw) as Record).map((one) => ({ sport, ...one })),
+ native: { variant: 'all', binds: [sport] },
+ }),
+ nativeShredSpecs: PLAYERS_NATIVE,
+ build: (cellar) => {
+ const { cards } = cellar.defineCaches({
+ cards: byEntity({ max: 64, fromRows: ([row]) => (row ? { id: row.player_id, team: row.team } : undefined) }),
+ });
+ return {
+ reads: {
+ Card: cellar.defineRead<{ sport: string; playerId: string }, { id: string; team: string | null } | undefined>({
+ select: (args, key) => cards.at(key, args.playerId),
+ empty: undefined,
+ }),
+ },
+ };
+ },
+ });
+ return { store, Card: pairRead(() => store.reads.Card) };
+}
+
+/** The longest the event loop goes without a turn while `run` is in flight. */
+async function longestBlock(run: () => Promise): Promise {
+ let last = performance.now();
+ let longest = 0;
+ let running = true;
+ const tick = () => {
+ const now = performance.now();
+ longest = Math.max(longest, now - last);
+ last = now;
+ if (running) setImmediate(tick);
+ };
+ setImmediate(tick);
+ await run();
+ running = false;
+ return Math.max(longest, performance.now() - last);
+}
+
+describe('a store bound to node:sqlite', () => {
+ it('fetches, writes through its writer, and reads on the main thread', async () => {
+ let teams = { p1: { player_id: 'p1', team: 'SF' }, p2: { player_id: 'p2', team: 'KC' } } as Record;
+ const { store, Card } = playerStore(() => JSON.stringify(teams));
+ store.bindSqlite(connect());
+
+ await store.lifecycle.fetch({ sport: 'nfl' });
+ expect(Card.getValue({ params: { sport: 'nfl', playerId: 'p1' } })).toEqual({ id: 'p1', team: 'SF' });
+
+ teams = { p1: { player_id: 'p1', team: 'LV' } };
+ await store.lifecycle.fetch({ sport: 'nfl' }, { staleTime: 0 });
+ expect(Card.getValue({ params: { sport: 'nfl', playerId: 'p1' } })).toEqual({ id: 'p1', team: 'LV' });
+ expect(Card.getValue({ params: { sport: 'nfl', playerId: 'p2' } })).toBeUndefined();
+ });
+
+ it('captures a read for a server render', async () => {
+ const { store, Card } = playerStore(() => JSON.stringify({ p1: { player_id: 'p1', team: 'SF' } }));
+ store.bindSqlite(connect());
+
+ const snapshot = await dehydrateReads([readCall(Card.useValue, { sport: 'nfl', playerId: 'p1' })]);
+
+ expect(Object.values(snapshot.reads)).toEqual([{ value: { id: 'p1', team: 'SF' } }]);
+ });
+
+ it('keeps the main thread free while a large body is parsed and written', async () => {
+ const many = JSON.stringify(Object.fromEntries(Array.from({ length: 30_000 }, (_, i) => [`p${i}`, { player_id: `p${i}`, team: `T${i % 32}` }])));
+ const onNode = playerStore(() => many);
+ const onMainThread = playerStore(() => many);
+ onNode.store.bindSqlite(connect());
+ onMainThread.store.bindSqlite(createSqlJsConnection({ capabilities: 'full' }));
+
+ const node = await longestBlock(() => onNode.store.lifecycle.fetch({ sport: 'nfl' }));
+ const sqljs = await longestBlock(() => onMainThread.store.lifecycle.fetch({ sport: 'nfl' }));
+
+ expect(onNode.Card.getValue({ params: { sport: 'nfl', playerId: 'p29999' } })).toEqual({ id: 'p29999', team: 'T15' });
+ expect(node).toBeLessThan(sqljs / 4);
+ fs.writeFileSync(path.join(os.tmpdir(), 'cellar-node-block.txt'), `node ${node.toFixed(1)}ms sqljs ${sqljs.toFixed(1)}ms\n`);
+ });
+});
diff --git a/src/tests/packaging.test.ts b/src/tests/packaging.test.ts
index c6a004e5..e18c1086 100644
--- a/src/tests/packaging.test.ts
+++ b/src/tests/packaging.test.ts
@@ -14,8 +14,15 @@
import { readFileSync } from 'fs';
import { join } from 'path';
+import { configureCellar } from '../runtime';
+
const ROOT = join(__dirname, '..', '..');
const SUBPATHS = ['nitro', 'sqljs', 'testing', 'diagnostics', 'inspector', 'redux'];
+/**
+ * Subpaths that are CommonJS whatever the importer asks for: the Node entry starts its writer from a file beside it,
+ * which only CommonJS can locate. The state it shares with the core lives in a global slot rather than a module.
+ */
+const COMMONJS_ONLY = ['node'];
function readPackage(...segments: string[]): Record {
return JSON.parse(readFileSync(join(ROOT, ...segments, 'package.json'), 'utf8'));
@@ -53,6 +60,24 @@ describe('entry point resolution', () => {
.map((key) => key.replace(/^\.\//, ''))
.sort();
- expect(declared).toEqual([...SUBPATHS].sort());
+ expect(declared).toEqual([...SUBPATHS, ...COMMONJS_ONLY].sort());
+ });
+
+ it('sends a CommonJS-only subpath into the CommonJS build however it is imported, stub and exports alike', () => {
+ const exports = readPackage().exports as unknown as Record>>;
+ for (const subpath of COMMONJS_ONLY) {
+ const entry = exports[`./${subpath}`];
+ const builds = [entry.import.default, entry.import.types, entry.require.default, entry.require.types, readPackage(subpath).main, readPackage(subpath).types];
+
+ expect(builds.map(buildOf)).toEqual(builds.map(() => 'commonjs'));
+ }
+ });
+
+ it('keeps the runtime a CommonJS-only subpath reads in one global slot, so both builds see one configuration', () => {
+ configureCellar({ serverRendering: true });
+ const slot = (globalThis as unknown as Record)[Symbol.for('cellar.runtime')];
+
+ expect(slot?.runtime.serverRendering).toBe(true);
+ configureCellar({ serverRendering: false });
});
});
diff --git a/src/tests/public_surface.test.ts b/src/tests/public_surface.test.ts
index 4fdfc37e..d47146e9 100644
--- a/src/tests/public_surface.test.ts
+++ b/src/tests/public_surface.test.ts
@@ -28,19 +28,20 @@ function exportedFrom(entry: string): string[] {
describe('the core entry point', () => {
it('exports what a store, a service or a screen writes against, and nothing else', () => {
expect(exportedFrom('index.ts')).toEqual([
- 'ALL_ENTITIES', 'BatchCommand', 'BoundedLru', 'CacheDeclaration', 'CacheFactory', 'CacheKeyPart', 'CellarContext', 'ChangeSet',
+ 'ALL_ENTITIES', 'BatchCommand', 'BoundedLru', 'CAPTURE', 'CacheDeclaration', 'CacheFactory', 'CacheKeyPart', 'Capturable',
+ 'Captured', 'CellarContext', 'CellarHydrate', 'CellarHydrateProps', 'ChangeSet',
'ColumnDef', 'Coverage', 'CoverageProviderProps', 'DATA_RESULT_KEYS', 'DataResult', 'DataStatus', 'DecodedRow', 'Dep',
'DerivedValues', 'DerivedValuesDef', 'EachOf', 'EntityCacheDeclaration', 'Loose', 'MaybeId', 'NO_CHANGES',
'NativeShredColumns', 'NativeShredSpec', 'PairedRead', 'PairedReadAcross', 'PartitionFetch', 'PartitionKeyColumn',
'PartitionLifecycle', 'PartitionSpec', 'PinnedConnection', 'PrimeState', 'QueryClient',
- 'QueryRuntime', 'QuerySpec', 'QueryStatus', 'RawQuery', 'ReadAcross', 'ReadGate',
- 'ReadGateRuntime', 'ReadOptions', 'RowOf', 'RowTable', 'RowTableSchema', 'ShredColumn', 'ShredColumns',
- 'ShredColumnsBase', 'ShredCommand', 'ShredOp', 'ShredSpec', 'SqlValue', 'SqliteConnection', 'SqliteStore', 'StoreFetchSpec',
- 'StoreFunctions', 'StorePush', 'StorePushOf', 'StorePushSpec', 'StoreSurface', 'StoreTableSchema',
+ 'QueryRuntime', 'QuerySpec', 'QueryStatus', 'RawQuery', 'ReadAcross', 'ReadCall', 'ReadGate',
+ 'ReadGateRuntime', 'ReadOptions', 'ReadSnapshot', 'RowOf', 'RowTable', 'RowTableSchema', 'ShredColumn', 'ShredColumns',
+ 'ShredColumnsBase', 'ShredCommand', 'ShredOp', 'ShredSpec', 'SnapshotEntry', 'SqlValue', 'SqliteConnection', 'SqliteStore',
+ 'StoreFetchSpec', 'StoreFunctions', 'StorePush', 'StorePushOf', 'StorePushSpec', 'StoreSurface', 'StoreTableSchema',
'TrackedValueOptions', 'VersionAtom', 'WindowedBlock', 'WithReadSpec', 'WriteResult', 'WriteSteps',
'byEntity', 'byPartition', 'configureCellar', 'createBoundedLru', 'createCoverage', 'createOnceGuard',
'createSqliteRowTable', 'createTrackedSelector', 'createWindowedList', 'defineShredColumns', 'defineSqliteStore',
- 'makeResult', 'pairRead', 'partitionKeyOf', 'pinnedReader', 'reactQueryRuntime', 'readRows',
+ 'dehydrateReads', 'makeResult', 'pairRead', 'partitionKeyOf', 'pinnedReader', 'reactQueryRuntime', 'readCall', 'readRows',
'readRowsIn', 'reportStoreDegradation', 'rowsOf', 'runTracked', 'shallowEqualArray',
'shallowEqualRecord', 'shallowEqualStruct', 'shallowEqualValue', 'shredColumnValue', 'useTrackedValue', 'withRead',
]);
diff --git a/src/tests/read/facade.test.ts b/src/tests/read/facade.test.ts
index 2e1b85c8..4b109754 100644
--- a/src/tests/read/facade.test.ts
+++ b/src/tests/read/facade.test.ts
@@ -17,6 +17,7 @@ describe('pairRead', () => {
calls.use.push([args, options]);
return makeResult('used', 'success');
},
+ capture: async () => undefined,
};
return { read, calls };
};
diff --git a/src/tests/read/hydration.test.ts b/src/tests/read/hydration.test.ts
new file mode 100644
index 00000000..9622f1df
--- /dev/null
+++ b/src/tests/read/hydration.test.ts
@@ -0,0 +1,322 @@
+import React from 'react';
+import TestRenderer, { act } from 'react-test-renderer';
+
+import { defineSqliteStore } from '../../define_sqlite_store';
+import { byEntity } from '../../read/derived_values';
+import { pairRead } from '../../read/facade';
+import { CellarHydrate, dehydrateReads, readCall, ReadSnapshot } from '../../read/hydration';
+import { configureCellar } from '../../runtime';
+import { reportStoreDegradation } from '../../diagnostics/telemetry';
+import { StoreTableSchema } from '../../table/partitioned';
+import { installTestRuntime } from '../../testing/runtime';
+import { createSqlJsConnection } from '../../testing/sqljs_connection';
+import type { DataResult } from '../../store_result';
+
+jest.mock('../../diagnostics/telemetry', () => ({ reportStoreDegradation: jest.fn() }));
+
+const runtime = installTestRuntime();
+
+type Player = { sport: string; player_id: string; team: string | null };
+type Card = { id: string; team: string | null };
+
+const PLAYERS: StoreTableSchema = {
+ table: 'players',
+ columns: { sport: { type: 'TEXT', notNull: true }, player_id: { type: 'TEXT', notNull: true }, team: { type: 'TEXT' } },
+ uniqueBy: ['sport', 'player_id'],
+ entityId: 'player_id',
+};
+
+const NO_CARDS: readonly (Card | undefined)[] = Object.freeze([]);
+
+/** A player store whose fetch serves `server`, published through `pairRead` the way a service publishes its reads. */
+function playerService() {
+ const server: Record | Error> = {
+ nfl: { p1: { player_id: 'p1', team: 'SF' }, p2: { player_id: 'p2', team: 'KC' } },
+ nba: { p1: { player_id: 'p1', team: 'LAL' } },
+ mlb: {},
+ };
+ const store = defineSqliteStore({
+ name: 'players',
+ schema: PLAYERS,
+ partition: ({ sport }: { sport?: string | null }) => (sport ? { sport } : null),
+ fetch: ({ sport }: { sport: string }) => ({
+ query: {
+ queryFn: async () => {
+ const body = server[sport];
+ if (body instanceof Error) throw body;
+ return { data: JSON.stringify(body) };
+ },
+ },
+ toRows: (raw: string) => Object.values(JSON.parse(raw) as Record).map((one) => ({ sport, ...one })),
+ }),
+ build: (cellar) => {
+ const { cards } = cellar.defineCaches({
+ cards: byEntity({ max: 64, fromRows: ([row]): Card | undefined => (row ? { id: row.player_id, team: row.team } : undefined) }),
+ });
+ return {
+ reads: {
+ Card: cellar.defineRead<{ sport: string; playerId: string }, Card | undefined>({
+ select: (args, key) => cards.at(key, args.playerId),
+ empty: undefined,
+ }),
+ CardAcross: cellar.defineReadAcross<{ sports: readonly string[]; playerId: string }, readonly (Card | undefined)[]>({
+ partitions: (args) => args.sports.map((sport) => ({ sport })),
+ select: (args, keys) => keys.map((key) => cards.at(key, args.playerId)),
+ empty: NO_CARDS,
+ }),
+ },
+ };
+ },
+ });
+ const Card = pairRead(() => store.reads.Card);
+ const CardAcross = pairRead(() => store.reads.CardAcross);
+ const bind = () => store.bindSqlite(createSqlJsConnection({ capabilities: 'full' }));
+ return { store, server, bind, useCard: Card.useValue, getCard: Card.getValue, useCardAcross: CardAcross.useValue, useEachCard: CardAcross.useEach };
+}
+
+type Probe = { current: () => T; rerender: () => void; unmount: () => void };
+
+/** Renders `useHook` under a `CellarHydrate` holding `snapshot`, and hands back what it returned. */
+function render(useHook: () => T, snapshot?: ReadSnapshot): Probe {
+ let latest: T | undefined;
+ function Component(): null {
+ latest = useHook();
+ return null;
+ }
+ const tree = () => React.createElement(CellarHydrate, { snapshot }, React.createElement(Component));
+ let renderer: TestRenderer.ReactTestRenderer | undefined;
+ act(() => {
+ renderer = TestRenderer.create(tree());
+ });
+ return {
+ current: () => latest as T,
+ rerender: () => act(() => renderer?.update(tree())),
+ unmount: () => act(() => renderer?.unmount()),
+ };
+}
+
+const lastPrimeSpec = () => runtime.useQuery.mock.calls[runtime.useQuery.mock.calls.length - 1]?.[0] as { enabled?: boolean } | undefined;
+
+const SF: Card = { id: 'p1', team: 'SF' };
+
+/** The snapshot a server would hand the page, after a round trip through the HTML it travels in. */
+async function serverSnapshot(build: (service: ReturnType) => Parameters[0]) {
+ const service = playerService();
+ service.bind();
+ const snapshot = await dehydrateReads(build(service));
+ return { snapshot: JSON.parse(JSON.stringify(snapshot)) as ReadSnapshot, service };
+}
+
+beforeEach(() => {
+ runtime.useQuery.mockClear();
+ runtime.fetchQuery.mockClear();
+ (reportStoreDegradation as jest.Mock).mockClear();
+ configureCellar({ serverRendering: false });
+});
+
+describe('dehydrateReads — on the server', () => {
+ it('fetches each read’s partition and files its value under the read and its args', async () => {
+ const { snapshot } = await serverSnapshot((s) => [readCall(s.useCard, { sport: 'nfl', playerId: 'p1' })]);
+
+ expect(Object.values(snapshot.reads)).toEqual([{ value: SF }]);
+ expect(runtime.fetchQuery).toHaveBeenCalledTimes(1);
+ });
+
+ it('takes the getter as readily as the hook, and keys both the same', async () => {
+ const byHook = await serverSnapshot((s) => [readCall(s.useCard, { sport: 'nfl', playerId: 'p1' })]);
+ const byGetter = await serverSnapshot((s) => [readCall(s.getCard, { playerId: 'p1', sport: 'nfl' })]);
+
+ expect(Object.keys(byGetter.snapshot.reads)).toEqual(Object.keys(byHook.snapshot.reads));
+ });
+
+ it('files a partition that came back empty as the read’s empty value, so the browser has nothing to fetch for it', async () => {
+ const { snapshot } = await serverSnapshot((s) => [readCall(s.useCard, { sport: 'mlb', playerId: 'p1' })]);
+
+ expect(Object.values(snapshot.reads)).toEqual([{ value: undefined }]);
+ });
+
+ it('marks a live read, so the browser goes on to fetch it', async () => {
+ const { snapshot } = await serverSnapshot((s) => [readCall(s.useCard, { sport: 'nfl', playerId: 'p1' }, { live: true })]);
+
+ expect(Object.values(snapshot.reads)).toEqual([{ value: SF, live: true }]);
+ });
+
+ it('captures nothing for a call that is still missing an arg', async () => {
+ const { snapshot } = await serverSnapshot((s) => [readCall(s.useCard, { sport: 'nfl', playerId: null })]);
+
+ expect(snapshot.reads).toEqual({});
+ expect(runtime.fetchQuery).not.toHaveBeenCalled();
+ });
+
+ it('leaves out a read whose fetch failed, reports it, and still captures the others', async () => {
+ const { snapshot } = await serverSnapshot((s) => {
+ s.server.nba = new Error('upstream 503');
+ return [readCall(s.useCard, { sport: 'nba', playerId: 'p1' }), readCall(s.useCard, { sport: 'nfl', playerId: 'p1' })];
+ });
+
+ expect(Object.values(snapshot.reads)).toEqual([{ value: SF }]);
+ expect(reportStoreDegradation).toHaveBeenCalledWith(expect.objectContaining({ scope: 'hydration.capture' }));
+ });
+
+ it('records which partitions held rows for a read across several', async () => {
+ const { snapshot } = await serverSnapshot((s) => [readCall(s.useCardAcross, { sports: ['nfl', 'mlb'], playerId: 'p1' })]);
+
+ // JSON carries a list's missing entry as null.
+ expect(Object.values(snapshot.reads)).toEqual([{ value: [SF, null], present: [true, false] }]);
+ });
+
+ it('refuses a function no pairRead published', () => {
+ expect(() => readCall(((_args: { params: { id: string } }) => undefined) as never, { id: 'x' })).toThrow('pairRead');
+ });
+});
+
+describe('a hook under CellarHydrate — in the browser', () => {
+ it('answers from the snapshot, as a success, while its store is not yet bound', async () => {
+ const { snapshot } = await serverSnapshot((s) => [readCall(s.useCard, { sport: 'nfl', playerId: 'p1' })]);
+ const browser = playerService();
+
+ const probe = render(() => browser.useCard({ params: { sport: 'nfl', playerId: 'p1' } }), snapshot);
+
+ expect(probe.current()).toMatchObject({ data: SF, status: 'success' });
+ });
+
+ it('does not fetch a partition the snapshot answered', async () => {
+ const { snapshot } = await serverSnapshot((s) => [readCall(s.useCard, { sport: 'nfl', playerId: 'p1' })]);
+ const browser = playerService();
+ browser.bind();
+
+ render(() => browser.useCard({ params: { sport: 'nfl', playerId: 'p1' } }), snapshot);
+
+ expect(lastPrimeSpec()?.enabled).toBe(false);
+ });
+
+ it('fetches a live read, and moves to the store’s value once its rows land', async () => {
+ const { snapshot } = await serverSnapshot((s) => [readCall(s.useCard, { sport: 'nfl', playerId: 'p1' }, { live: true })]);
+ const browser = playerService();
+ browser.bind();
+ browser.server.nfl = { p1: { player_id: 'p1', team: 'LV' } };
+
+ const probe = render(() => browser.useCard({ params: { sport: 'nfl', playerId: 'p1' } }), snapshot);
+ expect(lastPrimeSpec()?.enabled).toBe(true);
+ expect(probe.current().data).toEqual(SF);
+
+ await act(async () => {
+ await browser.store.lifecycle.fetch({ sport: 'nfl' });
+ });
+
+ expect(probe.current()).toMatchObject({ data: { id: 'p1', team: 'LV' }, status: 'success' });
+ });
+
+ it('prefers the store once it holds the partition, whoever fetched it', async () => {
+ const { snapshot } = await serverSnapshot((s) => [readCall(s.useCard, { sport: 'nfl', playerId: 'p1' })]);
+ const browser = playerService();
+ browser.bind();
+ browser.server.nfl = { p1: { player_id: 'p1', team: 'LV' } };
+ await browser.store.lifecycle.fetch({ sport: 'nfl' });
+
+ const probe = render(() => browser.useCard({ params: { sport: 'nfl', playerId: 'p1' } }), snapshot);
+
+ expect(probe.current().data).toEqual({ id: 'p1', team: 'LV' });
+ });
+
+ it('reads and fetches as usual for a call the snapshot does not hold', async () => {
+ const { snapshot } = await serverSnapshot((s) => [readCall(s.useCard, { sport: 'nfl', playerId: 'p1' })]);
+ const browser = playerService();
+ browser.bind();
+
+ const probe = render(() => browser.useCard({ params: { sport: 'nfl', playerId: 'p2' } }), snapshot);
+
+ expect(probe.current().data).toBeUndefined();
+ expect(lastPrimeSpec()?.enabled).toBe(true);
+ });
+
+ it('reports each partition of a read across several from the snapshot', async () => {
+ const { snapshot } = await serverSnapshot((s) => [readCall(s.useEachCard, { sports: ['nfl', 'mlb'], playerId: 'p1' })]);
+ const browser = playerService();
+
+ const probe = render(() => browser.useEachCard({ params: { sports: ['nfl', 'mlb'], playerId: 'p1' } }), snapshot);
+ const whole = render(() => browser.useCardAcross({ params: { sports: ['nfl', 'mlb'], playerId: 'p1' } }), snapshot);
+
+ expect(probe.current().map((result) => [result.data, result.status])).toEqual([
+ [SF, 'success'],
+ [null, 'success'],
+ ]);
+ expect(whole.current()).toMatchObject({ data: [SF, null], status: 'success' });
+ });
+
+ it('adds a nested snapshot to the one above it', async () => {
+ const outer = await serverSnapshot((s) => [readCall(s.useCard, { sport: 'nfl', playerId: 'p1' })]);
+ const inner = await serverSnapshot((s) => [readCall(s.useCard, { sport: 'nfl', playerId: 'p2' })]);
+ const browser = playerService();
+ const results: Record> = {};
+ function Both(): null {
+ results.p1 = browser.useCard({ params: { sport: 'nfl', playerId: 'p1' } });
+ results.p2 = browser.useCard({ params: { sport: 'nfl', playerId: 'p2' } });
+ return null;
+ }
+
+ act(() => {
+ TestRenderer.create(
+ React.createElement(CellarHydrate, { snapshot: outer.snapshot }, React.createElement(CellarHydrate, { snapshot: inner.snapshot }, React.createElement(Both))),
+ );
+ });
+
+ expect(results.p1.data).toEqual(SF);
+ expect(results.p2.data).toEqual({ id: 'p2', team: 'KC' });
+ });
+});
+
+describe('the module', () => {
+ it('loads under a React with no createContext, as a React Server Component imports it', () => {
+ jest.isolateModules(() => {
+ jest.doMock('react', () => ({ ...jest.requireActual('react'), createContext: undefined }));
+ const registry = globalThis as unknown as Record;
+ const held = registry[Symbol.for('cellar.read.hydration')];
+ delete registry[Symbol.for('cellar.read.hydration')];
+
+ // eslint-disable-next-line global-require
+ expect(() => require('../../read/hydration')).not.toThrow();
+ registry[Symbol.for('cellar.read.hydration')] = held;
+ jest.dontMock('react');
+ });
+ });
+});
+
+describe('a hook — when the process renders on the server', () => {
+ it('answers from the snapshot even when the store holds newer rows, so the HTML matches the snapshot the browser gets', async () => {
+ const { snapshot, service } = await serverSnapshot((s) => [readCall(s.useCard, { sport: 'nfl', playerId: 'p1' })]);
+ service.server.nfl = { p1: { player_id: 'p1', team: 'LV' } };
+ await service.store.lifecycle.fetch({ sport: 'nfl' }, { staleTime: 0 });
+ configureCellar({ serverRendering: true });
+
+ const probe = render(() => service.useCard({ params: { sport: 'nfl', playerId: 'p1' } }), snapshot);
+
+ expect(probe.current()).toMatchObject({ data: SF, status: 'success' });
+ });
+
+ it('answers empty, as loading, for a call the snapshot does not hold, the way the browser first renders it', async () => {
+ const { service } = await serverSnapshot((s) => [readCall(s.useCard, { sport: 'nfl', playerId: 'p1' })]);
+ configureCellar({ serverRendering: true });
+
+ const probe = render(() => service.useCard({ params: { sport: 'nfl', playerId: 'p1' } }));
+
+ expect(probe.current()).toMatchObject({ data: undefined, status: 'loading' });
+ expect(lastPrimeSpec()?.enabled).toBe(false);
+ });
+
+ it('reports a disabled call as a success, as the browser does', async () => {
+ const { service } = await serverSnapshot(() => []);
+ configureCellar({ serverRendering: true });
+
+ const probe = render(() => service.useCard({ params: { sport: 'nfl', playerId: 'p1' }, options: { enabled: false } }));
+
+ expect(probe.current()).toMatchObject({ data: undefined, status: 'success' });
+ });
+
+ it('still lets getValue read the store, which is how a server fills a snapshot', async () => {
+ const { service } = await serverSnapshot((s) => [readCall(s.useCard, { sport: 'nfl', playerId: 'p1' })]);
+ configureCellar({ serverRendering: true });
+
+ expect(service.getCard({ params: { sport: 'nfl', playerId: 'p1' } })).toEqual(SF);
+ });
+});
diff --git a/src/write/js_shred.ts b/src/write/js_shred.ts
new file mode 100644
index 00000000..6800aaad
--- /dev/null
+++ b/src/write/js_shred.ts
@@ -0,0 +1,30 @@
+/**
+ * A native shred run in JS: the statements a {@linkcode ShredCommand} writes, with the body parsed and each element's
+ * row built by the spec's reference interpreter. What a connection without the C++ shred runs: sql.js in tests, and the
+ * Node writer, off the main thread.
+ */
+
+import { evalShredSpec } from './shred_spec';
+import type { ShredSpec } from './shred_spec';
+import type { BatchCommand, ShredCommand } from '../table/connection';
+import type { SqlValue } from '../table/types';
+
+function shredStatements(spec: ShredSpec, rows: ReadonlyArray>, binds: ReadonlyArray): BatchCommand[] {
+ const commands: BatchCommand[] = [];
+ const deleteWhere = spec.deleteWhere ?? [];
+ if (deleteWhere.length) {
+ const where = deleteWhere.map((column) => `${column.column} = ?`).join(' AND ');
+ commands.push([`DELETE FROM ${spec.table} WHERE ${where};`, deleteWhere.map((column) => binds[column.bindIndex] ?? null)]);
+ }
+ const placeholders = spec.columns.map(() => '?').join(', ');
+ const insert = `${spec.insertVerb} INTO ${spec.table} (${spec.columns.join(', ')}) VALUES (${placeholders});`;
+ for (const row of rows) commands.push([insert, spec.columns.map((column) => row[column] ?? null)]);
+ return commands;
+}
+
+/** The statements one shred command runs: its delete, then one insert per element of the body. */
+export function shredCommandStatements({ shred: spec, rawJson, binds }: ShredCommand): BatchCommand[] {
+ const parsed = JSON.parse(rawJson) as unknown;
+ const elements = spec.source === 'objectValues' ? Object.values(parsed as Record) : (parsed as unknown[]);
+ return shredStatements(spec, evalShredSpec(spec, elements, binds), binds);
+}