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

Fundamentals

Boards, watermarks, write paths, replicas, watch, and the conventions every page assumes.

This page introduces boards, watermarks, write paths, replicas, and watch, along with the conventions used in the code samples.

Boards

A board is a user-defined table and the unit of everything: caching, routing, isolation, and change history. There is no shared mega-table underneath; each board is its own table in the lake and its own replica file on a serving node.

A board comes into existence one of two ways. Write to an unknown board id and it is created, with column types inferred from the data. Or declare it first:

await engine.createBoard("billing", {
  columns: { amount: "double", currency: "text", paid: "boolean" },
  strict: true,
});

strict: true makes the board refuse writes that name undeclared columns, on every node. The schema guide covers inference, declaration, and column widening.

Every board has three base columns (id, updated_at, state) plus whatever you add. The engine sets updated_at on every write. Rows are flat records of strings, numbers, booleans, and nulls.

Watermarks

A watermark is a position in a board’s history. Every write returns the watermark it committed at. Every read reports the watermark it was served at.

By default a read may be slightly stale. Hand a watermark back and the read is at least that fresh:

const w = (await engine.update("tasks", "t1", { status: "done" })).unwrap();
await engine.query("tasks", "SELECT * FROM {{table}}", { minWatermark: w });

That is read-your-write, and it works on any node in a cluster, which is what makes failover safe. The consistency page has the full model.

Write paths

writePath decides where a write is durable when it acks. The default, "wal", commits to a write-ahead log in the catalog Postgres: one fsynced Postgres transaction, and the write is safe even if the node dies immediately after. The lake and the replicas catch up in the background. The alternative, "lake", commits straight into DuckLake: simpler, slower, no log to drain. The write paths page explains both contracts.

Replicas

Reads never scan the lake. Each board gets a local DuckDB file on the serving node, synced to the board’s history before every query, and the query runs there. A replica is a cache, never truth: delete the file and it rebuilds from the lake. This is why reads are fast, why a dead node loses nothing, and why any node can serve any board. Details on the replicas page.

Watch

The change feed is one async iterable, identical embedded and over HTTP: replay from a watermark, then follow live.

for await (const event of engine.watch("tasks", { after })) {
  if (event.resync) continue; // fell behind retention: re-read state, keep going
  console.log(event.records);
}

The watch guide covers catch-up, retention, and the resync contract.

Results and errors

Fallible operations return a Result (from better-result) instead of throwing. A refusal is a value with a _tag you can branch on:

const outcome = await engine.insert("tasks", { id: "t1", status: "open" });

outcome.unwrap(); // happy path: the watermark, or a throw
if (outcome.isErr()) {
  outcome.error._tag; // "ColumnTypeConflict", "UnknownColumn", ...
}

Three places throw instead:

  • open() throws LakeUnavailable when the catalog is unreachable, because a boot failure is fatal at almost every call site. openSafe() returns the Result form.
  • Inside engine.transaction, the tx methods throw; the enclosing call converts the failure back into a Result.
  • The client’s query builder (select, selectAll) throws on failure, because Kysely’s driver interface is exception-based.

The errors reference catalogs every tag.

SQL and the table placeholder

Queries are DuckDB SQL. It is Postgres-flavored, so ordinary SQL reads the same, and DuckDB’s analytical extensions (TRY_CAST, list(), window functions, and the rest) are available. Two rules:

  • {{table}} stands for the board’s table. SQL never names a real table, schema, or board id; the server binds the placeholder.
  • Parameters are positional ($1, $2) and travel beside the SQL, so a value can never become syntax.
await engine.query("tasks", "SELECT status, count(*) FROM {{table}} GROUP BY 1", {
  params: [],
});

Embedded or over HTTP

The engine embeds in your process: open() gives you the full surface, including transactions with reads. That fits one service owning its boards.

Serving over HTTP exists for everything else: several services or processes sharing the same boards, horizontal scale-out across nodes with a router in front, and processes that should hold a capability token rather than the catalog credentials. serve() puts the wire protocol in front of an engine; connect() is the client. Reads, writes, and watch work identically on both surfaces; multi-board transactions are the one thing that stays embedded. The HTTP guide covers when and how.

Runtime

Lakefront ships TypeScript source and requires Bun 1.2 or later. Node cannot run it.

Last updated on September 10, 2026

Was this page helpful?