Electron + Drizzle + SQLite: Skip the Native Addon Hell, Use node:sqlite
Electron + Drizzle + SQLite Pitfall Notes
Project environment: Electron Forge + Vite + Drizzle ORM + SQLite
Final recommended driver:node:sqlite
1. Final Solution
The current project ended up being better suited to:
Electron Main Process
↓
Drizzle ORM
↓
node:sqlite
↓
SQLite file
Main reasons:
node:sqliteis a built-in Node/Electron module, requiring no extra native addon.- No need to handle
better_sqlite3.node. - No need for
electron-rebuild. - No ABI, ASAR unpack, native module copy, or other extra issues that come with
better-sqlite3. - For Electron + Forge + Vite, the only things that ultimately need handling are Vite
externaland the database/migration paths.
2. First Problem: node:sqlite Treated as a Browser Module by Vite
Initially used:
import { drizzle } from 'drizzle-orm/node-sqlite';
import { DatabaseSync } from 'node:sqlite';
Running:
npm run start
produced:
node_modules/drizzle-orm/node-sqlite/driver.js (7:9):
"DatabaseSync" is not exported by
"__vite-browser-external:node:sqlite"
Key error:
__vite-browser-external:node:sqlite
Cause
It's not that Drizzle doesn't support node:sqlite, nor that Electron doesn't support SQLite.
The real cause is:
src/main.ts
↓
drizzle-orm/node-sqlite
↓
node:sqlite
↓
Vite attempts to bundle
↓
treats node:sqlite as a browser external
↓
cannot find DatabaseSync
node:sqlite is a Node built-in module and should be loaded by Electron's Node runtime, not bundled by Vite.
Fix
vite.main.config.ts:
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
external: [
'node:sqlite',
],
},
},
});
The project's current actual config already adds:
'node:sqlite'
to external.
3. Midway Attempt at better-sqlite3
Having initially mistaken the node:sqlite driver as unsuitable for Electron, I tried switching to:
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
In the dev environment, a second category of problems soon appeared.
4. better-sqlite3: Vite Dynamic require of .node Fails
Error:
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.
Cause
better-sqlite3 is not a pure JavaScript package.
It internally depends on a native addon:
better_sqlite3.node
But Vite bundles the JS part of better-sqlite3 into:
.vite/build/main.js
After that, its internal dynamic require() looks for the .node file relative to the bundled location:
.vite/build/Release/better_sqlite3.node
The actual .node file is inside node_modules/better-sqlite3, so loading fails.
Fix at the Time
In:
vite.main.config.ts
add:
external: ['better-sqlite3']
That is:
Don't let Vite bundle
better-sqlite3; let Electron/Node load it itself at runtime.
After this:
electron-forge start
ran normally.
5. better-sqlite3: Works in Dev, Fails After electron-forge make
After externalizing better-sqlite3:
electron-forge start
✅ normal
But:
electron-forge make
❌ packaged App fails to run
The error becomes:
Cannot find module 'better-sqlite3'
Cause
In the dev environment:
project directory
└── node_modules
└── better-sqlite3
so after externalizing, Electron can find it from the project's node_modules.
But in the final packaged build:
Contents/Resources/app.asar
the main.js inside still retains:
require('better-sqlite3')
while the final application does not fully include a loadable better-sqlite3 package, hence:
Cannot find module 'better-sqlite3'
This shows:
Vite external
solves the "don't bundle the native package" problem,
but introduces:
How does Forge ultimately place the external dependency into the App?
6. Attempting @electron-forge/plugin-auto-unpack-natives
Install:
npm install -D @electron-forge/plugin-auto-unpack-natives
Add to Forge config:
{
name: '@electron-forge/plugin-auto-unpack-natives',
config: {},
}
This config is indeed already present in the project's current patch.
Result
Still produces:
Cannot find module 'better-sqlite3'
Cause
This plugin solves:
native .node files that have already entered the App
↓
cannot be loaded directly from ASAR
↓
automatically placed into app.asar.unpacked
It is not meant to guarantee that an entire external npm package gets copied into the final App.
Therefore:
auto-unpack-natives
and:
Cannot find module 'better-sqlite3'
are not problems on the same level.
7. Why better-sqlite3 Was Ultimately Abandoned
better-sqlite3 is not unusable in Electron, but it brings extra engineering problems:
better-sqlite3
↓
native addon
↓
better_sqlite3.node
↓
Electron ABI
↓
electron-rebuild
↓
Vite external
↓
Forge dependency packaging
↓
ASAR unpack
Whereas node:sqlite is a builtin provided by Electron's internal Node:
node:sqlite
↓
Electron runtime
No need for:
better_sqlite3.nodeelectron-rebuild- native ABI handling
- native package copying
- ASAR unpack
Thus the current project ultimately re-chose:
Drizzle + node:sqlite
8. Database File Cannot Use Relative Paths
If you write:
new DatabaseSync('./sqlite.db');
or:
path.join(__dirname, 'sqlite.db');
it may look normal in the dev environment, but the path is unreliable after final packaging.
Reasons:
process.cwd()does not guarantee pointing to the project directory.__dirnameafter Vite bundling may be inside.vite/build.- After packaging, code is usually inside
app.asar. - The App installation directory should not be used to store user runtime data.
- App upgrades may replace installation directory contents.
9. Where the Production Database Should Live
Recommended:
app.getPath('userData')
For example:
const dbPath = path.join(
app.getPath('userData'),
'sqlite.db',
);
const sqlite = new DatabaseSync(dbPath);
const db = drizzle({ client: sqlite });
On macOS roughly:
~/Library/Application Support/<AppName>/sqlite.db
On Windows roughly:
C:\Users\<User>\AppData\Roaming\<AppName>\sqlite.db
On Linux roughly:
~/.config/<AppName>/sqlite.db
Note for the Current Project
In the uploaded project patch, the current code is still:
const dbPath = path.join(
app.getPath('documents'),
'sqlite.db',
);
meaning the database is placed in Documents.
If there is no requirement to "let users directly see the database file," it's more recommended to change to:
app.getPath('userData')
as it better matches the data directory semantics of a desktop application.
10. Drizzle Schema
Current schema:
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(),
});
The corresponding migration will create:
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
Current project config:
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './drizzle',
schema: './src/db/schema.ts',
dialect: 'sqlite',
dbCredentials: {
url: 'sqlite.db',
},
});
Two concepts need to be distinguished here:
drizzle.config.ts's dbCredentials.url
is mainly for:
drizzle-kit
CLI usage.
Which database file Electron actually opens at runtime depends on:
new DatabaseSync(dbPath)
So even if the production App places the database at:
app.getPath('userData')
it does not require drizzle.config.ts to be written with that runtime path.
12. Migration Generation Flow
The current package.json has added:
{
"scripts": {
"generate": "npx drizzle-kit generate",
"migrate": "npx drizzle-kit migrate"
}
}
After modifying:
src/db/schema.ts
execute:
npm run generate
Drizzle will generate migrations in:
drizzle/
The project's currently generated migration structure looks like:
drizzle/
└── 20260828041323_strange_felicia_hardy/
├── migration.sql
└── snapshot.json
On app startup, you should execute:
migrate(db, {
migrationsFolder,
});
So that:
First startup
↓
creates sqlite.db
↓
executes all unexecuted migrations
↓
creates database tables
Later when upgrading the app:
Old sqlite.db is kept
↓
start new version of App
↓
execute newly added migrations
↓
keep old data and upgrade table structure
13. Migration Files Must Be Packaged Together with the Electron App
Migrations are not user data.
Therefore do not put:
drizzle/
into:
userData
The correct structure should be:
App Resources
└── drizzle/
└── migrations...
userData
└── sqlite.db
The current forge.config.ts has already added:
packagerConfig: {
asar: true,
extraResource: [
'./drizzle',
],
},
Its effect is:
project drizzle/
↓
electron-forge make
↓
Contents/Resources/drizzle/
So the production migration path should read:
path.join(process.resourcesPath, 'drizzle')
14. Migration Path Error: ENOENT scandir '/drizzle'
Later, this appeared:
Uncaught (in promise) Error:
Error invoking remote method 'db':
Error: ENOENT: no such file or directory,
scandir '/drizzle'
This error is very important.
It proves that the path passed to:
migrate(db, {
migrationsFolder,
});
ultimately became:
/drizzle
that is, a /drizzle in the OS root directory, which obviously does not exist.
15. Why It Became /drizzle
The code in the current patch is:
const migrationsFolder = app.isPackaged
? path.join(process.resourcesPath, 'drizzle')
: path.resolve(__dirname, '../../drizzle');
The dev environment uses:
path.resolve(__dirname, '../../drizzle')
But the project uses:
Electron Forge + Vite
Vite bundles the Main Process into something like:
.vite/build/main.js
At this point:
__dirname
is no longer:
src/
but the bundle output directory.
Therefore using:
../../drizzle
to guess the project root is very fragile.
It can even end up computing:
/drizzle
thus producing:
ENOENT scandir '/drizzle'
16. Final Recommended Migration Path Writing
Dev environment:
app.getAppPath()
Production environment:
process.resourcesPath
Recommended:
const migrationsFolder = app.isPackaged
? path.join(process.resourcesPath, 'drizzle')
: path.join(app.getAppPath(), 'drizzle');
Logic:
Dev Environment
app.getAppPath()
≈ /Users/user/IdeaProjects/bosszp
yields:
/Users/user/IdeaProjects/bosszp/drizzle
Production Environment
process.resourcesPath
≈ bosszp.app/Contents/Resources
yields:
bosszp.app/Contents/Resources/drizzle
This exactly matches the directory copied by Forge's:
extraResource: ['./drizzle']
17. Recommended Final Database Initialization Code
It is recommended that database initialization not be scattered across IPC handlers, but instead be initialized once during App startup.
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;
}
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Very useful