A synced Rindle app is three tiers, and they map cleanly onto a shape you already
know — client, stateless app server, database. The diagram draws the replicated
profile; standalone collapses the logical data-tier box to one write-owning rindled:
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 can 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 stateful and always up, like Postgres. Standalone is one
rindledowning a wal2 file, serialized writes, and the live query pipelines. Replicated is onerindle-replicatorHCTree write-master plus one or more read-onlyrindledfollowers. Both produce the same normalized, cv-stamped updates; the fleet adds concurrent ingress, journal recovery, and read fan-out.
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. It can be the standalone daemon, or a write master and 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. The browser discovers a public socket from its lease; fleet affinity tickets co-locate WebSocket and lease traffic. See deploying & scaling for the exact menu.
The daemon’s two planes
A rindled 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/materialization in both postures; standalone also owns mutations and migrations, with /v1/sql/* independently authenticated |
Writes never touch a follower: the fleet edge routes them to the master. Standalone
serves write and read/control traffic at the same origin.
createRindleApiServer({ rindle: { url, token } }) derives both trusted transports from
one local or fleet-edge connection. A directly networked standalone deployment with distinct
control and SQL bearers points explicit daemon and database transports at that same URL.
A browser never speaks either control plane or receives either 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). It holds the schema — generated from your SQL — plus
the named relationships and the
isomorphic mutators. You hand-write the
mutators beside the schema, and both tiers drive them.
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. The sequence diagrams show the replicated profile; standalone collapses the data-tier lifeline to the one daemon that commits the write and derives the delta.
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, and the client rebases without the
refused write. 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 returns. 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 selecting standalone or replicated |
one write-owning rindled, or rindle-replicator + read-only rindled follower(s) |
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 — standalone durability or a read-scaled fleet.
- Troubleshooting — the correctness rules, and how an app breaks when one is missed.