Build a synced app

Synced-app quickstart

Build a synced, local-first app by hand: SQL schema, generated TypeScript, isomorphic mutators, an optimistic browser client, a stateless API authority, and the Rindle data tier.

View as Markdown

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:

  1. evaluates rindle.ncl
  2. waits for the fleet
  3. applies migrations
  4. generates the schema
  5. 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