升级到 Prisma 7:“Rust 除锈” 带来的意外红利

作为一个 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.nodeschema-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.nodeschema-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 / 格式化:BiomeOxc(替代 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

参考链接

相关推荐
还好还好不是吗4 天前
MatrixMedia v0.10.x 更新:B站封面自定义 + 二开指南,开源项目的开发者体验再升级
electron·开源
习明然4 天前
我的本地化AI项目(三)
人工智能·python·electron·c#·avalonia
Hyyy5 天前
为什么 macOS 应用一换 Bundle ID,之前授予的权限就全失效了?
macos·electron
月走乂山7 天前
Anything Analyzer MCP 401 Unauthorized 故障排查
electron·故障排查·mcp·claude code·anything analyzer
熊猫钓鱼>_>7 天前
Electron:当 Web 技术统治桌面
大数据·前端·javascript·人工智能·架构·electron·agent
程序员老刘7 天前
Opencode从Tauri切回Electron,桌面端技术选型别被体积陷阱带偏了
electron·ai编程
大意的百合8 天前
Electron 应用如何上架微软商店:从 MSIX 打包到商店提交
javascript·microsoft·electron
专职9 天前
electron中拖动文件到桌面案例
前端·javascript·electron
SRET9 天前
Mineradio 沙盒隔离安装完全指南 | 一键安全运行 Electron 应用!!!
javascript·经验分享·安全·electron·aigc·音视频·ai编程