pure-effect 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -7,8 +7,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
7
7
  ```bash
8
8
  npm test # run all tests
9
9
  npx mocha test/all.js --grep "pattern" # run a single test by name
10
- npm run format # Prettier over index.js, index.d.ts, examples/*.js, test/all.js, README.md, CLAUDE.md
11
- npx tsd # type-level gate; the only check that reads index.d.ts
10
+ npm run format # Prettier over index.js, index.d.ts, examples/*.js, test/all.js, test/types.test-d.ts, README.md, CLAUDE.md
11
+ npx tsd # type-level gate over test/types.test-d.ts
12
+ npx tsc -p jsconfig.json # strict tsc over index.js, test/all.js, test/types.test-d.ts, and examples/*.js, reading index.d.ts as a consumer; the same config VS Code uses
12
13
  npx esbuild index.js --minify --format=esm | gzip -9 | wc -c # the size the README claims
13
14
  ```
14
15
 
@@ -46,7 +47,7 @@ The recording and replay exports are a 2x2 rather than four loose helpers: `reco
46
47
 
47
48
  There is deliberately no catch combinator (an `Attempt` node was designed and rejected), and the README's `Which Errors Are Data` section is the guidance that replaced it: an outcome the flow handles is data (a `next` branches on the result, and a rejecting `cmd` function catches into a result shape at the boundary), while `Failure` means abort and is reserved for the shell. Fallbacks written this way are fully traced and replayed; the failed attempt is recorded as that Command's result with the error as data, so nothing is lost, only categorized as handled. Catch was rejected because it reintroduces non-local control flow into a paradigm whose point is explicit data flow, and because every motivating case except one was already expressible. The one exception, recovering from `Retry` exhaustion in-flow, is now covered by the scoped per-use option `Retry(effect, { onExhausted })`. It was added, where general catch was rejected, for three reasons worth keeping: it was the only composition with no in-library expression (the shell workaround split one logical operation across two runs and two traces); retry-then-fallback is a canonical resilience pairing (Polly, resilience4j), so demand was predictable rather than speculative; and it completes `Retry`'s own failure handling rather than adding catch semantics, since `Retry` was already the node that reacts to failures and previously abdicated on the last one. The ceiling stands: no general catch node, nothing but exhaustion is catchable, `onExhausted` is per-use only (the global `retry` defaults cannot carry one, pinned by an `expectError`), and a cancelled branch skips the fallback.
48
49
 
49
- A Command's identity is resolved by the internal `commandName(eff)`: a non-empty string `meta.name`, else `cmd.name`, else `'anonymous'`. That identity is used in three places at once, which is why it has its own function rather than being inlined at the call site: trace entries are keyed on it, strict replay matching compares it against the recorded name, and telemetry spans are titled with it. `meta.name` exists because the previous rule, `cmd.name` alone, made the flagship feature depend on syntax: an inline arrow silently recorded as `'anonymous'`, and any minifier that mangles function names renamed every step of every trace. Both failures are silent, which is the worst property an identity can have. `cmd.name` remains supported and is what the examples use, since naming the thunk reads well and needs no second argument.
50
+ A Command's identity is resolved by the internal `commandName(eff)`: a non-empty string `meta.name`, else `cmd.name`, else `'anonymous'`. That identity is used in three places at once, which is why it has its own function rather than being inlined at the call site: trace entries are keyed on it, replay matching compares it against the recorded name, and telemetry spans are titled with it. `meta.name` exists because the previous rule, `cmd.name` alone, made the flagship feature depend on syntax: an inline arrow silently recorded as `'anonymous'`, and any minifier that mangles function names renamed every step of every trace. Both failures are silent, which is the worst property an identity can have. `cmd.name` remains supported and is what the examples use, since naming the thunk reads well and needs no second argument.
50
51
 
51
52
  Anything that is not an Effect is rejected by `asEffect` / `effectTypeError` with a message naming its source, and the distinction they encode is deliberate: a malformed flow is a bug and throws, while a Command that rejects is a domain outcome and becomes a `Failure`. The interpreter's catch rethrows `EffectTypeError` specifically so a flow bug cannot masquerade as a business error. Two details are important. `chain` validates the return of each pipeline step, which is the only place the offending step's name is known (`fn.name`), so that is where the useful message comes from. And the interpreter's `while` condition guards on `eff &&` before reading `eff.type`, because a continuation returning `undefined` otherwise threw a bare TypeError from the condition itself, outside any handler.
52
53
 
@@ -58,10 +59,10 @@ Before this, every one of those mistakes was silent or cryptic: forgetting `Succ
58
59
  | --- | --- |
59
60
  | `recorder(options?)` | Returns `{ onStep, entries, toTrace }`; the `onStep` hook records every Command's result or error. Options: `redact`, `maxEntries`, `stack` |
60
61
  | `recordEffect(flowFn, initialInput, opts?)` | Runs a flow for real while recording; returns `{ result, trace }`. Convenience for tests and scripts |
61
- | `replayEffect(effect, traceOrResolver, opts?)` | Replays a tree, feeding recorded outcomes to Commands instead of executing them. Takes a trace or a `Resolver`. Options: `context`, `strict`, `fastRetry`, `hooks`, `onMissing`, `onResolved` |
62
- | `timeTravel(flowFn, traceLog, options?)` | Narrated replay: rebuilds the flow from the recorded input, logs each step, warns on version mismatch and unreached steps |
62
+ | `replayEffect(effect, traceOrResolver, opts?)` | Replays a tree, feeding recorded outcomes to Commands instead of executing them. Takes a trace or a `Resolver`; returns `{ result, unreached }` for a trace and `{ result }` for a `Resolver`. Options: `context`, `fastRetry`, `hooks`, `onMissing`, `onResolved` |
63
+ | `timeTravel(flowFn, traceLog, options?)` | Narrated replay: rebuilds the flow from the recorded input, logs each step, warns on version mismatch and names any unreached steps; returns the bare outcome, since narration is its job |
63
64
 
64
- `fromTrace(traceLog, { strict })`, which builds a `Resolver` over the reference trace format, is **internal**, and `replayEffect` is its only caller. Deliberately not exported: a caller whose traces live in another shape writes a `Resolver` instead, and a caller who only wants to watch a replay uses `onResolved`. Between them those cover every known need, so exporting it would only widen the public surface. The one thing neither covers is _rewriting_ outcomes from a reference-format trace (substituting one step's result, or chaining a fallback), since `onResolved` is an observer whose return value is ignored. Export it again if that comes up; nothing depends on it staying private.
65
+ `fromTrace(traceLog, { onEntry })`, which builds a `Resolver` over the reference trace format, is **internal**, and `replayEffect` is its only caller. Deliberately not exported: a caller whose traces live in another shape writes a `Resolver` instead, and a caller who only wants to watch a replay uses `onResolved`. The `onEntry` observer is how `replayEffect` learns which entries were handed out; it subtracts those from the trace after the run to produce `unreached`, which is why that field exists for a trace and is absent for a `Resolver`. Between them those cover every known need, so exporting it would only widen the public surface. The one thing neither covers is _rewriting_ outcomes from a reference-format trace (substituting one step's result, or chaining a fallback), since `onResolved` is an observer whose return value is ignored. Export it again if that comes up; nothing depends on it staying private.
65
66
 
66
67
  See **Time-travel debugging** below for the invariants these depend on, and the README for each option in full.
67
68
 
@@ -90,7 +91,7 @@ runEffect(tree, context, callConfig?)
90
91
  → resolves to final Success or Failure
91
92
 
92
93
  replayEffect(tree, traceOrResolver, options?)
93
- → a trace goes through fromTrace(trace, { strict }); a function is used as the
94
+ → a trace goes through fromTrace(trace, { onEntry }); a function is used as the
94
95
  Resolver as-is (typeof check: an entries array is data, so it takes the
95
96
  trace path); a malformed trace rejects with a ReplayError
96
97
  → installs an onStep that answers each step from resolve(step) instead of
@@ -115,7 +116,7 @@ There is one slot per hook, and `configureEffect` merges the configurations of a
115
116
 
116
117
  The raw hooks are powerful: a hook decides whether the Command runs at all, which is how replay suppresses I/O, and anything it throws becomes the flow's outcome. That power is wrong for anything that only watches, and it caused two real bugs here: the tracing example serialized its input before calling `pipeline()`, so a cyclic input left flows unexecuted, and `recorder` let a throwing `redact` turn a successful run into a `Failure` carrying the redaction bug while recording an entry that claimed the Command had thrown. The internal `observeSteps(handler)` now holds that contract in one place: `op` always runs, its result is always returned, its error always propagates, and anything the observer throws is dropped. Keep it minimal and unexported. `recorder` is its only caller and needs only `onStep`, so there is no `onRun` branch and no error-reporting channel, and an exported second route to the raw hooks would widen the surface for integrations nobody has written. It briefly was exported, as `observe`, with both branches; that cost 270 bytes gzipped for one internal call site and no consumer, which is the same argument that removed `tap`.
117
118
 
118
- Trace entries carry `durationMs`, rounded to microseconds, because `observeSteps` measures the span around `op` anyway and nothing else recorded timings. `timeTravel` narrates it, but only under `strict` matching, since per-Command queues do not map a replayed step back to a recorded position.
119
+ Trace entries carry `durationMs`, rounded to microseconds, because `observeSteps` measures the span around `op` anyway and nothing else recorded timings. `timeTravel` narrates it, looking the entry up by path, or by position for a trace that carries none.
119
120
 
120
121
  `configureEffect` returns a function that restores the wiring in place when it was called, captured as a snapshot of the resolved runners rather than of the options, and it reverts only while that wiring is still installed. The guard matters because the first version reverted unconditionally: two callers installing and releasing in interleaved order silently discarded each other's hooks, which is the same class of silent clobbering the restore function was added to prevent. It exists because the global was otherwise write-only: there is no getter, so anything that installed hooks clobbered its host permanently and could not put things back, which made the global unusable from a test, a scoped experiment, or a library wrapping this one. Restoring is a snapshot and not a stack, so a restore that runs after a later `configureEffect` reverts to the older wiring and discards the newer one. The two are not interchangeable: `restore()` returns to whatever was installed before, while a bare `configureEffect()` asserts a known-clean slate. Suites here want the clean slate, so they reset; `restore()` is for code that installs hooks over someone else's wiring and has to hand it back.
121
122
 
@@ -142,27 +143,27 @@ Because a flow is data, a production run can be recorded and replayed later. Rec
142
143
  Four invariants hold the design together; breaking any of them breaks replay in a way tests may not catch:
143
144
 
144
145
  - **Replay drives the real interpreter.** `replayEffect` calls `runEffect` rather than walking the tree itself, which is why `Ask`, `Retry`, and `Parallel` replay correctly and why replay semantics cannot drift from execution semantics. The interpreter's only execution point is `await localStepRunner(cmdName, 'Command', eff.cmd)`, which hands the Command thunk to `onStep` as `op` instead of calling it. Replay's `onStep` never invokes `op` unless `onMissing: 'execute'`, so **no side effect can occur by default**. Preserve that single-execution-point property: a Command executed anywhere else in the interpreter would be invisible to both recording and replay.
145
- - **Path matching, with the two older modes kept for legacy traces.** Every step carries a `path`: its position in the Effect tree, numbered sequentially within a subtree, with each `Parallel` branch and each `Retry` attempt opening its own prefix (`0p1/0r2/0` is branch 1, retry attempt 2, first Command; a retry's `onExhausted` fallback opens `0rf/`). A path depends only on the shape of the tree, never on the order branches finish in, so it is order-independent **and** paradox-detecting at the same time. `fromTrace` matches by path whenever every entry has one, and it does so whatever `strict` says, deliberately: a caller who passed `strict: false` to work around `Parallel` should get correct matching once their traces carry paths rather than keeping the mode that option existed to work around. Duplicate paths throw, since a collision would reintroduce the failure paths exist to prevent. The older modes remain only for traces recorded before paths existed: `strict: true` consumes entries positionally, `strict: false` resolves per-Command FIFO queues. **The name-queue mode is the bug paths fixed.** It paired a step with the next recorded entry of the same name, which is completion order, so two `Parallel` branches calling the same Command swapped results whenever replay timing differed from production, and the replay still reported `Success`. `strict` is ignored when a `Resolver` is passed, because that `Resolver` has already chosen its own matching.
146
+ - **Path matching, with positional matching for traces that carry no paths.** Every step carries a `path`: its position in the Effect tree, numbered sequentially within a subtree, with each `Parallel` branch and each `Retry` attempt opening its own prefix (`0p1/0r2/0` is branch 1, retry attempt 2, first Command; a retry's `onExhausted` fallback opens `0rf/`). A path depends only on the shape of the tree, never on the order branches finish in, so it is order-independent **and** paradox-detecting at the same time. `fromTrace` matches by path whenever every entry has one. Duplicate paths throw, since a collision would reintroduce the failure paths exist to prevent. A trace whose entries carry no path (recorded before paths existed, or written by hand) is consumed positionally, each entry checked against the Command the flow asks for; exact for sequential flows, and unable to tell `Parallel` branches apart. There is no matching option any more. `strict` used to choose between that positional mode and a per-Command name-queue mode, which was the only way to replay `Parallel` before paths, and **the name-queue mode was the bug paths fixed**: it paired a step with the next recorded entry of the same name, which is completion order, so two `Parallel` branches calling the same Command swapped results whenever replay timing differed from production, and the replay still reported `Success`. Once paths made it unnecessary the option was removed rather than left as a footgun; a `Resolver` remains the way to match by any other rule. The positional resolver refuses a step whose path shows it is inside a `Parallel` (a `p` segment), with a `ReplayError` that says to add paths, because a positional answer there would be right only when the replay happened to finish in production's order, and silently wrong otherwise.
146
147
 
147
148
  - **The hook contract carries the path as a fourth parameter.** `onStep(name, type, op, path)`. Three-parameter hooks keep working, and `chainHooks` forwards the path through nested wrappers. A hand-rolled wrapper that calls `inner(name, type, op)` without forwarding `path` silently produces a trace with no paths, which then falls back to legacy positional matching: correct for sequential flows, and the old swapping bug for `Parallel`. Forward the fourth argument in anything that wraps `onStep`.
148
149
  - **`fastRetry` exists because of merge order.** The interpreter merges per-use options over call config (`{ ...localRetryDefaults, ...eff.options }`), so a `callConfig.retry` cannot override a delay written at the call site. `zeroRetryDelays` therefore rewrites `Retry` nodes to `delay: 0, backoff: 1` lazily, through their `next` continuations; without it, replaying a flow that retried in production waits out the production backoff.
149
150
  - **The outcome wrapper is load-bearing.** A `Resolver` returns `{ result }`, `{ error }`, or `undefined` for "not recorded". The wrapper keeps a Command that legitimately returned `undefined` (`{ result: undefined }`) distinct from having no recording, which is what makes the `onMissing` guard trustworthy.
150
151
 
151
- Two further details: `hooks` defaults to `false`, so a replay cannot reach a telemetry backend or a guardrail that performs I/O; and errors cross the trace via `serializeError` / `reviveError` because `message`, `stack`, and `cause` are non-enumerable on `Error` and a plain `JSON.stringify` would silently drop them; `cause` is carried recursively since it is often an `Error` itself. Non-`Error` values pass through unchanged, so a Command that rejected with a string still replays as a string.
152
+ Two further details: `hooks` defaults to `false`, so a replay cannot reach a telemetry backend or a guardrail that performs I/O; and errors cross the trace via `serializeError` / `reviveError` because `message`, `stack`, and `cause` are non-enumerable on `Error` and a plain `JSON.stringify` would silently drop them; `cause` is carried recursively since it is often an `Error` itself. Non-`Error` values pass through unchanged, so a Command that rejected with a string still replays as a string. `reviveError` defines `name` and `cause` as non-enumerable own properties, matching where a native Error keeps them, because the README's determinism check is `assert.deepEqual(replayed, result)` and Node compares own enumerable keys: assigning `e.name = ...` made every revived error carry an extra key and the check failed for every `Failure` while passing for every `Success`, which is exactly the half of the check that matters. A subclass instance still revives as a plain `Error` (the trace carries a name, not a prototype), so the check is documented as holding for plain errors.
152
153
 
153
154
  Recorded results are snapshotted by the internal `snapshot`, not stored by reference. Without it a later step that mutates a returned object rewrote what the trace said an earlier step returned, and because `toTrace` runs after the flow finishes, the mutation was already baked in by the time a sink serialized it: the trace recorded a value production never saw, and a replay fed that value back. It uses `structuredClone` where available, falls back to a JSON round trip, and falls back again to the reference for values that can be neither, since dropping the entry would be worse. Redaction runs first, so nothing sensitive is cloned before being stripped.
154
155
 
155
156
  `TraceLog` is the reference trace format, not a contract: `replayEffect` also takes a `Resolver`, so any storage shape works by writing one.
156
157
 
157
- `timeTravel` narrates through `replayEffect`'s `onResolved` hook rather than by wrapping a `Resolver`, which is why it can pass the trace straight through and why `fromTrace` needs no public export. Keep that split: `onResolved` is the seam for _observing_ a replay (logging, counting, step assertions), and a `Resolver` is the seam for _supplying_ outcomes. Narration that reaches for a `Resolver` is a sign the wrong seam is being used.
158
+ `timeTravel` narrates through `replayEffect`'s `onResolved` hook rather than by wrapping a `Resolver`, which is why it can pass the trace straight through and why `fromTrace` needs no public export. Keep that split: `onResolved` is the seam for _observing_ a replay (logging, counting, step assertions), and a `Resolver` is the seam for _supplying_ outcomes. `replayEffect` returns `{ result, unreached }` rather than the bare state, mirroring `recordEffect`'s `{ result, trace }`; `timeTravel` narrates the same list and returns the bare outcome, because narration is its whole job and a caller who wants the entries as data has `replayEffect`. `unreached` exists because a flow that stops issuing Commands early mismatches nothing: no `TimeParadox` fires, the replay ends in `Success`, and the recorded steps it never asked for are the only evidence of the divergence. A regression test for a fix that deliberately skips a step asserts which step; every other recording asserts the list is empty. It rides beside the result rather than on it so the flow's outcome stays exactly what production would have returned, and rather than behind an observer option (which it briefly was) because a value handed to a callback exactly once after completion is a return value in disguise, and the `let unreached` it forced into every test was the tell. It is absent, not empty, for a `Resolver`: an empty list would claim knowledge the replay does not have. `timeTravel` still narrates the list, but not after a `TimeParadox` or `ReplayError`, since the flow never got past the divergence and the error already names it. Narration that reaches for a `Resolver` is a sign the wrong seam is being used.
158
159
 
159
160
  ### TypeScript
160
161
 
161
162
  Full generic type declarations are in `index.d.ts` and referenced via the `types` field in `package.json`. Type-level tests live in `test/types.test-d.ts` and run via `tsd` as part of `npm test`.
162
163
 
163
- `index.d.ts` is hand-maintained beside `index.js`, so the two can drift. Two mechanisms guard the drift: the `Declaration parity` test pins the export lists to each other in both directions, and `npm test` now runs `tsc -p tsconfig.check.json` (checkJs over `index.js`), which keeps the JSDoc sound; it still never opens the declarations, so signature-level drift remains `tsd`'s job. The tsc leg was added after a narrowing error in the interpreter shipped through a green gate and was found in an editor, the second time a lens existed but was not in the gate. **Check `npm test` by exit code.** `tsd` failures print neither "passing" nor "failing", so piping the output through `grep -E "passing|failing"` hides them entirely, which is how `runEffect`'s declaration stayed deleted through several edits here while every other check reported green. It was removed as collateral by a slice that targeted an adjacent block, so after any structural edit to `index.d.ts`, compare the runtime exports against the declared ones rather than trusting the diff.
164
+ `index.d.ts` is hand-maintained beside `index.js`, so the two can drift. Three mechanisms guard the drift: the `Declaration parity` test pins the export lists to each other in both directions; `tsd` checks the declarations against `test/types.test-d.ts`; and `npm test` runs `tsc -p jsconfig.json`, strict `checkJs` over `index.js`, `test/all.js`, and `examples/*.js`. That last leg reads the declarations too, not only the JSDoc: `test/all.js` imports `../index.js`, and TypeScript resolves that to the sibling `index.d.ts` before it considers the JavaScript, so the test suite is checked as a strict consumer of the public types. The leg was added after a narrowing error in the interpreter shipped through a green gate and was found in an editor, the second time a lens existed but was not in the gate. It then had to be widened twice for the same reason. The config listed `index.js` alone while `test/all.js` opened with `// @ts-check` and was annotated to typecheck, so an editor showed errors there that the gate never saw. And it ran without `strict`, while VS Code checks JavaScript with `strictNullChecks` and `strictFunctionTypes` by default and consults only `tsconfig.json` or `jsconfig.json`, so it ignored the old `tsconfig.check.json` and reported errors against a green gate. The file is named `jsconfig.json` and is strict precisely so that the editor and the gate read one configuration; keep both properties, and add any checked JavaScript that lives in this repo to its `include` list. `test/types.test-d.ts` is in the list too, which is possible only because its deliberate errors are `@ts-expect-error` directives rather than `expectError` calls (see below). **Check `npm test` by exit code.** `tsd` failures print neither "passing" nor "failing", so piping the output through `grep -E "passing|failing"` hides them entirely, which is how `runEffect`'s declaration stayed deleted through several edits here while every other check reported green. It was removed as collateral by a slice that targeted an adjacent block, so after any structural edit to `index.d.ts`, compare the runtime exports against the declared ones rather than trusting the diff.
164
165
 
165
- Raw `tsc` over `test/types.test-d.ts` is the wrong lens and will always report errors: `expectError(...)` blocks contain deliberate type errors that `tsd` requires to be errors.
166
+ Deliberate type errors in `test/types.test-d.ts` are written as `// @ts-expect-error` directives, never as `tsd`'s `expectError(...)`. Both assert that the next expression fails to typecheck, but only the directive is understood by the compiler itself: `expectError` leaves a real diagnostic in the file that `tsd` swallows and every other tool reports, so with it the file showed ten red lines in VS Code and could not be part of the `tsc` gate, which is how a green `tsd` and a red editor coexisted for a while. With the directive, TypeScript suppresses the error and reports `Unused '@ts-expect-error' directive` if it ever stops occurring, and `tsd` fails on that diagnostic, so the assertion is as strong as before (verified by making a suppressed line valid and watching `tsd` fail). What is given up is `expectError`'s check that the error is one of a few specific codes: a directive is satisfied by any error on the line, so keep each suppressed expression small enough that only the intended mistake can fail it, and give every directive a reason. The directive covers exactly one line, so a suppressed expression stays on one line.
166
167
 
167
168
  `Effect<T, E, Ctx>` carries three type parameters: value type, error union, and context type. `Ctx` flows through `AskState`, `CommandState`, `RetryState`, and all `effectPipe` overloads. `Ask<T, E, Ctx>` types the context callback parameter, and `runEffect<T, E, Ctx>` enforces that the supplied context matches. `Ctx` defaults to `unknown` so existing code without context typing is unaffected.
168
169
 
@@ -170,6 +171,8 @@ Error unions need explicit return annotations. A step written as `(i: User): Eff
170
171
 
171
172
  `Retry`'s declared error is `RetryExhaustedError<E>`, not `E`, and that is deliberate: the interpreter never lets an inner attempt's error escape unwrapped, so on exhaustion the Failure carries `{ retryExhausted, lastError, attempts }` and the inner `E` survives only as `lastError`. `RetryState`'s `effect` field is typed `Effect<T, any, Ctx>` for the same reason, since the wrapped tree's error type is consumed by the retry loop rather than exposed. The declaration previously claimed `E`, which made `result.error` lie for every flow containing a `Retry`; the tsd assertions in the Retry section pin the corrected shape. With a per-use `onExhausted` the declared error is the fallback's own error type instead, since exhaustion then never escapes; that overload comes first, and `onExhausted` is typed on the per-use options only, so a global `configureEffect` retry default cannot carry one.
172
173
 
174
+ Every state type declares `next` as a method signature (`next(result: R): Effect<...>`) rather than a function-typed property, and that is load-bearing under `strictFunctionTypes`. A property's parameter is checked contravariantly, so a `CommandState<never, ...>`, which is what `Command` infers for a function that only throws or returns `Promise.reject(...)`, could not be assigned to `Effect`, whose member is `CommandState<any, ...>`: `any` is not assignable to `never`. Method parameters are checked bivariantly, so the same node is accepted. Nothing is lost, since `Effect` already erases the result type to `any`; what changed is that the throwing Commands in `test/all.js` stopped being type errors under a strict editor. The `tsd` Command section pins a throwing thunk as assignable to `Effect`, directly, under `Retry`, and through `effectPipe`, so a tidy-up that turns the methods back into properties fails the gate.
175
+
173
176
  The recording and replay exports are declared alongside the primitives (`ReplayStep`, `ReplayOutcome`, `Resolver`, `TraceEntry`, `TraceLog`, `RecorderOptions`, `TraceMeta`, `ReplayOptions<Ctx>`). Known gap: `test/types.test-d.ts` has no coverage for any of the replay exports; their declarations are checked only by `tsc` over the JSDoc in `index.js`. Add `tsd` assertions there when touching those types.
174
177
 
175
178
  ### Observability
@@ -186,11 +189,11 @@ Two constraints in that file are load-bearing. Telemetry must never decide wheth
186
189
 
187
190
  `test/all.js` contains all runtime tests and uses a user-registration domain as the running example. Tests assert on the _returned data structures_ (Commands, Failures) rather than on side effects, which is the core usage pattern to preserve. `registerUserFlow` is the same shape as the README's Quick Start, on purpose: `ensureEmailIsAvailable` is a `Command` whose `next` returns `Success(input)`, so the tests and the documentation cannot drift into demonstrating different idioms.
188
191
 
189
- The `Recording and replay` suite builds flows whose Commands count their own invocations, which is how it asserts that a replay performs **zero** I/O, including a case that exercises every primitive at once. It also covers duplicate Command names, recorded-context `Ask`, retry replay and exhaustion, `Parallel` needing `strict: false`, replaying a trace directly and as a bare entries array, malformed traces rejecting with a `ReplayError`, divergence and exhausted traces, `onMissing`, redaction, `maxEntries` with `dropped`, recorder independence, and `timeTravel`'s narration, including a `Parallel` narration case, which proves `timeTravel` still forwards `strict` now that it no longer resolves the trace itself.
192
+ The `Recording and replay` suite builds flows whose Commands count their own invocations, which is how it asserts that a replay performs **zero** I/O, including a case that exercises every primitive at once. It also covers duplicate Command names, recorded-context `Ask`, retry replay and exhaustion, `Parallel` replaying by path in either finishing order, replaying a trace directly and as a bare entries array, malformed traces rejecting with a `ReplayError`, divergence and exhausted traces, `unreached` (a shortened flow, full consumption, a paradox mid-replay, legacy and bare-array traces, and absence under a `Resolver`), a path-less `Parallel` trace refused, `onMissing`, redaction, `maxEntries` with `dropped`, recorder independence, and `timeTravel`'s narration, including a `Parallel` narration case and a paradox that suppresses the unreached warning.
190
193
 
191
- The `Malformed flows` suite covers the guard: the offending step named, a missing return called what it usually is, a continuation returning a plain value, a flow passed without its input, a flow bug thrown rather than folded into a `Failure`, and a thrown Command still becoming one. The `Command identity` suite pins the resolution order: `meta.name` winning over a named thunk, an inline arrow becoming identifiable through `meta.name`, both fallbacks, non-object and non-string metas ignored, and `meta.name` surviving the rebuild that `chain` performs on every pipe step so it reaches both a trace and a strict replay. The `Recorded step timings` suite reaches `observeSteps` through its only public consumer rather than testing an internal directly: durations recorded for both successful and thrown Commands, rounding, timings not disturbing replay, and an observation failure leaving the outcome alone. The `examples/opentelemetry-example.js` suite injects a stub tracer through the `tracer` option, which is why that option exists, and covers the cyclic input, values never reaching spans, span naming and nesting, error status on a thrown Command, and composition with recording. The `configureEffect merging` suite pins the composition semantics behaviourally rather than by inspecting a merged object: wrapper nesting order, unwind order on a thrown Command, interceptors all running, `retry` merging where attempts come from one configuration and the delay from another, slots left at their defaults, no accumulation across calls, a bare call resetting everything, and the restore function putting back a previous wiring, returning to no hooks, and reverting `retry` defaults. The `examples/recording-example.js` suite runs the reference wiring itself, covering a trace reaching the sink on failure, successes staying out of it, concurrent runs keeping separate traces, redaction before the sink, and recording composing with a telemetry hook.
194
+ The `Malformed flows` suite covers the guard: the offending step named, a missing return called what it usually is, a continuation returning a plain value, a flow passed without its input, a flow bug thrown rather than folded into a `Failure`, and a thrown Command still becoming one. The `Command identity` suite pins the resolution order: `meta.name` winning over a named thunk, an inline arrow becoming identifiable through `meta.name`, both fallbacks, non-object and non-string metas ignored, and `meta.name` surviving the rebuild that `chain` performs on every pipe step so it reaches both a trace and a replay. The `Recorded step timings` suite reaches `observeSteps` through its only public consumer rather than testing an internal directly: durations recorded for both successful and thrown Commands, rounding, timings not disturbing replay, and an observation failure leaving the outcome alone. The `examples/opentelemetry-example.js` suite injects a stub tracer through the `tracer` option, which is why that option exists, and covers the cyclic input, values never reaching spans, span naming and nesting, error status on a thrown Command, and composition with recording. The `configureEffect merging` suite pins the composition semantics behaviourally rather than by inspecting a merged object: wrapper nesting order, unwind order on a thrown Command, interceptors all running, `retry` merging where attempts come from one configuration and the delay from another, slots left at their defaults, no accumulation across calls, a bare call resetting everything, and the restore function putting back a previous wiring, returning to no hooks, and reverting `retry` defaults. The `examples/recording-example.js` suite runs the reference wiring itself, covering a trace reaching the sink on failure, successes staying out of it, concurrent runs keeping separate traces, redaction before the sink, and recording composing with a telemetry hook.
192
195
 
193
- `test/types.test-d.ts` contains type-level tests using `tsd`, verifying that generic type parameters flow correctly through `effectPipe` and `runEffect`.
196
+ `test/types.test-d.ts` contains type-level tests using `tsd`, verifying that generic type parameters flow correctly through `effectPipe` and `runEffect`. Its negative cases are `@ts-expect-error` directives rather than `expectError` calls, for the reason given in the TypeScript section.
194
197
 
195
198
  ### Example code in the README
196
199
 
package/README.md CHANGED
@@ -99,7 +99,7 @@ assert.equal(step2.cmd.name, 'cmdSaveUser');
99
99
 
100
100
  ## How It Works
101
101
 
102
- A flow is a chain of pairs: an I/O call to make, plus a `next` function that receives its answer and returns whatever comes after, another Command, a Success, or a Failure. `runEffect` walks that chain in a loop.
102
+ A flow is a chain of pairs: an I/O call to make, plus a `next` function that receives its answer and returns whatever comes after, another Command, a Success, or a Failure. `runEffect` walks that chain in a loop.
103
103
 
104
104
  Recording and replay attach at the single point where a Command's function is called: recording wraps that call to write down each answer, and replay substitutes the recorded answer without making the call, which is why a replayed flow performs no I/O.
105
105
 
@@ -132,13 +132,28 @@ A production incident becomes a permanent test of the flow's logic, with no mock
132
132
 
133
133
  ```js
134
134
  it('prod incident 8f3a: a 100% promo produces a $0 charge', async () => {
135
- const result = await replayEffect(checkoutFlow(trace.initialInput), trace);
135
+ const { result } = await replayEffect(checkoutFlow(trace.initialInput), trace);
136
136
  assert.equal(result.type, 'Failure');
137
137
  assert.equal(result.error.code, 'invalid_amount');
138
138
  });
139
139
  ```
140
140
 
141
- The test verifies that the flow still takes the recorded path and handles the recorded outcomes the same way: a refactor that reorders or drops a step raises a `TimeParadox` naming the path it diverged at, and changed error handling fails the assertion.
141
+ The test verifies that the flow still takes the recorded path and handles the recorded outcomes the same way: a refactor that reorders or replaces a step raises a `TimeParadox` naming the path it diverged at, and changed error handling fails the assertion.
142
+
143
+ One divergence is quieter. A flow that stops issuing Commands before the recording ends (a fix that skips the charge, say) mismatches nothing, so no paradox fires and the replay can end in `Success` with recorded steps left over. `replayEffect` returns those steps as `unreached` beside the result, so a test can say which ones it expects to skip, and fail if the answer changes:
144
+
145
+ ```js
146
+ it('incident 8f3a fixed: a 100% promo checks out without a charge', async () => {
147
+ const { result, unreached } = await replayEffect(checkoutFlow(trace.initialInput), trace);
148
+ assert.equal(result.type, 'Success');
149
+ assert.deepEqual(
150
+ unreached.map((e) => e.command),
151
+ ['cmdChargeCard']
152
+ );
153
+ });
154
+ ```
155
+
156
+ For every other recording of the flow, `unreached` should be empty: a fix that skips a step it was not meant to skip then fails loudly instead of passing.
142
157
 
143
158
  **Nondeterminism belongs inside a Command.** A value that varies between runs (the current time, a random ID) is I/O as far as replay is concerned. Wrap it in a Command and it is recorded and replayed like any other response; a step that calls `Date.now()` directly computes a fresh value on every replay and silently diverges from the trace.
144
159
 
@@ -146,10 +161,12 @@ The test verifies that the flow still takes the recorded path and handles the re
146
161
 
147
162
  ```js
148
163
  const { result, trace } = await recordEffect(registerUserFlow, input);
149
- const replayed = await replayEffect(registerUserFlow(input), trace);
164
+ const { result: replayed } = await replayEffect(registerUserFlow(input), trace);
150
165
  assert.deepEqual(replayed, result); // fails if any step computed a fresh value
151
166
  ```
152
167
 
168
+ The comparison holds for a `Failure` as well: an error that crossed the trace is revived with the same message, name, cause, and custom properties, and compares deep-equal to the one the Command threw. The one exception is an error class of your own, which revives as a plain `Error` carrying that class's name; compare `error.name` and `error.message` there.
169
+
153
170
  **The trace format is yours.** `replayEffect` also takes a resolver function in place of a trace, so OpenTelemetry spans, a log pipeline, or a database table work as well as the JSON that `recorder` produces.
154
171
 
155
172
  ```js
@@ -540,7 +557,7 @@ Runs a flow for real while recording, returning `{ result, trace }`. Accepts `re
540
557
 
541
558
  #### `replayEffect(effect, traceOrResolver, options?)`
542
559
 
543
- Replays an effect tree, feeding recorded results to Commands instead of running them.
560
+ Replays an effect tree, feeding recorded results to Commands instead of running them. Returns `{ result, unreached }`: the flow's outcome, and the recorded entries the flow never asked for (empty when every step was reached). A flow that stops early mismatches nothing and raises no `TimeParadox`, so `unreached` is where that divergence shows. For a resolver only `{ result }` is returned, since only a trace knows what it holds.
544
561
 
545
562
  - `traceOrResolver`: a trace (or bare entries array) to replay directly, or a resolver function for traces stored in some other shape. A resolver returns `{ result }`, `{ error }`, or `undefined` if the step is unrecorded. A malformed trace rejects with a `ReplayError`.
546
563
  - `options.context`: context for `Ask`; pass the recorded context.
@@ -549,9 +566,11 @@ Replays an effect tree, feeding recorded results to Commands instead of running
549
566
  - `options.hooks` (default `false`): whether configured `onRun` / `onBeforeCommand` may fire. Off so a replay cannot reach a telemetry backend.
550
567
  - `options.onResolved(step, outcome)`: observe each replayed step.
551
568
 
569
+ A trace whose entries carry no `path` (written by hand, or recorded before paths existed) is matched positionally, which is exact for a sequential flow; a `Parallel` step in such a trace is refused with a `ReplayError`, since completion order cannot tell its branches apart.
570
+
552
571
  #### `timeTravel(flowFn, traceLog, options?)`
553
572
 
554
- Replays a trace and narrates each step with its recorded duration, warning when recorded steps were never reached or when the trace's `version` differs from `options.version`. Takes `options.context` to override the trace's, and `options.log` in place of `console.log`.
573
+ Replays a trace and narrates each step with its recorded duration, naming any recorded steps that were never reached and warning when the trace's `version` differs from `options.version`. Returns the flow's outcome; for the unreached entries as data, use `replayEffect`. Takes `options.context` to override the trace's, and `options.log` in place of `console.log`.
555
574
 
556
575
  ## Limitations
557
576
 
package/index.d.ts CHANGED
@@ -27,14 +27,19 @@ export type CommandState<R, T, E = unknown, Ctx = unknown> = {
27
27
  * `Parallel` no argument is passed at all.
28
28
  */
29
29
  cmd: (signal?: AbortSignal) => Promise<R> | R;
30
- next: (result: R) => Effect<T, E, Ctx>;
30
+ /**
31
+ * Method syntax, not a function-typed property, on every state's `next`: `strictFunctionTypes`
32
+ * checks a property's parameter contravariantly, which made a `CommandState<never, ...>` (a `cmd`
33
+ * that only throws) unassignable to `Effect`. Method parameters are bivariant, so it is accepted.
34
+ */
35
+ next(result: R): Effect<T, E, Ctx>;
31
36
  meta?: CommandMeta;
32
37
  initialInput?: unknown;
33
38
  };
34
39
 
35
40
  export type AskState<T, E = unknown, Ctx = unknown> = {
36
41
  type: 'Ask';
37
- next: (context: Ctx) => Effect<T, E, Ctx>;
42
+ next(context: Ctx): Effect<T, E, Ctx>;
38
43
  initialInput?: unknown;
39
44
  };
40
45
 
@@ -53,7 +58,7 @@ export type RetryState<T, E = unknown, Ctx = unknown> = {
53
58
  type: 'Retry';
54
59
  effect: Effect<T, any, Ctx>;
55
60
  options: RetryOptions & { onExhausted?: (error: RetryExhaustedError<any>) => Effect<T, any, Ctx> };
56
- next: (value: T) => Effect<T, E, Ctx>;
61
+ next(value: T): Effect<T, E, Ctx>;
57
62
  initialInput?: unknown;
58
63
  };
59
64
 
@@ -66,7 +71,7 @@ export type RetryExhaustedError<E = unknown> = {
66
71
  export type ParallelState<T extends readonly unknown[], R, E = unknown, Ctx = unknown> = {
67
72
  type: 'Parallel';
68
73
  effects: { [K in keyof T]: Effect<T[K], E, Ctx> };
69
- next: (values: [...T]) => Effect<R, E, Ctx>;
74
+ next(values: [...T]): Effect<R, E, Ctx>;
70
75
  initialInput?: unknown;
71
76
  };
72
77
 
@@ -406,29 +411,45 @@ export interface ReplayOptions<Ctx = unknown> {
406
411
  */
407
412
  onMissing?: 'throw' | 'execute';
408
413
  onResolved?: (step: ReplayStep, outcome: ReplayOutcome | undefined) => void;
409
- /**
410
- * Legacy matching mode, used only for a trace whose entries carry no `path`. `true` (default)
411
- * consumes entries positionally and raises a `TimeParadox` on divergence; `false` matches
412
- * per-Command FIFO queues, which used to be required for flows containing `Parallel`. Ignored
413
- * for a trace carrying paths, which is matched by path, and when a Resolver is supplied, since
414
- * that Resolver has already chosen how it matches.
415
- */
416
- strict?: boolean;
414
+ }
415
+
416
+ /**
417
+ * What a replay returns: the flow's own outcome and, for a trace, the recorded entries the flow
418
+ * never asked for. A flow that stops issuing Commands early mismatches nothing, so `result` can be a
419
+ * `Success` with steps left over; `unreached` is where that shows. It is absent for a Resolver,
420
+ * since only a trace knows what it holds.
421
+ */
422
+ export interface Replay<T, E = unknown> {
423
+ result: SuccessState<T> | FailureState<E>;
424
+ unreached: TraceEntry[];
417
425
  }
418
426
 
419
427
  /**
420
428
  * Pass a trace to replay it directly, or a Resolver when traces are stored in some other
421
429
  * shape. To observe a replay rather than change how it resolves, use `onResolved`.
422
430
  * A malformed trace rejects with a `ReplayError`.
431
+ *
432
+ * Three overloads: a trace yields `unreached`, a Resolver yields none, and a source only known
433
+ * as the union of the two (a wrapper forwarding whatever it was handed) yields it as optional.
423
434
  */
435
+ export declare function replayEffect<T, E = unknown, Ctx = unknown>(
436
+ effect: Effect<T, E, Ctx>,
437
+ trace: TraceLog | TraceEntry[],
438
+ options?: ReplayOptions<Ctx>
439
+ ): Promise<Replay<T, E>>;
440
+ export declare function replayEffect<T, E = unknown, Ctx = unknown>(
441
+ effect: Effect<T, E, Ctx>,
442
+ resolver: Resolver,
443
+ options?: ReplayOptions<Ctx>
444
+ ): Promise<Omit<Replay<T, E>, 'unreached'>>;
424
445
  export declare function replayEffect<T, E = unknown, Ctx = unknown>(
425
446
  effect: Effect<T, E, Ctx>,
426
447
  traceOrResolver: Resolver | TraceLog | TraceEntry[],
427
448
  options?: ReplayOptions<Ctx>
428
- ): Promise<SuccessState<T> | FailureState<E>>;
449
+ ): Promise<Omit<Replay<T, E>, 'unreached'> & { unreached?: TraceEntry[] }>;
429
450
 
430
451
  export declare function timeTravel<T, E = unknown, Ctx = unknown>(
431
452
  flowFn: (input: any) => Effect<T, E, Ctx>,
432
453
  traceLog: TraceLog,
433
- options?: { strict?: boolean; log?: (...args: any[]) => void; context?: Ctx; version?: string }
454
+ options?: { log?: (...args: any[]) => void; context?: Ctx; version?: string }
434
455
  ): Promise<SuccessState<T> | FailureState<E>>;
package/index.js CHANGED
@@ -89,7 +89,7 @@ const Command = (cmd, next = (/** @type {any} */ result) => Success(result), met
89
89
  });
90
90
 
91
91
  /**
92
- * The name a Command is known by: what a trace records, what strict replay matches on, and what a
92
+ * The name a Command is known by: what a trace records, what replay matches on, and what a
93
93
  * telemetry span is called.
94
94
  *
95
95
  * `meta.name` wins when it is a non-empty string. Otherwise the identity is the thunk's own `name`,
@@ -755,11 +755,17 @@ const serializeError = (e, withStack) => {
755
755
  const reviveError = (v) => {
756
756
  if (!v || typeof v !== 'object' || v.__error !== true) return v;
757
757
  const e = new Error(v.message);
758
- e.name = v.name;
758
+ // `name` and `cause` are non-enumerable on a native Error (`name` is inherited, `cause` is set by the
759
+ // constructor), so they are defined the same way here. Assigning them would make them own enumerable
760
+ // keys, and the revived error would then never compare deep-equal to the one the Command threw, which
761
+ // is what the record-then-replay determinism check relies on. Everything else was enumerable on the
762
+ // original, since `serializeError` read it through `Object.keys`, so plain assignment is faithful.
763
+ const hidden = { enumerable: false, configurable: true, writable: true };
764
+ Object.defineProperty(e, 'name', { ...hidden, value: v.name });
759
765
  for (const [k, val] of Object.entries(v)) {
760
- if (k !== '__error' && k !== 'name' && k !== 'message') {
761
- /** @type {any} */ (e)[k] = k === 'cause' ? reviveError(val) : val;
762
- }
766
+ if (k === '__error' || k === 'name' || k === 'message') continue;
767
+ if (k === 'cause') Object.defineProperty(e, 'cause', { ...hidden, value: reviveError(val) });
768
+ else /** @type {any} */ (e)[k] = val;
763
769
  }
764
770
  return e;
765
771
  };
@@ -908,36 +914,36 @@ const entryToOutcome = (entry) => ('error' in entry ? { error: reviveError(entry
908
914
  *
909
915
  * Path matching is used whenever every entry carries a `path`, which is every trace this
910
916
  * version records. A path is the Command's position in the Effect tree, so it is order-independent
911
- * and still paradox-detecting: it works for `Parallel` without `strict: false`, and it tells two
912
- * branches that call the same Command apart by where they are rather than by which finished first.
913
- * `strict` does not apply, since path matching is neither positional nor lenient.
917
+ * and still paradox-detecting: it works for `Parallel` as it is, and it tells two branches that
918
+ * call the same Command apart by where they are rather than by which finished first.
914
919
  *
915
- * The two older modes remain for traces recorded before paths existed. Strict matching consumes
916
- * entries in recorded order and verifies the flow asks for the Command the trace expects. Name
917
- * matching resolves per-Command FIFO queues, which was the only option for `Parallel` and is the
918
- * mode that could pair a branch with a sibling's result when both called the same Command.
920
+ * A trace whose entries carry no path (recorded before paths existed, or written by hand) is
921
+ * matched positionally: entries are consumed in recorded order and each must name the Command the
922
+ * flow asks for. That is exact for sequential flows and cannot tell `Parallel` branches apart, which
923
+ * is what paths were added to fix.
919
924
  *
920
925
  * @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
921
926
  * @param {Object} [options]
922
- * @param {boolean} [options.strict] - Positional matching with paradox detection (default `true`).
923
- * Ignored for a trace carrying paths.
927
+ * @param {(entry: TraceEntry) => void} [options.onEntry] - Observes each entry as it is handed to a step,
928
+ * which is how `replayEffect` learns which recorded entries the flow never asked for.
924
929
  * @returns {Resolver}
925
930
  */
926
931
  const fromTrace = (traceLog, options = {}) => {
927
- const { strict = true } = options;
932
+ const { onEntry } = options;
928
933
  const entries = Array.isArray(traceLog) ? traceLog : traceLog?.trace;
929
934
  if (!Array.isArray(entries)) throw replayError('Trace has no `trace` array.');
935
+ const resolveEntry = (/** @type {TraceEntry} */ entry) => {
936
+ if (onEntry) onEntry(entry);
937
+ return entryToOutcome(entry);
938
+ };
930
939
 
931
- // Paths win when the trace has them, whatever `strict` says: a caller who passed `strict: false`
932
- // to work around Parallel should get the correct matching once their traces carry paths, rather
933
- // than keeping the mode that option existed to work around.
934
940
  if (entries.length > 0 && entries.every((e) => typeof e.path === 'string')) {
935
941
  const byPath = new Map(entries.map((e) => [e.path, e]));
936
942
  // Paths are derived from tree position, so they are unique by construction. A collision means
937
943
  // either a hand-built trace or a bug in path derivation, and silently keeping the last entry
938
944
  // would reintroduce exactly the wrong-result-per-branch failure paths exist to prevent.
939
945
  if (byPath.size !== entries.length) {
940
- throw replayError('Trace has duplicate step paths, so it cannot be matched by path.');
946
+ throw replayError('Trace has duplicate step paths.');
941
947
  }
942
948
  return (step) => {
943
949
  const entry = byPath.get(step.path);
@@ -948,29 +954,24 @@ const fromTrace = (traceLog, options = {}) => {
948
954
  });
949
955
  }
950
956
  if (entry.command !== step.name) throw timeParadox(step, entry.command);
951
- return entryToOutcome(entry);
952
- };
953
- }
954
-
955
- if (strict) {
956
- return (step) => {
957
- const entry = entries[step.index];
958
- if (!entry) throw replayError(`Trace exhausted: no entry #${step.index} for '${step.name}'.`);
959
- if (entry.command !== step.name) throw timeParadox(step, entry.command);
960
- return entryToOutcome(entry);
957
+ return resolveEntry(entry);
961
958
  };
962
959
  }
963
960
 
964
- /** @type {Map<string, TraceEntry[]>} */
965
- const queues = new Map();
966
- for (const entry of entries) {
967
- if (!queues.has(entry.command)) queues.set(entry.command, []);
968
- /** @type {TraceEntry[]} */ (queues.get(entry.command)).push(entry);
969
- }
970
961
  return (step) => {
971
- const queue = queues.get(step.name);
972
- if (!queue || queue.length === 0) throw replayError(`No recorded result left for '${step.name}'.`);
973
- return entryToOutcome(/** @type {TraceEntry} */ (queue.shift()));
962
+ // Positional matching pairs steps by completion order, which is exactly what makes Parallel
963
+ // branches indistinguishable. Refusing is better than a result that is right only when the
964
+ // replay happens to finish in production's order.
965
+ if (typeof step.path === 'string' && /p\d+\//.test(step.path)) {
966
+ throw replayError(
967
+ `Trace carries no paths, so '${step.name}' inside a Parallel cannot be matched positionally.`,
968
+ { command: step.name, path: step.path }
969
+ );
970
+ }
971
+ const entry = entries[step.index];
972
+ if (!entry) throw replayError(`Trace exhausted: no entry #${step.index} for '${step.name}'.`);
973
+ if (entry.command !== step.name) throw timeParadox(step, entry.command);
974
+ return resolveEntry(entry);
974
975
  };
975
976
  };
976
977
 
@@ -1028,10 +1029,19 @@ const zeroRetryDelays = (eff) => {
1028
1029
  * `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
1029
1030
  * `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
1030
1031
  * @property {(step: ReplayStep, outcome: ReplayOutcome | undefined) => void} [onResolved] - Observes each step.
1031
- * @property {boolean} [strict] - Matching mode used when a trace is passed instead of a Resolver.
1032
- * `true` (default) consumes entries positionally and raises a `TimeParadox` on divergence;
1033
- * `false` matches per-Command FIFO queues, which is required for flows containing `Parallel`.
1034
- * Ignored when a Resolver is supplied, since that Resolver has already chosen how it matches.
1032
+ */
1033
+
1034
+ /**
1035
+ * What a replay returns: the flow's own outcome, and, when a trace was supplied, the recorded
1036
+ * entries the flow never asked for. `unreached` is absent for a Resolver, since only a trace
1037
+ * knows what it holds.
1038
+ *
1039
+ * A flow that stops issuing Commands before its recording ends mismatches nothing, so no
1040
+ * `TimeParadox` fires and `result` can be a `Success` with recorded steps left over. That is
1041
+ * sometimes the point of a fix and sometimes a fix that quietly dropped a step; `unreached` is
1042
+ * the only place the difference shows, which is why it travels with the result rather than
1043
+ * behind an option.
1044
+ * @typedef {{ result: SuccessState | FailureState, unreached?: TraceEntry[] }} Replay
1035
1045
  */
1036
1046
 
1037
1047
  /**
@@ -1051,15 +1061,20 @@ const zeroRetryDelays = (eff) => {
1051
1061
  * Resolver supplying each Command's recorded outcome (`undefined` if it has none). Write a Resolver when
1052
1062
  * traces are stored in some other shape; to observe a replay without one, use `onResolved`.
1053
1063
  * @param {ReplayOptions} [options]
1054
- * @returns {Promise<SuccessState | FailureState>}
1064
+ * @returns {Promise<Replay>} `{ result, unreached }` for a trace, `{ result }` for a Resolver
1055
1065
  */
1056
1066
  // `async` so a malformed trace arrives as a rejection rather than a synchronous throw:
1057
1067
  // the function otherwise returns a promise, and callers should not have to handle both.
1058
1068
  const replayEffect = async (effect, traceOrResolver, options = {}) => {
1059
- const { context = {}, fastRetry = true, hooks = false, onMissing = 'throw', onResolved, strict = true } = options;
1069
+ const { context = {}, fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
1060
1070
  // A trace is data and a Resolver is a function, so nothing else is needed to tell them
1061
1071
  // apart, including the bare entries array that `fromTrace` also accepts.
1062
- const resolve = typeof traceOrResolver === 'function' ? traceOrResolver : fromTrace(traceOrResolver, { strict });
1072
+ const fromResolver = typeof traceOrResolver === 'function';
1073
+ /** @type {Set<TraceEntry>} */
1074
+ const reached = new Set();
1075
+ const resolve = fromResolver
1076
+ ? traceOrResolver
1077
+ : fromTrace(traceOrResolver, { onEntry: (entry) => void reached.add(entry) });
1063
1078
  let index = 0;
1064
1079
 
1065
1080
  /** @type {StepRunner} */
@@ -1088,25 +1103,29 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1088
1103
  callConfig.onBeforeCommand = async () => {};
1089
1104
  }
1090
1105
 
1091
- return runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
1106
+ const result = await runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
1107
+ if (fromResolver) return { result };
1108
+ // `fromTrace` has already validated the shape, so the entries are here in one form or the other.
1109
+ const entries = Array.isArray(traceOrResolver) ? traceOrResolver : traceOrResolver.trace;
1110
+ return { result, unreached: entries.filter((entry) => !reached.has(entry)) };
1092
1111
  };
1093
1112
 
1094
1113
  /**
1095
1114
  * Replays a reference-format trace and narrates each step. Rebuilds the flow from the
1096
- * recorded input, reports the outcome, and warns when recorded steps were never
1115
+ * recorded input, reports the outcome, and names any recorded steps that were never
1097
1116
  * reached, which means the current code issued fewer Commands than production did.
1098
1117
  *
1099
1118
  * @param {(input: any) => Effect} flowFn - The same flow function that produced the trace
1100
1119
  * @param {TraceLog} traceLog - A trace from `recordEffect` or a `recorder`
1101
1120
  * @param {Object} [options]
1102
- * @param {boolean} [options.strict] - Positional matching with paradox detection; set `false` for `Parallel`
1103
1121
  * @param {(...args: any[]) => void} [options.log] - Defaults to `console.log`
1104
1122
  * @param {any} [options.context] - Overrides the context stored on the trace
1105
1123
  * @param {string} [options.version] - Current build id; warns when it differs from the trace's
1106
- * @returns {Promise<SuccessState | FailureState>}
1124
+ * @returns {Promise<SuccessState | FailureState>} The flow's outcome. Narration is the point of this function;
1125
+ * a caller who wants the unreached entries as data uses `replayEffect`.
1107
1126
  */
1108
1127
  const timeTravel = async (flowFn, traceLog, options = {}) => {
1109
- const { strict = true, log = console.log, context, version } = options;
1128
+ const { log = console.log, context, version } = options;
1110
1129
  const { initialInput, trace, flowName, version: traceVersion } = traceLog;
1111
1130
  // `message` and `stack` are non-enumerable on Error, so JSON.stringify alone would
1112
1131
  // drop the most useful line of the report.
@@ -1124,22 +1143,19 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
1124
1143
 
1125
1144
  // Narration goes through `onResolved` rather than a wrapped Resolver: observing each
1126
1145
  // step is all this needs, and `replayEffect` already resolves the trace itself.
1127
- let consumed = 0;
1128
1146
  // A path maps a replayed step back to its recorded entry whatever order branches finished in, so
1129
- // timings are narrated for Parallel too. Positional lookup is the fallback for a legacy trace with
1130
- // no paths, and only under strict matching, since per-Command queues carry no position.
1147
+ // timings are narrated for Parallel too. Positional lookup is the fallback for a trace with no paths,
1148
+ // which is how such a trace is matched anyway.
1131
1149
  const byPath = new Map(trace.filter((e) => typeof e.path === 'string').map((e) => [e.path, e]));
1132
1150
  const timing = (/** @type {ReplayStep} */ step) => {
1133
- const recorded = byPath.get(step.path) ?? (strict ? trace[step.index] : undefined);
1151
+ const recorded = byPath.get(step.path) ?? trace[step.index];
1134
1152
  return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
1135
1153
  };
1136
- const result = await replayEffect(flowFn(initialInput), traceLog, {
1137
- strict,
1154
+ const replay = await replayEffect(flowFn(initialInput), traceLog, {
1138
1155
  context: context !== undefined ? context : traceLog.context || {},
1139
1156
  onResolved: (step, outcome) => {
1140
1157
  // Resolving from a trace either answers or throws, so this is never undefined.
1141
1158
  if (outcome === undefined) return;
1142
- consumed++;
1143
1159
  log(
1144
1160
  'error' in outcome
1145
1161
  ? `Step ${step.index + 1}: ${step.name} threw${timing(step)} ${format(outcome.error)}`
@@ -1147,11 +1163,21 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
1147
1163
  );
1148
1164
  }
1149
1165
  });
1166
+ const { result, unreached = [] } = replay;
1150
1167
 
1151
1168
  log(`Replay finished with state: ${result.type}`);
1152
1169
  log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
1153
- if (consumed < trace.length) {
1154
- log(`Warning: ${trace.length - consumed} recorded ${stepsText} were never reached. The flow diverged.`);
1170
+ // After a replay error the flow never got past the divergence, so the entries behind it are not
1171
+ // news; the error already names where the timeline split. Only a flow that ended on its own terms
1172
+ // with steps left over is worth a warning.
1173
+ const haltedByReplay = result.type === 'Failure' && /^(TimeParadox|ReplayError)$/.test(result.error?.name);
1174
+ if (unreached.length > 0 && !haltedByReplay) {
1175
+ // Named, not just counted: the step a fix stopped issuing is usually the one under suspicion.
1176
+ const names = unreached.map((e) =>
1177
+ typeof e.path === 'string' ? `${e.command} (path '${e.path}')` : e.command
1178
+ );
1179
+ const count = unreached.length === 1 ? '1 recorded step was' : `${unreached.length} recorded steps were`;
1180
+ log(`Warning: ${count} never reached: ${names.join(', ')}. The flow diverged.`);
1155
1181
  }
1156
1182
  return result;
1157
1183
  };
@@ -2,11 +2,12 @@
2
2
  "compilerOptions": {
3
3
  "allowJs": true,
4
4
  "checkJs": true,
5
+ "strict": true,
5
6
  "noEmit": true,
6
7
  "target": "es2022",
7
8
  "module": "nodenext",
8
9
  "moduleResolution": "nodenext",
9
10
  "lib": ["es2022", "dom"]
10
11
  },
11
- "files": ["index.js"]
12
+ "include": ["index.js", "test/all.js", "test/types.test-d.ts", "examples/*.js"]
12
13
  }