Database
Every account gets a real SQL database per environment — SQLite, opened inside the server process, shared by every function that declares it. Queries are plain parameterized SQL and cost microseconds, not round-trips.
Declaring it
export const config = { db: true };
export default async function handler(request, context) {
await context.db.exec(
"CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY, title TEXT)");
const { title } = await request.json().catch(() => ({}));
if (title) await context.db.exec("INSERT INTO todos (title) VALUES (?)", [title]);
return context.json({ todos: await context.db.query("SELECT * FROM todos") });
}
Like every capability, undeclared means absent: without config.db, context.db is undefined. The database is scoped per account and environment — all of your functions share one database, /f/@stage/… invocations see stage's own, and another account's functions can never reach yours. The editor's Run button gets the same prod database as your deployed functions, so what you try is what runs.
The API
| call | returns |
|---|---|
context.db.query(sql, params?) | Rows as objects keyed by column name. At most 10,000 rows — add a LIMIT. |
context.db.exec(sql, params?) | { changes, lastInsertRowid } — for statements that don't return rows. |
context.db.transaction([[sql, params], …]) | { changes }. An atomic batch: every statement applies or none. Deliberately not a callback — nothing may hold the database lock across an await. |
Params bind positionally to ? placeholders and may be strings, numbers, booleans, or null — objects and arrays are refused rather than silently stringified, so JSON in a column is a deliberate JSON.stringify. Blob columns come back as base64 text.
The console
The Database section of the console browses and manages the same database: tables with rowcounts and their CREATE statements, click-to-browse, and a create-table wizard that is a visible SQL generator — the statement renders live as you build and Create runs exactly what's shown. The primary key is choosable: INTEGER (auto-incrementing) or UUID, which emits id TEXT PRIMARY KEY DEFAULT (uuid()). Column defaults read predictably: parenthesized input is an expression ((datetime('now')), (uuid())), numbers are literals, anything else becomes a quoted string. A raw SQL box covers everything the wizard doesn't — renames and type changes are table rebuilds in SQLite, so they're raw-SQL territory on purpose.
uuid() is a platform-registered SQL function (SQLite has none of its own), available in every query and default. External tools can read your database file freely; an insert relying on a uuid() default needs the platform's connection.
Guardrails
Tenant SQL is the point, so enforcement is structural — the worst a hostile query can do is spoil its own database:
- 64 MB per database, enforced by SQLite itself (
max_page_count): overflow fails your write, never the platform. - The invocation's deadline bounds SQL exactly as it bounds JavaScript — a runaway query dies with the sandbox.
ATTACH,DETACH, andPRAGMAare refused; extensions cannot load.- Console queries run through the same guarded connection with the same rules — the console can do nothing a function couldn't.
Which data tool when
| capability | shaped for |
|---|---|
context.db | Relational app data — rows, queries, joins — shared across your functions. |
context.state | Coordination: per-function key-value with compare-and-set versioning. |
context.objects | Big binaries and user uploads, in your own S3-compatible bucket. |
Semantics worth knowing
- Writes serialize per database (SQLite's single-writer model); reads are cheap. At function scale this is a feature — no connection pools, no lock tuning.
INTEGER PRIMARY KEYauto-increments — it's SQLite's rowid alias.- Types are affinities:
BOOLEANstores as integer,TIMESTAMPas text.datetime('now')is the idiomatic timestamp. - Data lives on the server, durable across restarts and redeploys. Off-server snapshot backups and export are on the roadmap; until then, treat irreplaceable data accordingly.
rusted run(the local no-server loop) supplies a scratch SQLite database — same SQL, same op protocol, reset when the dev server exits.- Self-hosting: the files live in one directory — systemd's
StateDirectorywhen the server runs under a unit that grants one, otherwise./rusted-dbs;rusted serve --db-dir(orRUSTED_DB_DIR) overrides it. If that path isn't writable, the server says so at boot and only database calls fail.
rusted