npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

turndb

v0.1.6

Published

A content-addressed columnar store for AI traces: byte-exact, embedded, and readable by the CLI and the Rust crate over the same file.

Downloads

924

Readme

turndb

This package declares Node >=22 <27; its required rebuild/test matrix is Node 22, 24, and 26. See the support and compatibility policy for the distinction between the portable WASI profile, native source qualification, and published support.

A content-addressed columnar store for AI traces. Byte-exact, embedded, single-writer — a store is one .turndb file.

The engine is Rust compiled to wasm32-wasip1. No native addon, no prebuild matrix, no postinstall — one .wasm runs everywhere Node does.

npm install turndb
import { open } from 'turndb';

const store = await open('./traces.turndb');   // its parent directory is created if needed

store.putBody('alice/1700000000000/req-1#input', JSON.stringify(messages), {
  model: 'claude-opus-5',
  inputTokens: 1204,
});

// One logical inference, with independently addressable request and response bytes. The return
// value itself is the acknowledgement: it is produced only after the durability sync succeeds.
const ack = store.write([{
  kind: 'put',
  id: 'alice/1700000000000/req-1',
  contents: [
    { name: 'request', bytes: requestBytes },
    { name: 'response', bytes: responseBytes },
  ],
  // An array preserves order and duplicate names; an object cannot.
  attrs: [['kind', 'llm_exchange'], ['tag', 'first'], ['tag', 'second']],
}], { durable: true });
if (!ack.durable) throw new Error('the source copy is still required');

store.sync();    // the ACK point — durable from here
store.flush();   // seal into the columnar plane for other readers

store.get('alice/1700000000000/req-1#input');       // bytes, byte-exact
store.getText('alice/1700000000000/req-1#input');   // UTF-8 convenience; lossy on invalid bytes
store.scanIds({ prefix: 'alice/', limit: 50, reverse: true });

// Structured paging: the engine filters and projects, so a timeline page that selects no
// content opens no fold block. `next` is an opaque checked cursor.
const page = store.scan({
  prefix: 'alice/',
  direction: 'reverse',
  limit: 50,
  attrs: ['model', 'inputTokens'],
  contents: [{ name: 'body', mode: 'metadata' }],
  predicates: [{ kind: 'attr', name: 'model', op: 'eq', value: 'claude-opus-5' }],
});
page.rows[0].attrs;              // [['model', 'claude-opus-5'], ['inputTokens', 1204n]]
page.stats.io.foldBlocksTouched; // 0n on a metadata-only page

Why

Agent and chat APIs are stateless, so every request re-sends the whole conversation. A trace store therefore receives the same message text over and over. General stores pay for those bytes every time; archival dedup stores collapse them but stop being queryable.

turndb splits the two planes — a fold holding content addressed by BLAKE3, and parts holding ids, body programs and typed attribute columns. Because a part holds no content, compaction never touches content.

Three things to know

sync() is the ACK point. putBody is not durable on its own. flush() is a different thing again: it seals writes into the columnar plane so other readers see them. Your own handle sees its unflushed writes without either — a live view can read back what it just wrote for free. write(operations, { durable: true }) combines an atomic generic batch with that ACK point and returns { applied, durable: true }; a thrown error is never an acknowledgement.

Flush cadence is a compression dial. Blocks sealed short compress worse. Measured on real trace traffic, flushing every record gave 15×; every 50 gave 171×; every 512 gave 292×. Batch your writes.

Cross-process exclusion is yours to provide. The native engine takes an advisory flock, but this package is always the wasm32-wasip1 build, on every host including Linux and macOS, and WASI has no advisory locking. The lock file is created and gates nothing.

The host layer permits one live Store per process. That is not enough isolation: another process can still open the same file. The obligation is therefore at most one open writer per store directory across every process. The guest cannot enforce or detect a violation. In four measured overlapping-writer runs on Node 24, both writers received successful sync() acknowledgements and one writer's complete record set was silently discarded. The surviving store was internally consistent and every remaining record was readable, so a clean read or verification cannot prove that an acknowledged write survived. Other overlap patterns may fail differently.

Sequential opens, including different directories, reuse one WASI instance. This removes per-construction external-memory pressure while keeping the sandbox narrow: only the current store directory is mounted. A consumer that must hold multiple stores open concurrently needs separate processes.

Call close() explicitly. A dropped handle is reclaimed when JavaScript eventually collects it, so forgetting close() does not wedge the process forever, but collection has no timing guarantee and the next open() refuses while the old handle is still live.

Integrity and health

store.verify() verifies the committed snapshot: the retained-manifest chain, every immutable part section, every fold frame, and byte-exact reconstruction of every named content value. It returns exact record, content-value, content-byte, identity, part-section, fold, and retained- manifest counts. Staged writes are deliberately outside that scope; sync and flush them first when they must be included.

store.health() is the cheap operational view. It reports whether the handle is available plus its commit and staged-entry counts; it makes no integrity claim. A clean health result is therefore not a substitute for verify().

Missing records remain null. Corrupt persisted state throws TurndbError with code === 'CORRUPTION', whether the damage prevents the store from opening or is found while reading or verifying it. Callers can classify failures through error.code without parsing the diagnostic message; unsupported operations use UNSUPPORTED, and malformed calls use INVALID_ARGUMENT.

Write admission

open accepts maxRecordBytes, maxBatchBytes, maxBatchRecords, and maxIdentifierBytes as positive u32 numbers. Defaults are 64 MiB, 256 MiB, 4,096, and 4 KiB. The first two are not raw body limits: they count deterministic worst-case complete WAL frames, treating every folded piece as novel, so acceptance never depends on hidden dedup history. An atomic batch is completely charged and validated before any member mutates the fold or WAL. See write admission limits for the exact unit and native API.

Frame and persistent object admission

open also accepts maxStoredFrameBytes and maxDecodedFrameBytes, positive u32 values defaulting to 512 MiB. They are checked before a WAL, part, or fold frame allocates stored or decoded linear memory. maxDirectoryEntries, maxWalFrames, and maxFoldBlocks are positive u32 object-count ceilings defaulting to 100,000, 100,000, and 1,000,000. They bound enumeration-driven collection growth, physical frames in an unflushed WAL, and blocks plus block-id span in one fold generation. store.readLimits() reports all five effective values.

A strict frame profile seals fold blocks early so small records keep progressing; one indivisible oversized piece is refused before mutation, and an oversized part output is refused before publication. Batch WAL frames and future filesystem/block objects are likewise admitted before the associated mutation. See atomic frame read admission and persistent object-count admission.

When a write stalls, and by how much

This build is single-threaded, so two operations run on the calling thread. Both are predictable enough to budget for; this section gives the arithmetic.

Block seals. The fold gathers unique content until it reaches blockTarget (default 4 MiB), then seals the block: one zstd compression, executed inside whichever putBody crossed the boundary. Every other put is fast (hundreds of MB/s across the boundary); the crossing one pays the whole seal. Content the store has seen before — same bytes, same BLAKE3 — dedups and never accumulates toward a seal.

Your stall budget is a rate, not a total: one seal per blockTarget of unique content, so

seals per hour  =  unique bytes ingested per hour  /  blockTarget

and the cost of each seal is set by level. Measured on 4 MiB blocks of synthetic unique-content bodies through this package's wasm build, in a Node 22 container on one Linux x86-64 workstation (a single workstation, Intel Core Ultra 7 155H): ~80ms per seal at level 3 — this package's default — versus ~1.7s at level 19 (the engine's default, tuned for the native build where compression runs on a thread pool).

Level 3 also costs more disk; no figure is published because measurements through the package on one real trace corpus varied materially with workload ordering and sample configuration. The default is set by the stall, not the disk cost. If disk matters, measure your own workload: write a sample at both levels and compare the directories. A trace workload writing 1.8 GB/day of unique content seals ~430 times a day: ~34 seconds of total stall at level 3, ~12 minutes — in 1.7-second pauses — at level 19. Pass level: 19 only if that trade is one you have measured your event loop against.

Compaction. Merges never rewrite content — that is the format's load-bearing claim and the engine asserts it — but they rebuild the reference plane (piece dictionary and columns), whose size tracks content volume. autoCompact() is a total merge: linear in the store's on-disk bytes (~5s/GB at level 19 — four points on synthetic stores from 0.7 MB to 1.9 GB through this build, Node 22, a single workstation; the level 3 merge cost is unmeasured), only ever run when you call it, and the only merge that settles deletes. maybeCompact() is the bounded alternative: it merges the oldest few parts, so the stall is capped by the run you allow rather than the store you've accumulated. A long-lived single-threaded embedder should call maybeCompact() on its idle path and reserve autoCompact() for moments when a multi-second pause is acceptable.

Attributes keep order and duplicate keys

Byte-exact reconstruction depends on both, and a JS object can represent neither. Pass an object for convenience, or an array of pairs when it matters:

store.putBody(id, body, [['finishReason', 'end_turn'], ['finishReason', 'max_tokens']]);

Stored integers return as JavaScript bigint, including small values. Integer-valued number inputs are accepted only inside JavaScript's safe range; use bigint for the full signed-i64 range. Unsigned u64 and UTC nanosecond timestamps use { u: bigint } and { timestampNs: bigint } wrappers so a read-modify-write cycle retains their type. Uint8Array stores binary metadata and null stores explicit null; missing remains absence of the key. See the version-2 scalar contract. The JSON-only WASM boundary carries those values as decimal text internally, never through a float. Non-finite f64 values also use explicit text spellings rather than JSON null. Use { fBits: '7ff8000000000001' } when an exact f64 bit pattern matters; reads return that form for NaNs so their payload cannot be canonicalized at the JavaScript boundary.

Capability profile

await capabilities() (or store.capabilities()) reports the cross-binding contract-v1 profile. Its operations are stable Tier-1 names; bindingOperations separately lists every callable package convenience. The profile also carries the lifecycle-journal capacity and facts explicitly absent here. In particular, allocated filesystem blocks, a cancellation token, and atomic no-replace publication are absent on WASI; none is reported as zero or silently accepted. Because that last primitive is required by seal, the portable profile omits seal instead of publishing a weaker artifact.

await compiledCapabilities() is the separate answer to what mechanisms and format guarantees the guest contains. It describes the WASI guest, not the host OS: this package reports embedder-enforced writer exclusion and no threads even when Node itself runs on Linux. It deliberately carries no physical_erasure prediction. What one erase did is returned by store.eraseIds(ids) as measured, not_applicable, or not_reclaimed evidence for that operation.

The callable observability surface is pull-based and local to the open handle:

  • metrics() returns cumulative operation counters and durations.
  • lifecycleEvents({ after, limit }) reads the bounded journal non-destructively, including cursor gaps and typed error codes.
  • contentLiveness() classifies live, stranded-dead, and block-reclaimable content after flush.
  • spaceUsage() classifies logical file bytes by manifest reachability. Its allocated-byte fields are { state: 'absent' } on WASI, never a fabricated zero.

No SQL here, on purpose

The query engine would dominate the artifact, and the two things an application does — a point lookup and a page scan — are already served by the id order. Ids sort lexicographically by their UTF-8 bytes, so designing them with the query in mind (member/timestamp/...) gives prefix-then-time paging with no secondary index.

Compare ids as bytes, not with JS <. JS compares UTF-16 code units, and the two orders disagree above the BMP: 'a\u{10000}' sorts below 'a\uFFFF' in JS and above it in UTF-8. For ASCII ids they agree, so this only bites once an id carries an astral character — quietly, as a wrong page boundary rather than an error. Use prefixUpperBound to build a range, or compare Buffer.from(id, 'utf8').

For analytics, the turndb CLI runs SQL against the same file. No daemon, no second copy.

License

Apache-2.0. See LICENSE and NOTICE.