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 over the bearer-authed control plane — query leases
and reads to a
rindledfollower, writes to therindle-replicatorwrite-master (aSplitDaemonClientroutes each leg).
It holds no data and no live state. Two packages do the work:
@rindle/api-server (the request handlers) and
@rindle/daemon-client (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). 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.exec(sql, params)is the 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);
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’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.
The server
createRindleApiServer ties the queries, the mutators, the daemon client, and your
authorizers together. In the one topology 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:
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
daemonoption accepts anyRindleDaemonClient. Arindledfollower serves no write endpoints, so a synced app hands it aSplitDaemonClientwith the write-master on the writes leg; a singleHttpRindleDaemonClientonly works against a node that accepts both.
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 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:
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:
// 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
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
Scaling reads.
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:
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 INSERTs 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 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 — 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.
- Scaling reads — follower affinity and pin fan-out across a read fleet.
- Full app: the issue tracker — a complete API tier with auth, cursor validation, and both rejection paths.
- Supported queries — the query shapes your resolvers can return.
- Connect your app to Rindle Cloud — the same wiring with the
baseUrland token pointed at a managed daemon.