Build a synced app

Full app: the issue tracker

The flagship demo — a real issue tracker on the three-tier topology: an always-up rindled daemon, a serverless-shaped API server, and an optimistic React client, with 5,000 seeded issues, paginated live windows, optimistic writes, and two live rejection paths. Real code, one command.

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

Issue-tracker wiring: two network planes The browser subscribes straight to the rindled daemon over the public WebSocket for cv-stamped normalized rows, while query resolution and writes travel through the API server over a private HTTP control plane. ws · cv-stamped normalized rows HTTP /api/rindle/* names + mutations control plane private BROWSER React + @rindle/* optimistic engine local IVM + reads API SERVER app authority resolves named queries runs authoritative mutators RINDLED DAEMON always-up daemon holds data + IVM private control plane
Two wires: the browser subscribes straight to the daemon over the public WebSocket; writes and query resolution go through the API server's private control plane.
  • 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 dev boots 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