ch10-drizzle
對應 10. D1 + Drizzle ORM:型別安全的資料層
在 GitHub 上檢視·10 個檔案·11.1 KB
取得並執行
這個範例可以獨立 clone 執行,不依賴其他章節。
npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch10-drizzle
cd ch10-drizzle
npm install可用指令
npm run dev # wrangler dev
npm run typecheck # tsc --noEmit
npm run cf-typegen # wrangler types --env-interface CloudflareBindings
npm run db:apply # wrangler d1 migrations apply linkforge-demo
npm run db:generate # drizzle-kit generate說明
ch10 — Drizzle ORM on D1
Section titled “ch10 — Drizzle ORM on D1”Companion example for docs/10-drizzle.md.
Pinned to the 0.4x line — what npm i drizzle-orm actually installs today.
The Drizzle docs describe the 1.0 RC line instead; the two are incompatible.
npm installnpx drizzle-kit generatenpx wrangler d1 migrations apply linkforge-demo # defaults to LOCAL (ch09)npm run devB=localhost:8787curl -s "$B/seed"curl -s "$B/types"curl -s "$B/batch"curl -s "$B/batch-rollback"curl -s "$B/transaction"curl -s "$B/plan"Verified findings
Section titled “Verified findings”2026-07-28 · drizzle-orm@0.45.2 · drizzle-kit@0.31.10 · wrangler@4.114.0
Two incompatible lines shipping simultaneously
Section titled “Two incompatible lines shipping simultaneously”$ npm view drizzle-orm dist-tags{ "latest": "0.45.2", "beta": "1.0.0-beta.22", "rc": "1.0.0-rc.4", ... }Plus ~50 other dist-tags (beelink, revert-netlify, kit-duckdb, …).
Pin exact versions; do not use ^.
Migration layout differs between lines
Section titled “Migration layout differs between lines”drizzle-kit@0.31.10:
drizzle/0000_complex_tyger_tiger.sqldrizzle/meta/0000_snapshot.jsondrizzle/meta/_journal.jsondrizzle-kit@1.0.0-rc.4:
drizzle/20260728080351_sparkling_cable/migration.sqldrizzle/20260728080351_sparkling_cable/snapshot.jsonFlat + journal versus nested folders, and sequence numbers become timestamps.
Wrangler and the 1.0 layout — a silent green CI
Section titled “Wrangler and the 1.0 layout — a silent green CI”Without migrations_pattern:
▲ [WARNING] Could not find any migration files matching `drizzle/*.sql`. It looks like there are migration files matching `drizzle/*/migration.sql` though. If you are using drizzle to manage your migrations, please set `migrations_pattern` to `drizzle/*/migration.sql` in wrangler.jsonc.
✅ No migrations to apply!Note the exit is 0. CI passes and production has no tables. With
"migrations_pattern": "drizzle/*/migration.sql" it applies correctly and
records the name as 20260728080351_sparkling_cable/migration.sql.
db.transaction() compiles and dies
Section titled “db.transaction() compiles and dies”{"threw":"Error: Failed query: begin\nparams: "}Drizzle emits a bare begin, which D1 rejects (chapter 09). Same in
1.0.0-rc.4. Tracking issue: drizzle-team/drizzle-orm#2463, still open.
batch() is the atomic path, with positional types
Section titled “batch() is the atomic path, with positional types”[[{"id":"t1","name":"Acme","plan":"pro",...}], [{"n":2}], {"success":true,"meta":{"rows_read":3,"rows_written":4,...},"results":[]}]Rollback verified:
{"before":3,"after":3, "err":{"threw":"D1_ERROR: UNIQUE constraint failed: links.tenant_id, links.slug ..."}, "firstRowSurvived":false}Keep the array inline — hoisting it to a const widens the tuple and loses
per-item types.
mode fixes chapter 09’s lossy round-trips
Section titled “mode fixes chapter 09’s lossy round-trips”{"row":{"isActive":true,"createdAt":"2026-07-28T08:05:41.656Z","expiresAt":null}, "isActiveType":"boolean","createdAtIsDate":true}The ORM does not protect you from rows_read
Section titled “The ORM does not protect you from rows_read”db.select().from(links).where(eq(links.slug, "cf")).orderBy(desc(links.createdAt))
against a schema whose unique index is (tenant_id, slug):
{"plan":[{"detail":"SCAN links"},{"detail":"USE TEMP B-TREE FOR ORDER BY"}]}The composite index is unused because the query filters only on slug, not on
the leftmost column. Obvious in raw SQL, invisible behind .where(eq(...)).
Use .toSQL() and feed it to EXPLAIN QUERY PLAN.
Exercise: change the filter to
and(eq(links.tenantId, "t1"), eq(links.slug, "cf")) and watch SCAN become
SEARCH links USING INDEX idx_links_tenant_slug.
原始碼
wrangler.jsoncpackage.jsonsrc/db.tssrc/index.tssrc/schema.tsdrizzle/0000_complex_tyger_tiger.sqldrizzle/meta/_journal.jsondrizzle/meta/0000_snapshot.jsondrizzle.config.tstsconfig.json
wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "ch10-drizzle",
"main": "src/index.ts",
"compatibility_date": "2026-07-24",
"observability": { "enabled": true },
"d1_databases": [
{
"binding": "DB",
"database_name": "linkforge-demo",
"database_id": "00000000-0000-0000-0000-0000000d1010",
"migrations_dir": "./drizzle"
}
]
}package.json
{ "name": "ch10-drizzle", "private": true, "type": "module",
"scripts": { "dev": "wrangler dev", "typecheck": "tsc --noEmit",
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
"db:generate": "drizzle-kit generate",
"db:apply": "wrangler d1 migrations apply linkforge-demo" },
"dependencies": { "drizzle-orm": "^0.45.2" },
"devDependencies": { "drizzle-kit": "^0.31.10", "typescript": "^5.9.0", "wrangler": "^4.114.0" } }src/db.ts
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
/**
* Build a Drizzle client per request. Bindings live on `env`, so there is no
* module-level singleton to create (chapter 03).
*
* NOTE (0.4x line): the option is `schema`. On the 1.0 line it is `relations`
* and `schema` is Omit-ed away for SQLite drivers.
*/
export const getDb = (env: CloudflareBindings) => drizzle(env.DB, { schema });
export type Db = ReturnType<typeof getDb>;src/index.ts
import { eq, desc, sql } from "drizzle-orm";
import { getDb } from "./db";
import { links, tenants } from "./schema";
const t = async (fn: () => Promise<unknown>): Promise<unknown> => {
try { return { ok: await fn() }; }
catch (e) { return { threw: String(e).slice(0, 220) }; }
};
export default {
async fetch(request: Request, env: CloudflareBindings): Promise<Response> {
const url = new URL(request.url);
const db = getDb(env);
switch (url.pathname) {
case "/seed": {
await db.delete(links);
await db.delete(tenants);
await db.insert(tenants).values({ id: "t1", name: "Acme", plan: "pro" });
await db.insert(links).values([
{ tenantId: "t1", slug: "cf", url: "https://developers.cloudflare.com/", createdAt: new Date() },
{ tenantId: "t1", slug: "hono", url: "https://hono.dev/", createdAt: new Date() },
]);
return Response.json({ seeded: true });
}
// mode:"boolean" and mode:"timestamp_ms" fix chapter 09's lossy round-trips.
case "/types": {
const row = await db.select().from(links).where(eq(links.slug, "cf")).get();
return Response.json({
row,
isActiveType: typeof row?.isActive,
createdAtIsDate: row?.createdAt instanceof Date,
});
}
// batch() keeps per-statement result types positionally.
case "/batch": {
const r = await t(() =>
db.batch([
db.select().from(tenants),
db.select({ n: sql<number>`count(*)` }).from(links),
db.insert(links).values({
tenantId: "t1", slug: `b${Date.now()}`, url: "https://x", createdAt: new Date(),
}),
]),
);
return Response.json(r);
}
// Whole-batch rollback on a unique violation.
case "/batch-rollback": {
const before = await db.$count(links);
const err = await t(() =>
db.batch([
db.insert(links).values({ tenantId: "t1", slug: "will-rollback", url: "https://a", createdAt: new Date() }),
db.insert(links).values({ tenantId: "t1", slug: "cf", url: "https://dup", createdAt: new Date() }),
]),
);
const after = await db.$count(links);
const survivor = await db.select().from(links).where(eq(links.slug, "will-rollback")).get();
return Response.json({ before, after, err, firstRowSurvived: survivor !== undefined });
}
// THE trap: this type-checks and fails at runtime on D1.
case "/transaction": {
const r = await t(() =>
db.transaction(async (tx) => {
await tx.insert(links).values({
tenantId: "t1", slug: `tx${Date.now()}`, url: "https://tx", createdAt: new Date(),
});
return "committed";
}),
);
return Response.json(r);
}
// Cost check: does Drizzle's generated SQL still use the index?
case "/plan": {
const q = db.select().from(links)
.where(eq(links.slug, "cf"))
.orderBy(desc(links.createdAt));
const { sql: text, params } = q.toSQL();
const plan = await env.DB.prepare(`EXPLAIN QUERY PLAN ${text}`).bind(...params).all();
return Response.json({ sql: text, params, plan: plan.results });
}
default:
return new Response("try /seed /types /batch /batch-rollback /transaction /plan\n", { status: 404 });
}
},
} satisfies ExportedHandler<CloudflareBindings>;src/schema.ts
import { sql } from "drizzle-orm";
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const tenants = sqliteTable("tenants", {
id: text("id").primaryKey(),
name: text("name").notNull(),
plan: text("plan", { enum: ["free", "pro"] }).notNull().default("free"),
createdAt: integer("created_at").notNull().default(sql`(unixepoch() * 1000)`),
});
export const links = sqliteTable(
"links",
{
id: integer("id").primaryKey({ autoIncrement: true }),
tenantId: text("tenant_id").notNull().references(() => tenants.id),
slug: text("slug").notNull(),
url: text("url").notNull(),
// SQLite has no BOOLEAN. mode:"boolean" makes Drizzle do the 0/1 mapping
// that chapter 09 had to do by hand.
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }),
},
(t) => [
uniqueIndex("idx_links_tenant_slug").on(t.tenantId, t.slug),
index("idx_links_tenant_created").on(t.tenantId, t.createdAt),
],
);
export type Link = typeof links.$inferSelect;
export type NewLink = typeof links.$inferInsert;drizzle/0000_complex_tyger_tiger.sql
CREATE TABLE `links` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`tenant_id` text NOT NULL,
`slug` text NOT NULL,
`url` text NOT NULL,
`is_active` integer DEFAULT true NOT NULL,
`created_at` integer NOT NULL,
`expires_at` integer,
FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_links_tenant_slug` ON `links` (`tenant_id`,`slug`);--> statement-breakpoint
CREATE INDEX `idx_links_tenant_created` ON `links` (`tenant_id`,`created_at`);--> statement-breakpoint
CREATE TABLE `tenants` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`plan` text DEFAULT 'free' NOT NULL,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
);drizzle/meta/_journal.json
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1785225792554,
"tag": "0000_complex_tyger_tiger",
"breakpoints": true
}
]
}drizzle/meta/0000_snapshot.json
{
"version": "6",
"dialect": "sqlite",
"id": "a0e50d1f-30c6-4129-acee-45d922a1b5b8",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"links": {
"name": "links",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"tenant_id": {
"name": "tenant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"idx_links_tenant_slug": {
"name": "idx_links_tenant_slug",
"columns": [
"tenant_id",
"slug"
],
"isUnique": true
},
"idx_links_tenant_created": {
"name": "idx_links_tenant_created",
"columns": [
"tenant_id",
"created_at"
],
"isUnique": false
}
},
"foreignKeys": {
"links_tenant_id_tenants_id_fk": {
"name": "links_tenant_id_tenants_id_fk",
"tableFrom": "links",
"tableTo": "tenants",
"columnsFrom": [
"tenant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tenants": {
"name": "tenants",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plan": {
"name": "plan",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'free'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./src/schema.ts",
dialect: "sqlite",
// d1-http is for drizzle-kit push/pull/studio against a REMOTE D1.
// Migrations themselves are applied with `wrangler d1 migrations apply`.
driver: "d1-http",
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "",
databaseId: process.env.CLOUDFLARE_DATABASE_ID ?? "",
token: process.env.CLOUDFLARE_D1_TOKEN ?? "",
},
});tsconfig.json
{ "compilerOptions": { "target": "esnext", "lib": ["esnext"], "module": "esnext",
"moduleResolution": "bundler", "types": ["./worker-configuration.d.ts"],
"strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true },
"include": ["src/**/*.ts", "worker-configuration.d.ts"] }