DEV Community

Ömer Arslan
Ömer Arslan

Posted on

I was rewriting the same database plumbing in every Electron app, so I built NookDB

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.

  1. A typed schema and query layer, so I wasn't writing stringly-typed SQL and casting rows by hand.
  2. IPC plumbing to get the database — which lives in the main process — into the renderer. ipcMain.handle for every operation, a preload bridge, serialization on both ends.
  3. 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' }[]
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode
// 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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)