The whole three-tier architecture as a copy-paste recipe: a
browser client with its own optimistic engine, a stateless API server that is
your app’s authority, and the Rindle data tier — a rindle-replicator write-master
that owns the authoritative data plus a rindled read-follower (the colocated pair)
that streams live deltas — run it yourself or let Rindle Cloud run it for you.
It uses the same normalized schema as the
full issue tracker (user · issue · tag ·
comment), so this is a true subset of that app — follow the per-tier pages for depth.
If you want a ready-to-run project instead of building it by hand,
create-rindle scaffolds the same shape as a small TanStack
Start app. This page is the manual version.
The shape in one line. The browser sends only names and arguments up — query names to subscribe, mutation names to write — and gets normalized row deltas back. Each mutator is one body, run on both tiers: the browser drives it as the instant local prediction, the server drives the same body as the authority, and the engine rebases the client onto it. You write no cache, no refetch, no rollback code — and no second copy of your writes in SQL.
Your schema is SQL. The source of truth is the SQLite schema the write-master holds. You evolve it with SQL migrations (by hand, or with Drizzle), and the TypeScript schema your app imports is generated from the live follower with
rindle schema gen— never hand-written. That one rule keeps all three tiers in lockstep.
What you’ll build
A live issue list with a per-issue comment count. The schema is SQL; everything else is a handful of files:
| File | Tier | What’s in it |
|---|---|---|
migrations/0001_init.sql |
schema (source of truth) | CREATE TABLE ×4 + indices — the real schema |
shared/schema.gen.ts |
generated | rindle schema gen output — don’t hand-edit |
shared/app-def.ts |
shared contract | relationships, the query builder, the isomorphic mutators (one body, both tiers) |
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 driven with server authority |
0 · Install
You need Node ≥ 22.18 (the API server below runs .ts files with node
directly — no build step) and a Vite + React project to build in. Scaffold one if
you don’t have one, then add the Rindle packages:
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 @rindle/daemon-client # API server
pnpm add zod # mutator arg schemas
pnpm add -D @rindle/cli concurrently # toolchain + process runner (step 6)
@rindle/cli ships the rindle CLI and the daemon binaries as prebuilt,
per-platform artifacts — nothing compiles. @rindle/optimistic is the browser store,
@rindle/client the query builders and mutator vocabulary (and your generated schema),
@rindle/wasm the in-browser IVM engine, @rindle/react the bindings (skip it if you’re
not on React — the store has a framework-agnostic core), @rindle/api-server the
authority’s request handlers, and @rindle/daemon-client the typed control-plane client.
Then scaffold the project’s Rindle files:
npx rindle init # writes rindle.ncl (the colocated pair, loopback) + migrations/
That’s all the setup there is — you won’t run anything until step 6, where one
pnpm dev starts the pair (the rindle-replicator write-master + its rindled
follower), applies your migrations, generates the TypeScript schema, and boots your app.
(You don’t list tables anywhere; the follower introspects whatever schema your migrations
create on the master. See run the daemon for the full config, restart
recovery, and auth.)
1 · The schema, in SQL
Author the normalized schema as one additive SQL migration: four tables joined back
together at query time. Every table needs a single PRIMARY KEY; columns are TEXT /
INTEGER / REAL (no blob / bigint yet); use IF NOT EXISTS so a re-run is safe.
The indices keep the joins fast in both directions.
-- migrations/0001_init.sql (npx rindle migrate create init scaffolds the file)
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); -- the paginated window
CREATE INDEX IF NOT EXISTS issue_owner ON issue (ownerId); -- the owner join, reverse
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 INDEX IF NOT EXISTS tag_name ON tag (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);
CREATE INDEX IF NOT EXISTS comment_author ON comment (authorId);
There’s nothing to run yet. The pnpm dev you’ll write in step 6 applies pending
migrations and generates the TypeScript schema from the live daemon — on boot and
again on every migrations/ change. (Against an already-running daemon — production,
say — the standalone commands are npx rindle migrate apply and
npx rindle schema gen --out shared/schema.gen.ts.)
shared/schema.gen.ts is a generated artifact — one const per table (sorted by name)
plus the createSchema aggregate. Don’t hand-edit it; this is what the daemon emits for
the migration above:
// 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] });
The declared SQL type drives the generated type.
rindle schema genreads each column’s declared type:TEXT→string(),INTEGER/REAL→number(),BOOLEAN→boolean(),JSON→json<T>(). Refine ajson<T>()element type by hand after generating. Two limits: integers beyond ±(2⁵³−1) lose precision (the engine’s number domain isf64), andblobisn’t supported yet.Using Drizzle (or any tool)? Author your schema however you like, run its generator (
drizzle-kit generate, sqlite dialect — offline, no DB) to emit*.sql, and point the applier at it:npx rindle migrate apply --dir ./drizzle. The daemon never opens your database file — all DDL flows through it. SQL is the only contract. Full details and thedrizzle-kitaffinity caveat: schema & migrations.
Need browser-only tables? Keep
shared/schema.gen.tsgenerated. Define drafts, selections, and other private UI tables in a hand-written module and combine them withextendSchema(generatedSchema, { tables: [localTable] }); pass that extended schema to the browser client, while the API server keeps using the generated synced schema. See schema & migrations.
2 · The shared contract
Both tiers import the generated synced schema, the relationships (the joins,
declared once), a query builder, and the mutators. A mutator is written once, as
a generator that yields logical write ops (yield tx.insert(...)) instead of
touching a database, and is paired with the zod schema for its args by
shared(args, gen):
- The browser drives the body synchronously against its local tables — the optimistic prediction. It is re-invoked on every rebase, so it must not read clocks or randomness: generate ids and timestamps at the callsite and pass them in as args.
- The API server drives the same body inside an authoritative transaction, rendering each yielded op to SQL (step 5). No hand-written SQL twins.
The acting user is never an argument — each tier injects it as ctx.user (the client
its local user, step 4; the server its authenticated principal, step 5), so it can’t
be spoofed over the wire.
// 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"; // ← the generated schema (step 1)
export { schema, comment, issue, tag, user };
export const q = newQueryBuilder(schema); // one builder, shared by the *.queries.ts files
export type Issue = Row<typeof issue>;
// Each join declared ONCE as a typed value: the correlation lives here, not at every callsite.
export const rels = defineRelationships({
issueOwner: rel(issue, user, { ownerId: "id" }), // an issue → its owner
issueComments: rel(issue, comment, { id: "issueId" }), // an issue → its comments
issueTags: rel(issue, tag, { id: "issueId" }), // an issue → its tags
});
// One zod schema per mutator: the server parses the UNTRUSTED wire args through it; both
// tiers derive the arg TYPE from it. Note there's no `owner`/`author` arg — that's ctx.user.
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>;
// `shared` bound to the schema: every tx.insert/update/delete below checks its table,
// column names, value types, and pk columns at compile time.
const { shared } = defineMutators(schema);
// The ISOMORPHIC mutators. Only (name, args) ever crosses a wire — never these effects.
export const mutators = {
createIssue: shared(createIssueArgs, function* (tx, a: CreateIssueArgs, ctx: MutatorCtx): MutationGen {
// insert the acting user's row if absent — renders ON CONFLICT DO NOTHING on the server
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 }); // pk + only the columns that change
}),
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: `yield tx.row(...)` evaluates to the row on both tiers, so the
// ownership rule lives in the one body — a non-owner's delete is a no-op locally AND
// in the server's authoritative run (where ctx.user is the verified principal).
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) for a point read
and yield tx.query(builder) for a full query, both seeing the current state plus
this transaction’s own earlier writes, on every tier. Multi-op helpers are generators
spread with yield*; a single-op helper returns one op to yield.
3 · The named query
Remote subscriptions must be named: define each with defineQuery, co-located with
the component that reads it. The same value is callable on the client (it stamps its
result with the wire identity, so a subscription syncs) and registerable on the
server (step 5). Its validate step runs on both tiers, so client and server build a
byte-identical AST from the same untrusted args.
This one windows the (big) issue table newest-first and carries a live commentCount
— a correlated count over the comment table, maintained incrementally as comments come
and go (no re-scan). That’s the normalized payoff:
// 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), // rows: Issue & { commentCount: number }
);
Joining the owner row or the tag list onto each issue is the same
.sub(alias, rel, fragment)move, composed as fragments so a component tree declares its own data — see compose the UI with fragments. The issue tracker does exactly that.
4 · The browser client
createRindleClient boots the wasm engine, opens the ws subscription to the daemon,
resolves query leases through your API server, and runs the mutation queue — one call
wires the whole tier.
// 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
},
daemon: { wsUrl: import.meta.env.VITE_FLEET_WS, affinity: true }, // stable fleet edge
onRejected: (envelope, reason) => showToast(`${envelope.name} rejected: ${reason}`),
});
// (currentUser / showToast are your app's helpers — any session id and toast UI work.)
Read live views with useQuery (re-renders only when the result changes), write through
app.mutate.<name>(args) — which drives the mutator’s body against the 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>
);
}
// creating an issue is one optimistic call — the list shows it instantly
// (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() });
// add a comment — the issue's commentCount ticks up the moment the call returns:
app.mutate.addComment({ id: crypto.randomUUID(), issueId: id, body: "on it", createdAt: Date.now() });
The mutation’s name and arguments (never its effects) are pushed to the API server;
the confirmed deltas stream back and the client rebases. If the authoritative run
rejects the write, the optimistic rows vanish on their own and onRejected fires — no
rollback code. See the browser client for folded high-frequency writes,
local query resolution, and the loading/pending signals.
5 · The API server
Your app’s authority — stateless and serverless-shaped. It authenticates the caller, resolves named queries to ASTs (re-running each validator on the untrusted args), drives the same mutators the browser predicted, and talks to the data tier: reads and materializations go to the follower, writes to the write-master. It holds no data and no live state — and, because the mutators are shared, no second copy of your writes:
// server/api.ts
import { createServer } from "node:http";
import { createRindleApiServer, registerQueries, sharedApiMutators, SplitDaemonClient } from "@rindle/api-server";
import type { MutationContext } from "@rindle/api-server";
import type { MutatorCtx } from "@rindle/client";
import { HttpRindleDaemonClient } from "@rindle/daemon-client";
import { issuesPageQuery } from "../src/IssueList.queries.ts";
import { mutators, schema } from "../shared/app-def.ts";
type User = string | undefined;
// (a) named queries → ASTs: just the list of co-located defineQuery values.
const queries = registerQueries<User>([issuesPageQuery]);
// (b) the SAME mutators the browser predicts, auto-driven: parse the untrusted wire args
// through each mutator's .args schema, inject the AUTHENTICATED principal as ctx.user,
// drive the body, render every yielded op to SQL. Throw to HARD-reject; a run that
// legitimately changes nothing (deleteIssue's ownership guard) is an accepted-but-no-op
// — either way the client's optimistic write snaps back on its own.
const sharedCtx = (ctx: MutationContext<User>): MutatorCtx => {
if (!ctx.user) throw new Error("unauthenticated");
return { user: ctx.user };
};
// (c) tie them together. One topology (design 214): READS go to the follower, WRITES go to
// the replicator write-master — SplitDaemonClient routes each leg. (Local defaults below;
// in dev the write leg is set by RINDLE_REPLICATOR_URL — see step 6.)
const reads = new HttpRindleDaemonClient({
baseUrl: process.env.RINDLE_DAEMON_URL ?? "http://127.0.0.1:7600", // the follower — reads
headers: { authorization: `Bearer ${process.env.RINDLE_DAEMON_TOKEN ?? ""}` }, // unset in local dev
});
const api = createRindleApiServer<User>({
daemon: new SplitDaemonClient(
new HttpRindleDaemonClient({
baseUrl: process.env.RINDLE_REPLICATOR_URL ?? "http://127.0.0.1:7611", // the write-master — writes
headers: { authorization: `Bearer ${process.env.RINDLE_REPLICATOR_TOKEN ?? ""}` },
}),
reads,
),
schema, // drives the SQL renderer for the yielded ops
queries,
mutators: sharedApiMutators(mutators, sharedCtx),
authorizeQuery: ({ user }) => typeof user === "string" && user.length > 0,
authorizeMutation: ({ user }) => typeof user === "string" && user.length > 0,
});
// (d) you own the HTTP — @rindle/api-server is transport-agnostic (node:http, a
// Worker, a Lambda, Hono…). 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 here 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);
});
}
Need authority the client must not predict — a policy guard the client deliberately
skips, or relational SQL a keyed op can’t express (an owner-gated cascade, a dedup by a
non-pk column)? Override just that name next to the spread:
{ ...sharedApiMutators(mutators, sharedCtx), createIssue: withSpamGuard(mutators.createIssue) }.
For those overrides, context-scoped queries (“my issues”), the optional one-shot read
route, and the two rejection shapes in full, see the API server.
6 · Run it
One command. concurrently runs your three processes: the pair (via rindle up,
which renders your rindle.ncl, supervises the write-master + follower, and folds the
whole schema workflow in — apply pending migrations to the master and regenerate
shared/schema.gen.ts from the follower on boot and on every migrations/ change),
your API server, and the web dev server:
// package.json — `rindle up` renders rindle.ncl and supervises the pair (write-master +
// follower), and owns migrations + schema-gen; the API server and web tier are yours, so a
// plain process runner glues the three together. `rindle exec` derives the app-facing
// read/write/ws bindings from rindle.ncl. (`node server/api.ts` runs TypeScript directly — Node ≥ 22.18.)
{
"scripts": {
"dev": "concurrently -k -n rindle,api,web \"pnpm dev:rindle\" \"pnpm dev:api\" \"pnpm dev:web\"",
"dev:rindle": "rindle up --migrate --gen shared/schema.gen.ts --watch",
"dev:api": "rindle exec -- node --watch server/api.ts",
"dev:web": "rindle exec -- vite"
}
}
pnpm dev
The browser and API server use the stable local fleet edge (7650) for subscriptions and reads;
the API server sends writes to the write-master (7611). rindle exec supplies these derived
bindings. Bearer tokens are omitted in local dev and remain separate server-only environment
configuration in production. A browser never speaks a control plane.
On the very first boot the api and web processes start before shared/schema.gen.ts
exists — node --watch fails, waits, and restarts the moment the daemon writes it
(Vite recovers the same way). Every boot after that is clean, and adding a migration
while pnpm dev runs applies it — and regenerates the schema — on save, so your app’s
types track the schema with no extra step.
Point the frontend dev server’s /api at the API server’s port (7700):
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: { proxy: { "/api": "http://127.0.0.1:7700" } },
});
Open two browser windows and watch writes sync live across them: reads resolve locally and instantly; writes apply optimistically and rebase as the API server confirms them.
Go to production
Your app code doesn’t change — only where the data tier lives does. The schema workflow is the same everywhere: apply migrations to the write-master and regenerate the schema from a follower.
-
Rindle Cloud (managed). Provision a managed app in a few minutes (Cloud quickstart). The Connect panel hands you the values your tiers need — Live queries (
wss://…) for the browser’sdaemon.wsUrl, the Control plane (https://…:8443) for the API server’s read leg, the write-master endpoint for its write leg, and a daemon token (API server only — it never reaches the browser). Migrate the master and regenerate from the follower:npx rindle migrate apply --remote # → the write-master npx rindle schema gen --url https://YOUR-APP:8443 --token "$RINDLE_DAEMON_TOKEN" --out shared/schema.gen.tsThen swap the endpoints into steps 4 and 5 (
RINDLE_REPLICATOR_URLfor the write leg,RINDLE_DAEMON_URLfor the read leg). See Connect your app for the exact diff. -
Self-host.
@rindle/cliships the prebuilt binaries; run the pair on a box you operate — arindle.nclwithfollowers = 1is the colocated pair (set auth tokens, expose the follower’s two ports and the master’s write port), and apply migrations withrindle migrate apply --url <write-master> --token …. See run the daemon and deploying & scaling for the read-scaled shapes.
Rules that keep it correct
The contract is view-after-write == fresh-query across all three tiers. A handful of constraints uphold it — break one and the app goes subtly wrong:
- SQL is the source of truth; the TS schema is generated. Evolve the schema with
additive migrations (
CREATE TABLE,ADD COLUMN,CREATE INDEX— every table a single PK), apply them to the write-master, and re-runrindle schema genafter each one (in local dev, therindle up --migrate --gen … --watchinsidepnpm devdoes the apply-and-regenerate on everymigrations/change). Don’t hand-edit the generated file (except to refine ajson<T>()element type). - A mutator is one isomorphic body — deterministic and replayable. No
Date.now(), noMath.random(), no I/O — the client re-invokes it on every rebase. Generate ids and timestamps at the callsite and pass them in as args; the acting user isctx.user(injected per tier), never an argument. Add an explicit server entry only for authority the client must not predict. - Remote subscriptions must be named. Define each with
defineQueryand call the value (issuesPageQuery({ limit })). An ad-hocapp.store.query.issue.where…builder resolves locally only (off already-synced rows) and never opens a server subscription — handy for instant drill-downs, but it won’t pull new data. (name, args)is all that crosses the wire. Client-built ASTs and client-computed effects never become authority. The server parses every mutation’s args through the mutator’s co-located.argsschema, and each query’svalidatestep re-runs on the untrusted args.- The daemon token is server-only. It gates the private control plane; the browser only ever holds the lease-gated WebSocket. Keep it in the API server’s secret store.
- Subscribe to windows, not whole tables. Order +
limit, and ratchet the limit up for “load more”. IVM keeps the window — and itscountAs— exact as rows enter and leave. - Folded (high-frequency) writes must be absorbing. If you reach for
app.mutate.<name>.folded(...)for a slider/drag, replaying only the last args must equal replaying all of them — anincrement()-style mutator must not be folded, and a mutator that reads (yield tx.row/tx.query) can’t be.
When something goes subtly wrong anyway, work down Troubleshooting — every failure mode there is one of these rules, broken.
Next steps
- The three-tier architecture — the topology this realizes, and the two round-trips (subscribe, write) drawn out.
- Scaffold with create-rindle — generate a TanStack Start app with the same three-tier shape.
- The browser client · The API server — the runtime tiers you write and wire.
@rindle/cli— local daemon supervision, migrations, and schema generation.- Server rendering — preload named queries for first paint and hand off to the live wasm client.
- Compose the UI with fragments — join the owner and tags onto each issue as composable, waterfall-free fragments.
- Connect your app to Rindle Cloud — the managed daemon, the three values, and the self-hosted → Cloud diff.
- Full app: the issue tracker — this same normalized schema as a complete React app, with both rejection paths live.
- Devtools — inspect optimistic mutations, live queries, and raw deltas while developing.
- The change model — the
Add/Remove/Edit/Childdeltas the daemon streams.