最近几个项目分别用了 Prisma、Drizzle 和干脆不用 ORM 直接写 SQL。

同一个 AI 在这三种上犯的错完全不一样,这点挺出乎我意料的… Prisma 上它写的是「上个大版本的正确写法」,Drizzle 上它卡在类型和 API 签名,裸 SQL 上它压根不知道那个 driver 有什么限制。

这里记录下我从 schema 到迁移这条链路上用来兜底的几条做法,纯属备忘。

Prisma 7 / Drizzle 0.45 / Next 16。版本对不上的话,下面这些写法都会变哦。

Prisma 7 有几个 AI 一定会写错的地方

mikiacg 那个仓库的 CLAUDE.md:18-25 有一节直接叫「关键版本差异(AI 常见错误)」,六条 bullet 里跟 Prisma 相关的就一条:

Prisma 7 generator 为 "prisma-client"(不是 "prisma-client-js"),导入路径 @/generated/prisma/client

一条塞了两件事。同一个仓库的 .cursor/rules/library-versions.mdc 结尾那张 ❌→✅ 对照表把它拆成了第 3、4 两项,分开又写了一遍(那个仓库同时维护着三套约定文件,这么写的代价我在讲约定文件那篇里算过账,这里只挑跟数据层有关的看)。

看起来冗余,其实很有必要。因为这两件事不是「AI 不会」,是「AI 记的是旧的」。

不写死它百分之百按记忆猜,屡试不爽捏。

我自己再补一条: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 根本不连库——镜像里那两行 SKIP_ENV_VALIDATION=1 加假连接串就是这么来的,我在讲自托管那篇里贴过原文。

要留意的是这个因果方向:不是 Dockerfile 写错了,是 prisma.config.ts 里那句无条件 throw 把构建期一起管上了。校验写在配置文件顶层的库都有这毛病,Prisma 只是我最近撞上的那个。

这几条全属于「AI 训练数据里是旧写法」,写进 CLAUDE.md 是性价比最高的一类约定,一行字省你半小时喔。

db push 建不出原生 SQL 索引

这个坑不怪 AI,但 AI 也不会主动提醒你。

vns-next 的搜索用了 pg_trgm 扩展加三个 GIN 索引:标题模糊、别名数组包含、标签名模糊。这些 prisma db push 一个都不会帮你建,它只同步 schema 里表达得出来的东西。

所以我把它们单独放了一份 scripts/db/indexes.sql,文件头第一行注释就写着「Prisma schema 无法声明 trgm/表达式索引,db:push 后手动执行」。

更要紧的是验收方式。commit 35659c4 的 body 里记的那句是「pg_trgm 搜索(EXPLAIN 验证走索引)」。

查询能跑通不代表走了索引,全表扫也能返回正确结果,只是慢。这种事必须看执行计划,不能看接口有没有 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

随便挑一个成员做断言。能跑,但它把校验责任整个丢掉了,真要认真做就得先过一遍 zod 的 enum。

这里我先记着,还没动…

又是版本差异

Drizzle 的表定义第二个参数从 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 那两条一个性质呢。查 changelog 的时候记得别翻错版本,0.45 里找不到这条的。

还有个不是代码的问题

本地我跑的是 pnpm db:push,README 里给别人的步骤同样是 pnpm db:pushdrizzle/ 目录下到现在就一个 baseline migration。

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 四张表。

但你自己 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 写不对不是因为笨,是因为它没法凭空知道你的表叫什么。这种就属于必须人先定死、再让它接线的部分啦。

顺带一个化石:那个仓库 .claude/settings.local.json 的白名单里连着两条命令,删掉旧认证库的 catch-all 路由目录、新建新认证库的 catch-all 路由目录。

一次换库的过程就这么被完整记录下来了,而最终那个 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 测试全绿」。

数据迁移最怕的不是崩,是静默错。字段串位、日期偏一天,跑完全绿你也看不出来,只能靠纯函数加单测顶着。

时区那个坑值得单独说。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 挑了前三个写进导入说明,第 48 行另给了条验证 SQL:导入后 select count(*) from work 应为 145。有这一行,换台机器重跑一遍心里才有底哦。

最后一条容易忽略:迁移脚本和前端共用的编解码函数,要抽成零依赖模块。

vns-next 的 src/lib/download-label.ts:1-3 文件头写着:

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

起因是 commit 404a769 的「splitLabel 抽出零依赖模块防 gray-matter 进客户端包」。

共用函数一旦顺手引了个解析库,它就会顺着 import 链一路爬进客户端 bundle,谁也拦不住。

不用 ORM 的时候

另一个项目干脆没上 ORM,直接用一个 serverless Postgres 的 HTTP driver 写裸 SQL。

省下来的复杂度,会原样还给你的迁移脚本:

  • 那个 driver 一次只能执行一条语句,所以迁移脚本得自己写一个按分号切分的状态机,还要处理 dollar-quoted 块和字符串里的分号
  • 批量写入必须合成单条 unnest upsert。HTTP 驱动下每条语句都是一次独立的网络往返,条数一多耗时就很难看了
  • 迁移用纯文件名序号、每次全量重跑,靠 SQL 自身幂等,连迁移状态表都没有

最后这条反而最省心。因为没有状态表,也就没有「状态表和真实 schema 不一致」这种最难查的问题喽。

我现在的几条习惯

做法解决什么使用频率
schema 先定死再让 AI 写查询避免它按记忆猜字段★★★★★
迁移脚本必须幂等可重跑中途失败能直接再跑一遍★★★★★
解析/编解码抽成纯函数配单测数据迁移最怕静默错★★★★
原生索引单独一份 SQLpush/generate 都不会帮你建★★★
构建期喂假连接串Docker 构建层里没有数据库★★★

先写到这,等下次换 ORM 再补喔~