跳到內容

D1 + Drizzle ORM:型別安全的資料層

查證日期
驗證環境drizzle-orm@0.45.2 / 1.0.0-rc.4·drizzle-kit@0.31.10 / 1.0.0-rc.4·wrangler@4.114.0
前置章節09. D1 入門

這是全系列地雷最密的一章,而且大部分地雷跟 SQL 無關,跟版本有關。

三件必須先講的事:

  1. npm 的 latest 和 Drizzle 官網文件描述的是兩條不相容的版本線。 npm i drizzle-orm 給你 0.45.2,但 orm.drizzle.team 的 Cloudflare D1 頁面叫你裝 drizzle-orm@rc1.0.0-rc.4)。兩邊 API 不同。
  2. db.transaction() 在兩條線上都會在 D1 上 runtime 炸掉。 型別完全過得了,一跑就死。
  3. ORM 不會保護你不踩第 9 篇的成本陷阱。 我實測 Drizzle 產生的一個看起來完全正常的查詢,query plan 是 SCAN

實測今天的 npm dist-tags:

Terminal window
$ 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-tagbeelinkrevert-netlifykit-duckdb……)。這本身就是一個訊號:這個專案目前是在 npm 上做開發分支管理的。務必鎖定確切版本,不要用 ^

兩條線的差異(SQLite / D1 相關)

Section titled “兩條線的差異(SQLite / D1 相關)”
0.45.21.0.0-rc.4
drizzle() 選項{ schema, casing, logger, cache }{ relations, logger, cache }SQLite driver 的 schemaOmit
關聯定義relations()defineRelations() / defineRelationsPart()
命名慣例drizzle({ casing: 'snake_case' })drizzle() 不再接受 —— 移到 table 定義時
取欄位getTableColumns()新增 getColumns()getTableColumns 仍在)
驗證整合獨立套件 drizzle-zoddrizzle-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;

實測確實有效:

Terminal window
$ 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 是真的 booleancreatedAt 是真的 Date。這是用 ORM 最實在的好處之一。

$inferSelect / $inferInsert 讓型別從 schema 流出來,第 26 篇會一路流到 React component。

🔴 Migration 佈局:兩條線輸出完全不同

Section titled “🔴 Migration 佈局:兩條線輸出完全不同”

這是 D1 + Drizzle 最實際的互通性問題。

drizzle-kit@0.31.10(0.4x 線):

Terminal window
$ npx drizzle-kit generate
[✓] Your SQL migration file ➜ drizzle/0000_complex_tyger_tiger.sql 🚀
$ find drizzle -type f
drizzle/0000_complex_tyger_tiger.sql
drizzle/meta/0000_snapshot.json
drizzle/meta/_journal.json

drizzle-kit@1.0.0-rc.4(1.0 線):

Terminal window
$ npx drizzle-kit generate
[✓] Your SQL migration ➜ drizzle/20260728080351_sparkling_cable/migration.sql 🚀
$ find drizzle -type f
drizzle/20260728080351_sparkling_cable/migration.sql
drizzle/20260728080351_sparkling_cable/snapshot.json

扁平 + meta/_journal.json → 巢狀資料夾,而且編號從序號變成時間戳。

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 表裡已有的紀錄對不上。升級時要手動修那張表。兩邊的文件都沒有提這件事。

記錄表誰執行
wrangler d1 migrations applyd1_migrationsWrangler
drizzle-kit migrate(走 d1-http__drizzle_migrationsdrizzle-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";
});

實測:

Terminal window
$ 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。

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[],型別就沒了。

回滾也確認有效:

Terminal window
$ 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

Terminal window
$ 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(走 d1-http)會直接把 schema 差異推到遠端資料庫,不產生檔案。

適合:個人專案的早期探索階段。 不適合:任何有多人、有 staging/production 分離、需要 code review 的專案 —— 因為資料庫變更不進版控、不可審、不可回溯。

本系列一律用 migration 檔案。


完整程式碼:examples/ch10-drizzle/

Terminal window
cd examples/ch10-drizzle
npm install
npx drizzle-kit generate # 產生 migration
npx wrangler d1 migrations apply linkforge-demo # 注意:預設本機(第 9 篇)
npm run dev
Terminal window
B=localhost:8787
curl -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: begin
curl -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!」。


packages/db 是全 repo 唯一碰資料庫的地方。

drizzle-orm 0.45.2 # exact, no caret — 1.0 is still RC
drizzle-kit 0.31.10 # exact

用確切版本,不用 ^ 這個專案在 npm 上有 50 幾個 dist-tag,^ 的風險不值得冒。1.0 stable 出來之後再排一次專門的升級(含 d1_migrations 表的名稱修正)。

① 禁止 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 測試

packages/db/test/plans.test.ts
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 的讀取時只改一處。

packages/db/src/links.ts
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() 不會幫你加,掃描成本照算。

Terminal window
# 1. 改 packages/db/src/schema.ts
# 2. 產生
pnpm --filter db drizzle-kit generate
# 3. 本機套用並測試
wrangler d1 migrations apply linkforge --local
# 4. staging
wrangler 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 applydrizzle-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 繞過去會讓欄位名靜默改變。


  1. 先選版本線再寫程式碼。latest 是 0.45.2,官網文件是 1.0-rc.4,兩條不相容,而且要鎖確切版本。
  2. **db.transaction() 在 D1 上型別會過、runtime 會死。**只有 batch() 有原子性。
  3. ORM 不會保護你不踩 rows_read.toSQL() + EXPLAIN QUERY PLAN 要變成日常習慣。

Cloudflare 官方目前沒有 Drizzle 的 D1 教學 —— D1 tutorials 只有 Prisma,Drizzle 只出現在 community projects 頁面。這一章填的是一個真的空缺。


下一篇11. D1 進階:Read Replication 與 Sessions API —— 最重要的一句話是:不呼叫 withSession(),你開的讀取複本等於沒開。