D1 + Drizzle ORM:型別安全的資料層
這篇要解決的問題
Section titled “這篇要解決的問題”這是全系列地雷最密的一章,而且大部分地雷跟 SQL 無關,跟版本有關。
三件必須先講的事:
- npm 的
latest和 Drizzle 官網文件描述的是兩條不相容的版本線。npm i drizzle-orm給你 0.45.2,但 orm.drizzle.team 的 Cloudflare D1 頁面叫你裝drizzle-orm@rc(1.0.0-rc.4)。兩邊 API 不同。 db.transaction()在兩條線上都會在 D1 上 runtime 炸掉。 型別完全過得了,一跑就死。- ORM 不會保護你不踩第 9 篇的成本陷阱。 我實測 Drizzle 產生的一個看起來完全正常的查詢,query plan 是
SCAN。
先選版本線,再寫任何程式碼
Section titled “先選版本線,再寫任何程式碼”實測今天的 npm dist-tags:
$ npm view drizzle-orm dist-tags{ "latest": "0.45.2", "beta": "1.0.0-beta.22", "rc": "1.0.0-rc.4", ... }
$ npm view drizzle-kit dist-tags{ "latest": "0.31.10", "beta": "1.0.0-beta.22", "rc": "1.0.0-rc.4", ... }drizzle-orm 1.0 stable 尚未發佈。 1.0 線從 2026-05 進 RC,到現在還在動。
而 Drizzle 官網的 D1 入門頁已經改成 npm i drizzle-orm@rc。所以「照文件做」和「照 npm i 做」的人會落在兩條不相容的線上。
順帶一提,那兩個套件加起來有 50 幾個 dist-tag(
beelink、revert-netlify、kit-duckdb……)。這本身就是一個訊號:這個專案目前是在 npm 上做開發分支管理的。務必鎖定確切版本,不要用^。
兩條線的差異(SQLite / D1 相關)
Section titled “兩條線的差異(SQLite / D1 相關)”0.45.2 | 1.0.0-rc.4 | |
|---|---|---|
drizzle() 選項 | { schema, casing, logger, cache } | { relations, logger, cache } — SQLite driver 的 schema 被 Omit 掉 |
| 關聯定義 | relations() | defineRelations() / defineRelationsPart() |
| 命名慣例 | drizzle({ casing: 'snake_case' }) | drizzle() 不再接受 —— 移到 table 定義時 |
| 取欄位 | getTableColumns() | 新增 getColumns()(getTableColumns 仍在) |
| 驗證整合 | 獨立套件 drizzle-zod | drizzle-orm/zod、/valibot、/typebox、/arktype、/effect-schema 子路徑 |
| 泛型參數 | DrizzleD1Database<TSchema> | DrizzleD1Database<TRelations> |
| D1 Sessions API | 型別不接受 D1DatabaseSession | 接受(第 11 篇會用到) |
那個 casing 的變化特別陰險:1.0 上 drizzle({ casing: 'snake_case' }) 會型別錯誤,如果你 cast 繞過去,欄位名會靜默地變回原樣。
本篇的範例用 0.4x 線(因為那是 npm i 的預設),並在需要時標註 1.0 的寫法。
Schema:把第 9 篇的型別陷阱修掉
Section titled “Schema:把第 9 篇的型別陷阱修掉”第 9 篇實測過 D1 的三個型別損耗:boolean 讀回 0/1、時間戳只是數字、undefined 會 throw。Drizzle 的 mode 選項可以在應用層修掉前兩個:
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" does the 0/1 mapping for you. 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;實測確實有效:
$ curl -s localhost:8787/types{ "row": { "id":1, "slug":"cf", "isActive": true, "createdAt": "2026-07-28T08:05:41.656Z", "expiresAt": null }, "isActiveType": "boolean", "createdAtIsDate": true}isActive 是真的 boolean、createdAt 是真的 Date。這是用 ORM 最實在的好處之一。
$inferSelect / $inferInsert 讓型別從 schema 流出來,第 26 篇會一路流到 React component。
🔴 Migration 佈局:兩條線輸出完全不同
Section titled “🔴 Migration 佈局:兩條線輸出完全不同”這是 D1 + Drizzle 最實際的互通性問題。
drizzle-kit@0.31.10(0.4x 線):
$ npx drizzle-kit generate[✓] Your SQL migration file ➜ drizzle/0000_complex_tyger_tiger.sql 🚀
$ find drizzle -type fdrizzle/0000_complex_tyger_tiger.sqldrizzle/meta/0000_snapshot.jsondrizzle/meta/_journal.jsondrizzle-kit@1.0.0-rc.4(1.0 線):
$ npx drizzle-kit generate[✓] Your SQL migration ➜ drizzle/20260728080351_sparkling_cable/migration.sql 🚀
$ find drizzle -type fdrizzle/20260728080351_sparkling_cable/migration.sqldrizzle/20260728080351_sparkling_cable/snapshot.json扁平 + meta/_journal.json → 巢狀資料夾,而且編號從序號變成時間戳。
wrangler 吃得下嗎?實測
Section titled “wrangler 吃得下嗎?實測”0.4x 佈局:直接可用。 設 "migrations_dir": "./drizzle" 就好,wrangler 的預設 pattern drizzle/*.sql 會抓到,meta/ 被忽略:
🚣 5 commands executed successfully.┌──────────────────────────────┬────────┐│ name │ status │├──────────────────────────────┼────────┤│ 0000_complex_tyger_tiger.sql │ ✅ │└──────────────────────────────┴────────┘1.0 佈局,沒設 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!Wrangler 貼心到專門為 drizzle 寫了一段提示。但注意最後那一行 —— 它印了「✅ No migrations to apply!」而且 exit code 是 0。
意思是:你的 CI 會綠燈,而 production 一張表都沒建。 搭配第 9 篇那個「預設打本機」的陷阱,這是兩層疊在一起的沉默失敗。
1.0 佈局,設了 migrations_pattern:
{ "d1_databases": [{ "binding": "DB", "database_name": "linkforge", "database_id": "<uuid>", "migrations_dir": "./drizzle", "migrations_pattern": "drizzle/*/migration.sql" }]}┌──────────────────────────────────────────────┬────────┐│ name │ status │├──────────────────────────────────────────────┼────────┤│ 20260728080351_sparkling_cable/migration.sql │ ✅ │└──────────────────────────────────────────────┴────────┘可以用。注意記錄進 d1_migrations 的名字是相對於 migrations_dir 的完整路徑。
⚠️ 從 0.4x 升級到 1.0 會讓所有 migration 「看起來沒跑過」,因為檔名格式整個變了,和
d1_migrations表裡已有的紀錄對不上。升級時要手動修那張表。兩邊的文件都沒有提這件事。
兩套 migration 系統不要混用
Section titled “兩套 migration 系統不要混用”| 記錄表 | 誰執行 | |
|---|---|---|
wrangler d1 migrations apply | d1_migrations | Wrangler |
drizzle-kit migrate(走 d1-http) | __drizzle_migrations | drizzle-kit |
選一套,而且在 README 裡大聲寫出來。 混用會讓兩邊各自以為自己是對的。
本系列選 wrangler d1 migrations apply,理由:它和 --remote / 環境 / CI 是同一套心智模型,而且不需要額外的 API token。drizzle-kit 只用來 generate。
🔴 db.transaction() 型別會過,runtime 會死
Section titled “🔴 db.transaction() 型別會過,runtime 會死”// Compiles. Fails at runtime on D1.await db.transaction(async (tx) => { await tx.insert(links).values({ ... }); return "committed";});實測:
$ curl -s localhost:8787/transaction{"threw":"Error: Failed query: begin\nparams: "}Drizzle 送出一個裸的 begin,而第 9 篇實測過 D1 會拒絕它。
這在 0.45.2 和 1.0.0-rc.4 上都一樣(可以從已發佈的 d1/session.js 讀到兩邊都是 sql.raw("begin"))。追蹤 issue 是 drizzle-team/drizzle-orm#2463,仍然開著。
型別系統完全不擋你 —— 這是 ORM 在 D1 上最危險的一個 API。
batch():正確的原子性做法
Section titled “batch():正確的原子性做法”const [tenantRows, counts, inserted] = await db.batch([ db.select().from(tenants), db.select({ n: sql<number>`count(*)` }).from(links), db.insert(links).values({ ... }),]);實測回傳:
[ [{"id":"t1","name":"Acme","plan":"pro","createdAt":1785225941000}], [{"n":2}], {"success":true,"meta":{"rows_read":3,"rows_written":4, ...},"results":[]}]每一項保留自己的結果型別(tuple 定位型別)。要維持這個型別推導,陣列必須直接內聯寫;抽成 const 會被放寬成 Query[],型別就沒了。
回滾也確認有效:
$ curl -s localhost:8787/batch-rollback{ "before": 3, "after": 3, "err": { "threw": "Error: D1_ERROR: UNIQUE constraint failed: links.tenant_id, links.slug ..." }, "firstRowSurvived": false}第一條 INSERT 被第二條的失敗回滾了。
🔴 ORM 不會保護你不踩成本陷阱
Section titled “🔴 ORM 不會保護你不踩成本陷阱”這是本篇最重要的一節。
第 9 篇的結論是「rows_read 是帳單、索引是唯一槓桿」。ORM 讓寫查詢變容易了,但成本模型完全沒變,而且 ORM 把 SQL 藏起來反而讓問題更難發現。
我們的 schema 有一個 (tenant_id, slug) 的唯一索引。寫一個看起來完全正常的查詢:
db.select().from(links).where(eq(links.slug, "cf")).orderBy(desc(links.createdAt))用 .toSQL() 取出 SQL,再丟給 EXPLAIN QUERY PLAN:
$ curl -s localhost:8787/plan{ "sql": "select \"id\", \"tenant_id\", \"slug\", ... from \"links\" where \"links\".\"slug\" = ? order by \"links\".\"created_at\" desc", "params": ["cf"], "plan": [ { "detail": "SCAN links" }, { "detail": "USE TEMP B-TREE FOR ORDER BY" } ]}SCAN links。 索引完全沒用上。
原因是複合索引 (tenant_id, slug) 的最左欄位是 tenant_id,而這個查詢只過濾 slug。SQL 老手一眼就看得出來,但當你寫的是 .where(eq(links.slug, "cf")) 時,這件事完全不明顯 —— 你看不到 SQL。
而且第二行 USE TEMP B-TREE FOR ORDER BY 說明排序也要額外建暫存結構。
所以 .toSQL() 應該是你的日常工具:
const q = db.select().from(links).where(and(eq(links.tenantId, t), eq(links.slug, s)));const { sql: text, params } = q.toSQL();const plan = await env.DB.prepare(`EXPLAIN QUERY PLAN ${text}`).bind(...params).all();把這個包成一個測試 helper,每條新查詢都跑一次、斷言 plan 裡沒有 SCAN。第 38 篇會把它變成正式的測試。
drizzle-kit push vs migration
Section titled “drizzle-kit push vs migration”drizzle-kit push(走 d1-http)會直接把 schema 差異推到遠端資料庫,不產生檔案。
適合:個人專案的早期探索階段。 不適合:任何有多人、有 staging/production 分離、需要 code review 的專案 —— 因為資料庫變更不進版控、不可審、不可回溯。
本系列一律用 migration 檔案。
完整程式碼:
examples/ch10-drizzle/
cd examples/ch10-drizzlenpm installnpx drizzle-kit generate # 產生 migrationnpx wrangler d1 migrations apply linkforge-demo # 注意:預設本機(第 9 篇)npm run devB=localhost:8787curl -s "$B/seed"curl -s "$B/types" # boolean 與 Date 都是真的curl -s "$B/batch" # tuple 定位型別curl -s "$B/batch-rollback" # 整批回滾curl -s "$B/transaction" # Failed query: begincurl -s "$B/plan" # SCAN links ← 索引沒用上練習一:把 /plan 的查詢改成 .where(and(eq(links.tenantId, "t1"), eq(links.slug, "cf"))),再跑一次,看 SCAN 變成 SEARCH ... USING INDEX。
練習二:把 drizzle-kit 換成 1.0.0-rc.4 重新 generate,看目錄結構的變化,以及不加 migrations_pattern 時 wrangler 那句危險的「✅ No migrations to apply!」。
接進 LinkForge
Section titled “接進 LinkForge”packages/db 是全 repo 唯一碰資料庫的地方。
版本策略寫進 VERSIONS.md
Section titled “版本策略寫進 VERSIONS.md”drizzle-orm 0.45.2 # exact, no caret — 1.0 is still RCdrizzle-kit 0.31.10 # exact用確切版本,不用 ^。 這個專案在 npm 上有 50 幾個 dist-tag,^ 的風險不值得冒。1.0 stable 出來之後再排一次專門的升級(含 d1_migrations 表的名稱修正)。
三條寫進 lint / review 的規則
Section titled “三條寫進 lint / review 的規則”① 禁止 db.transaction()
加一條 ESLint no-restricted-syntax:
{ selector: "CallExpression[callee.property.name='transaction']", message: "D1 does not support interactive transactions. Use db.batch() (chapter 10).",}型別系統不擋,就用 linter 擋。
② 每個 repository 函式都要有 query plan 測試
it("findBySlug uses the composite index", async () => { const plan = await explain(findBySlugQuery("t1", "cf")); expect(plan).not.toContain("SCAN");});③ 資料存取一律經過 packages/db 的 repository 函式
apps/api 不直接組查詢。理由有三:租戶隔離的 where 條件只寫在一個地方(第 41 篇)、query plan 測試有明確的測試對象、第 11 篇要換成 session-aware 的讀取時只改一處。
export const findBySlug = (db: Db, tenantId: string, slug: string) => db.select().from(links) .where(and(eq(links.tenantId, tenantId), eq(links.slug, slug))) .limit(1) // chapter 09: .get() does not add LIMIT for you .get();注意那個 .limit(1) —— 第 9 篇實測過 .first() / .get() 不會幫你加,掃描成本照算。
Migration 工作流
Section titled “Migration 工作流”# 1. 改 packages/db/src/schema.ts# 2. 產生pnpm --filter db drizzle-kit generate# 3. 本機套用並測試wrangler d1 migrations apply linkforge --local# 4. stagingwrangler d1 migrations apply linkforge --env staging --remote# 5. production(走 CI,第 40 篇)wrangler d1 migrations apply linkforge --env production --remote第 3 到 5 步的 --local / --remote 一律明寫,即使 --local 是預設值。理由是可讀性:讀 CI 設定的人不該需要記得 Wrangler 的預設值。
本篇交付物:packages/db 完整落地(schema、migration、repository 函式、query plan 測試)、apps/api 改用 repository、ESLint 規則、VERSIONS.md 鎖版。
① 混用兩條版本線
npm i drizzle-orm 給 0.45.2,官網文件寫的是 @rc。先選線,鎖確切版本。
② 用 db.transaction()
型別會過,runtime 送出裸 begin,D1 拒絕。用 batch()。
③ 1.0 的 migration 佈局沒設 migrations_pattern
Wrangler 印「✅ No migrations to apply!」並 exit 0 —— CI 綠燈,production 沒動。
④ 從 0.4x 升到 1.0 沒修 d1_migrations
檔名格式變了,所有 migration 會被視為未套用。
⑤ 混用 wrangler d1 migrations apply 和 drizzle-kit migrate
兩張不同的記錄表。
⑥ 以為 ORM 會幫你避開 rows_read
不會。實測一個正常的 .where(eq(...)) 產生 SCAN。用 .toSQL() + EXPLAIN QUERY PLAN。
⑦ 忘記 .limit(1)
.get() 和 .first() 都不會自動加。
⑧ 把 db.batch([...]) 的陣列抽成變數
型別會從 tuple 放寬成陣列,逐項型別就沒了。
⑨ production 用 drizzle-kit push
資料庫變更不進版控。
⑩ 依賴 1.0 的 casing 選項
drizzle() 不再接受,cast 繞過去會讓欄位名靜默改變。
本篇要記住的三句話
Section titled “本篇要記住的三句話”- 先選版本線再寫程式碼。
latest是 0.45.2,官網文件是 1.0-rc.4,兩條不相容,而且要鎖確切版本。 - **
db.transaction()在 D1 上型別會過、runtime 會死。**只有batch()有原子性。 - ORM 不會保護你不踩
rows_read。.toSQL()+EXPLAIN QUERY PLAN要變成日常習慣。
- Drizzle + Cloudflare D1(注意:預設描述 1.0 RC)
- Relations v1 → v2 遷移
- drizzle-orm#2463 — D1 transaction not supported
- D1 migrations(
migrations_pattern尚未收錄,只有 changelog 有)
Cloudflare 官方目前沒有 Drizzle 的 D1 教學 —— D1 tutorials 只有 Prisma,Drizzle 只出現在 community projects 頁面。這一章填的是一個真的空缺。
下一篇:11. D1 進階:Read Replication 與 Sessions API —— 最重要的一句話是:不呼叫 withSession(),你開的讀取複本等於沒開。