Build a synced app

The API server

Your app's authority — a stateless, serverless-shaped tier that authenticates the caller, resolves named queries to ASTs, drives the same isomorphic mutators the browser predicted into SQL, and keeps pinned queries warm for one-shot reads.

View as Markdown

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:

  1. Authenticates the caller (your session/JWT, however you do it).
  2. Resolves named queries to real query ASTs — validating args, and adding any tenancy/auth filters the client can’t be trusted to apply.
  3. Drives the same mutators the browser predicted — one shared body per name, its logical ops rendered to approved SQL under this tier’s authority.
  4. Talks to the data tier over the bearer-authed control plane — query leases and reads to a rindled follower, writes to the rindle-replicator write-master (a SplitDaemonClient routes 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’s onRejected fires 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-guard returns 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 daemon option accepts any RindleDaemonClient. A rindled follower serves no write endpoints, so a synced app hands it a SplitDaemonClient with the write-master on the writes leg; a single HttpRindleDaemonClient only works against a node that accepts both.

The schema option is what lets the server render a yield 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 pinUser option (default undefined), never a per-viewer identity — don’t pin a context-scoped query like myIssues.
  • 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