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.
// (`?url` is Vite's asset-URL import; the package exports the asset at ./pkg/*.)
import wasmUrl from "@rindle/wasm/pkg/rindle_bg.wasm?url";
await initWasm(wasmUrl);
export const app = await createRindleClient({
schema,
mutators,
user: () => currentUser(), // the acting principal a mutator sees as ctx.user
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 follower's PUBLIC subscription port
onRejected: (envelope, reason) => showToast(`${envelope.name} rejected: ${reason}`),
});
The options in full:
schema— the browser schema. Usually this is the schema generated from your SQL withrindle schema gen; if you have local-only UI tables, passextendSchema(generatedSchema, { tables: [...] }).mutators— your isomorphic mutators (below). The client drives each body synchronously as the optimistic prediction; the API server drives the same body as the authority.user—() => string, the local identity injected as a mutator’sctx.user(re-read on every invoke, so it’s stable across a rebase re-invoke). Match it to whateverapi.headerssends up; the server injects its own authenticated principal for the authoritative run.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 therindledfollower’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, flushFolds, clientID, close }.
Server-rendering? Never construct the wasm engine during SSR/prerender — lazily
import("@rindle/optimistic")+import("@rindle/wasm")on the client and memoize the boot promise. See Server rendering;create-rindleships the deferred-boot pattern insrc/rindle-client.ts.
Isomorphic mutators
A write is a call to a named mutator — written once and run on both
tiers. A mutator is a generator that yields logical write ops
(yield tx.insert(...)) instead of touching a database directly; the client
drives the body synchronously against the local tables — the optimistic
prediction, re-invoked on every rebase, so it must not read clocks or
randomness (pass those in as args) — and the API server
drives the same body inside an authoritative transaction, rendering each op to
SQL. The acting user is never an argument — each tier injects it as ctx.user
(the client’s user option here; the server’s authenticated principal there), so
it can’t be spoofed over the wire.
import { defineMutators } from "@rindle/client";
import type { MutationGen } from "@rindle/client";
import type { ClientRegistry } from "@rindle/optimistic";
import { z } from "zod";
import { schema } from "./schema.gen.ts";
const { shared } = defineMutators(schema); // ops typecheck against the schema
export const mutators = {
// update names only the pk + the columns that change; a missing row is a no-op
setStatus: shared(
z.object({ id: z.string(), status: z.string(), updatedAt: z.number() }),
function* (tx, a): MutationGen {
yield tx.update("issue", { id: a.id, status: a.status, updatedAt: a.updatedAt });
},
),
} satisfies ClientRegistry;
The full contract — the op vocabulary (insert / update / upsert /
insertIgnore / delete), reads inside a body (yield tx.row /
tx.query / tx.all), the ctx.user principal, and the determinism rules — is
Isomorphic mutators. Everything on the rest of this page (rebase,
folding, rejection) applies to any mutator you write there.
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 { issuesPageQuery } from "./components/IssueListItem.queries.ts";
import { app } from "./rindle-client.ts";
createRoot(root).render(
<Rindle store={app.store}>
<App />
</Rindle>,
);
function IssueList() {
const rows = useQuery(issuesPageQuery({ limit: 50 })); // readonly Issue[], live
return <ul>{rows.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;
}
When a whole component tree needs data, don’t give each component its own
useQuery — that’s a request waterfall. Declare each component’s slice as a
fragment, compose them into one named root query, open that root with
useRoot, and let descendants read opaque refs with useFragment. See
Compose the UI with fragments.
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(issuesPageQuery({ limit: 50 }));
view.subscribe((rows) => render(rows)); // fires immediately, then on every change
Wire that into whatever reactivity your framework has — the store core is framework-agnostic:
- Vue —
subscribeinto aref/shallowRef, unsubscribe inonUnmounted. - Svelte — wrap in a
readable(store)(the callback returns the unsubscriber). - Solid —
createSignal+onCleanup(unsub). - Vanilla — call your DOM render function from the subscription.
app.mutate.<name>(args) is the same everywhere — call it from any event handler.
Remote subscriptions must be named — define each one with
defineQueryand call the value (issuesPageQuery(args)); it stamps the result with its wire identity, so it always syncs. 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.) Because a query is defined once and callable, there is no unstamped builder to import by accident: every named query lives next to its component as adefineQuery.
Local-only tables: drafts, selections, prefs
Not every row belongs to the server. Selection state, draft text, expanded/collapsed rows,
and view preferences often need the same reactive query machinery as synced rows, but they
should be client-authoritative and private. Declare those tables in the shared client
schema with { local: true }:
import { createSchema, number, string, table } from "@rindle/client";
export const issue = table("issue")
.columns({ id: number(), title: string() })
.primaryKey("id");
export const draft = table("draft", { local: true })
.columns({ id: string(), issueId: number(), body: string() })
.primaryKey("id");
export const schema = createSchema({ tables: [issue, draft] });
A local-only table never crosses the wire: it is omitted from the normalized schema the client advertises to the server, it is not tracked by the daemon, and an optimistic rebase never rewinds it. That makes it the right place for UI-only state that should survive server confirmations/rejections unchanged.
Use store.writeLocal for those direct client writes:
await app.store.writeLocal((tx) =>
tx.add("draft", { id: "compose", issueId: 42, body: "half-written reply" }),
);
Two guardrails keep private and synced state separate:
writeLocalaccepts only local-only tables; writing a synced table there is rejected.- Mutators cannot read or write local-only tables. A mutator is replayed on the
server and during rebase from
(name, args)alone, so it must not depend on private browser rows.
app.store.query can still materialize local-only tables and compose them with synced rows
locally. Named server queries cannot: newQueryBuilder(schema) excludes local-only tables
so your remote contract never accidentally depends on private UI state.
When your synced schema is generated, keep it generated. Put local tables in a small
hand-authored module and combine them with extendSchema:
// shared/schema.local.ts
import { extendSchema, string, table } from "@rindle/client";
import { schema as generatedSchema } from "./schema.gen.ts";
export const composerDraft = table("composerDraft", { local: true })
.columns({ id: string(), body: string() })
.primaryKey("id");
export const clientSchema = extendSchema(generatedSchema, { tables: [composerDraft] });
Hand clientSchema to createRindleClient in the browser. Your API server and named-query
registry should continue to use the generated synced schema; extendSchema refuses ordinary
non-local tables, so server-owned tables still come only from rindle schema gen.
Writes: optimistic, rebased
app.mutate.<name>(args) drives a mutator’s body against 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 drives the same body in an authoritative 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: shared(z.object({ id: z.string(), delta: z.number() }), function* (tx, a): MutationGen {
const cur = (yield tx.row("issue", { id: a.id })) as { priority: number } | undefined; // base + this txn's writes
if (cur) yield tx.update("issue", { id: a.id, priority: cur.priority + 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.
Folded writes: high-frequency drags
Some writes fire tens of times a second — dragging a slider, scrubbing a timeline,
a field that persists on every keystroke. The local view has to track every
intermediate instantly, but the server only ever needs the last value. Sent as
plain mutate.<name>(args) calls, sixty drag events become sixty pending entries and
sixty server writes — and every rebase re-invokes all sixty.
app.mutate.<name>.folded(opts, args) collapses a run of same-key calls into one
pending entry whose args are overwritten in place: the local view still updates on
every call, but the server write is debounced and ships only the last value.
function onSliderDrag(id: string, value: number) {
// setScore(id, value) is an ordinary mutator; `.folded` just changes how it's queued.
app.mutate.setScore.folded({ key: id, debounceMs: 120, maxWaitMs: 1000 }, { id, value });
// the local view tracks every frame; one server write lands once the drag settles.
}
key(required) — the identity the run folds on. The fold key is(mutator name, key), so two sliders bound to two rows fold independently, while the same slider dragged twice folds into one entry. Use the row’s primary key.debounceMs(default 120) — the server write fires this long after the last call forkey.maxWaitMs— a hard cap so a never-idle drag still persists periodically. Without it, only an idle gap ofdebounceMsever flushes.deferAcrossWrites(defaultfalse) — by default, an overlapping ordinary write flushes the fold first, so a read-dependent write never sees a value the server hasn’t been told about yet. Settrueonly for an isolated leaf cell, to keep deferring for maximum economy.
.folded() returns a small handle rather than a mutation id (none is assigned until
the write flushes): { flush(), mid }, where mid is a Promise<number> that
resolves with the wire id once the window flushes — await it when you need the ack.
const h = app.mutate.setScore.folded({ key: id }, { id, value });
h.flush(); // force the debounced write out right now
const wireId = await h.mid; // resolves once it flushes
app.flushFolds() drains every outstanding fold immediately, and the client also
flushes automatically on pagehide / beforeunload, so a fold in flight isn’t lost
on navigation. (Two costs are inherent to debouncing the write: an unclean crash
mid-drag loses the un-shipped tail, and other clients see the new value a beat later.
Every non-folded write still flushes as soon as possible.)
Folded mutators must be absorbing — replaying only the last args must yield the same state as replaying all of them (
setScore(8)aftersetScore(5)is just8). Anincrement()-style mutator is not absorbing and must not be folded; the folded path refuses a mutator that reads state withyield tx.row/tx.query(the classic non-absorbing shape) by throwing.
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.
Loading and pending are separate signals
A query carries a ResultType that tracks the server channel only — has this
query been answered yet?
app.backend.resultType(queryId); // "unknown" (loading) → "complete"
app.backend.onResultType((qid, rt) => …); // or subscribe to transitions
"unknown" means not hydrated — the server hasn’t produced a first result yet —
and "complete" means it has. A pending optimistic write does not move it: the
prediction is your best current answer, so the query stays "complete" through the
write, and a rejection is an event (onRejected), not a downgrade. ("error" is
reserved for a future server-side, query-level error and is currently unproduced.)
With React, useQueryStatus(query) returns this same value.
“Is an unconfirmed write pending here?” is a separate axis — drive a “saving…” affordance off it and it clears itself once the server catches up:
app.backend.pending(queryId); // true while a pending write touches its tables
app.backend.onPending((qid, pending) => …); // reactive: flips on invoke, back on confirm
app.backend.pendingTables(); // the coarse, table-level set
Most UIs ignore both and just render the optimistic result — which is the whole point of optimism.
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.
Devtools
For development, attach the Rindle devtools core after createRindleClient and
mount the React panel once near the root. The floating Rindle launcher opens the
mutation timeline, query inspector, and raw delta stream.
See Devtools for the package wiring, SSR-safe mounting pattern,
and production tree-shaking rules. create-rindle apps
include this wiring by default.
Next steps
- Isomorphic mutators — the full write contract: the op vocabulary, reads inside a body, the acting principal, the determinism rules.
- Compose the UI with fragments — let a component tree declare its data as composable fragments that assemble into one waterfall-free query.
- The API server — the tier that drives these same mutators with authority and resolves your named queries.
- Server rendering — preload named queries, hydrate the seed store, then hand off to the live wasm client.
@rindle/cli— run the local daemon and schema workflow this client subscribes to.- Devtools — inspect optimistic mutations, live queries, and raw deltas in development.
- 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).