defuss-dbsonl

defuss-dbsonl

v0.4.0MIT licensed, ESM and CommonJS, TypeScript declarations

Transactional key-value storage for DSON

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

The demos run the packaged library in your tab. Three use memory storage; one keeps a counter in your browser's origin private file system (OPFS).

import { openDbsonl } from 'defuss-dbsonl';
import { createFileBackend } from 'defuss-dbsonl/server';
const db = await openDbsonl<number>(
  await createFileBackend('./data'), 'accounts', { create: true });
const before = await db.beginRead();
await db.transaction(async tx => {
  const alice = (await tx.get('alice')).value!;
  const bob = (await tx.get('bob')).value!;
  tx.put('alice', alice - 20);
  tx.put('bob', bob + 20); // both or neither
});
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.

    FieldRead backResult
    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.

    1. Lockone writer at a time
    2. Appendvalues and index pages
    3. Flushbytes reach the device
    4. Publishthe 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
    RuntimeStoragePoint readSnapshot readScanBatchCompactionReopen
    Node.jsnative async31.1412.985.5518.6824.650.52
    Node.jsnative sync13.855.173.7617.6023.000.42
    Node.jsmemory11.214.814.349.1810.980.03
    Bunnative async41.1017.359.3712.8419.370.31
    Bunnative sync28.458.246.8612.5416.810.32
    Bunmemory23.507.566.7610.0313.260.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

    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.

    import { openDbsonl } from 'defuss-dbsonl';
    import { createFileBackend } from 'defuss-dbsonl/server';
    const db = await openDbsonl(await createFileBackend('./data'), 'orders', { create: true });
    await db.batch([
      { operation: 'put', key: 'order:1', value: { placed: new Date(), total: 4200n } },
      { operation: 'delete', key: 'cart:1' },
    ]);
    await db.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.

    MIT license.