Build a synced app

The three-tier architecture

How a synced Rindle app is wired — an optimistic browser client, a stateless API authority, and the always-up rindled daemon — plus the two planes and who is trusted with what.

View as Markdown

A synced Rindle app is three tiers, and they map cleanly onto a shape you already know — client, stateless app server, database:

Rindle three-tier architecture A browser client sends query names and mutation arguments to a stateless API server, which resolves them, sends writes to the rindle-replicator write-master and reads to a rindled read-follower; the follower streams normalized, cv-stamped rows back to the browser over the public WebSocket. names + args queries · mutations control plane private · HTTP BROWSER your React app own IVM engine, local reads optimistic writes API SERVER your app's authority resolves names, runs mutators stateless · serverless-shaped DATA TIER run it like Postgres write-master + follower(s) always up · derives deltas normalized, cv-stamped rows · public WebSocket
Three tiers — client, stateless authority, and the Rindle data tier (a replicator write-master + rindled read-follower[s]). Names and arguments flow up; normalized row deltas stream back.

The whole issue-tracker example is exactly this, end to end — and the synced-app quickstart builds a subset of it by hand if you’d rather start from code. If you want a small generated project, create-rindle scaffolds the same topology as a TanStack Start app. The rest of this page is the map; the per-tier pages are the detail.

The three tiers

  • The browser runs createRindleClient — its own IVM engine (@rindle/wasm) over its own local database. Reads resolve locally and instantly; writes apply optimistically through named mutators and the engine rebases them as the server confirms. A rejected write snaps back on its own.
  • The API server is your app’s authority. It is stateless and serverless-shaped — every request could be a fresh lambda. It authenticates the caller, resolves named queries to query ASTs, runs the authoritative mutators into approved SQL, and enforces auth and policy. It holds no data and no live state; it talks to the daemon over a private, bearer-authed HTTP control plane.
  • The data tier is the rindle-replicator write-master plus one or more rindled read-followers — stateful and always up, like Postgres. The master owns the authoritative HCTree database and accepts concurrent transactions into one total write order; each follower holds a replica and the live query pipelines, derives the incremental delta after every write it receives, and streams normalized, cv-stamped updates to every subscriber. The smallest is a colocated pair on one box.

The split is deliberate: the data tier is the stateful thing you operate; the API server scales to zero and back; the browser holds a fast local replica of just the rows its queries need.

This page draws the data tier as one logical box — internally it is the write-master and its follower(s), smallest as a colocated pair on one box. The same three tiers scale out to a replicated backup or a write-master with N read followers near your users. The browser keeps one stable fleet endpoint; signed affinity tickets co-locate its WebSocket and lease requests on a follower without changing app code. See deploying & scaling for the full menu — and which shapes you can run yourself versus have us run for you on Rindle Cloud (the Cloud quickstart provisions one in a few minutes).

The follower’s two planes

A rindled follower exposes two network surfaces, kept separate so the untrusted browser and the trusted server-to-server traffic never share a door:

Plane Port Who connects Carries
Public WebSocket wsPort browser clients the normalized subscription stream — init, subscribe/unsubscribe, and cv-stamped snapshot + delta frames out
Private HTTP control httpPort your API server reads only/materialize (mint a lease), /execute-sql-read (a raw read), /dematerialize

Writes never touch a follower: the API server sends them to the rindle-replicator write-master’s ingress (/execute-sql-txn, /migrate, /mutate-session/*, /reject-mutation) — its SplitDaemonClient routes reads to the follower and writes to the master. A browser never speaks either control plane. It can open a ws subscription (with a lease token the API server minted for it) and nothing else. Every privileged action — turning a query name into a real AST, turning a mutation into SQL — happens in the API tier, behind your auth.

The one shared artifact

Both ends of your app import one contract file (shared/app-def.ts in the example): the schemagenerated from your SQL — plus the named relationships and the isomorphic mutators, which you hand-write beside it and both tiers drive.

import { createSchema, newQueryBuilder, number, string, table } from "@rindle/client";

// The schema block is generated from your SQL by `rindle schema gen` — see /docs/schema.
export const issue = table("issue")
  .columns({ id: string(), title: string(), status: string(), /* … */ createdAt: number() })
  .primaryKey("id");
export const schema = createSchema({ tables: [issue] });

The named queries are not in that file — each is a singular defineQuery, co-located with the component that reads it (a React-free *.queries.ts module). One value is defined once and used on both tiers: callable on the client (it stamps its result with the wire identity, so a subscription syncs) and registered on the server with registerQueries. Its optional validate step runs on both tiers, so client and server build a byte-identical AST:

// src/components/IssueListItem.queries.ts
import { defineQuery, newQueryBuilder } from "@rindle/client";
import { schema } from "../../shared/app-def.ts";

const q = newQueryBuilder(schema);

// a live *window* over a big table, not "all issues"
export const issuesPageQuery = defineQuery(
  "issuesPage",
  validateIssuesPageArgs,
  ({ limit }: IssuesPageArgs) => q.issue.orderBy("createdAt", "desc").limit(limit),
);

A mutator is one isomorphic body, driven by two tiers. The client drives it optimistically against the local tables (the prediction); the API server drives the same body under its own authority, rendering its logical ops to real SQL. Only (name, args) ever crosses a wire — client-built ASTs and client-computed effects never become server authority.

The two round-trips

Everything an app does is one of two flows. Both send only names and arguments up; both get normalized row deltas back.

Subscribing to a query

Subscribing to a query A sequence across three lifelines — browser, API server, rindled read-follower. The browser posts a query name and args to the API server; the API server resolves the name to an AST and calls the follower to materialize it; the follower returns a lease token relayed back to the browser; the browser opens a WebSocket subscription presenting that lease; the follower then streams the normalized snapshot and live cv-stamped deltas straight back to the browser. BROWSER optimistic · local IVM API SERVER your authority · stateless RINDLED read-follower · always up POST { name, args } materialize(issuesPage) 1 resolve name → AST daemon.materialize(ast) 2 lease token · relayed by API 3 open ws subscription · presents lease 4 normalized snapshot → live cv-stamped deltas 5
Subscribing — only a query name and its args travel up. The follower mints a lease, then streams the normalized snapshot and every live cv-stamped delta straight back to the browser.

The query name is the wire identity. The client builds the same query locally (to materialize a view), but what travels is { name, args }; the API server owns what that name means and can wrap it in tenancy or auth filters the client can’t see.

Making a write

Making a write A sequence across three lifelines — browser, API server, and the Rindle data tier. The browser's predicted mutator runs against the local engine and updates the view instantly; the client posts a mutation envelope of mid, name and args to the API server; the API server runs the authoritative mutator and sends the resulting SQL to the rindle-replicator write-master, idempotent on mid; the write-master applies it and replicates it to a rindled follower, which derives every affected query's delta and streams them back; the browser then rebases onto authoritative state and replays still-pending mutators. BROWSER optimistic · local IVM API SERVER your authority · stateless DATA TIER write-master → follower predicted mutator → LOCAL engine the view updates now · optimistic 1 POST { mid, name, args } 2 authoritative mutator → SQL executeSqlTxn · idempotent on mid 3 derives every affected delta → streams back 4 REBASE rewind to authoritative · replay pending 5
Writing — the predicted mutator updates the local view instantly; the authoritative mutator runs server-side against the write-master, which replicates to a follower whose derived deltas rebase the client onto authoritative state. If that mutator throws, a rejection rides the same stream and the optimistic rows vanish — no rollback code.

If the authoritative mutator throws, the API server calls /reject-mutation instead; the rejection rides back on the stream, the client rebases without the refused write, and the optimistic rows vanish from every affected view — no rollback code, because the authoritative state never saw the write.

The correctness contract

Across all three tiers the guarantee is the same one the engine makes everywhere: view-after-write == fresh-query. The deltas the daemon derives, applied in order by the client’s engine, always equal what a from-scratch query would return. Optimistic now, authoritative the moment the daemon confirms — and the two converge, with no torn reads in between (frames buffer on the client and release coherently at the daemon’s progress mark).

What each tier is made of

Tier You write Built on
Browser client schema, queries, predicted mutators, UI @rindle/optimistic (createRindleClient), @rindle/wasm, @rindle/react
API server named queries → ASTs, authoritative mutators, auth/policy @rindle/api-server, @rindle/daemon-client
Data tier a rindle.ncl (the write-master + follower[s]) rindle-replicator (write-master) + rindled (the rindle-server follower) over the multi-threaded Cluster

Next steps

  • The browser clientcreateRindleClient, optimistic mutators, reads with @rindle/react, snap-back on rejection.
  • Scaffold with create-rindle — a small TanStack Start app with all three tiers wired.
  • The API servercreateRindleApiServer, authoritative mutators, authorization, talking to the daemon’s control plane.
  • Server rendering — preload named queries for first paint, then hand off to the live browser client.
  • Run the daemonrindled, the two planes, the config, restart recovery, and the Cluster engine underneath.
  • Full app: the issue tracker — all three tiers, real code, one command to run.
  • The change model — the normalized deltas the daemon streams.