kastor0.1.0
Get started

GUIDE 35Electron SQLite

kastor with Electron and SQLite

A desktop app keeps its data on the user's machine, and SQLite is the natural store. In Electron the database belongs to the main process; the renderer asks for data over IPC. kastor gives the main process typed queries, and applies the migrations you ship with each release when the app starts.

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.

Project
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 app
Terminal
npm install kastor-orm better-sqlite3
npm install -D electron @electron/rebuild
npx electron-rebuild -f -w better-sqlite3
npx kastor-orm init --db sqlite

better-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.

src/main/db/index.ts
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.

src/main/ipc.ts
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))
  }
}
src/preload/index.ts
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 }
src/main/main.ts
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:

electron-builder.yml
appId: com.example.notes
asarUnpack:
  - node_modules/better-sqlite3/**
extraResources:
  - from: kastor/migrations
    to: migrations

During 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/migrations ships as an extra resource and runs through deploy at 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 deploy with an upsert, 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.

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.