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. 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
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 |
| Daemon | a JSON config + the tables to register | rindled (the rindle-server crate) over the multi-threaded Cluster |
Next steps
- The browser client —
createRindleClient, optimistic mutators, reads with@rindle/react, snap-back on rejection. - The API server —
createRindleApiServer, authoritative mutators, authorization, talking to the daemon’s control plane. - 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.