This is the three-tier architecture as a real app you can
run. It lives in the repo at packages/example-issue-tracker and boots all three
tiers — the Rust rindled daemon, the API server, and the Vite/React client — with
a single command.
It is the worked version of the browser client, the API server, and the daemon wired together.
Live demo: it’s hosted at issues.rindle.sh — a shared sandbox that resets daily. Or run the whole stack locally in one command with
pnpm dev(below).
Three tiers, real wires
- The browser runs the optimistic engine: mutations apply instantly and rebase on confirmation; a rejected write snaps back on its own.
- The API server is the app authority: it resolves named queries to ASTs, runs the authoritative mutators into approved SQL through the daemon’s control plane, and enforces auth and policy.
- The daemon (
rindled) is always up.pnpm devboots the Rust binary, which holds the data and the IVM pipelines and streams normalized updates to every subscriber.
One shared artifact
shared/app-def.ts is the single file both ends import — the schema, the named
queries, and the client’s predicted mutators (the API server holds the
authoritative twins under the same names):
export const PAGE_SIZE = 50;
export const queries = defineQueries({
// A live *window*, not "all issues": newest-first, capped at one page, with an
// optional cursor. There is deliberately no unbounded query.
issuesPage: (cursor: IssueCursor | null) => {
const page = q.issue.orderBy("createdAt", "desc").orderBy("id", "asc").limit(PAGE_SIZE);
return cursor
? page.start({ createdAt: cursor.createdAt, id: cursor.id }, { exclusive: true })
: page;
},
});
The issue table is big — the daemon is seeded with 5,000 issues at boot — so the client never asks for everything. It holds paginated live windows and accumulates more with “Load more”. IVM keeps every window correct: a new issue enters the top window incrementally (the 50th row falls out as it does), and a window backfills after a delete.
The client is one call
src/client.ts wires the entire client stack with createRindleClient — the local
wasm engine, the daemon ws subscription, and the API-server mutation queue:
import { createRindleClient } from "@rindle/optimistic";
import { mutators, schema } from "../shared/app-def.ts";
export const app = await createRindleClient({
schema,
mutators,
api: {
url: "", // same-origin: Vite proxies /api/*
headers: () => ({ "x-user": currentUser() }), // a real app sends a session/JWT
},
daemon: { wsUrl: import.meta.env.VITE_DAEMON_WS ?? "ws://127.0.0.1:7601" },
onRejected: (envelope, reason) => rejectionHandler(envelope, reason),
});
src/App.tsx reads through @rindle/react’s useQuery (under a
<Rindle store={app.store}> provider in main.tsx) and writes through
app.mutate.*:
const rows = useQuery(queries.issuesPage(cursor)); // live, reference-stable
// …
app.mutate.setStatus({ id: issue.id, status, updatedAt: Date.now() }); // optimistic
Reads resolve locally and instantly; writes apply optimistically and rebase as the API server confirms them.
The API tier enforces policy
server/api.ts holds the authoritative mutators and the rules the client cannot be
trusted to keep — tx.exec writes the approved SQL, and throwing rejects the
mutation. Two rejection paths are wired so you can see both behaviors live:
const apiMutators = defineApiMutators<User>({
createIssue: (tx, a, ctx) => {
const user = requireUser(ctx.user);
if (/\bspam\b/i.test(a.title)) throw new Error('the word "spam" is not allowed'); // HARD reject
tx.exec("INSERT INTO issue (...) VALUES (?, ?, ...)", [/* … */]);
},
// Ownership enforced IN the SQL — a non-owner's delete is accepted-but-no-op,
// and the optimistic delete snaps back when the empty authoritative change syncs.
deleteIssue: (tx, a, ctx) =>
tx.exec("DELETE FROM issue WHERE id = ? AND owner = ?", [a.id, requireUser(ctx.user)]),
});
- Hard reject — a title containing “spam” is refused; the client shows a rejection toast and the optimistic row snaps back.
- Accepted-but-no-op — deleting an issue you don’t own changes nothing; the optimistic delete snaps back when the authoritative state syncs in.
Each browser window picks its own “user” (the x-user header), so ownership — and
the second case — is easy to demonstrate with two windows.
The daemon + the seed
server/dev.ts builds and boots the Rust rindled against a generated
daemon.json (the issue table, two ports, the bearer token, four workers), then
starts the API server and the seed. server/seed.ts inserts the 5,000-row corpus as
one idempotency-keyed bulk transaction through the control plane, so the
file-backed daemon never re-seeds on restart:
await daemon.executeSqlTxn({
idempotencyKey: `seed-issues-v2-${count}`,
statements: seedStatements(count), // ≤100 rows/statement (SQLite's 999-param cap)
});
Run it
# from packages/ once: pnpm install, and build the wasm engine if you haven't:
# (cd wasm && ./build.sh)
cd example-issue-tracker
pnpm dev # builds + boots rindled (cargo), the API server, the seed, then Vite
Open the printed URL in two browser windows and watch edits sync live across them. For a headless, CI-able proof of the whole wiring:
pnpm smoke
Where to look
| Concern | File |
|---|---|
| schema + named queries (paginated windows) + predicted mutators | shared/app-def.ts |
the one-call client wire-up (createRindleClient) |
src/client.ts |
page accumulation + React binding (useQuery) over copy-on-write views |
src/App.tsx |
| authoritative mutators + policy + auth + cursor validation | server/api.ts |
| idempotent bulk seeding through the control plane | server/seed.ts |
the always-up daemon (rindled) boot + 3-tier wiring |
server/dev.ts |
Next steps
- The three-tier architecture — the topology this realizes.
- The browser client · The API server · Run the daemon — each tier in depth.
- The change model — the normalized deltas the daemon streams.