@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.
The server
This example uses the schema, issuesPageQuery, and shared mutators from the
browser client guide. 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:
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:
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:
// 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:
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.
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 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:
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:
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 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:
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 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 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 before using it.
For bulk jobs, scripts, or writes with no browser prediction, use ordinary SQL. The background-write guide explains transactional idempotency and mutation-envelope differences.
Next steps
- The browser client — connect the shared query and mutator contract.
- Isomorphic mutators — define deterministic shared operations.
- Pinned queries — maintain server results between requests.
- Server rendering — seed pages through one-shot reads.
- Deploying and scaling — choose the data-tier topology.