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, androllbackTransactionall throwlakefront: 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.
streamQuerythrowslakefront: 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. |