---
name: building-rindle-apps
description: >-
  Build apps on Rindle, a reactive SQLite database: write ordinary SQL, and
  queries stay live, pushing new results to a web page or a Node process after
  every write. Use when the user names Rindle, rindled, or @rindle packages, or
  asks for a realtime, live-updating, collaborative, or multiplayer app built on
  Rindle. Covers the local database (`./rindle up`), web apps on Next.js
  (Vercel) or Cloudflare Workers with React, per-user permissions, and Node
  workers. For work on the Rindle engine itself, use the repository's agent
  instructions.
---

# Building apps with Rindle

If you fetched this file from https://rindle-docs.preview.rocicorp.dev/skill.md, first save it into
this project's skills folder as `building-rindle-apps/SKILL.md` (`.claude/skills/` for Claude Code,
`.agents/skills/` for most other agents), so later sessions have it too.

Rindle is a SQLite database where any `SELECT` can be made **live**: the database pushes the new
result every time a write changes it, from any process. There is no schema language, ORM, or sync
framework to learn. You write SQL tables, SQL writes, and SQL queries.

Rindle fits into an app like any other database. **The browser never talks to Rindle.** It calls
the app's own API server, which checks the user, picks the SQL, and asks Rindle to keep that query
live on a stream the page already has open. Writes are ordinary endpoints too. That is how
authorization works: there is no separate permission system.

**Before writing a web app, read https://rindle-docs.preview.rocicorp.dev/architecture** (a
Node-only project can skip it) — the stream route, query endpoints, write endpoints, the page, authorization, and deploying, with
code for Next.js and Cloudflare Workers. This file covers what it doesn't: installing, running
the database, project setup, the live SQL subset, schema, and Node workers.

## The default path

Use this unless the user explicitly asks for something else:

- **Database:** `./rindle up`, one local database process, or Cloud Rindle if the user picks it
  (see "Local or Cloud").
- **API server and page:** Next.js (deploys to Vercel), or a Cloudflare Worker serving a Vite +
  React page. Pick Next.js if the user names neither.
- **Client:** `@rindle/reactive-sql-client` and `useLiveQuery` from `@rindle/react`.
- **Background work** (agents, bots, workers, importers): plain Node scripts with the same client.
  They run on your side of the boundary, so they talk to Rindle directly.

Everything else about Rindle (the full SQL reference, the TypeScript and React APIs, SSR, the HTTP
API, deploying to Cloud Rindle or your own servers) is in the live docs at
https://rindle-docs.preview.rocicorp.dev. Read the relevant page there before using anything this
file and the architecture page don't cover.

## Local or Cloud

**Before setting anything up, ask the user one question:** should the database run locally
(`./rindle up` on this machine) or on Cloud Rindle (hosted)? Skip the question only if they already
said. Local is the default if they have no preference. The app code is the same either way; only
where `RINDLE_URL` points changes.

- **Local:** follow "Run the database" below.
- **Cloud Rindle:** install the database binary as below (the `rindle` binary is also the Cloud CLI),
  then follow the Cloud page, https://rindle-docs.preview.rocicorp.dev/cloud:
  1. The user runs `./rindle login` themselves: it opens a browser to confirm a code, so don't run
     it for them. Wait until they say it's done (`./rindle whoami` shows the account).
  2. Run `./rindle deploy` in the project root. It creates the database and prints
     `RINDLE_URL=https://…` and `RINDLE_DATABASE_TOKEN=…`.
  3. Put both lines in `.env` (and `.dev.vars` for a Worker) for local development, and in the
     hosting provider's server environment for production. Both clients read both variables.
     Never commit them or send them to the browser. `.rindle/cloud.json` is safe to commit.
  4. Run the migration script (see "Schema and migrations") against that environment.

  There is no `./rindle up` with Cloud: the database is already running.

## Install Rindle

Rindle is not on the npm registry. It comes from the `dev` GitHub Release, through the GitHub
CLI (`gh`, signed in to an account that can read `rocicorp/rindle`). Install it with these plain
downloads, in the project root (after scaffolding the app, below). Don't run the release's
`install.sh`: these steps do the same without running a downloaded script.

**1. The database**, one native binary, to `./rindle`. Pick the file for this machine
(`uname -sm`): `aarch64-apple-darwin` (macOS Apple silicon), `x86_64-apple-darwin` (macOS Intel),
`x86_64-unknown-linux-gnu` or `aarch64-unknown-linux-gnu` (glibc Linux; there is no Windows or
Alpine build, use WSL2 or a glibc distribution):

```sh
gh release download dev -R rocicorp/rindle -p 'rindle-aarch64-apple-darwin.tar.gz' -O - | tar -xz
echo /rindle >> .gitignore
```

Run it as `./rindle`; nothing is installed on `PATH`.

**2. The JS packages**, as tarballs kept in `.rindle/packages/` (commit that directory, or re-run
this step on a fresh checkout):

```sh
mkdir -p .rindle/packages
gh release download dev -R rocicorp/rindle -D .rindle/packages -p 'rindle-js-*.tgz' --clobber
npm install .rindle/packages/rindle-js-*.tgz
```

That adds every `@rindle/*` package to `dependencies` with a `file:` spec, including
`@rindle/reactive-sql-client`, `@rindle/daemon-client` (the server's sessions) and
`@rindle/react`. Without a `package.json`, run `npm init -y && npm pkg set type=module` first.
Node 22.18 or later is needed.

Do not `npm i` any `@rindle/*` package by name: the registry copies are older and will be removed.
Run the same steps again to update. If `gh` is missing or not signed in, stop and ask the user to
install it and run `gh auth login`.

## Run the database

```sh
./rindle up
```

`up` runs in the foreground until Ctrl-C. Run it from the project root, in its own terminal (or in
the background with its output in a log file). It:

- stores the database in `.rindle/data.db` (it survives restarts; add `.rindle/data.db*`, `.env`
  and, for a Worker, `.dev.vars` and `.wrangler` to `.gitignore`, but not `.rindle/packages/`,
  which `package.json` points at);
- listens on the first free port at or above 7600;
- prints `RINDLE_URL=http://127.0.0.1:<port>` and writes that line to `.env`;
- requires no token locally. Anything that can reach the port can run any SQL, which is why only
  your server may know `RINDLE_URL`. `--token <secret>` turns a token on; your server then uses
  `RINDLE_URL=http://:<secret>@127.0.0.1:<port>`, the same form as production.

Never hard-code the port; read `RINDLE_URL` from the environment. The port can change between runs
when 7600 is taken, so restart the app server after restarting `up`. `--port <n>` pins one.

Its output also carries **index advisories**: when a live query would get slow as a table grows,
it prints the exact `CREATE INDEX` to add. Put that statement in a new migration.

## Project setup

Keep server code (sessions, endpoints, the SQL) in server-only modules. Nothing the browser loads
may import `createRindleSessions`, know the `RINDLE_URL` value, or contain the app's SQL. Create
the two server clients once, in one module every endpoint imports (in a Worker, put it beside
`worker/index.ts`):

```ts
// lib/rindle.ts — server only
import { createRindleSessions } from "@rindle/daemon-client";
import { createReactiveSqlClient } from "@rindle/reactive-sql-client";

export const rindle = createRindleSessions();                      // reads RINDLE_URL
export const db = await createReactiveSqlClient({ rindle: {} });   // for writes; no I/O yet

/** The live client and query ids the page sends with every query request. */
export const ids = (request: Request, userID: string) => ({
  clientID: request.headers.get("Rindle-Client"),
  queryID: request.headers.get("Rindle-Query"),
  userID,
});
```

### Next.js

```sh
npx create-next-app@latest my-app --ts --app --eslint --no-tailwind --no-src-dir --use-npm --yes
cd my-app
# then install Rindle (see "Install Rindle")
```

Add `"type": "module"` to the `package.json` create-next-app wrote, so Node runs the migration
script as ESM. Next.js reads `.env`, so `./rindle up` in the app directory is all the
configuration local development needs. Endpoints are route files (`app/api/notes/route.ts`), as on the
architecture page. The page that calls `useLiveQuery` is a client component (`"use client"`).

create-next-app turns on `cacheComponents`, so a page that reads `params` (a share link like
`/poll/[id]`) is a server `page.tsx` that renders the client component inside `<Suspense>`.

**Server rendering.** If the user wants pages rendered on the server with their live rows (the
first load shows data, then stays live), read https://rindle-docs.preview.rocicorp.dev/ssr before
writing it, including its Next.js section. Don't fetch the rows separately with `readOnce` and
pass them down: the SSR client hands the server's queries to the page without a second request.

### Cloudflare Worker

A Vite + React page served as static assets, with the Worker answering `/api/*`:

```sh
npm create vite@latest my-app -- --template react-ts --no-interactive
cd my-app
npm i -D @cloudflare/vite-plugin wrangler
# then install Rindle (see "Install Rindle")
```

```ts
// vite.config.ts
import { cloudflare } from "@cloudflare/vite-plugin";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [react(), cloudflare()], build: { target: "es2022" } });
```

```jsonc
// wrangler.jsonc
{
  "name": "my-app",
  "main": "./worker/index.ts",
  "compatibility_date": "2026-05-28",
  "compatibility_flags": ["nodejs_compat"], // puts vars and secrets on process.env
  "assets": { "not_found_handling": "single-page-application", "run_worker_first": ["/api/*"] }
}
```

`worker/index.ts` routes each `/api/...` path to its handler, reading secrets from `process.env`
as the snippets here do:

```ts
// worker/index.ts
export default {
  async fetch(request: Request): Promise<Response> {
    const { pathname } = new URL(request.url);
    const m = request.method;
    if (pathname === "/api/rindle-connect") return m === "DELETE" ? disconnect(request) : connect(request);
    if (pathname === "/api/todos") return m === "POST" ? createTodo(request) : todos(request);
    const done = pathname.match(/^\/api\/todos\/(\d+)\/done$/);
    if (done && m === "POST") return markDone(request, Number(done[1]));
    // …login, me, logout
    return new Response("Not found", { status: 404 });
  },
};
```

`npm run dev` (Vite, on port 5173) runs the Worker in workerd. The Vite template's `tsc -b` checks
only `src/`, so add `worker` to a tsconfig's `include` to typecheck the Worker. Wrangler's local dev reads variables from
`.dev.vars`, so copy the `RINDLE_URL` line from `.env` there (and add `SESSION_SECRET`, below), or
start Vite with `CLOUDFLARE_INCLUDE_PROCESS_ENV=true` and the variables in the environment.
Clients can be created at module scope, but never `await` I/O there: a Worker can only do I/O
inside a request.

### Sign-in

Use the app's existing auth if it has one: endpoints only need the user's id. For a new app with no
auth, a stand-in session is enough: a name, kept in an **HttpOnly** cookie signed with
`SESSION_SECRET`, so page scripts (and an XSS) can't read it. Never keep the session, a token, or
`RINDLE_URL` in `localStorage` or anywhere page scripts can read.

```ts
// lib/auth.ts — WebCrypto, so it runs on Node, Next.js, and Workers
const COOKIE = "session";
const secret = () => process.env.SESSION_SECRET || "dev-only-secret";
const enc = new TextEncoder();

async function sign(value: string) {
  const key = await crypto.subtle.importKey("raw", enc.encode(secret()), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(value)));
  return btoa(String.fromCharCode(...mac)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

export async function getUser(request: Request): Promise<{ id: string } | null> {
  const raw = (request.headers.get("cookie") ?? "").split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`));
  if (!raw) return null;
  const value = decodeURIComponent(raw.slice(COOKIE.length + 1));
  const dot = value.lastIndexOf(".");
  const id = value.slice(0, dot);
  return dot > 0 && (await sign(id)) === value.slice(dot + 1) ? { id } : null;
}

export async function sessionCookie(request: Request, id: string) {
  const secure = new URL(request.url).protocol === "https:" ? "; Secure" : "";
  return `${COOKIE}=${encodeURIComponent(`${id}.${await sign(id)}`)}; Path=/; HttpOnly; SameSite=Lax${secure}`;
}

export const clearedCookie = `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
```

A `POST /api/login` endpoint sets `sessionCookie(request, name)` with `Set-Cookie`, `GET /api/me`
returns the user (or `null`) so the page knows whether to show sign-in, and `POST /api/logout`
clears the cookie and calls `rindle.disconnect({ userID })`. When the app needs to name other users (to
share or assign to them), upsert a `app_user(id TEXT PRIMARY KEY)` row on login and check it in
the write.

### The page's client

Create one live client per signed-in page, and close it on sign-out. A hook keeps that tidy:

```tsx
import { useEffect, useState } from "react";
import { createReactiveSqlClient, type ReactiveStreamClient } from "@rindle/reactive-sql-client";

export function useStreamClient(): ReactiveStreamClient | null {
  const [db, setDb] = useState<ReactiveStreamClient | null>(null);
  useEffect(() => {
    let client: ReactiveStreamClient | undefined;
    let closed = false;
    void createReactiveSqlClient({ stream: "/api/rindle-connect" }).then((c) => {
      if (closed) return c.close();
      client = c;
      setDb(c);
    });
    return () => {
      closed = true;
      client?.close();
    };
  }, []);
  return db;
}
```

Render the signed-in part of the page with `key={user.id}`, so a different user gets a fresh client.
`useLiveQuery(db, "/api/notes")` accepts `null` while the client is being created.

## Schema and migrations

Migrations run from your side, never from the page. Keep them in a module and run it with a script
before starting the app (and against production's `RINDLE_URL` when deploying):

```ts
// scripts/migrate.ts — run with: node --env-file=.env scripts/migrate.ts
import { createReactiveSqlClient } from "@rindle/reactive-sql-client";

const db = await createReactiveSqlClient({ rindle: {} }); // reads RINDLE_URL
// Applied once per name, ever; later runs are no-ops.
await db.migrate("0001_init", `
  CREATE TABLE IF NOT EXISTS note (
    id    INTEGER PRIMARY KEY AUTOINCREMENT,
    owner TEXT    NOT NULL,
    title TEXT    NOT NULL
  );
  CREATE INDEX IF NOT EXISTS note_owner ON note (owner, id);
`);
db.close();
```

Node 22.18 or later runs `.ts` files directly.

- `migrate(name, sql)` applies the SQL once per name and records it. The same name with
  **different** SQL fails (`MIGRATION_CHECKSUM_MISMATCH`). To change the schema, add a migration
  with a new name. Never edit an applied one; during development, deleting `.rindle/data.db*`
  (with the database stopped) starts over.
- `migrate` takes schema statements only: `CREATE`/`DROP TABLE`, `ALTER TABLE ... ADD`/`DROP
  COLUMN`, `CREATE`/`DROP INDEX`. Seed data with ordinary
  writes that are safe to repeat (`INSERT ... ON CONFLICT (id) DO NOTHING`).
- Schema statements are not transactional: if the third statement fails, the first two stay
  applied and the migration is not recorded. Write `IF NOT EXISTS` so a fixed migration can re-run.
- Every table needs a primary key, and key columns cannot be `NULL`.
  `id INTEGER PRIMARY KEY AUTOINCREMENT` is the simple choice. A `TEXT` key is fine too.
- Column types decide what JavaScript values you get back in live rows:

  | Declare | Live rows give you |
  | --- | --- |
  | `TEXT` | `string` |
  | `INTEGER`, `REAL`, `NUMERIC` | `number` |
  | `BOOLEAN` | `boolean` (write `true`/`false` or `1`/`0`) |
  | `JSON` | the parsed value (write `JSON.stringify(value)`) |

  `BLOB` columns, `BIGINT` keys, `RENAME`, and changing a column's type are not supported.
- `DEFAULT`, `NOT NULL`, `UNIQUE`, `CHECK`, and indexes work as in SQLite. A live query with
  `ORDER BY ... LIMIT` wants an index on its filter columns followed by its `ORDER BY` columns,
  in the same `ASC`/`DESC` directions; `up` prints the exact index when one is missing.
- Set timestamps from the app (`Date.now()` as a param) rather than with SQL time functions.

## Writes

Write endpoints run SQL with the server's `db` (from `lib/rindle.ts`, above). Writes are durable when the promise resolves, and every affected live query updates right after,
for every user who can see the rows.

```ts
// One statement. `args` bind to `?` placeholders in order.
const { result } = await db.sql.execute({
  sql: "INSERT INTO poll (owner, question, created_at) VALUES (?, ?, ?)",
  args: [user.id, question, Date.now()],
});
const pollId = Number(result.lastInsertRowid); // lastInsertRowid is a string (or null)

// Several statements, atomically: all or none.
await db.sql.batch([
  { sql: "INSERT INTO option (poll_id, label) VALUES (?, ?)", args: [pollId, "Yes"] },
  { sql: "INSERT INTO option (poll_id, label) VALUES (?, ?)", args: [pollId, "No"] },
]);

// Reads and writes in one transaction (use when a write depends on a read).
await db.sql.withTransaction(async (tx) => {
  const r = await tx.execute({ sql: "SELECT stock FROM item WHERE id = ?", args: [id] });
  if ((r.rows[0]?.[0] as number) < 1) throw new Error("sold out"); // throwing rolls back
  await tx.execute({ sql: "UPDATE item SET stock = stock - 1 WHERE id = ?", args: [id] });
});
```

- **Put the permission check in the SQL** (`UPDATE ... WHERE id = ? AND owner = ?`), then answer
  `403` when `result.rowsAffected` is `0`. The user's id always comes from the session, never from
  the request body or URL. When the guard also checks the input (the assignee exists), check the
  input first, so `0` means only "not yours".
- `execute` resolves to `{ result }`: `{ columns, rows, rowsAffected, lastInsertRowid }`. `rows` are
  **arrays in column order** with raw SQLite values. `tx.execute` inside `withTransaction` resolves
  to that result itself, not to `{ result }`.
- `withTransaction` is optimistic: if another write changed what it read before it commits, the
  commit fails with a conflict and nothing is applied. For read-then-write logic that should just
  try again (a bid, a stock count), use `db.sql.withTransactionRetry(fn)`, which reruns `fn`. A
  guarded single `UPDATE ... WHERE` (checking `rowsAffected`) needs neither.
- `batch` is all-or-nothing on **errors** only. A statement that matches no rows is not an error,
  so the rest still commit. When a write must only happen if a guarded `UPDATE` matched, use
  `withTransaction`, check `rowsAffected`, and throw to roll back.
- `RETURNING` works. A constraint violation rejects the promise (`UNIQUE constraint failed`). For
  "at most once", use a constraint plus `ON CONFLICT ... DO NOTHING` and check `rowsAffected`; for
  "change my answer", `ON CONFLICT (...) DO UPDATE SET col = excluded.col`.
- `execute` and `batch` refuse schema statements; use `migrate`.
- Writes are not optimistic: the page shows a change when it arrives on the stream, a round trip
  later. A test that clicks and immediately asserts should wait for the new state; for a checkbox
  bound to a live row, Playwright's `check()` fails ("did not change its state"), so `click()` and
  then wait for it to become checked.

## Live SQL: what a live query can be

`rindle.addQuery` (in an endpoint) and `db.materialize` (in Node) accept a SQLite `SELECT` in the
subset below, which the database keeps up to date incrementally. Anything outside it is refused at
once with an error that names the problem: `addQuery` answers `400` with it, and the page sees it
as the query's `error`. A refused query is a modeling question, not a bug to work around with
polling.

**Only the selected columns reach the page.** A column list must include the primary key and every
`ORDER BY` column (the compiler refuses one that doesn't). Select exactly what this user may see; a
column used only in `WHERE` is never sent.

**Supported:**

- `SELECT *` or `SELECT col, col` (plain column names; no `AS` renames, no expressions), including the
  primary key and every `ORDER BY` column.
- `WHERE` comparing a column to a literal or a `?` param: `=`, `!=`, `<`, `<=`, `>`, `>=`,
  `<>`, `IS NULL`, `IS NOT NULL`, `LIKE`, `NOT LIKE`, `ILIKE`, `IN (?, ?)`, `NOT IN (...)`,
  `BETWEEN ? AND ?`, `IS TRUE`/`IS FALSE`, combined with `AND`, `OR`, and parentheses. The full
  list is at https://rindle-docs.preview.rocicorp.dev/sql.
- `ORDER BY` any columns, `ASC`/`DESC`, and `LIMIT n`. A query that shows a list should have a
  `LIMIT`.
- `EXISTS (SELECT 1 FROM child WHERE child.parent_id = parent.id AND ...)` and `NOT EXISTS`. This
  is how permission rules read other tables (shares, team membership).
- **Nested results (instead of JOIN).** A trailing `WITH` clause attaches related rows to each
  result row:

  ```sql
  SELECT * FROM poll WHERE id = ?
  WITH options OF option ON poll_id = id (ORDER BY id)
  ```

  gives `[{ id, question, ..., options: [{ id, poll_id, label }, ...] }]`. The syntax is
  `WITH [ONE] <field> OF <table> ON <column in that table> = <column in the outer table> (<body>)`.
  The body can hold `WHERE`, `ORDER BY`, `LIMIT`, and further `WITH` clauses, and may be empty
  `()`. `WITH ONE` gives one object or `null` instead of an array, for a to-one link. Several
  `WITH` clauses can follow one another; nothing else may follow the first `WITH`.
- **Counts and sums per row**, as a correlated subquery in the column list:

  ```sql
  SELECT id, label, (SELECT count(*) FROM vote WHERE vote.option_id = option.id) AS votes
  FROM option WHERE poll_id = ? ORDER BY id
  ```

  `count(*)`, `sum(col)`, and `avg(col)` work this way, and the subquery can carry its own `WHERE`
  of column-to-literal or column-to-param comparisons. It cannot nest another subquery, call a
  function such as `COALESCE`, or compare against a column of a third table. This is the one place
  `AS` is allowed.

**Refused, and what to do instead:**

| Refused | Instead |
| --- | --- |
| `JOIN` of any kind | `WITH` / `WITH ONE` (nested rows), or `EXISTS` to filter |
| Top-level `count(*)`, `GROUP BY`, `HAVING` (allowed only inside `EXISTS`) | A correlated count on a parent row. For a global total, keep a one-row table such as `stats`, or count a small live result in JavaScript. |
| `DISTINCT`, `UNION`, CTEs (`WITH x AS`), subqueries in `FROM`, window functions | Model a table that holds what you need, or nest with `WITH` |
| `OFFSET` | Keyset paging: `WHERE (created_at, id) > (?, ?) ORDER BY created_at, id LIMIT n`, or for descending order `WHERE created_at < ? OR (created_at = ? AND id < ?)` |
| Expressions or functions anywhere (`created_at > ? - 60000`, `lower(name)`, `now()`) | Compute the value in JavaScript and pass it as a param. A param is fixed for the life of the query. |
| `col AS alias`, `*` mixed with expressions | Plain column names; rename in JavaScript |
| Comparing two columns (`a < b`) | Store the comparison's result in its own column when you write the row |
| Named params (`:id`), array params | Positional `?` (or `?1`, `$1`), one per value |

A `SELECT` run through `db.sql.execute` is not live and can use joins, `GROUP BY` and the rest of
SQLite over your tables (not `sqlite_master` or other internal tables), but it reads once.
`db.readOnce` takes only the live subset above.

## Patterns for common apps

- **Who can see what** is the query's `WHERE`: `owner = ?`, `OR EXISTS (SELECT 1 FROM share ...)`,
  team membership through `EXISTS`. Pick a different query per role in the endpoint. More examples
  are on the architecture page.
- **Revoking access is live.** Deleting a share or a membership row removes the rows from the other
  user's open page. For rules outside the data (sign-out, a ban), call `rindle.disconnect({ userID })`.
- **Share links.** Put the id in the URL and pass it to the query endpoint (`/api/polls/12`); the
  endpoint still checks that the user may see it.
- **Votes, likes, reactions.** One row per user per thing, with a `UNIQUE` constraint, and a
  correlated `count(*)` on the parent for the totals. Toggle with insert and delete.
- **Chat.** `ORDER BY id DESC LIMIT n`, then reverse in JavaScript to display oldest first.
- **Per-user unread counts.** Keep a `message_count` on the channel, bumped in the same `batch` as
  each insert, and a `read_state(channel_id, user_id, read_count, PRIMARY KEY (channel_id, user_id))`
  row set to the channel's count when the user views it. Then
  `SELECT * FROM channel ORDER BY name WITH ONE mine OF read_state ON channel_id = id (WHERE user_id = ?)`
  gives every unread count as `message_count - (mine?.read_count ?? 0)`.
- **Presence.** Each page upserts a `presence(id, name, last_seen)` row through an endpoint every
  few seconds. Live-query the whole (small) table and filter `last_seen > Date.now() - 15000` in
  JavaScript on a timer.
- **Agents and workers.** A `job` table with a `status` column is the queue. Endpoints insert
  `pending` rows and the page live-queries their status; a Node worker claims rows with a
  conditional `UPDATE` and writes results back (see "Node").
- **Derived state.** If a live query needs something the SQL subset cannot compute, compute it when
  you write and store it in a column.

## Node: scripts, workers, and agents

A Node process on your side of the boundary uses the client directly, with live queries:

```ts
// worker.ts — run with: node --env-file=.env worker.ts
import { createReactiveSqlClient } from "@rindle/reactive-sql-client";

const db = await createReactiveSqlClient({ rindle: {} }); // reads RINDLE_URL

const pending = await db.materialize<{ id: number; prompt: string }>(
  "SELECT id, prompt FROM job WHERE status = 'pending' ORDER BY id LIMIT 10",
);
pending.subscribe(async (jobs) => {
  for (const job of jobs) {
    // Claim it. With several workers, only the one whose UPDATE changed a row owns the job.
    const { result } = await db.sql.execute({
      sql: "UPDATE job SET status = 'working' WHERE id = ? AND status = 'pending'",
      args: [job.id],
    });
    if (result.rowsAffected !== 1) continue;
    const answer = await doTheWork(job.prompt);
    await db.sql.execute({ sql: "UPDATE job SET status = 'done', answer = ? WHERE id = ?", args: [answer, job.id] });
  }
});
// The open live query keeps the process running. Ctrl-C (or db.close()) ends it.
```

The listener fires immediately with the current rows and again after every change, including the
worker's own, and can run again while an earlier run is still awaiting, so make it safe to repeat
(the conditional `UPDATE` above does). `await db.readOnce(sql, params)` reads a live-subset query once; for any other one-off read (a
permission check, a lookup), use `db.sql.execute`. For
streaming output (an LLM answer), update the row as chunks arrive, batched every 50–100 ms; every
viewer's query updates on each write.

## Before you finish

- Check it end to end: run the database and the app, sign in as two users in two browser windows
  (or a normal and a private window), write in one, and watch the other update without a reload.
  Check that each user sees only their own rows.
- Check the boundary: the page's network requests go only to the app's `/api/...` routes, and no
  file the browser loads contains the app's SQL or the `RINDLE_URL` value (its address or token).
  The client library itself contains `createRindleSessions` and mentions the name `RINDLE_URL`;
  that's fine, as long as browser code never calls it.
- Tell the user how to run it: `./rindle up`, the migration script, then the app.
- **Deploying.** The app's server environment gets the database's variables plus
  `SESSION_SECRET` (on Cloudflare, `wrangler secret put`). With Cloud Rindle those are the
  `RINDLE_URL` and `RINDLE_DATABASE_TOKEN` that `./rindle deploy` printed. Self-hosted, Rindle runs
  somewhere private with `--token`, and the app gets `RINDLE_URL=https://:<token>@<rindle-host>`
  (https://rindle-docs.preview.rocicorp.dev/self-hosted). Run the migration script against
  production before the app. See "Deploying" on the architecture page.

## Troubleshooting

| Symptom | Cause and fix |
| --- | --- |
| `fetch failed` / `ECONNREFUSED` in the server | The database is not running, or `RINDLE_URL` is stale. Start `./rindle up`, then restart the app server. |
| `pass url, or set RINDLE_URL` | The server process has no `RINDLE_URL`: Next.js reads `.env` from the app directory; a Worker needs `.dev.vars` (or `CLOUDFLARE_INCLUDE_PROCESS_ENV=true`) and `nodejs_compat`. |
| Worker: `Cannot perform I/O on behalf of a different request` or `Disallowed operation called within global scope` | I/O at module scope. Create clients there, but call them only inside a request. |
| Query `error` is `401` or `403` | Your endpoint refused it: the session cookie is missing or doesn't grant the query. |
| Query `error` is `400` with `unsupported: ...` or `live queries must select ...` | The SELECT is outside the live subset, or its column list leaves out the primary key or an `ORDER BY` column. See "Live SQL". |
| Live query never leaves `loading` | The stream route isn't returning `rindle.connect(...)` as is, or a proxy buffers it, or the stream and query endpoints pass different `userID`s (each pair is its own client). |
| `STATEMENT_KIND_MISMATCH` | DDL passed to `execute`/`batch`. Use `db.migrate`. |
| `MIGRATION_CHECKSUM_MISMATCH` | An applied migration's SQL was edited. Use a new name, or delete `.rindle/data.db*` in development. |
| `no such table` | Run the migration script before the app. |
| Comparisons fail on ids | `lastInsertRowid` is a string; wrap it in `Number(...)`. |
| Vite dev prints `Unable to fetch the Request.cf object` | A network proxy blocks Cloudflare's lookup. Harmless locally. |
| `@rindle/react` has no `useLiveQuery`, or no `createRindleSessions` | The `@rindle` packages came from npm and predate this skill. Install from GitHub (see "Install Rindle"). |
| `gh: ... HTTP 404` or `release not found` | `gh` is signed in to an account that cannot read `rocicorp/rindle`. |
| `up` says the directory has `rindle.json` | The older multi-process setup ran there. Use a fresh directory, or `./rindle up --multiprocess`. |

## API summary

The architecture page shows each of these in context.

```ts
import { createRindleSessions } from "@rindle/daemon-client";
import { createReactiveSqlClient } from "@rindle/reactive-sql-client";
import { useLiveQuery } from "@rindle/react";

// Server: sessions (reads RINDLE_URL; or createRindleSessions({ url }))
const rindle = createRindleSessions();
rindle.connect({ clientID, userID? }): Promise<Response>                   // the stream route
rindle.addQuery({ clientID, queryID, userID? }, sql, params): Promise<Response>  // a query endpoint
rindle.removeQuery({ clientID, queryID, userID? }): Promise<Response>      // the stream route's DELETE
rindle.disconnect({ userID?, clientID? }): Promise<number>                 // ends streams; returns how many
// clientID and queryID come from the Rindle-Client and Rindle-Query request headers.

// Server or Node: SQL
const db = await createReactiveSqlClient({ rindle: {} });   // or { rindle: { url } }
db.sql.execute({ sql, args? }): Promise<{ result: StatementResult }>
db.sql.batch([{ sql, args? }, ...]): Promise<{ results: StatementResult[] }>
db.sql.withTransaction(async (tx) => ...)                    // tx.execute(...) -> StatementResult
db.migrate(name, sql): Promise<{ applied: boolean }>
db.materialize<Row>(sql, params?): Promise<LiveQuery<Row>>   // Node: data, subscribe(fn), destroy()
db.readOnce<Row>(sql, params?): Promise<Row[]>
db.close()

// Browser
const live = await createReactiveSqlClient({ stream: "/api/rindle-connect" });
useLiveQuery<Row>(live, "/api/notes" | null): { rows: readonly Row[]; status: "loading" | "complete" | "error"; error?: Error }
live.close()
```

`status` is `"loading"` until the first result, then `"complete"`. Show "no notes yet" only when
`status === "complete"`. `rows` are plain objects, a new array on every change; don't mutate them.
Changing the endpoint swaps the query; unmounting removes it.
