# The API server

Resolve authorized named queries, run authoritative mutators, and serve one-shot reads from the Rindle data tier.

`@rindle/api-server` connects your application rules to the Rindle data tier.
It resolves named queries, authorizes requests, runs authoritative mutators, and
serves one-shot reads. It works in a persistent process or a serverless handler.

Your HTTP adapter authenticates the caller and supplies a verified user context.
The package does not verify sessions or JWTs for you. The data tier stores rows,
maintains queries, and serves the browser's authorized WebSocket subscriptions.

Use this package for a synced app or authorized server read models. For ordinary
SQL without that application protocol, use [Rindle SQL](https://rindle.sh/docs/sql-client).

## The server

This example uses the schema, `issuesPageQuery`, and shared mutators from the
[browser client guide](https://rindle.sh/docs/client#define-the-shared-query-and-writes). Its issue
list is shared by all authenticated users. Add row-level filters when your app
has private rows.

Define the server's user type in `server/auth.ts`:

```ts
export type User = string | undefined;

export function requireUser(user: User): string {
  if (!user) throw new Error("authentication required");
  return user;
}
```

Put the server configuration in `server/api.ts`:

```ts
import {
  createRindleApiServer,
  registerQueries,
  sharedApiMutators,
} from "@rindle/api-server";
import { issuesPageQuery, mutators, schema } from "../shared/client-example.ts";

import { requireUser } from "./auth.ts";
import type { User } from "./auth.ts";

export const api = createRindleApiServer<User>({
  rindle: {},
  schema,
  queries: registerQueries<User>([issuesPageQuery]),
  mutators: sharedApiMutators(mutators, ({ user }) => ({ user: requireUser(user) })),
  authorizeQuery: ({ user }) => Boolean(user),
  authorizeMutation: ({ user }) => Boolean(user),
});
```

`rindle: {}` reads `RINDLE_URL` and `RINDLE_DATABASE_TOKEN` from the server
environment. `rindle dev` supplies them locally. You can pass
`rindle: { url, token }` explicitly instead. Keep the token on the server.

The unified connection creates both the SQL mutation transport and the daemon
query-control client. A standalone deployment sends both to one daemon. A fleet
edge routes writes to the master and query operations to a follower.

`schema` is required for logical mutator operations such as `tx.update` and
`tx.row`. Query-only servers and raw-SQL mutators can omit it. The query registry
still needs a schema to construct its queries.

The API instance owns clients that it creates. Call `api.close()` at application
shutdown. Injected clients keep their caller-owned lifecycle.

## Named queries → ASTs

`registerQueries` accepts shared `defineQuery` values. It validates each request's
arguments and builds the authoritative query AST. The browser sends the name and
arguments; it does not supply an AST for the server to trust.

A query definition need not live next to a React component. Keep it in a shared
module with no browser-only imports. Both the browser and server must register
compatible versions of its name, arguments, and result shape.

### Queries scoped to the current user

A context-scoped query accepts its context separately from its arguments. For
the quickstart's `issue.ownerId` column, define:

```ts
// shared/my-issues.ts
import { defineQuery, newQueryBuilder } from "@rindle/client";
import { z } from "zod";
import { schema } from "./schema.gen.ts";

const q = newQueryBuilder(schema);
export const myIssuesQuery = defineQuery(
  "myIssues",
  z.object({ limit: z.number().int().min(1).max(100) }).parse,
  ({ limit }, ctx: { user: string | undefined }) =>
    q.issue.where.ownerId(ctx.user ?? "").orderBy("createdAt", "desc").limit(limit),
);
```

Add `myIssuesQuery` to `registerQueries<User>([...])`. The server supplies its
`ApiContext` (`{ user, request }`) as the query's context. The browser calls it as
`myIssuesQuery({ limit: 20 }, { user: sessionUserID })` to construct its local
query. Only `{ name, args }` crosses the query wire; the server derives its user
from authentication again. Keep the authorization gate so an absent user cannot
read rows through the example's empty-string fallback.

### Additional server filters

When the server must add a filter, replace that name's resolver with a
`defineApiQueries` entry. This is an alternative `queries` map for `server/api.ts`:

```ts
import { defineApiQueries, registerQueries } from "@rindle/api-server";
import type { ApiQueries } from "@rindle/api-server";
import { issuesPageQuery } from "../shared/client-example.ts";
import { requireUser } from "./auth.ts";
import type { User } from "./auth.ts";

export const privateQueries = defineApiQueries<User, ApiQueries<User>>({
  ...registerQueries<User>([issuesPageQuery]),
  issuesPage: (ctx, args) =>
    issuesPageQuery.resolve(args).where.ownerId(requireUser(ctx.user)),
});
```

Pass `privateQueries` as the API server's `queries` option. The later object
entry replaces the shared resolver under the same name. `resolve(args)` runs its validator
before the additional filter is applied.

Encode data visibility in the authoritative query. A routing hint or affinity
ticket is not a row-level authorization rule. The daemon can share a
materialization when the canonical query and visibility scope match.
`subject` and `routingKey` also affect placement and one-shot read reuse; see
[Server rendering](https://rindle.sh/docs/ssr).

The daemon can group eligible queries that differ in a root equality value into
a parameterized query family. That optimization preserves each subscriber's
result; it does not replace authorization. The daemon's `queryFamilies` option
controls it.

## Driving the shared mutators

`sharedApiMutators` turns the browser's shared registry into authoritative server
handlers. It parses untrusted arguments through each mutator's `.args`, injects
the server's authenticated `ctx.user`, and drives the generator's logical
operations in one transaction.

The browser predicts against local rows. The server runs against authoritative
rows, so a read-dependent body can produce a different result. Rebase reconciles
that difference. See [Isomorphic mutators](https://rindle.sh/docs/mutators) for the shared contract.

On the standard SQL mutation backend, pure writes use one request. A mutator's
first read opens an interactive mutation transaction. Its earlier writes, reads,
later writes, and mutation watermark commit together. The protocol deduplicates
replayed mutation IDs and rejects gaps.

### Server-only authority

Override a mutator by name when the server must apply an additional rule. For
example, this alternative registry adds a title rule to the shared body:

```ts
import { runSharedMutation, sharedApiMutators } from "@rindle/api-server";
import type { ApiMutators } from "@rindle/api-server";
import { mutators } from "../shared/client-example.ts";
import { requireUser } from "./auth.ts";
import type { User } from "./auth.ts";

export const guardedMutators: ApiMutators<User> = {
  ...sharedApiMutators(mutators, ({ user }) => ({ user: requireUser(user) })),
  setTitle: (tx, raw, ctx) => {
    const args = mutators.setTitle.args.parse(raw);
    if (/\bspam\b/i.test(args.title)) throw new Error("title is not allowed");
    return runSharedMutation(mutators.setTitle, args, { user: requireUser(ctx.user) }, tx);
  },
};
```

Pass `guardedMutators` as the API server's `mutators` option.

`ServerMutationTx` also provides transaction-bound raw SQL through
`tx.sql.execute`, `tx.sql.batch`, and `tx.sql.query`. Use that surface for
server-only relational work. Raw reads see the transaction's earlier writes.
These methods are absent from the shared browser transaction.

A policy exception rejects the mutation and rolls back its application changes.
The authority still advances its mutation watermark so the browser can remove
the prediction. An accepted no-op also settles the prediction, but it does not
produce `onRejected`. Infrastructure failures remain retryable failures; they
do not become business rejections merely because a request failed.

### Work outside the mutation transaction

A `scoped` mutator can do work before or after its one authoritative transaction.
`scope.transact` opens that transaction. Each `scope.sql` call outside it commits
independently.

This example assumes tables `import_attempt(key TEXT PRIMARY KEY)` and
`import_job(key TEXT PRIMARY KEY, status TEXT NOT NULL)` already exist:

```ts
import { scoped } from "@rindle/api-server";
import { z } from "zod";

const importArgs = z.object({ key: z.string() });
export const finishImport = scoped(async (scope, raw: unknown) => {
  const { key } = importArgs.parse(raw);
  await scope.sql.execute(
    "insert into import_attempt (key) values (?) on conflict do nothing",
    [key],
  );
  await scope.transact((tx) =>
    tx.sql.execute("update import_job set status = 'complete' where key = ?", [key]),
  );
});
```

An outside write can remain visible when the mutation transaction later fails.
Outside work can run again on envelope replay, even if the authoritative
transaction is absorbed. Give it an explicit idempotency rule. The example's
unique attempt key makes its insert safe to repeat.

A clean return from `scope.transact` follows the authoritative commit. A later
failure cannot undo that commit. `onScopeError` reports such failures;
without a handler, the API server logs them. For external effects that must
survive a process crash, use your application's durable job or outbox mechanism.
See [Streaming LLM responses](https://rindle.sh/docs/llm-streams) for a scoped workflow.

## Bring your own HTTP

The package provides `handleQueryJson`, `handleReadJson`, and `handleMutateJson`.
It does not start an HTTP listener. Its default route names are:

| Route | Handler | Result |
| --- | --- | --- |
| `/api/rindle/query` | `handleQueryJson` | A query lease and connection metadata |
| `/api/rindle/read` | `handleReadJson` | `{ rows, cvMin, queryKey }` |
| `/api/rindle/mutate` | `handleMutateJson` | A mutation verdict or list of verdicts |

The following Web-standard adapter takes your authentication function as an
explicit dependency. Mount its returned handler in a framework or server that
uses `Request` and `Response`:

```ts
import { RindleApiError } from "@rindle/api-server";
import { api } from "./api.ts";
import type { User } from "./auth.ts";

export function createHandler(authenticate: (request: Request) => Promise<User>) {
  return async (request: Request): Promise<Response> => {
    const path = new URL(request.url).pathname;
    if (![api.routes.query, api.routes.read, api.routes.mutate].includes(path)) {
      return new Response("Not found", { status: 404 });
    }
    if (request.method !== "POST") {
      return new Response("Method not allowed", { status: 405, headers: { allow: "POST" } });
    }

    let body: unknown;
    try {
      body = await request.json();
    } catch {
      return Response.json({ error: "Invalid JSON" }, { status: 400 });
    }

    try {
      const context = { user: await authenticate(request), request };
      const result = path === api.routes.query
        ? await api.handleQueryJson(body, context)
        : path === api.routes.read
          ? await api.handleReadJson(body, context)
          : await api.handleMutateJson(body, context);
      return Response.json(result);
    } catch (error) {
      if (error instanceof RindleApiError) {
        return Response.json({ error: error.message }, { status: error.status });
      }
      console.error(error);
      return Response.json({ error: "Request failed" }, { status: 500 });
    }
  };
}
```

`authenticate` must verify your session or token. An unverified `x-user` header
is not authentication. If your framework already authenticates requests, pass
that verified identity into the same handler context.

A denied query throws a 403 API error. A denied mutation becomes a rejected
mutation verdict and still advances the watermark. It normally returns through
the successful JSON handler response, so inspect the verdict.

`handleMutateJson` accepts one envelope or `{ envelopes: [...] }`. A batch runs
in order. If a transport failure interrupts it, the client retries; the standard
SQL backend absorbs the already-applied prefix.

## Pinned queries & the one-shot read

A [pinned query](https://rindle.sh/docs/pinned-queries) keeps its maintained result with no
subscribers. Configure dedicated public queries in `pinnedQueries`, then call
`await api.assertPins()` at startup. The linked guide provides a complete example.

Pins resolve under `pinUser` (default `undefined`), without a per-request
context. They consume memory and maintenance work while idle. Do not substitute
a shared pin for per-request authorization.

`assertPins()` is idempotent for the same canonical query. Daemon materializations
are not durable across restarts, so reassert pins after a boot-ID change.
`pinFanout` can assert them on every live follower; without it, the configured
daemon connection receives the requests.

The current lease path also pins other argument combinations leased under any
name listed in `pinnedQueries`. Use fixed or tightly bounded arguments for those
names to avoid retaining unbounded results.

A one-shot read passes the same named-query resolution and authorization as a
lease. It reuses a pin only when the canonical query and visibility scope match.
An unpinned read can keep a temporary materialization for `readIdleTtlMs`.
Neither path creates a subscriber.

The result contains `rows`, `cvMin`, and `queryKey`. It describes the changes
applied by the serving engine; a follower can lag the write authority. SSR uses
this result to seed a page before browser handoff. See
[Server rendering](https://rindle.sh/docs/ssr) for readiness, visibility, and affinity.

## Talking to Rindle

Ordinary applications configure `rindle` and let the API server own its
transports. Advanced integrations can inject `daemon`, `database`, `sql`, or a
mutation `backend`. Explicit fields override their corresponding derived
transport. `database` creates an owned SQL client; `sql` accepts a caller-owned
session. Without either, the legacy `daemonBackend` is the fallback.

`HttpRindleDaemonClient` and `SplitDaemonClient` from `@rindle/daemon-client`
provide custom control-plane connections. Keep the appropriate write-control
connection when using room or lifecycle operations. A read-only follower client
can serve named-query reads alongside a separate SQL mutation backend.

`postgresBackend` is a separate preview integration. It runs mutators against
Postgres while a gateway feeds Rindle followers. Its query and replay limitations
differ from the standard SQL backend; read
[Postgres as the source of truth](https://rindle.sh/docs/postgres-source) before using it.

For bulk jobs, scripts, or writes with no browser prediction, use ordinary SQL.
The [background-write guide](https://rindle.sh/docs/background-writes) explains transactional
idempotency and mutation-envelope differences.

## Next steps

- [The browser client](https://rindle.sh/docs/client) — connect the shared query and mutator contract.
- [Isomorphic mutators](https://rindle.sh/docs/mutators) — define deterministic shared operations.
- [Pinned queries](https://rindle.sh/docs/pinned-queries) — maintain server results between requests.
- [Server rendering](https://rindle.sh/docs/ssr) — seed pages through one-shot reads.
- [Deploying and scaling](https://rindle.sh/docs/deploy) — choose the data-tier topology.

---

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