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 daemon’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.
- Runs the authoritative mutators into approved SQL — the twin of the client’s predicted mutators, sharing names but never effects.
- Talks to the daemon over the bearer-authed control plane to mint query leases and execute writes.
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
defineApiQueries maps each query name to a function of (ctx, args) that returns
a query AST. This is where the server decides what a name means — so a malformed
client can’t smuggle arbitrary values into the query, and you can scope results to
the authenticated user:
import { defineApiQueries } from "@rindle/api-server";
import type { ApiContext } from "@rindle/api-server";
import { newQueryBuilder } from "@rindle/client";
import { PAGE_SIZE, schema } from "../shared/app-def.ts";
const q = newQueryBuilder(schema);
const apiQueries = defineApiQueries<User>({
issuesPage: (ctx: ApiContext<User>, cursor: unknown) => {
const page = q.issue.orderBy("createdAt", "desc").orderBy("id", "asc").limit(PAGE_SIZE);
if (cursor == null) return page;
// validate the cursor SHAPE — never trust the client's value verbatim
if (!isIssueCursor(cursor)) throw new Error("bad cursor");
return page.start({ createdAt: cursor.createdAt, id: cursor.id }, { exclusive: true });
},
});
ctx is an ApiContext<User> — { user, request }, where user is whatever your
auth produced. The server-side query can be a superset of the client’s (add
.where.tenant(ctx.user.org), etc.); the client only ever sends { name, args }.
Authoritative mutators → SQL
defineApiMutators maps each mutator name to (tx, args, ctx). You build the write
with tx.exec(sql, params); the statements run in one transaction on the
daemon. This is the authority — validate hard here, because the client’s prediction
is just a guess:
import { defineApiMutators } from "@rindle/api-server";
import type { MutationContext, SqlMutationTx } from "@rindle/api-server";
const apiMutators = defineApiMutators<User>({
createIssue: (tx: SqlMutationTx, a: CreateIssueArgs, ctx: MutationContext<User>) => {
const user = requireUser(ctx.user); // throw → the mutation is rejected
tx.exec(
`INSERT INTO issue (id, title, status, priority, owner, tags, comments, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[a.id, cleanTitle(a.title), a.status, a.priority, user,
JSON.stringify(a.tags), JSON.stringify([]), a.createdAt, a.createdAt],
);
},
// Ownership enforced IN the SQL: a non-owner's delete is accepted-but-no-op, and the
// client's optimistic delete snaps back when the (empty) authoritative change syncs in.
deleteIssue: (tx: SqlMutationTx, a: { id: string }, ctx: MutationContext<User>) =>
tx.exec("DELETE FROM issue WHERE id = ? AND owner = ?", [a.id, requireUser(ctx.user)]),
});
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.
There are two rejection shapes, and both make the client’s optimistic write disappear correctly:
- Hard reject — the mutator throws. The API server calls the daemon’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 mutator runs SQL that legitimately changes nothing
(the
... AND owner = ?guard above). 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. The daemon is an HttpRindleDaemonClient pointed at the
daemon’s control-plane (HTTP) port with the bearer token:
import { createRindleApiServer } from "@rindle/api-server";
import { HttpRindleDaemonClient } from "@rindle/daemon-client";
const api = createRindleApiServer<User>({
daemon: new HttpRindleDaemonClient({
baseUrl: "http://127.0.0.1:7600", // the daemon's httpPort
headers: { authorization: `Bearer ${process.env.RINDLE_DAEMON_TOKEN}` },
}),
queries: apiQueries,
mutators: apiMutators,
authorizeQuery: ({ user }) => typeof user === "string" && user.length > 0,
authorizeMutation: ({ user }) => typeof user === "string" && user.length > 0,
});
authorizeQuery / authorizeMutation run before resolving a query or a mutation;
return false to forbid (a 403). The server exposes api.routes
({ query, mutate }, defaulting to /api/rindle/query and /api/rindle/mutate —
the routes the client posts to) and two JSON handlers.
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.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);
handleQueryJson returns a lease ({ leaseToken, materializationId, … }) the
client uses to open its ws subscription; handleMutateJson accepts one envelope or
an in-order batch and returns the per-mutation outcome.
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 uses it for
you. You’ll reach for it directly for bulk, out-of-band writes like seeding:
import { HttpRindleDaemonClient } from "@rindle/daemon-client";
const daemon = new HttpRindleDaemonClient({
baseUrl: "http://127.0.0.1:7600",
headers: { authorization: `Bearer ${token}` },
});
// One idempotency-keyed transaction; the file-backed daemon won't re-apply it on
// restart, so a seed script is safe to run every boot.
await daemon.executeSqlTxn({
idempotencyKey: "seed-issues-v1",
statements: [{ sql: "INSERT INTO issue (...) VALUES (?, ?, ...)", params: [/* … */] }],
});
HttpRindleDaemonClient also surfaces the daemon’s boot id: pass onBootId and
it fires whenever the daemon restarts (it keeps no durable materialization state),
which is the hook to re-assert any pinned queries (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.
- The browser client — the predicted mutators these authoritative twins shadow.
- 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.