A synced Rindle app is three tiers, and they map cleanly onto a shape you already know — client, stateless app server, database:
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-replicatorwrite-master plus one or morerindledread-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 schema — generated 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
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
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 client —
createRindleClient, 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 server —
createRindleApiServer, 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 daemon —
rindled, the two planes, the config, restart recovery, and theClusterengine underneath. - Full app: the issue tracker — all three tiers, real code, one command to run.
- The change model — the normalized deltas the daemon streams.