Skip to content
Lakefront
Esc
↑↓navigate↵open⌘Jpreview
On this page

Client

connect and BoardClient, covering raw SQL queries, the board-rooted builder, mutation methods, and watch for lakefront/client.

import { connect } from "lakefront/client";

The wire client: connect returns a client scoped to one board, with the engine’s own query surface over HTTP, a board-rooted builder on top, and typed methods for writes and the change feed.

Functions

connect

function connect(options: ClientOptions): { board(boardId: string): BoardClient };
Parameter Type Default Description
options ClientOptions (required) See ClientOptions.

Returns { board(boardId): BoardClient }. A client only exists in the context of a board; there is no way to issue a query without calling board first.

const client = connect({ url: "http://127.0.0.1:8080" });
const board = client.board("acme_roadmap");

BoardClient

client.board(boardId) returns a BoardClient. It hides the CQRS split: query, select, and db go through query.execute; the mutation methods go through the mutate.* procedures and return the watermark they committed at. A board is one table, so reads are rooted at the board: nothing underneath it to name.

Reads

query

query(
  sql: string,
  options?: { params?: readonly Cell[]; minWatermark?: number },
): Promise<Result<BoardQueryOutcome, WireError>>
Parameter Type Default Description
sql string (required) SQL using {{table}} for the board’s table.
options.params readonly Cell[] [] Positional bind values ($1, $2, …).
options.minWatermark number this client’s atLeast floor Read-your-write floor for this call.

Raw SQL against this board: the same surface as engine.query, over the wire. The outcome mirrors engine.query’s shape exactly, plus servedBy; see BoardQueryOutcome.

const outcome = (await board.query("SELECT count(*) AS n FROM {{table}}")).unwrap();
outcome.result.rows; // columnar, like engine.query

select

select<SE extends SelectExpression<BoardSchema, "items">>(
  selections: SelectArg<BoardSchema, "items", SE>,
)

Starts a Kysely chain rooted at the board: a board is one table, so there is no table to name. .where(), .orderBy(), .execute() follow.

selectAll

selectAll();

The whole-row variant of select.

db

db: Kysely<BoardSchema>;

The raw Kysely instance, for joins and subqueries the rooted select/selectAll chains do not reach.

atLeast

atLeast(watermark: number): BoardClient

Returns a new BoardClient whose reads refuse to be served by a replica older than watermark: read-your-write. Never mutates the client it is called on.

const written = await board.insert({ id: "i1", state: "active", title: "first item" });
written.unwrap("insert failed"); // -> the watermark it committed at

const fresh = board.atLeast(written.unwrap());
await fresh.selectAll().execute();

Writes

Mutations return the watermark they committed at, or a typed failure. A rejected write is never retried automatically unless it carries an idempotencyKey: it may have landed, and the caller is the only one who knows whether replaying it is safe. A keyed mutation is safe to replay (the server answers a repeat with the original ack), and the transport retries and fails over accordingly. See the idempotency guide for the exactly-once contract that makes a keyed retry safe.

insert

insert(row: Row, options?: MutateOptions): Promise<Result<number, WireError>>

Inserts one row. Sugar over insertMany.

insertMany

insertMany(rows: readonly Row[], options?: MutateOptions): Promise<Result<number, WireError>>
Parameter Type Default Description
rows readonly Row[] (required) Rows to insert.
options MutateOptions {} See MutateOptions.

update

update(id: string, patch: Row, options?: MutateOptions): Promise<Result<number, WireError>>

Patches one row by id. Sugar over updateMany.

updateMany

updateMany(
  updates: ReadonlyArray<{ readonly id: string; readonly patch: Row }>,
  options?: MutateOptions,
): Promise<Result<number, WireError>>
Parameter Type Default Description
updates ReadonlyArray<{ id: string; patch: Row }> (required) One entry per row to patch.
options MutateOptions {} See MutateOptions.

Many single-row patches as one atomic write; see Engine.updateMany.

delete

delete(id: string, options?: MutateOptions): Promise<Result<number, WireError>>

Deletes one row by id. Sugar over deleteMany.

deleteMany

deleteMany(ids: readonly string[], options?: MutateOptions): Promise<Result<number, WireError>>
Parameter Type Default Description
ids readonly string[] (required) Row ids to delete.
options MutateOptions {} See MutateOptions.

Feed

watch

watch(
  options?: { after?: number; signal?: AbortSignal },
): AsyncGenerator<WatchEvent, void, undefined>
Parameter Type Default Description
options.after number node’s current head Watermark to start from; a past value catches up first.
options.signal AbortSignal undefined Ends the loop when aborted.

Follows the board’s change feed over the node’s SSE stream, as an async iterable: the same surface Engine.watch offers embedded, carried over GET /watch.

for await (const event of board.watch({ after })) {
  if (event.resync) {
    // re-read current state, then continue -- the cursor already moved
  } else {
    // event.records
  }
}

Ends when signal aborts or the server closes the stream (a token past its expiry does that; reconnect with a fresh one). A server-sent error event is thrown, so an unhandled for await propagates it to the caller. Design, retention, and what resync means for a consumer are covered in the watch guide.

Operations

lastRead

lastRead(): LastRead

What the client’s most recent read reported about where it was served from: not needed for correctness, useful for demonstrating cache affinity. See LastRead.

stats

stats(): Promise<Result<EngineStats & { nodeId: string; boards: string[] }, WireError>>

Calls node.stats.

Types

ClientOptions

Transport knobs shared with the wire-level client: exactly RpcClientOptions from lakefront/contract.

Field Type Default Notes
url string (required) URL of a server node or router.
token string undefined Capability token, sent as Authorization: Bearer.
timeoutMs number 10_000 Per-request deadline.
retry { times: number; delayMs: number } { times: 2, delayMs: 50 } Transport-failure retries. times: 0 disables.
fetch typeof globalThis.fetch globalThis.fetch Injectable transport, for tests.

See the wire contract for the retry-eligibility rule: idempotent procedures always, mutations only when the input carries an idempotencyKey.

BoardClientOptions

ClientOptions & { readonly boardId: string }. What BoardClient.create (and, through it, connect(...).board(boardId)) takes.

BoardQueryOutcome

What query resolves to: engine.query’s outcome shape plus servedBy, naming which node served this request over HTTP.

Field Type
result { columns: string[], rows: Cell[][] }
sync SyncResult
cache "hit" | "rebuild" | "reattach"
syncMs / queryMs number
watermark number
servedBy string

MutateOptions

Field Type Notes
idempotencyKey string (optional) Makes the mutation exactly-once: a replay with the same key answers with the original ack instead of applying again, and the transport may then retry and fail over. Semantics: the idempotency guide.

WatchEvent

type WatchEvent =
  | { readonly resync: false; readonly records: readonly ChangeRecord[] }
  | { readonly resync: true; readonly from: number };

The same discriminant ChangesPage carries (see the wire contract), minus the paging cursor the iterator manages for you. ChangeRecord is { seq: number, kind: WalKind, payload: WalPayload, actor?: string }.

LastRead

Field Type
watermark number | null
servedBy string | null
cache string | null

ItemRow

interface ItemRow {
  id: string;
  updated_at: number | null;
  state: string | null;
  [column: string]: Cell;
}

interface BoardSchema {
  items: ItemRow;
}

The three named fields are the TypeScript view of BASE_COLUMNS from lakefront/contract (id: VARCHAR, updated_at: TIMESTAMP, state: VARCHAR), columns every board has. Cell is string | number | boolean | null.

A TIMESTAMP reads back as epoch microseconds in UTC, so new Date(row.updated_at / 1000) gives a Date. The engine sets updated_at on every write.

The index signature allows user-defined columns. The client checks the wire types and query structure but does not know each board’s schema.

Limits

The Kysely dialect (dialect.ts) throws rather than silently downgrading behavior:

  • No transactions. beginTransaction, commitTransaction, and rollbackTransaction all throw lakefront: transactions are not supported on the read path. Reads are served from a per-board replica synced per request, so there is no cross-request transaction to begin. See the transactions guide for what runs transactionally instead.
  • No streaming. streamQuery throws lakefront: streaming is not supported.

Lower-level exports

Plumbing for people building their own layer over the wire contract rather than using BoardClient directly.

Export What it is
LakefrontDialect The Dialect implementation BoardClient wires into Kysely: a Postgres-flavoured compiler (DuckDB’s SQL is Postgres-flavoured) driving a transport that speaks the wire contract instead of a database protocol.
newRequestState / RequestState Constructs the mutable per-request state (minWatermark, lastWatermark, lastServedBy, lastCache) a dialect instance is bound to. atLeast() produces a client bound to a fresh one rather than mutating a shared object.
PlaceholderPlugin The Kysely plugin that rewrites every table reference into a {{entity}} placeholder token, so the SQL leaving the client names no schema and no board id.
CLIENT_ENTITIES ReadonlySet<string> of entities a client may name ({"items"}), mirroring the server’s allowlist. The server is still the enforcement point; this only fails faster.
PLACEHOLDER_PATTERN /\{\{([A-Za-z0-9_]{1,32})\}\}/gu: the regex a {{token}} must match.
bindingsFrom bindingsFrom(sql: string): { token: string; entity: string }[], recovers entity bindings from compiled SQL by scanning for placeholder tokens, rather than carrying a side-channel map.

Last updated on September 10, 2026

Was this page helpful?