Contract
The wire contract, covering every procedure's shapes, the RPC transport client, capability tokens, and the tagged error catalog.
import { ... } from "lakefront/contract";
The wire contract is defined once, as a zod-schema-backed object; lakefront/server’s handlers and lakefront/client’s methods are both derived from it by mapped types, so neither can drift from it.
Functions
isProcedureName
function isProcedureName(name: string): name is ProcedureName;
Whether name is a key of contract.
createRpcClient
function createRpcClient(options: RpcClientOptions): RpcClient;
| Parameter | Type | Default | Description |
|---|---|---|---|
options |
RpcClientOptions |
(required) | See RpcClientOptions. |
Returns a proxy typed against Contract. Each call POSTs to ${url}/rpc/${procedure} and decodes the response against that procedure’s compiled output schema.
orThrow
function orThrow(client: RpcClient): RpcClientOrThrow;
Wraps a client so every call unwraps, throwing a plain Error (not a Panic) carrying the tag and message on a server-side failure.
mintToken
function mintToken(secret: Uint8Array, kid: string, claims: Claims): string;
| Parameter | Type | Default | Description |
|---|---|---|---|
secret |
Uint8Array |
(required) | Root HMAC secret for this key id. |
kid |
string |
(required) | Key id, embedded in the token. |
claims |
Claims |
(required) | See Claims. |
Issues a root capability. Throws if claims.boards or claims.verbs would be empty, since that would be a token authorizing nothing.
const token = mintToken(secret, "env", {
boards: "*",
verbs: ["read", "write", "admin"],
exp: now + 86_400,
});
attenuate
function attenuate(token: string, caveat: Partial<Claims> & { ttlSeconds?: number }): string;
| Parameter | Type | Default | Description |
|---|---|---|---|
token |
string |
(required) | Token to derive a weaker one from. |
caveat |
Partial<Claims> & { ttlSeconds?: number } |
(required) | Restriction to append; ttlSeconds, when given, wins over a direct exp. |
Returns a strictly weaker token, derived locally from the token string alone: no secret, no round-trip. Widening is never possible, since verification intersects the caveat with the base claims rather than replacing them.
const view = attenuate(service, {
boards: ["acme_roadmap"],
verbs: ["read"],
ttlSeconds: 300,
});
verifyToken
function verifyToken(
token: string,
secretByKid: (kid: string) => Uint8Array | undefined,
nowSeconds?: number,
): TokenVerification;
| Parameter | Type | Default | Description |
|---|---|---|---|
token |
string |
(required) | Token to verify. |
secretByKid |
(kid: string) => Uint8Array | undefined |
(required) | Resolves a key id to its root secret. |
nowSeconds |
number |
current time | Clock for the expiry check. |
Returns the token’s effective claims: the base claims intersected with every caveat in the chain, board patterns intersected exactly (the narrower of two overlapping patterns survives), verbs filtered to the overlap, exp taking the minimum. Failure reasons are deliberately coarse; see TokenVerification.
claimsAllow
function claimsAllow(claims: Claims, boardId: string, verb: Verb): boolean;
The authorization predicate every enforcement point reduces to: does verb appear in claims.verbs, and does some board pattern in claims.boards cover boardId.
soleBoardOf
function soleBoardOf(claims: Claims): string | undefined;
The one board a token pins, only when it pins exactly one non-prefix board: what lets a client omit boardId from RpcClientOptions entirely.
messageOf
function messageOf(cause: unknown): string;
Normalizes a thrown value into a message without swallowing its type: cause instanceof Error ? cause.message : String(cause).
Classes
RpcError
class RpcError extends Error {
readonly status: number;
readonly procedure: string;
constructor(status: number, procedure: string, message: string);
}
Thrown internally by createRpcClient for a transport-level failure with no decodable envelope; the retry loop converts a final one into a TransportFailed WireError rather than letting it escape.
Procedures
The board id is never in a procedure’s input: it travels in the request envelope, resolved from auth context by the server. That makes cross-board access unrepresentable in the query language, rather than merely validated.
| Procedure | Input | Output |
|---|---|---|
query.execute |
{ sql: string, tables?: TableBinding[], params?: Cell[], minWatermark?: number | null } |
QueryResult & { watermark: number, servedBy: string, cache: CacheOutcome, sync: SyncResult, syncMs: number, queryMs: number } |
mutate.insert |
{ rows: Row[] (min 1), idempotencyKey?: string } |
{ watermark: number } |
mutate.update |
{ updates: { id: string, patch: Row }[] (min 1), idempotencyKey?: string } |
{ watermark: number } |
mutate.delete |
{ ids: string[] (min 1), idempotencyKey?: string } |
{ watermark: number } |
changes.read |
{ after: number, limit?: number } |
ChangesPage |
node.stats |
{} |
EngineStats & { nodeId: string, boards: string[] } |
idempotencyKey is z.string().min(1).max(200), optional on every mutation. Semantics: the idempotency guide.
contract is narrower than the engine: data-plane verbs only. Lifecycle and schema operations (createBoard, dropBoard, addColumn, and so on) are admin decisions made next to the engine, outside HTTP. So are transactions: Engine.transaction runs embedded only, and no procedure here spans a multi-write commit over HTTP.
Types
Cell
string | number | boolean | null. Backed by the runtime schema cell.
Row
Readonly<Record<string, Cell>>. Backed by the runtime schema row.
QueryResult
{ columns: string[], rows: Cell[][] }: columnar, not row objects. Per-row object construction is where a JS serving layer burns its latency budget on wide reads. Backed by the runtime schema queryResult.
WalKind
"insert" | "upsert" | "update" | "delete" | "drop". Backed by the runtime schema walKind.
WalPayload
A union keyed by shape: { rows: Row[] } (insert/upsert), { id: string, patch: Row } (update), { ids: string[] } (delete), or { drop: true } (a whole-board tombstone). Backed by the runtime schema walPayload.
TableBinding
{ token: string, entity: string }, both matching /^[A-Za-z0-9_]{1,32}$/. Binds a {{token}} in SQL to a logical entity, constrained because the server builds a regex from it. Backed by the runtime schema tableBinding.
CacheOutcome
"hit" | "rebuild" | "reattach". Backed by the runtime schema cacheOutcome.
SyncResult
{ from: number, to: number, skipped: boolean, changesApplied: number, rowsInserted: number, rowsDeleted: number, columnsAdded: string[] }. Backed by the runtime schema syncSummary.
ParkedBoardStat
{ boardId: string, failures: number, since: number }. The wire shape EngineStats.flusherParked carries; backed by the runtime schema parkedBoard.
EngineStats
{ flusherParked: ParkedBoardStat[], cached: number, capacity: number, hits: number, rebuilds: number, reattaches: number, evictions: number, connectionsInUse: number }: what Engine.stats() returns; node.stats extends it with nodeId and boards. Backed by the runtime schema engineStats.
ChangeRecord
{ seq: number, kind: WalKind, payload: WalPayload, actor?: string }: one change-feed record. Backed by the runtime schema changeRecord.
ChangesPage
Discriminated on resync: { resync: false, records: ChangeRecord[], next: number } | { resync: true, from: number }. See the watch guide for the tail-only contract this encodes. Backed by the runtime schema changesPage.
WireTag
Every discriminant a WireError can carry, closed so a caller branches exhaustively and a typo’d tag is a compile error:
- Engine errors, derived from
ENGINE_ERRORS:InvalidBoardId,EntityNotAllowed,PlaceholderUnbound,LakeUnavailable,QueryFailed,SyncFailed,ReplicaUnavailable,WriteFailed,ReservedColumnName,ColumnTypeConflict,BoardNotFound,BoardExists,UnknownColumn - Client-side transport synthesis:
MalformedResponse,TransportFailed - Server-side, raised before a handler runs:
Unauthenticated,Forbidden,InvalidInput,MissingBoardEnvelope,UnknownProcedure
Full descriptions and fields for each: the error catalog.
WireError
{ _tag: WireTag, message: string }. Backed by the runtime schema wireError.
WireEnvelope
{ status: "ok", value: unknown } | { status: "error", error: WireError }: better-result’s own shape, decoded by a discriminant check.
Contract
typeof contract.
ProcedureName
keyof Contract & string.
In
type In<K extends ProcedureName> = z.infer<Contract[K]["input"]>;
Out
type Out<K extends ProcedureName> = z.infer<Contract[K]["output"]>;
Handlers
type Handlers<Ctx> = {
[K in ProcedureName]: (input: In<K>, ctx: Ctx) => Promise<Result<Out<K>, WireError>>;
};
A server satisfies Handlers<Ctx> on its handler object is the whole conformance check.
RpcClient
type RpcClient = {
[K in ProcedureName]: (input: In<K>) => Promise<Result<Out<K>, WireError>>;
};
RpcClientOrThrow
type RpcClientOrThrow = {
[K in ProcedureName]: (input: In<K>) => Promise<Out<K>>;
};
RpcClientOptions
| Option | Type | Default | What it does |
|---|---|---|---|
url |
string |
(required) | The server node or router URL. |
boardId |
string |
undefined |
Sent as x-lakefront-board. Optional when token pins exactly one board, since the server derives it (see soleBoardOf). |
token |
string |
undefined |
Capability token, sent as Authorization: Bearer. |
fetch |
typeof globalThis.fetch |
globalThis.fetch |
Override for testing or a custom transport. |
timeoutMs |
number |
10_000 |
Per-request deadline (AbortSignal.timeout). |
retry |
{ times: number, delayMs: number } |
{ times: 2, delayMs: 50 } |
Transport-failure retries: network error, timeout, or a 5xx with no decodable envelope. times: 0 disables. |
A decoded envelope error is never retried: the server answered, and retrying a refusal is not reconciliation. Only transport failures retry, and only when the call is safe to repeat:
- Every procedure in
IDEMPOTENT_PROCEDURES(query.execute,node.stats,changes.read) always retries. - A mutation retries only when its input carries an
idempotencyKey; the WAL then answers a retried attempt with the original ack, so it can never double-apply.
The client marks such a request with IDEMPOTENT_HEADER (x-lakefront-idempotent: 1), so the router can apply the same rule without parsing bodies. Retries back off as delayMs * 2^i * (1 + random()).
Verb
const VERBS = ["read", "write", "schema", "admin"] as const;
type Verb = (typeof VERBS)[number];
Claims
| Field | Type | What it means |
|---|---|---|
boards |
readonly string[] | "*" |
Boards this token authorizes. An entry ending in * is a prefix grant (e.g. acme_*); the string "*" grants every board. |
verbs |
readonly Verb[] |
Which of read/write/schema/admin this token authorizes. |
exp |
number |
Unix seconds. Attenuations can only bring this closer, never further out. |
actor |
string (optional) |
Who the caller acts for: an audit identity, not an authorization. Once set by a caveat it cannot be changed by further attenuation, only inherited. |
TokenVerification
type TokenVerification = Result<{ claims: Claims; kid: string }, AuthFailureReason>;
AuthFailureReason
"malformed" | "unknown-key" | "bad-signature" | "expired". A caller cannot probe which check failed beyond the tag.
EngineError
The union of every tagged error class raised by the engine and carried across the wire unchanged: InvalidBoardId, EntityNotAllowed, PlaceholderUnbound, LakeUnavailable, QueryFailed, SyncFailed, ReplicaUnavailable, WriteFailed, ReservedColumnName, ColumnTypeConflict, BoardNotFound, BoardExists, UnknownColumn, each also exported by name as a TaggedError from better-result. Fields and the conditions that raise each: the error catalog.
Constants
contract
const contract: { "query.execute": ...; "mutate.insert": ...; "mutate.update": ...; "mutate.delete": ...; "changes.read": ...; "node.stats": ... };
The procedure map itself: each entry’s input/output are AOT-compiled zod schemas (z.compile), built once at module load rather than per request. See Procedures for the full table.
WIRE_TAGS
const WIRE_TAGS: readonly WireTag[];
Every value WireTag can hold, as a runtime array; see WireTag.
IDEMPOTENT_HEADER
"x-lakefront-idempotent". Marks a request as safe to retry, hedge, and fail over; the client sets it exactly when the body carries an idempotencyKey, so the router never parses bodies.
IDEMPOTENT_PROCEDURES
ReadonlySet<string> of {"query.execute", "node.stats", "changes.read"}: procedures idempotent by nature and therefore always safe to retry, hedge, and fail over. Imported by both the client’s retry gate and the router’s hedging gate, so they cannot drift.
ENGINE_ERRORS
const ENGINE_ERRORS: {
InvalidBoardId;
EntityNotAllowed;
PlaceholderUnbound;
LakeUnavailable;
QueryFailed;
SyncFailed;
ReplicaUnavailable;
WriteFailed;
ReservedColumnName;
ColumnTypeConflict;
BoardNotFound;
BoardExists;
UnknownColumn;
};
The single lookup object every derivation in the system uses: WIRE_TAGS, the engine’s own error-wrapping, and the reference catalog are all built from it, so a class added here is everywhere at once.
BASE_COLUMNS
BASE_COLUMNS = { id: "VARCHAR", updated_at: "TIMESTAMP", state: "VARCHAR" };
Fixed columns every board table has; their types are fixed by the system and never inferred from data.