Local-first data with a memory.

A schema-first reactive database for Electron, built on a Rust core.

npm install nookdb @nookdb/electron
nook — shell
nookdb shell · in-browser simulation. Type help for commands.
nook> db.todos.where(t => !t.done).orderBy('title')
idtitledone
1buy milkfalse
3water plantsfalse
2 rows · 0 network requests
running an in-browser simulation — not the Rust core

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.

type once
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
read autocomplete · inferred return
const open = await db.todos.find()
//    ↑ open: Todo[]
open[0].titlestringdoneboolean
write typo caught at compile time
db.todos.insert({
  titel: "Ship it",
})
 'titel' does not exist in Todo
update patch shape checked
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.

app.ts
const todos = db.todos.live({ done: false })

const unsub = todos.subscribe(render)
// later: unsub()
live re-renders 0
mutation · elsewhere db.todos.insert(…)
    Network requests: 0. How it works: a Rust commit notifier wakes matching subscriptions; the query re-runs and emits a fresh snapshot. No polling, no JS-side diffing.

    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.

    Before 3 files · ~40 lines · manual refetch + broadcast
    main
    ipcMain.handle('todos:get', () => db.todos.all())
    ipcMain.handle('todos:add', (_, t) => db.todos.insert(t))
    preload
    contextBridge.exposeInMainWorld('api', {
      todosGet: () => ipcRenderer.invoke('todos:get'),
      todosAdd: (t) => ipcRenderer.invoke('todos:add', t),
    })
    renderer
    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
    }
    With NookDB 1 file · 1 line · auto-synced across windows
    main
    // your schema lives here
    // nothing to wire
    preload
    // bridge — automatic
    renderer · react
    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 →

    100%

    Type-safe (no as casts)

    0

    IPC boilerplate

    4

    Lines to live query

    ~7ms

    Single strict-fsync write

    What NookDB does.

    const schema = s.collection(
      id:    s.id(),
      title: s.string(),
      done:  s.boolean(),
    );
    Inferred type TypeScript
    type Todo = 
      id:    string;
      title: string;
      done:  boolean;
    
    No codegen. No as casts. No schema drift.
    const todos = db.todos.live( done: false );
    
    todos.subscribe(render);
    Live results
    title: 'buy milk' , done: false
    title: 'walk the dog' , done: false
    title: 'ship nookdb' , done: false
    // Renderer — any window
    const todos = useLive(() =>
      db.todos.live()
    );
    
    // Changes from Renderer #1
    // appear in Renderer #2
    // automatically.
    Renderer #1
    buy milk
    walk the dog
    ship nookdb
    Renderer #2
    buy milk
    walk the dog
    ship nookdb
    await db.transaction(async (tx) => 
      await tx.todos.insert(
        title: 'critical'
      );
      await tx.todos.update(/* ... */);
    );
    // fsync committed before resolve
    txn
    write log
    fsync
    durable
    kill -9
    Data intact.

    Tested with kill‑9 mid-transaction.
    Zero data loss across proptest + crash-injection suites.

    built for ai-assisted development

    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.

    paste into your assistant

    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:

    llms.txtllms-full.txt

    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.txt and llms-full.txt expose 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.

    Read the full architecture →

    Durable by design. Yours by default.

    crash safety

    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.

    data ownership

    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.

    schema evolution

    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 →
    version ledger
    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.

    single insert · writes/sec ↑ higher is better
    NookDB 139 writes/sec
    better-sqlite3 47 writes/sec
    indexed lookup · ms/op ↓ lower is better
    NookDB 2,791 ms
    better-sqlite3 209 ms

    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.

    Compare deep-dive: vs better-sqlite3 →

    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

    app.ts
    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)
    A complete app — schema, write, live query — in one file.
    npm install nookdb @nookdb/electron

    Or clone a working repo: examples/electron-todo →