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); +}