A synced Rindle app is three tiers, and they map cleanly onto a shape you already know — client, stateless app server, database:
The synced-app quickstart builds exactly this, end to end,
by hand — or 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 uses one bearer-authenticated Rindle ingress for both read/control and SQL traffic.
- 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. A self-hosted fleet can add N read followers; Rindle Cloud currently offers master-only SQL or a master plus one follower on its packed OVH fleet. In either case, the browser discovers a public socket from its lease and signed affinity tickets co-locate WebSocket and lease traffic without changing application configuration. See deploying & scaling for the exact menu.
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 unified edge routes SQL and mutation traffic to the
rindle-replicator master and read/materialization traffic to a follower.
createRindleApiServer({ rindle: { url, token } }) derives both trusted transports from
that one connection. A browser never speaks either control plane or receives the
token. It opens only the public WebSocket returned by an authorized lease. Every
privileged action — turning a query name into an AST or 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
- Synced-app quickstart — build this by hand, or scaffold it on TanStack Start.
- Recipes — folded mutations, fine-grained reactivity, compose with fragments, TanStack Start, server rendering.
- Deploying & scaling — from a colocated pair to read-scaled followers.
- Troubleshooting — the correctness rules, and how an app breaks when one is missed.