作为一个 Electron + pnpm monorepo 技术栈的桌面应用项目,我们在过去一年中经历了两次 Prisma 大版本升级:5→6 和 6→7。前一次是按部就班的版本跟进,后一次则是一次架构层面的重写,最终让我们收获了一个零原生依赖、零引擎二进制文件的打包方案。
本文记录这个过程中的技术决策、踩坑实录,以及那个"意外红利"的真正来源。
背景:我们的技术栈
- 包管理:pnpm workspace (monorepo)
- 桌面端:Electron(本文方案只要求其捆绑的 Node.js ≥ 22.5,实测 37.10.3 即可,无需追新版本)
- ORM:Prisma + SQLite
- 产物目标:Windows 安装包 (~120MB),要求离线可用、无额外运行时依赖
第一次升级:Prisma 5 → 6
这是典型的版本跟进升级。Prisma 6 的 release notes 列出了几个 headline 改进:
- 支持 Node.js 22/23
- 性能优化(query engine 启动更快)
- 一些废弃 API 的清理
我们的升级改动很常规:
prisma
// packages/common/src/prisma/schema.prisma (Prisma 6)
generator client {
provider = "prisma-client-js"
engineType = "library" // 使用 library engine
binaryTargets = ["native", "windows"] // 为目标平台预编译
output = "../generated/db_client"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
javascript
// packages/common/src/utils/prisma/index.js (Prisma 6)
const { PrismaClient } = require('db_client')
function getPrisma(ctx) {
const { dbUrl, qePath } = getDBConstants(ctx)
const option = {
log: ['error', 'info', 'warn'],
__internal: {
engine: {
binaryPath: qePath // 关键:指向 query engine 二进制
}
}
}
if (!isDev) {
option.datasources = { db: { url: dbUrl } }
}
return new PrismaClient(option)
}
痛点 :和 v5 版本一样,打包时依然必须在 app.asar 外单独放置 query_engine-windows.dll.node 和 schema-engine-windows.exe,还要在代码里动态计算路径。这是 Prisma 6 桌面应用最脆弱的部分------路径解析一旦出错,启动就崩溃。
第二次升级:Prisma 6 → 7
官方定位:这不是升级,是重写
Prisma 7 的核心变化(参考 官方升级指南):
| 维度 | Prisma 6 | Prisma 7 |
|---|---|---|
| Query Engine | Rust 查询引擎 (query_engine-*.dll.node) |
删除,改为 TypeScript Query Compiler |
| Generator | prisma-client-js |
prisma-client(强制指定 output) |
| 连接方式 | 通过 datasource url |
强制 Driver Adapter |
| 产物格式 | CJS/ESM 可选 | 始终输出 ESM TypeScript |
| 迁移工具 | schema-engine-*.exe 二进制 |
无引擎,需自行实现或 fork CLI |
这意味着:Prisma 6- 时代那个最容易出错的引擎二进制分发问题,整个消失了。但代价是数据库集成层需要一次性重写。
一个必须澄清的边界 :Prisma 7 删掉的是它自己的 Rust 引擎二进制(query_engine-*.dll.node 和 schema-engine-*.exe)。这不等于「打包后就没有任何原生依赖」了------因为 Prisma 7 强制走 Driver Adapter,而官方推荐的 SQLite adapter 是 @prisma/adapter-better-sqlite3,它底层依赖 better-sqlite3 这个原生 C++ 模块 ,仍会给你的包塞进一个需要按 ABI 重编译的 .node。
逆流而动:当整个前端都在"锈化",Prisma 却在"去锈"
这里有一个值得停下来想一想的行业背景。
过去几年,前端工具链几乎是集体转向 Rust的:
- 打包器:esbuild(Go)、SWC(替代 Babel)、Turbopack、Rspack、Rolldown(Rust 重写的 Rollup,Vite 的下一代底座)
- Lint / 格式化:Biome、Oxc(替代 ESLint + Prettier)
- CSS:Lightning CSS(替代 postcss 的一部分)
主旋律非常清晰:用 Rust 换性能。JS 写的工具太慢,换成 Rust/Go 的原生二进制,构建速度动辄十倍提升。本项目自己也吃着这波红利------用的就是 esbuild 和已迁到 Rust 生态的 Vite。
而 Prisma 7 做的恰好是反方向 :把用了几年的 Rust query engine 删掉,改回 TypeScript + WASM 的 Query Compiler。在"人人锈化"的大势下,这看起来像逆流而动。
但这两件事其实不矛盾,关键在于 Rust 二进制待在哪一层:
| 前端工具链的 Rust | Prisma 6 的 Rust 引擎 | |
|---|---|---|
| 运行位置 | 构建期,在开发者机器 / CI 上跑 | 运行期,随产物分发到用户机器 |
| 是否进最终产物 | 否------产出的是纯 JS/CSS | 是 ------query_engine-*.dll.node 必须打进包 |
| 平台耦合 | 只需匹配 CI 的平台 | 必须为每个目标平台准备对应二进制 |
| 出错的代价 | 构建慢一点、装个二进制 | 用户端启动直接崩(路径找不到、ABI 不匹配) |
看清这张表,"逆流"的表象就消解了:
前端把 Rust 放进构建期工具 ,是把原生代码的复杂度关在了开发者这一侧------用户拿到的还是纯 JS,Rust 的麻烦不会外溢到分发产物。
Prisma 6 的问题恰恰相反:它把 Rust 引擎放进了运行期产物 ,于是原生二进制的全部痛点(多平台编译、ABI 匹配、路径定位、asar 解包)全都外溢到了每一个终端用户的机器上。对一个要做成离线安装包的桌面应用来说,这是最难受的位置。
所以 Prisma 7"去锈"去的不是 Rust 本身,而是运行期分发面上的原生二进制 。这跟前端"锈化"追求的目标其实是同一个:让最终交付物尽量轻、尽量没有平台耦合。前端靠"把 Rust 留在构建期"达成,Prisma 7 靠"运行期彻底不要原生引擎"达成------路径相反,方向一致。
对本项目而言,这个转向是决定性的利好 :Prisma 6 时代打包最脆弱的一环(在 app.asar 外定位引擎二进制)不是被优化了,而是从根上不存在了 。也正因为 Prisma 7 把运行期让成了一块"纯 JS + adapter 接口"的空地,我们才有机会用一个零编译的 node:sqlite 去填它------这才有了下一节的重写。
关键决策:用什么 Driver Adapter?
官方推荐 SQLite 用 @prisma/adapter-better-sqlite3。但我们实测发现:
better-sqlite3是原生模块(C++ addon),必须针对 Electron 的 ABI 用electron-rebuild重新编译- 打包时必须
asarUnpack解压.node文件,路径处理又是一场噩梦 - 换 Electron 版本、换 CI 机器架构,都可能触发一轮 rebuild
转折点 :Node.js 22.5.0 引入了内置模块 node:sqlite。只要打包所用的 Electron 捆绑的 Node ≥ 22.5 就能用上它------这个门槛并不高,实测 Electron 37.10.3 就已满足,无需任何原生编译。
我们决定:自己实现一个基于 node:sqlite 的 Driver Adapter,替换 better-sqlite3。
实现:基于 node:sqlite 的 Driver Adapter
1. 自定义 Adapter 核心代码
javascript
// packages/common/src/utils/prisma/nodeSqliteAdapter.js
const { DatabaseSync } = require('node:sqlite')
const { ColumnTypeEnum, DriverAdapterError } = require('@prisma/driver-adapter-utils')
// SQLite 扩展错误码(node:sqlite 使用数值 errcode,而非字符串)
const SQLITE_CONSTRAINT_UNIQUE = 2067
const SQLITE_CONSTRAINT_PRIMARYKEY = 1555
const SQLITE_CONSTRAINT_NOTNULL = 1299
const SQLITE_CONSTRAINT_FOREIGNKEY = 787
const SQLITE_BUSY = 5
class NodeSqliteQueryable {
constructor(db, adapterOptions) {
this.db = db
this.adapterOptions = adapterOptions
}
async queryRaw(query) {
const { columnNames, declaredTypes, values } = this.performIO(query)
const columnTypes = getColumnTypes(declaredTypes, values)
return {
columnNames,
columnTypes,
rows: values.map((row) => mapRow(row, columnTypes))
}
}
performIO(query) {
const args = query.args.map((arg, i) => mapArg(arg, query.argTypes[i], this.adapterOptions))
const stmt = this.db.prepare(query.sql)
stmt.setReadBigInts(true)
// node:sqlite 的 columns() 判断语句是否返回行
let columns = []
try { columns = stmt.columns() } catch { /* non-SELECT returns [] */ }
if (columns.length === 0) {
stmt.run(...args)
return { columnNames: [], declaredTypes: [], values: [] }
}
stmt.setReturnArrays(true)
return {
declaredTypes: columns.map((c) => c.type),
columnNames: columns.map((c) => c.name),
values: stmt.all(...args)
}
}
onError(error) {
throw new DriverAdapterError(convertDriverError(error))
}
}
// 错误映射:node:sqlite 与 better-sqlite3 的关键差异
function mapDriverError(error) {
// node:sqlite: error.code === 'ERR_SQLITE_ERROR'(恒定)
// 真正的扩展码在 error.errcode(整数)
const extended = typeof error.errcode === 'number' ? error.errcode : undefined
if (extended === SQLITE_BUSY || extended === 517) {
return { kind: 'SocketTimeout' }
}
if (extended === SQLITE_CONSTRAINT_UNIQUE || extended === SQLITE_CONSTRAINT_PRIMARYKEY) {
return {
kind: 'UniqueConstraintViolation',
constraint: { fields: parseConstraintFields(error.message) }
}
}
// ... 其他错误码映射
throw error
}
// Factory:符合 SqlMigrationAwareDriverAdapterFactory 接口
class PrismaNodeSqlite {
constructor(config, options) {
this.config = config
this.options = options
}
connect() {
const dbPath = this.config.url.replace(/^file:/, '')
const db = new DatabaseSync(dbPath)
// WAL 模式:读写并发不阻塞
if (dbPath !== ':memory:') {
db.exec('PRAGMA journal_mode = WAL')
}
db.exec('PRAGMA busy_timeout = 5000')
db.exec('PRAGMA foreign_keys = ON')
return Promise.resolve(new PrismaNodeSqliteAdapter(db, this.options))
}
}
module.exports = { PrismaNodeSqlite }
2. 运行时连接方式
javascript
// packages/common/src/utils/prisma/index.js
const { PrismaClient } = require('../../generated/db_client/client')
const { PrismaNodeSqlite } = require('./nodeSqliteAdapter')
function getPrisma(ctx) {
if ('prisma' in global) return global.prisma
const { dbUrl } = getDBConstants(ctx)
// Prisma 7:通过 adapter 连接,不再依赖 query engine 二进制
const adapter = new PrismaNodeSqlite({ url: dbUrl })
const prisma = new PrismaClient({
adapter,
log: ['error', 'info', 'warn']
})
global.prisma = prisma
return prisma
}
3. Schema 与配置的分离
prisma
// packages/common/src/prisma/schema.prisma
// 注意:Prisma 7 不允许在 schema 中保留 url,否则报 P1012
generator client {
provider = "prisma-client"
output = "../generated/db_client"
runtime = "nodejs"
importFileExtension = ""
}
datasource db {
provider = "sqlite"
// url 被删除!运行时通过 adapter 连接,CLI 通过 prisma.config.ts
}
typescript
// packages/common/prisma.config.ts
import { defineConfig } from 'prisma/config'
export default defineConfig({
schema: './src/prisma/schema.prisma',
migrations: {
path: './src/prisma/migrations'
},
datasource: {
url: process.env.DATABASE_URL ?? 'file:./dev.db'
}
})
4. 迁移:从 fork CLI 到直接执行 SQL
Prisma 7 删除了 schema-engine 二进制,这意味着打包后的应用无法再用 prisma migrate deploy。我们的方案:直接读取 migration.sql 文件,通过 node:sqlite 执行。
javascript
// packages/common/src/utils/prisma/runPrismaCommand.js
const { DatabaseSync } = require('node:sqlite')
async function runPrismaCommand({ ctx }) {
const { dbPath } = getDBConstants(ctx)
const migrationsDir = resolveMigrationsDir(ctx)
const migrations = listMigrations(migrationsDir) // 按文件名排序
const db = new DatabaseSync(dbPath)
db.exec('PRAGMA busy_timeout = 5000')
db.exec('PRAGMA journal_mode = WAL')
db.exec(MIGRATIONS_TABLE_DDL) // 创建 _prisma_migrations 表
const appliedRows = db
.prepare('SELECT migration_name, checksum FROM "_prisma_migrations" WHERE rolled_back_at IS NULL')
.all()
const applied = new Map(appliedRows.map((r) => [r[0], r[1]]))
for (const name of migrations) {
const sql = fs.readFileSync(path.join(migrationsDir, name, 'migration.sql'), 'utf8')
const sum = crypto.createHash('sha256').update(sql, 'utf8').digest('hex')
if (applied.has(name)) {
// Drift guard:已应用的迁移文件被修改则报错
const recorded = applied.get(name)
if (recorded && recorded !== sum) {
throw new Error(`migration ${name} checksum mismatch`)
}
continue
}
db.exec('BEGIN')
try {
db.exec(sql)
db.prepare(
`INSERT INTO "_prisma_migrations" (id, checksum, finished_at, migration_name, started_at, applied_steps_count)
VALUES (?, ?, ?, ?, ?, 1)`
).run(crypto.randomUUID(), sum, new Date().toISOString(), name, new Date().toISOString())
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
db.close()
}
打包侧:electron-builder 的陷阱
依赖声明的坑
prisma CLI 必须放在 devDependencies,不能是 dependencies:
json
{
"devDependencies": {
"prisma": "7.8.0"
},
"dependencies": {
"@prisma/client": "7.8.0",
"@prisma/driver-adapter-utils": "7.8.0"
}
}
原因 :prisma 包有一个可选 peer dependency better-sqlite3。如果它是生产依赖,electron-builder 会尝试 npm rebuild better-sqlite3,而这个包根本没安装,导致 node-gyp 失败。
离线打包配置
yaml
# electron-builder.yml
win:
target: nsis
# 使用本地已下载的 electron,跳过 github 下载
electronDist: "./node_modules/.pnpm/electron@43.0.0-beta.3/node_modules/electron/dist"
extraResources:
- from: "../packages/common/dist/prisma/migrations"
to: "prisma/migrations"
环境变量(用于国内网络):
bash
# winCodeSign / nsis 二进制镜像
export ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
"意外的红利":打包时和多余的 dll 和 exe 说再见
回到标题说的"红利",需要澄清它的真正来源:
| 因素 | 贡献 |
|---|---|
| Prisma 7 | 做"减法":删除 Rust query engine,强制走 driver adapter。等于卸掉了必须随包分发、必须在 asar 外定位的引擎二进制包袱。这不是它送红利,是它不再挡路。 |
| Electron 捆绑的 Node ≥ 22.5 | 提供内置 node:sqlite(Node 22.5+ 首次可用,Electron 37.x 即满足),让我们能填上 Prisma 7 让出的 adapter 空位,而不引入 better-sqlite3 这类原生模块。门槛是 Node 版本,不是某个特定 Electron 大版本。 |
所以准确说法是:"Prisma 卸掉原生引擎"和"Electron 捆绑的 Node 内置了 SQLite"这两个独立趋势在同一时间窗口交汇,催生的组合红利。
任何一边早一点都吃不到:
- Prisma 6 还绑着引擎,必须处理二进制分发
- Node 22.5 之前没有
node:sqlite,只能用 better-sqlite3 等原生驱动
红利落到实处:electron-builder.yml 少了多少东西
抽象的"零原生依赖"不如直接看打包配置的前后对比。这是同一个应用、同一个 electron-builder.yml,Prisma 6 与 Prisma 7 两版的差异。
Prisma 6 时代------为了引擎二进制,配置里要做两件事。
第一,extraResources 必须把引擎二进制、schema、env 一起塞进产物(否则运行时找不到引擎直接崩):
yaml
# electron-builder.yml (Prisma 6) --- extraResources
extraResources:
# ↓↓↓ 两个必须随包分发的原生二进制 ↓↓↓
- from: ../../node_modules/.pnpm/@prisma+engines@6.19.2/node_modules/@prisma/engines/schema-engine-windows.exe
to: prisma/schema-engine-windows.exe
- from: ../../node_modules/.pnpm/@prisma+engines@6.19.2/node_modules/@prisma/engines/query_engine-windows.dll.node
to: prisma/query_engine-windows.dll.node
# ↓↓↓ 引擎还要 schema 和 env 才能跑迁移 ↓↓↓
- from: ../../packages/common/src/prisma/schema.prisma
to: prisma/schema.prisma
- from: ../../packages/common/src/prisma/.env
to: prisma/.env
# ↓↓↓ 下面这两项才是真正的业务资产 ↓↓↓
- from: ../../packages/common/src/prisma/migrations
to: prisma/migrations
- from: ../../packages/common/dist/storage
to: storage
第二,files 里还要反向排除 一大堆引擎垃圾------因为 @prisma/engines 会把各平台(darwin/linux/arm64/musl...)的二进制全拖进 node_modules,不排除就白白胖几十 MB,还可能误打包错平台的 .node:
yaml
# electron-builder.yml (Prisma 6) --- files 里被迫写的一堆排除规则
files:
# ...常规排除...
- "!**/node_modules/@prisma/engines/{introspection-engine,schema-engine,prisma-fmt,query_engine-,libquery_engine}*"
- "!**/node_modules/prisma/{query,libquery}_engine*"
- "!**/node_modules/prisma/**/*.mjs"
- "!**/node_modules/prisma/*.db"
- "!**/node_modules/**/*{darwin,linux,android,freebsd,openbsd,arm64,musl_x64}*/**"
Prisma 7 + node:sqlite 之后------上面两坨全部删掉。 extraResources 只剩真正的业务资产,files 里那 5 行引擎排除规则一行不留:
yaml
# electron-builder.yml (Prisma 7) --- 全部
# Prisma 7 Rust-free:不再分发任何 query/schema 引擎二进制;
# node:sqlite 是 Electron 内置 Node 自带的驱动,也没有原生模块要 unpack。
# 只需分发运行时执行的 SQL 迁移文件 + 种子数据。
extraResources:
- from: ../../packages/common/src/prisma/migrations
to: prisma/migrations
- from: ../../packages/common/dist/storage
to: storage
# files: 里那 5 行 @prisma/engines / 各平台 .node 的排除规则------不再需要,已删除
一目了然,打包侧被移除的东西:
| 项目 | Prisma 6 | Prisma 7 |
|---|---|---|
schema-engine-windows.exe(分发) |
✅ 必须 | ❌ 无 |
query_engine-windows.dll.node(分发) |
✅ 必须 | ❌ 无 |
schema.prisma + .env(供引擎跑迁移) |
✅ 必须 | ❌ 无 |
files 里引擎/多平台 .node 排除规则 |
5 行 | 0 行 |
asarUnpack 原生 .node |
视 adapter 而定 | ❌ 无 |
| 产物里的原生二进制总数 | 2 个 + 一堆待排除项 | 0 |
schema.prisma 和 .env 也不再进包,是因为迁移已改成运行时直接执行 migration.sql(见上一节 runPrismaCommand),不再需要引擎读 schema。剩下随包走的 migrations/ 是纯文本 SQL,storage/ 是种子数据------两者都是"数据",不是"可执行二进制"。这才是"和多余的 dll 和 exe 说再见"的字面意义。
代价
node:sqlite在 37.x 的某些版本仍是 实验性 API- Driver adapter 和迁移器需自行维护(我们写了约 500 行适配代码 + 测试)
- 官方推荐路径仍是
@prisma/adapter-better-sqlite3
本质是**"愿意自扛一点集成责任,换取打包侧的巨大简化"**------红利真实,但非白得。
而放到"前端全面锈化"的大背景下看,这次升级更像一个提醒:技术选型该问的不是"这东西是不是 Rust 写的",而是"原生二进制待在哪一层、由谁承担它的复杂度"。 前端把 Rust 关进构建期,Prisma 7 把 Rust 请出运行期,两者殊途同归,都在给最终交付物减负。本项目的收益,正是踩在了这条共同的方向线上。
版本与依赖清单
| 组件 | 版本 |
|---|---|
| Prisma | 7.8.0 |
| @prisma/client | 7.8.0 |
| @prisma/driver-adapter-utils | 7.8.0 |
| Electron | 43.0.0-beta.3(本样本;实际业务项目 37.10.3 即可) |
| Node.js (bundled) | ≥ 22.5(node:sqlite 可用即可) |
| pnpm | 11.x |
| electron-builder | 26.0.11 |