Databases
runtime:db connects to a database, runs queries, and streams the results. It ships with a SQLite driver; anything else is a package, and the pieces those drivers are built from are exported alongside.
One call opens every database, and you pass the driver to it. A driver is a value you import, not a global you install by importing it for its side effects — so the backend is visible at the call, and what comes back is that driver's connection rather than the portable minimum.
import { connect, sqlite, sql } from "runtime:db"; const db = await connect("sqlite:./app.db", { driver: sqlite }); await db.execute(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE ) `); await db.execute(sql`INSERT INTO users (name, email) VALUES (${"Ada"}, ${"ada@example.com"})`); for await (const user of await db.query("SELECT id, name FROM users")) { console.log(user.id, user.name); } await db.close();
A database file is a file, and is scoped like one — the same root jail and --allow-read / --allow-write lists as file handling. esrun --allow-read=./data --allow-write=./data app.js is enough to open sqlite:./data/app.db for writing and nothing else. See Securing Runtime.
Passing values
Never build SQL by concatenation. Both of these bind values as parameters:
// A tagged template — every ${…} becomes a parameter. await db.query(sql`SELECT * FROM users WHERE email = ${email}`); // Or placeholders, by position or by name. await db.query("SELECT * FROM users WHERE email = ?", [email]); await db.query("SELECT * FROM users WHERE email = :email", { email });
The template keeps the fragments and the values apart until the backend renders them, so there is no arrangement of it that puts a value into the text — and the same template works against a backend that spells placeholders $1 instead of ?.
Fragments compose:
const recent = sql`AND created_at > ${cutoff}`; const rows = await db.query(sql`SELECT * FROM posts WHERE author = ${id} ${recent}`);
Reading results
query() returns a result set that streams. Iterate it for a large table:
for await (const row of await db.query("SELECT * FROM events")) { process(row); }
…or collect it when the result is small:
const users = await (await db.query("SELECT * FROM users")).toArray(); const one = await (await db.query("SELECT * FROM users WHERE id = ?", [1])).first();
Rows arrive a batch at a time, so a table larger than memory costs one batch — and stopping early (break) closes the cursor and leaves the connection usable.
A row is a lazy view over its batch, so a column you never read is never decoded. That means { ...row } does not copy the columns — it gives an empty object. Use row.toObject(). JSON.stringify(row) works as you would expect.
const row = await (await db.query("SELECT id, name FROM users")).first(); row.name; // "Ada" row.toObject(); // { id: 1, name: "Ada" } JSON.stringify(row); // '{"id":1,"name":"Ada"}' { ...row }; // {} — use toObject()
Large integers come back as a bigint only when a number would have lost the value, so an id of 9007199254740993 survives; blobs come back as Uint8Array.
Inserting many rows
const rows = users.map((u) => [u.name, u.email]); await db.executeMany("INSERT INTO users (name, email) VALUES (?, ?)", rows);
One statement, many parameter sets — crossed once and prepared once. A loop of execute() calls does the same work but pays a boundary crossing per row, and for a bulk load that is where nearly all of the time goes. It runs as a single transaction unless one is already open, so a batch either applies completely or not at all.
Transactions
await db.transaction(async (tx) => { await tx.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", [amount, from]); await tx.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", [amount, to]); });
Commits when the function returns, rolls back when it throws. Nested calls become savepoints, so a helper that opens a transaction works whether or not its caller already did:
async function addUser(conn, name) { return conn.transaction(async (tx) => tx.execute("INSERT INTO users (name) VALUES (?)", [name])); } await db.transaction(async (tx) => { await addUser(tx, "Ada"); // a savepoint, not a second transaction await addUser(tx, "Grace"); });
Handling failures
Errors carry a portable code, so an application can branch on what happened without knowing which database said it:
import { DbErrorCode } from "runtime:db"; try { await db.execute("INSERT INTO users (email) VALUES (?)", [email]); } catch (e) { if (e.code === DbErrorCode.UniqueViolation) return { error: "that email is taken" }; throw e; }
A denied capability keeps its own code (ERR_CAPABILITY_DENIED) rather than being flattened into a database error, so the two stay distinguishable.
In-memory databases
sqlite::memory: gives you a database that exists only in memory — ideal for tests, and for using SQL as a query language over data you already have:
const db = await connect("sqlite::memory:", { driver: sqlite });
It touches no filesystem, so it needs no capability: it works under esrun with nothing granted. Each connection gets its own database.
Encryption
const key = crypto.getRandomValues(new Uint8Array(32)); const db = await connect("sqlite:./secret.db", { driver: sqlite, key });
Encryption is experimental upstream. The key goes in the options object — a key in the connection string ends up in logs, error messages and stack traces, so connect("sqlite:./app.db?key=…", { driver: sqlite }) is refused rather than honoured quietly.
Other databases
sqlite is the driver that ships in the runtime. Every other one is ordinary JavaScript over runtime:net — no native code, and nothing added to the runtime for it. Importing one gives you a driver, and the call is the call you already know:
import { connect } from "runtime:db"; import { driver } from "@opentf/esrun-postgres"; const db = await connect("postgres://user@host/db", { driver });
Everything above — sql, streaming rows, transactions, the portable error codes — works the same against it, because drivers are built on the same exported pieces the built-in one is. Pooling is the same call too: connect(url, { driver, pool: { max: 20 } }) gives a pool that presents exactly what one connection does.
Every driver package exports its driver under the same name — driver — and nothing as a default, so the import reads the same whichever backend it is and { driver } is the whole of the option. Two of them in one module are import { driver as postgres }.
| Package | Schemes | |
|---|---|---|
| built in | sqlite: | a file format and a SQL dialect |
@opentf/esrun-postgres | postgres: postgresql: | the wire protocol, Temporal types, LISTEN/NOTIFY |
@opentf/esrun-redis | redis: rediss: | RESP3, pub/sub, pipelining, cluster, Sentinel, and a Redis client alongside the backend |
A backend that is not a SQL database
Redis is the one that is not, and the shape it takes is worth knowing before you write portable code. Commands are arrays rather than text, through the query-AST form the contract has carried since the first release:
import { connect, queryAst } from "runtime:db"; import { driver } from "@opentf/esrun-redis"; const db = await connect("redis://localhost", { driver }); await db.execute(queryAst(["SET", "k", "v"]));
It declares what it is — supports.queryText: false, supports.queryAst: true, supports.transactions: false, supports.subscriptions: true — so code that checks rather than assumes keeps working, and code that does not gets ERR_DB_QUERY_FORM or ERR_DB_UNSUPPORTED by name rather than something stranger further down. Most programs using Redis want its own vocabulary instead, and it is on the very same object: await db.set("k", "v", { ex: 60 }).
Two rules worth knowing before you meet them. Subscribing takes a connection over: the first subscribe gives it to a read loop, and it runs no ordinary commands afterwards — which over RESP2 is the protocol's own rule, not the driver's — so a program that both listens and works needs two connections. And a blocking command (BLPOP and its family, XREAD BLOCK) must be given a timeout. It holds the connection for as long as it blocks, so a bounded wait is a stall you chose, while 0 means forever — a connection that never comes back, and through a pool one that is gone for the life of the process. The unbounded form is refused before it reaches the wire unless the connection was opened with { blocking: true }, which is how a queue worker says that is the point.
The Redis guide covers both surfaces in full — pub/sub, pipelining, MULTI/EXEC, cluster, Sentinel — and compares the driver against Node, Bun and Deno feature by feature.
If you are writing a driver, see Drivers & ORMs and run runBackendConformance() to check it behaves like the built-ins.