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
- The vendored C library from
damus-io/nostrdbis built intolibnostrdb.so. This library dynamically links only tolibc.so.6; LMDB and secp256k1 are statically linked into it. src/bindings.tsuses Bun'sdlopen()to load the shared object at runtime.- The library path is resolved by checking
native/libnostrdb.so(npm install) and the monorepo-rootdeps/nostrdb/libnostrdb.so(source build), using the first file that exists. - 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*outtoNULL. So an init failure no longer leaks native resources andBunNdbcan 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 alibnostrdb.sothat was not rebuilt together with the shim is rejected before any native call. - 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 inABI_OFFSETS(src/ffi.ts). These MUST be verified viamake -C packages/bunostrdb/native abi-probeafter any submodule update, and the load-timendb_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, currentlyGLIBC_2.38). - The vendored
deps/nostrdbsubmodule (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 producelibnostrdb.soand 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
- The native monitor thread is not a JavaScript Worker. It is a raw pthread spawned by the C library. Bun's
threadsafe: truehandles the cross-thread scheduling automatically. BunNdbis NOT thread-safe across Bun Workers. The FFI pointers are process-local. Sharing an instance across Workers requires IPC and is not supported.- Within a single Bun process, concurrent async tasks may call
processEvent(),query(),subscribe(), etc. on the sameBunNdbinstance. 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. Transactionobjects are NOT thread-safe. EachTransactionwraps a single LMDB read transaction, which is bound to one thread at a time. Do not share aTransactionacross async contexts.destroy()must not race with other calls, and must not be called from within a subscription callback. Ensure all pending operations have completed before callingdestroy(); 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 collectionndb_stat: mdb_cursor_open failed— LMDB cursor errorndb_end_query() failed— written byTransaction.close()toconsole.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
BunNdbinstances 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.
