pure-effect 0.12.0 → 0.13.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/index.js CHANGED
@@ -221,8 +221,22 @@ const asEffect = (value, source) => {
221
221
  * @returns {Effect}
222
222
  */
223
223
  const chain = (effect, fn, initialInput) => {
224
+ // The outermost pipeline wins. A step can return a sub-pipeline whose nodes already carry that
225
+ // sub-pipeline's start, and an outer chain reaches every one of them through the wrapped `next`
226
+ // continuations, so overwriting here is what makes the whole tree, a Failure from any depth, and
227
+ // the root a hook-based recorder reads carry the input of the flow that was actually called. The
228
+ // previous rule, stamp only when unset, let a Retry- or Parallel-headed sub-pipeline leave its own
229
+ // input on the root, and a trace recorded from it rebuilt the flow from the wrong value. A Success
230
+ // is never stamped: nothing reads the input off a terminal value, and leaving it bare is what lets
231
+ // `assert.deepEqual(result, Success(v))` hold whatever shape the flow had.
224
232
  const withII = (/** @type {Effect} */ e) =>
225
- initialInput !== undefined && e.initialInput === undefined ? { ...e, initialInput } : e;
233
+ initialInput !== undefined && e.type !== 'Success' ? { ...e, initialInput } : e;
234
+
235
+ // A continuation that returned nothing has to be caught before `effect.type` is read. Reading it off
236
+ // `undefined` throws a bare TypeError, and since every continuation runs inside the interpreter's try
237
+ // block, that TypeError became a domain Failure: the flow bug took the business-error branch and no
238
+ // step was named. The `default` arm below never sees it, because the switch itself is what throws.
239
+ if (effect == null) return asEffect(effect, 'A continuation');
226
240
 
227
241
  switch (effect.type) {
228
242
  case 'Success':
@@ -260,7 +274,14 @@ const chain = (effect, fn, initialInput) => {
260
274
  const effectPipe = (...fns) => {
261
275
  return (start) => {
262
276
  const chainWithII = (/** @type {Effect} */ eff, /** @type {(v: any) => Effect} */ fn) => chain(eff, fn, start);
263
- return fns.reduce(chainWithII, /** @type {Effect} */ (Success(start)));
277
+ const tree = fns.reduce(chainWithII, /** @type {Effect} */ (Success(start)));
278
+ // One identity pass over the finished tree. `chain` re-stamps a sub-pipeline's nodes only as
279
+ // it wraps the continuations that lead to them, and the last step has no later step to do
280
+ // that wrapping, so a sub-pipeline returned by the last step would keep its own input on
281
+ // everything past its head. The pass wraps those continuations too, so every node reachable
282
+ // from here, a Failure at any depth included, carries this pipeline's `start`. `Success` is the
283
+ // identity step: it can never return a non-Effect, so no message is ever attributed to it.
284
+ return chain(tree, Success, start);
264
285
  };
265
286
  };
266
287
 
@@ -282,12 +303,7 @@ const defaultRunWrapper = async (effect, op, flowName) => await op();
282
303
  /** @type CommandInterceptor */
283
304
  const defaultCommandInterceptor = async (command, context) => {};
284
305
 
285
- let stepRunner = defaultStepRunner;
286
- let runWrapper = defaultRunWrapper;
287
- let commandInterceptor = defaultCommandInterceptor;
288
-
289
306
  const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
290
- let retryDefaults = { ...defaultRetryOptions };
291
307
 
292
308
  /**
293
309
  * @typedef {Object} EffectConfiguration
@@ -298,63 +314,77 @@ let retryDefaults = { ...defaultRetryOptions };
298
314
  */
299
315
 
300
316
  /**
301
- * Configures the global behavior of the Effect runner, including the command interceptor and telemetry.
317
+ * A per-call configuration: an `EffectConfiguration` plus `inherit`. With `inherit: true` (the default)
318
+ * the call's hooks are added to the wiring `configureEffect` installed, by the merge `configureEffect`
319
+ * applies to several configurations: global outermost, interceptors in order, `retry` merging with the
320
+ * call winning. With `inherit: false` the global wiring is not consulted at all, so any slot the call
321
+ * leaves unset falls back to the library default. A per-call hook cannot replace a single global slot on
322
+ * its own; that was the previous default, and it is how a per-call recorder silently switched off an
323
+ * application's tracing.
302
324
  *
303
- * Several configurations can be passed and are merged, which is how independent concerns share the
304
- * one slot each hook has:
325
+ * @typedef {EffectConfiguration & { inherit?: boolean }} CallConfiguration
326
+ */
327
+
328
+ /** @type {EffectConfiguration[]} */
329
+ let layers = [];
330
+
331
+ /**
332
+ * The installed layers merged into one configuration, empty when nothing is configured. This is the
333
+ * only derived form of `layers`: `runEffect` reads it and merges the call's configuration over it,
334
+ * and a slot no layer defines is simply absent, so the library default is chosen at the one place the
335
+ * hook is used rather than held in a second set of variables kept in step by hand.
336
+ * @type {EffectConfiguration}
337
+ */
338
+ let globalConfig = {};
339
+
340
+ /** Recomputes the effective wiring from the installed layers, earlier layers outermost. */
341
+ const applyLayers = () => {
342
+ globalConfig = chainHooks(...layers);
343
+ };
344
+
345
+ /**
346
+ * Adds a configuration to the global wiring of the Effect runner: telemetry, the command interceptor,
347
+ * and retry defaults.
305
348
  *
306
- * configureEffect(telemetryHooks(), recordingHooks({ sink }))
349
+ * Each call adds one layer on top of those already installed and returns a function that removes that
350
+ * layer, wherever it sits by then. Layers merge the way several configurations passed to one call do:
351
+ * `onStep` and `onRun` are wrappers, so they nest with the earliest layer outermost and the latest
352
+ * closest to the Command; `onBeforeCommand` interceptors all run, in the order installed; and `retry`
353
+ * merges with later layers winning. So these are the same:
307
354
  *
308
- * `onStep` and `onRun` are wrappers, so they nest with the first configuration outermost and the last
309
- * closest to the Command. `onBeforeCommand` interceptors all run, in the order given, and `retry`
310
- * merges with later configurations winning.
355
+ * configureEffect(telemetryHooks(), recordingHooks({ sink }));
356
+ * configureEffect(telemetryHooks()); configureEffect(recordingHooks({ sink }));
311
357
  *
312
- * Merging happens per call and does not accumulate across calls: a later `configureEffect` still
313
- * replaces the previous wiring entirely, and calling it with nothing resets every slot to its default.
358
+ * Calling it with no arguments at all removes every layer. A call whose arguments are all `undefined`
359
+ * is a conditional install that installed nothing: it adds no layer and removes none.
314
360
  *
315
- * Returns a function that puts back whatever was installed when this call was made, so a caller can
316
- * install hooks without owning the wiring forever. That is what makes this usable from a test, a
317
- * request-scoped experiment, or a library that wraps this one: without it, installing anything means
318
- * clobbering the host's telemetry permanently, since there is no way to read the current wiring.
319
- * Restoring undoes this call only while its wiring is still in effect. If a later `configureEffect` has
320
- * run since, restoring does nothing rather than discarding that newer wiring, so two callers installing
321
- * and releasing in interleaved order cannot clobber each other.
361
+ * Layers replaced the previous one-slot rule, under which a later call displaced the earlier wiring and
362
+ * the returned function restored a snapshot, guarded so that interleaved installs could not clobber
363
+ * each other. Removing a layer needs no guard: A installs, B installs, A removes, and B is still there.
364
+ * It also means a library can install its own hooks without erasing its host's, which under the old rule
365
+ * was possible only if the host passed the library's configuration into its own call.
322
366
  *
323
- * @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
324
- * @returns {() => void} Restores the wiring that was in place before this call
367
+ * @param {...(EffectConfiguration | undefined)} configs - Configurations merged into one layer, outermost first
368
+ * @returns {() => void} Removes the layer this call added; a second call does nothing
325
369
  */
326
370
  const configureEffect = (...configs) => {
327
- const previousStepRunner = stepRunner;
328
- const previousRunWrapper = runWrapper;
329
- const previousCommandInterceptor = commandInterceptor;
330
- const previousRetryDefaults = retryDefaults;
331
-
332
- const options = chainHooks(...configs);
333
- stepRunner = options.onStep ? options.onStep : defaultStepRunner;
334
- runWrapper = options.onRun ? options.onRun : defaultRunWrapper;
335
- commandInterceptor = options.onBeforeCommand ? options.onBeforeCommand : defaultCommandInterceptor;
336
- retryDefaults = options.retry ? { ...defaultRetryOptions, ...options.retry } : defaultRetryOptions;
337
-
338
- const installedStepRunner = stepRunner;
339
- const installedRunWrapper = runWrapper;
340
- const installedCommandInterceptor = commandInterceptor;
341
- const installedRetryDefaults = retryDefaults;
342
-
371
+ // Only a call with no arguments at all is a reset. A call whose arguments are all absent, such as
372
+ // `configureEffect(flag ? hooks : undefined)`, is a conditional install that happened not to install
373
+ // anything, so it adds no layer and must not remove anyone else's.
374
+ if (configs.length === 0) {
375
+ layers = [];
376
+ applyLayers();
377
+ return () => {};
378
+ }
379
+ const present = configs.filter(Boolean);
380
+ if (present.length === 0) return () => {};
381
+ const layer = chainHooks(...present);
382
+ layers = [...layers, layer];
383
+ applyLayers();
343
384
  return () => {
344
- // Only undo an install that is still in effect. Without this check, two callers that install
345
- // and release in interleaved order silently discard each other's hooks: A installs, B installs,
346
- // A restores, and B's wiring is gone though B never released it.
347
- const unchanged =
348
- stepRunner === installedStepRunner &&
349
- runWrapper === installedRunWrapper &&
350
- commandInterceptor === installedCommandInterceptor &&
351
- retryDefaults === installedRetryDefaults;
352
- if (!unchanged) return;
353
-
354
- stepRunner = previousStepRunner;
355
- runWrapper = previousRunWrapper;
356
- commandInterceptor = previousCommandInterceptor;
357
- retryDefaults = previousRetryDefaults;
385
+ if (!layers.includes(layer)) return;
386
+ layers = layers.filter((l) => l !== layer);
387
+ applyLayers();
358
388
  };
359
389
  };
360
390
 
@@ -501,20 +531,32 @@ const runEffect =
501
531
  * Iterates through the Effect tree, executing Commands and handling async flow.
502
532
  * Ask effects are resolved synchronously with the context object.
503
533
  *
504
- * Per-call config takes precedence over global configureEffect defaults.
505
- * onRun fires exactly once per runEffect call. Retry attempts run inside that
534
+ * Per-call config is merged over the global configureEffect wiring, or over nothing under
535
+ * `inherit: false`. onRun fires exactly once per runEffect call. Retry attempts run inside that
506
536
  * single span rather than spawning their own, keeping telemetry non-duplicated.
507
537
  *
508
538
  * @param {Effect} effect - The Effect tree returned by a pipeline
509
539
  * @param {any} [context] - Optional context object. Passed to Ask continuations and the Command Interceptor.
510
- * @param {EffectConfiguration} [callConfig] - Per-call overrides; merged over global configureEffect defaults.
540
+ * @param {CallConfiguration} [callConfig] - Per-call configuration, added to the wiring `configureEffect`
541
+ * installed unless `inherit: false`, which ignores that wiring for this run.
511
542
  * @returns {Promise<SuccessState | FailureState>}
512
543
  */
513
544
  async function runEffect(effect, context = {}, callConfig = {}) {
514
- const localStepRunner = callConfig.onStep ? callConfig.onStep : stepRunner;
515
- const localRunWrapper = callConfig.onRun ? callConfig.onRun : runWrapper;
516
- const localCommandInterceptor = callConfig.onBeforeCommand ? callConfig.onBeforeCommand : commandInterceptor;
517
- const localRetryDefaults = callConfig.retry ? { ...retryDefaults, ...callConfig.retry } : retryDefaults;
545
+ const { inherit = true, ...local } = callConfig;
546
+ // A value that is not a boolean, such as a string left over from an older API, must not be
547
+ // coerced: `'false'` inheriting everything is exactly the silent behaviour this option removes.
548
+ if (typeof inherit !== 'boolean') {
549
+ throw new TypeError(`callConfig.inherit must be true or false, got ${JSON.stringify(inherit)}.`);
550
+ }
551
+ // The call's configuration is merged over the installed wiring, or over nothing when the call
552
+ // inherits nothing, by the same merge `configureEffect` applies to several configurations. Most
553
+ // calls carry no configuration of their own, and those use the installed wiring as it is.
554
+ const base = inherit ? globalConfig : {};
555
+ const resolved = Object.keys(local).length ? chainHooks(base, local) : base;
556
+ const localStepRunner = resolved.onStep || defaultStepRunner;
557
+ const localRunWrapper = resolved.onRun || defaultRunWrapper;
558
+ const localCommandInterceptor = resolved.onBeforeCommand || defaultCommandInterceptor;
559
+ const localRetryDefaults = { ...defaultRetryOptions, ...resolved.retry };
518
560
 
519
561
  /**
520
562
  * @param {Effect} eff
@@ -657,7 +699,17 @@ const runEffect =
657
699
  throw effectTypeError(eff, 'The flow');
658
700
  }
659
701
 
660
- return localRunWrapper(effect, () => execute(effect), context?.flowName || '');
702
+ // Every Failure leaves through here, including one a Parallel branch or a Retry fallback handed
703
+ // back. `chain` stamps only what passes through the continuations it wraps, and those subtrees
704
+ // are executed directly rather than reached through a continuation, so their Failures arrive
705
+ // carrying the subtree's own input or none. Restamping the outcome with the root's input is
706
+ // what makes "a Failure carries the input of the flow that was called" true at any depth.
707
+ const rootInput = effect?.initialInput;
708
+ const run = async () => {
709
+ const result = await execute(effect);
710
+ return result.type === 'Failure' && rootInput !== undefined ? Failure(result.error, rootInput) : result;
711
+ };
712
+ return localRunWrapper(effect, run, context?.flowName || '');
661
713
  };
662
714
 
663
715
  /**
@@ -892,6 +944,8 @@ const recorder = (options = {}) => {
892
944
  const recordEffect = async (flowFn, initialInput, options = {}) => {
893
945
  const { context = {}, version, ...recorderOptions } = options;
894
946
  const rec = recorder(recorderOptions);
947
+ // The recorder is added to the global wiring, so recording inside an instrumented application keeps
948
+ // its spans.
895
949
  const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
896
950
  return {
897
951
  result,
@@ -1023,8 +1077,11 @@ const zeroRetryDelays = (eff) => {
1023
1077
  * @typedef {Object} ReplayOptions
1024
1078
  * @property {any} [context] - Context for `Ask`; pass the recorded context to reproduce a run faithfully.
1025
1079
  * @property {boolean} [fastRetry] - Strips Retry delays so a replay does not wait out production backoff.
1026
- * @property {boolean} [hooks] - Lets configured `onRun` and `onBeforeCommand` fire. Off by default, so a
1027
- * replay cannot reach a telemetry backend or a guardrail that performs I/O.
1080
+ * @property {boolean} [hooks] - Runs the replay inside the hooks `configureEffect` installed, with the
1081
+ * resolver innermost, so a configured `onStep` observes each replayed step and `onRun` and
1082
+ * `onBeforeCommand` fire. Off by default, which ignores the global hooks, so a replay cannot
1083
+ * reach a telemetry backend or a guardrail that performs I/O. Global `retry` defaults apply
1084
+ * either way, since a replay has to make the attempts production made.
1028
1085
  * @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
1029
1086
  * `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
1030
1087
  * `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
@@ -1096,12 +1153,14 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1096
1153
  return outcome.result;
1097
1154
  };
1098
1155
 
1099
- /** @type {EffectConfiguration} */
1100
- const callConfig = { onStep };
1101
- if (!hooks) {
1102
- callConfig.onRun = async (effect, op) => await op();
1103
- callConfig.onBeforeCommand = async () => {};
1104
- }
1156
+ // Off, a replay ignores the global hooks, so it can reach neither a telemetry backend nor a guardrail
1157
+ // that performs I/O. On, it runs inside them with the resolver innermost, so a configured onStep
1158
+ // observes each replayed step and the Command still never executes. The global retry defaults are
1159
+ // kept either way: `inherit: false` would drop them with the hooks, and a Retry that production ran
1160
+ // under a configured `attempts` would then exhaust early on replay and report a Failure production
1161
+ // never saw. Retry shape is part of what a replay reproduces; the hooks are not.
1162
+ /** @type {CallConfiguration} */
1163
+ const callConfig = { onStep, inherit: hooks, retry: globalConfig.retry };
1105
1164
 
1106
1165
  const result = await runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
1107
1166
  if (fromResolver) return { result };
package/package.json CHANGED
@@ -1,19 +1,47 @@
1
1
  {
2
2
  "name": "pure-effect",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Records what your business logic did in production and replays it anywhere: time-travel debugging for JavaScript and TypeScript, with zero dependencies. Business logic is plain data you can test without mocks.",
5
+ "keywords": [
6
+ "effect",
7
+ "effect-system",
8
+ "functional-core",
9
+ "imperative-shell",
10
+ "time-travel",
11
+ "replay",
12
+ "tracing",
13
+ "testing",
14
+ "zero-dependencies",
15
+ "typescript"
16
+ ],
5
17
  "type": "module",
6
18
  "exports": "./index.js",
7
19
  "types": "./index.d.ts",
20
+ "files": [
21
+ "index.js",
22
+ "index.d.ts",
23
+ "CHANGELOG.md"
24
+ ],
25
+ "sideEffects": false,
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
8
29
  "scripts": {
9
30
  "test": "mocha && tsd && tsc -p jsconfig.json",
10
- "format": "prettier --write index.js index.d.ts examples/*.js test/all.js test/types.test-d.ts README.md CLAUDE.md"
31
+ "format": "prettier --write index.js index.d.ts examples/*.js test/all.js test/types.test-d.ts README.md CLAUDE.md CHANGELOG.md CONTRIBUTING.md"
11
32
  },
12
33
  "tsd": {
13
34
  "directory": "test"
14
35
  },
15
36
  "author": "Aycan Gulez",
16
37
  "homepage": "https://pure-effect.org",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/aycangulez/pure-effect.git"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/aycangulez/pure-effect/issues"
44
+ },
17
45
  "license": "MIT",
18
46
  "devDependencies": {
19
47
  "@opentelemetry/api": "^1.9.1",
package/.prettierrc DELETED
@@ -1,12 +0,0 @@
1
- {
2
- "tabWidth": 4,
3
- "useTabs": false,
4
- "singleQuote": true,
5
- "semi": true,
6
- "trailingComma": "none",
7
- "jsxSingleQuote": true,
8
- "printWidth": 120,
9
- "arrowParens": "always",
10
- "endOfLine": "lf",
11
- "proseWrap": "never"
12
- }
package/CLAUDE.md DELETED
@@ -1,205 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## Commands
6
-
7
- ```bash
8
- npm test # run all tests
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, 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
13
- npx esbuild index.js --minify --format=esm | gzip -9 | wc -c # the size the README claims
14
- ```
15
-
16
- No build or lint step: the library ships as plain ES modules with no transpilation. Formatting is Prettier, configured in `.prettierrc`; match it when editing.
17
-
18
- The README's badge and feature bullet both state a minified-and-gzipped ceiling, currently around 4 KB. Re-measure with the command above after adding anything to `index.js`, because the badge is a static `img.shields.io/badge/` URL with the number written into it rather than a live query, so it cannot correct itself.
19
-
20
- **Markdown prose has no hard line breaks.** Write each paragraph and each list item as one line and let the editor soft-wrap; never wrap prose at a column. Hard breaks make later edits reflow whole paragraphs, turning a one-word change into a multi-line diff. `proseWrap: never` enforces this, so `npm run format` unwraps any prose that arrives wrapped. Even so, write it unwrapped in the first place rather than relying on the sweep, since a wrapped paragraph reads as an intended shape until something reflows it. Fenced code blocks are untouched by the setting, and table rows stay one per line; only prose joins.
21
-
22
- **No `thunk` in the README.** Reader-facing prose says the Command's function, or `cmd`. The word remains fine in this file, in JSDoc, and in code comments, where precision for maintainers matters more than approachability, but a README reader should never need to know it. `grep -n "thunk" README.md` checks a change before it lands.
23
-
24
- **No em dashes.** The `—` character does not appear anywhere in this repository: not in `README.md`, not in this file, not in JSDoc, code comments, test names, or assertion messages. Reach for the punctuation that carries the actual relationship instead of a dash that blurs it. A colon when the second half explains or names the first. Parentheses for an aside the sentence could survive without. A semicolon between two linked independent clauses. A comma for a light pause or a trailing "which" clause. A full stop when the clause can stand on its own, which is usually the honest fix for a dash joining two complete thoughts. Nothing enforces this mechanically, so it is on the author; `grep -rn "—" --include="*.js" --include="*.ts" --include="*.md" .` checks a change before it lands. Table cells that need a "not applicable" placeholder use `n/a`.
25
-
26
- ## Architecture
27
-
28
- **pure-effect** is a zero-dependency effect system for JavaScript implementing the "Functional Core, Imperative Shell" pattern. Business logic returns plain data structures instead of executing side effects, enabling testing without mocks. The same property makes runs recordable and replayable: because a flow is inert data until the interpreter walks it, a recorded run can be fed back through the interpreter with no I/O at all.
29
-
30
- ### Core abstractions (all in `index.js`)
31
-
32
- | Export | Shape | Purpose |
33
- | --- | --- | --- |
34
- | `Success(value)` | `{ type: 'Success', value }` | Wraps a successful result |
35
- | `Failure(error, initialInput)` | `{ type: 'Failure', error, initialInput }` | Short-circuits the pipeline |
36
- | `Command(cmdFn, nextFn, meta)` | `{ type: 'Command', cmd, next, meta }` | Defers a side effect for the interpreter; `meta.name` sets the Command's identity |
37
- | `Ask(nextFn)` | `{ type: 'Ask', next }` | Reads the `context` passed to `runEffect`; passes it to `nextFn` |
38
- | `Retry(effect, options)` | `{ type: 'Retry', effect, options, next }` | Wraps any Effect tree with retry-on-failure semantics; handled natively by the interpreter; a per-use `options.onExhausted` runs a fallback Effect when every attempt has failed |
39
- | `Parallel(effects, next?)` | `{ type: 'Parallel', effects, next }` | Runs multiple Effect trees concurrently; the first branch to fail cancels its siblings and its Failure is returned; context flows into all branches |
40
- | `effectPipe(...fns)` | n/a | Composes functions into a sequential pipeline via `chain` |
41
- | `runEffect(effect, context, callConfig?)` | async | Interpreter: traverses the effect tree, executes Commands; resolves `Ask` and `Retry`; per-call `callConfig` overrides global defaults |
42
- | `configureEffect(...configs)` | n/a | Sets process-wide telemetry hooks (`onStep`, `onRun`, `onBeforeCommand`) and global `retry` defaults; merges several configurations into one wiring; returns a restore function; overridden by per-call `callConfig` |
43
-
44
- The recording and replay exports are a 2x2 rather than four loose helpers: `recorder` and `replayEffect` are the capabilities, at the hook and tree level, while `recordEffect` and `timeTravel` are the flow-level pair that also own trace metadata. That metadata is why the conveniences exist and should not be cut as mere sugar: `initialInput` lets a flow be rebuilt, `context` lets `Ask` resolve on replay, and `version` makes a stale trace detectable. A caller who assembles a trace by hand and forgets `context` gets one that silently diverges. `timeTravel` is presentation over `replayEffect` and could be rebuilt from `onResolved` in about ten lines, but it stays: the one-line form is what makes the feature graspable.
45
-
46
- `Command(cmd, next, meta)` keeps that parameter list, and `next` is optional because most of Commands in this repo pass their result straight through: it defaults to `(result) => Success(result)`. `Parallel`'s `next` follows the same convention, defaulting to `(values) => Success(values)`, so a bare `Parallel(effects)` resolves to the ordered array of success values. The README's `Composing Larger Flows` section documents the composition patterns the primitives already support (sub-pipeline branching, `Parallel` fan-in, local joins that carry a minimal purpose-shaped value); those shapes need no combinators, which is why none were added. The no-accumulator guidance there is deliberate bug avoidance, not taste: a growing scope object hides which step depends on what, lets stale fields outlive their producers, and widens what carries PII, so keep the section's examples threading minimal values if they are ever reworked.
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.
49
-
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.
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.
53
-
54
- Before this, every one of those mistakes was silent or cryptic: forgetting `Success()` gave `Cannot read properties of undefined (reading 'type')` with no step named, a continuation returning a plain value made `runEffect` resolve to that value so `result.type === 'Success'` quietly took the else branch, and `runEffect(flow)` without an input resolved to the function itself.
55
-
56
- ### Recording and replay (all in `index.js`)
57
-
58
- | Export | Purpose |
59
- | --- | --- |
60
- | `recorder(options?)` | Returns `{ onStep, entries, toTrace }`; the `onStep` hook records every Command's result or error. Options: `redact`, `maxEntries`, `stack` |
61
- | `recordEffect(flowFn, initialInput, opts?)` | Runs a flow for real while recording; returns `{ result, trace }`. Convenience for tests and scripts |
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 |
64
-
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.
66
-
67
- See **Time-travel debugging** below for the invariants these depend on, and the README for each option in full.
68
-
69
- ### Data flow
70
-
71
- ```
72
- effectPipe(f1, f2, f3)(input)
73
- → returns tree of Success / Failure / Command / Ask / Retry / Parallel values
74
- → f1 runs eagerly here; initialInput is threaded through chain so every
75
- node in the tree carries it without post-construction mutation
76
-
77
- runEffect(tree, context, callConfig?)
78
- → per-call callConfig merges over global configureEffect defaults
79
- → executes Commands async, passes results into next(result), repeats
80
- → resolves Ask by calling next(context), continues
81
- → resolves Retry via an inner execute() loop (not recursive runEffect),
82
- so onRun fires exactly once per runEffect call regardless of attempts;
83
- on exhaustion returns Failure({ retryExhausted: true, lastError, attempts }),
84
- unless a per-use onExhausted returns a fallback Effect: it executes under the
85
- retry's `f` path prefix, its success feeds next, its failure propagates
86
- unwrapped, and a cancelled Parallel branch skips it
87
- → resolves Parallel by running all effects via Promise.all with the same
88
- context; if any effect returns Failure, returns the first Failure by
89
- array index and skips next; otherwise calls next with the array of
90
- unwrapped success values
91
- → resolves to final Success or Failure
92
-
93
- replayEffect(tree, traceOrResolver, options?)
94
- → a trace goes through fromTrace(trace, { onEntry }); a function is used as the
95
- Resolver as-is (typeof check: an entries array is data, so it takes the
96
- trace path); a malformed trace rejects with a ReplayError
97
- → installs an onStep that answers each step from resolve(step) instead of
98
- calling op(), so eff.cmd is never applied and its I/O never happens
99
- → { result } → handed to the Command's next(result)
100
- → { error } → thrown, so the interpreter produces a Failure
101
- → undefined → throws a ReplayError unless onMissing: 'execute'
102
- → otherwise delegates to runEffect, so Ask / Retry / Parallel behave
103
- exactly as they do in production
104
- ```
105
-
106
- The `chain` combinator (internal) drives composition: `Success` passes its value to the next function, `Failure` short-circuits, `Command` defers execution, `Ask` wraps its continuation so the chain propagates through it, `Retry` wraps its continuation via object spread (same pattern as `Ask`). `chain` accepts an optional `initialInput` parameter; `effectPipe` passes the pipeline's starting value through every `chain` call so that `initialInput` is stamped on all nodes, including mid-pipeline `Failure`s, without mutation. `runEffect` loops through the tree with a `while` loop rather than recursion.
107
-
108
- `configureEffect` options (all overridable per-call via `runEffect`'s third argument):
109
-
110
- - `onStep`: fires on every Command execution; wraps the `cmd` call (use for per-command tracing)
111
- - `onRun`: fires once per `runEffect` call; wraps the whole workflow (use for top-level spans); receives `context.flowName` as the third argument; does **not** fire again for Retry attempts
112
- - `onBeforeCommand`: intercepts each Command before execution; receives the Command and the `context` passed to `runEffect`
113
- - `retry`: global Retry defaults `{ attempts, delay, backoff }`; per-use options passed to `Retry(effect, options)` merge on top (defaults: `attempts: 3`, `delay: 100ms`, `backoff: 1` flat)
114
-
115
- There is one slot per hook, and `configureEffect` merges the configurations of a single call but does not accumulate across calls: a later call replaces the previous wiring, an unset key returns that slot to its default, and calling it with nothing resets everything. A per-call `callConfig.onStep` likewise displaces the global one for that run, which is why `recordEffect` records without tracing. Recording and tracing both want `onStep`, so they go into one call: `configureEffect(telemetryHooks(), recordingHooks({ sink }))`. Anything that installs hooks on a caller's behalf will silently clobber whatever was there, which is why recording is a reference file rather than a library export. Note the asymmetry: `runEffect`'s third argument takes a single configuration, so merging is only available on the global path. That was the accepted cost of folding the merge into `configureEffect` rather than exporting it.
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`.
118
-
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.
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.
122
-
123
- That global state is also why the test suite needs care. Hooks outlive a suite, so every `describe` that could inherit another's wiring guards with `beforeEach`. The `Core` suite went unguarded for a while and was safe only because it is declared first, which is positional luck rather than isolation: a suite added above it, or a future split into several files, would silently change what it runs with.
124
-
125
- The internal `chainHooks` does that merging, and it relies on `onStep` and `onRun` being wrappers around an `op`: nesting them composes, with the first configuration outermost, so a thrown Command unwinds from the last back to the first. `onBeforeCommand` is an observer, so interceptors run in sequence instead. Keep those semantics if the hook shapes ever change. It was briefly exported; folding it into `configureEffect` removed an export and reads better at the call site, since a merge is only ever useful on the way into a configuration.
126
-
127
- ### Sharp edges pinned by tests
128
-
129
- Three semantics are surprising enough that `Documented sharp edges` in the test suite exists to keep them from changing silently, and each has a Limitations entry.
130
-
131
- `Retry` repeats the entire wrapped tree on every attempt, including Commands that already succeeded, because the retry loop calls `execute(eff.effect)` again rather than resuming. `Retry(effectPipe(charge, receipt))` charges three times when the receipt step fails twice, and the run still reports `Success`. The semantics are defensible, since retry means run this tree again, but the README's "wraps any Effect tree" invites exactly the dangerous construction, so both the JSDoc and the Retry section now say to wrap the failing Command instead. If this is ever changed to resume rather than repeat, the pinning test is the one to read first.
132
-
133
- `Parallel` cancellation is cooperative. The first branch to fail aborts an `AbortController` scoped to that `Parallel`, linked to the enclosing one so cancellation nests. Two levels of effect follow. A cancelled branch starts no further Commands, checked at the top of `execute`'s loop, which needs nothing from user code and is where most of the benefit is. Stopping the Command already in flight needs its thunk to accept the `AbortSignal` it is handed, which is why `cmd` is now called as `cmd(signal)` inside a `Parallel` and with no argument anywhere else: passing an argument only where it means something keeps a thunk written as `(x) => ...` from silently receiving one. A thunk that ignores the signal runs to completion, so a branch whose first Command is a write can still write after a sibling has failed, which is what the pinning test covers. `Parallel` still awaits every branch before returning, deliberately: returning early would leave cancelled work running unobserved after the Failure, with its rejections unhandled. The triggering branch is tracked separately from branches that failed because they were cancelled, so a cancellation never displaces the real error; when several branches fail in the same tick the first by array order wins, which is the older documented behaviour and is pinned.
134
-
135
- Every `Failure` carries `initialInput`, stamped by `chain`, alongside the full error. Neither is trimmed and neither should be: a test asserting on a Failure and a developer reading one both need the whole thing. PII control is split deliberately. `redact` is the single mechanism for a trace, and the shell owns what reaches logs, so the guidance is to log `result.error` rather than serialize a whole Failure.
136
-
137
- `redact` therefore has to see everything a trace holds, and for a while it did not. It covered Command results only, so a caller who installed a correct redact still shipped `initialInput` verbatim, the `context` verbatim, and any custom property on a thrown error: in the registration flow that is the plaintext password in two places and the auth token in a third. It now receives results, serialized errors, and the trace's own `initialInput` and `context`, with a `kind` argument of `'result'`, `'error'`, `'initialInput'`, or `'context'` to tell them apart. Keep that coverage complete if the trace format grows another field, since a field redact cannot see is a field that leaks. An absent `initialInput` or `context` is left `undefined` rather than passed through, so a redact that spreads its argument cannot turn nothing into an empty object.
138
-
139
- ### Time-travel debugging
140
-
141
- Because a flow is data, a production run can be recorded and replayed later. Recording is just an `onStep` hook, so it needs no change at call sites: install `recorder().onStep` via `configureEffect`, or pass it as per-call config. It shares the slot with tracing, so pass both configurations to one `configureEffect` call when both are wanted. `recordEffect` is the convenience wrapper for tests and scripts.
142
-
143
- Four invariants hold the design together; breaking any of them breaks replay in a way tests may not catch:
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.
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.
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`.
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.
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.
151
-
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.
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.
155
-
156
- `TraceLog` is the reference trace format, not a contract: `replayEffect` also takes a `Resolver`, so any storage shape works by writing one.
157
-
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.
159
-
160
- ### TypeScript
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`.
163
-
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.
165
-
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.
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.
169
-
170
- Error unions need explicit return annotations. A step written as `(i: User): Effect<User, 'taken'> => ...` contributes `'taken'` to the pipeline's union, but the same step left to inference from a bare `Success(x) | Failure(y)` return type yields `E = unknown`, and one `unknown` absorbs every other member of the union, so `result.error` degrades to `unknown` for the whole pipeline. Annotate `Effect<T, E>` on any step whose error type is meant to survive, and write type-level examples that way.
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.
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
-
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.
177
-
178
- ### Observability
179
-
180
- `examples/opentelemetry-example.js` wires OpenTelemetry spans into `configureEffect`'s hooks: `onRun` opens one span per run, `onStep` a child span per Command. It is reference code, not part of the library, and it follows the same shape as the recording example: `telemetryHooks(options)` returns a configuration, `enableTelemetry(options)` installs it, and `startTelemetrySdk(options)` stands up an exporter. Nothing happens on import, which is deliberate: the SDK used to start and register a SIGTERM handler at module scope, so merely importing the file opened an exporter and every test run tried to reach `localhost:4318`.
181
-
182
- Spans carry names, timings, and status; values belong in a trace. That split is why the example puts no Command input or output on a span: a trace already holds every result keyed by Command name, `redact` strips it before it leaves the process, and what remains can be replayed, whereas a span attribute can be redacted neither after the fact nor before an initial input carrying a password reaches the backend. Removing that recording also removed everything that had to guard it, which is what shrank the file from 94 code lines to 69.
183
-
184
- Two constraints in that file are load-bearing. Telemetry must never decide whether business logic runs: the old version called `JSON.stringify(effect.initialInput)` before `pipeline()`, so an input holding a cycle, which any request object or database client has, threw out of `onRun` and left the flow unexecuted while `runEffect` rejected instead of resolving. And `onStep` must `await op()` and return its result, since returning without calling `op` is precisely how replay suppresses I/O, so the same mistake in a tracing hook would silently stop every Command.
185
-
186
- `examples/recording-example.js` is the same kind of file for production recording: one recorder per run held in an `AsyncLocalStorage` scope, with `onBeforeCommand` capturing the context so a replay can resolve `Ask`. It stays out of the library for two reasons worth preserving. `node:async_hooks` would be the library's only import and a Node-only one, where `index.js` currently imports nothing and runs anywhere, and the policy it encodes (what to keep, where the sink is, what `redact` strips) belongs to the application. It exports `recordingHooks`, which returns a configuration, plus `enableRecording`, which installs it; the returning form is the composable one, since a self-installing helper would replace hooks the app already set. Both examples are covered by tests, so a change to the hook contract breaks them rather than letting them rot silently, and neither should be edited without running `npm test`.
187
-
188
- ### Tests
189
-
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.
191
-
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.
193
-
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.
195
-
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.
197
-
198
- ### Example code in the README
199
-
200
- README snippets are code readers copy, and no test covers them. Verify them by extracting the fenced blocks and running them against a stub `db`, not by reading them. A broken example survived review here precisely because it looked right: the flow ended in a bare `saveUser` after a guard that resolved to `Success(true)`, so `db.saveUser(true)` was called, the registration data never reached the database, and the run still reported `Success`. The tree-walking assertions in **Testing Without Mocks** passed the whole time, because `cmd.name` checks the shape and Command arguments live in a closure where nothing can assert on them.
201
-
202
- Two shape rules keep documented pipelines honest, and the Quick Start is written to both:
203
-
204
- - **Every step accepts and returns the piped value.** The email guard is a `Command` whose `next` returns `Success(input)` rather than `Success(true)`, so `saveUser` receives the registration data and the pipeline matches the sentence above it. Threading the value through a guard's own continuation costs nothing when the guard is already a Command, which is why no combinator is needed for it.
205
- - **Prefer threading the value over `() => f(outer)`, but do not present it as a rule.** Closing over an outer variable runs correctly in JavaScript and costs nothing there, so the README says so plainly rather than implying breakage. The real cost is TypeScript: a step that ignores its parameter constrains nothing about what produced it, so changing an upstream output from `User` to `string` raises no error in that form and a compile error when a step consumes the value. Documented examples still thread the value, because that is what keeps a typed pipeline checked end to end, and because it reads as data flow. Note that JavaScript examples have no type safety net at all, which is how the `Success(true)` bug shipped: the equivalent TypeScript is rejected with `'boolean' is not assignable to 'User'`.