Project structure
An Electron app has a main process (Node.js, full system access), preload scripts (a bridge) and renderer processes (web pages). SQLite and kastor live in the main process only.
src/main/
db/schema.ts tables and relations
db/index.ts opens the file, migrates, creates the client
ipc.ts ipcMain.handle channels
main.ts app lifecycle and windows
src/preload/index.ts contextBridge API for the renderer
src/renderer/ the UI, calls window.api
kastor.config.ts for migrate dev during development
kastor/migrations/ shipped with the appnpm install kastor-orm better-sqlite3
npm install -D electron @electron/rebuild
npx electron-rebuild -f -w better-sqlite3
npx kastor-orm init --db sqlitebetter-sqlite3 is a native module. The rebuild step compiles it for Electron's Node version; Electron Forge and electron-builder can run it for you during packaging. Recent Electron versions also ship node:sqlite, which the kastor sqlite adapter accepts and which needs no rebuild; check that your Electron version includes it before relying on it.
Open, migrate, then create the client
The database file goes in the user data folder, which belongs to the user and survives updates. At startup the main process opens it, applies any migrations shipped with this version, and only then creates the client and the windows. deploy from kastor-orm/migrate is the same engine as kastor migrate deploy: it records applied migrations in the database and skips them next time.
import { join } from 'node:path'
import Database from 'better-sqlite3'
import { app } from 'electron'
import { kastor } from 'kastor-orm'
import { sqlite } from 'kastor-orm/sqlite'
import { deploy } from 'kastor-orm/migrate'
import { schema, relations } from './schema'
const create = (file: string) => {
const handle = new Database(file)
handle.pragma('journal_mode = WAL')
const adapter = sqlite(handle)
return { adapter, db: kastor({ adapter, schema, relations }) }
}
export type Db = ReturnType<typeof create>['db']
let current: Db | undefined
export async function openDatabase(): Promise<Db> {
const { adapter, db } = create(join(app.getPath('userData'), 'app.db'))
// In a packaged app the migrations ship in resources/, next to app.asar.
const dir = app.isPackaged ? join(process.resourcesPath, 'migrations') : join(app.getAppPath(), 'kastor', 'migrations')
const { applied } = await deploy(adapter, dir)
if (applied.length) console.log(`applied ${applied.length} migration(s)`)
current = db
return db
}
export const getDb = (): Db => {
if (!current) throw new Error('openDatabase() has not run yet')
return current
}Back up the file before migrating if the data is precious: copying app.db to app.db.bak before deploy gives users a way back if a migration fails halfway.
Typed IPC through a preload bridge
The renderer should not see the database, only a small API. Register handlers in the main process, then expose matching functions with contextBridge. Sharing the handler types keeps both sides in sync.
import { ipcMain } from 'electron'
import { getDb } from './db'
export const handlers = {
'notes:list': (search: string) =>
getDb().posts.findMany({
where: search ? { title: { contains: search, mode: 'insensitive' } } : {},
include: { tags: true },
orderBy: { id: 'desc' },
take: 100,
}),
'notes:create': (title: string, body: string) =>
getDb().posts.create({ data: { title, body, author: { connectOrCreate: { where: { email: 'me@local' }, create: { email: 'me@local' } } } } }),
'notes:delete': (id: number) => getDb().posts.delete({ where: { id } }),
}
export type Handlers = typeof handlers
export function registerIpc() {
for (const [channel, fn] of Object.entries(handlers)) {
ipcMain.handle(channel, (_event: unknown, ...args: unknown[]) => (fn as (...a: unknown[]) => unknown)(...args))
}
}import { contextBridge, ipcRenderer } from 'electron'
import type { Handlers } from '../main/ipc'
type Api = { [K in keyof Handlers]: (...args: Parameters<Handlers[K]>) => Promise<Awaited<ReturnType<Handlers[K]>>> }
const api: Api = {
'notes:list': (search) => ipcRenderer.invoke('notes:list', search),
'notes:create': (title, body) => ipcRenderer.invoke('notes:create', title, body),
'notes:delete': (id) => ipcRenderer.invoke('notes:delete', id),
}
contextBridge.exposeInMainWorld('api', api)
export type { Api }import { app, BrowserWindow } from 'electron'
import { join } from 'node:path'
import { openDatabase, getDb } from './db'
import { registerIpc } from './ipc'
app.whenReady().then(async () => {
await openDatabase()
registerIpc()
const win = new BrowserWindow({ webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: true } })
await win.loadFile(join(__dirname, '../renderer/index.html'))
})
app.on('will-quit', () => {
void getDb().$disconnect()
})IPC uses structured clone, which carries Date, bigint and Uint8Array values, so kastor results reach the renderer with their types intact.
Packaging and updates
Two things need care when you package. The native module must sit outside the asar archive so it can be loaded, and the migrations folder must ship as a plain folder the main process can read. With electron-builder:
appId: com.example.notes
asarUnpack:
- node_modules/better-sqlite3/**
extraResources:
- from: kastor/migrations
to: migrationsDuring development, change the schema and run npx kastor migrate dev --name what_changed against a development file configured in kastor.config.ts. Commit the generated SQL; the next release applies it on every user's machine at startup. Never edit a migration that already shipped: kastor checks checksums and reports modified files, and users who already applied the old version would never get the change.
Schema changes across app versions
Users skip versions. Someone may update from version 1.2 straight to 1.9, so the app must apply every migration in between, in order, on their machine. That is exactly what deploy does: it reads the shipped folder, compares it with the history stored in the user's database, and applies what is missing. Keep every migration that ever shipped in kastor/migrations, even when it looks obsolete.
Production checklist
Before the first deploy, check these points. Each one prevents a problem that otherwise shows up only under real traffic, usually as exhausted connections, a leaked secret or a schema that does not match the code that is running:
- The database file lives in
userData, never inside the app bundle. - better-sqlite3 is rebuilt for Electron and unpacked from asar.
kastor/migrationsships as an extra resource and runs throughdeployat startup.- The renderer only sees the preload API, with context isolation and the sandbox on.
- Shipped migration files are never edited; new changes get new files.
Pitfalls
- Destructive migrations. On a server you can inspect data before dropping a column; on thousands of desktops you cannot. Prefer additive changes, and copy data forward in the same migration when you rename.
- Several windows. All windows share the main process client through IPC. Do not open the file from a second process.
- Blocking the main process. better-sqlite3 is synchronous. Keep queries small and indexed, or move heavy work to a utility process with its own connection.
- Seeding first run data. Insert defaults in a migration or right after
deploywith anupsert, so reruns are harmless.
Questions people ask
Q1Should Electron renderers access SQLite directly?
No. Keep the database in the main process and expose narrow IPC channels through a preload script with contextBridge. The renderer stays sandboxed and never holds a file handle.
Q2How do I run migrations in a desktop app?
Ship the kastor/migrations folder with the app and call deploy(adapter, dir) from kastor-orm/migrate at startup. It applies only the migrations that the user's database has not seen, so updates upgrade the schema in place.
Q3Why does better-sqlite3 fail to load in Electron?
It is a native module compiled for a specific Node ABI. Rebuild it for your Electron version with @electron/rebuild, or let electron-builder or Electron Forge do it during packaging.
Q4Where should the database file live?
Under app.getPath('userData'), which is per user and survives app updates. Never inside the app bundle, which is read only and replaced on update.