---
title: Fundamentals
description: 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:

```ts
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](/guides/schema) 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:

```ts
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](/concepts/consistency) 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](/concepts/write-paths) 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](/concepts/replicas).

## Watch

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

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

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

```ts
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](/guides/http) covers when and how.

## Runtime

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