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.nodeelectron-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;
}