The relational lens

July 3, 2026 · View on GitHub

Part of the LibreDB guides. See also: key-value · document · catalog.

The table lens is the typed face over the same kernel: a table is a schema-validated collection of rows. Where doc accepts any JSON object, table declares its columns and their types up front and enforces them at insert. A row is stored under <table>:<pk>, so a table is literally a schema-validated document collection — it reuses the document codec and the same collection-isolation boundary.

You declare a schema (the columns, their types, and which one is the primary key) when you open the table:

import { open, table } from "@libredb/libredb";

const db = open(); // or open({ path: "data.libredb" }) for durability

const users = table(db, "users", {
  primaryKey: "id",
  columns: { id: "string", name: "string", age: "number", active: "boolean" },
});

users.insert({ id: "1", name: "Ada", age: 36, active: true });
users.insert({ id: "2", name: "Grace", age: 45, active: false });

users.get("1"); // { id: "1", name: "Ada", age: 36, active: true }
users.get("missing"); // undefined

db.close();

A column type is one of "string", "number", "boolean", or "object" (a plain JSON object). A "number" column accepts only finite numbers — NaN and Infinity are rejected at insert (JSON cannot represent them, so they would round-trip as a schema-violating null). The primaryKey must name a declared "string" column — it becomes the kernel key.

Validation is strict at insert

Insert rejects a row that is missing a declared column, has a wrong-typed value, or carries a field the schema does not declare — there is no silent coercion or field-dropping:

users.insert({ id: "3", name: "Edsger" });
// throws: missing required column "age"

users.insert({ id: "3", name: "Edsger", age: "old", active: true });
// throws: column "age" expected number, got string

users.insert({ id: "3", name: "Edsger", age: 40, active: true, role: "ops" });
// throws: unknown column "role" (not declared in the table schema)

Writes report what they changed

Like the other lenses, every write returns a WriteResult ({ changed }):

users.insert({ id: "1", name: "Ada Lovelace", age: 36, active: true }).changed; // 1 (overwrote)

users.delete("2").changed; // 1 (it existed)
users.delete("2").changed; // 0 (already gone)

Querying — where, select, join

Reads return a chainable Query (which is itself a lazy, re-iterable Result). where(predicate) keeps the rows whose top-level fields all equal the predicate's — deep and type-sensitive, just like document find, so 36 does not match "36". select(...columns) projects each row down to the named columns. They compose in any order:

const people = table(open(), "people", {
  primaryKey: "id",
  columns: { id: "string", name: "string", team: "string", active: "boolean" },
});

people.insert({ id: "1", name: "Ada", team: "research", active: true });
people.insert({ id: "2", name: "Grace", team: "research", active: false });
people.insert({ id: "3", name: "Edsger", team: "ops", active: true });

people.where({ team: "research", active: true }).select("name").toArray();
// [{ name: "Ada" }]

join(other, leftField, rightField) is an inner equi-join via nested loop: it pairs each left row with every right row whose rightField equals the left row's leftField. The result rows carry every column of both sides, qualified as table.column, so the two sides never collide — and select/where name a qualified column the same way as any other:

const orders = table(db, "orders", {
  primaryKey: "id",
  columns: { id: "string", userId: "string", total: "number" },
});

orders.insert({ id: "o1", userId: "1", total: 42 });
orders.insert({ id: "o2", userId: "1", total: 7 });

users.join(orders, "id", "userId").select("users.name", "orders.total").toArray();
// [
//   { "users.name": "Ada Lovelace", "orders.total": 42 },
//   { "users.name": "Ada Lovelace", "orders.total": 7 },
// ]

where is O(n) in the table size and join is O(n*m) — nested-loop, no indexes (the same deliberate v1 omission as the document lens). Unmatched rows on either side are dropped (inner join), and a left key matching several right rows fans out to several result rows.