14
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

D1に適したORMはPrismaかDrizzleか

14
Posted at

はじめに

ハッカソンでD1を使うことになったのですが、PrismaとDrizzleのどちらがD1に適しているんだろうと思いました。

それぞれ、別プロジェクトで使用したことはあるものの、どちらも便利だなと感じただけで、明確な選定理由は思い浮かびませんでした。

せっかくな機械なので、D1を使う前提で、どちらのORMが適しているのか調べてみたので、その結果を紹介する記事です。

この記事で扱わないこと

一般的な Prisma と Drizzle の比較には踏み込みません。
機能の豊富さやエコシステムの大きさで比べると、また違う結論になります。

この記事で扱うのは D1 という条件下での話だけです。

「Prisma は重い」はもう古い

「Prismaは重い」とよく耳にしていたので、まずはバンドルサイズから調査しました。Workers にはバンドルサイズの上限があるので、ORM が重いと不利になるためです。

結論から言うと、この前提は現行版では成り立ちません。

Prisma はもともと Rust 製のクエリエンジンをバイナリで同梱していました。これが「重い」と言われた原因です。しかし、Rust を廃して TypeScript と WASM で実装し直したバージョンが v6.16.0 で GA になり、v7 では標準になりました。

Prisma の公式ブログには、バンドルサイズについて次のように書かれています。

90% smaller bundle size (from ~14MB to 1.6 MB)
(Prisma ORM without Rust: Latest Performance Benchmarks より)

Worker のバンドルサイズ上限は、gzip 圧縮後で Free プランが 3MB、Paid プランが 10MB です。圧縮前ならどちらも 64MB になります。Prisma が公表している 1.6MB は圧縮前の数字なので、gzip をかければさらに小さくなります。どちらの基準で見ても収まる計算です。

この数字は Prisma 自身が公表したものです。独立した第三者によるベンチマークは、調べた範囲では見つかりませんでした。実際に採用するときは自分のプロジェクトで計測することをおすすめします。

参考までに Drizzle はコアが gzip 後で約 8.3KB、依存ゼロです。
桁で見ればまだ軽いものの、「14MB 対 8KB」だった頃とは前提が変わっています。

つまり、軽さを理由に Prisma を外すのは、もう正確ではありません。では本当の理由はどこにあるのでしょうか。ここから3つ挙げます。

理由1 アダプタが2年以上 Preview のまま

そもそもアダプタとは

Prisma が実際にデータベースと通信する部分を、外から差し替えるための層です。

Prisma はもともと Rust 製エンジンが自前でデータベースに TCP 接続していました。しかし Workers では TCP ソケットが使えません。env.DB バインディング経由でしか D1 を触れないため、通信部分だけを差し込めるようにした仕組みが driver adapter です。

最終的にどちらも env.DB.prepare() を呼ぶところに行き着きます。違うのは、そこに至るまでに何層あるかです。

[Drizzle]
   drizzle-orm/d1                           ← D1 専用の入口
        │  最初から D1 の作法を知っている
        ▼
   env.DB.prepare(sql).bind(...args).all()
        │
        ▼
       D1
[Prisma]
   PrismaClient
        │  queryRaw({ sql, args }) を呼ぶ    ← Prisma が決めた共通の形
        ▼
   PrismaD1                                 ← 翻訳係。ここが今も Preview
        │  D1 の作法に変換する
        ▼
   env.DB.prepare(sql).bind(...args).all()
        │
        ▼
       D1

Prisma には翻訳係が1枚挟まっています。なぜ必要なのでしょうか。Prisma は PostgreSQL も MySQL も D1 も、同じ PrismaClient で扱います。データベースごとに通信方法が違うため、「Prisma が期待する形」を1つ決めておき、各データベース用のアダプタがそこへ翻訳する構造になっているからです。

一方 Drizzle は drizzle-orm/d1drizzle-orm/node-postgres のように、driver ごとに入口が分かれています。それぞれが相手の API を直接知っているため、翻訳する層が要りません。

この違いは初期化のコードにそのまま出ます。

drizzle.ts
import { drizzle } from "drizzle-orm/d1";

// env.DB をそのまま渡す
const db = drizzle(env.DB);
prisma.ts
import { PrismaD1 } from "@prisma/adapter-d1";
import { PrismaClient } from "@prisma/client";

// env.DB を包んでから渡す
const adapter = new PrismaD1(env.DB);
const prisma = new PrismaClient({ adapter });

new PrismaD1(env.DB) の一行が、図でいう翻訳係にあたります。

これは優劣ではなく設計判断です。Prisma の方式には「アダプタさえ書けば新しいデータベースに対応できる」という拡張性があります。実際 v7 では、すべてのデータベースがこの仕組みに統一されました。

問題は D1 という個別のケースで、その翻訳係の成熟度にあります。

Preview が意味すること

Prisma の機能ステータスは Early Access、Preview、GA の3段階です。Preview は本番利用が公式に推奨されておらず、破壊的変更が予告なく入る可能性がある段階を指します。

その @prisma/adapter-d1 が、今も Preview のままです。

Prisma ORM support for Cloudflare D1 is currently in Preview.
(Prisma Docs: Cloudflare D1 より)

D1 への対応自体は v5.12.0(2024年4月)から始まっています。2年以上経っても GA になっていません。

紛らわしいのは、driver adapter という仕組み自体はすでに GA だという点です。v7 では Rust 廃止にともない、すべてのデータベースが adapter 経由になり必須化されました。Preview なのは D1 個別の実装だけです。

一方で drizzle-orm/d1 は通常のサポート対象ドライバで、Preview のような但し書きは付きません。

Prisma はコア技術が成熟した一方で、D1 との統合部分だけが取り残されている状態だと言えます。

理由2 マイグレーションが wrangler と噛み合わない

Prisma Migrate は D1 に直接適用できません。migrate devmigrate deploy が D1 に繋がらないため、SQL を吐き出して wrangler に渡し直す流れになります。

[Prisma]  schema.prisma を編集
             │
             ▼
   prisma migrate diff               ← migrate dev は使えない
     --from-local-d1                    差分SQLを「出力するだけ」
     --to-schema prisma/schema.prisma
     --script  >  0001_init.sql
             │
             ▼
   wrangler d1 execute <DB> --file=./0001_init.sql
[Drizzle]  schema.ts を編集
             │
             ▼
   drizzle-kit generate              ← 素のSQL + 台帳を生成
     ├─ migrations/0001_xxx.sql
     └─ migrations/meta/_journal.json    ← 適用状況の台帳
             │
             ▼
   wrangler d1 migrations apply <DB>
                                     ← 台帳を見て未適用分だけ流す

違いは、適用済みかどうかの台帳を誰が持つかです。

Drizzle の場合、generate が出力する _journal.json を wrangler がそのまま読みます。ORM と wrangler が同じ台帳を共有するため、wrangler d1 migrations apply が未適用分だけを判定して流してくれます。

Prisma の場合、migrate diff は SQL を出力するだけで適用管理をしません。wrangler d1 execute も指定したファイルを実行するだけです。どこまで適用したかは自分で管理することになります。

D1 の公式ドキュメントも migrations_pattern という設定を用意していて、ORM 側の出力を受け取る前提になっています。wrangler d1 migrations create ではなく ORM 側のコマンドを使うように、と明記されています。

この違いは CI を組むときに効いてきます。Drizzle なら本番反映は次の1行で済みます。

.github/workflows/deploy.yml
- run: wrangler d1 migrations apply <DB> --remote

未適用分の判定を wrangler がやってくれるため、CI 側に条件分岐が要りません。Prisma の場合は「今回どのファイルを流すか」を CI に教える仕組みを自分で用意することになります。

理由3 トランザクションの失敗が見えない

ここが個人的に一番効いた理由です。

D1 は interactive transaction を持ちません。つまり Prisma と Drizzle のどちらを選んでも、トランザクションは使えません。違うのは失敗の仕方です。

なぜ D1 はトランザクションを持てないのか

interactive transaction は「BEGIN を発行し、クエリを投げ、アプリ側で結果を見て判断し、またクエリを投げ、COMMIT する」という流れです。この間ずっと、データベースがセッションとロックを握ったままアプリを待つ必要があります。

[通常のDB]                         [D1]
  接続を張りっぱなし                 リクエストごとに使い捨て
      │                               │
  BEGIN                           ①呼び出し ─┐
  SELECT ...  ← アプリが考える中     │        │ この間、DBは
  UPDATE ...     DBは待機中          │        │ 何も覚えていない
  COMMIT                          ②呼び出し ─┘

エッジ環境では接続が使い捨てで、実行場所も世界中に分散しています。アプリ側が遅れたり落ちたりすると、ロックが握られたまま放置されてしまいます。分散環境でこれを許すと簡単に詰まるため、1回の呼び出しで完結する batch() だけが許可されています。

同じ割り切りは珍しくありません。Neon の HTTP driver も単発クエリと batch のみで、interactive transaction を使うには WebSocket 接続が必要です。

失敗の仕方が違う

在庫を減らして注文を作る、という処理で考えてみます。

await prisma.$transaction([
  prisma.item.update({ where: { id }, data: { stock: { decrement: 1 } } }),
  prisma.order.create({ data: { itemId: id, userId } }),
]);

Prisma の公式ドキュメントには "Transactions not supported" という独立した見出しがあり、次のように書かれています。

implicit & explicit transactions will be ignored and run as individual queries, which breaks the guarantees of the ACID properties
(Prisma Docs: Cloudflare D1 より)

implicit は配列形式、explicit はコールバック形式を指します。どちらの書き方をしても、囲んだだけの個別クエリとして順に実行されます。

そのため注文の作成が失敗すると、次のようになります。

在庫を減らす → 成功して確定する
注文を作る   → 失敗して例外が投げられる
      ↓
アプリは注文作成のエラーを受け取る
けれど在庫は減ったまま残る

ここが誤解しやすいところです。エラー自体は出ます。出ないのは「巻き戻らなかった」という事実のほうです。try/catch を書いていても、ロールバックされた前提でエラー処理を書いてしまいます。

一方 Drizzle は BEGIN を発行しようとした時点で落ちます。

D1_ERROR: To execute a transaction, please use the state.storage.transaction()
or state.storage.transactionSync() APIs instead of the SQL BEGIN TRANSACTION
or SAVEPOINT statements...

トランザクションの開始で失敗するため、中のクエリは1つも実行されません。データは変わらないままです。

[Prisma]   在庫だけ減って注文がない状態が残る    → データの復旧が必要
[Drizzle]  何も実行されない                     → 書き方を直すだけで済む

厄介なのは、Prisma の壊れ方が静かなことです。トランザクションが仕事をするのは途中で失敗したときだけなので、すべて成功する限り結果は変わりません。開発中もテストも正常系を通ることがほとんどで、異常系を踏んだ本番で初めてデータが壊れます。

Drizzle は最初の1回で落ちるため、必ず気づけます。しかも気づいた後には db.batch() という代替が用意されています。

黙って整合性が壊れるより、落ちてくれるほうが安全だと判断しました。

原子性が必要なときはどう書くか

失われるのは「途中で判断を挟むこと」だけです。batch() があるので、原子性そのものは確保できます。

実務でよく使うのは、判断を SQL 側に埋め込む書き方です。

UPDATE items SET stock = stock - 1 WHERE id = ? AND stock >= 1

読んでから書くのではなく、条件付き更新1発にまとめます。更新された行数が 0 なら在庫不足だったと判断できます。

複数のテーブルをまとめて更新したいときは batch() を使います。D1 の batch は暗黙的に1つのトランザクションになるため、1つでも失敗すれば全体がロールバックされます。

transfer.ts
await db.batch([
  db.update(account).set({ balance: sql`balance - 1000` }).where(eq(account.id, 1)),
  db.update(account).set({ balance: sql`balance + 1000` }).where(eq(account.id, 2)),
]);

本当に強い整合性が必要なら Durable Objects という選択肢もあります。D1 で BEGIN を実行したときのエラーメッセージ自体が、Durable Objects の state.storage.transaction() を使うよう案内してきます。D1 自体が Durable Objects の上に構築されているためです。

なお Prisma には、D1 の batch() を使う公式の手段がありません。D1 と Prisma の組み合わせでは、原子性が必要な処理を安全に書く方法が実質的に存在しないことになります。

Drizzle を選んでも無傷ではない

ここまで Drizzle を選ぶ理由を挙げてきました。ただし、選べば問題がなくなるわけではありません。調べる中で気になった点を3つ挙げます。

1つ目は、公式ドキュメントが v1 の RC 版に寄り始めていることです。D1 の接続ガイドを開くと、インストールコマンドがすでに drizzle-orm@rc になっています。安定版を使っている場合、手順をそのままコピペすると噛み合いません。RC 版ではマイグレーションのファイル構造も変わっていて、_journal.json への言及が消えています。

2つ目は、Cloudflare 公式のサポートの手厚さです。Prisma には D1 との連携を扱った専用チュートリアルがありますが、Drizzle に同等のページはありません。D1 固有の挙動については、公式ドキュメントより GitHub の Issue のほうが情報が濃いというのが実感です。

3つ目は、チーム開発でのマイグレーション衝突です。drizzle-kit generate は連番を _journal.json の最後のエントリから機械的に採番します。ディスク上のファイルとは突き合わせません。そのため2人が同じ状態を基準に生成すると、同じ番号が振られて台帳が壊れます。

3つ目については drizzle-kit check というコマンドが用意されていて、この衝突を検知できます。CI や pre-push フックに入れておくと安心です。

drizzle-kit check

最後に

調べる前は、バンドルサイズの軽さが選定理由になると思っていました。実際に調べてみると、そこはすでに大きな差ではなくなっていました。もし軽さだけで判断していたら、間違った理由で正しい結論に辿り着いていたことになります。

一番の決め手になったのは、トランザクションが使えないという同じ制約に対して、失敗の仕方が違うという点でした。黙って壊れるか、その場で落ちるか。この差はハッカソンのような短期開発ではとくに大きいと感じています。

とはいえ、これは 2026年8月時点での判断です。@prisma/adapter-d1 が GA になれば、理由の1つは消えます。この記事を読んでいる時点でどうなっているかは、ぜひ公式ドキュメントで確認してみてください。

D1 で ORM を選んだ方がいれば、どんな理由で決めたのかコメントで教えていただけると嬉しいです。

参考文献

株式会社シンシア

株式会社シンシアでは、実務未経験のエンジニアの方や学生エンジニアインターンを採用し一緒に働いています。
※ シンシアにおける働き方の様子はこちら

弊社には年間100人以上の実務未経験の方に応募いただき、技術面接を実施しております。
この記事が少しでも学びになったという方は、ぜひ wantedly のストーリーもご覧いただけるととても嬉しいです!

14
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?