Skip to content
Onboarding contents

OnboardingFirst steps

Synced-app quickstart

Connect a SQL schema, named queries, shared mutators, browser client, and API server in an existing project.

View as Markdown

Build a small issue tracker with live queries and optimistic writes. An optimistic write updates the browser immediately, before the server accepts it. Open the app in two windows to see each accepted write sync between them.

This guide assembles the three tiers in a Vite + React app: the browser, your API server, and the Rindle data tier. It introduces SQL migrations, a shared schema, named queries, and shared mutators in that order. A mutator is a function that describes a write.

For a generated app with routing and server rendering, start with create-rindle. Those integrations are optional. This guide uses a separate Node API server so you can see each tier.

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

You need Node 22.18 or later and pnpm. The API server runs TypeScript directly. Create the project, then install its dependencies:

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/

Create the files in the following steps. Step 6 starts the database processes, applies migrations, generates the schema, and starts both app processes. Imports from shared/schema.gen.ts remain unresolved until that first run.

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 NOT NULL, name TEXT NOT NULL, PRIMARY KEY (id));

CREATE TABLE IF NOT EXISTS issue (
  id TEXT NOT NULL, title TEXT NOT NULL, status TEXT NOT NULL,
  priority TEXT NOT NULL, ownerId TEXT NOT NULL,
  createdAt REAL NOT NULL, updatedAt REAL NOT NULL, 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 NOT NULL, issueId TEXT NOT NULL, name TEXT NOT NULL, PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS tag_issue ON tag (issueId, name);

CREATE TABLE IF NOT EXISTS comment (
  id TEXT NOT NULL, issueId TEXT NOT NULL, authorId TEXT NOT NULL,
  body TEXT NOT NULL, createdAt REAL NOT NULL, 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 the browser and API server import the schema, relationships, query builder, and mutators. A relationship declares how columns join two tables.

A shared mutator uses a JavaScript generator: each yield asks its caller to perform a database operation. The browser applies these operations locally. The API server applies the same operations in a SQL transaction.

After server updates arrive, the browser reapplies pending mutators to the confirmed data. This process is called rebase. Generate IDs and timestamps before you call a mutator, then pass them as arguments. Each tier supplies the acting user through 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

To sync a query, give it a name with defineQuery and register it on the API server. The browser sends that name and its arguments. The server builds the approved query and arranges its subscription. The argument validator runs on both tiers.

This query selects the newest issues and counts the comments for each issue. Rindle updates the result as issues and comments change:

// 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 starts the browser engine and connects it to your API server. The API server authorizes subscriptions and accepts mutations. Rindle discovers the WebSocket endpoint for live updates automatically.

This example uses a fixed development identity. Both browser windows act as demo:

// src/rindle-client.ts
import { createRindleClient } from "@rindle/optimistic";
import { mutators, schema } from "../shared/app-def.ts";

const currentUser = () => "demo";

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) => window.alert(`${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:

// src/main.tsx — replace the generated Vite entry point
import { createRoot } from "react-dom/client";
import { Rindle, useQuery, useQueryStatus } from "@rindle/react";
import { issuesPageQuery } from "./IssueList.queries.ts";
import { app } from "./rindle-client.ts";

createRoot(document.getElementById("root")!).render(
  <Rindle store={app.store}>
    <IssueList />
  </Rindle>,
);

function IssueList() {
  const query = issuesPageQuery({ limit: 50 });
  const rows = useQuery(query);
  const status = useQueryStatus(query);

  function createIssue() {
    const title = window.prompt("Issue title");
    if (!title?.trim()) return;
    app.mutate.createIssue({
      id: crypto.randomUUID(), title: title.trim(), status: "todo",
      priority: "medium", createdAt: Date.now(),
    });
  }

  return (
    <main>
      <h1>Issues</h1>
      <button onClick={createIssue}>Create issue</button>
      {status === "unknown" && <p>Loading issues…</p>}
      {status === "complete" && rows.length === 0 && <p>No issues yet.</p>}
      <ul>
        {rows.map((r) => (
          <li key={r.id}>
            {r.title} — {r.status} · {r.commentCount} comments{" "}
            <button onClick={() => app.mutate.setStatus({
              id: r.id, status: "done", updatedAt: Date.now(),
            })}>Mark done</button>{" "}
            <button onClick={() => app.mutate.addComment({
              id: crypto.randomUUID(), issueId: r.id,
              body: "A comment from the demo", createdAt: Date.now(),
            })}>Add comment</button>
          </li>
        ))}
      </ul>
    </main>
  );
}

The event handlers generate IDs and timestamps once per action. The shared mutators can reuse those arguments each time they run.

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

The API server decides which queries and writes a caller can use. It builds approved queries and runs the shared mutators against the database. The database token stays in this process.

For this local demo, the server trusts the x-user header. This is a development identity, not authentication. Before deployment, replace it with a verified session and add the access rules your app needs.

// server/api.ts
import { createServer } from "node:http";
import { createRindleApiServer, registerQueries, RindleApiError, 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 () => {
    if (req.method !== "POST" || ![api.routes.query, api.routes.read, api.routes.mutate].includes(req.url ?? "")) {
      res.writeHead(404).end();
      return;
    }
    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.read   ? await api.handleReadJson(body, ctx)   :
      await api.handleMutateJson(body, ctx);
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify(out));
  })().catch((error: unknown) => {
    const status = error instanceof RindleApiError ? error.status : error instanceof SyntaxError ? 400 : 500;
    console.error(error);
    res.writeHead(status, { "content-type": "application/json" });
    res.end(JSON.stringify({ error: status === 500 ? "Internal server error" : String(error) }));
  });
}).listen(7700, "127.0.0.1");

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 the URL printed by Vite in two browser windows. Click Create issue in one window. The issue appears immediately and then syncs to the other window.

Click Add comment to increase its live comment count. Click Mark done to change its status. Both windows update without a refresh.

Go to production

Replace the demo identity with real authentication and review your query and mutation policies. Deploy the browser and API server on your chosen app host.

On Rindle Cloud, configure the API server with RINDLE_URL and the server-only RINDLE_DATABASE_TOKEN. For a self-hosted data tier, see deploying and scaling. The schema, queries, and shared mutators use the same APIs in both deployments.

Next steps