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

Query

Run SQL against a board's replica from the engine, or build it with Kysely through the HTTP client.

Reads run against the board’s local DuckDB replica, synced to at least the requested watermark first. A query never touches the truth store directly.

SQL dialect

Queries run as DuckDB SQL, not Postgres’s dialect, though DuckDB’s own SQL is close enough to Postgres’s that most statements read the same. DuckDB’s analytical functions are available too, things like TRY_CAST and list() that Postgres doesn’t have. For the {{table}} placeholder and the $1/$2 params convention every query uses, see the fundamentals guide.

engine.query

const outcome = (
  await engine.query(
    "acme_roadmap",
    "SELECT id, title FROM {{table}} WHERE status = $1 ORDER BY id",
    { params: ["open"] },
  )
).unwrap();

outcome.result.columns; // ["id", "title"]
outcome.result.rows; // [["task-1", "Ship the thing"], ...]

{{table}} is a placeholder, not the board’s real table name: it is bound server-side against a fixed entity allowlist, which is what makes cross-board access unrepresentable in the query language rather than merely checked. params are positional ($1, $2, …) and carried separately from the SQL text, so a value can never become syntax.

QueryOutcome

interface QueryOutcome {
  readonly result: { columns: string[]; rows: Cell[][] };
  readonly sync: SyncResult; // { from, to, skipped, changesApplied, rowsInserted, rowsDeleted, columnsAdded }
  readonly cache: "hit" | "rebuild" | "reattach";
  readonly syncMs: number;
  readonly queryMs: number;
  readonly watermark: number;
}

result is columnar: an array of columns and an array of row-arrays, not row objects, because building one object per row is exactly the cost a JS serving layer burns its latency budget on for a wide read.

Field What it tells you
sync What the pre-query sync did, or that it was skipped because the replica was already current
cache Whether the replica was already attached (see below)
syncMs / queryMs Freshness cost split from execution cost
watermark This read’s own watermark, usable as a later minWatermark

cache is one of three outcomes:

cache Meaning
hit The replica was already attached
rebuild The replica was rebuilt from the lake
reattach The replica was reattached from disk

Read-your-write: minWatermark and atLeast

Pass minWatermark to refuse a read served by a replica older than a watermark you already hold, typically one a write just returned:

const w = (await engine.update("acme_roadmap", "task-2", { status: "done" })).unwrap();
const fresh = await engine.query("acme_roadmap", "SELECT status FROM {{table}} WHERE id = $1", {
  params: ["task-2"],
  minWatermark: w,
});

The client’s equivalent is atLeast(watermark), which returns a new BoardClient bound to that floor rather than mutating the one you called it on. The full watermark model, including what “at least” costs, cross-board staleness bounds, and why this is the one case that pays a round-trip on an otherwise-current replica, is covered in the consistency model.

Query over HTTP

The client’s query is engine.query over the wire. Same SQL, same {{table}}, same options, same outcome shape. The board handle already knows its board, so there is no first argument:

import { connect } from "lakefront/client";

const db = connect({ url }).board("acme_roadmap");

const outcome = (
  await db.query("SELECT id, title FROM {{table}} WHERE status = $1 ORDER BY id", {
    params: ["open"],
  })
).unwrap();

outcome.result.rows; // columnar, exactly like engine.query
outcome.servedBy; // plus which node answered -- the one extra field

Build queries with Kysely

select and selectAll start a Kysely chain rooted at the board: a board is one table, so there is no table to name. The chain returns row objects rather than columns:

const rows = await db
  .select(["id", "title"])
  .where("status", "=", "open")
  .orderBy("id")
  .execute();
const built = db.select(["id", "title"]).where("status", "=", "open");

built.compile().sql; // Postgres-flavoured SQL with a placeholder, never a real table name
built.compile().parameters; // ["open"]

A plugin rewrites the table reference into a placeholder before the query leaves the client (the same mechanism as {{table}}), so compiled SQL never names a schema or a board. compile() is Kysely’s escape hatch for inspecting what will be sent without executing it.

After any mutation, db.atLeast(watermark) chains the same way:

const w = (await db.insert({ id: "task-4", state: "active", title: "Write docs" })).unwrap();
const openTasks = await db.atLeast(w).select(["id"]).where("status", "=", "open").execute();

db.lastRead() reports what the previous read did: { watermark, servedBy, cache }. It’s useful for demonstrating cache affinity or debugging routing; correctness doesn’t depend on it. For joins or subqueries beyond the rooted chain, the raw Kysely instance is db.db.

withReplica: low-level access

engine.withReplica(boardId, fn) hands fn the raw pieces query() assembles from: a leased DuckDB connection, the attached LocalReplica, the Lake, and the HeadCache. Attaching the replica and tracking it as busy are handled for you; syncing is not. query() calls sync before running SQL, withReplica does not, so a caller that skips it may run against a replica that is behind the lake.

Last updated on September 10, 2026

Was this page helpful?