API reference
The entire public surface fits on one screen, on purpose. Growing it requires a design-doc update in the same PR.
import { defineSchema, larva, t } from "@larva-db/core";
const db = larva({ schema }); // connect (or create)
await db.sql`SELECT * FROM users WHERE age > ${21}`; // tagged template (primary API)
await db.query("SELECT * FROM users WHERE age > ?", [21]); // raw string + positional params
await db.transaction(async (tx) => { /* … */ }); // all-or-nothing, one commit
const past = await db.asOf(new Date(Date.now() - 600_000)); // read-only past snapshot
await db.rollbackTo(past.version); // restore (itself undoable)
await db.export({ format: "postgres" }); // the escape hatch
await db.vacuum(); // reclaim storage outside retention
await db.upgrade(); // one-way flip to the top format
await db.inspect(); // read-only physical layout: chunks + zone mapslarva(options)
| Option | Default | |
|---|---|---|
schema | — | code-first schema from defineSchema — authoritative; drift vs. the store is a loud startup error, except a plain nullable column added in code, which auto-migrates as additive ALTER TABLE |
prefix | "larva/" | blob-path prefix the database lives under; one store can hold many databases |
store | Vercel Blob | any StorageAdapter — ships with S3Adapter for AWS S3 / Cloudflare R2 |
commitLog | false | create new databases in format 3; never changes existing ones |
import { larva, S3Adapter } from "@larva-db/core";
const db = larva({
schema,
store: new S3Adapter({
bucket: "my-bucket",
endpoint: "https://<account>.r2.cloudflarestorage.com", // omit for AWS S3
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
}),
});Schema: defineSchema and t
const schema = defineSchema(
{
invoices: {
number: t.sequence().primaryKey(), // auto-assigned integer
customer: t.text(),
total: t.real(),
createdAt: t.timestamp().partitionBy(),
},
grants: {
id: t.text().primaryKey(),
userId: t.text().references("users.id"),
feature: t.text(),
},
},
{ uniques: { grants: [["userId", "feature"]] } }, // composite unique constraints
);Column types: t.text(), t.integer(), t.real(), t.boolean(), t.timestamp() (ISO 8601 text), t.sequence(), t.uuid().
Column modifiers:
.primaryKey()— exactly one per table; tables without one get an implicit ULIDid..unique()— enforced on INSERT and upsert; usable as anON CONFLICTtarget..references("table.column")— becomes a realFOREIGN KEYin the Postgres export (not enforced at runtime)..partitionBy()— at most one per table; maintains zone-map statistics so range/equality filters on this column prune storage reads..index()— maintains a secondary index so=/IN/range filters on this column prune storage reads. Performance-only (a stale or missing index can never change results); synced automatically at connect, with a one-commit backfill over existing data. The primary key and the.partitionBy()column never need it.
t.sequence()
An auto-assigned integer: omit it on INSERT and read it back with RETURNING. Values come from CAS-claimed ranges off the commit hot path, so drawing numbers never contends with writes; ranges are disjoint across processes, so numbers are unique across concurrent writers by construction. Gappy on crash — exactly the Postgres sequence contract.
t.uuid()
An auto-ID text column: omit it on INSERT and Larva fills a time-ordered UUID (v7), readable back with RETURNING. Unlike t.sequence() there is nothing to coordinate — each writer invents its own value, so ID generation can never contend, even across many processes. Time-ordering keeps new rows clustered in chunk zone maps, so primary-key pruning stays effective (a random UUIDv4 would scatter). Supplying an explicit value is respected. Prefer this over t.sequence() unless you need small human-facing numbers (invoice #42).
Composite unique constraints
Declared in defineSchema’s second argument; two or more columns each; a key containing SQL NULL never conflicts. Addressable as multi-column ON CONFLICT targets.
Typed rows
import type { InferRow } from "@larva-db/core";
type Invoice = InferRow<typeof schema, "invoices">;
// { number: number; customer: string | null; total: number | null; createdAt: string | null }
const rows = await db.sql<Invoice>`SELECT * FROM invoices`;db.transaction(fn, opts?)
The callback runs against one pinned snapshot with read-your-writes; everything it writes lands in one atomic commit. On conflict with a concurrent writer, the commit protocol rebases (disjoint changes — staged data is reused) or re-runs the whole callback against a fresh snapshot (overlapping changes). Throwing aborts cleanly: nothing lands.
db.export({ format })
| Format | Returns | Notes |
|---|---|---|
"postgres" | string | one pg_dump-shaped .sql file — CREATE TABLE with real types, COPY blocks, FKs after the data. psql $DATABASE_URL < export.sql is the whole migration |
"sqlite" | Uint8Array | a genuine .db file (needs the bun runtime) → Turso, D1, anywhere |
"json" | Record<string, Row[]> | every table |
"csv" | Record<string, string> | one CSV per table |
db.vacuum(opts?)
Drops history outside retention (retainDays: 7, retainVersions: 50 — whichever keeps more) and deletes chunks referenced by no retained version. Safe alongside readers and writers.
db.upgrade()
Flips the store to the top format — the ordered commit log plus two-tier fast appends — one atomic commit, one-way, idempotent. Data, history, and rollback survive; clients older than the format then refuse loudly instead of writing through the wrong protocol.
db.inspect(version?)
Read-only introspection of the store’s physical layout: version, committedAt, formatVersion, and per-table chunk lists with each chunk’s row count and primary-key/partition zone-map min/max. A derived, format-agnostic projection of the otherwise-private manifest (never the raw thing, so on-store format evolution can’t break the contract), with no blob URLs — the private-data invariant holds. Reads manifest metadata only, never a chunk — strictly lighter than export().
With a version number it time-travels to any retained past version, exactly like asOf (throws VERSION_NOT_FOUND if pruned). One visibility note: fast-append rows that are durable but not yet folded aren’t in any chunk yet, so they don’t appear here until the fold — same as for any other cross-instance reader.
This is what powers the data viewer — the chunk panel and version scrubber are inspect() calls.
Errors
All machine-readable, by design:
| Error | Meaning |
|---|---|
SqlError (.code) | unsupported/invalid SQL — the message names the feature and the alternative |
SchemaError | drift, bad schema declarations, type mismatches |
ConflictError | a commit lost to concurrent writers after exhausting retries — never silent; surface it |
FormatError | the store’s format is newer than this client — update the package |
Everything here is also a shell command — see the CLI.