# The API server

Your app's authority — a stateless, serverless-shaped tier that authenticates the caller, resolves named queries to ASTs, drives the same isomorphic mutators the browser predicted into SQL, and keeps pinned queries warm for one-shot reads.

The API server is **your app's authority**. It is stateless and
serverless-shaped — every request could be a fresh lambda — and it sits between the
untrusted browser and the [data tier's private control plane](/docs/daemon#two-planes).
It does four things:

1. **Authenticates** the caller (your session/JWT, however you do it).
2. **Resolves named queries** to real query ASTs — validating args, and adding any
   tenancy/auth filters the client can't be trusted to apply.
3. **Drives the [same mutators](/docs/mutators)** the browser
   predicted — one shared body per name, its logical ops rendered to approved SQL
   under this tier's authority.
4. **Talks to the data tier** over the bearer-authed control plane — query leases
   and reads to a `rindled` follower, writes to the `rindle-replicator` write-master
   (a `SplitDaemonClient` routes each leg).

It holds no data and no live state. Two packages do the work:
[`@rindle/api-server`](#the-server) (the request handlers) and
[`@rindle/daemon-client`](#talking-to-the-daemon) (the typed control-plane client).

## Named queries → ASTs

A query is defined **once**, with `defineQuery`, co-located with the component that
reads it (a React-free `*.queries.ts` module — see
[Compose the UI with fragments](/docs/fragments)). The same value is callable on the
client (it stamps its result with the wire identity, so a subscription always
**syncs**) and registerable on the server. `registerQueries` turns the list of those
co-located queries into the server's query surface — it re-runs each query's
**validator** on the UNTRUSTED wire args and builds the authoritative AST, so client
and server resolve a **byte-identical** query and a malformed client can't smuggle a
garbage value in:

```ts
import { registerQueries } from "@rindle/api-server";
import { issuesPageQuery, myIssuesQuery } from "../src/components/IssueListItem.queries.ts";
import { issueDetailQuery } from "../src/components/IssueDetail.queries.ts";
import { recentCommentsQuery } from "../src/components/ActivityFeed.queries.ts";
import { usersQuery } from "../src/components/UserBadge.queries.ts";

const apiQueries = registerQueries<User>([
  issuesPageQuery,
  myIssuesQuery,
  issueDetailQuery,
  recentCommentsQuery,
  usersQuery,
]);
```

Each `defineQuery` carries its own arg validation and AST construction next to the
fragment and component it feeds, so the server's query surface is just *the list of
queries the app defines* — no second place to restate the shape, and no chance of
the two tiers drifting.

A **context-scoped** query — "my issues", scoped to the authenticated principal —
declares a second `ctx` parameter on its `build`. The client passes its session ctx
at the callsite; the server injects its AUTHORITATIVE `ApiContext` (`{ user, request }`),
which `registerQueries` forwards as the query's ctx. Context is **off-wire**: the
wire still carries only `{ name, args }`, so a client can never spoof the identity —
there is no owner arg to tamper with, and the server re-derives it from its trusted
session:

```ts
// src/components/IssueListItem.queries.ts — defined ONCE, runs on both tiers
export const myIssuesQuery = defineQuery(
  "myIssues",
  validateMyIssuesArgs,                              // → { limit }; the OWNER is NOT here
  ({ limit }: MyIssuesArgs, ctx: { user: string | undefined }) =>
    q.issue.where.ownerId(ctx.user ?? "").orderBy("createdAt", "desc").limit(limit),
);

// client: passes its session user as ctx — myIssuesQuery({ limit: 20 }, { user })
// server: registerQueries forwards its authoritative ApiContext as ctx; both tiers
//         build the same AST, and the wire carries only { name: "myIssues", args: { limit } }
```

When the server must **diverge** from the client — add a tenancy/auth filter the
client can't see — reach for `defineApiQueries`, which maps a name directly to a
`(ctx, args)` resolver. Register a server-specific query under the same name and it
wins:

```ts
import { defineApiQueries, registerQueries } from "@rindle/api-server";
import type { ApiContext, ApiQueries } from "@rindle/api-server";
import { issuesPageQuery } from "../src/components/IssueListItem.queries.ts";

// Same NAME as the client's issuesPage, but the authority narrows it further — the
// client only ever sends { name, args }, so it never sees this extra filter.
const serverOnly = defineApiQueries<User, ApiQueries<User>>({
  issuesPage: (ctx: ApiContext<User>, args: unknown) =>
    issuesPageQuery.resolve(args).where.ownerId(requireUser(ctx.user)),
});

// Both are plain { name → resolver } maps, so a later entry wins on a name clash:
const apiQueries = { ...registerQueries<User>([issuesPageQuery, /* … */]), ...serverOnly };
```

`issuesPageQuery.resolve(args)` re-runs the same validator and builds the canonical
`Query`; from there the server is free to add what the client can't be trusted to.
`ctx` is an `ApiContext<User>` — `{ user, request }`, where `user` is whatever your
auth produced.

There is no separate "scope key": if two requests resolve to the same canonical
AST, the daemon may dedupe them, because the result is the same. If tenant or user
visibility matters, **encode it into the AST** — a context-scoped query or a
server-side filter like the ones above.

## Driving the shared mutators

The server does **not** re-write each mutator. Your mutators are
[isomorphic](/docs/mutators) — one generator body per name,
carrying its own arg schema via `shared(args, gen)` — and `sharedApiMutators`
auto-drives the whole registry: for each mutator it parses the **untrusted** wire
args through the mutator's `.args`, injects this tier's **authenticated** principal
as `ctx.user`, and drives the same body the client predicted, rendering every
yielded op to dialect SQL. The statements run in **one transaction** on the write-master:

```ts
import { sharedApiMutators } from "@rindle/api-server";
import type { MutationContext } from "@rindle/api-server";
import type { MutatorCtx } from "@rindle/client";
import { mutators } from "../shared/app-def.ts";

/** The MutatorCtx a shared body sees on the server: the AUTHENTICATED principal
 *  (throw if absent — a rejection). Never a client-supplied author arg. */
function sharedCtx(ctx: MutationContext<User>): MutatorCtx {
  return { user: requireUser(ctx.user) };
}

const apiMutators = sharedApiMutators(mutators, sharedCtx);
```

That triad — parse, inject, drive — is the whole server side of most mutators, and
it guarantees every shared name has a server implementation by construction.
`MutationContext<User>` is `{ user, envelope, daemon, request }` — it carries the
authenticated `user`, the mutation `envelope` (`{ clientID, mid, name, args }`), and
the `daemon` client if you need it directly.

### Server-only authority

Write an explicit entry **only** for authority the client must *not* predict, and
let it override the auto-wrapped default by key (spread first, override wins):

- **A server-only policy guard** — a check the client deliberately doesn't run, so
  the rejection path stays exercised end to end. Parse the args, run the guard, then
  drive the same shared body with `runSharedMutation`.
- **Relational SQL a keyed op can't express** — an owner-gated cascade, a dedup by a
  non-pk column. `tx.exec(sql, params)` is the raw escape hatch; the client predicts
  the plain shared op and snaps back if the authority disagrees.

```ts
import { defineApiMutators, runSharedMutation, sharedApiMutators } from "@rindle/api-server";
import type { ApiMutator, ApiMutators, MutationContext, ServerMutationTx, SharedMutatorWithArgs } from "@rindle/api-server";
import { issueIdArgs, mutators } from "../shared/app-def.ts";

const apiMutators = defineApiMutators<User, ApiMutators<User>>({
  ...sharedApiMutators(mutators, sharedCtx),

  // (a) a policy layered onto the shared body: guard, then drive the SAME body.
  createIssue: withTitleGuard(mutators.createIssue),

  // (b) the raw-SQL escape hatch: ownership enforced IN the SQL, so a non-owner's
  //     delete is accepted-but-no-op and the optimistic delete snaps back on its own.
  deleteIssue: async (tx: ServerMutationTx, raw: unknown, ctx: MutationContext<User>) => {
    const { id } = issueIdArgs.parse(raw);
    tx.exec("DELETE FROM issue WHERE id = ? AND ownerId = ?", [id, requireUser(ctx.user)]);
  },
});

/** Wrap a shared mutator with a server-only policy that runs BEFORE any write. */
function withTitleGuard<A extends { title: string }>(gen: SharedMutatorWithArgs<A>): ApiMutator<User, unknown> {
  return (tx, raw, ctx) => {
    const a = gen.args.parse(raw);
    if (/\bspam\b/i.test(a.title)) throw new Error("the word 'spam' is not allowed"); // throw → reject
    return runSharedMutation(gen, a, sharedCtx(ctx), tx);
  };
}
```

`ServerMutationTx` carries both surfaces: the logical
`tx.insert / update / upsert / insertIgnore / delete / row` (rendered to dialect
SQL) *and* the raw `tx.exec`. What stays explicit is exactly your server-only
authority surface — everything else is the one shared body.

There are **two rejection shapes**, and both make the client's optimistic write
disappear correctly:

- **Hard reject** — the mutator body (or a guard / arg parse) **throws**. The API
  server calls the write-master's `/reject-mutation`; the client's `onRejected` fires and
  the optimistic row snaps back. (In the example, a title containing the word "spam"
  throws.)
- **Accepted-but-no-op** — the run legitimately changes nothing
  (the `... AND ownerId = ?` guard above, or a shared body whose read-guard
  `return`s early). The write is accepted; when the empty
  authoritative result syncs in, the optimistic change rebases away on its own.

## The server

`createRindleApiServer` ties the queries, the mutators, the daemon client, and your
authorizers together. In the [one topology](/docs/deploy) the `daemon` is a
`SplitDaemonClient`: it routes **reads** (`materialize`/`query`) to a `rindled`
follower and **writes** (`executeSqlTxn`/`migrate`/sessions) to the
`rindle-replicator` write-master — you hand it one `HttpRindleDaemonClient` per leg:

```ts
import { createRindleApiServer, SplitDaemonClient } from "@rindle/api-server";
import { HttpRindleDaemonClient } from "@rindle/daemon-client";
import { schema } from "../shared/app-def.ts";

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}` },
});
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 dialect-SQL renderer for the mutators' logical ops
  queries: apiQueries,
  mutators: apiMutators,
  authorizeQuery: ({ user }) => typeof user === "string" && user.length > 0,
  authorizeMutation: ({ user }) => typeof user === "string" && user.length > 0,
});
```

> The `daemon` option accepts any `RindleDaemonClient`. A `rindled` follower serves no
> write endpoints, so a synced app hands it a `SplitDaemonClient` with the write-master
> on the writes leg; a single `HttpRindleDaemonClient` only works against a node that
> accepts both.

> The `schema` option is what lets the server render a `yield tx.insert(...)`
> into dialect-correct SQL. `postgresBackend(...)` remains a low-level library adapter,
> but Postgres relay/source deployment is not part of the current supported topology;
> synced deployments send authoritative mutations to the HCTree write-master.

`authorizeQuery` / `authorizeMutation` run before a query or mutation resolves; return
`false` to forbid (a `403`). They receive the full request, not just the user —
`{ user, name, args, context }` for a query and `{ user, envelope, context }` for a
mutation — so you can gate on the query name, its args, or the mutation envelope, then
destructure what you need. The server exposes `api.routes` (`{ query, read, mutate }`,
defaulting to `/api/rindle/query`, `/api/rindle/read`, `/api/rindle/mutate` — the routes
the client posts to) and a JSON handler for each.

## Bring your own HTTP

`@rindle/api-server` is **transport-agnostic**. It gives you `handleQueryJson(body,
ctx)` and `handleMutateJson(body, ctx)`; you own the HTTP. That is what makes it
serverless-shaped — wire it into `node:http`, a Cloudflare Worker, a Lambda,
anything:

```ts
import { createServer } from "node:http";

createServer((req, res) => {
  void (async () => {
    const body = JSON.parse(await readBody(req));
    const ctx = { user: req.headers["x-user"], request: req }; // verify a JWT here in prod
    try {
      const out =
        req.url === api.routes.query  ? await api.handleQueryJson(body, ctx)  :
        req.url === api.routes.read   ? await api.handleReadJson(body, ctx)   : // optional one-shot read
        req.url === api.routes.mutate ? await api.handleMutateJson(body, ctx) :
        notFound();
      res.writeHead(200, { "content-type": "application/json" });
      res.end(JSON.stringify(out));
    } catch (err) {
      res.writeHead(statusOf(err), { "content-type": "application/json" });
      res.end(JSON.stringify({ error: String(err) }));
    }
  })();
}).listen(7700);

// The three helpers are yours (they're plumbing, not Rindle API):
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);
  });
}
function notFound(): never { throw Object.assign(new Error("not found"), { status: 404 }); }
function statusOf(err: unknown): number {
  return typeof (err as { status?: number })?.status === "number" ? (err as { status: number }).status : 500;
}
```

`handleQueryJson` returns a lease (`{ leaseToken, materializationId, … }`) the client
uses to open its ws subscription; `handleReadJson` resolves the same named query **once**
and returns the rows — no subscription, for [SSR](/docs/ssr) or a preload;
`handleMutateJson` accepts one envelope or an in-order batch (`{ envelopes: [...] }`)
and returns the per-mutation outcome. Batched envelopes run strictly sequentially; a
**policy rejection still advances the daemon's mutation id**, so later envelopes stay
contiguous and keep applying. A daemon *error* fails the whole batch: the client
retries it and the daemon's per-mutation dedup absorbs the already-applied prefix.

On a Web-standard runtime (a Cloudflare Worker, Deno, TanStack Start, Hono, a
Lambda behind a `Request` adapter) the same three handlers wire to
`Request`/`Response` just as directly:

```ts
export async function handleRindle(kind: "query" | "read" | "mutate", request: Request): Promise<Response> {
  try {
    const body = await request.json().catch(() => ({}));
    const ctx = { user: await verifyUser(request), request };
    const out =
      kind === "query"  ? await api.handleQueryJson(body, ctx)  :
      kind === "read"   ? await api.handleReadJson(body, ctx)   :
                          await api.handleMutateJson(body, ctx);
    return Response.json(out);
  } catch (err) {
    const status = (err as { status?: number })?.status ?? 500;
    return Response.json({ error: String(err instanceof Error ? err.message : err) }, { status });
  }
}
```

## Pinned queries & the one-shot read

A materialized view **is a cache** — one the engine keeps exact on every write.
This is the server-side cache pattern: pin the hot queries so they stay warm with
zero subscribers, then serve them with a one-shot read instead of a subscription.
It's how one daemon holds a public page — a forum's topic list, a storefront, a
leaderboard — with no TTLs and no invalidation code.

### Pinning

A normal materialization lives while someone holds a lease. `pinnedQueries` names
the queries to keep regardless: `assertPins()` materializes each with a **pinned
policy** that survives zero subscribers, so the first request of the day reads a
result that is already maintained — as warm as the thousandth:

```ts
// The reads leg of the SplitDaemonClient — pins materialize on the follower, and its
// boot id drives the re-warm. (Pass the wrapping SplitDaemonClient as `daemon` below.)
const reads = new HttpRindleDaemonClient({
  baseUrl: process.env.RINDLE_DAEMON_URL ?? "http://127.0.0.1:7600",       // the follower
  headers: { authorization: `Bearer ${process.env.RINDLE_DAEMON_TOKEN}` },
  // Fires on the first response and again after every follower restart; must not
  // throw. A follower keeps no durable materialization state, so this hook is
  // what re-warms the pins after a restart.
  onBootId: () => void api.assertPins().catch(console.error),
});

const api = createRindleApiServer<User>({
  daemon: new SplitDaemonClient(writeMaster, reads),  // writeMaster = the write-master leg (see "The server")
  queries: apiQueries,
  pinnedQueries: [{ name: "topicsPage", args: { limit: 50 } }],
});

await api.assertPins(); // materialize once — warm before the first request
```

Three properties to lean on:

- **Idempotent.** The daemon dedupes by canonical query, so a re-assert reuses the
  existing materialization — call it at startup and from `onBootId`, freely.
- **Viewer-independent.** Pins are shared by every reader, so they resolve under
  the `pinUser` option (default `undefined`), never a per-viewer identity — don't
  pin a context-scoped query like `myIssues`.
- **Cheap to hold.** A pin costs O(change) maintenance per write, once, shared by
  every viewer — versus O(table) × readers for re-running the query per request.
  "Stale" isn't a reachable state: the contract is *view-after-write == fresh-query*.

### The one-shot read

`handleReadJson` parses `{ name, args }` and runs `readQuery`: the **same authority
path as a lease** — named-query resolution, `authorizeQuery`, context injection —
but the daemon serializes the current view **once** and returns assembled rows.
No subscriber is registered, so a dropped page render leaks nothing; an unpinned
query's pipeline self-reclaims after the idle TTL (`readIdleTtlMs`) unless the
browser's follow-up subscribe lands first. Against a **pinned** query it is a pure
read of the warm materialization — the endpoint a public page or edge function
calls (the `api.routes.read` branch in the HTTP example above).

The response is `{ rows, cvMin, queryKey }` — assembled, nested-by-name rows plus
the watermark [SSR](/docs/ssr) uses to hand a preloaded page off to a live client.
Follower placement stays in the affinity ticket; the response never swaps the
client to a per-follower WebSocket endpoint.

### Across a fleet

A single follower materializes each pin once. In a fleet, provide the operator-side
`pinFanout` hook and `assertPins()` pushes every pin to **all live followers** — a
per-viewer affinity ticket still routes each lease to exactly one. See
[Scaling reads](/docs/cloud-scaling).

## Talking to the daemon

`@rindle/daemon-client` is the typed client for the control plane.
`HttpRindleDaemonClient` covers the whole surface — `materialize`,
`executeSqlTxn`, `rejectMutation`, `dematerialize` — and the API server drives it for
you through the `SplitDaemonClient` (reads → the follower, writes → the write-master).
You'll reach for it directly for **bulk, out-of-band writes** like seeding — and a
write goes to the **write-master**, not a follower:

```ts
import { HttpRindleDaemonClient } from "@rindle/daemon-client";

const master = new HttpRindleDaemonClient({
  baseUrl: process.env.RINDLE_REPLICATOR_URL ?? "http://127.0.0.1:7611",  // the write-master
  headers: { authorization: `Bearer ${token}` },
});

// One idempotency-keyed transaction; the write-master won't re-apply it on restart,
// so a seed script is safe to run every boot.
await master.executeSqlTxn({
  idempotencyKey: "seed-issues-v1",
  statements: [{ sql: "INSERT INTO issue (...) VALUES (?, ?, ...)", params: [/* … */] }],
});
```

(Batch multi-row `INSERT`s at ≤100 rows per statement — SQLite caps a statement
at 999 bound parameters.)

`HttpRindleDaemonClient` also surfaces the **boot id**: pass `onBootId` on the
follower (reads) leg and it fires whenever that follower restarts — the re-warm hook the
[pinned-queries section](#pinned-queries-the-one-shot-read) wires to `api.assertPins()`.

## Scope

The API tier is **your** code — these packages give you the request handlers and the
typed daemon client, not a framework. Auth, rate limiting, and multi-tenancy live
here, in front of the control plane the browser can never reach directly.

## Next steps

- [Run the daemon](/docs/daemon) — the control plane this tier calls and the ws
  plane the client subscribes to.
- [Isomorphic mutators](/docs/mutators) — the shared bodies this tier drives: the
  op vocabulary, reads, and the determinism rules.
- [The browser client](/docs/client) — the tier that predicts these same mutator
  bodies optimistically.
- [Server rendering](/docs/ssr) — one-shot reads through this same authority for
  first paint.
- [Scaling reads](/docs/cloud-scaling) — follower affinity and pin fan-out across a
  read fleet.
- [Full app: the issue tracker](/docs/example-issue-tracker) — a complete API tier
  with auth, cursor validation, and both rejection paths.
- [Supported queries](/docs/supported-queries-ts) — the query shapes your resolvers can
  return.
- [Connect your app to Rindle Cloud](/docs/cloud-connect) — the same wiring with the
  `baseUrl` and token pointed at a managed daemon.

---

[View this page on Rindle](https://rindle.sh/docs/api-server)
