Skip to content
Reference contents

ReferenceClients & servers

The API server

Resolve authorized named queries, run authoritative mutators, and serve one-shot reads from the Rindle data tier.

View as Markdown

@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