Build a synced app

Full app: the issue tracker

A real issue tracker on all three tiers — rindled daemon, API server, optimistic React client — with paginated live windows over 5,000 issues and two live rejection paths. One command.

View as Markdown

This is the three-tier architecture as a real app you can run — the worked, complete version of the synced-app quickstart. It lives in the repo at apps/example-issue-tracker and boots all three tiers — the Rust data tier (a rindle-replicator write-master + its rindled follower, the colocated pair), the API server, and the Vite/React client — with a single command.

The quickstart builds a true subset of this app by hand, file by file; this page is the finished thing — run it, click it, and read it.

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).

Same schema, at scale

It’s the same normalized schema as the quickstart — user · issue · tag · comment, joined back together at query time — wired through the same createRindleClient (browser), registerQueries + sharedApiMutators (API server), and the colocated pair (write-master + follower). Two things a toy dataset can’t show you, this app does:

A growing live window over a big table. The write-master is seeded with 5,000 issues at boot, so the client never asks for everything — it holds a growing live window, and infinite scroll just ratchets limit up a page at a time (one named subscription, not a stack of cursor windows). IVM keeps the window correct: a new issue enters the top incrementally (the last row falls out as it does), and the window backfills after a delete.

Idempotent bulk seeding through the write-master. server/seed.ts inserts the 5,000-row corpus as one idempotency-keyed bulk transaction on the write-master, so it never re-seeds on restart:

await daemon.executeSqlTxn({
  idempotencyKey: `seed-issues-v3-${count}`,
  statements: seedStatements(count), // ≤100 rows/statement (SQLite's 999-param cap)
});

Two rejection paths, live

The mutators are isomorphic — one shared body per name, auto-driven on the server by sharedApiMutators (the code is the quickstart’s step 5). The only explicit server entries are the authority the client must not predict — and both rejection behaviors are wired through them so you can watch them in two browser windows, each window picking its own user via the x-user header:

  • Hard reject — creating an issue whose title contains “spam” is refused (a server-only policy guard wrapped around the shared body); 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 (an owner-gated cascade is relational authority a keyed op can’t express, so it stays raw SQL: … WHERE id = ? AND ownerId = ?); the optimistic delete snaps back when the empty authoritative change syncs in.

Run it

# once, from the repo root — install and build the artifacts the app needs:
pnpm install
pnpm run build:wasm                                              # the browser engine (packages/wasm/pkg)
cargo build -p rindle-server -p rindle-replicator -p rindle-cli -p rindle-dev-edge  # the local fleet binaries

cd apps/example-issue-tracker
pnpm dev      # boots the colocated pair (via `rindle up`), seeds 5,000 issues, 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 + relationships + isomorphic mutators (the shared contract) shared/app-def.ts
co-located named queries (defineQuery) + fragments src/components/*.queries.ts
the one-call client wire-up (createRindleClient) src/rindle-client.ts
window accumulation + fragment-rooted React binding (useRoot) over copy-on-write views src/AppChrome.tsx
the sharedApiMutators spread + server-only authority overrides + registerQueries + policy + auth server/app-api.ts
idempotent bulk seeding through the write-master server/seed.ts
the colocated pair (write-master + follower) boot + 3-tier wiring package.json — the dev scripts (rindle up + concurrently); entrypoint.sh for the deployed ROLE=colocated box

Next steps