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

@mnemonica/dive

v0.8.5

Published

Object-bound context propagation and execution-flow tracing — no ALS, no async_hooks

Readme

@mnemonica/dive

Data + Flow for userland instances.

uncaughtException and unhandledRejection never know where they came from or which data caused them. Dive answers that: context is pinned to userland instances (Data), and every wrapped invocation appends an edge to a bounded trace (Flow). When the Data Flow fails, the error is pinned to its deepest trace edge — so the error carries both the data and the flow that happened to it.

No AsyncLocalStorage. No async_hooks.

Successor to context-dive (2018).


Before You Start

Dive is standalone: it imports nothing at all and works with any objects you choose as context. Used with mnemonica — the instance-inheritance library whose types carry their construction context (data flow) with them — it becomes automatic: every constructed instance is its own context. That wiring lives in the @mnemonica/nestjs adapter (attachHooks), not in dive itself.

The ecosystem:

  • mnemonica — the core: typed instance inheritance, lifecycle hooks, composite error stacks.
  • @mnemonica/nestjs — the NestJS adapter: attachHooks() (dive ↔ mnemonica lifecycle wiring), module system, pipes, interceptors, Thunderstruck boundary feeding.
  • typeomatica — runtime strict-type enforcement for instance fields (Proxy-based). Companion for construction-time data integrity.
  • @mnemonica/tactica — the compile-time side: generates the TypeScript registry so lookup() and define() are fully typed.

The Paradigm Shift

ALS:  context is bound to the async resource (timer, I/O, HTTP request)
Dive: context is bound to the INSTANCE — any object you choose

When data flows through a system, instances carry their own context. The queue doesn't need to know about the original request — it just processes instances, and each instance brings its context via wrap().

Execution flow = Data flow.

ALS Problem

const als = new AsyncLocalStorage();

als.run({ requestId: 'A' }, () => {
  setTimeout(() => {
    als.getStore(); // { requestId: 'A' } — works
  }, 100);
});

ALS works for simple cases. But:

// Request creates 100 instances, shuffles them, queues for later
const instances = create100Instances(req);
shuffle(instances);
queue.push(...instances); // ALS context dies with request

// 30 seconds later, queue consumer processes instance #73
// ALS: getStore() === undefined ❌
// Dive: the failure IS instance #73 — getErrorInstance(err) ✅

ALS stores one context per async resource. All timers, promises, and I/O in the same request share the same store. When the request ends, the store goes away. A queue consumer running 30 seconds later has no context.

Dive Solution

import { wrap, current } from '@mnemonica/dive';
import { getFlow, getErrorInstance } from '@mnemonica/dive';
// the mnemonica ↔ dive wiring lives in the adapter:
import { attachHooks } from '@mnemonica/nestjs';
import { defaultTypes } from 'mnemonica';

// records creation edges, auto-wraps instance methods
attachHooks(defaultTypes);

const instance = new MyType({ requestId: 'A', data: 42 });
// postCreation hook fires:
//   - a 'create' edge is appended to the trace
//   - instance methods are wrapped

// Any method call runs in the instance's context
// AND records a trace edge:
instance.process((result) => {
  current() === instance; // true ✅
});

// When processing FAILS, the error recovers everything:
try {
  instance.process();
} catch (err) {
  // → the instance (the data that caused it)
  getErrorInstance(err);
  // → [create:MyType, method:process] (the flow)
  getFlow(err);
}

Dive captures context at wrap-time and restores + records it at invocation-time. No async resource tracking needed. The instance is the context — and the trace is its story.


Installation

# standalone — zero dependencies of any kind
npm install @mnemonica/dive

# with mnemonica — the adapter carries the lifecycle wiring
npm install @mnemonica/dive @mnemonica/nestjs mnemonica

Dive has no dependency on mnemonica at all — not even a peer one. The two meet only inside @mnemonica/nestjs, which depends on both.


Quick Start

Standalone — any object can be the context:

import { wrap, current, getErrorInstance } from '@mnemonica/dive';

const job = { id: 'req-123' };

// capture the context now; it is restored at invocation time
const process = wrap(() => {
  current() === job; // true
}, job);

// even 30 seconds later, from a decoupled queue consumer:
setTimeout(process, 30_000);

With mnemonica, construction itself becomes the context switch — via the adapter's attachHooks:

import { attachHooks } from '@mnemonica/nestjs';
import { current } from '@mnemonica/dive';
import { defaultTypes } from 'mnemonica';

// one-line activation: creation edges + auto-wrapped methods
attachHooks(defaultTypes);

const RequestData = defaultTypes.define(
  'RequestData',
  function (this: { id: string }, data: { id: string }) {
    this.id = data.id;
  }
);

const instance = new RequestData({ id: 'req-123' });
current() === instance; // true

API

attachHooks(collection) — moved to @mnemonica/nestjs

The mnemonica lifecycle wiring is adapter-level code, not engine code. It now ships as @mnemonica/nestjs:

import { attachHooks } from '@mnemonica/nestjs';
attachHooks(collection); // preCreation + postCreation + creationError

Dive exports the primitives that wiring is built from, for custom integrations (other frameworks, non-Nest mnemonica apps, your own lifecycle events):

| Primitive | Called when | |---|---| | enterContext(instance) | a lifecycle event enters an instance's context | | wrapConstructorArg(fn, context) | a constructor receives a callback argument | | upgradeConstructorArg(arg, instance) | construction finished; unused callbacks now belong to the instance | | wrapInstanceMethods(instance) | an instance should run methods in its own context | | recordCreation(name, instance, parent?) | construction succeeded — 'create' edge under data-flow parentage | | recordCreationError(name, error, parent?) | construction failed — error pinned to the failed edge | | isWrappedFunction(fn) | guard against double-wrapping |

wrap(fn, context?) / wrap(fn, label?) / wrap(fn, context, label)

wrap<T extends (...args: unknown[]) => unknown>(
  fn: T,
  context?: object,
  label?: string
): T;

Capture context at wrap-time (explicit, or the current ambient context), restore it at invocation-time, and record the invocation as a trace edge. Which of the two happened is recorded on the edge as instanceSource ('explicit' | 'ambient'): the ambient fallback is newest-wins and best-effort, so consumers can distrust it under concurrency.

The optional label is a grouping tag for tooling (one label may group many names). Every wrap also captures its callsite (file:line:col, plain path) once at wrap time — the runtime half of the join into tactica's eds.json probe registry, which uses the same location format. Edges carry both as edge.label / edge.callsite, and the edge name follows the caption cascade:

  • label + named fn → label:name
  • label + anonymous fn → the callsite (the label survives on edge.label)
  • no label → fn.name, falling back to the callsite, then 'anonymous'

Re-rooting (wrap(w, differentContext)) preserves the ORIGINAL wrap's label/callsite: a bulb's identity is where it was first wrapped.

Handles new calls (via Reflect.construct), wraps returned functions, wraps function arguments (recursively, to any depth), wraps Promise-resolved functions, and pins rejections to the call's edge.

Re-wrapping an already wrapped function follows scope shadowing:

  • wrap(w) or wrap(w, sameContext) — idempotent, returned as-is.
  • wrap(w, differentContext) — the callback changes ownership: a 'recontext' handoff edge is recorded on the new context (parented on the old context's latest retained edge), and a fresh wrapper around the ORIGINAL function is returned — wrappers never stack. Existing references to the old wrapper keep telling the old story.
  • Function arguments crossing wrapped calls are auto-wrapped idempotently — they never shadow. Re-rooting is always your explicit act.
const w1 = wrap(job, requestInstance);
service.register(wrap(w1, serviceInstance));
// handoff recorded: getFlow(serviceInstance) walks back through the
// 'recontext' edge into requestInstance's branch

current()

current(): object | undefined;

The instance executing right now (the "newest-wins" switcher). Fine for single-flow code. For anything concurrent, use getFlow() — the trace holds the truth even when "current" is ambiguous.

getFlow(target?)

// branch of the current cursor (empty at rest)
getFlow(): FlowEdge[];
// flight recorder: the branch that produced the error
getFlow(error: Error): FlowEdge[];
// the branch of that instance's latest edge
getFlow(instance: object): FlowEdge[];

Reconstructs an execution branch from the trace, oldest edge first. Returns copies — mutating them does not corrupt the trace.

interface FlowEdge {
  id: number;
  parentId: number | null;
  // the data this edge happened to
  instance: object | undefined;
  // type / method / function name
  name: string;
  kind: 'create' | 'call' | 'construct' | 'method' | 'recontext';
  // start time (Date.now())
  ts: number;
  // ms, set when the invocation completes
  duration: number | undefined;
  status: 'running' | 'ok' | 'error';
  // grouping tag / wrap site, when given
  label?: string;
  callsite?: string;
  // how the instance was attributed: passed by the caller, or captured
  // from the newest-wins ambient (best-effort — distrust it under
  // concurrency); undefined when the edge carries no instance
  instanceSource?: 'explicit' | 'ambient';
}

getTrace()

getTrace(): FlowEdge[];

The whole retained trace — copies of every edge still in the ring buffer, oldest first. Unlike getFlow() it needs no target: this is the inspection surface for tooling (remote debugging, visualization) asking "what flowed through this process?" when no cursor is live.

Edges carry their instance reference — callers crossing a process boundary (CDP, WS, HTTP) must map to a JSON-safe shape themselves (the adapter's formatFlow shows the idiom).

getErrorInstance(error)

getErrorInstance(error: Error): object | undefined;

The data pinned to an error. The error is pinned once, at the deepest wrapped boundary it passed through — so this points at the failure site, not at some outer re-throw.

getRunningEdges()

getRunningEdges(): FlowEdge[];

The edges still running right now — the unfinished fibers. This is the suspect set for uncaughtException / unhandledRejection attribution, queryable in O(1) without scanning the ring. Copies, same semantics as getTrace(). Entries are born at edge creation and removed at settle (sync return, promise settle, error mark), so the store is bounded by true concurrency and leaks nothing when nobody consumes it. Under a bounded ring it also doubles as eviction-immune storage for unfinished fibers. See reports/running-edges-store-design.md.

setTraceLimit(limit)

// default: Number.MAX_SAFE_INTEGER (unbounded since 2026-09-02 — retention
// is meant to be GC-driven via weak instance refs; pass 1024 for the
// pre-flip behavior). Safe by default because weak instance refs are the
// default mode; opting OUT of weak refs (setWeakInstanceRefs(false)) with
// an unbounded ring pins every instance it records — don't do that in
// production.
setTraceLimit(limit: number): void;

Sets the ring-buffer size of the trace. 0 disables recording (context switching still works; getFlow() returns empty branches). Shrinking evicts the oldest edges immediately.

setWeakInstanceRefs(enable) / getCollectedInstanceCount()

// default: WeakRef (since 2026-09-02) — pass false to pin instances strongly
setWeakInstanceRefs(enable: boolean): void;
getCollectedInstanceCount(): number;

Weak mode stores edge.instance as a WeakRef behind a getter and registers each instance with a FinalizationRegistry: when GC collects an instance, its edges are marked instanceCollected: true and the counter advances — a finished fiber becomes observable. The edge skeleton (ids, name, kind, status) stays in the ring; only the payload is released. Measured on the chaos fixture (60k requests, unbounded ring): strong mode pinned ~6.7KB/request with zero release after load; weak mode released all 60000 payloads and ran ~70% faster. Trade-off: getFlow() / getErrorInstance() on an old trace may deref to undefined — snapshot instance data at settle/error time if you need postmortem payloads.

Payload survival is per-object, not per-fiber. A fiber that carries ONE context instance through all its edges (the common case — one DATA flowing through wraps) keeps every edge's payload alive while ANY single reference to that instance exists anywhere: a pending timer's closure, a suspended await frame, the unwinding crash stack. Edges pointing at different instances have independent fates. Skeletons are always held strongly by the ring — GC never touches them.

Crash-time delivery contract: at uncaughtException / unhandledRejection, the crashing fiber's payloads ARE alive (the crash path itself roots them) — but only until the handler returns. So extract and ship INSIDE the handler, synchronously; a consumer that polls later may find payloads collected. The ring is the live view; your crash handler's export (Jaeger, mnemographica) is the postmortem store.

clear()

clear(): void;

Reset everything: trace, cursor, depth, context, trace limit, and the registered lifecycle hooks. Useful for testing — adapter-level subscribers must re-register after a clear().

registerHook(event, hook)

const unregister = registerHook('enter', (payload) => { /* ... */ });
unregister();
// or, by reference, when the closure was not kept:
unregisterHook('enter', myHook);

Dive publishes its ground truth — emission, never ingestion. Subscribers (ALS/OTel vendors, monitoring layers) correlate from THEIR side at the one moment both trees share a frame; dive never imports async_hooks and never trusts external propagation. Same shape and philosophy as mnemonica's own registerHook. Detach via the returned unregister function, or with unregisterHook(event, hook) when the closure was not kept (no-op for unknown hooks).

Events:

  • enter — right after the edge is recorded, while cursor and lastContext hold the truthful values. Payload: the fresh edge object itself (attach your own symbols to it — a span id gives you the reverse join for free) plus the invocation args by reference.
  • leave — the sync close, with the edge's final status/duration and what the wrap produced (plain value / wrapped function / tapped promise; undefined when the call threw).
  • settle — when a tapped promise chain closes: result on resolution, error on rejection. Distinct from leave, so "the sync head returned" is never confused with "the work is done".
  • recontext — a re-wrap handoff: the callback changed ownership, and the payload (fn, previousContext, context, plus the handoff edge) links the old context's story to the new one.
  • createopt-in: a construction edge recorded via recordCreation/recordCreationError. Deliberately not an enter — that lifecycle is the adapter's own mnemonica-hook domain, and re-publishing it as enter would double-report there. create exists for third-party subscribers that are not the adapter; error is set on the recordCreationError path.

Hooks fire only when an edge is recorded — with setTraceLimit(0) no event fires. Dispatch cost when nobody is subscribed is one length check per edge. Subscriber exceptions are contained per-subscriber: a throwing hook degrades its own observability, never the trace.

import { registerHook } from '@mnemonica/dive';

// correlate an OTel span with every wrapped call
registerHook('enter', ({ edge, args }) => {
	const span = tracer.startSpan(edge.name);
	(edge as Record<symbol, unknown>)[SPAN] = span;
});
registerHook('settle', ({ edge, error }) => {
	const span = (edge as Record<symbol, unknown>)[SPAN] as Span | undefined;
	if (error) span?.recordException(error);
	span?.end();
});

The Execution-Flow Trace

The trace is what makes concurrent flows honest. Its parentage rule:

  • Depth > 0 (truly nested inside another wrapped invocation): the edge parents on the cursor — "Y called X" is recorded as it happened.
  • Depth === 0 (entered from an unwrapped boundary: timer, emitter, route handler): the edge parents on the data — the latest edge of the context instance. The cursor may hold a stale edge from an unrelated flow; trusting it would merge two requests into one branch.

Construction edges always parent on the data-flow parent (the parent instance's own latest edge), so the trace forms a forest isomorphic to the mnemonica instance chain:

create:RequestData ── create:RouteData ── method:load ── call:onLoaded
create:RequestData ── create:RouteData ── method:load ── error edge

Two requests interleaved in one process produce separate branches — the old single-global-switcher clobbering cannot corrupt the trace, because the switcher is never used for parentage.


Stress Test

A stress scenario proves context survival across random async boundaries. It is a test fixture (test/stress/), not a published entry point — run it with npm test or read it as a worked example.

Flow:

  1. Create 100 StressEntity instances with random values (each carries its uuid and requestId in its own data — no side maps)
  2. Fisher-Yates shuffle, register 70% to global registry
  3. Random consumer picks instances (20–100ms intervals)
  4. ~55% success | ~17% sync throw | ~14% async reject | ~14% nested construction
  5. Failures happen INSIDE wrapped boundaries → self-pinned errors
  6. DLQ collects failures; every entry derives requestId/uuid from getErrorInstance(error) and proves a non-empty getFlow(error)

Key result: every failure is traceable back to the originating request — data AND flow — even though instances were shuffled, queued, and processed seconds later. The test/uncaught-real.spec.ts child-process test proves the same across REAL uncaughtException / unhandledRejection boundaries, where ALS's ambient store is gone.


Framework Integration

For NestJS there is a dedicated adapter: @mnemonica/nestjs — module system, validation pipe, interceptors, and attachHooks() (the dive ↔ mnemonica lifecycle wiring). MnemonicaModule.forRoot({ thunderstruck: true }) activates the whole bundle.

Outside NestJS, call attachHooks(collection) from the same package once at startup, and every mnemonica instance created while serving a request becomes context automatically (the instance is the context). At decoupled boundaries (queues, timers, emitters), wrap() the callback with the instance it processes — the failure will then carry the data and the flow. For anything else, the integration primitives (see API) let you wire dive into your own lifecycle events.


ALS Comparison

| Scenario | ALS | Dive | |----------|-----|------| | Simple async chain | ✅ Works | ✅ Works | | Synchronous instance creation | ❌ Loses context | ✅ Shifts per instance | | setTimeout 30s later | ❌ Store gone | ✅ Context preserved | | Random queue shuffle | ❌ No traceability | ✅ Every failure carries data + flow | | Nested construction error | ❌ No parent context | ✅ Parent in error | | Concurrent interleaved flows | ✅ Auto-isolated | ✅ Trace isolates; bare current() is newest-wins (documented) | | Memory overhead | One store per async resource | Bounded ring buffer (setTraceLimit) |


The async_hooks Isomorphism

Dive knowingly re-uses the shape of async_hooks — and inverts what it attaches to:

| async_hooks | dive | |---|---| | asyncId | edge id | | triggerAsyncId | edge parentId | | executionAsyncId() | the trace cursor | | init / before / after / destroy | wrap() entry/exit bookkeeping | | AsyncLocalStorage store | lastContext behind current() |

The difference is the attachment point. async_hooks parents the graph on async resources — timers, promises, I/O handles the runtime created — and instruments everything from inside the runtime, whether you asked or not; AsyncLocalStorage then tries to filter that noise back down. Dive parents the graph on invocations carrying data — and wraps only what you explicitly wrapped, from userland. The default is silence; you pay per wrap.

This is also why dive survives the synchronous split (nodejs/diagnostics#249) that breaks async_hooks-based CLS: at a sync boundary no async resource is created, so there is nothing to hook — but the invocation still happens, and dive's context lives on the instance, not on the resource.

There is a cautionary prequel here. In the diagnostics-WG era, Thomas Watson described monkeypatching Node's own bootstrap — down at the serializer layer — to wrap everything for tracing. The result was combinatorial bloom: instrument-everything pays for everything, and the traces drown in their own exhaust. Dive's answer to that story is the opt-in model: the same graph shape, but hung from data you chose, at boundaries you chose.


Intentionally Not Covered

Dive wraps direct function calls, constructors, Promise chains, and instance methods. It does NOT auto-wrap every possible execution boundary. Here is why.

What We Do NOT Track

| Boundary | Status | Reason | |----------|--------|--------| | Arrays / objects containing functions | Use wrap() | Deep inspection causes false positives (every object method would be wrapped) | | setTimeout / setInterval | Use wrap() | Timer monkey-patching breaks user code and third-party libraries | | Event emitters (on, once) | Use wrap() | Would need to patch Node.js EventEmitter prototype — fragile | | Streams (pipe, on('data')) | Use wrap() | Same as emitters; also streams often live longer than context | | Property getters / setters | Not supported | Method wrapping only handles descriptor.value, not accessors | | Generators / yield | Use wrap() | Each yield creates a suspension point; auto-wrapping requires intercepting next() |

Why Not Auto-Wrap Everything?

Auto-wrapping every boundary causes a cyclomatic / combinatory explosion — and it is not just performance overhead, it is correctness overhead. Deep auto-wrapping:

  • Wraps user-intentional plain objects (false positives)
  • Breaks library code that expects unwrapped references
  • Creates memory leaks if we hold strong refs to every returned object

Manual Wrapping Is the Escape Hatch

For any boundary not auto-wrapped, use wrap() explicitly:

// Arrays containing callbacks
const wrappedHandlers = handlers.map(fn => wrap(fn, instance));

// setTimeout
setTimeout(wrap(() => processTask(), instance), 1000);

// Event emitters
emitter.on('data', wrap(onData, instance));

Generators and yield

Generators create a suspension boundary at every yield. Dive does not auto-wrap them because yield can fire across arbitrary async boundaries. Manually wrap each resumption:

function* myGenerator() {
  yield step1();
  yield step2();
}

const gen = myGenerator();
// step1 runs with instance as context
const result1 = wrap(() => gen.next(), instance)();
// step2 runs with instance as context
const result2 = wrap(() => gen.next(), instance)();

For async generators, wrap the resumptions the same way — the async keyword does not change the wrapping semantics.

Note that wrapping the generator function itself does not help: the wrapped call returns the iterator object, the edge closes 'ok' at that moment, and the body still runs later through unwrapped next() calls. wrap() traces iterator creation, not the body — the resumptions are the unit of work, so they are what you wrap.

The deeper reason this stays manual: yield is a "stop the world on the stack" pattern — it suspends the frame itself, where async/await is a simple continuation the promise tap can outlive. Intercepting next() would mean dive owns the iterator protocol's pacing — computability bought at the expense of debuggability. Reframe the usage instead: collect the steps, await them, or wrap each resumption explicitly.

The Rule of Thumb

If the execution flow passes through a function call, Dive can track it. If the flow escapes through a non-function boundary (array slot, event emitter, stream), use wrap() manually.

This keeps Dive predictable, fast, and correct.


Boundaries

Execution flow spans more than one runtime. A request's story may cross a database, a message queue, or another service — and each of those runtimes traces its own path in its own way. Dive's single responsibility is this runtime: the process, in memory.

The reason is structural. Dive pins context to object identity — the instance and the error object, tracked via a WeakMap and symbol properties. Serialization destroys identity: what comes back from the database is a new object with the same field values, and no library can tell from the object alone that it descends from request 42. This is not a gap to fix; it is the boundary every in-process tracer shares, ALS included.

The contract between runtimes is a correlation key carried as data:

  1. Carry the identifier in the payload (e.g. a uuid stored with the DB record) — it survives because it travels as data, not as identity.
  2. On read-back, construct the mnemonica type from the record (new RequestData(dbRecord)): dive tracking resumes from that point, and the uuid links the new flow branch back to the original one.
  3. Across the wire, let the tracer built for it do its job: the @mnemonica/nestjs adapter emits OpenTelemetry spans carrying dive.instance.uuid, so Jaeger stitches what dive cannot see.

Where dive differs from ALS is which in-process boundary it pins to. ALS binds context to the async resource chain — ambient, correct only while every library propagates perfectly, and already gone when uncaughtException fires. Dive pins to the object graph — which is why attribution survives process-level escapes and arbitrary queue reordering.

Falsifiable, not "trust us"

Every claim above is gated by a script that exits non-zero on any misattribution:

| Proof | Where | What it gates | |-------|-------|---------------| | Stress suite | npm test, this repo | shuffle + queue + DLQ attribution; real process-level escapes in a child process | | load:proof | FineCut pilot (finecut/nest-dive) | 200 unique-marker crashes over real TCP, 50 in flight, zero misattribution | | proof:queue | FineCut pilot (finecut/nest-dive) | 140 markers through a random-order, random-delay queue — request 42 stays 42 |

Falsifiable means there is a way to research further — not that nothing works. Something works, and these instruments will say so the day it stops.


History

  • 2018: context-diveasync_hooks + manual callback patching (the HolyJS 2018 talk package)
  • 2020: AsyncLocalStorage — native Node.js, 90% coverage
  • 2025: @mnemonica/dive v0.1 — object-bound context, no ALS (single-global switcher)
  • 2026: v0.2 redesign — the switcher demoted to a cursor over a bounded execution-flow trace; construction edges parent on the data-flow lineage; the identifier-map subsystem (link/unlink) removed — the data carries its own identity, and errors carry the data. API simplified to 7 functions.
  • 2026: v0.3 — the engine/adapter split, completed. Dive is a palette of wrappers and imports nothing at all; the mnemonica-specific attachHooks moved to @mnemonica/nestjs, rebuilt from dive's exported integration primitives. thunderstruck (pre-root payload collection) moved out with it — dive was never meant to be a storage. The adapter keeps those payloads in a WeakMap keyed on request objects: GC is the only release, and retention is exactly the request's lifetime.
  • 2026: async edge closure — the promise tap now closes the edge ('ok' + full-lifetime duration) when the whole chain settles, and 'running' means genuinely unsettled. The domain vocabulary (statuses, kinds, fallback names) was hoisted to single-definition constants.

Motivation: nodejs/diagnostics#249 — synchronous execution splits break async_hooks-based CLS.

The design decision log — considered-and-rejected alternatives, parked designs, and when to revisit them — lives in DECISIONS.md in the repository.


Internals

The store is four parts (no async_hooks):

  • edges — a Map<id, FlowEdge> ordered by insertion. Since 2026-09-02 the default is unbounded (setTraceLimit opts back into a bounded ring; 1024 was the old default) and instance refs are weak by default: edge.instance is a WeakRef deref and GC reachability is the memory bound, with the FinalizationRegistry marking collected instances on their edges. setWeakInstanceRefs(false) opts back into strong refs — edges then pin their instances, so bound the ring if you do that.
  • cursor — the id of the edge executing right now (null at rest), plus activeDepth tracking how deep we are inside wrapped invocations. Depth decides parentage (see "The Execution-Flow Trace").
  • latestEdge — a WeakMap<instance, edgeId> with each instance's most recent edge, so construction and method calls continue the instance's own story. Weak, so instances are never pinned by this map.
  • lastContext — the "newest-wins" switcher behind current(). Deliberately NOT used for trace parentage: concurrent flows may clobber the switcher, but they cannot corrupt the trace.

Context also rides on error objects via two non-enumerable symbol properties (mnemonica.dive.edge, mnemonica.dive.instance), pinned once at the deepest wrapped boundary the error passes through — which is how data and flow survive to uncaughtException / unhandledRejection handlers where ALS's ambient store is already gone.

Method wrapping is applied to the instance's immediate prototype, using this (the receiver) as the context. For plain classes — where many instances share one prototype — each method is wrapped ONCE. Mnemonica gives every instance its own immediate prototype, so for mnemonica instances this is still per-instance; it is not worse, just not a win.


License

MIT


Explanation

This is the whole machinery in execution order. The implementation is one file (src/index.ts, ~630 lines, no imports).

0. The shape

Dive is not a class or an object — it's module-level mutable state plus functions. The entire store is five let bindings at the top of the file:

  • edges: Map<id, FlowEdge> — the trace itself (a ring buffer; the oldest entries are evicted past traceLimit)
  • latestEdge: WeakMap<instance, edgeId> — "where this instance's story last continued"
  • cursor: number | null — the edge executing right now
  • activeDepth: number — how many wrapped invocations deep we are
  • lastContext — the newest-wins switcher behind current()

1. The entrypoint

There is no start function. Dive is inert until a wrapped function is invoked. In a mnemonica app the wiring entrypoint is attachHooks(collection) (adapter side), which registers mnemonica lifecycle hooks — but those hooks themselves only call dive primitives. So the real entrypoint, always, is: somebody calls a function that wrap() returned.

2. wrap(fn, context?) — the heart

Two phases. Wrap time (once): capture the context — explicit argument, or whatever lastContext is right then. Already-wrapped functions pass through untouched.

Call time — every invocation of the wrapped function:

  1. Save previousContext / previousCursor; set lastContext = capturedContext.
  2. recordEdge(...) appends a FlowEdge {id, parentId, instance, name, kind, ts, status:'running'}. The parent comes from executionParent(context) — see step 3 below.
  3. cursor = edge.id; activeDepth++ — we are now inside a wrapped invocation.
  4. Wrap the args: any function passed into this call gets wrapped with the same context — context propagates down.
  5. Call the real fn — via Reflect.construct if invoked with new.
  6. If the result is a function, wrap it — context propagates forward.
  7. If the result is a Promise, tap it: the edge closes ('ok' + full-lifetime duration) when the whole chain settles — a promise never resolves to a promise, the runtime flattens thenables before the tap fires, so promise-in-promise needs no wrapping of its own — resolved functions get wrapped, rejections get pinError(error, edge, context) then re-throw.
  8. Sync throw → pinError, rethrow.
  9. finally: restore cursor, activeDepth--, restore lastContext (for promises, duration is stamped at settlement by step 7's tap). The state machine is back exactly where the caller left it.

Steps 4+6 are the ALS replacement: propagation is not ambient, it's viral through values — every wrapped call wraps its inputs and outputs, so context chains to any depth without touching the runtime.

3. The parentage rule — executionParent

  • activeDepth > 0: we're truly nested inside another wrapped call → parent is the cursor. "Y called X" is recorded as it happened.
  • activeDepth === 0: we entered from an unwrapped boundary (setTimeout fired, emitter called, route handler) — the cursor may be a stale edge from some other request. So the edge parents on the data: latestEdge.get(context) — the context instance's own most recent edge.

This is the line that makes the queue proof possible: interleaved requests can clobber lastContext and even the cursor, but a fresh edge at a boundary continues its instance's story, never a stranger's.

4. The error path — pinError

Every edge an error propagates through gets status = 'error' — but the error object is pinned only once (if the symbol's already there, return). Deepest boundary wins; outer re-throws can't overwrite the failure site. Two non-enumerable symbols go onto the error: mnemonica.dive.edge (edge id) and mnemonica.dive.instance (the data). That's the whole trick behind crash attribution: the error carries its provenance, so uncaughtException — where ALS's store is long dead — can still recover everything.

5. The read paths

  • current() — just lastContext. Honest but newest-wins; ambiguous under concurrency by design.
  • getFlow(target) — resolve a starting edge (cursor / error's pinned edge / instance's latest edge), then walk parentId upward, unshifting into an array → the branch, oldest first.
  • getErrorInstance(err) — pinned instance; fallback: the instance of the pinned edge.

6. How mnemonica instances enter the picture — attachHooks (adapter)

  • preCreation: enterContext(parent) + wrapConstructorArg on function args — callbacks handed to a constructor carry context, via a mutable holder so they can be re-pointed at the not-yet-built instance.
  • postCreation: recordCreation(name, instance, parent) → a create edge parented on the parent instance's latest edge (data-flow lineage); then wrapInstanceMethods(instance) redefines every method on the instance's immediate prototype with the same bookkeeping as wrap() but kind:'method' and context = the receiver this; upgradeConstructorArg re-points unused arg callbacks at the built instance.
  • creationError: recordCreationError — a failed create edge under the surviving parent, error pinned to it.

7. End-to-end: one queue-proof request

  1. POST /proofnew ProofEntity({uuid, marker, expect}). preCreation/postCreation fire → create:ProofEntity edge; process gets wrapped on the prototype. HTTP response leaves. Request cycle over.
  2. Seconds later, a setTimeout tick fires (unwrapped boundary, depth 0) → instance.process() → wrapped method records method:process, parented on that instance's own create edge, not on whatever ran last.
  3. await random delay → throw for marker 57 → the promise tap pins the error to this edge + this instance → rethrows.
  4. The queue's catch calls recordFailure(err)getErrorInstance(err) → the instance → utils.extract{uuid, marker} → outcome stored.
  5. GET /proof/:uuid reads it back. The script asserts the marker matches what it sent — which it can only do if step 2's parentage and step 3's pinning never crossed wires.

That's the whole loop: wrap at boundaries, record edges, parent on data, pin errors once, read from the error. Everything else in the file (setTraceLimit, clear) is housekeeping.