Build the three-tier app by hand: an optimistic browser client, a
stateless API server (your authority), and the Rindle data tier (a rindle-replicator
write-master + a rindled read-follower). The browser sends only names and arguments up and gets
normalized row deltas back. Each mutator is one body run on both tiers. Want it generated
instead? create-rindle scaffolds this exact shape on TanStack Start.
| File | Tier | What’s in it |
|---|---|---|
migrations/0001_init.sql |
schema (source of truth) | CREATE TABLE ×4 + indices |
shared/schema.gen.ts |
generated | rindle schema gen output — don’t hand-edit |
shared/app-def.ts |
shared contract | relationships + the isomorphic mutators |
src/IssueList.queries.ts |
shared contract | the named query, co-located with its component |
src/rindle-client.ts + UI |
browser | createRindleClient, reads via @rindle/react, optimistic writes |
server/api.ts |
API server | named queries → ASTs + the same mutators with server authority |
0 · Install
Needs Node ≥ 22.18 (the API server runs .ts directly) and a Vite + React project.
pnpm create vite my-app --template react-ts && cd my-app && pnpm install
pnpm add @rindle/optimistic @rindle/client @rindle/wasm @rindle/react # browser
pnpm add @rindle/api-server # API server
pnpm add zod # mutator arg schemas
pnpm add -D @rindle/cli concurrently # toolchain + process runner
npx rindle init # writes rindle.ncl (the colocated pair, loopback) + migrations/
Nothing runs until step 6, where one pnpm dev starts the pair, applies migrations, generates the
schema, and boots your app.
1 · The schema, in SQL
SQL is the source of truth. Author the normalized schema as one migration. Every table needs a
single PRIMARY KEY, and columns are TEXT / INTEGER / REAL / BOOLEAN / JSON.
-- migrations/0001_init.sql
CREATE TABLE IF NOT EXISTS user (id TEXT, name TEXT, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS issue (
id TEXT, title TEXT, status TEXT, priority TEXT, ownerId TEXT,
createdAt REAL, updatedAt REAL, PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS issue_created ON issue (createdAt DESC, id);
CREATE INDEX IF NOT EXISTS issue_owner ON issue (ownerId);
CREATE TABLE IF NOT EXISTS tag (id TEXT, issueId TEXT, name TEXT, PRIMARY KEY (id));
CREATE INDEX IF NOT EXISTS tag_issue ON tag (issueId, name);
CREATE TABLE IF NOT EXISTS comment (id TEXT, issueId TEXT, authorId TEXT, body TEXT, createdAt REAL, PRIMARY KEY (id));
CREATE INDEX IF NOT EXISTS comment_issue ON comment (issueId, createdAt);
rindle schema gen introspects the live daemon and emits shared/schema.gen.ts — one const per
table plus the createSchema aggregate. Don’t hand-edit it:
// shared/schema.gen.ts — Generated by `rindle schema gen`. Do not edit by hand.
import { createSchema, number, string, table } from "@rindle/client";
export const comment = table("comment")
.columns({ id: string(), issueId: string(), authorId: string(), body: string(), createdAt: number() })
.primaryKey("id");
export const issue = table("issue")
.columns({
id: string(), title: string(), status: string(), priority: string(),
ownerId: string(), createdAt: number(), updatedAt: number(),
})
.primaryKey("id");
export const tag = table("tag")
.columns({ id: string(), issueId: string(), name: string() })
.primaryKey("id");
export const user = table("user")
.columns({ id: string(), name: string() })
.primaryKey("id");
export const schema = createSchema({ tables: [comment, issue, tag, user] });
Use Drizzle or any tool that emits *.sql, then npx rindle migrate apply --dir ./drizzle. See
schema & migrations for types, data migrations, and browser-only tables.
2 · The shared contract
Both tiers import the generated schema, the relationships (joins declared once), a query builder,
and the mutators. A mutator is one generator that yields logical ops (not SQL). The browser
drives it synchronously as the optimistic prediction, and the API server drives the same body into
SQL. The prediction is re-invoked on every rebase, so no clocks or randomness. Pass ids and
timestamps as args. The acting user is never an arg — each tier injects ctx.user.
// shared/app-def.ts — imported by BOTH the browser and the API server
import { defineMutators, defineRelationships, newQueryBuilder, rel } from "@rindle/client";
import type { MutationGen, MutatorCtx, Row } from "@rindle/client";
import type { ClientRegistry } from "@rindle/optimistic";
import { z } from "zod";
import { schema, comment, issue, tag, user } from "./schema.gen.ts";
export { schema, comment, issue, tag, user };
export const q = newQueryBuilder(schema);
export type Issue = Row<typeof issue>;
export const rels = defineRelationships({
issueOwner: rel(issue, user, { ownerId: "id" }),
issueComments: rel(issue, comment, { id: "issueId" }),
issueTags: rel(issue, tag, { id: "issueId" }),
});
export const createIssueArgs = z.object({
id: z.string(), title: z.string(), status: z.string(), priority: z.string(), createdAt: z.number(),
});
export type CreateIssueArgs = z.infer<typeof createIssueArgs>;
export const addCommentArgs = z.object({
id: z.string(), issueId: z.string(), body: z.string(), createdAt: z.number(),
});
export type AddCommentArgs = z.infer<typeof addCommentArgs>;
const { shared } = defineMutators(schema);
export const mutators = {
createIssue: shared(createIssueArgs, function* (tx, a: CreateIssueArgs, ctx: MutatorCtx): MutationGen {
yield tx.insertIgnore("user", { id: ctx.user, name: ctx.user });
yield tx.insert("issue", {
id: a.id, title: a.title, status: a.status, priority: a.priority,
ownerId: ctx.user, createdAt: a.createdAt, updatedAt: a.createdAt,
});
}),
// writes the comment table → the issue's live commentCount ticks up on its own
addComment: shared(addCommentArgs, function* (tx, a: AddCommentArgs, ctx: MutatorCtx): MutationGen {
yield tx.insertIgnore("user", { id: ctx.user, name: ctx.user });
yield tx.insert("comment", { id: a.id, issueId: a.issueId, authorId: ctx.user, body: a.body, createdAt: a.createdAt });
yield tx.update("issue", { id: a.issueId, updatedAt: a.createdAt });
}),
setStatus: shared(
z.object({ id: z.string(), status: z.string(), updatedAt: z.number() }),
function* (tx, a): MutationGen {
yield tx.update("issue", { id: a.id, status: a.status, updatedAt: a.updatedAt });
},
),
// an isomorphic READ: the ownership guard runs identically on both tiers
deleteIssue: shared(z.object({ id: z.string() }), function* (tx, a, ctx): MutationGen {
const cur = (yield tx.row("issue", { id: a.id })) as Issue | undefined;
if (!cur || cur.ownerId !== ctx.user) return;
yield tx.delete("issue", { id: a.id });
}),
} satisfies ClientRegistry;
The op vocabulary is tx.insert / tx.update (pk + changed columns) / tx.upsert /
tx.insertIgnore / tx.delete, plus reads yield tx.row(table, pk) and yield tx.query(builder).
Both see this transaction’s own earlier writes. Full detail: isomorphic mutators.
3 · The named query
Remote subscriptions must be named. defineQuery is callable on the client (it stamps the wire
identity, so a subscription syncs), and you register it on the server. Its validate step runs on both
tiers. This one windows the issue table newest-first and carries a live commentCount — a
correlated count maintained incrementally, no re-scan:
// src/IssueList.queries.ts — co-located with the component below
import { defineQuery } from "@rindle/client";
import { q, rels } from "../shared/app-def.ts";
type IssuesPageArgs = { limit: number };
export const issuesPageQuery = defineQuery(
"issuesPage",
(raw): IssuesPageArgs => {
const limit = (raw as IssuesPageArgs).limit;
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw new Error("bad limit");
return { limit };
},
({ limit }: IssuesPageArgs) =>
q.issue.orderBy("createdAt", "desc").limit(limit).countAs("commentCount", rels.issueComments),
);
Join the owner row or tags onto each issue with .sub(...), composed as
fragments. Subscribe to windows (order + limit), not whole tables.
4 · The browser client
createRindleClient boots the wasm engine, leases queries through your API server (its first lease
returns the public WebSocket endpoint + placement ticket), and runs the mutation queue:
// src/rindle-client.ts
import { createRindleClient } from "@rindle/optimistic";
import { mutators, schema } from "../shared/app-def.ts";
export const app = await createRindleClient({
schema,
mutators,
user: () => currentUser(), // the acting principal a mutator sees as ctx.user
api: {
url: "", // same-origin: posts to /api/rindle/* (proxied in step 6)
headers: () => ({ "x-user": currentUser() }), // a real app sends a session/JWT
},
onRejected: (envelope, reason) => showToast(`${envelope.name} rejected: ${reason}`),
});
Read live views with useQuery. Write through app.mutate.<name>(args), which drives the mutator
against local tables synchronously, so the view updates before the call returns:
import { createRoot } from "react-dom/client";
import { Rindle, useQuery } from "@rindle/react";
import { issuesPageQuery } from "./IssueList.queries.ts";
import { app } from "./rindle-client.ts";
createRoot(root).render(
<Rindle store={app.store}>
<IssueList />
</Rindle>,
);
function IssueList() {
const rows = useQuery(issuesPageQuery({ limit: 50 })); // live, reference-stable
return (
<ul>
{rows.map((r) => (
<li key={r.id} onClick={() => app.mutate.setStatus({ id: r.id, status: "done", updatedAt: Date.now() })}>
{r.title} — {r.status} · {r.commentCount} comments
</li>
))}
</ul>
);
}
// ids and timestamps are generated at the callsite; the author is ctx.user, not an arg:
const id = crypto.randomUUID();
app.mutate.createIssue({ id, title: "ship it", status: "todo", priority: "medium", createdAt: Date.now() });
app.mutate.addComment({ id: crypto.randomUUID(), issueId: id, body: "on it", createdAt: Date.now() });
The mutation’s name and args (never its effects) go to the API server. Confirmed deltas stream back and the client rebases. A rejected write’s optimistic rows vanish on their own. See the browser client for local reads, pending signals, and folded writes.
5 · The API server
Your authority — stateless and serverless-shaped. It authenticates the caller and resolves named
queries to ASTs. It drives the same mutators the browser predicted (injecting the authenticated
ctx.user, rendering each yielded op to SQL). It talks to the data tier through one Rindle ingress:
// server/api.ts
import { createServer } from "node:http";
import { createRindleApiServer, registerQueries, sharedApiMutators } from "@rindle/api-server";
import type { MutationContext } from "@rindle/api-server";
import type { MutatorCtx } from "@rindle/client";
import { issuesPageQuery } from "../src/IssueList.queries.ts";
import { mutators, schema } from "../shared/app-def.ts";
type User = string | undefined;
const queries = registerQueries<User>([issuesPageQuery]);
const sharedCtx = (ctx: MutationContext<User>): MutatorCtx => {
if (!ctx.user) throw new Error("unauthenticated");
return { user: ctx.user };
};
const rindleUrl = process.env.RINDLE_URL;
const databaseToken = process.env.RINDLE_DATABASE_TOKEN;
if (!rindleUrl || !databaseToken) throw new Error("start this app with `rindle dev -- …`");
const api = createRindleApiServer<User>({
rindle: { url: rindleUrl, token: databaseToken },
schema,
queries,
mutators: sharedApiMutators(mutators, sharedCtx),
authorizeQuery: ({ user }) => typeof user === "string" && user.length > 0,
authorizeMutation: ({ user }) => typeof user === "string" && user.length > 0,
});
// You own the HTTP — @rindle/api-server is transport-agnostic. Mount the JSON handlers on api.routes.
createServer((req, res) => {
void (async () => {
const body = JSON.parse(await readBody(req));
const ctx = { user: req.headers["x-user"] as string | undefined, request: req }; // verify a JWT in prod
const out =
req.url === api.routes.query ? await api.handleQueryJson(body, ctx) :
req.url === api.routes.mutate ? await api.handleMutateJson(body, ctx) :
{ error: "not found" };
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(out));
})();
}).listen(7700);
function readBody(req: import("node:http").IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => resolve(body));
req.on("error", reject);
});
}
If you need authority the client must not predict (a policy guard, relational SQL a keyed op can’t express), override only that name next to the spread. See the API server for overrides, context-scoped queries, and the rejection shapes.
6 · Run it
rindle dev owns the topology. It:
- evaluates
rindle.ncl - waits for the fleet
- applies migrations
- generates the schema
- launches your app command with
RINDLE_URL+RINDLE_DATABASE_TOKEN
(An integrated framework uses only -- vite dev. This manual example runs two processes.)
// package.json
{
"scripts": {
"dev": "rindle dev --migrate --gen shared/schema.gen.ts -- concurrently -k -n api,web \"node --watch server/api.ts\" \"vite\""
}
}
// vite.config.ts — point /api at the API server
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: { proxy: { "/api": "http://127.0.0.1:7700" } },
});
pnpm dev
Open two browser windows and watch writes sync live. Reads resolve locally and instantly. Writes apply optimistically and rebase as the API server confirms.
Go to production
Your app code doesn’t change — only where the data tier lives does. On Rindle
Cloud, the Connect panel gives the API server one RINDLE_URL + one
server-only RINDLE_DATABASE_TOKEN (npx rindle deploy --migrate). Self-hosting?
@rindle/cli ships the prebuilt master/follower/edge binaries — see
deploying & scaling.
Next steps
- The three-tier architecture — the topology, and the two round-trips drawn out.
- Recipes: folded mutations · fragments · TanStack Start · server rendering.
- Scaffold with create-rindle — the same shape, generated.
- Troubleshooting — the rules that keep it correct, and how it breaks when one is.