API index and search · Build metadata
Supporting declarations
packages/api-server/src/index.ts. These declarations explain referenced types. Only package-page symbols are package exports.
DEFAULT_RINDLE_API_ROUTES
export declare const DEFAULT_RINDLE_API_ROUTES: {
readonly query: "/api/rindle/query";
readonly read: "/api/rindle/read";
readonly mutate: "/api/rindle/mutate";
readonly applyRowChangeTxn: "/api/rindle/apply-row-change-txn";
readonly claimRoomEpoch: "/api/rindle/claim-room-epoch";
readonly roomLmids: "/api/rindle/room-lmids";
readonly roomBoot: "/api/rindle/room-boot";
readonly stream: "/api/rindle/stream";
};MaybePromise
export type MaybePromise<T> = T | PromiseLike<T>;ApiContext
/** Request context supplied by the application after authentication. The HTTP adapter or trusted
* caller owns `user`; do not populate it from an unverified request body. */
export interface ApiContext<User> {
user: User;
request?: unknown;
}ApiQueryResult
export type ApiQueryResult = Ast | Query<any, any, any>;ApiQuery
export type ApiQuery<User, Args> = (ctx: ApiContext<User>, args: Args) => MaybePromise<ApiQueryResult>;ApiQueries
export type ApiQueries<User> = Record<string, ApiQuery<User, any>>;RunQueryInput
export interface RunQueryInput<User> {
user: User;
name: string;
args: unknown;
query: ApiQuery<User, any>;
context: ApiContext<User>;
}RunQuery
export type RunQuery<User> = (input: RunQueryInput<User>) => MaybePromise<ApiQueryResult>;AuthorizeQueryInput
export interface AuthorizeQueryInput<User> {
user: User;
name: string;
args: unknown;
context: ApiContext<User>;
}AuthorizeMutationInput
export interface AuthorizeMutationInput<User> {
user: User;
envelope: MutationEnvelope;
context: ApiContext<User>;
}Authorizer
/** An optional request gate. Return `false` or throw to deny; `true` and `undefined` allow.
* This gate does not add row predicates or replace access checks inside a mutator. */
export type Authorizer<T> = (input: T) => MaybePromise<boolean | void>;MutationContext
export interface MutationContext<User> {
user: User;
envelope: MutationEnvelope;
daemon: RindleDaemonClient;
request?: unknown;
}ServerSql
/** A deliberately narrow raw-SQL facade exposed by the API server. On {@link ServerMutationTx}
* it is bound to the open mutation transaction; on {@link MutationScope} each call runs in its
* own transaction outside the mutation boundary. Column aliases should be unique: positional
* driver rows are keyed by column name, so a duplicate alias is represented by its last value. */
export interface ServerSql {
/** Queue/execute one statement. A transaction-bound call commits with the surrounding mutation. */
execute(sql: string, params?: readonly WireValue[]): Promise<void>;
/** Queue/execute an ordered statement batch. An empty batch is a no-op. On an
* outside-transaction surface ({@link MutationScope.sql}, `backend.outsideSql`) the batch MUST
* execute as ONE atomic transaction — all statements or none. Every built-in backend does (the
* daemon's `execute-sql-txn`, the sql-client's `/v1/sql/batch`, the Postgres plugger's
* BEGIN/COMMIT); a custom backend that loops statements without a transaction silently breaks
* the stream plane's chunk+CAS and compaction invariants, which ride single `batch` calls. */
batch(statements: readonly SqlStatement[]): Promise<void>;
/** Run a read and return rows keyed by their column names. */
query<Row = Record<string, unknown>>(sql: string, params?: readonly WireValue[]): Promise<Row[]>;
}SqlMutationTx
/** The raw-SQL escape hatch for relational/authority statements a keyed op can't express — an
* owner-gated cascade, a `NOT EXISTS` dedup. Prefer `tx.sql`; `exec` remains the synchronous
* compatibility shorthand for a queued `tx.sql.execute`, and `statements` is the raw write list. */
export interface SqlMutationTx {
readonly sql: ServerSql;
exec(sql: string, params?: WireValue[]): void;
readonly statements: readonly SqlStatement[];
}ServerMutationTx
/** The write handle a server mutator runs against — the ASYNC twin of the client's `MutationTx`. It
* is both the isomorphic {@link ServerWriteTx} logical surface (insert/update/upsert/insertIgnore/
* delete/row, rendered to dialect SQL) AND the legacy {@link SqlMutationTx} raw escape hatch. Both
* implementations run reads through the OPEN transaction (read-your-writes): Postgres executes
* everything live; the SQL-client and daemon adapters accumulate writes and lazily upgrade to an
* interactive mutation session at the first read (DAEMON-INTERACTIVE-TXN-DESIGN.md §5). */
export interface ServerMutationTx extends ServerWriteTx, SqlMutationTx {
/** Run a full query (a fluent `Query` or its wire `Ast`) INSIDE the open transaction —
* read-your-writes, like {@link ServerWriteTx.row} but for arbitrary shapes. Returns the
* parsed nested result tree: an array for a plural root, an object or `null` for a `.one()`
* root, with cells in their raw SQLite storage-class representations (the same vocabulary
* `row` speaks). Remote SQLite backends: compiled by `@rindle/query-compiler`'s sqlite dialect
* (bind params, NO casts — §5.4) and executed through the mutation session. Postgres: lands with
* the read-compiler catalog integration (POSTGRES-READ-COMPILER-DESIGN.md Phase B). */
query(q: Ast | Query<any, any, any>): Promise<unknown>;
}ApiMutatorResult
export type ApiMutatorResult = void | SqlStatement[] | SqlTxn;ApiMutator
/** A server mutator: a plain async function against the live {@link ServerMutationTx}. Two ways to
* write one (MUTATORS-ISOMORPHIC): drive it directly (the raw escape hatch — an owner-gated cascade,
* a `NOT EXISTS` dedup — plus a returned `SqlStatement[]`/`SqlTxn`), OR delegate to a SHARED generator
* (the SAME body the client predicts) via {@link runSharedMutation}, keeping only the server-only
* authority (arg parse, principal, policy) in the wrapper. */
export type ApiMutator<User, Args> = (tx: ServerMutationTx, args: Args, ctx: MutationContext<User>) => MaybePromise<ApiMutatorResult>;MutationRejected
/** Thrown by {@link MutationScope.transact} when the transacted body BUSINESS-rejects: the data
* rolled back and `lmid` advanced alone (§2.4). Catch it to COMPENSATE an outside-tx side effect
* (refund the charge), then rethrow or return — the mutation's protocol outcome is already sealed
* as rejected, so a post-reject throw can't change it. A DB/infra failure is NOT this — it
* propagates as the raw driver error (the client retries; `lmid` did not advance). */
export declare class MutationRejected extends Error {
readonly reason: string;
constructor(reason: string);
}MutationScope
/** The per-mutation server handle a {@link ScopedMutator} runs against. Code before {@link transact}
* runs OUTSIDE the transaction; code after a clean `transact` runs AFTER the commit. The
* `lmid`-always-advances invariant is the HARNESS's, not the author's: {@link RindleApiServer.pushMutation}
* seals the response from this handle's recorded outcome, so an early return, a never-called
* `transact`, or a swallowed {@link MutationRejected} still advances `lmid` and never wedges the
* client's pending queue. */
export interface MutationScope {
/** Raw SQL OUTSIDE the mutation transaction. Every call commits independently and therefore may
* be observed even if {@link transact} later rejects or fails. Calls may also repeat when an
* envelope is retried, so outside writes need their own idempotency key/unique constraint. */
readonly sql: ServerSql;
/** Open the ONE atomic write transaction and drive `body` inside it, committing (stamping `lmid`
* co-transactionally) on a clean return. MAY be called at most once — a second call throws.
*
* Two forms:
* - `transact(sharedMutator, args, ctx)` — drive a SHARED (generator) mutator (the same body the
* client predicts); pass the already-parsed `args` and the server `ctx` (fold server-only
* values like a charge id into `ctx` here).
* - `transact(run)` — a raw callback receiving the live {@link ServerMutationTx} (the escape
* hatch: `tx.exec`, logical writes, read-your-writes reads).
*
* A THROW from the body that is not a {@link BackendError} is a BUSINESS rejection: the data rolls
* back, `lmid` advances alone, and this method throws {@link MutationRejected} (so surrounding
* code can compensate). A {@link BackendError} is INFRA: it propagates (the client retries).
*
* **The callback form RETURNS ITS BODY'S VALUE**, and only on the committed path — a rejection
* throws, so there is never a value to act on for a transaction that rolled back. That is what
* lets a post-commit effect be decided by a TRANSACTIONAL read instead of a second, racy one
* afterwards:
*
* ```ts
* const kick = await scope.transact(async (tx) => {
* const prior = await tx.row("message", { id: a.assistantMessageId });
* if (prior) return undefined; // a replayed envelope — do NOT re-fire the effect
* tx.insert("message", …);
* return { streamId: a.assistantMessageId, history: await readHistory(tx, a.chatId) };
* });
* if (kick) void startGeneration(kick); // committed, and decided against committed state
* ```
*
* A tx-form mutator cannot do this: its return value is already the logical-write channel
* ({@link ApiMutatorResult}). A post-commit effect chosen by a transactional read is exactly what
* the scoped form is for. */
transact<T>(run: (tx: ServerMutationTx) => T | Promise<T>): Promise<T>;
transact<A, C extends MutatorCtx>(mutator: SharedMutator<A, C>, args: A, ctx: C): Promise<void>;
}ScopedMutator
/** A SCOPED server mutator (WORK-OUTSIDE-TX): server-only code, ONE `scope.transact`, optional
* post-commit code. Register it by wrapping in {@link scoped} — the tag the api-server routes on to
* hand it a {@link MutationScope} instead of running its whole body inside the transaction. */
export type ScopedMutator<User, Args> = (scope: MutationScope, args: Args, ctx: MutationContext<User>) => void | Promise<void>;ScopedApiMutator
/** A {@link ScopedMutator} tagged by {@link scoped} so the harness invokes it with a
* {@link MutationScope}. Typed as a BRANDED tx-form {@link ApiMutator} purely so it registers in the
* `mutators` record without widening it to a union (which would break contextual inference for every
* plain tx-form entry). Its true runtime shape is `(scope, args, ctx)`; the tag — not the type —
* routes it, and it is never actually called as a tx-form mutator. */
export type ScopedApiMutator<User, Args> = ApiMutator<User, Args> & {
readonly __rindleScoped: true;
};scoped
/** Mark a mutator as SCOPED so the api-server gives it a {@link MutationScope} (author-controlled tx
* boundary via `scope.transact`) rather than running its whole body inside the transaction. Register
* it alongside the tx-form mutators — it wins by key like any override:
*
* ```ts
* mutators: defineApiMutators({
* ...sharedApiMutators(sharedMutators, sharedCtx), // tx-form (common case)
* createOrder: scoped(async (scope, raw, ctx) => { // needs outside-tx work
* const args = createOrder.args.parse(raw);
* const chargeId = await stripe.charge(args.amount, { idempotencyKey: ctx.envelope.mid }); // outside tx
* try {
* await scope.transact(createOrder, args, { ...sharedCtx(ctx), chargeId }); // inside tx
* } catch (e) {
* await stripe.refund(chargeId); // compensate — the write rejected
* throw e;
* }
* await sendReceipt(ctx.user); // after commit
* }),
* }),
* ```
*/
export declare function scoped<User, Args>(fn: ScopedMutator<User, Args>): ScopedApiMutator<User, Args>;ApiMutators
export type ApiMutators<User> = Record<string, ApiMutator<User, any>>;QueryLeaseRequest
export interface QueryLeaseRequest<User> {
user: User;
name: string;
args: unknown;
request?: unknown;
/** The browser's stable `clientId` (sent on the query POST) — the anonymous routing-key fallback
* when there is no authenticated subject and no session cookie (READ-ROUTER-DESIGN.md §1.5/§2.2).
* A routing HINT only, never authorization. */
clientId?: string;
/** The browser's opaque follower-affinity ticket (FOLLOWER-AFFINITY-DESIGN.md §3), read off the
* query POST and forwarded opaquely on `materialize` so the fleet edge selects the follower
* the browser's ws is pinned to (§2, §4) — both legs co-locate. The api-server does NOT verify
* it (the fleet does); it holds no signing key. Absent ⇒ single daemon / affinity off. */
affinity?: string;
}QueryLeaseRealtime
/**
* The room-serve block on a query lease (RINDLE-REALTIME-QUERY-ENABLEMENT §2.1 step 5 / §2.4,
* slice G-iv-b): present when the named query carries a realtime label naming a configured room
* profile (302 §5 — declared, not derived; no coverage proof). The G-v client uses it to open the
* room transport for THIS query beside — never instead of — its daemon session.
*
* It is a dedicated block on purpose: the ROOM ws is a SEPARATE connection this query opens beside
* its daemon session (never a migration of the daemon session — the daemon ws host is fixed and
* placed by the affinity ticket). A room-served lease's top-level fields are byte-identical to the
* daemon-served ones.
*/
export interface QueryLeaseRealtime {
/** The client store's gate/domain key for this room source (`connectSource`) AND the string the
* wasm engine's `parse_source_key` accepts: any string other than the reserved `"daemon"`
* parses as a room source, and the established convention is `"room:" + doc`
* (e.g. `room:document/doc:d1`). */
sourceKey: string;
/** Where the client opens the ROOM ws for this query (from `realtime.locateRoom`) — its OWN
* connection, distinct from the daemon session's fixed ws host. */
wsEndpoint: string;
/** The room's self-authorizing signed lease (`@rindle/room/token`): the APPROVED query AST +
* doc + subject, HMAC-signed with `realtime.roomTokenKey` so the room shell's
* `downstream.tokenKeys` ring verifies it. The room materializes on first presentation. */
roomToken: string;
/** Token expiry (ms epoch) — the client's renewal clock (renewal = a fresh lease). */
exp: number;
/** The wire room doc (`"<profile>/<key>"`, minted server-side — never client-derived). */
doc: string;
/** Per-footprint-table specs: the §2.2 owned/followed split + §3.2 routing metadata. Since
* H-iii each spec also carries `footprintWhere` — the same exact membership predicate the boot
* wire ships the room gate (one compiler, `compileRoomScopeSpecs`) — feeding the client's §3
* prove-or-slow-path router. Advisory routing metadata, never a credential (§3.2). */
tables: RoomTableSpec[];
}QueryLeaseLifecycleLease
/**
* One minted SYSTEM-STREAM lease on a query lease's `lifecycle` block (RINDLE-REALTIME-QUERY-
* ENABLEMENT §4, Slice I-iii): an ordinary daemon materialization over one of the four
* `_rindle_*` lifecycle system tables (registered by the daemon's `enable_realtime_lifecycle` —
* `rust/rindle-replica/src/mutations.rs`), attachable by the EXISTING client subscribe path
* (present `leaseToken` on a `subscribe` frame, exactly like the primary lease). The identity
* fields (`scope`/`doc`/`clientId`) document the minted AST's predicate — the client keys its
* retains and its release-time row filters on them.
*/
export interface QueryLeaseLifecycleLease {
/** Which system table this lease's subscription serves. */
table: string;
leaseToken: string;
/** DOORBELL only: the §4.1 occupancy scope — the wire room doc (`"<profile>/<key>"`). */
scope?: string;
/** FENCE entries only: the room doc the predicate is scoped to. */
doc?: string;
/** FENCE ledger/outcome entries: present iff the predicate was ALSO client-scoped (the lease
* request carried `clientId`). Absent ⇒ doc-only predicate — the client filters to its own
* rows regardless (defense in depth). */
clientId?: string;
}QueryLeaseLifecycle
/** The §4 lifecycle block on a query lease (Slice I-iii): present only when BOTH the realtime
* `lifecycle` config is on AND the query is realtime-labeled. `doorbell` rides EVERY labeled
* lease (occupancy is counted whether or not the query is room-served — the 1→2 upgrade trigger
* needs solo watchers subscribed BEFORE any room exists, §4.1); `fence` rides only a ROOM-SERVED
* lease (the §4.2/§7.1/§3.3 downgrade surfaces are meaningful only where a room domain exists).
* The §4.2 fence VALUE (`finalFlushSeq`) is deliberately NOT here — it arrives with the I-v
* downgrade response; I-iii only stands up the streams. */
export interface QueryLeaseLifecycle {
doorbell: QueryLeaseLifecycleLease;
fence?: QueryLeaseLifecycleLease[];
}QueryLeaseRealtimeFence
/** The §4.2 downgrade fence on a query lease (Slice I-v): rides a labeled reply whose §4.1
* occupancy gate CLOSED (so there is NO `realtime` block) when the server could drain the room.
* A SIBLING of `realtime`, never nested inside it — block-ABSENCE is the downgrade signal, and
* the fence rides alongside that absence. Its `finalFlushSeq` is the room's last COMMITTED flush
* seq; the client's frozen room ghost holds visible until the daemon plane has provably absorbed
* it (`_rindle_room_watermark(doc) ≥ finalFlushSeq`). Absent from every non-downgrade reply. */
export interface QueryLeaseRealtimeFence {
/** The retiring room source's gate/domain key — `"room:" + doc`, matching what the room-served
* lease's {@link QueryLeaseRealtime.sourceKey} carried. */
sourceKey: string;
doc: string;
finalFlushSeq: number;
}QueryLeaseResponse
export interface QueryLeaseResponse {
leaseToken: string;
materializationId: string;
queryKey?: string;
reused?: boolean;
/** The public daemon/fleet WebSocket endpoint. With the unified `rindle` connection this is
* derived from the same ingress URL (or `rindle.wsUrl`) so a browser can open its transport from
* this lease and needs no application-authored runtime-config route. */
wsEndpoint?: string;
/** Fresh opaque follower-placement ticket minted with this follower-local lease. The optimistic
* client offers it on the WebSocket opened at {@link wsEndpoint}, pinning both legs to the same
* follower even though the first connection is created only after this response. */
affinity?: string;
/** The room-serve block (G-iv-b) — see {@link QueryLeaseRealtime}. Absent ⇒ the lease is
* byte-identical to the legacy daemon-served shape. */
realtime?: QueryLeaseRealtime;
/** The §4.2 downgrade fence (Slice I-v) — see {@link QueryLeaseRealtimeFence}. Present only on a
* labeled reply whose occupancy gate closed AND `realtime.lifecycle.drainRoom` could drain the
* room; absent otherwise (including on every room-served reply). */
realtimeFence?: QueryLeaseRealtimeFence;
/** The §4 lifecycle system-stream block (Slice I-iii) — see {@link QueryLeaseLifecycle}.
* Minted ONLY under the opt-in `realtime.lifecycle` config; absent ⇒ byte-identical to the
* pre-lifecycle response. */
lifecycle?: QueryLeaseLifecycle;
}QueryReadRequest
/** A one-shot SSR read of a named query (SSR-DESIGN.md §6): same `(name, args)` surface as a lease,
* but the daemon serializes the current view ONCE and registers no subscriber. */
export interface QueryReadRequest<User> {
user: User;
name: string;
args: unknown;
request?: unknown;
/** The browser's stable `clientId` — the anonymous routing-key fallback (see
* {@link QueryLeaseRequest.clientId}). Lets the SSR read co-locate on the follower the booting
* client's first subscribe will hit (READ-ROUTER-DESIGN.md §2.4). */
clientId?: string;
/** The browser's opaque follower-affinity ticket — see {@link QueryLeaseRequest.affinity}.
* Forwarded on the one-shot `query` so an SSR read lands on the same pinned follower. */
affinity?: string;
}QueryReadResponse
/** The assembled (nested-by-name) first-paint snapshot the server-side Store seeds + dehydrates
* (SSR-DESIGN.md §3.3). `rows` hydrate without an engine; `cvMin` is their watermark baseline. */
export interface QueryReadResponse {
rows: Array<{
cols: Record<string, unknown>;
[rel: string]: unknown;
}>;
cvMin?: number;
queryKey?: string;
}MutationRunInput
/** The context a {@link MutationBackend} needs to run one mutation inside its transaction. */
export interface MutationRunInput {
envelope: MutationEnvelope;
/** Schema-derived render metadata (from {@link RindleApiServerOptions.schema}). A logical op on a
* table absent here throws loudly — never a silent dropped write. `{}` when no schema is set. */
render: RenderIndex;
/** Invoke the (authorized) mutator against the backend-provided tx. A THROW that is NOT a
* {@link BackendError} is a BUSINESS rejection (roll the data back, then advance `lmid` alone,
* §2.4); a {@link BackendError} (a DB-layer failure) is INFRA and rejects the returned promise. */
run(tx: ServerMutationTx): Promise<void>;
}MutationOutcome
/** The result of {@link MutationBackend.runMutation}: either the data+lmid committed together, or a
* business rejection whose data was rolled back but whose `lmid` still advanced (§2.4). */
export type MutationOutcome = {
accepted: true;
output: SqlTxnOutput;
} | {
accepted: false;
reason: string;
output?: unknown;
};MutationBackend
/**
* Where a mutation runs and who stamps `lmid` — the seam that makes the mutator authoring surface
* backend-agnostic (`BYO-POSTGRES-LMID-CONTRACT-DESIGN.md` §6; MUTATORS-ISOMORPHIC plan). Three
* implementations ship: {@link sqlBackend} is the preferred managed-SQL path; {@link daemonBackend}
* keeps the private control-plane compatibility path; and {@link postgresBackend} runs a real
* interactive PG transaction with confirmation riding the CDC loop down.
*
* The load-bearing invariant is that a mutation ALWAYS advances the client's `last_mutation_id`:
* - `runMutation` runs the mutator inside the backend's transaction; on success it advances `lmid`
* to `envelope.mid` in the SAME atomic unit (OPTIMISTIC-WRITES-DESIGN.md §8.2); on a BUSINESS
* rejection it rolls the data back but STILL advances `lmid` (§2.4 — else the client's pending
* queue never drains and the optimistic stack wedges).
* - `reject` is the pre-flight path (unknown mutator, failed authorization): NO data, `lmid` alone.
* - An infrastructure failure THROWS from `runMutation` — never a user rejection (the client
* retries; mid dedup absorbs any applied prefix).
*/
export interface MutationBackend {
/** The SQL dialect this backend renders logical ops to (drives placeholder style). */
readonly dialect: SqlDialect;
/** Optional raw-SQL surface outside the mutation transaction. Built-in backends provide it;
* custom backends may omit it, in which case `scope.sql` fails as an infrastructure error. */
readonly outsideSql?: ServerSql;
runMutation(input: MutationRunInput): Promise<MutationOutcome>;
reject(input: {
envelope: MutationEnvelope;
reason: string;
}): Promise<unknown>;
}PushMutationRequest
export interface PushMutationRequest<User> {
user: User;
envelope: MutationEnvelope;
request?: unknown;
}PushMutationsRequest
export interface PushMutationsRequest<User> {
user: User;
envelopes: MutationEnvelope[];
request?: unknown;
}PushMutationResponse
export type PushMutationResponse = {
accepted: true;
rejected: false;
output: SqlTxnOutput;
} | {
accepted: false;
rejected: true;
reason: string;
output?: unknown;
};RindleApiRoutes
export interface RindleApiRoutes {
query: string;
read: string;
mutate: string;
applyRowChangeTxn: string;
claimRoomEpoch: string;
roomLmids: string;
roomBoot: string;
stream: string;
}RoomHostResponse
/** A room-host reply the transport writes VERBATIM (`status` + JSON `body`): the
* daemon's fence/conflict/identity semantics ride specific statuses and body shapes
* the room's `httpAuthority` decodes, so this endpoint trio can't run through the
* throw-on-error result shapes the viewer endpoints use. */
export interface RoomHostResponse {
status: number;
body: unknown;
}PinnedQuery
/** A named query to keep permanently materialized (warm with zero subscribers). */
export interface PinnedQuery {
name: string;
args?: unknown;
}PinFanout
/** The explicit fleet pin fan-out seam (READ-ROUTER-DESIGN.md §4.2). The api-server resolves each
* pin's authoritative AST under `pinUser` and hands the ready {@link MaterializeInput}s here; the
* implementation (the read router) fans EACH across all live followers. Distinct, on purpose, from
* a per-viewer `materialize` (which routes ONE) — a pin-assert always sprays ALL. */
export interface PinFanout {
assertPins(pins: readonly MaterializeInput[]): Promise<void>;
}RoomBootFlush
/** The room-boot flush leg (enablement §5): where the placed room's write-behind lands and what
* credential it presents. `urls` are the trio's ROUTE PATHS (root-relative — the shell resolves
* them against the boot call's origin), so an app that overrides `routes` needs no out-of-band
* sync; `headers` ride every flush call verbatim (`httpAuthority`'s `headers`). */
export interface RoomBootFlush {
urls: {
apply: string;
claim: string;
lmids: string;
};
headers: Record<string, string>;
}RoomBootResponse
/** The `/room-boot` response (RINDLE-REALTIME-DESIGN.md §10.1 — the DO shell's cold-boot
* callback): the claimed placement epoch, the room's upstream footprint lease, and the flush
* leg. A cold room boots inert and serves nothing until this returns. */
export interface RoomBootResponse {
epoch: number;
upstreamLeaseToken: string;
/** Where the room opens its upstream subscription (a routed deploy's follower). Absent ⇒ the
* shell's statically configured rindled ws endpoint. */
upstreamWsEndpoint?: string;
/** Fresh opaque follower-placement ticket minted alongside `upstreamLeaseToken`. The DO offers
* it with `rindle.v1` on the separate upstream ws so a static fleet endpoint replays to the
* exact follower holding that local lease. Absent when daemon affinity is off. */
upstreamAffinity?: string;
/** Per-footprint-table scope specs (H-iv-b), compiled from the resolved footprint AST + the
* profile's context set (the legacy anonymous profile compiles with an empty context set):
* what the shell hands the wasm room's `enableWritesV2` — the §3.3 commit gate. Optional
* only for wire compatibility with pre-H-iv-b servers; a shell that doesn't receive them
* enables the v1 table-granular write plane exactly as before. */
scopes?: RoomScopeSpec[];
flush: RoomBootFlush;
}RindleRealtimeOptions
export interface RindleRealtimeOptions<User> {
/** The boot shell secret (§10.1 — a host binding, never client-derived). Authenticates the
* room's `Authorization: Bearer` on `/room-boot` (the default {@link authorizeBoot}) and keys
* the DEFAULT epoch-bound flush credential. */
shellSecret: string;
/** NAMED room profiles (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §2.1): profile name → key
* derivation + canonical unwindowed footprint + read-only context tables. The wire room key
* for a named profile is `"<profile>/<key>"`; `/room-boot` splits it and resolves the
* profile's footprint with the bare key. Compiled + validated LOUDLY at construction (§2.3):
* a windowed footprint, a context table missing from the schema/footprint, or a registered
* query whose realtime label names a missing profile all throw from `createRindleApiServer`. */
rooms?: Record<string, RoomProfile<User>>;
/** doc → the room's approved upstream footprint (§3.1) — an `Ast` or fluent `Query`. MAY
* delegate to the named-query registry internally; throw `RindleApiError("not-found", …, 404)`
* for a doc that shouldn't exist. The §9 footprint budget belongs here — it runs once per
* placement, at lease mint.
* @deprecated Prefer named {@link rooms} profiles (RINDLE-REALTIME-QUERY-ENABLEMENT §2.1).
* This bare form remains as the single-profile LEGACY alias — the anonymous/default profile:
* a doc with no known `"<profile>/"` prefix resolves here, byte-identically to before named
* profiles existed (and with none of their construction/boot-time validation). */
resolveFootprint?: (doc: string, ctx: ApiContext<User>) => MaybePromise<ApiQueryResult>;
/** Gates the flush trio — `authorizeRoom`, relocated. Default: verify the default flush
* credential from the {@link ROOM_FLUSH_CREDENTIAL_HEADER} request header — which requires the
* transport to pass its incoming request as `context.request` (Fetch `Request` and node
* `IncomingMessage` shapes are both understood). */
authorize?: Authorizer<ApiContext<User>>;
/** Gates `/room-boot`. Default: constant-time `Authorization: Bearer` check against
* {@link shellSecret} (same `context.request` requirement as {@link authorize}). */
authorizeBoot?: Authorizer<ApiContext<User>>;
/** Mint the flush headers a placed room presents on every flush call. Default:
* {@link mintRoomFlushCredential} under {@link ROOM_FLUSH_CREDENTIAL_HEADER}. Override this
* and {@link authorize} TOGETHER — they are the two ends of one credential. */
mintFlushHeaders?: (input: {
doc: string;
epoch: number;
}) => MaybePromise<Record<string, string>>;
/** Lease TTL for the room's upstream footprint materialization (defaults to the server-wide
* `leaseTtlMs`, else the daemon's default). */
upstreamLeaseTtlMs?: number;
/** Static endpoint where rooms open their upstream subscription. In a follower fleet this is the
* fleet ws URL; `/room-boot` pairs it with the materialization's fresh placement ticket so the
* room lands on the exact follower holding its lease. Absent ⇒ no explicit upstream (the Node
* room shell may use its own default; the shipped DO shell requires this endpoint). */
upstreamWsEndpoint?: string;
/** Locate (or place) the room serving `doc` and return the ROOM ws endpoint a room-served
* lease's client should open (G-iv-b; on the DO shell this is the Worker's room URL). The
* endpoint rides the lease's dedicated `realtime.wsEndpoint` — its OWN connection, distinct from
* the daemon session's fixed ws host. Absent ⇒ room-serving is OFF: labeled queries serve from
* the daemon exactly as today (fail-open). */
locateRoom?: (doc: string) => MaybePromise<{
wsEndpoint: string;
}>;
/** The room lease token signing key (`@rindle/room/token`): `kid` + secret, matching an entry
* in the room shell's `downstream.tokenKeys` ring. Required for room-serving (without it a
* labeled query fail-opens to the daemon with a one-time warning). A separate secret from
* `shellSecret` on purpose — the shell's ring is the client-token trust domain, the shell
* secret is the boot/flush trust domain. */
roomTokenKey?: {
kid: string;
secret: string;
};
/** Room lease token TTL, ms (default 5 minutes — the §4.1 short-TTL backstop; renewal is a
* fresh lease through this server, never an extension). */
roomTokenTtlMs?: number;
/** Loud-diagnostics sink for the realtime layer (profile compilation warnings + the one-time
* per-(query, profile) "not room-served" serve-decision warnings). Defaults to
* `console.warn`; injectable for tests. */
warn?: (message: string) => void;
/** The §4 upgrade/downgrade lifecycle plane (RINDLE-REALTIME-QUERY-ENABLEMENT §4, Slice
* I-iii): PRESENCE of this block is the opt-in — every realtime-labeled lease then
* additionally mints the doorbell system lease (occupancy, §4.1) and every ROOM-SERVED lease
* the fence bundle (watermark + ledger + outcomes, §4.2/§7.1/§3.3) — see
* {@link QueryLeaseLifecycle}. Requires the daemon to have run `enable_realtime_lifecycle`
* (the four `_rindle_*` system tables must be registered or the minted materializations fail
* — which fail-opens with a one-time warning, never blocking the lease). Absent ⇒ the lease
* response is byte-identical to pre-lifecycle. */
lifecycle?: RindleRealtimeLifecycleOptions;
}RindleRealtimeLifecycleOptions
/** {@link RindleRealtimeOptions.lifecycle}. PRESENCE of the block is the opt-in switch (I-iii);
* the fields below are the Slice I-iv occupancy knobs (§4.1, decisions D4/D6/D7). All optional —
* `lifecycle: {}` gets the designed defaults. */
export interface RindleRealtimeLifecycleOptions {
/** D6 (§4.1): the occupancy threshold for room-serving. A labeled lease whose scope counts
* FEWER than this many distinct unexpired sessions (the caller's own included) ships WITHOUT
* the realtime block — served from the daemon, indistinguishable from an uncovered query —
* but WITH the doorbell, so the 1→2 transition wakes it (that is the point: solo docs never
* cost room infrastructure). Default **2** (the design's 1→2 trigger). Set `1` to room-serve
* solo viewers (the pre-I-iv behavior under lifecycle config). */
minSessions?: number;
/** The §9.1 hysteresis window, ms (default **120_000**). Two consumers: (a) the lazy sweep
* (D4) keeps expired session rows lingering at least this long past expiry — Slice I-v's
* downgrade decision ("no other unexpired row AND the newest other row expired > graceMs
* ago") is read FROM those rows, so they must survive to be read; (b) this slice's gate
* applies the same hysteresis upward: a scope with an other-session row expired ≤ graceMs
* ago keeps room-serving through the window (see `lifecycleOccupancy` — no flap on one
* client's brief lapse). §9.1-tunable: raise it for docs where collaborators churn slowly. */
graceMs?: number;
/** TTL of an occupancy session row, ms — `expires_at = now + sessionTtlMs` on every labeled
* lease mint/renewal (D7: session identity = the request's `clientId`; two tabs are two
* sessions iff their clientIds differ). Default = the server's `leaseTtlMs`, else 5 minutes —
* matching the room-token renewal cadence (`roomTokenTtlMs`, renewed 30s early), so a
* room-attached client's renewals keep its row unexpired; a daemon-attached solo client's row
* MAY lapse (it has no renewal timer) and is refreshed by its next doorbell-triggered
* re-lease — occupancy converges through the doorbell itself. */
sessionTtlMs?: number;
/** The §4.2 downgrade drain hook (Slice I-v). When the occupancy gate CLOSES for a labeled
* lease whose scope PLAUSIBLY hosted a room (an other-session row still lingers — never a
* never-shared solo doc), the api-server calls this to drain the room's pending write-behind
* and learn its last COMMITTED `flush_seq`, then rides the value back on the lease as the
* {@link QueryLeaseRealtimeFence}. The deployment wires it to the room shell's / DO's `/drain`
* control. Absent ⇒ no fence is attached (the client hits its loud legacy downgrade path);
* a throw fails OPEN to the same (a downgrade never blocks the lease). Concurrent drains across
* api-server instances are fine — `/drain` is idempotent. */
drainRoom?: (doc: string) => Promise<{
finalFlushSeq: number;
}>;
}ROOM_FLUSH_CREDENTIAL_HEADER
export declare const ROOM_FLUSH_CREDENTIAL_HEADER = "x-rindle-room-credential";RoomFlushCredentialPayload
export interface RoomFlushCredentialPayload {
v: 1;
doc: string;
epoch: number;
iat: number;
}mintRoomFlushCredential
/** Sign the default epoch-bound flush credential (`/room-boot` mints one per placement). */
export declare function mintRoomFlushCredential(opts: {
shellSecret: string;
doc: string;
epoch: number;
/** Mint time; defaults to `Date.now()`. Injectable for tests. */
now?: number;
}): Promise<string>;verifyRoomFlushCredential
/** Verify a flush credential's MAC, then its claims; returns the payload or throws. The MAC is
* checked FIRST — no claim is trusted before it passes. */
export declare function verifyRoomFlushCredential(credential: string, shellSecret: string): Promise<RoomFlushCredentialPayload>;RindleDatabaseOptions
/** Database connection used by the API server's managed SQL path.
*
* `intMode` defaults to `"number"` because logical Rindle rows use the JSON-safe {@link WireValue}
* vocabulary — `"bigint"` does not survive `JSON.stringify`, and `"string"` silently retypes every
* integer, breaking arithmetic in mutator bodies. The cost is a HARD BOUND: a mutator read of an
* integer outside ±(2^53 − 1) rejects that mutation rather than silently rounding it. Tables with
* keys beyond that range (snowflake ids, and so on) must override `intMode` and have their mutators
* handle the resulting type. Commit receipts are unaffected — they never decode row values. */
export type RindleDatabaseOptions = Pick<SqlClientOptions, "url" | "authToken" | "fetch" | "intMode">;RindleConnectionOptions
/** The unified fleet connection — one URL, one key. A Rindle is BOTH layers at once: the SQL
* database below and the sync/IVM control plane above, served by a single ingress that routes
* each request to the right tier. This option derives both from that one origin: the SQL layer
* ({@link RindleApiServerOptions.database}) at `url` with `token`, and the control-plane client
* ({@link RindleApiServerOptions.daemon}) at the same `url` — so an application configures "a
* rindle", not two subsystems.
*
* The derived control-plane client sends the same bearer. A unified ingress is an explicitly
* merged customer API-server trust tier: it routes SQL to the master and control requests to a
* follower, while the browser still receives neither credential. Deployments that keep those
* trust tiers separate pass an explicit {@link RindleApiServerOptions.daemon}; explicit fields
* always win over this derivation. */
export interface RindleConnectionOptions {
/** The single ingress origin. Defaults to `$RINDLE_URL` — exported by `rindle dev` whenever
* the rendered topology collapsed read+write onto one ingress. */
url?: string;
/** Public subscription WebSocket endpoint returned on query leases. Defaults to {@link url} with
* `http:` → `ws:` / `https:` → `wss:`. Override when HTTP and WebSocket ingress differ. */
wsUrl?: string;
/** The server-side bearer for both legs of the unified ingress. Defaults to
* `$RINDLE_DATABASE_TOKEN` (also a `rindle dev` export); required through one of those channels
* unless both legs are configured explicitly. It never reaches the browser. */
token?: string;
}RindleApiServerOptions
export interface RindleApiServerOptions<User> {
/** The sync/IVM control-plane client (leases, materialization, rooms). Optional once
* {@link rindle} is configured — the api-server then derives an HTTP client against the single
* ingress. Pass one explicitly for a tokened production fleet, a split/routed deployment, or a
* custom transport; an explicit client wins over the derivation. */
daemon?: RindleDaemonClient;
/** The unified connection (one URL, one key) that derives {@link daemon} and {@link database}
* from the fleet's single ingress. `rindle: {}` resolves both halves from the `rindle dev`
* environment. Any explicitly configured `daemon` / `database` / `sql` / `backend` field takes
* precedence over its derived counterpart. */
rindle?: RindleConnectionOptions;
/** Preferred managed setup. The API server constructs and owns its SQL client; authoritative
* mutators, `tx.sql`, and `scope.sql` use it, while `daemon` remains only the lease/query/
* materialization/room control plane. Mutually exclusive with {@link sql} unless `backend`
* explicitly replaces both. */
database?: RindleDatabaseOptions;
/** Advanced injection/test seam for an already-created SQL session. Most applications should
* configure {@link database} and never import `createSqlClient`. When present (and `backend` is
* absent), authoritative mutators run through {@link sqlBackend}. */
sql?: SqlSession;
/** Where mutations are applied and `lmid` is stamped ({@link MutationBackend}). Default:
* managed `sqlBackend` when `database` or `sql` is configured, otherwise the compatibility
* `daemonBackend(daemon)`. Pass `postgresBackend(...)` when Postgres is the source of truth. */
backend?: MutationBackend;
/** The typed schema (`createSchema`/`refineSchema`). Required only when a mutator uses the LOGICAL
* write vocabulary (`tx.insert`/`update`/`upsert`/`insertIgnore`/`delete`/`row`) — it drives the
* dialect SQL renderer (column order, pk, quoting). A logical op with no schema configured throws
* loudly. Pure raw-`tx.exec` mutators do not need it. */
schema?: Schema;
queries?: ApiQueries<User>;
runQuery?: RunQuery<User>;
mutators?: ApiMutators<User>;
/** Optional gate before resolving a named query, for leases and one-shot reads. If omitted,
* no gate runs. The query definition still owns row visibility predicates. */
authorizeQuery?: Authorizer<AuthorizeQueryInput<User>>;
/** Optional gate before mutation execution. If omitted, no gate runs. Mutators still own
* row-level checks and business rules inside their authoritative transaction. */
authorizeMutation?: Authorizer<AuthorizeMutationInput<User>>;
/** Rindle Realtime (RINDLE-REALTIME-ENABLEMENT-DESIGN.md §3.1): the ONE named opt-in.
* Presence activates the room flush trio AND `/room-boot`; absence keeps every room
* endpoint 403. Takes precedence over the deprecated {@link authorizeRoom}. */
realtime?: RindleRealtimeOptions<User>;
/** The LM stream plane (LM-STREAM-CHECKPOINT-DESIGN.md): the ONE named opt-in for streaming a
* model response live while the durable store only ever sees coarse checkpoints. Presence
* activates {@link RindleApiServer.openStream}/{@link RindleApiServer.subscribeStream} and
* `/stream`; absence keeps them 403. */
streams?: RindleStreamOptions<User>;
/** The room write-authority gate (§5.3.1): validates the caller is a placed room —
* the epoch-bound flush credential rides `context.request`, and what it means is
* the app's to define. The room endpoints are DISABLED (403) until this is set:
* hosting a write authority is an explicit opt-in, never a default.
* @deprecated Use {@link realtime} (its `authorize`) — this bare form gates the
* flush trio but never activates `/room-boot`. When both are set, `realtime` wins. */
authorizeRoom?: Authorizer<ApiContext<User>>;
routes?: Partial<RindleApiRoutes>;
mode?: StreamMode;
materializationPolicy?: MaterializationPolicy | ((input: QueryLeaseRequest<User>) => MaybePromise<MaterializationPolicy>);
leaseTtlMs?: number;
/** Idle TTL (ms) the warm pipeline a one-shot SSR {@link RindleApiServer.readQuery read} leaves
* behind is held at (SSR-DESIGN.md §3.4) — it must comfortably cover page-load + client-boot +
* the follow-up live `subscribe` so the browser lands on a still-warm pipeline (the warm
* handoff). The TTL is NOT part of the dedup key (max-wins), so it only ever extends a shared
* query's window. Absent ⇒ the daemon's default idle TTL. */
readIdleTtlMs?: number;
subject?: string | ((input: QueryLeaseRequest<User>) => MaybePromise<string | undefined>);
/** The ANONYMOUS routing key forwarded to the read router (READ-ROUTER-DESIGN.md §2.2) — used by
* HRW placement when there is no authenticated `subject`. The router keys on `subject ?? this`;
* the resolved value rides `metadata.routingKey` to the daemon. Default: the browser-supplied
* `clientId` (from the query POST body). NOTE: the default cannot co-locate an ANONYMOUS SSR read
* with the booting client — an SSR server can't see the browser's localStorage `clientId`, so the
* two legs compute different keys and §2.4's warm handoff misses (still correct — just an extra
* first-touch materialize). For anonymous SSR co-location, set this to read a server-set session
* cookie from `input.request` (it rides the SSR request AND every browser request). A routing
* HINT only — never authorization. Ignored by a single (unrouted) daemon, which has nothing to
* route. */
routingKey?: string | ((input: QueryLeaseRequest<User>) => MaybePromise<string | undefined>);
/** The EXPLICIT fleet pin fan-out — when set, {@link RindleApiServer.assertPins} fans each
* resolved pin across ALL live followers through it (a fleet control action over the machine
* list — FOLLOWER-AFFINITY-DESIGN.md §11) instead of materializing each pin once on the (single)
* daemon. A per-viewer `materialize` always routes ONE; a pin-assert always fans ALL — never
* inferred from `policy.kind`. Absent ⇒ single-daemon behavior (one materialize per pin). */
pinFanout?: PinFanout;
/** Named queries to keep permanently materialized via {@link RindleApiServer.assertPins}.
* Each is materialized with a `pinned` policy (survives zero subscribers) so late joiners
* attach to an already-warm result. Pins are viewer-independent — resolved with `pinUser`. */
pinnedQueries?: PinnedQuery[];
/** The user context pins resolve under (pins are shared, so they should not depend on a
* per-viewer identity). Defaults to `undefined`. */
pinUser?: User;
/** Surfaced when a SCOPED mutator ({@link scoped}) throws from code that runs AFTER `scope.transact`
* has already sealed the protocol outcome — a post-commit effect, or a compensation handler running
* after a business rejection. The outcome is fixed (this callback CANNOT change the client's
* response or the `lmid` advance), but the throw must not vanish: a failed refund is real money.
* Absent ⇒ the error is logged to `console.error`. */
onScopeError?: (err: unknown, info: {
phase: "committed" | "rejected";
envelope: MutationEnvelope;
}) => void;
}RindleApiServer
export interface RindleApiServer<User> {
readonly routes: RindleApiRoutes;
/** Close the SQL client created from {@link RindleApiServerOptions.database}, and drop every live
* stream's readers and timers WITHOUT a durable write (that is {@link drainStreams}). Injected
* SQL sessions and custom backends remain caller-owned. Idempotent. */
close(): void;
/** Open an LM stream (LM-STREAM-CHECKPOINT §2): commits the durable POINTER row, then hands back
* the producer handle. A resolved handle means the message already exists for every client's
* query — so a subscriber that arrives before the first token has something to attach to.
* Throws 403 unless {@link RindleApiServerOptions.streams} is configured. */
openStream(input: OpenStreamInput<User>): Promise<StreamHandle>;
/** Attach a reader at `from` — the same call serves a first-touch subscriber (`from: 0`), a late
* joiner (`from` = the seq its IVM view shows), and a reconnect (`from` = `Last-Event-ID`).
* Terminates with `end`, or with `stale`/`absent` when the client should fall back to the
* durable plane (both are ordinary answers, never errors). */
subscribeStream(input: SubscribeStreamInput<User>): Promise<StreamSubscription>;
/** Parse a default `{streamId, from?}` subscribe body and run {@link subscribeStream}. For the
* GET + `EventSource` shape, use {@link streamResponse} (or build the body with
* `streamRequestFromHttp(request)` yourself). */
handleStreamJson(body: unknown, context: ApiContext<User>): Promise<StreamSubscription>;
/** The subscribe route in ONE call: parse a GET (`?streamId=…&from=…`, with `Last-Event-ID`
* winning), authorize + subscribe, and encode the SSE response. A refusal comes back as a JSON
* error `Response` (403 for denied or unconfigured) rather than a throw, so the route body is a
* single expression after authentication. For custom transports, compose
* `streamRequestFromHttp` + {@link subscribeStream} + `streamFramesToSse` instead. */
streamResponse(request: {
url: string;
headers: {
get(name: string): string | null;
};
}, context: ApiContext<User> & {
keepAliveMs?: number;
}): Promise<Response>;
/** Checkpoint every live stream's outstanding tail, then seal it `interrupted`
* (LM-STREAM-CHECKPOINT §5). Wire it to SIGTERM: without it a rolling deploy drops each
* response's un-checkpointed tail and strands rows saying `streaming` forever. */
drainStreams(): Promise<void>;
createQueryLease(input: QueryLeaseRequest<User>): Promise<QueryLeaseResponse>;
/** (Re-)materialize every `pinnedQueries` entry with a pinned policy. Idempotent — the daemon
* dedupes by canonical query, so a re-assert reuses the existing materialization. Call it at
* startup and whenever the daemon restarts (e.g. from the daemon-client `onBootId` hook), since
* the daemon holds no durable materialization state. No-op when `pinnedQueries` is empty. */
assertPins(): Promise<void>;
pushMutation(input: PushMutationRequest<User>): Promise<PushMutationResponse>;
/** Apply an in-order batch (the client mutation queue's flush). Envelopes run strictly
* sequentially; a rejection still advances the daemon's lmid, so later envelopes in the
* batch stay contiguous and keep applying. A daemon ERROR throws for the whole batch —
* the client retries it and the daemon's mid dedup absorbs the already-applied prefix. */
pushMutations(input: PushMutationsRequest<User>): Promise<PushMutationResponse[]>;
/** One-shot SSR read (SSR-DESIGN.md §6): resolve `(name, args)` → AST (same authority path as a
* lease, `authorizeQuery` enforced), have the daemon serialize the current view once, and return
* the assembled rows for the loader to seed + dehydrate. Registers NO subscriber — a dropped
* render leaks nothing; the pipeline self-reclaims after the idle TTL ({@link
* RindleApiServerOptions.readIdleTtlMs}) unless the browser's follow-up `subscribe` lands first. */
readQuery(input: QueryReadRequest<User>): Promise<QueryReadResponse>;
handleQueryJson(body: unknown, context: ApiContext<User>): Promise<QueryLeaseResponse>;
/** Parse a default `{name, args}` read body and run {@link readQuery}. */
handleReadJson(body: unknown, context: ApiContext<User>): Promise<QueryReadResponse>;
/** Accepts `{envelope}` (one) or `{envelopes: [...]}` (an in-order batch → array reply). */
handleMutateJson(body: unknown, context: ApiContext<User>): Promise<PushMutationResponse | PushMutationResponse[]>;
/** The room's flush (§5.3.1): gate on `authorizeRoom`, forward the txn to the write
* authority, and pass the store's verdict through VERBATIM — `200 {applied, cv}`,
* `409 {error:"fenced"|"conflict", …}`, or the loud identity `500`. Write
* `status` + `body` as-is; the room's `httpAuthority` decodes them. */
handleApplyRowChangeTxnJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
/** Claim the next placement epoch for a doc (§2.5), same gate + envelope. */
handleClaimRoomEpochJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
/** The room's boot probe (§3.3), same gate + envelope. */
handleRoomLmidsJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
/** The DO shell's cold-boot callback (§10.1; enablement §3.1): authenticate the shell
* secret, resolve the doc's footprint, claim the placement epoch, mint the upstream
* lease and the flush leg. 403 until {@link RindleApiServerOptions.realtime} is set. */
handleRoomBootJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
}RindleApiErrorCode
export type RindleApiErrorCode = "bad-request" | "forbidden" | "not-found" | "rejected";RindleApiError
export declare class RindleApiError extends Error {
readonly code: RindleApiErrorCode;
readonly status: number;
constructor(code: RindleApiErrorCode, message: string, status: number);
}SplitDaemonClient
/**
* A read/write-split `RindleDaemonClient` (READ-ROUTER-DESIGN.md §2.1). Writes
* (`executeSqlTxn` / `rejectMutation` / `applyRowChangeTxn` / `migrate`) go to the single
* write-master, UNCHANGED and never through the router; reads (`materialize` / `query` /
* `dematerialize`, and raw `executeSqlRead`) go to the read router. Raw reads default to a replica
* (`consistency:"eventual"`) so they scale off the write-master; pass `consistency:"strong"` on a
* read to route it to the master for read-your-writes after a write to the same data. Hand one of
* these to {@link createRindleApiServer} as `daemon` to point the reads leg at the router while
* writes stay on the master — no placement logic enters the api-server.
*
* ```ts
* const daemon = new SplitDaemonClient(
* new HttpRindleDaemonClient({ baseUrl: MASTER_URL, headers: writeAuth }), // writes → master
* new HttpRindleDaemonClient({ baseUrl: ROUTER_URL, headers: routerAuth }), // reads → router
* );
* ```
*/
export declare class SplitDaemonClient implements RindleDaemonClient {
private readonly writes;
private readonly reads;
constructor(writes: RindleDaemonClient, reads: RindleDaemonClient);
executeSqlTxn(input: SqlTxn): Promise<SqlTxnOutput>;
executeSqlRead(input: SqlRead): Promise<SqlReadOutput>;
rejectMutation(input: MutationRejection): Promise<MutationRejectionOutput>;
beginMutationSession(input: MutationSessionBegin): Promise<MutationSessionBeginOutput>;
execInMutationSession(input: MutationSessionExec): Promise<unknown>;
queryInMutationSession(input: MutationSessionQuery): Promise<SqlReadOutput>;
commitMutationSession(input: MutationSessionRef): Promise<SqlTxnOutput>;
rollbackMutationSession(input: MutationSessionRef): Promise<unknown>;
applyRowChangeTxn(input: RowChangeTxn): Promise<RowChangeTxnOutput>;
claimRoomEpoch(input: ClaimRoomEpochInput): Promise<ClaimRoomEpochOutput>;
roomLmids(input: RoomLmidsInput): Promise<RoomLmidsOutput>;
migrate(input: MigrateInput): Promise<MigrateOutput>;
materialize(input: MaterializeInput): Promise<MaterializeOutput>;
query(input: QueryOnceInput): Promise<QueryOnceOutput>;
dematerialize(input: DematerializeInput): Promise<DematerializeOutput>;
}SqlDialect
/** A SQL dialect for the logical mutation renderer. */
export interface SqlDialect {
readonly name: "sqlite" | "postgres";
/** Render the i-th (1-based) bind placeholder. sqlite: `?`; postgres: `$i`. */
placeholder(oneBased: number): string;
/** Optional value coercion hook (e.g. a future SQLite `0/1` boolean). Default: identity. */
encodeValue?(v: WireValue, type: ColType): WireValue;
}sqliteDialect
export declare const sqliteDialect: SqlDialect;postgresDialect
export declare const postgresDialect: SqlDialect;TableRenderMeta
/** Per-table metadata the renderer needs (all reachable from a `TableMeta`). */
export interface TableRenderMeta {
/** Columns in schema (wire) order — the stable INSERT column list + completeness check. */
columns: string[];
/** Primary-key column NAMES — the WHERE / ON CONFLICT target / SET partition. */
pkNames: string[];
/** Column name → declared type (only consulted by {@link SqlDialect.encodeValue}). */
types: Record<string, ColType>;
/** Columns a full insert must name — the non-nullable ones (design 206 §6.2). */
required: string[];
/** The nullable (omittable-to-null) columns — an omitted one binds `NULL` (design 206 §6.2). */
nullable: ReadonlySet<string>;
}RenderIndex
export type RenderIndex = Record<string, TableRenderMeta>;buildRenderIndex
/** Build the {@link RenderIndex} from a typed schema (`schema.tables[name]` is a `TableMeta`). */
export declare function buildRenderIndex(schema: Schema): RenderIndex;renderOp
/** Render one {@link MutationOp} to a `{sql, params}` for the dialect, or `null` for a no-op (an
* `update` whose row names only pk columns — nothing to SET, matching the client's no-op edit). */
export declare function renderOp(op: MutationOp, meta: TableRenderMeta, dialect: SqlDialect): SqlStatement | null;renderPointRead
/** Render a point read (`tx.row`) — `SELECT <cols> FROM "T" WHERE <pk>` — for read-your-writes. */
export declare function renderPointRead(table: string, pk: KeyedRow, meta: TableRenderMeta, dialect: SqlDialect): SqlStatement;BackendError
/** Thrown (wrapping the driver error) by a server tx's DB calls, so the seam can tell an INFRA
* failure (retry) from a mutator-body throw (business rejection). */
export declare class BackendError extends Error {
readonly driverError: unknown;
constructor(driverError: unknown);
}daemonBackend
/** Legacy/private-plane adapter. Kept for existing deployments; its mutation policy is shared with
* {@link sqlBackend}, so the two transports cannot drift. */
export declare function daemonBackend(daemon: RindleDaemonClient): MutationBackend;sqlBackend
/** Run API-server mutators through `@rindle/sql-client`'s explicit mutation facade. Query leases,
* SSR reads and room control continue to use `daemon`; only authoritative mutation execution moves
* to the versioned SQL transport. */
export declare function sqlBackend(sql: SqlSession): MutationBackend;PgQuery
/** The query surface a {@link PostgresPlugger} transaction exposes. `exec` runs one statement;
* `query` returns rows keyed by column name (read-your-own-writes inside the txn). */
export interface PgQuery {
exec(sql: string, params?: unknown[]): Promise<void>;
query(sql: string, params?: unknown[]): Promise<Array<Record<string, unknown>>>;
}PostgresPlugger
/** The thin driver adapter that keeps `pg` / `postgres.js` out of this package's dependencies
* (`BYO-POSTGRES-LMID-CONTRACT-DESIGN.md` §6.2): run `fn` inside ONE transaction — commit on
* resolve, roll back on throw. {@link pgPoolPlugger} adapts a node-postgres `Pool`. */
export interface PostgresPlugger {
transaction<T>(fn: (q: PgQuery) => Promise<T>): Promise<T>;
}PostgresBackendOptions
export interface PostgresBackendOptions {
/** Rewrite each MUTATOR statement's SQL before it runs (never the lmid upsert). The intended
* use is dialect bridging for a dual-topology app whose mutators are written SQLite-style:
* pass {@link questionToDollarParams} to convert `?` placeholders to `$1..$n`. */
rewriteSql?: (sql: string) => string;
}postgresBackend
/**
* The BYO-Postgres {@link MutationBackend} (`BYO-POSTGRES-LMID-CONTRACT-DESIGN.md` §6.3): one PG
* transaction runs the mutator's statements and ALWAYS upserts `_rindle_client_mutations` —
* the upsert sits outside any acceptance guard by construction, so the §2.4 footgun (a rejection
* that forgets to advance `lmid` and wedges the client's pending queue) cannot be written.
*
* Confirmation does NOT come from this call's response: the lmid row rides the same PG commit
* through CDC → relay → follower and reaches the client in the same coherent release as the
* data (§8.2 relocated upstream). A rejection's `reason` still returns on the HTTP reply, but
* nothing rejection-shaped travels the replication path — the optimistic prediction snaps back
* when the advanced `lmid` arrives.
*/
export declare function postgresBackend(plugger: PostgresPlugger, opts?: PostgresBackendOptions): MutationBackend;PgPoolLike
/** The slice of a node-postgres `Pool` the plugger needs — structural, so `pg` stays a
* dependency of the APP, never of this package. */
export interface PgPoolLike {
connect(): Promise<{
query(sql: string, params?: unknown[]): Promise<{
rows: Array<Record<string, unknown>>;
}>;
release(err?: unknown): void;
}>;
}pgPoolPlugger
/** Adapt a node-postgres `Pool` (or anything pool-shaped) to a {@link PostgresPlugger}:
* one client per transaction, `BEGIN`/`COMMIT` bracketing, `ROLLBACK` + rethrow on failure. */
export declare function pgPoolPlugger(pool: PgPoolLike): PostgresPlugger;questionToDollarParams
/**
* Rewrite SQLite-style `?` positional placeholders to Postgres `$1..$n`, for mutators written
* once and run against either backend (pass as {@link PostgresBackendOptions.rewriteSql}).
* Skips `'…'` string literals (with `''` escapes), `"…"` quoted identifiers, `--` line comments,
* and non-nested C-style block comments. Do not mix `?` and `$n` styles in one statement.
*/
export declare function questionToDollarParams(sql: string): string;defineApiQueries
export declare function defineApiQueries<User, Q extends ApiQueries<User>>(queries: Q): Q;registerQueries
/**
* Register a list of co-located client {@link NamedQuery `defineQuery`} values as the server's query
* surface — the bulk, no-boilerplate counterpart to {@link defineApiQueries}. Each query already
* carries its wire `name` and a `resolve` that re-runs its validator on the UNTRUSTED wire args and
* builds the authoritative `Query`, so the server just imports every co-located query and hands the
* list here. The same validated args build a byte-identical AST on both tiers.
*
* The query's AUTHORITATIVE {@link ApiContext} is forwarded as `resolve`'s ctx — so a context-scoped
* `defineQuery` (e.g. "my issues") is built from the server's trusted principal, never the client's.
* A context-free query simply ignores the extra argument.
*
* Use {@link defineApiQueries} instead (or in addition) only when the server must DIVERGE from the
* client — define a server-specific `defineQuery` with the same name and register that.
*
* ```ts
* queries: registerQueries<User>([issuesPageQuery, issueDetailQuery, recentCommentsQuery, usersQuery]),
* ```
*/
export declare function registerQueries<User>(queries: readonly NamedQuery<any, any, any>[]): ApiQueries<User>;defineApiMutators
export declare function defineApiMutators<User, M extends ApiMutators<User>>(mutators: M): M;guardMutator
/**
* Wrap a SHARED (generator) mutator with a row-level ACCESS GUARD — the multi-tenant authz twin of
* {@link sharedApiMutators}. It parses the untrusted wire args, derives the {@link MutatorCtx}
* principal (the SAME mapping you pass to `sharedApiMutators`), evaluates `predicate` against the OPEN
* mutation txn (so it can READ the rows the write depends on), and throws `forbidden` (403 — the
* client's optimistic write snaps back) when access is denied; otherwise it drives the SAME body the
* client predicts ({@link runSharedMutation}). Use it for the entries that need server-only authority
* the client cannot predict, OVERRIDING the auto-wrapped default (spread `sharedApiMutators(...)`
* first, then the guarded overrides win by key):
*
* ```ts
* const principal = (ctx) => ({ user: requireUser(ctx.user) });
* mutators: defineApiMutators({
* ...sharedApiMutators(sharedMutators, principal),
* updateSlide: guardMutator(sharedMutators.updateSlide, principal,
* async (tx, a, { user }) =>
* (await tx.query(q.slide.where.id(a.slideId).where(editableBy(user)).one())) != null,
* { message: "not permitted to edit this slide" }),
* }),
* ```
*
* The predicate keeps the shared body READ-FREE, so the client's `.folded` hot paths (drag/keystroke)
* still fold — the read is server-side only. Return `false` to deny (→ the default or `opts.message`
* forbidden); return `true`/nothing to allow. To reject with a different status/message (a business
* rejection, a not-found), throw a {@link RindleApiError} from inside the predicate instead. `principal`
* runs before the predicate, so it too may throw `forbidden` for an anonymous caller.
*/
export declare function guardMutator<User, Args>(gen: SharedMutatorWithArgs<Args>, principal: (ctx: MutationContext<User>) => MutatorCtx, predicate: (tx: ServerMutationTx, args: Args, ctx: MutatorCtx) => boolean | void | Promise<boolean | void>, opts?: {
message?: string;
}): ApiMutator<User, unknown>;ShapeExemplar
/** One exemplar invocation for {@link dumpQueryShapes} — the `args`/`user` a query is built with.
* Literal values never matter to the dump (shapes are deduped with literals stripped); what an
* exemplar buys is BRANCH coverage, so supply one per code path a query function can take
* (an optional filter present/absent, each enum axis, …). */
export interface ShapeExemplar<User = unknown> {
args?: unknown;
user?: User;
}QueryShapesDoc
/** The query-shapes document `rindle indices suggest` consumes: the app's synced tables (name +
* primary key) and one wire AST per structurally distinct shape a registered query can build. */
export interface QueryShapesDoc {
tables: Array<{
name: string;
primaryKey: string[];
}>;
queries: Array<{
name: string;
ast: Ast;
}>;
}dumpQueryShapes
/**
* Dump every registered named query's wire AST — feeder 1 ("exemplar enumeration") of
* `rindle indices suggest` (docs/INDEXING.md applied mechanically to the query set).
*
* Because named queries are FUNCTIONS of `(args, ctx)`, one query can build structurally
* different ASTs on different args; each exemplar invocation contributes its shape, and shapes
* that differ only in literal values (a limit, a filter string) dedupe to one entry. A query
* with no configured exemplars is invoked once with no args. The registry is the app's whole
* server-side query surface, so the resulting document is the complete static shape set —
* modulo arg-value-dependent branches, which need an exemplar (or runtime shape recording) to
* surface.
*/
export declare function dumpQueryShapes<User>(opts: {
schema: Schema;
queries: ApiQueries<User>;
exemplars?: Partial<Record<string, ReadonlyArray<ShapeExemplar<User>>>>;
}): Promise<QueryShapesDoc>;createRindleApiServer
export declare function createRindleApiServer<User = unknown>(options: RindleApiServerOptions<User>): RindleApiServer<User>;