Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <CellarHydrate snapshot={snapshot}><Page /></CellarHydrate>;
```

`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
Expand Down Expand Up @@ -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 |

Expand All @@ -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 |
Expand Down
7 changes: 7 additions & 0 deletions docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
| ------------------------------------------------ | ------------------------------------------------------------------------ |
Expand All @@ -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 | |
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions node/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -106,6 +116,7 @@
"diagnostics/package.json",
"inspector/package.json",
"redux/package.json",
"node/package.json",
"!**/tests",
"!**/*.test.ts",
"!**/*.probe.ts"
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
11 changes: 11 additions & 0 deletions src/node/index.ts
Original file line number Diff line number Diff line change
@@ -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';
175 changes: 175 additions & 0 deletions src/node/node_connection.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

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<number, { resolve: (rows: unknown[]) => 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<R, 'id' | 'sync'> : 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<unknown[]> =>
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<BatchCommand | ShredCommand>) => {
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 };
Loading
Loading