Skip to content

Resource management (using) ​

Every native handle in bunostrdb supports Explicit Resource Management: declare it with using (or await using) and it is destroyed automatically at scope exit — on return, on throw, on every path.

Supported classes ​

Classusing callsNotes
BunNdbdestroy()Also supports await using → destroyAsync() (see below).
Filterdestroy()
FilterBuilderdestroy()No-op once build() succeeds (ownership transfers).
Transactionclose()Never closeStrict() — scope exit can't mask your error.
NoteBuilderdestroy()No-op once built.

Disposal is idempotent everywhere, so mixing explicit destroy()/close() with using on the same object is safe. Explicit teardown remains fully supported — using is sugar, not a new lifecycle.

Ownership and lifetime ​

Each wrapper type owns or borrows native memory. Follow these rules to avoid use-after-free:

TypeOwns?LifetimeDestroy / notes
BunNdbYesUntil scope exit (using / await using)using disposes via destroy() (await using via destroyAsync() when a subscription callback is configured). A FinalizationRegistry safety net calls ndb_destroy() if the object is garbage-collected as a last-resort leak mitigation, but it cannot close transactions, unsubscribe, or clean up JS callbacks (see GC finalizer). Do not rely on GC for correctness.
FilterYesUntil scope exit (using)using disposes via destroy(). The native filter's heap allocations (elem_buf, data_buf) are freed.
FilterBuilderYesUntil .build() or scope exitbuild() transfers ownership to Filter. Scope exit disposes via destroy(), which abandons without building.
TransactionYes (read txn)Until scope exit (using calls close())Scope exit uses the non-throwing close() (logs errors); call closeStrict() yourself when close failures must throw. Safe to call in finally blocks. A failed ndb_end_query() consumes the LMDB handle, so the transaction is marked closed (not retried) and the error is kept in closeError/getErrors().
Note (Owned)Yes (JS buffer)As long as the Note is aliveGC handles the JS buffer. Native pointer is recomputed from the buffer on each FFI call. tags() retains the originating note, and Tags.count() validates owner lifetime.
Note (Unowned)NoOnly while the originating transaction/database is aliveDo NOT store beyond the transaction's lifetime. Callback-borrowed notes (custom predicates, fold visitors) expire when the native call returns — call .copy() inside the callback to retain.
Note (Callback-borrowed)NoOnly inside the predicate/visitor callExpires at native return; any later use throws. Never dispose filters, transactions, or the database from inside a predicate/visitor.
NoteBuilderYes (builder buffer)Until .build() or scope exitbuild() copies note data into an independent buffer. Scope exit disposes via destroy().
TagsNo (borrows Note)Only while the owning Note is aliveHold a reference to the Note.

Basic pattern ​

ts
// @ts-nocheck
import { BunNdb, FilterBuilder } from "bunostrdb";

{
	using ndb = new BunNdb("./data");
	using filter = new FilterBuilder().kinds([1]).limit(10).build();
	for (const note of ndb.query([filter], 10)) {
		console.log(note.id, note.content);
	}
} // filter.destroy(), then ndb.destroy() — in reverse order, even on throw

await using for subscription databases ​

BunNdb.destroy() is synchronous. When a subscription callback was configured, destroyAsync() additionally drains callback tasks already posted to the event loop before tearing down native state — so for those databases, prefer await using:

ts
// @ts-nocheck
import { BunNdb, FilterBuilder } from "bunostrdb";

{
	await using ndb = new BunNdb("./data", (subId) => console.log("note on", subId));
	const subId = ndb.subscribe([new FilterBuilder().kinds([1])]);
	// ... publish, poll, wait ...
} // destroyAsync() ran: callback drained, subscriptions closed, DB freed

Without a subscription callback, plain using is equivalent — there is nothing to drain.

Ownership transfer: builder → filter ​

build() moves the native allocation from builder to filter. This composes safely with using: after a successful build the builder is consumed and its disposal is a no-op.

ts
// @ts-nocheck
import { FilterBuilder } from "bunostrdb";

using builder = new FilterBuilder();
builder.kinds([1]).authors(["<64-hex-pubkey>"]);
using filter = builder.build(); // builder now consumed; both disposals safe

One rule: bind first, then configure. A chained call that throws leaves nothing to dispose, because the using binding was never assigned:

ts
// @ts-nocheck
import { FilterBuilder } from "bunostrdb";

// BAD: if .kinds() throws (invalid input), the builder leaks.
using bad = new FilterBuilder().kinds([-1]);

// GOOD: a throw in .kinds() still disposes the builder.
using builder = new FilterBuilder();
builder.kinds([-1]);

Transactions ​

ts
// @ts-nocheck
import { BunNdb, Transaction } from "bunostrdb";

using ndb = new BunNdb("./data");
{
	using txn = new Transaction(ndb);
	// ... reads bound to txn ...
} // txn.close()

Scope-exit close uses the non-throwing close() and records failures on the owning database (getErrors()), exactly as an explicit close() would. When close failures must throw, call closeStrict() yourself instead of using.

Collections still need a loop ​

using manages one lexically-scoped binding — not an array. For a dynamic set of filters, keep the explicit loop (the destroyFilters helper in src/cli.ts does exactly this):

ts
// @ts-nocheck
import { destroyFilters } from "./src/cli.js";
import { BunNdb, type Filter } from "bunostrdb";

using ndb = new BunNdb("./data");
const filters: Filter[] = []; // ... build N filters ...
try {
	console.log(ndb.query(filters, 32).length);
} finally {
	destroyFilters(filters);
}

Don't teardown the DB inside native callbacks ​

Disposal calls the same guards as destroy()/close(). Never dispose a handle from inside a fold/tryFold visitor or a custom filter predicate — it throws instead of corrupting native state. Database teardown (BunNdb.destroy()/destroyAsync(), using/await using on a BunNdb) is likewise rejected while subscription callbacks are being dispatched during pollForNotes()/waitForNotes() — it throws instead of leaving the enclosing poll with a null handle. Defer teardown until after polling returns. Unsubscribing, nested polling, and disposing Filter/FilterBuilder/Transaction handles from inside a subscription callback remain allowed; let the outer scope own DB disposal:

ts
// @ts-nocheck
import { BunNdb, FilterBuilder } from "bunostrdb";

using ndb = new BunNdb("./data");
using filter = new FilterBuilder().kinds([1]).build();
// GOOD: the visitor only reads; the scope disposes.
const count = ndb.fold([filter], 0, (acc) => acc + 1);

Escaping resources ​

using disposes at the end of the enclosing block. Don't use it for a handle that outlives the scope — a database stored on an object, a filter returned to a caller, a transaction kept across awaits outside the block. Those keep explicit destroy()/close() at the owner's teardown point.

Teardown ordering ​

Scope exit on a BunNdb (using → destroy(), await using → destroyAsync()) performs these steps in order:

  1. Sets teardownStarted so subscription callbacks already queued by the native monitor become no-ops. destroyed is not set yet.
  2. Closes all still-open read transactions.
  3. Unsubscribes all tracked subscriptions (best-effort).
  4. If any transaction is still open, defers native teardown — calling ndb_destroy() while a transaction is open is a use-after-free. The database stays open and usable; destroyed remains false and scope-exit disposal retries once the transaction is resolved. Only pre-native deferrals are retryable.
  5. Calls ndb_destroy() to stop native threads and free resources. If the native call throws, the database is left in a failed-teardown state (getPtr() rejects, scope-exit disposal becomes a no-op). The native memory is intentionally leaked (bounded, reclaimed by the OS at process exit). Observe the failure via getErrors().teardownErrors. Do NOT call ndb_destroy() again on this handle. Concurrent destroyAsync() callers share one in-flight promise.
  6. Only now sets destroyed = true and closes the JSCallback (after native destroy, to prevent invocation from a dead context).

Errors from each step are collected in teardownErrors rather than thrown individually. A failed unsubscribe during scope-exit disposal (destroy() / destroyAsync()) is a native resource leak risk: if ndb_unsubscribe() fails, the subscription remains active in the native library. These are edge cases (typically caused by database corruption) but should be treated as serious errors when they occur.

GC finalizer (last resort only). If a BunNdb is garbage-collected without scope-exit disposal via using / await using, the FinalizationRegistry calls ndb_destroy() only when no subscription callback was configured — otherwise it intentionally leaks the native struct rather than risk a use-after-free crash from the GC'd JSCallback. Always scope databases with using / await using; never rely on GC for native cleanup.

destroyAsync() — JS callback scheduling barrier for callback mode. When a subscription callback was configured, await using is the recommended teardown path: it runs destroyAsync(), which sets teardownStarted (making already-queued callback invocations no-ops) and then yields to the JS event loop via a microtask + macrotask sequence before calling ndb_destroy(). This is a JS scheduling barrier, not a native drain guarantee: it gives the Bun event loop a chance to drain threadsafe callback tasks that were already posted by the native monitor thread before the ndb_destroy() call, but it does not synchronize with or drain the native monitor thread itself. In practice this is sufficient because Bun's threadsafe JSCallback uses postTaskTo() to schedule on the JS event loop — once the JS scheduling barrier completes, any posted task has either run or been dropped by the teardownStarted guard. Prefer await using over using when a subscription callback was configured. See Threading and concurrency for the underlying callback mechanics.

Released under the BSD-3.0 License.