@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()anddefine()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 chooseWhen 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 mnemonicaDive 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; // trueAPI
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 + creationErrorDive 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)orwrap(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 branchcurrent()
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, whilecursorandlastContexthold 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 invocationargsby reference.leave— the sync close, with the edge's finalstatus/durationand what the wrap produced (plain value / wrapped function / tapped promise;undefinedwhen the call threw).settle— when a tapped promise chain closes:resulton resolution,erroron rejection. Distinct fromleave, 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.create— opt-in: a construction edge recorded viarecordCreation/recordCreationError. Deliberately not anenter— that lifecycle is the adapter's own mnemonica-hook domain, and re-publishing it asenterwould double-report there.createexists for third-party subscribers that are not the adapter;erroris set on therecordCreationErrorpath.
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 edgeTwo 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:
- Create 100
StressEntityinstances with random values (each carries itsuuidandrequestIdin its own data — no side maps) - Fisher-Yates shuffle, register 70% to global registry
- Random consumer picks instances (20–100ms intervals)
- ~55% success | ~17% sync throw | ~14% async reject | ~14% nested construction
- Failures happen INSIDE wrapped boundaries → self-pinned errors
- DLQ collects failures; every entry derives
requestId/uuidfromgetErrorInstance(error)and proves a non-emptygetFlow(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:
- Carry the identifier in the payload (e.g. a
uuidstored with the DB record) — it survives because it travels as data, not as identity. - 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. - Across the wire, let the tracer built for it do its job: the
@mnemonica/nestjsadapter emits OpenTelemetry spans carryingdive.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-dive—async_hooks+ manual callback patching (the HolyJS 2018 talk package) - 2020:
AsyncLocalStorage— native Node.js, 90% coverage - 2025:
@mnemonica/divev0.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
attachHooksmoved 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 aWeakMapkeyed 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-lifetimeduration) 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— aMap<id, FlowEdge>ordered by insertion. Since 2026-09-02 the default is unbounded (setTraceLimitopts back into a bounded ring; 1024 was the old default) and instance refs are weak by default:edge.instanceis aWeakRefderef and GC reachability is the memory bound, with theFinalizationRegistrymarking 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 (nullat rest), plusactiveDepthtracking how deep we are inside wrapped invocations. Depth decides parentage (see "The Execution-Flow Trace").latestEdge— aWeakMap<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 behindcurrent(). 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 pasttraceLimit)latestEdge: WeakMap<instance, edgeId>— "where this instance's story last continued"cursor: number | null— the edge executing right nowactiveDepth: number— how many wrapped invocations deep we arelastContext— the newest-wins switcher behindcurrent()
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:
- Save
previousContext/previousCursor; setlastContext = capturedContext. recordEdge(...)appends aFlowEdge{id, parentId, instance, name, kind, ts, status:'running'}. The parent comes fromexecutionParent(context)— see step 3 below.cursor = edge.id; activeDepth++— we are now inside a wrapped invocation.- Wrap the args: any function passed into this call gets wrapped with the same context — context propagates down.
- Call the real
fn— viaReflect.constructif invoked withnew. - If the result is a function, wrap it — context propagates forward.
- 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 getpinError(error, edge, context)then re-throw. - Sync throw →
pinError, rethrow. finally: restorecursor,activeDepth--, restorelastContext(for promises,durationis 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 thecursor. "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()— justlastContext. 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 walkparentIdupward,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)+wrapConstructorArgon 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)→ acreateedge parented on the parent instance's latest edge (data-flow lineage); thenwrapInstanceMethods(instance)redefines every method on the instance's immediate prototype with the same bookkeeping aswrap()butkind:'method'and context = the receiverthis;upgradeConstructorArgre-points unused arg callbacks at the built instance. - creationError:
recordCreationError— a failedcreateedge under the surviving parent, error pinned to it.
7. End-to-end: one queue-proof request
POST /proof→new ProofEntity({uuid, marker, expect}). preCreation/postCreation fire →create:ProofEntityedge;processgets wrapped on the prototype. HTTP response leaves. Request cycle over.- Seconds later, a
setTimeouttick fires (unwrapped boundary, depth 0) →instance.process()→ wrapped method recordsmethod:process, parented on that instance's owncreateedge, not on whatever ran last. awaitrandom delay →throwfor marker 57 → the promise tap pins the error to this edge + this instance → rethrows.- The queue's
catchcallsrecordFailure(err)→getErrorInstance(err)→ the instance →utils.extract→{uuid, marker}→ outcome stored. GET /proof/:uuidreads 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.
