Every Electron app I built reached the same fork: "okay, now add a database." And every time, that one sentence quietly turned into three separate jobs.
- A typed schema and query layer, so I wasn't writing stringly-typed SQL and casting rows by hand.
-
IPC plumbing to get the database — which lives in the main process — into the renderer.
ipcMain.handlefor every operation, a preload bridge, serialization on both ends. - Reactivity, some event bus so the UI re-rendered when the data underneath it changed.
Each piece is reasonable on its own. Together they're a pile of boilerplate that every app reinvents slightly differently, and that I got tired of writing. NookDB is my attempt to make those three things one thing.
It's MIT, on npm, and the docs live at https://nookdb.pages.dev.
Schema first, types follow
You define your data once with a small DSL, and your types, validation, and indexes all derive from that single source:
import { open, s } from 'nookdb';
const schema = {
users: s
.collection({
id: s.id(),
email: s.string().email(),
role: s.enum(['admin', 'user'] as const),
})
.uniqueIndex('email')
.index('role'),
};
const db = await open('./app.db', { schema });
await db.users.insert({ id: 'u1', email: 'ali@example.com', role: 'admin' });
const admins = await db.users.find({ role: 'admin' });
// ^? { id: string; email: string; role: 'admin' | 'user' }[]
db.users is fully typed from the schema — find / findOne / count / insert / delete all infer their arguments and return shapes from the s.* chain. You can add sort / limit / offset options, and they flow all the way through. The Rust core is the authoritative validator, so a bad write is rejected at the engine, not just in TypeScript.
Reactive by default
Every query has a .live() variant. It emits a fresh snapshot on every committed write that touches a matching document, and it coalesces rapid commits so subscribers only see the final state:
for await (const admins of db.users.live({ role: 'admin' })) {
render(admins);
}
For React there's a useLive hook:
import { useLive } from '@nookdb/react';
function AdminList({ db }) {
const admins = useLive(() => db.users.live({ role: 'admin' }), [db]);
return <ul>{admins.map((u) => <li key={u.id}>{u.email}</li>)}</ul>;
}
No manual event bus, no cache-invalidation bookkeeping. The database tells the UI when something changed.
The Electron part: no IPC code
This is the piece I cared about most. In Electron, the renderer uses the same typed API as the main process. You wire up a host in main and hand each renderer a MessagePort:
// main.ts
import { openHost } from '@nookdb/electron/main';
const host = await openHost('./app.db', { schema });
const { port1, port2 } = new MessageChannelMain();
host.connectPort(port1, { /* frame info */ });
win.webContents.postMessage('nook:port', null, [port2]);
// renderer.ts
import { connectNook } from '@nookdb/electron/renderer';
const db = await connectNook({ schema });
const admins = await db.users.find({ role: 'admin' }); // no ipc handler for this
for await (const list of db.users.live({ role: 'admin' })) // live, also no ipc
render(list);
There's a typed proxy over MessagePortMain doing the work, plus a schema-hash handshake that rejects a renderer whose schema doesn't match the host — so the two processes can't silently disagree about the shape of your data. A pluggable Authorizer lets you gate operations per sender if you need to; the default is permissive.
Durability: a Rust core
Storage is redb, a pure-Rust embedded key-value store, wrapped in ACID transactions with fsync where it matters and kill-9 crash safety. It's exposed to Node through a NAPI-rs v3 binding, with prebuilt native binaries for linux x64/arm64 (gnu+musl), macOS x64/arm64, and windows x64-msvc — so your users never need a compiler or node-gyp.
The honest part: this is not a speed play
If you benchmark NookDB against better-sqlite3 on raw query throughput, SQLite wins on most read paths — find-by-index, count, and read-modify-write transactions are meaningfully faster on better-sqlite3. (The benchmark harness is in the repo under benchmarks/; run it yourself.) NookDB is faster on single inserts, but that's not the reason to use it.
The reason to use it is the developer experience: schema-first types, built-in live queries, and zero-IPC multi-process — without hand-wiring all of that around a raw SQLite binding. If your bottleneck is heavy local analytical queries over large datasets, better-sqlite3 is still the right tool, and I'll happily tell you so.
Try it
pnpm add nookdb
pnpm add @nookdb/react # useLive hook
pnpm add @nookdb/electron # main / preload / renderer bridge
pnpm add -D @nookdb/cli # backup | restore | migrate | inspect
- GitHub: https://github.com/nookwright/nookdb
- Docs: https://nookdb.pages.dev
- Examples:
electron-todo(CRUD + live) andelectron-notes(two windows, live updates propagating between them)
Node 20+, Electron 28+, MIT. If you try it, I'd genuinely like to hear where the schema DSL or the multi-process model feels wrong — that's the feedback that's useful this early.