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

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

<figure class="doc-fig">
<svg viewBox="0 0 860 240" role="img" aria-labelledby="archT archD" class="doc-arch">
<title id="archT">Rindle three-tier architecture</title>
<desc id="archD">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.</desc>
<defs>
<marker id="archInk" markerWidth="8" markerHeight="8" refX="6" refY="3.5" orient="auto" markerUnits="userSpaceOnUse"><path d="M0,0 L7,3.5 L0,7 Z" fill="var(--ink-f)"/></marker>
<marker id="archOrg" markerWidth="8" markerHeight="8" refX="6" refY="3.5" orient="auto" markerUnits="userSpaceOnUse"><path d="M0,0 L7,3.5 L0,7 Z" fill="var(--orange)"/></marker>
</defs>
<line x1="222" y1="86" x2="323" y2="86" stroke="var(--ink-f)" stroke-width="1.4" marker-end="url(#archInk)"/>
<line x1="536" y1="86" x2="637" y2="86" stroke="var(--ink-f)" stroke-width="1.4" marker-end="url(#archInk)"/>
<path d="M744,152 L744,188 Q744,202 730,202 L130,202 Q116,202 116,188 L116,158" fill="none" stroke="var(--orange)" stroke-width="1.6" stroke-linejoin="round" class="flow" marker-end="url(#archOrg)"/>
<text x="273" y="74" text-anchor="middle" class="wlbl">names + args</text>
<text x="273" y="100" text-anchor="middle" class="wsub">queries · mutations</text>
<text x="587" y="74" text-anchor="middle" class="wlbl">control plane</text>
<text x="587" y="100" text-anchor="middle" class="wsub">private · HTTP</text>
<rect class="card" x="16" y="34" width="200" height="118" rx="12"/>
<circle cx="32" cy="58" r="3" fill="var(--cobalt)"/>
<text x="44" y="61" class="ey">BROWSER</text>
<text x="32" y="92" class="ti">your React app</text>
<text x="32" y="114" class="su">own IVM engine, local reads</text>
<text x="32" y="130" class="su2">optimistic writes</text>
<rect class="card" x="330" y="34" width="200" height="118" rx="12"/>
<circle cx="346" cy="58" r="3" fill="var(--teal)"/>
<text x="358" y="61" class="ey">API SERVER</text>
<text x="346" y="92" class="ti">your app's authority</text>
<text x="346" y="114" class="su">resolves names, runs mutators</text>
<text x="346" y="130" class="su2">stateless · serverless-shaped</text>
<rect class="card" x="644" y="34" width="200" height="118" rx="12"/>
<circle cx="660" cy="58" r="3" fill="var(--orange)"/>
<text x="672" y="61" class="ey">DATA TIER</text>
<text x="660" y="92" class="ti">run it like Postgres</text>
<text x="660" y="114" class="su">write-master + follower(s)</text>
<text x="660" y="130" class="su2">always up · derives deltas</text>
<text x="430" y="222" text-anchor="middle" class="slbl">normalized, cv-stamped rows · public WebSocket</text>
</svg>
<figcaption class="doc-figcap">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.</figcaption>
</figure>

The whole [issue-tracker example](/docs/example-issue-tracker) is exactly this,
end to end — and the [synced-app quickstart](/docs/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`](/docs/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`](/docs/client) — 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](/docs/api-server). 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`](/docs/deploy) write-master plus one
  or more [`rindled`](/docs/daemon) 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](/docs/deploy) for the full menu — and which shapes you can run
yourself versus have us run for you on [Rindle Cloud](https://cloud.rindle.sh) (the
[Cloud quickstart](/docs/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`](/docs/deploy) write-master's ingress (`/execute-sql-txn`,
`/migrate`, `/mutate-session/*`, `/reject-mutation`) — its
[`SplitDaemonClient`](/docs/api-server#talking-to-the-daemon) 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 **schema** — [generated from your SQL](/docs/schema) — plus the named
[relationships](/docs/fragments#name-your-joins-once) and the
**[isomorphic mutators](/docs/client#isomorphic-mutators)**, which you hand-write
beside it and both tiers drive.

```ts
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:

```ts
// 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

<figure class="doc-fig">
<svg viewBox="0 0 820 300" role="img" aria-labelledby="subT subD" class="doc-arch">
<title id="subT">Subscribing to a query</title>
<desc id="subD">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.</desc>
<defs>
<marker id="subA" markerWidth="8" markerHeight="8" refX="6" refY="3.5" orient="auto" markerUnits="userSpaceOnUse"><path d="M0,0 L7,3.5 L0,7 Z" fill="var(--ink-f)"/></marker>
<marker id="subO" markerWidth="8" markerHeight="8" refX="6" refY="3.5" orient="auto" markerUnits="userSpaceOnUse"><path d="M0,0 L7,3.5 L0,7 Z" fill="var(--orange)"/></marker>
</defs>
<line class="life" x1="110" y1="52" x2="110" y2="288"/>
<line class="life" x1="410" y1="52" x2="410" y2="288"/>
<line class="life" x1="710" y1="52" x2="710" y2="288"/>
<rect class="card" x="24" y="6" width="172" height="46" rx="12"/>
<circle cx="110" cy="19" r="3" fill="var(--cobalt)"/>
<text x="110" y="31" text-anchor="middle" class="ey">BROWSER</text>
<text x="110" y="43" text-anchor="middle" class="su">optimistic · local IVM</text>
<rect class="card" x="324" y="6" width="172" height="46" rx="12"/>
<circle cx="410" cy="19" r="3" fill="var(--teal)"/>
<text x="410" y="31" text-anchor="middle" class="ey">API SERVER</text>
<text x="410" y="43" text-anchor="middle" class="su">your authority · stateless</text>
<rect class="card" x="624" y="6" width="172" height="46" rx="12"/>
<circle cx="710" cy="19" r="3" fill="var(--orange)"/>
<text x="710" y="31" text-anchor="middle" class="ey">RINDLED</text>
<text x="710" y="43" text-anchor="middle" class="su">read-follower · always up</text>
<line x1="120" y1="92" x2="402" y2="92" stroke="var(--ink-f)" stroke-width="1.4" marker-end="url(#subA)"/>
<text x="261" y="84" text-anchor="middle" class="wlbl">POST { name, args }</text>
<text x="261" y="105" text-anchor="middle" class="wsub">materialize(issuesPage)</text>
<circle class="num" cx="110" cy="92" r="8.5"/>
<text class="numt" x="110" y="95" text-anchor="middle">1</text>
<line x1="420" y1="134" x2="702" y2="134" stroke="var(--ink-f)" stroke-width="1.4" marker-end="url(#subA)"/>
<text x="561" y="126" text-anchor="middle" class="wlbl">resolve name → AST</text>
<text x="561" y="147" text-anchor="middle" class="wsub">daemon.materialize(ast)</text>
<circle class="num" cx="410" cy="134" r="8.5"/>
<text class="numt" x="410" y="137" text-anchor="middle">2</text>
<line class="ret" x1="700" y1="178" x2="120" y2="178" stroke="var(--ink-f)" stroke-width="1.1" marker-end="url(#subA)"/>
<rect class="chip" x="326" y="159" width="168" height="18" rx="9"/>
<text x="410" y="171" text-anchor="middle" class="wlbl">lease token · relayed by API</text>
<circle class="num" cx="710" cy="178" r="8.5"/>
<text class="numt" x="710" y="181" text-anchor="middle">3</text>
<line x1="120" y1="224" x2="702" y2="224" stroke="var(--ink-f)" stroke-width="1.4" marker-end="url(#subA)"/>
<rect class="chip" x="302" y="205" width="216" height="18" rx="9"/>
<text x="410" y="217" text-anchor="middle" class="wlbl">open ws subscription · presents lease</text>
<circle class="num" cx="110" cy="224" r="8.5"/>
<text class="numt" x="110" y="227" text-anchor="middle">4</text>
<line class="flow" x1="700" y1="270" x2="120" y2="270" stroke="var(--orange)" stroke-width="1.8" marker-end="url(#subO)"/>
<rect class="chip" x="284" y="251" width="252" height="18" rx="9"/>
<text x="410" y="263" text-anchor="middle" class="strm">normalized snapshot → live cv-stamped deltas</text>
<circle class="num numO" cx="710" cy="270" r="8.5"/>
<text class="numt" x="710" y="273" text-anchor="middle">5</text>
</svg>
<figcaption class="doc-figcap">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.</figcaption>
</figure>

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

<figure class="doc-fig">
<svg viewBox="0 0 820 300" role="img" aria-labelledby="wrT wrD" class="doc-arch">
<title id="wrT">Making a write</title>
<desc id="wrD">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.</desc>
<defs>
<marker id="wrA" markerWidth="8" markerHeight="8" refX="6" refY="3.5" orient="auto" markerUnits="userSpaceOnUse"><path d="M0,0 L7,3.5 L0,7 Z" fill="var(--ink-f)"/></marker>
<marker id="wrO" markerWidth="8" markerHeight="8" refX="6" refY="3.5" orient="auto" markerUnits="userSpaceOnUse"><path d="M0,0 L7,3.5 L0,7 Z" fill="var(--orange)"/></marker>
</defs>
<line class="life" x1="110" y1="52" x2="110" y2="288"/>
<line class="life" x1="410" y1="52" x2="410" y2="288"/>
<line class="life" x1="710" y1="52" x2="710" y2="288"/>
<rect class="card" x="24" y="6" width="172" height="46" rx="12"/>
<circle cx="110" cy="19" r="3" fill="var(--cobalt)"/>
<text x="110" y="31" text-anchor="middle" class="ey">BROWSER</text>
<text x="110" y="43" text-anchor="middle" class="su">optimistic · local IVM</text>
<rect class="card" x="324" y="6" width="172" height="46" rx="12"/>
<circle cx="410" cy="19" r="3" fill="var(--teal)"/>
<text x="410" y="31" text-anchor="middle" class="ey">API SERVER</text>
<text x="410" y="43" text-anchor="middle" class="su">your authority · stateless</text>
<rect class="card" x="624" y="6" width="172" height="46" rx="12"/>
<circle cx="710" cy="19" r="3" fill="var(--orange)"/>
<text x="710" y="31" text-anchor="middle" class="ey">DATA TIER</text>
<text x="710" y="43" text-anchor="middle" class="su">write-master → follower</text>
<path d="M118,84 H150 V96 H120" fill="none" stroke="var(--ink-f)" stroke-width="1.3" marker-end="url(#wrA)"/>
<text x="162" y="86" class="wlbl">predicted mutator → LOCAL engine</text>
<text x="162" y="99" class="wsub">the view updates now · optimistic</text>
<circle class="num" cx="110" cy="90" r="8.5"/>
<text class="numt" x="110" y="93" text-anchor="middle">1</text>
<line x1="120" y1="132" x2="402" y2="132" stroke="var(--ink-f)" stroke-width="1.4" marker-end="url(#wrA)"/>
<text x="261" y="124" text-anchor="middle" class="wlbl">POST { mid, name, args }</text>
<circle class="num" cx="110" cy="132" r="8.5"/>
<text class="numt" x="110" y="135" text-anchor="middle">2</text>
<line x1="420" y1="176" x2="702" y2="176" stroke="var(--ink-f)" stroke-width="1.4" marker-end="url(#wrA)"/>
<text x="561" y="168" text-anchor="middle" class="wlbl">authoritative mutator → SQL</text>
<text x="561" y="189" text-anchor="middle" class="wsub">executeSqlTxn · idempotent on mid</text>
<circle class="num" cx="410" cy="176" r="8.5"/>
<text class="numt" x="410" y="179" text-anchor="middle">3</text>
<line class="flow" x1="700" y1="226" x2="120" y2="226" stroke="var(--orange)" stroke-width="1.8" marker-end="url(#wrO)"/>
<rect class="chip" x="288" y="207" width="244" height="18" rx="9"/>
<text x="410" y="219" text-anchor="middle" class="strm">derives every affected delta → streams back</text>
<circle class="num numO" cx="710" cy="226" r="8.5"/>
<text class="numt" x="710" y="229" text-anchor="middle">4</text>
<path d="M118,262 H150 V274 H120" fill="none" stroke="var(--ink-f)" stroke-width="1.3" marker-end="url(#wrA)"/>
<text x="162" y="264" class="wlbl">REBASE</text>
<text x="162" y="277" class="wsub">rewind to authoritative · replay pending</text>
<circle class="num" cx="110" cy="268" r="8.5"/>
<text class="numt" x="110" y="271" text-anchor="middle">5</text>
</svg>
<figcaption class="doc-figcap">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.</figcaption>
</figure>

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](/docs/client)** | schema, queries, predicted mutators, UI | `@rindle/optimistic` (`createRindleClient`), `@rindle/wasm`, `@rindle/react` |
| **[API server](/docs/api-server)** | named queries → ASTs, authoritative mutators, auth/policy | `@rindle/api-server`, `@rindle/daemon-client` |
| **[Data tier](/docs/daemon)** | 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 client](/docs/client) — `createRindleClient`, optimistic mutators,
  reads with `@rindle/react`, snap-back on rejection.
- [Scaffold with create-rindle](/docs/create-rindle) — a small TanStack Start app
  with all three tiers wired.
- [The API server](/docs/api-server) — `createRindleApiServer`, authoritative
  mutators, authorization, talking to the daemon's control plane.
- [Server rendering](/docs/ssr) — preload named queries for first paint, then
  hand off to the live browser client.
- [Run the daemon](/docs/daemon) — `rindled`, the two planes, the config, restart
  recovery, and the `Cluster` engine underneath.
- [Full app: the issue tracker](/docs/example-issue-tracker) — all three tiers,
  real code, one command to run.
- [The change model](/docs/change-model) — the normalized deltas the daemon streams.

---

[View this page on Rindle](https://rindle.sh/docs/architecture)
