defuss-dbsonl runs on Node.js, Bun and in dedicated browser workers. Batches and transactions publish all of their changes or none, snapshots keep reading the state they opened on, and several processes or workers can share one database.
DSON is typed JSON that keeps Map and SetDate and RegExpbiginttyped arraysundefined and NaNcyclic references
await before.get('alice'); // still the old balance
await before.close();
The snapshot before keeps its root while the transaction commits.
What you get
Values keep their types
Values are stored as DSON, typed JSON from defuss-dson: Map, Set, Date, RegExp, bigint, typed arrays, undefined, NaN and cyclic references come back as they went in. JSON turns a Map into {} and a date into a string, and throws on a bigint.
Readers never block writers
A snapshot pins one immutable root of the tree. Commits keep publishing while it reads, and it sees none of them until you open a new one.
All changes or none
A batch or a transaction becomes visible as one revision. A transaction fails with CONFLICT when any other commit published after its snapshot, which rules out write skew.
Opens fast, however much it holds
A log-based store replays its log on open, so startup slows as data grows. defuss-dbsonl reads a small header that points at the current tree, not the stored data; the benchmark reopened a database in 0.52 ms on Node.js. Memory is capped by the page cache budget, not by the number of keys.
Shared by processes and tabs
Servers coordinate through kernel file locks, browser workers through Web Locks. A process that dies releases its locks, and no lock expires on a timer, so a paused writer never loses ownership.
Corruption fails closed
Every frame carries a CRC32C checksum, and verify() walks every reachable value and index page. Nothing repairs data by guessing, because a guessed repair can turn damage into silent data loss.
Where it fits
The storage and API layer for defuss-dataview
defuss-dbsonl is designed as the backing store and API of defuss-dataview. It turns the byte I/O of defuss-storage into a transactional key-value store and encodes every value with defuss-dson.
defuss-dataviewFilters, sorting, paging and tree traversal for table and tree grids
defuss-dbsonl this packageTransactions, snapshots, the B+tree index, locks and compaction
defuss-dsonTyped JSON in frames with CRC32C checksums
defuss-storageBounded byte I/O, one API for every medium
Native file system
OPFS and directory handles
Memory
Live demo
Four guarantees you can test here
Each card runs defuss-dbsonl in this tab, built from the same package entries you install. The demos send no network requests.
A snapshot keeps its root
Isolation without read locks
The left side reads the latest commit. The right side reads a snapshot, opened when the page loaded or when you last opened a new one. Each transfer is a transaction that moves 20 from alice to bob in one commit.
Latest, revision 0
alice
0
bob
0
Snapshot, revision 0
alice
0
bob
0
A stale transaction fails
Optimistic concurrency, no silent retry
Transaction A reads visits and waits. Writer B commits in between. When A publishes, its snapshot is stale, so it fails with CONFLICT. The library does not retry, because a retry would run your callback and its side effects again.
0visits
Values come back as they went in
DSON instead of JSON
The button stores one object with a date, bytes, a bigint, an undefined property and a reference to itself, then reads it back and checks each field.
Field
Read back
Result
Not stored yet.
It survives a reload
OPFS in a dedicated worker
This counter lives in a database in your browser's origin private file system. Reload the page and it keeps its value. Open the page in a second tab: both tabs' workers write the same database, and Web Locks order their commits.
0revision 0
Starting the worker.
Building blocks
Six parts, one store
Copy-on-write B+tree
Fanout 32. A commit rewrites the path from leaf to root and never changes a written page.
Immutable roots
Every revision is a root. A snapshot pins one and reads it while new roots are published.
Two head slots
Two checksummed 4 KiB slots name the current root. Writers alternate between them; the newest valid slot wins.
DSON frames
Values and index pages are DSON frames with a length, a header CRC32C and a payload CRC32C.
Bounded page cache
Decoded index pages stay in an LRU cache limited by bytes and page count. Values are always read fresh.
Lock order
Commit lock, then generation pin, then I/O lock. Kernel locks on servers, Web Locks in browsers, no timeouts.
How it works
One commit, crash-safe at every step
A commit writes next to the old data, never over it, and switches with one small write at the end.
1Lockone writer at a time
2Appendvalues and index pages
3Flushbytes reach the device
4Publishthe older of two head slots
Crash before step 4 The previous head still points at a complete tree.Design choices
What each choice costs
Each card names the design in use, the obvious alternative, why the alternative lost and what the choice costs.
Finding keys
Persistent B+tree with fanout 32 and a bounded page cache
Instead of an in-memory key map rebuilt from a log
Why
Memory does not grow with the number of keys, and opening needs no replay.
Cost
A point read whose page is not cached reads from disk.
Readers and writers
Immutable roots, pinned per snapshot
Instead of read locks that block commits
Why
A slow reader cannot stall a writer.
Cost
Old generations stay on disk until their readers close.
Atomic publication
Two checksummed head slots, written alternately
Instead of writing a new file, then renaming it
Why
OPFS and Storage offer no uniform atomic rename.
Cost
Every commit writes one 4 KiB slot.
Several writers
OS whole-file locks or Web Locks, held per commit
Instead of one process that owns the database
Why
Independent processes and workers share one database.
Cost
Commits serialize, so revisions keep a single order.
Transactions
One revision check for the whole database
Instead of per-key conflict detection
Why
It rules out write skew and range phantoms without tracking read sets.
Cost
Writers on disjoint keys can still conflict.
Space reuse
Explicit compact() and collect()
Instead of in-place updates or a background vacuum
Why
No hidden I/O: the application decides when to pay.
Cost
Compaction needs disk space for one live copy.
Stale locks
Kernel locks, released when a process dies
Instead of lock files that expire after a timeout
Why
A paused process never loses ownership to a second writer.
Cost
Servers need the native lock addon.
Benchmarks
What an operation costs
defuss-dbsonl 0.4.0 with native asynchronous I/O on an Apple M5 Max under macOS, Node.js 24.22.1 and Bun 1.4.3. Each figure is the median of three runs; every run takes the median of seven rounds over 1000 records after two warmups and checks every returned value.
Point read
get(), head checked on every call
Node.js
31.1 µs
Bun
41.1 µs
Snapshot read
get() on an open snapshot
Node.js
13.0 µs
Bun
17.4 µs
Scan
per row, in key order
Node.js
5.6 µs
Bun
9.4 µs
Atomic batch
1000 writes in one flushed commit
Node.js
18.7 ms
Bun
12.8 ms
Compaction
1000 live records copied
Node.js
24.7 ms
Bun
19.4 ms
Reopen
indexed open of an existing database
Node.js
0.52 ms
Bun
0.31 ms
All modes, in milliseconds per 1000 operations
Runtime
Storage
Point read
Snapshot read
Scan
Batch
Compaction
Reopen
Node.js
native async
31.14
12.98
5.55
18.68
24.65
0.52
Node.js
native sync
13.85
5.17
3.76
17.60
23.00
0.42
Node.js
memory
11.21
4.81
4.34
9.18
10.98
0.03
Bun
native async
41.10
17.35
9.37
12.84
19.37
0.31
Bun
native sync
28.45
8.24
6.86
12.54
16.81
0.32
Bun
memory
23.50
7.56
6.76
10.03
13.26
0.03
Synchronous I/O is opt-in and blocks its thread, including during flushes; use it in a worker or CLI. Batch, compaction and reopen are per workload, not per operation.
Three runs with a warm file cache on a busy machine (load average 4.6 to 8.3). They do not measure cold-disk latency or power-loss behavior. Raw samples are in validation/bench-macos-arm64.json, and make bench repeats the run on your machine.
Tests
Tested on real files, locks and workers
The tests run against real memory and native storage, real kernel locks and real browser workers, with no mocks: the defects that matter here, such as partial reads, torn slots and lock lifetimes, live inside the subsystems a mock would replace.
24
tests pass; 1 Linux-only test skipped on macOS
90%
statements covered, 85% required
85%
branches covered, 75% required
97%
functions covered, 85% required
98%
lines covered, 85% required
Tree against a Map
Every tree result is compared with an independent Map, alongside atomic batches, snapshots, conflicts, phantoms and quota failures.
mvcc.test.ts
Crashes and corruption
Torn head slots, truncated and damaged data, publication boundaries and an interrupted create, written as real byte images.
v2-recovery.test.ts
Real kernel locks
Independent native backends contend for real OS locks and hit real permission failures.
native-v2.test.ts
Native I/O edges
Descriptor limits, stale roots after another client compacts, partial reads and writes, large binary values.
native-performance.test.ts
Web Locks
Shared pins, try-locks, cancelled waits and pinned generations through the browser backend.
web-locks.test.ts
The packed package
Node.js, Bun, ESM, CommonJS and types from the tarball; v0.2.0 and current writers killed mid-batch; Chromium workers; every demo on this page.
make e2e
Contract
Guarantees
All or nothing
A batch or transaction becomes visible at once, as one revision, or not at all.
Serializable transactions
A transaction commits only if no other commit published after its snapshot; otherwise it fails with CONFLICT instead of running your callback again.
Stable snapshots
A snapshot reads the same root until you close it, while writers keep committing.
Flushed before published
Values and index pages reach the device before a head slot points at them. On servers, directory metadata is synced too.
Crash-safe heads
Two checksummed slots survive an interrupted write of either one. A writer reuses the unpublished tail of a failed commit.
Fails closed
Corrupt published data fails its checksum or verify(); nothing is repaired by guessing.
Locks without timeouts
Kernel locks and Web Locks are released when their process or worker ends, never by a clock.
The platforms these guarantees were tested on, and the ones they were not, are listed in the README.
Get started
Setup
The package needs Node.js 20.19 or newer, Bun, or a dedicated worker in a browser with OPFS and Web Locks.
bun add defuss-dbsonl
The /server entry adds kernel file locks and directory sync. Every process that opens the same directory joins the same lock protocol.
OPFS grants synchronous access handles only to dedicated workers, so the database runs in one. Every worker that reaches the same files must use the same namespace, because the namespace names their shared Web Locks.
const db = await openDbsonl(createMemoryBackend(), 'example', { create: true });
const snapshot = await db.beginRead();
for await (const row of snapshot.scan({ gte: 'a', lt: 'n' })) console.log(row.key);
await snapshot.close();
Read the protocol behind these guarantees
ARCH.md documents the lock order, the crash cases and reclamation. The test suite runs them against real files, kernel locks, killed processes and browser workers.