Skip to content

Architecture ​

How the bunostrdb package is put together: the native build, the loader, threading, LMDB locking, and where diagnostics go. If you only use the library through BunNdb, this page explains what happens underneath — see Getting started for the happy path.

How it works ​

  1. The vendored C library from damus-io/nostrdb is built into libnostrdb.so. This library dynamically links only to libc.so.6; LMDB and secp256k1 are statically linked into it.
  2. src/bindings.ts uses Bun's dlopen() to load the shared object at runtime.
  3. The library path is resolved by checking native/libnostrdb.so (npm install) and the monorepo-root deps/nostrdb/libnostrdb.so (source build), using the first file that exists.
  4. A thin C shim (native/shim.c → libnostrdb_shim.so) wraps functions that return structs by value (which Bun FFI cannot handle). ndb_init() is called directly; the vendored implementation has been patched for staged cleanup: on any failure it closes the LMDB environment, joins/destroys threads it actually started, destroys mutexes it initialized, frees the struct, and sets *out to NULL. So an init failure no longer leaks native resources and BunNdb can throw cleanly. The shim also reports the nostrdb submodule commit it was built from (ndb_nostrdb_commit()); the loader throws at import time if it does not match the pinned commit, so a libnostrdb.so that was not rebuilt together with the shim is rejected before any native call.
  5. Struct sizes (ndb_config = 56B, ndb_filter = 104B, etc.) and selected field offsets are hard-coded to match the vendored header's Linux x86_64 layout, centralized in ABI_OFFSETS (src/ffi.ts). These MUST be verified via make -C packages/bunostrdb/native abi-probe after any submodule update, and the load-time ndb_abi_check()/ ndb_abi_check_offset() guards (plus the commit-identity check) must pass. The build also enforces a glibc floor (make -C packages/bunostrdb/native glibc-check, currently GLIBC_2.38).
  6. The vendored deps/nostrdb submodule (at the monorepo root) contains build patches (Makefile + src/config.h) that add -fPIC, shared library output, and byteswap support. These patches are required to produce libnostrdb.so and must be preserved through submodule updates.

For the pointer-category convention used across the FFI layer, see ABI pointer convention.

Threading and concurrency ​

A single BunNdb instance manages one LMDB database with multiple internal threads: an ingestion writer (processEvent() only queues JSON for parsing by background ingester threads — queries immediately after ingestion may not see new data yet), a single writer committing parsed notes, and a single monitor invoking the subscription callback on matches.

How the subscription callback works ​

The callback is created with threadsafe: true. Bun's threadsafe JSCallback uses WebKit's ScriptExecutionContext::postTaskTo() mechanism: the JS callback body is always scheduled on the JS event loop, never executed on the native pthread. This means:

  • Array.push() in the callback is safe (runs on the JS main thread).
  • The native monitor thread returns immediately (non-blocking to the caller).
  • User callbacks are invoked on the main thread via drainSubNotifications().
  • There is an inherent delay between when the native thread fires and when the JS body runs (next event loop tick).

The subNotificationQueue swap pattern in drainSubNotifications() is a re-entrancy guard: if a user callback triggers recursive pollForNotes, a fresh queue collects new notifications during that call.

Thread-safety rules ​

  1. The native monitor thread is not a JavaScript Worker. It is a raw pthread spawned by the C library. Bun's threadsafe: true handles the cross-thread scheduling automatically.
  2. BunNdb is NOT thread-safe across Bun Workers. The FFI pointers are process-local. Sharing an instance across Workers requires IPC and is not supported.
  3. Within a single Bun process, concurrent async tasks may call processEvent(), query(), subscribe(), etc. on the same BunNdb instance. The native library performs its own internal locking. This concurrent access path is not stress-tested by this wrapper — the wrapper makes no guarantee of correctness under heavy concurrency. Validate under your workload before relying on it in production.
  4. Transaction objects are NOT thread-safe. Each Transaction wraps a single LMDB read transaction, which is bound to one thread at a time. Do not share a Transaction across async contexts.
  5. destroy() must not race with other calls, and must not be called from within a subscription callback. Ensure all pending operations have completed before calling destroy(); perform teardown outside the callback to avoid re-entrancy and ordering surprises. The method unsubscribes all subscriptions and frees native resources. See Teardown ordering.

Native error behavior (stderr) ​

The upstream nostrdb C library writes diagnostic messages to stderr in certain failure scenarios. These messages are not captured by the TypeScript layer and will appear on the process's standard error. Examples include:

  • ndb_stat failed at ndb_begin_query — transaction open failure during statistics collection
  • ndb_stat: mdb_cursor_open failed — LMDB cursor error
  • ndb_end_query() failed — written by Transaction.close() to console.error

When building an MCP server or long-running process, capture and route stderr to a log file or monitoring system. Do not discard stderr — it contains the only diagnostic information for native-layer failures.

Database directory locking ​

LMDB allows one concurrent read-write transaction per environment (one writer at a time per process/thread coordination), not one permanently exclusive writer process. Two BunNdb instances pointing to the same directory still contend for the same lock — the second instance will block or fail to open.

  • One writer per database directory. Two BunNdb instances pointing to the same directory will contend for the same lock. The second instance will either block or fail to open.
  • Readers are safe. Multiple processes can open the same database directory for reading simultaneously. Each reader gets a consistent snapshot via MVCC.
  • Do not move or rename the data directory while the database is open. LMDB holds file descriptors on the data and lock files.
  • Crash recovery. If the process crashes, the lock file may be stale. LMDB handles this automatically on the next ndb_init() — the lock is reacquired transparently. Manual cleanup is not needed.

Released under the BSD-3.0 License.