# Synced-app quickstart

Build a synced, local-first app end to end on one page: a SQL schema as the source of truth, generated TypeScript, an optimistic browser store, isomorphic mutators, your API authority, and the rindled daemon (self-hosted or on Rindle Cloud). Every file, every command.

The whole [three-tier architecture](/docs/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](https://cloud.rindle.sh) run it for you.

It uses the same normalized schema as the
[full issue tracker](/docs/example-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`](/docs/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:

```bash
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`](/docs/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:

```bash
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](/docs/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.

```sql
-- 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:

```ts
// 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 gen` reads each
> column's declared type: `TEXT` → `string()`, `INTEGER` / `REAL` → `number()`,
> **`BOOLEAN` → `boolean()`**, **`JSON` → `json<T>()`**. Refine a `json<T>()` element type
> by hand after generating. Two limits: integers beyond ±(2⁵³−1) lose precision (the
> engine's number domain is `f64`), and `blob` isn'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 the
> `drizzle-kit` affinity caveat: [schema & migrations](/docs/schema).

> **Need browser-only tables?** Keep `shared/schema.gen.ts` generated. Define drafts, selections,
> and other private UI tables in a hand-written module and combine them with
> `extendSchema(generatedSchema, { tables: [localTable] })`; pass that extended schema to the
> browser client, while the API server keeps using the generated synced schema. See
> [schema & migrations](/docs/schema#adding-local-only-client-tables-to-a-generated-schema).

## 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 `yield`s 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.

```ts
// 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:

```ts
// 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](/docs/fragments). The
> [issue tracker](/docs/example-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.

```ts
// 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:

```tsx
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](/docs/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:

```ts
// 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](/docs/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:

```json
// 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"
  }
}
```

```bash
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`):

```ts
// 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](/docs/cloud-quickstart)). The **Connect** panel hands you the values
  your tiers need — **Live queries** (`wss://…`) for the browser's `daemon.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:

  ```bash
  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.ts
  ```

  Then swap the endpoints into steps 4 and 5 (`RINDLE_REPLICATOR_URL` for the write leg,
  `RINDLE_DAEMON_URL` for the read leg). See [Connect your app](/docs/cloud-connect) for
  the exact diff.

- **Self-host.** [`@rindle/cli`](/docs/rindle-cli) ships the prebuilt binaries; run the
  pair on a box you operate — a `rindle.ncl` with `followers = 1` is the colocated pair
  (set auth tokens, expose the follower's two ports and the master's write port), and
  apply migrations with `rindle migrate apply --url <write-master> --token …`. See
  [run the daemon](/docs/daemon) and [deploying & scaling](/docs/deploy) 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-run `rindle schema gen` after each one** (in
  local dev, the `rindle up --migrate --gen … --watch` inside `pnpm dev` does the
  apply-and-regenerate on every `migrations/` change). Don't hand-edit the generated file (except to refine a `json<T>()`
  element type).
- **A mutator is one isomorphic body — deterministic and replayable.** No `Date.now()`,
  no `Math.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 is `ctx.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 `defineQuery` and call the
  value (`issuesPageQuery({ limit })`). An ad-hoc `app.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 `.args` schema, and each query's `validate` step 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 its `countAs` — 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 — an `increment()`-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](/docs/troubleshooting) — every failure mode there is one of these
rules, broken.

## Next steps

- [The three-tier architecture](/docs/architecture) — the topology this realizes, and the
  two round-trips (subscribe, write) drawn out.
- [Scaffold with create-rindle](/docs/create-rindle) — generate a TanStack Start app
  with the same three-tier shape.
- [The browser client](/docs/client) · [The API server](/docs/api-server) — the
  runtime tiers you write and wire.
- [`@rindle/cli`](/docs/rindle-cli) — local daemon supervision, migrations, and
  schema generation.
- [Server rendering](/docs/ssr) — preload named queries for first paint and hand
  off to the live wasm client.
- [Compose the UI with fragments](/docs/fragments) — join the owner and tags onto each
  issue as composable, waterfall-free fragments.
- [Connect your app to Rindle Cloud](/docs/cloud-connect) — the managed daemon, the three
  values, and the self-hosted → Cloud diff.
- [Full app: the issue tracker](/docs/example-issue-tracker) — this same normalized schema
  as a complete React app, with both rejection paths live.
- [Devtools](/docs/devtools) — inspect optimistic mutations, live queries, and raw
  deltas while developing.
- [The change model](/docs/change-model) — the `Add` / `Remove` / `Edit` / `Child` deltas
  the daemon streams.

---

[View this page on Rindle](https://rindle.sh/docs/synced-app-quickstart)
