跪拜 Guibai
← Back to the summary

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:


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:

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:


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;
}
Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

天平

Very useful