Build a synced app

The three-tier architecture

How a synced Rindle app is wired: an optimistic browser client, a stateless API server that is your app's authority, and the always-up rindled daemon that holds the data and the live queries. The two planes, the two round-trips, 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:

Rindle three-tier architecture A browser client sends query names and mutation arguments to a stateless API server, which resolves them and drives the always-up rindled daemon over a private HTTP control plane; the daemon streams normalized, cv-stamped rows back to the browser over the public WebSocket. names + args queries · mutations control plane private · HTTP BROWSER your React app own IVM engine, local reads optimistic writes API SERVER your app's authority resolves names, runs mutators stateless · serverless-shaped RINDLED DAEMON run it like Postgres owns SQLite + live IVM always up · derives deltas normalized, cv-stamped rows · public WebSocket
Three tiers — client, stateless authority, always-up daemon. Names and arguments flow up; normalized row deltas stream back.

The whole issue-tracker example is exactly this, end to end. 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 daemon is rindled — stateful and always up, like Postgres. It owns the SQLite database and the live query pipelines, derives the incremental delta after every committed write, and streams normalized, cv-stamped updates to every subscriber.

The split is deliberate: the daemon is the one 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.

The daemon’s two planes

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 the control plane — /materialize (mint a lease), /execute-sql-txn (apply a write), /reject-mutation, /dematerialize

A browser never speaks the 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 file (shared/app-def.ts in the example): the schema, the named queries, and the client’s predicted mutators.

import { createSchema, defineQueries, json, newQueryBuilder, number, string, table } from "@rindle/client";

export const issue = table("issue")
  .columns({ id: string(), title: string(), status: string(), /* … */ createdAt: number() })
  .primaryKey("id");
export const schema = createSchema({ tables: [issue] });

const q = newQueryBuilder(schema);
export const queries = defineQueries({
  // a live *window* over a big table, not "all issues"
  issuesPage: (cursor: IssueCursor | null) =>
    cursor
      ? q.issue.orderBy("createdAt", "desc").limit(50).start(cursor, { exclusive: true })
      : q.issue.orderBy("createdAt", "desc").limit(50),
});

Mutators come in twins that share a name, never their effects. The client’s predicted twin runs optimistically against the local tables; the API server’s authoritative twin runs 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

Subscribing to a query A sequence across three lifelines — browser, API server, rindled daemon. The browser posts a query name and args to the API server; the API server resolves the name to an AST and calls the daemon to materialize it; the daemon returns a lease token relayed back to the browser; the browser opens a WebSocket subscription presenting that lease; the daemon 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 owns data · 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 daemon 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, rindled daemon. 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 daemon, idempotent on mid; the daemon applies it, 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 RINDLED owns data · always up 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, and the daemon's 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, 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
Daemon a JSON config + the tables to register rindled (the rindle-server crate) over the multi-threaded Cluster

Next steps

  • The browser clientcreateRindleClient, optimistic mutators, reads with @rindle/react, snap-back on rejection.
  • The API servercreateRindleApiServer, authoritative mutators, authorization, talking to the daemon’s control plane.
  • Run the daemonrindled, the two planes, the config, restart recovery, and the Cluster engine underneath.
  • Full app: the issue tracker — all three tiers, real code, one command to run.
  • The change model — the normalized deltas the daemon streams.