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/optimistic → rindled |
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: hello → snapshot → batch, 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
- The three-tier architecture — the full synced app the homes compose into.
- Reactive queries in the browser — the
@rindle/wasmengine on its own, no server. - Replicate a source & materialize views — the native Node / Rust engine.
- The change model — the
Add/Remove/Edit/Childdeltas the stream carries. - Fold the delta stream yourself (Rust) — the seam at its lowest level.