The browser tier runs its own IVM engine (@rindle/wasm) over its own
local database, fed by a stream of normalized row deltas from the
daemon. It doesn’t render a result someone else computed — it
computes its own, against rows it holds locally.
That one move solves three things app developers otherwise wire up by hand, badly:
- Live queries — a materialized view stays correct as data changes, with no polling and no refetch.
- The cache — there is no separate cache to invalidate. The local database is the cache, and the engine keeps every view derived from it exact.
- Optimistic updates — a named mutator applies to the local tables synchronously; the local engine re-materializes every affected view before the call returns. The server is the authority; the engine rebases your pending writes as confirmations stream in, and a rejected write snaps back on its own — you never write rollback code.
One call wires the whole tier
createRindleClient boots the wasm engine, opens the ws subscription to the
daemon, resolves query leases through your API server, and runs
the mutation queue. It returns the Store, the optimistic backend, and the
mutate entry point.
import { createRindleClient } from "@rindle/optimistic";
import { initWasm } from "@rindle/wasm";
import { mutators, schema } from "../shared/app-def.ts";
// Optional: point the engine at a custom-served wasm asset before the client's own
// (idempotent) init runs. Omit this and createRindleClient boots the default wasm.
import wasmUrl from "rindle-wasm-bin?url";
await initWasm(wasmUrl);
export const app = await createRindleClient({
schema,
mutators,
api: {
url: "", // same-origin; or "https://api.example.com"
headers: () => ({ "x-user": currentUser() }), // a real app sends a session/JWT
},
daemon: { wsUrl: "ws://127.0.0.1:7601" }, // the daemon's PUBLIC subscription port
onRejected: (envelope, reason) => showToast(`${envelope.name} rejected: ${reason}`),
});
The options in full:
schema— the shared schema (the same value the API server imports).mutators— your predicted mutators (below); the API server holds the authoritative twins under the same names.api— where named queries resolve to leases and mutations are pushed:url, optionalroutes, andheaders(an object, or a function re-evaluated per request — put your session/JWT here).daemon—{ wsUrl }for the daemon’s public subscription endpoint (or{ transport }to supply your own, e.g. in tests).onRejected(envelope, reason)— fired when the API server refuses a mutation; the optimistic prediction has already snapped back by the time you see it. Use it to surface a message.clientID— a stable identity (defaults to a localStorage-persisted UUID, so a reload keeps its mutation sequence).
It returns { store, backend, mutate, clientID, close }.
Predicted mutators
A write is a call to a named mutator. The client’s mutators are
deterministic, replayable functions of (tx, args) — they run against the
local tables now and are re-invoked on every rebase, so they must not read
clocks or randomness (pass those in as args).
Prefer the keyed MutationTx methods — insert / update / upsert /
delete / row — which take rows as objects keyed by column and are
schema-checked:
import type { ClientRegistry, MutationTx } from "@rindle/optimistic";
export const mutators = {
createIssue: (tx: MutationTx, a: CreateIssueArgs) =>
tx.insert("issue", {
id: a.id, title: a.title, status: a.status, priority: a.priority,
owner: a.owner, tags: JSON.stringify(a.tags), comments: JSON.stringify([]),
createdAt: a.createdAt, updatedAt: a.createdAt,
}),
// update names only the pk + the columns that change; a missing row is a no-op
setStatus: (tx: MutationTx, a: { id: string; status: IssueStatus; updatedAt: number }) =>
tx.update("issue", { id: a.id, status: a.status, updatedAt: a.updatedAt }),
deleteIssue: (tx: MutationTx, a: { id: string }) => tx.delete("issue", { id: a.id }),
} satisfies ClientRegistry;
tx.row("issue", { id }) reads a row by primary key — and it sees the current
base plus this transaction’s own staged writes, which is what makes
read-dependent mutators correct under rebase. (insert needs every column; update
only the pk plus what changes; delete only the pk. The positional
get/add/remove/edit methods are the raw wire shape — reach for them only
when you want bare cells in column order.)
Reads: live views
With React
Wrap the tree in <Rindle store={app.store}> and read a query with useQuery. The
hook returns the live .data and re-renders only when the result changes — views
are reference-stable, so memoized components stay put.
import { Rindle, useQuery } from "@rindle/react";
import { queries } from "../shared/app-def.ts";
import { app } from "./client.ts";
createRoot(root).render(
<Rindle store={app.store}>
<App />
</Rindle>,
);
function IssueList() {
const rows = useQuery(queries.issuesPage(null)); // readonly Issue[], live
return <ul>{rows.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;
}
Without React
app.store.materialize(query) returns an ArrayView whose .data is the current
result; subscribe fires now and after every change.
const view = app.store.materialize(queries.issuesPage(null));
view.subscribe((rows) => render(rows)); // fires immediately, then on every change
Remote subscriptions must be named — build them with
defineQueriesand passqueries.foo(args). An ad-hocapp.store.query.issue.where…builder query is resolved locally only, off rows already synced; it never opens a server subscription. (That local resolution is a feature — see below.)
Writes: optimistic, rebased
app.mutate.<name>(args) calls a predicted mutator. It applies to the local tables
synchronously — every affected view updates before the call returns — and
returns the mutation id. No spinner, no round-trip, nothing to await:
app.mutate.createIssue({ id, title: "ship it", status: "todo", /* … */ createdAt: Date.now() });
// the issue list already shows it; the API server confirms moments later.
Under the hood the mutation’s name and arguments (never its effects) are queued and pushed to the API server, which runs the authoritative twin in a transaction. The confirmed deltas stream back and the client rebases: it rewinds to the authoritative state, re-invokes every still-pending mutator on top, and the engine derives exactly the view delta that resolves the difference.
Re-invocation is what makes read-dependent writes correct. A mutator that reads before it writes recomputes against the authoritative data on every rebase — so it replays the intent, not a stale effect:
bumpPriority: (tx, a: { id: string; delta: number }) => {
const cur = tx.row("issue", { id: a.id }); // base + this txn's writes
if (cur) tx.update("issue", { id: a.id, priority: (cur.priority as number) + a.delta });
},
// predicts against a local value; if the server lands a concurrent change first,
// the rebase re-runs the +delta against the authoritative value and settles correctly.
Rejection snaps back on its own
If the authoritative mutator throws, nothing commits; the rejection rides back on
the stream and the client rebases without the refused mutation. The phantom
rows vanish from every affected view — there is no rollback code, because the
authoritative state never saw the write. onRejected fires so you can show a
message.
Each query also carries a ResultType so the UI can tell confirmed from in-flight:
app.backend.resultType(queryId); // "unknown" → "complete" | "error"
app.backend.onResultType((qid, rt) => …); // or subscribe to transitions
"unknown" while a pending mutation touches the query’s tables, "complete" once
everything shown is authoritative, "error" when a rejection touched it.
Local query resolution
A second query over rows you’ve already synced resolves locally, immediately — the local engine answers it from the base tables it already holds, with no new server round-trip:
// issues already pulled in by issuesPage; a detail view materializes off them:
const detail = app.store.query.issue.where.id(eq(selectedId)).one().materialize();
detail.subscribe((row) => renderDetail(row)); // Issue | null, resolved locally
This is what makes navigation and drill-downs feel instant: they hit the local engine, and the server stream only ever adds freshness on top.
Next steps
- The API server — the authoritative twin of your mutators and the resolver for your named queries.
- Run the daemon — the always-up server the client subscribes to.
- Full app: the issue tracker — this client tier in a real React app.
- The change model — the normalized deltas the engine folds.
- Reactive queries in the browser — the
@rindle/wasmengine the client runs on, used standalone (no server).