Reference
D1
D1 is Cloudflare's managed SQLite database and Goribu's built-in SQL option. There is no driver, connection string or pool to manage. You declare the database once, then query it from any handler as req.d1.
export async function GET(req, res) {
const poll = await req.d1.get("SELECT id, title FROM polls WHERE id = ?", [req.params.id]);
if (!poll) return res.notFound();
return res.render(PollPage, { poll });
}Declare it in goribu.config.js and only there:
export default {
database: { type: "d1", name: "your-app-db" },
};Do not add a d1_databases entry to wrangler.jsonc; Goribu synthesizes the DB binding from this config and declaring it in both places is an error. In development Wrangler creates a local SQLite file keyed by name. On deploy Goribu resolves the name to the real database UUID, creating the database if it does not exist so you never commit UUIDs by accident.
Migrations
You cannot query a table that does not exist, so schema comes first. Migrations live in migrations/ at the project root. Create one with the CLI:
npx goribu migration:create create_polls_tableNames are snake_case. Goribu prefixes a timestamp so files sort in creation order and two branches don't collide. A migration exports an up(d1) function. d1 has the same methods as req.d1, so there is no separate migration API to learn:
export async function up(d1) {
await d1.run(`
CREATE TABLE polls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
}Migration files are JavaScript today, even in TypeScript projects. They still work normally with a TypeScript app because Goribu's CLI imports them directly when you run migrate or deploy; the database only sees the SQL you execute. Keep the CLI-generated .js extension for now. Native .ts migration files are planned, but need CLI loader support before they can be generated safely.
Apply pending migrations locally with npx goribu migrate. Goribu records applied migrations in a _migrations table it creates on first run. You never run production migrations by hand: npx goribu deploy applies them before the new Worker goes live and if one fails the deploy stops and the previous Worker keeps serving.
npx goribu migrate:rollback reverses the last local migration by calling its down(d1). Generated migrations leave down commented out on purpose. That's because a fake rollback that marks a migration reverted without changing the schema is worse than none. Treat rollback as a local tool. In production, the safer fix is a new forward migration.
Reading rows
req.d1.get() returns the first matching row as a plain object, or null:
const poll = await req.d1.get("SELECT * FROM polls WHERE id = ?", [req.params.id]);
if (!poll) return res.notFound();req.d1.all() returns an array of rows. [] when nothing matches:
const polls = await req.d1.all("SELECT id, title FROM polls ORDER BY created_at DESC");Writing rows
req.d1.run() is for writes and DDL. It returns lastRowId (the inserted auto-increment id, when the table has one) and changes (rows affected). The latter is how you confirm an UPDATE or DELETE actually hit something:
const { lastRowId } = await req.d1.run("INSERT INTO polls (title) VALUES (?)", ["Best topping?"]);
const { changes } = await req.d1.run("DELETE FROM polls WHERE id = ?", [req.params.id]);
if (changes === 0) return res.notFound();Batches
req.d1.batch() commits several statements together. If one fails, none commit. Each statement is a { sql, params } object and the return is an array of results, one per statement.
await req.d1.batch([
{ sql: "INSERT INTO polls (title) VALUES (?)", params: ["Best topping?"] },
{ sql: "INSERT INTO options (poll_id, text) VALUES (?, ?)", params: [1, "Margherita"] },
{ sql: "INSERT INTO options (poll_id, text) VALUES (?, ?)", params: [1, "Pepperoni"] },
]);Always parameterize
Never interpolate user input into SQL. Every method takes a params array so use that for security.
// Wrong — SQL injection risk:
await req.d1.get(`SELECT * FROM polls WHERE title = '${title}'`);
// Right:
await req.d1.get("SELECT * FROM polls WHERE title = ?", [title]);Freshness modes
Most reads want plain req.d1: it continues from the browser's previous bookmark when there is one, otherwise it takes the fastest available read. Two escape hatches change that:
// Strict freshness — the read must reflect the very latest write.
const user = await req.d1.primary().get("SELECT * FROM users WHERE id = ?", [req.user.id]);
// Relaxed — public, cacheable, non-personalized reads where slightly stale is fine.
const posts = await req.d1.relaxed().all("SELECT id, title FROM posts ORDER BY created_at DESC LIMIT 20");Use primary() for auth, billing, admin and permission checks, like anywhere stale data would be wrong. Use relaxed() for public pages: it ignores the browser bookmark and does not set one, so a shared page can't accidentally become personalized. Do not use relaxed() for a read that must reflect the user's own recent write.
Going deeper
Sessions and read replicas
req.d1 runs queries through a D1 Session by default. These are not login sessions. They are database-consistency sessions: a small bookmark records which version of the database a browser has already seen, which is what makes reads consistent even when they are served from a replica. Goribu manages the bookmark for you, storing it in an HTTP-only goribu_d1_bookmark cookie when a query runs. A route that never touches req.d1 creates no session and sets no cookie. No KV, Redis or Durable Object required.
Read replication is enabled on the database in Cloudflare, not in code. With it off, every read goes to the primary and req.d1 behaves like ordinary D1. With it on, reads may be served from nearby replicas while preserving read-your-own-writes for the same browser. Writes always go to the primary; replicas improve read latency and throughput, not write locality. (On older compatibility dates without the Sessions API, the facade transparently falls back to the raw binding, every query hits the primary and no bookmark is produced.)
Zero-downtime schema changes
Production migrations run before the new Worker ships, so there is a brief window where the old Worker serves traffic against the new schema. Additive changes are safe through that window: new tables, nullable columns, columns with defaults, new indexes. Renaming or dropping a column the current Worker still uses, or adding a NOT NULL column without a default, will break it.
For those, expand and contract across two deploys. First, add the new shape while the old keeps working:
export async function up(d1) {
await d1.run("ALTER TABLE polls ADD COLUMN question TEXT");
await d1.run("UPDATE polls SET question = title");
}Ship a Worker that writes both columns and reads the new one with a fallback. Then, once nothing reads the old column, drop it in a second migration. Solo projects with no traffic can accept the short window; apps with users should split risky changes.
Query builders and the raw session
req.d1.raw is the active D1 session, not the original binding, so a query builder inherits the same bookmark and replica behavior as the convenience methods:
import { drizzle } from "drizzle-orm/d1";
const db = drizzle(req.d1.raw);
const strictDb = drizzle(req.d1.primary().raw);
const publicDb = drizzle(req.d1.relaxed().raw);For the original Cloudflare D1 binding, use req.d1.binding.
Foreign keys
D1 is SQLite, which does not enforce foreign keys unless PRAGMA foreign_keys = ON is set. Declaring FOREIGN KEY … ON DELETE CASCADE records the constraint but does not enforce it on its own, and Goribu does not enable the pragma for you — turning it on against a database with existing orphan rows can make later writes fail. If you rely on cascades, enable foreign keys deliberately or delete related rows yourself with req.d1.batch().
In tests
There is no special test mode. A handler under test uses whatever DB binding you wire up. The Workers Vitest pool (Miniflare) gives you a real local D1 you migrate and query like production, which is the closest test to reality.
If req.d1 is used with no binding attached, it does not silently no-op. Every method throws the missing-binding error below. A silently-passing query would hide real bugs, so the failure is loud by design. Provide a binding (or a stub implementing get/all/run/batch) for handlers that read the database.
Missing binding
Using req.d1 with no database declared throws immediately:
[goribu] req.d1 was used but no D1 binding was found. Expected env.DB. Add
`database: { type: 'd1', name: 'your-db-name' }` to goribu.config.js. Goribu
will synthesize the env.DB binding for dev and deploy.Add the database entry to goribu.config.js and restart the dev server.