Build a synced app

The three-tier architecture

How a synced Rindle app is wired — an optimistic browser client, a stateless API authority, and a standalone or replicated always-up data tier.

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. The diagram draws the replicated profile; standalone collapses the logical data-tier box to one write-owning rindled:

Rindle three-tier architecture A browser client sends query names and mutation arguments to a stateless API server. The server uses one Rindle ingress backed by either one standalone rindled or an HCTree master and rindled follower. The data tier streams normalized, cv-stamped rows back to the browser over the public WebSocket returned by a query lease. names + args queries · mutations Rindle ingress one URL · bearer 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 standalone OR master + follower(s) always up · derives deltas normalized, cv-stamped rows · public WebSocket
Three tiers — client, stateless authority, and the Rindle data tier (one standalone daemon or a replicator + follower fleet). Names and arguments flow up. Normalized row deltas stream back.

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 rindled owning a wal2 file, serialized writes, and the live query pipelines. Replicated is one rindle-replicator HCTree write-master plus one or more read-only rindled followers. 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 schemagenerated 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

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, 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