const open = await db.todos.find() // ↑ open: Todo[] open[0].▏titlestringdoneboolean
Local-first data with a memory.
A schema-first reactive database for Electron, built on a Rust core.
npm install nookdb @nookdb/electron db.todos.where(t => !t.done).orderBy('title') | id | title | done |
|---|---|---|
| 1 | buy milk | false |
| 3 | water plants | false |
Pre-1.0: API stabilizing — pin exact versions in production. Post-1.0: strict SemVer.
Schema typed once. Types everywhere.
Define your data with s.*. NookDB infers TypeScript types automatically — no codegen step, no as casts, no schema drift.
const schema = { todos: s.collection({ id: s.id(), title: s.string(), done: s.boolean(), createdAt: s.date(), }), } type Todo = typeof schema.todos.$type // ↑ inferred once — never written by hand
db.todos.insert({ titel: "Ship it", }) ✗ 'titel' does not exist in Todo
db.todos.update( { id }, { done: "yes" }, ) ✗ done: boolean — string not assignable
Queries that update themselves.
Subscribe with .live(). When the underlying data changes — anywhere in your app — your callback fires. No polling. No websockets. No refetch.
const todos = db.todos.live({ done: false }) const unsub = todos.subscribe(render) // later: unsub()
db.todos.insert(…) Illustrative demo — your real db.todos.live() runs in your Electron process.
Stop writing IPC.
In Electron, data lives in main; UI lives in renderers. NookDB bridges them. Subscribe in any window — the bridge takes care of the rest.
ipcMain.handle('todos:get', () => db.todos.all()) ipcMain.handle('todos:add', (_, t) => db.todos.insert(t))
contextBridge.exposeInMainWorld('api', {
todosGet: () => ipcRenderer.invoke('todos:get'),
todosAdd: (t) => ipcRenderer.invoke('todos:add', t),
}) const [todos, setTodos] = useState([])
useEffect(() => { api.todosGet().then(setTodos) }, [])
const onAdd = async (t) => {
await api.todosAdd(t)
setTodos(await api.todosGet()) // manual refetch
// …and broadcast to every other window
} // your schema lives here // nothing to wire
// bridge — automatic
await db.todos.insert({ title }) const todos = useLive(() => db.todos.live() )
One typed channel does the IPC: query results are serialized across the process boundary, and a per-window Authorizer gates what each renderer is allowed to read. Same API in any framework — useLive is the React convenience from @nookdb/react; Vue, Svelte and vanilla consume the LiveQuery.subscribe() / AsyncIterator surface directly. How the bridge works →
Type-safe (no as casts)
IPC boilerplate
Lines to live query
Single strict-fsync write
What NookDB does.
const schema = s.collection( id: s.id(), title: s.string(), done: s.boolean(), );
type Todo = id: string; title: string; done: boolean;
as casts. No schema drift.const todos = db.todos.live( done: false ); todos.subscribe(render);
// Renderer — any window const todos = useLive(() => db.todos.live() ); // Changes from Renderer #1 // appear in Renderer #2 // automatically.
await db.transaction(async (tx) => await tx.todos.insert( title: 'critical' ); await tx.todos.update(/* ... */); ); // fsync committed before resolve
Tested with kill‑9 mid-transaction.
Zero data loss across proptest + crash-injection suites.
Your assistant already knows NookDB.
Schema-first means your AI works with types, not guesses. Point it at the docs and let it build the whole app.
Help me build an Electron desktop app with NookDB (npm: nookdb + @nookdb/electron). Read the full docs at https://nookdb.pages.dev/llms-full.txt, then:
MCP server planned — so Claude / Cursor can pull live schema context natively.
-
Types guide every completion
Your AI can't invent a field that isn't in your schema —
s.*infers the exact types it autocompletes against. -
Errors it can actually fix
Every error is typed and prefixed
[kind] message. Paste one in; your assistant knows what broke and why. -
The whole reference, in one paste
llms.txtandllms-full.txtexpose the complete docs as plain text — full context your assistant ingests in one shot.
How it works.
Rust core via redb 2.x.
tokio::task::spawn_blocking bridges sync storage to JS async.
Errors are reshaped at each boundary with a [kind] message prefix convention.
Durable by design. Yours by default.
Survives kill -9.
Every committed transaction is fsync-flushed on a Rust core (redb). CI crash-injects — SIGKILL mid-write — and asserts the data is intact on reopen. No torn writes, no corruption.
Your data is just a file.
No server, no proprietary lock-in. The database is a single file on the user’s disk — copy it, back it up, inspect it. Drop NookDB tomorrow and your data stays right where it is.
Not encrypted at rest yet — pair it with OS-level disk encryption (FileVault, BitLocker). At-rest encryption is on the roadmap.
Your schema will change. There’s a ledger for that.
NookDB records applied schema versions in a transactional, idempotent ledger kept inside the same file — so shipping a change is tracked, not guessed. The full declarative migration DSL (up / down / backfill) is on the roadmap.
Migrations guide →const status = await db.migrateStatus()
// → { currentVersion: 0, appliedCount: 0 }
await db.migrateRun([1, 2, 3]) // idempotent
await db.migrateListApplied()
// → [1, 2, 3] When NookDB. When not.
Built for Electron desktop. Honest about what it isn't.
When to use NookDB
- Notes, journals, IDEs, design tools, finance apps — anywhere the user owns the data.
- UIs that update themselves when main writes — no
ipcMain.handle, no manual broadcast. - Offline-first by default — zero network in the hot path, zero ms of sync latency.
- Type-safe end-to-end — one schema validated in Rust, inferred across main + renderer.
Need browser support, multi-device sync, or a shared server backend? NookDB isn't it.
Side-by-side comparisons: vs Dexie vs RxDB vs Jazz vs better-sqlite3
Honest numbers.
Two bars. One we win. One we don't.
Durable fsync’d writes are where NookDB shines — every commit hits disk before resolving. The cost: better-sqlite3 still leads raw reads by roughly 3–13× depending on op. We show one of each.
Methodology: pnpm --filter @nookdb/benchmarks run ·
Run on Windows x64, Node v25.6.1, 2026-05-20.
tinybench orchestrates each case; we report hz (ops/sec) and mean (ms/op). Canonical numbers come from the GitHub Actions Ubuntu CI matrix; the dev-machine numbers below are advisory.
Questions.
Pre-1.0, in public — answers are short and honest.
Is this free?
Yes — MIT licensed, fully open source. That includes the whole library: no paywalled core, no feature gate.
Is it production-ready?
Pre-1.0. The core (Rust + redb) is tested with crash-injection. The TypeScript surface is still settling. Pin exact versions if you ship.
Why redb, not SQLite?
Different tradeoffs. redb is pure Rust, lock-free reads, simpler crash semantics. SQLite is unbeatable for SQL workloads. NookDB picked redb for a reason — read the architecture.
Does it sync?
No. By design. If you need sync, use ElectricSQL or Jazz on top — they're built for that.
Who builds this?
One person, in public, under Nookwright. NookDB is the database I wanted while shipping my own Electron apps — I build it because I use it. The repo is open. The roadmap is honest.
How do I get help?
GitHub issues — I read every one. There is no formal SLA pre-1.0, but bugs with a reproduction get attention fast.
Does it phone home?
No. Zero telemetry, zero network requests from the library — NookDB is an embedded local database. The only network traffic is whatever your app does.
Get started
import { s, open } from 'nookdb'
const schema = {
todos: s.collection({
id: s.id(),
title: s.string(),
done: s.boolean(),
}),
}
const db = await open('app.nook', { schema })
await db.todos.insert({ title: 'ship it', done: false })
// re-renders itself whenever todos change — anywhere
db.todos.live({ done: false }).subscribe(render) npm install nookdb @nookdb/electron Or clone a working repo: examples/electron-todo →