Write data
Insert, patch, delete, and append-upsert rows on a board, from the embedded engine or the HTTP client.
Every write goes to the board’s write path, never to a replica, and every write returns the watermark it committed at. Hand that watermark back as minWatermark (Engine) or through atLeast() (client) to read your own write. See the consistency model for the full picture.
Insert one row
import { Engine } from "lakefront";
const result = await engine.insert("acme_roadmap", {
id: "task-1",
state: "active",
title: "Ship the thing",
priority: 3,
});
const watermark = result.unwrap();import { connect } from "lakefront/client";
const db = connect({ url }).board("acme_roadmap");
const watermark = (
await db.insert({ id: "task-1", state: "active", title: "Ship the thing" })
).unwrap();insert and every mutation below accept a board that does not exist yet under the default autoCreate: "on-write", and a column that has not been seen before under the default columns: "infer". Both are configurable. See the schema guide.
Update one row
await engine.update("acme_roadmap", "task-2", { status: "done" });
The client version is identical in shape: db.update(id, patch, options?).
Delete one row
await engine.delete("acme_roadmap", "task-1");
// db.delete("task-1") on the client
upsert: append-based, opt-in
upsert writes a new version of a row instead of issuing an UPDATE. An UPDATE on a DuckLake table has to locate the existing rows and mark them deleted before it can write the new values. Appending skips that step entirely, since the read path already reduces by primary key on the way out (see the performance numbers).
That speed has two costs, which is why update() remains the default and upsert is opt-in:
- Rows must be complete:
upsertreplaces a row, it does not patch one. - Superseded versions accumulate in the table until
compact()reclaims them. - The winner across concurrent writers is chosen by version stamp, so it’s last-writer-wins to whatever precision the writers’ clocks agree on.
await engine.upsert("acme_roadmap", {
id: "task-1",
state: "active",
title: "Ship the thing",
status: "in_review",
});
// Later, once churn has built up superseded versions:
await engine.compact("acme_roadmap");
upsert and compact are engine-only. The wire contract does not expose them over HTTP: they’re data-shape and maintenance decisions made next to the engine, not a client call.
Batch writes: the Many forms
Each singular verb has a batch counterpart that takes an array and lands it as one write: insertMany, updateMany, deleteMany on the client, and those three plus upsertMany on the engine. A singular call is sugar over its Many form with a one-element array.
insertMany
await engine.insertMany("acme_roadmap", [
{ id: "task-1", state: "active", title: "Ship the thing", priority: 3 },
{ id: "task-2", state: "active", title: "Fix the bug", priority: 1 },
]);await db.insertMany([
{ id: "task-1", state: "active", title: "Ship the thing", priority: 3 },
{ id: "task-2", state: "active", title: "Fix the bug", priority: 1 },
]);updateMany: many patches, one write
updateMany applies many single-row patches as one atomic write: the shape an OLTP loop actually produces (patch row after row of one board) without paying a WAL append or lake commit per row. Patches apply in array order, so two patches to the same id stack exactly as two separate update() calls would.
await engine.updateMany("acme_roadmap", [
{ id: "task-1", patch: { status: "in_review" } },
{ id: "task-2", patch: { status: "done" } },
]);await db.updateMany([
{ id: "task-1", patch: { status: "in_review" } },
{ id: "task-2", patch: { status: "done" } },
]);On the wire this is mutate.update {updates}.
deleteMany
await engine.deleteMany("acme_roadmap", ["task-1", "task-2"]);
// db.deleteMany(["task-1", "task-2"]) on the client
On the wire this is mutate.delete {ids}.
upsertMany
await engine.upsertMany("acme_roadmap", [
{ id: "task-1", state: "active", title: "Ship the thing", status: "in_review" },
{ id: "task-2", state: "active", title: "Fix the bug", status: "done" },
]);
Engine-only, same as upsert.
Options every write accepts
interface WriteOptions {
readonly idempotencyKey?: string;
readonly actor?: string;
}
actor is recorded on the WAL records a write produces (audit identity for the change feed) under writePath: "wal" only. The lake write path keeps only current state, so there is nothing to attach it to there. idempotencyKey makes a mutation safely retryable: see the idempotency guide for the full contract.
On the client, the same knob is MutateOptions, passed as a second argument:
import type { MutateOptions } from "lakefront/client";
await db.insertMany(rows, { idempotencyKey: crypto.randomUUID() } satisfies MutateOptions);
Handling the result
Every write returns Result<number, EngineError> on the engine and Result<number, WireError> on the client: the number is the watermark. The happy path unwraps:
const watermark = (await engine.insertMany("acme_roadmap", rows)).unwrap();
A refusal is a typed value, not a throw. Branch on error._tag:
const result = await engine.insertMany("acme_roadmap", rows);
if (result.isErr()) {
switch (result.error._tag) {
case "ReservedColumnName":
// a column name started with "_": see /guides/schema
break;
case "ColumnTypeConflict":
// a value didn't fit the column's inferred type
break;
default:
console.error(result.error.message);
}
}
The full tag catalog is in the error reference.