Use the engine directly

Backends & the homes

One engine, one query API, one correctness contract — over wasm in the browser, native SQLite in Node, or a server over the network. The Backend seam that lets one materialized view serve every tier.

The JS client is built around one seam: a Backend. The core package @rindle/client owns everything above the seam — the typed schema, the fluent query builder, the comparator, the Store, and the ArrayView that folds a change stream into a live, materialized tree. A Backend owns everything below it — where the engine actually runs.

Swap the backend, keep the code. The schema you write, the query you build, the view you subscribe to, and the contract you rely on (view-after-write == fresh-query) are identical in every home. This page is the conceptual underpinning; for the full synced app the homes compose into, start with the architecture.

The homes

Home Package Engine Reads
Browser @rindle/wasm the IVM engine compiled to wasm, in-process synchronous, local
Node @rindle/replica native SQLite + BEGIN CONCURRENT CDC, in-process synchronous, local
Server (network) @rindle/optimisticrindled a daemon over a WebSocket, behind your API tier eventually consistent, then local
Rust rindle the std-only core, embedded directly — (the engine itself)

The browser and Node backends are local — the engine is in your process, so a write applies and the affected views update before store.write resolves. The server home runs the engine on the rindled daemon and streams changes to subscribers; in its richest form (the synced client) the browser also runs a local engine over those streamed rows, so reads are instant and writes optimistic.

What a backend does

A Backend is a small interface. The Store drives it; you rarely implement one yourself.

interface Backend {
  registerQuery(qid: QueryId, ast: Ast): void;     // start a live query
  unregisterQuery(qid: QueryId): void;             // tear it down
  mutate(mutations: Mutation[]): Promise<void>;    // apply writes
  onEvent(handler: (qid: QueryId, ev: ChangeEvent) => void): void;
}

Every backend emits the same per-query ChangeEvent stream, in three shapes:

  • hello — the schema + comparator version for this query (the view resets to it).
  • snapshot — the initial result set, as a batch of adds.
  • batch — the incremental changes for one committed write.

The Store builds one ArrayView per query and routes that query’s events into it. Because the stream shape is identical, one ArrayView implementation folds every backend’s output — the wasm engine’s, the native replica’s, or a remote server’s.

const store = new Store(schema, backend); // backend = WasmBackend | ReplicaBackend | …
const view = store.query.issue.where.closed(false).materialize();
view.subscribe(render);

Local vs. remote, precisely

A local backend (@rindle/wasm, @rindle/replica) runs the engine in-process and emits a lossless stream: hellosnapshotbatch, with no gaps to recover from. materialize() comes back already hydrated.

A remote backend speaks the same ChangeEvent stream to the Store, but underneath it owns a wire protocol: it validates an epoch / sequence / fingerprint-stamped frame stream from the server, and on a gap or drift it re-subscribes — the server re-registers under a new epoch and sends a fresh snapshot, and the ArrayView resets in place so the view reference you hold survives. The Store and ArrayView never see any of that; they just receive a clean stream. (This split — validation in the remote backend, folding in the core view — is what lets one view serve both.)

The synced backend composes two homes

The seam composes. The synced browser client (@rindle/optimistic, wired by createRindleClient) is a backend built from two homes at once: a local wasm engine for instant reads and optimistic writes, with its base tables fed by a remote daemon’s normalized stream. Writes are named mutators the local engine applies instantly and rebases as the server confirms — a rejected write snaps back on its own. The Store and ArrayView above the seam don’t change — they materialize from the local engine, which is itself kept current by the network. That’s how one client gets local-engine speed and server authority behind the same materialize().

Why this matters

You prototype against the browser engine, ship the same components against a Node replica, and move queries to the synced client without touching a line of query code. The hard part — keeping a materialized result correct under incremental change — is solved once, in the engine, and reused at every tier.

Next steps