Electron 集成 Drizzle + SQLite 踩坑笔记

Electron + Drizzle + SQLite 踩坑笔记

项目环境:Electron Forge + Vite + Drizzle ORM + SQLite

最终推荐驱动:node:sqlite

1. 最终方案

当前项目最后更适合使用:

text 复制代码
Electron Main Process
        ↓
Drizzle ORM
        ↓
node:sqlite
        ↓
SQLite 文件

主要原因:

  • node:sqlite 是 Node/Electron 自带模块,不需要额外的 native addon。
  • 不需要处理 better_sqlite3.node
  • 不需要 electron-rebuild
  • 不存在 better-sqlite3 的 ABI、ASAR unpack、native module 拷贝等额外问题。
  • 对 Electron + Forge + Vite 来说,最终只需要处理好 Vite external 和数据库/migration 路径。

2. 第一个问题:node:sqlite 被 Vite 当成浏览器模块

最开始使用:

ts 复制代码
import { drizzle } from 'drizzle-orm/node-sqlite';
import { DatabaseSync } from 'node:sqlite';

运行:

bash 复制代码
npm run start

出现:

text 复制代码
node_modules/drizzle-orm/node-sqlite/driver.js (7:9):
"DatabaseSync" is not exported by
"__vite-browser-external:node:sqlite"

关键错误:

text 复制代码
__vite-browser-external:node:sqlite

原因

不是 Drizzle 不支持 node:sqlite,也不是 Electron 不支持 SQLite。

真正原因是:

text 复制代码
src/main.ts
    ↓
drizzle-orm/node-sqlite
    ↓
node:sqlite
    ↓
Vite 尝试进行 bundle
    ↓
把 node:sqlite 当成 browser external
    ↓
找不到 DatabaseSync

node:sqlite 是 Node 内置模块,应该交给 Electron 的 Node runtime 加载,而不是让 Vite 打包。

解决

vite.main.config.ts

ts 复制代码
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      external: [
        'node:sqlite',
      ],
    },
  },
});

项目当前实际配置中已经把:

ts 复制代码
'node:sqlite'

加入了 external


3. 中途尝试 better-sqlite3

由于一开始误以为 node:sqlite 驱动不适合 Electron,因此尝试切换到:

ts 复制代码
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';

开发环境很快又遇到了第二类问题。


4. better-sqlite3:Vite 动态 require .node 失败

报错:

text 复制代码
Error occurred in handler for 'db':

Error: Could not dynamically require
"/Users/user/IdeaProjects/bosszp/.vite/build/Release/better_sqlite3.node".

Please configure the dynamicRequireTargets or/and
ignoreDynamicRequires option of @rollup/plugin-commonjs
appropriately for this require call to work.

原因

better-sqlite3 不是纯 JavaScript 包。

它内部依赖 native addon:

text 复制代码
better_sqlite3.node

但 Vite 把 better-sqlite3 的 JS 部分 bundle 到:

text 复制代码
.vite/build/main.js

之后,其内部动态 require() 根据 bundle 后的位置寻找:

text 复制代码
.vite/build/Release/better_sqlite3.node

实际 .node 文件却在 node_modules/better-sqlite3 内,所以加载失败。

当时的解决方法

在:

text 复制代码
vite.main.config.ts

加入:

ts 复制代码
external: ['better-sqlite3']

即:

不让 Vite bundle better-sqlite3,运行时由 Electron/Node 自己加载。

这样之后:

bash 复制代码
electron-forge start

可以正常运行。


5. better-sqlite3:开发正常,但 electron-forge make 后失败

better-sqlite3 external 后:

text 复制代码
electron-forge start
✅ 正常

但是:

text 复制代码
electron-forge make
❌ 打包后的 App 运行失败

错误变成:

text 复制代码
Cannot find module 'better-sqlite3'

原因

开发环境中:

text 复制代码
项目目录
└── node_modules
    └── better-sqlite3

所以 external 后 Electron 可以从项目 node_modules 找到它。

但正式打包:

text 复制代码
Contents/Resources/app.asar

中的 main.js 仍然保留:

js 复制代码
require('better-sqlite3')

而最终应用中没有完整包含可以供它加载的 better-sqlite3 package,于是:

text 复制代码
Cannot find module 'better-sqlite3'

这说明:

text 复制代码
Vite external

解决的是"不要 bundle native package"的问题,

但又引出了:

text 复制代码
Forge 最终如何把 external dependency 放进 App

的问题。


6. 尝试 @electron-forge/plugin-auto-unpack-natives

安装:

bash 复制代码
npm install -D @electron-forge/plugin-auto-unpack-natives

Forge 配置中加入:

ts 复制代码
{
  name: '@electron-forge/plugin-auto-unpack-natives',
  config: {},
}

项目当前 patch 中确实已经存在这项配置。

结果

仍然出现:

text 复制代码
Cannot find module 'better-sqlite3'

原因

这个插件解决的是:

text 复制代码
已经进入 App 的 native .node 文件
↓
不能直接从 ASAR 中加载
↓
自动放到 app.asar.unpacked

它不是用来保证整个 external npm package 被复制进最终 App 的。

因此:

text 复制代码
auto-unpack-natives

和:

text 复制代码
Cannot find module 'better-sqlite3'

不是同一个层面的问题。


7. 为什么最后放弃 better-sqlite3

better-sqlite3 在 Electron 中并不是不能用,但会带来额外工程问题:

text 复制代码
better-sqlite3
    ↓
native addon
    ↓
better_sqlite3.node
    ↓
Electron ABI
    ↓
electron-rebuild
    ↓
Vite external
    ↓
Forge dependency packaging
    ↓
ASAR unpack

node:sqlite 是 Electron 内部 Node 提供的 builtin:

text 复制代码
node:sqlite
    ↓
Electron runtime

无需:

  • better_sqlite3.node
  • electron-rebuild
  • native ABI 处理
  • native package 拷贝
  • ASAR unpack

因此当前项目最终重新选择:

text 复制代码
Drizzle + node:sqlite

8. 数据库文件不能使用相对路径

如果写:

ts 复制代码
new DatabaseSync('./sqlite.db');

或者:

ts 复制代码
path.join(__dirname, 'sqlite.db');

开发环境可能看起来正常,但正式打包后路径不可靠。

原因:

  • process.cwd() 不保证指向项目目录。
  • __dirname 在 Vite bundle 后可能位于 .vite/build
  • 打包后代码通常位于 app.asar
  • App 安装目录不应该拿来存储用户运行数据。
  • 应用升级可能替换安装目录内容。

9. 正式数据库应该存在哪里

推荐:

ts 复制代码
app.getPath('userData')

例如:

ts 复制代码
const dbPath = path.join(
  app.getPath('userData'),
  'sqlite.db',
);

const sqlite = new DatabaseSync(dbPath);
const db = drizzle({ client: sqlite });

macOS 大致为:

text 复制代码
~/Library/Application Support/<AppName>/sqlite.db

Windows 大致为:

text 复制代码
C:\Users\<User>\AppData\Roaming\<AppName>\sqlite.db

Linux 大致为:

text 复制代码
~/.config/<AppName>/sqlite.db

当前项目需要注意

上传的项目 patch 中当前代码还是:

ts 复制代码
const dbPath = path.join(
  app.getPath('documents'),
  'sqlite.db',
);

即数据库被放到 Documents。

如果没有"让用户直接看到数据库文件"的需求,更推荐改成:

ts 复制代码
app.getPath('userData')

因为它更符合桌面应用的数据目录语义。


10. Drizzle Schema

当前 schema:

ts 复制代码
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';

export const usersTable = sqliteTable('users_table', {
  id: int().primaryKey({ autoIncrement: true }),
  name: text().notNull(),
  age: int().notNull(),
  email: text().notNull().unique(),
});

对应 migration 会创建:

sql 复制代码
CREATE TABLE `users_table` (
    `id` integer PRIMARY KEY AUTOINCREMENT,
    `name` text NOT NULL,
    `age` integer NOT NULL,
    `email` text NOT NULL UNIQUE
);

11. drizzle.config.ts

当前项目配置:

ts 复制代码
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  out: './drizzle',
  schema: './src/db/schema.ts',
  dialect: 'sqlite',
  dbCredentials: {
    url: 'sqlite.db',
  },
});

这里需要区分两个概念:

text 复制代码
drizzle.config.ts 的 dbCredentials.url

主要是给:

text 复制代码
drizzle-kit

CLI 使用。

而 Electron 正式运行时真正打开哪个数据库文件,取决于:

ts 复制代码
new DatabaseSync(dbPath)

所以正式 App 即使数据库放在:

text 复制代码
app.getPath('userData')

也不要求 drizzle.config.ts 写成那个运行时路径。


12. Migration 生成流程

当前 package.json 已增加:

json 复制代码
{
  "scripts": {
    "generate": "npx drizzle-kit generate",
    "migrate": "npx drizzle-kit migrate"
  }
}

修改:

text 复制代码
src/db/schema.ts

之后执行:

bash 复制代码
npm run generate

Drizzle 会在:

text 复制代码
drizzle/

生成 migration。

项目目前生成的 migration 结构类似:

text 复制代码
drizzle/
└── 20260828041323_strange_felicia_hardy/
    ├── migration.sql
    └── snapshot.json

应用启动时应该执行:

ts 复制代码
migrate(db, {
  migrationsFolder,
});

这样:

text 复制代码
第一次启动
↓
创建 sqlite.db
↓
执行全部未执行 migration
↓
创建数据库表

之后升级应用:

text 复制代码
旧 sqlite.db 保留
↓
启动新版 App
↓
执行新增 migration
↓
保留旧数据并升级表结构

13. Migration 文件必须跟着 Electron App 一起打包

migration 不是用户数据。

因此不要把:

text 复制代码
drizzle/

放进:

text 复制代码
userData

正确结构应该是:

text 复制代码
App Resources
└── drizzle/
    └── migrations...

userData
└── sqlite.db

当前 forge.config.ts 已加入:

ts 复制代码
packagerConfig: {
  asar: true,
  extraResource: [
    './drizzle',
  ],
},

作用是:

text 复制代码
项目 drizzle/
↓
electron-forge make
↓
Contents/Resources/drizzle/

所以正式版 migration 路径应该读取:

ts 复制代码
path.join(process.resourcesPath, 'drizzle')

14. Migration 路径错误:ENOENT scandir '/drizzle'

后续出现:

text 复制代码
Uncaught (in promise) Error:
Error invoking remote method 'db':

Error: ENOENT: no such file or directory,
scandir '/drizzle'

这个错误非常重要。

它证明传给:

ts 复制代码
migrate(db, {
  migrationsFolder,
});

的路径最终变成:

text 复制代码
/drizzle

也就是操作系统根目录中的:

text 复制代码
/drizzle

显然不存在。


15. 为什么会变成 /drizzle

当前 patch 里的代码是:

ts 复制代码
const migrationsFolder = app.isPackaged
  ? path.join(process.resourcesPath, 'drizzle')
  : path.resolve(__dirname, '../../drizzle');

开发环境使用:

ts 复制代码
path.resolve(__dirname, '../../drizzle')

但是项目用了:

text 复制代码
Electron Forge + Vite

Vite 会把 Main Process bundle 到类似:

text 复制代码
.vite/build/main.js

此时:

ts 复制代码
__dirname

已经不是:

text 复制代码
src/

而是 bundle 输出目录。

因此通过:

ts 复制代码
../../drizzle

去猜项目根目录非常脆弱。

最终甚至可能计算出:

text 复制代码
/drizzle

于是出现:

text 复制代码
ENOENT scandir '/drizzle'

16. Migration 路径最终推荐写法

开发环境:

ts 复制代码
app.getAppPath()

正式环境:

ts 复制代码
process.resourcesPath

推荐:

ts 复制代码
const migrationsFolder = app.isPackaged
  ? path.join(process.resourcesPath, 'drizzle')
  : path.join(app.getAppPath(), 'drizzle');

逻辑:

开发环境

text 复制代码
app.getAppPath()
≈ /Users/user/IdeaProjects/bosszp

得到:

text 复制代码
/Users/user/IdeaProjects/bosszp/drizzle

正式环境

text 复制代码
process.resourcesPath
≈ bosszp.app/Contents/Resources

得到:

text 复制代码
bosszp.app/Contents/Resources/drizzle

这正好对应 Forge:

ts 复制代码
extraResource: ['./drizzle']

复制后的目录。


17. 推荐的最终数据库初始化代码

建议数据库初始化不要散落在 IPC handler 中,而是在 App 启动阶段初始化一次。

ts 复制代码
import { app } from 'electron';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { drizzle } from 'drizzle-orm/node-sqlite';
import { migrate } from 'drizzle-orm/node-sqlite/migrator';

export function initDatabase() {
  const dbPath = path.join(
    app.getPath('userData'),
    'sqlite.db',
  );

  const sqlite = new DatabaseSync(dbPath);

  const db = drizzle({
    client: sqlite,
  });

  const migrationsFolder = app.isPackaged
    ? path.join(process.resourcesPath, 'drizzle')
    : path.join(app.getAppPath(), 'drizzle');

  console.log({
    dbPath,
    migrationsFolder,
    appPath: app.getAppPath(),
    resourcesPath: process.resourcesPath,
    isPackaged: app.isPackaged,
  });

  migrate(db, {
    migrationsFolder,
  });

  return db;
}
相关推荐
律宏阔16 分钟前
Electron preload.ts 类型无法自动推导的解决方案
前端
名字还没想好☜28 分钟前
kubectl 排障实战:jsonpath 精准取值、custom-columns、events 排序与 top 速查
运维·前端·chrome·docker·kubernetes
李昊哲小课34 分钟前
Spring Boot 4 旅游主题实战教程 阶段二:Web 开发基础
前端·spring boot·旅游
ITmaster07311 小时前
从零到一!前端搭建本地轻量化 RAG 问答系统
前端
夏炳辉.1 小时前
Flex布局中 flex: 1 的完整解析与实战指南
前端·css·css3
CIO_Alliance2 小时前
AI提示系列(2)| Few-shot与ReAct有何不同? 大模型工具调用的底层逻辑详解
前端·人工智能·深度学习·神经网络·react.js·前端框架·ai+ipaas
cindershade2 小时前
别只收三个数字:前端 RUM 如何建立可解释的体验数据链
前端
前端 贾公子2 小时前
第09章:上下文与记忆 (4)
java·服务器·前端
Coodor3 小时前
使用web也可以写NFC微信小程序拉取
前端·微信小程序·小程序·nfc拉起小程序