runtime:db

Databases, in two tiers. The application tier is connect() and what it returns. The driver tier is what a third party needs to add a backend of their own. Both are exported from runtime:db; the split is in the documentation, not the specifier.

Capability: FileRead / FileWrite — none for sqlite::memory:

Exposed as an ES module under the runtime: scheme. Status: Available. runtime:db adds no capability of its own: opening a database is a filesystem access and is scoped as one, by the same root jail and --allow-read / --allow-write lists that back runtime:fs.

Import

JavaScript
import { connect, sqlite, sql } from "runtime:db";

connect(url, options)

Opens a connection with the driver you pass. sqlite is built in; every other driver is a package export.

JavaScript
const db  = await connect("sqlite:./app.db", { driver: sqlite });
const mem = await connect("sqlite::memory:", { driver: sqlite });
const enc = await connect("sqlite:./secret.db", { driver: sqlite, key });

// Every driver package exports its driver as `driver`, and nothing as a
// default. Two of them in one module are told apart with `as`.
import { driver as postgres } from "@opentf/esrun-postgres";
const pg = await connect("postgres://user@host/app", { driver: postgres });
OptionTypeMeaning
driverDriverRequired. The backend to open with — every driver package exports one as driver.
poolboolean | { max, idleTimeout, acquireTimeout }Pool instead of opening one connection.

Everything else in the options object belongs to the driver — key, cipher and readOnly for sqlite, a host and TLS settings for a networked one.

sqlite optionTypeMeaning
keystring | Uint8ArrayEncryption key, hex or bytes.
cipherstringCipher name; defaults to the backend's.
readOnlybooleanOpen without the ability to write.

What comes back is that driver's connection, so a driver's own surface is on the object connect returned and needs no second entry point: Redis's commands, PostgreSQL's LISTEN. The URL's scheme is checked against the driver's, so a driver pointed at a URL it does not take is refused at the call.

A key never goes in the URL

A key in a connection string ends up in logs, in error messages and in stack traces. connect("sqlite:./app.db?key=…", { driver: sqlite }) is refused, not honoured quietly. Pass it in the options object.

pool

JavaScript
const pool = await connect("postgres://host/app", { driver: postgres, pool: { max: 20 } });
await pool.query("SELECT 1");            // borrows and returns a connection
await pool.transaction(async (tx) => …); // holds one for the whole of it
await pool.withConnection(async (c) => …);  // for anything stateful across calls

A pooled connection answers the same surface a single one does, plus size, idle and pending. A connection is returned to the pool only if the driver says it is fit for the next caller (reusable), and destroyed otherwise.

sqlite: names a file format and a SQL dialect the way postgres:// names a wire protocol — not a particular implementation, which may be replaced without the URL changing.

sqlite::memory: opens a database that exists only in memory, and each connection gets its own. The named form (:memory:name), which in SQLite means sharing one, is refused rather than quietly not sharing.

Connection

MemberReturns
query(q, params?)Promise<Rows>
execute(q, params?)Promise<{ changes, lastInsertRowid }>
executeMany(sql, rows)Promise<{ changes, lastInsertRowid, results? }>
transaction(fn)whatever fn returns
withConnection(fn)whatever fn returns, with one connection held for it
subscribe(channels, handler?) / unsubscribe(channels?)Promise<void>
subscribed, subscriptionswhether it is delivering messages, and what it listens to
usable, reusablestill worth using; fit for the next caller
close()Promise<void> — also Symbol.asyncDispose
dialect, backendthe backend's dialect and name

Every one of these is on a single connection and on a pooled one, so code that holds "a connection" never has to ask which kind it has.

q is SQL text, a sql `` template, or a QueryAst. params is an array (bound by position) or an object (bound by name).

Which forms a backend takes is its own declaration — dialect.supports.queryText and supports.queryAst — and the form it does not take is refused with ERR_DB_QUERY_FORM, in either direction. sqlite: and postgres: take SQL; redis: takes command arrays and refuses SQL.

supports.transactions is the matching declaration for transaction(fn): a backend that says false refuses with ERR_DB_UNSUPPORTED rather than emitting a BEGIN it has never heard of, and its executeMany is not atomic, because there is no transaction to wrap it in.

Cancelling

JavaScript
await db.query(sql, params, { signal: AbortSignal.timeout(5_000) });

Aborting asks the backend to cancel and waits for it to answer, so the connection is left in a known state and stays usable — which is the difference between cancelling and hanging up. The rejection carries the signal's own reason rather than the backend's word for a cancelled statement, including when a streaming result is abandoned halfway.

How much can actually be cancelled is a backend question, and the interface does not pretend otherwise: sqlite: interrupts a running statement, postgres: sends a cancel on a second connection, and a backend that implements neither still rejects the caller while the work runs to completion.

JavaScript
await db.execute("INSERT INTO users (name, age) VALUES (?, ?)", ["ada", 36]);
await db.execute("INSERT INTO users (name) VALUES (:name)", { name: "grace" });

executeMany(sql, rows)

One statement, many parameter sets, one crossing and one prepare.

JavaScript
await db.executeMany("INSERT INTO users (name, email) VALUES (?, ?)", [
  ["Ada", "ada@example.com"],
  ["Grace", "grace@example.com"],
]);

The reason to reach for it is arithmetic rather than taste: a crossing costs about the same whatever it carries, so a loop that crosses once per row spends its time on the boundary instead of in the database.

It runs as one transaction

Unless one is already open, in which case it joins that one. A batch that half-applied would be a worse default than either alternative — and outside a transaction each statement would be committed durably on its own, which is the slow path this exists to avoid, arrived at by accident.

A sql `` template with values describes a single row, so it is refused here rather than silently running the first row's values for every row.

results carries one result per parameter set where the backend reports them — which the default batch path always does, since it ran the sets one at a time. It is absent when the backend answered for the batch as a whole, so check before reading it. Without it, a batch of inserts against a backend that generates keys is a batch whose keys are unreachable: the aggregate can only carry the last.

subscribe(channels, handler?)

Server-pushed messages: PostgreSQL's LISTEN/NOTIFY, Redis's pub/sub, a change stream elsewhere.

JavaScript
await conn.subscribe("orders", (payload, { channel }) => console.log(channel, payload));
conn.onMessage = (payload, context) => …;      // the catch-all
conn.onSubscribeError = (error) => …;          // the delivery loop failed
await conn.unsubscribe("orders");              // omit the name for all of them

It resolves once the backend has confirmed the subscription, so publishing immediately afterwards cannot race it. Subscribing gives the connection over to delivering messages, and on most backends it then refuses ordinary work with ERR_DB_CONNECTION_BUSY — which is the protocol's own rule as often as it is the driver's, and how you would deploy it anyway.

supports.subscriptions is the declaration; a backend without them refuses by name. A pooled connection refuses too: a subscription needs a connection that does not come back, which is the opposite of a pool's premise.

transaction(fn)

Commits when fn returns and rolls back when it throws. Nested calls become savepoints where the backend has them, so a helper that opens a transaction composes with a caller that already did instead of committing the outer one early. A rollback that itself fails never replaces the error that caused it.

JavaScript
await db.transaction(async (tx) => {
  await tx.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", [100, from]);
  await tx.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", [100, to]);
});

sql ``

Every interpolation becomes a parameter, never text. A nested fragment splices with its own values, so queries compose without either half knowing the other's placeholder numbering.

JavaScript
const active = sql`AND status = ${"active"}`;
await db.query(sql`SELECT * FROM users WHERE id > ${10} ${active}`);

Fragments and values stay apart until the backend renders them, so one template targets $1, ? and :name backends unchanged — and there is no arrangement of it that puts a value into the text.

Rows

Async-iterable, pulled one batch at a time — never the whole result, so a table larger than memory streams through at the cost of a batch.

MemberMeaning
[Symbol.asyncIterator]Streams rows.
toArray()The whole result as an array.
first()The first row, or null. Closes the cursor.
columns[{ name, declType }].
exhaustedtrue when the rows came back with the query and no cursor was opened.
close()Closes the cursor early.

Stopping early — break, return, a throw — closes the cursor on the way out and leaves the connection usable.

A result small enough to fit one batch comes back with the query itself, so no cursor is opened and the whole query costs one crossing instead of three.

One open result set per connection

A networked backend's connection is a single conversation. Querying while another result is still streaming is refused with ERR_DB_CONNECTION_BUSY rather than queued — queueing would deadlock, since the open result only finishes when the caller drains it and the caller is waiting on the queue. Finish the result, or use a second connection. (sqlite: has no such limit: each query gets its own cursor.)

Row

A lazy view over its batch, with one getter per column, so a query that selects more columns than it reads pays only for the ones it touches. A 64-bit integer arrives as a bigint only where a number would have lost it; a blob arrives as a Uint8Array.

What a column produces is the backend's decision. DbOutput — the union above — is what sqlite produces, and Row<V> is generic so a driver can say what it produces instead: @opentf/esrun-postgres decodes timestamptz into a Temporal.Instant and jsonb into the document, and types its rows accordingly. Held through the portable Connection type, a row's values are unknown, because an unknown backend decodes what it likes.

Spreading a row does not copy its columns

The getters live on the prototype, so { ...row } yields an empty object (and leaks nothing internal either). row.toObject() is how a row is materialized. JSON.stringify(row) works, and row.values() gives the columns in query order.

Errors

A failure is a DbError whose code is layered: the driver's own classification first, then a stable host code, then ERR_DB_BACKEND. A denied capability stays ERR_CAPABILITY_DENIED and a jail escape stays ERR_JAIL_ESCAPE — an application testing for those should not have to know the call went through a database. The backend's own code, where it had one, stays on e.backendCode.

JavaScript
import { DbErrorCode } from "runtime:db";

try {
  await db.execute("INSERT INTO users (email) VALUES (?)", [email]);
} catch (e) {
  if (e.code === DbErrorCode.UniqueViolation) return { taken: true };
  throw e;
}
CodeMeaning
ERR_DB_UNIQUE_VIOLATIONA unique constraint or primary key collided.
ERR_DB_FOREIGN_KEY_VIOLATIONA foreign key constraint failed.
ERR_DB_NOT_NULL_VIOLATIONA NOT NULL column was given null.
ERR_DB_CHECK_VIOLATIONA CHECK constraint failed.
ERR_DB_DEADLOCKThe transaction was chosen as a deadlock victim.
ERR_DB_SERIALIZATION_FAILUREThe transaction could not be serialized; retry it.
ERR_DB_THROTTLEDThe service is shedding load — a quota, a rate limit, a connection cap. Distinct from ERR_DB_BUSY, which is one resource held by someone else.
ERR_DB_NOT_FOUNDThe thing addressed does not exist. Distinct from an empty result: a query that matched nothing succeeded.
ERR_DB_BUSYThe database is locked by another writer.
ERR_DB_CONNECTION_LOSTThe connection went away mid-operation.
ERR_DB_AUTH_FAILEDThe server refused the credentials.
ERR_DB_TIMEOUTThe database gave up on the statement.
ERR_DB_SYNTAXThe backend could not parse the statement.
ERR_DB_UNDEFINED_TABLE / ERR_DB_UNDEFINED_COLUMNNo such table / column.
ERR_DB_READ_ONLYA write against a read-only database.
ERR_DB_QUERY_FORMThe query was handed in a form this backend does not take.
ERR_DB_UNSUPPORTEDThe backend, scheme, option or parameter type is not supported.
ERR_DB_CLOSEDThe connection is closed.
ERR_DB_CONNECTION_BUSYThe connection is already streaming a result set — only the caller draining it can free it. Distinct from ERR_DB_BUSY, which is the database refusing.
ERR_DB_BACKENDThe backend failed in a way with no portable name — check e.backendCode.

The driver tier

For adding a backend or building an ORM.

ExportPurpose
defineDriver(spec)Make a driver: name, schemes, dialect, open(url, options), and optionally pooled(url, options, poolOptions).
PooledConnectionThe pooled form pool: true builds. Subclass it to put your own surface on a pool.
BaseConnectionTransactions, savepoints, the closed-connection check. Implement _query, _execute, _close; override _executeMany for a fast batch path, _cancel to really cancel, and the _*Transaction trio for a backend without SQL.
Dialectplaceholder(i), quoteIdent(name), supports — including queryText, queryAst and transactions.
defineRowShape(columns) / decodeBatch(bytes, shape, n)The row decoder for a backend handed bytes.
defineRecordShape(columns) / Rows.fromObjects(records)The same Row contract for a backend whose values are already JavaScript.
encodeParams / splitParamsParameter encoding.
ByteWriterA growable buffer with length-prefix back-fill.
mapError / asDbErrorMap a backend's vocabulary onto the portable codes.
runBackendConformance(open)The suite that proves a backend behaves like the built-ins.
JavaScript
import { defineDriver, BaseConnection, Dialect } from "runtime:db";

const dialect = new Dialect({ name: "mydb", placeholder: (i) => `$${i}` });

class MyConnection extends BaseConnection {
  constructor(socket) {
    super({ dialect, backend: "mydb" });
    this.socket = socket;
  }
  async _query({ text, positional }) { /* … */ }
  async _execute({ text, positional }) { /* … */ }
  async _close() { /* … */ }

  // Optional. The default loops `_execute` — correct, and no faster than the
  // loop it replaces. Override it with whatever the backend does in one round
  // trip: a reused prepared statement, or pipelined protocol messages.
  async _executeMany(query, sets) { /* … */ }
}

export const driver = defineDriver({
  name: "mydb",
  schemes: ["mydb"],
  dialect,
  open: async (url, options) => new MyConnection(await open(url, options)),
});

Nothing is registered, and nothing has to be imported for its side effects: your package's export is the driver, and connect(url, { driver }) is how a caller reaches it. Export it as a named driver and nothing as a default, as the first-party packages do — one import shape for every backend, and a caller with two of them tells them apart with as.

If your rows are not bytes

The batch layout exists because a wire protocol hands over bytes and decoding them lazily is worth the machinery. A backend that never had bytes — a document store answering JSON, a graph or vector service over HTTP, an in-process engine holding objects — hands over records instead, and nothing downstream can tell the difference:

JavaScript
async _query(q) {
  const docs = await this.find(q.ast);       // already objects
  return Rows.fromObjects(docs);             // columns default to their keys
}

For a driver that wants control of batching, a RowSource may answer { records, done } — each record an array of values in column order, paired with defineRecordShape(columns) — instead of { bytes, rows, done }. Batch either way: a caller who breaks out of the loop after three rows should not have paid for three thousand.

A backend that does not speak SQL declares it, and inherits the rest unchanged:

JavaScript
const dialect = new Dialect({
  name: "mydb",
  placeholder: () => { throw new DbError("no placeholders here", { code: DbErrorCode.QueryForm }); },
  supports: { queryText: false, queryAst: true, transactions: false },
});

supports also carries your own capabilities — a vector index, a full-text mode, a graph traversal. They survive on dialect.supports untouched, and they are how an ORM branches on a backend that did not exist when it was written. driver.dialect.supports answers before a connection is opened.

Most conformance checks are written in SQL. Against such a backend they are skipped with a reason rather than failed — a check you cannot express is not a finding — and what runs is the part that holds for every backend.

Adding a networked backend needs no new runtime code — a driver is JS over runtime:net. See Databases for the guide and Internals for why it is built this way.

Last updated on
Edit this page