Build a synced app

The browser client

createRindleClient gives the browser its own IVM engine over its own local database, fed by the daemon's normalized stream. Reads resolve locally and instantly; writes apply optimistically through named mutators, rebased by the engine itself; rejections snap back on their own.

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, optional routes, and headers (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 defineQueries and pass queries.foo(args). 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.)

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