Database
Each project has its own SQLite database, stored in the app’s Durable Object on Cloudflare. Every API request and cron job runs right next to the data, one request at a time — so “check stock, then decrement” can’t race with another order, without transactions. app/db.ts is generated: just import { db } from "app/db".
Tables — app/<resource>/<resource>.table.ts
One file per resource, standard Drizzle. Import builders from app/db (never from drizzle-orm directly).
// app/orders/orders.table.ts
import { sqliteTable, text, integer } from "app/db";
export const orders = sqliteTable("orders", {
id: integer("id").primaryKey({ autoIncrement: true }),
customerName: text("customer_name").notNull(),
total: integer("total").notNull(),
status: text("status").notNull().default("pending"),
createdAt: integer("created_at").notNull(),
});Schema changes apply on deploy, additively: new tables are created, new columns are added. A new .notNull() column without .default() on an existing table is added as nullable (the deploy notes say so) — give required columns a default. Nothing is dropped or retyped automatically. Everything Drizzle declares reaches the DB — .unique(), .references(), indexes, composite primary keys — and foreign keys are enforced (a missing parent or deleting a parent that still has children fails; choose onDelete deliberately).
What the generated app/db.ts looks like
app/db.ts looks like// AUTO-GENERATED by Manh. Do not edit.
import { drizzle } from "drizzle-orm/durable-sqlite";
export { sqliteTable, integer, text, real, blob, index, uniqueIndex, primaryKey, foreignKey, unique } from "drizzle-orm/sqlite-core";
export {
eq, ne, lt, lte, gt, gte, like, ilike, and, or, not, isNull, isNotNull,
inArray, notInArray, between, asc, desc, sql, count, sum, avg, min, max,
} from "drizzle-orm";
// Created lazily on first use, over the Durable Object's storage.
export const db = /* drizzle(ctx.storage) */;Queries with Drizzle
import { db, eq, desc, and } from "app/db";
import { orders } from "app/orders/orders.table";
// Select all
const all = await db.select().from(orders);
// Select with where + order
const recent = await db
.select()
.from(orders)
.where(eq(orders.status, "pending"))
.orderBy(desc(orders.createdAt))
.limit(20);
// Insert + return
const [created] = await db
.insert(orders)
.values({ id: crypto.randomUUID(), customerName: "Alice", total: 42, status: "pending" })
.returning();
// Update + return
const [updated] = await db
.update(orders)
.set({ status: "completed", updatedAt: Date.now() })
.where(eq(orders.id, "abc"))
.returning();
// Delete + return
const [deleted] = await db
.delete(orders)
.where(eq(orders.id, "abc"))
.returning();Transactions: not needed for races (requests run one at a time). Never db.transaction(async …) — on the Durable Object driver it is synchronous, so writes after the first await escape it and a throw inside is lost. Check everything first, then write; for writes that must be all-or-nothing use a sync callback with .run() / .get() and no await (framework spec → Database).
$name store (optional)
Small side data (settings, counters, short lists) can skip a table: a free identifier starting with $ in a backend .ts file is a persistent JSON value.
$todos ??= [];
$todos.push({ title: "Buy paper", done: false }); // id assigned on push
return { data: $todos };It loads on first use in a request and saves at the end, all or nothing (a thrown error or 4xx/5xx response saves nothing). Every request loads the whole list, so keep it to a few thousand items — business objects belong in tables. Backend files only.