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. It does four things:
- Authenticates the caller (your session/JWT, however you do it).
- Resolves named queries to real query ASTs — validating args, and adding any tenancy/auth filters the client can’t be trusted to apply.
- Drives the same mutators the browser predicted — one shared body per name, its logical ops rendered to approved SQL under this tier’s authority.
- Talks to the data tier through one application-facing Rindle ingress — query leases, materializations, rooms, and authoritative mutation SQL are routed to the right internal tier.
It holds no data and no live state. Applications normally import only
@rindle/api-server; it owns the request handlers and its internal transports.
Import the lower-level clients directly only for an advanced split/custom deployment or standalone
SQL outside this server.
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). 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:
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:
// 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:
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 — 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:
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.sqlis the transaction-bound raw escape hatch; the client predicts the plain shared op and snaps back if the authority disagrees.
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);
await tx.sql.execute("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 raw tx.sql.execute / batch / query. Raw queries run against the same
open transaction and therefore read their own writes. The older synchronous
tx.exec(sql, params) remains as a compatibility shorthand for queuing a raw
write. These server-only methods are intentionally absent from the shared/browser
transaction: what stays explicit is exactly your 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’sonRejectedfires 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-guardreturns early). The write is accepted; when the empty authoritative result syncs in, the optimistic change rebases away on its own.
Work outside the mutation transaction
Wrap a server-only override in scoped when it must do work before or after the one
authoritative transaction. scope.transact supplies the transaction-bound
ServerMutationTx; scope.sql is the corresponding raw-SQL escape hatch
outside that boundary:
import { scoped } from "@rindle/api-server";
const importIssue = scoped(async (scope, args: { key: string; issueId: string }) => {
const [staged] = await scope.sql.query<{ payload: string }>(
"select payload from staged_import where key = ?",
[args.key],
);
if (!staged) throw new Error("unknown staged import");
await scope.sql.execute("insert into import_attempt (key) values (?) on conflict do nothing", [args.key]);
await scope.transact((tx) =>
tx.sql.execute("insert into issue (id) values (?)", [args.issueId]),
);
});
Every scope.sql call commits independently. It can remain visible if
scope.transact later rejects or fails, and it may run again when the client retries
an envelope. Give outside writes their own unique/idempotency key (the example’s
unique import_attempt.key); domain writes normally belong on the tx.sql inside
scope.transact. A database failure from either facade remains an infrastructure
failure, so the API server does not advance lmid and the client can retry.
The server
createRindleApiServer ties the queries, mutators, database connection, daemon client, and your authorizers
together. A Rindle is both layers at once — the SQL database below and the sync/IVM control plane
above — served by one ingress, so the simplest configuration is the unified connection: one URL,
one key.
import { createRindleApiServer } from "@rindle/api-server";
import { schema } from "../shared/app-def.ts";
const api = createRindleApiServer<User>({
rindle: {}, // one URL, one key — resolved from RINDLE_URL + RINDLE_DATABASE_TOKEN
// (both exported by `rindle dev`); or pass { url, token } explicitly
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,
});
rindle derives both trusted transports against that single origin: the database layer for
authoritative mutations through the HCTree master’s SQL facade, and the daemon layer for query
leases, materialization, and rooms. Both send the same server-only bearer. The fleet edge routes
each request to the right process; applications do not manage separate master/follower URLs or
credentials.
The query-lease response also carries the public wsEndpoint and the follower’s opaque affinity
ticket. The optimistic browser client opens that endpoint lazily, so the app needs no separate
runtime-config route or browser WebSocket variable. Pass rindle.wsUrl only when the public
WebSocket ingress cannot be derived from the HTTP URL.
Explicit daemon, database, sql, and backend options remain advanced escape hatches for a
custom self-hosted router, injected test transport, or deliberately split trust domains. An
explicit field wins over the corresponding derived transport. Normal local and Rindle Cloud apps
should use rindle: {} or rindle: { url, token } and should not mint a second public URL or token.
When
databaseis present (configured explicitly or derived fromrindle), the API server constructs, owns, and uses its SQL client as the default mutation backend; callapi.close()during shutdown. An explicitbackendstill wins. Omitting bothrindleanddatabasepreserves the legacydaemonBackend(daemon)behavior. The advancedsqloption accepts an already-created, caller-ownedSqlSession, but normal applications do not need to import a second package. Ordinary SQL calls never synthesize a mutation id: only the explicit mutation facade receives the envelope’s{ clientId, mid }, andlmidremains server-owned output.
The
daemonoption accepts anyRindleDaemonClient. Keep a write-master leg when using room or lifecycle features that mutate control-plane state; a follower-only client is sufficient for an app that uses only named-query reads/materializations alongside the SQL mutation path.
The
schemaoption is what lets the server render ayield 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:
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 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 write-master’s mutation id, so later envelopes stay
contiguous and keep applying. A mutation transport error fails the whole batch: the client
retries it and the write-master’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:
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:
const api = createRindleApiServer<User>({
rindle: {}, // RINDLE_URL + RINDLE_DATABASE_TOKEN
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 follower dedupes by canonical query, so a re-assert reuses the existing materialization — call it at startup and after a follower boot-id change.
- Viewer-independent. Pins are shared by every reader, so they resolve under
the
pinUseroption (defaultundefined), never a per-viewer identity — don’t pin a context-scoped query likemyIssues. - 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 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
Deploying & scaling. Managed Rindle Cloud does not expose a
multi-follower plan yet.
Talking to Rindle
Normal applications configure rindle and let the API server own both clients.
@rindle/daemon-client is the lower-level typed control client;
HttpRindleDaemonClient and SplitDaemonClient remain useful for custom self-hosted
routing, boot-id hooks, control-plane features, and compatibility.
For ordinary bulk, out-of-band writes like seeding, use the SQL client against the same unified ingress:
import { createSqlClient } from "@rindle/sql-client";
const sql = createSqlClient({
url: process.env.RINDLE_URL!,
authToken: process.env.RINDLE_DATABASE_TOKEN!,
});
await sql.batch([
{ sql: "INSERT INTO issue (...) VALUES (?, ?, ...)", args: [/* … */] },
]);
(Batch multi-row INSERTs at ≤100 rows per statement — SQLite caps a statement
at 999 bound parameters.)
HttpRindleDaemonClient also surfaces the boot id. An advanced explicit client can
pass onBootId and call api.assertPins() whenever its follower restarts; see the
daemon’s recovery hook.
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 Rindle ingress the browser can never reach directly.
Next steps
- Run the daemon — the control plane this tier calls and the ws plane the client subscribes to.
- Isomorphic mutators — the shared bodies this tier drives: the op vocabulary, reads, and the determinism rules.
- The browser client — the tier that predicts these same mutator bodies optimistically.
- Server rendering — one-shot reads through this same authority for first paint.
- Deploying & scaling — follower affinity and pin fan-out across a self-hosted read fleet.
- Synced-app quickstart — the API tier wired end to end with the browser client and data tier.
- Supported queries — the query shapes your resolvers can return.
- Connect your app to Rindle Cloud — the same
rindlewiring with managedRINDLE_URLandRINDLE_DATABASE_TOKENvalues.