ここ最近のいくつかのプロジェクトでは、Prisma、Drizzle、そしてORMを使わずに直接SQLを書く、という3つのやり方をそれぞれ使った。

同じAIでも、この3つで犯すミスはまったく違う。これはけっこう意外だった…

Prismaでは「前のメジャーバージョンの正しい書き方」を書き、Drizzleでは型とAPIシグネチャでつまずき、生SQLではそのドライバにどんな制約があるのかまったく分かっていない。

ここでは、schemaからマイグレーションまでのこの一連の流れで保険として使っているいくつかのやり方をメモしておく。完全に備忘録だ。

Prisma 7 / Drizzle 0.45 / Next 16。バージョンが合っていないと、以下の書き方はすべて変わるよ。

Prisma 7 でAIが必ず書き間違える箇所がいくつかある

acg-site そのリポジトリの CLAUDE.md:18-25 には「重要なバージョン差異(AIのよくある間違い)」という名の節があって、6つのbulletのうちPrisma関連は1つだけ:

Prisma 7 の generator は "prisma-client"("prisma-client-js" ではない)、インポートパスは @/generated/prisma/client

1行に2つのことが詰め込まれている。同じリポジトリの .cursor/rules/library-versions.mdc の末尾にある ❌→✅ の対照表では、これが3番目と4番目の項目に分けられ、別々に書き直されている(あのリポジトリは3セットの規約ファイルを同時に管理していて、そう書くコストは規約ファイルの記事で計算した。ここではデータ層に関係するものだけ拾って見る)。

冗長に見えるが、実はとても必要だ。というのも、この2つは「AIができない」のではなく「AIが覚えているのが古い」からだ。

明示しておかなければ、100%記憶から推測してくる。何度試してもそうなる。

自分からもう1つ補足すると、Prisma 7からは接続文字列がschemaから prisma.config.ts に移り、そのファイル内の検証はたいてい無条件に例外を投げる形で書かれる。

vns-next の prisma.config.ts:5-8 はまさにこうだ:

const url = process.env.DATABASE_URL;
if (!url) {
  throw new Error("DATABASE_URL is not defined");
}

その結果、Dockerのビルド期にもプレースホルダ値を渡さなければならない。たとえ prisma generate がデータベースにまったく接続しなくても——イメージ内のあの2行、SKIP_ENV_VALIDATION=1 と偽の接続文字列はそういう経緯で生まれたもので、セルフホスティングの記事で原文を貼った。

注意すべきはこの因果の向きだ。Dockerfileが間違っているのではなく、prisma.config.ts にある無条件の throw がビルド期まで巻き込んでいるのだ。設定ファイルのトップレベルに検証を書くライブラリはみなこの問題を抱えていて、Prismaはたまたま僕が最近ぶつかったものにすぎない。

これらの項目はすべて「AIの訓練データでは古い書き方」に属する。CLAUDE.md に書き込むのはコスパが最も高い類の規約で、1行で30分節約できるよ。

db push ではネイティブSQLインデックスを作れない

この罠はAIのせいではないが、AIも進んで教えてはくれない。

vns-nextの検索は pg_trgm 拡張と3つのGINインデックスを使っている:タイトルのあいまい検索、別名配列の包含、タグ名のあいまい検索だ。これらを prisma db push は1つも作ってくれない。schemaで表現できるものしか同期しないからだ。

なので、それらは別の scripts/db/indexes.sql にまとめてある。ファイル冒頭の1行目のコメントには「Prisma schema では trgm/式インデックスを宣言できない。db:push 後に手動で実行する」と書いてある。

さらに重要なのは検収の方法だ。commit 35659c4 のbodyに書いたのは「pg_trgm 検索(EXPLAINでインデックス使用を検証)」という一言。

クエリが通ったからといってインデックスを使っているとは限らない。全表スキャンでも正しい結果は返る、ただ遅いだけだ。こういうのは実行計画を見るべきで、APIが200を返すかどうかでは判断できないよ。

Drizzle 側のいくつかの摩擦点

thdlのほうではDrizzle + postgres.jsを使っていて、摩擦点はまったく別の顔ぶれになった。

コネクションプール

devのホットリロードで接続が何度も新規作成され、リークし尽くすので、globalThis のシングルトンをぶら下げる必要がある。

Prismaユーザーにはおなじみの罠で、Drizzleに移っても同じように自分で書かなければならない(src/lib/db/index.ts:8-13):

declare global {
  var __thdl_pg: ReturnType<typeof postgres> | undefined;
}

const client = globalThis.__thdl_pg ?? postgres(connectionString, { max: 10 });
if (process.env.NODE_ENV !== "production") globalThis.__thdl_pg = client;

列挙型カラムと searchParams の遭遇

URLから来るのは常に string で、pgEnum のカラムは9つのリテラルのユニオンであり、その間にはきれいな変換経路がない。

最終的にコードはこうなった(src/app/(site)/resources/page.tsx:21):

cat ? eq(resources.category, cat as "music") : undefined

適当にメンバーを1つ選んでアサーションしている。動きはするが、検証の責任を丸ごと捨てている。本気でやるならまずzodのenumを通すべきだ。

ここはとりあえずメモしておくだけで、まだ手を付けていない…

またしてもバージョン差異

Drizzleのテーブル定義の第2引数は0.36から配列を返す形に変わり、オブジェクトを返さなくなった。thdlに入っているのは0.45.2で、書き方は src/lib/db/schema.ts:113-117 のとおり:

(t) => [
  index("resources_status_created_idx").on(t.status, t.createdAt),
  index("resources_category_idx").on(t.category),
]

AIはおそらく => ({ ... }) と書いてくる。Prismaのあの2つと同じ性質だね。changelogを調べるときはバージョンを間違えないように。0.45にはこの項目は見つからない。

もう1つ、コードではない問題

ローカルで僕が実行しているのは pnpm db:push で、READMEで他の人に示している手順も同じく pnpm db:push、drizzle/ ディレクトリの下には今のところbaseline migrationが1つだけある。

package.json:14 には確かに db:migrate が定義されているが、リポジトリ全体を grep しても、どこからも呼ばれていない。デプロイ側はさらに潔く、deploy/ には Caddyfile と systemd unit がひとつずつあるだけで、ExecStart は node server.js そのもの、データベースには一切触れない。

つまり、schema がどう段階的に進化してきたのか、バージョン管理された記録は一切なく、すべて push の場当たり的な同期に頼っているということだ。

今はまだデータベースが小さいので、push 一発で片付けてしまっても確かに問題はない。テーブル構造が複雑になってきたり、本当に二人目がデプロイするとなったら、このツケは払うことになる… 分かってはいるのだが、まだ手を付けていない。

ORM と認証ライブラリの接続は手作業で合わせる必要がある

thdl は better-auth の drizzleAdapter を使っており、これが user / session / account / verification の4つのテーブルを規定している。

ただし自分の schema 内のテーブル名がこの通りとは限らないので、手作業でマッピングし直す必要がある(src/lib/auth.ts:8-16):

database: drizzleAdapter(db, {
  provider: "pg",
  schema: {
    user: schema.users,
    session: schema.sessions,
    account: schema.accounts,
    verification: schema.verifications,
  },
}),

この部分を AI が正しく書けないのは、頭が悪いからではなく、あなたのテーブルが何という名前なのかをゼロから知るすべがないからだ。これはもう、人がまず固く決めてから、AI に配線させるしかない類の部分なのだ。

ついでに化石をひとつ。あのリポジトリの .claude/settings.local.json のホワイトリストには、旧認証ライブラリの catch-all ルートディレクトリを削除し、新認証ライブラリの catch-all ルートディレクトリを作成する、という2つのコマンドが並んでいる。

ライブラリを入れ替える過程がそのまま完全に記録されているわけで、しかも最終的なその route ファイルはたった4行、笑ってしまう。

マイグレーションスクリプトは冪等に、そして単体テストも添える

vns-next は Hugo の旧サイトから Postgres へ移行する。scripts/migrate-content.ts のファイル先頭コメントの最初の一文がそのまま方針になっている。「冪等かつ再実行可能:Work/Page は主キーで upsert;DownloadLink は先に deleteMany してから挿入;SiteLink/TeamMember/DlEntry は先にクリアしてから挿入。」

途中で失敗したらそのままもう一度走らせればいい。この前提のおかげで、移行作業全体がずっと楽になった。

パース処理は独立したモジュール scripts/lib/parse.ts に切り出し、31 個のテストケース、6 個の describe ブロックを用意した。commit 35659c4 の body に書いた受け入れ条件は「31 テスト全緑」の一言だ。

データ移行で一番怖いのはクラッシュではなく、静かなエラーだ。フィールドの位置がずれたり、日付が1日ずれたりしても、全部緑で終わったら気づけない。純粋関数と単体テストで支えるしかない。

タイムゾーンの罠は個別に触れる価値がある。js-yaml のデフォルト schema は、date: 2024-07-31 03:56:40 のようなタイムゾーンなしの文字列をそのまま Date としてパースし、UTC として計算するので、その場でタイムゾーンが狂う。

解決策は JSON_SCHEMA に切り替えて文字列のまま保ち、自分で +08:00 に従ってパースすること。scripts/lib/parse.ts:12-13 のコメント原文:

js-yaml は gray-matter の依存なので、ここではそのまま再利用する。
JSON_SCHEMA を使って date: 2024-07-31 03:56:40 を文字列のまま保つ(デフォルト schema では UTC として Date にパースされ、タイムゾーンが狂う)

データ量も照合できるようにしたい。移行後に commit 35659c4 の body へ work=145 / tag=247 / download_link=2307 / page=22 / dl_entry=89 を記録した。

deploy/README.md:35 は最初の3つを選んでインポート手順に書き、48行目には検証用 SQL をもう1本添えた:インポート後、select count(*) from work は 145 になるはずだ。この一行があるからこそ、別のマシンで再実行しても安心できるのだ。

最後のひとつは見落としやすい:移行スクリプトとフロントエンドで共有するエンコード・デコード関数は、依存ゼロのモジュールに切り出すべきだ。

vns-next の src/lib/download-label.ts:1-3 のファイル先頭にはこう書いてある:

/**
 * DownloadLink.label 的编码/解码 —— 纯函数、零依赖
 *(迁移脚本与前端共用;不要在这里引入任何 node/解析库依赖)
 */

きっかけは commit 404a769 の「splitLabel を依存ゼロのモジュールに切り出し、gray-matter がクライアントバンドルに入るのを防ぐ」だ。

共有関数がつい気軽にパーサライブラリを import してしまうと、import チェーンを伝ってクライアントバンドルまで入り込んでいき、誰にも止められない。

ORM を使わない場合

別のプロジェクトでは ORM を一切入れず、serverless Postgres の HTTP driver で生の SQL を書いている。

省いた複雑さは、そのままマイグレーションスクリプトに返ってくる:

  • この driver は一度にひとつの文しか実行できないので、マイグレーションスクリプトではセミコロンで分割する状態機械を自分で書き、さらに dollar-quoted ブロックや文字列内のセミコロンも処理しなければならない
  • バルク書き込みは単一の unnest upsert にまとめる必要がある。HTTP driver では文ごとに独立したネットワーク往復が発生するので、本数が増えると所要時間が目も当てられないことになる
  • マイグレーションはファイル名の連番のみで管理し、毎回全量を再実行、SQL 自身の冪等性に任せていて、マイグレーション状態テーブルすら持たない

最後のこれはむしろ一番気が楽だ。状態テーブルがなければ、「状態テーブルと実際の schema が一致しない」という最も追究しづらい問題も起きないからね。

今の自分のいくつかの習慣

手法解決すること使用頻度
schema を先に固めてから AI にクエリを書かせる記憶でフィールドを推測するのを避ける★★★★★
マイグレーションスクリプトは冪等で再実行可能にする途中で失敗してもそのままもう一度流せる★★★★★
パース/エンコード・デコードを純粋関数に切り出して単体テストを付けるデータ移行で一番怖いのはサイレントな誤り★★★★
ネイティブインデックスは別途 SQL にまとめるpush/generate はどちらも作ってくれない★★★
ビルド時にダミーの接続文字列を渡すDocker のビルドレイヤーにはデータベースがない★★★

とりあえずここまで。次に ORM を乗り換えたときにまた追記するね~