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

```ts
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

```ts
function connect(options: ClientOptions): { board(boardId: string): BoardClient };
```

| Parameter | Type            | Default    | Description                          |
| --------- | --------------- | ---------- | ------------------------------------ |
| `options` | `ClientOptions` | (required) | See [ClientOptions](#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.

```ts
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

```ts
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](#boardqueryoutcome).

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

### select

```ts
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

```ts
selectAll();
```

The whole-row variant of `select`.

### db

```ts
db: Kysely<BoardSchema>;
```

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

### atLeast

```ts
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.

```ts
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](/guides/idempotency) for the exactly-once contract that makes a keyed retry safe.

### insert

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

Inserts one row. Sugar over `insertMany`.

### insertMany

```ts
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](#mutateoptions). |

### update

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

Patches one row by id. Sugar over `updateMany`.

### updateMany

```ts
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](#mutateoptions). |

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

### delete

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

Deletes one row by id. Sugar over `deleteMany`.

### deleteMany

```ts
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](#mutateoptions). |

**Feed**

### watch

```ts
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`.

```ts
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](/guides/watch).

**Operations**

### lastRead

```ts
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](#lastread).

### stats

```ts
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](/reference/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](/guides/idempotency). |

### WatchEvent

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

The same discriminant `ChangesPage` carries (see the [wire contract](/reference/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

```ts
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](/guides/transactions) 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.                                        |
