Build a synced app

The browser client

createRindleClient gives the browser its own IVM engine over a local database — reads resolve instantly, writes apply optimistically and rebase, and rejections snap back on their own.

View as Markdown

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 with rindle schema gen; if you have local-only UI tables, pass extendSchema(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’s ctx.user (re-read on every invoke, so it’s stable across a rebase re-invoke). Match it to whatever api.headers sends up; the server injects its own authenticated principal for the authoritative run.
  • api — where named queries resolve to leases and mutations are pushed: url, optional routes, and headers (an object, or a function re-evaluated per request — put your session/JWT here).
  • daemon{ wsUrl } for the rindled follower’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-rindle ships the deferred-boot pattern in src/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:

  • Vuesubscribe into a ref/shallowRef, unsubscribe in onUnmounted.
  • Svelte — wrap in a readable(store) (the callback returns the unsubscriber).
  • SolidcreateSignal + 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 defineQuery and call the value (issuesPageQuery(args)); it stamps the result with its wire identity, so it always syncs. An ad-hoc app.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 a defineQuery.

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:

  • writeLocal accepts 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 for key.
  • maxWaitMs — a hard cap so a never-idle drag still persists periodically. Without it, only an idle gap of debounceMs ever flushes.
  • deferAcrossWrites (default false) — 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. Set true only 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) after setScore(5) is just 8). An increment()-style mutator is not absorbing and must not be folded; the folded path refuses a mutator that reads state with yield 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/wasm engine the client runs on, used standalone (no server).