pure-effect 0.16.0 → 0.17.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.
Files changed (4) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +24 -8
  3. package/index.js +223 -122
  4. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to pure-effect are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and from 1.0.0 the project follows [Semantic Versioning](https://semver.org/). Before 1.0.0 a minor version could change behaviour; each such change is marked below.
4
4
 
5
+ ## [0.17.0] - 2026-10-06
6
+
7
+ ### Changed
8
+
9
+ - **Inside a `Parallel`, a Command's function gets the `AbortSignal` only if it takes a parameter.** Every function was handed the signal, so one passed by name whose first parameter has a default value read the signal in its place: `Command(nanoid)` returned an empty ID inside a `Parallel` and a full one outside, and a helper with a default page size got the signal as its page size. The signal now goes only to a function that declares a parameter, counted as `Function.length` counts them, which leaves out parameters with default values, so such a function keeps its default everywhere. A function that reads the signal through a parameter with a default value or through `...args`, or that a wrapper hides behind a `length` of 0, is no longer given it, and runs to completion when its branch is cancelled, as a function that ignores the signal does; write it as `(signal) => ...`. A plain first parameter that a function treats as optional still takes the signal, as before.
10
+ - **An `options` argument refuses a name it does not read.** `Retry`, `Parallel`, `recorder`, `recordEffect`, `replayEffect` and `timeTravel` ignored any other name, so a misspelt or borrowed one ran with the default and nothing said so: `Parallel(batch, { concurrency: 4 })`, the name p-limit uses, ran with no limit, `{ settle: true }` ran fail-fast so one bad record cancelled the batch, and `{ attemps: 5 }` got 3 attempts. Such a name now throws, naming it and the options the function reads; `Retry` and `Parallel` throw an `EffectTypeError` as the flow is built, the others a `TypeError`. TypeScript already refused most of them in an object literal written in the call. An option set to `undefined` is still accepted.
11
+ - **The message for a step a trace lacks says what `onMissing: 'execute'` would run.** It advised `'execute'` where the Commands only read, so a replay that met a newly added lookup could run it that way, and every step after it, which had moved to a new path, ran live too. The message now says so, and advises `'execute'` only where every Command the flow can still reach goes to a test double or only reads.
12
+
13
+ ### Fixed
14
+
15
+ - **An `onStep` hook that calls `op()` without awaiting it can no longer replace a Command's result with `undefined`.** A hook that returned `undefined` in place of the Command's value made the run reject, but the check ran as soon as the hook returned, while a Command that took any time was still running, so a found user still reached `next` as `undefined` and the flow took the branch for a missing one. The interpreter now waits for a Command the hook left running before it judges the step: the run rejects if the Command returned a value, a throw from it is an I/O fault, as it is for a hook that awaited `op`, and nothing is left running unobserved.
16
+ - **`timeTravel` warns about unreached steps after a flow's own error named like a replay error.** It recognized its own replay errors by name, so a flow that ended on an error of its own named `ReplayError` or `TimeParadox` lost the warning about recorded steps it never reached. It now recognizes only the errors the library raises.
17
+
5
18
  ## [0.16.0] - 2026-10-01
6
19
 
7
20
  ### Added
@@ -223,6 +236,7 @@ All notable changes to pure-effect are recorded here. The format follows [Keep a
223
236
 
224
237
  - Initial release: `Success`, `Failure`, `Command`, `effectPipe`, and `runEffect`.
225
238
 
239
+ [0.17.0]: https://github.com/aycangulez/pure-effect/compare/v0.16.0...v0.17.0
226
240
  [0.16.0]: https://github.com/aycangulez/pure-effect/compare/v0.15.0...v0.16.0
227
241
  [0.15.0]: https://github.com/aycangulez/pure-effect/compare/v0.14.0...v0.15.0
228
242
  [0.14.0]: https://github.com/aycangulez/pure-effect/compare/v0.13.0...v0.14.0
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  - Inject context without touching function signatures
10
10
  - Built-in retry, plus parallel execution that cancels sibling branches on the first failure
11
11
  - OpenTelemetry-ready via lifecycle hooks
12
- - Zero dependencies, about 7 KB minified and gzipped
12
+ - Zero dependencies, under 8 KB minified and gzipped
13
13
  - Works in JavaScript, and in TypeScript 5.1 or later (full generics, bundled `.d.ts`)
14
14
 
15
15
  ## Table of Contents
@@ -171,6 +171,8 @@ A flow does what `async`/`await` code does, as data. Instead of making a call an
171
171
  | a value from the request, such as the tenant | `Ask`; see [Passing Runtime Context](#passing-runtime-context) |
172
172
  | `Date.now()` or a random ID | a Command, so a replay gets the recorded value |
173
173
 
174
+ One rule is behind every row. In `async` code, an `if` or a loop can have an `await` inside it. In a flow it can't: an `if` goes in a step or a Command's `next`, where the answer has already arrived, and a loop that makes calls becomes a `Parallel` or a `Retry`. So each decision lives in a small function of its own, which a test can call directly.
175
+
174
176
  The Quick Start's `registerUserFlow` is this function, translated row by row: each `await` became a Command, the code after it became that Command's `next`, and each `throw` became a `Failure`.
175
177
 
176
178
  ```js
@@ -205,6 +207,20 @@ assert.deepEqual(ensureEmailAvailable(input).next({ id: 1 }), Failure('Email alr
205
207
 
206
208
  This is what the small functions are for. Each step is a place a test can start, so a branch is tested by calling the step it lives in. An `async` function has one way in, so a test of any branch runs everything before it, with every call on the way replaced. The mocks can check one thing these tests cannot: the arguments each call received, which [Testing Without Mocks](#testing-without-mocks) leaves to an integration test.
207
209
 
210
+ You can switch one function at a time. The function keeps its name and parameters, and its body runs the flow and turns a `Failure` back into a throw, so the code that calls it does not change. The mocked test above passes against this version too, so it checks the switch before the flow's own tests replace it:
211
+
212
+ ```js
213
+ // Callers still await registerUserAsync and catch what it throws.
214
+ async function registerUserAsync(input) {
215
+ const result = await runEffect(registerUserFlow(input));
216
+ // A step's Failure holds the error as written, here a string; a Command's function threw an Error.
217
+ if (result.type === 'Failure') throw result.error instanceof Error ? result.error : new Error(result.error);
218
+ return result.value;
219
+ }
220
+
221
+ await assert.rejects(registerUserAsync({ email: 'bad-email', password: '123' }), { message: 'Invalid email.' });
222
+ ```
223
+
208
224
  A loop is written in two parts: a step that gets the list, and a `Parallel` over it. `Parallel` runs a list of flows, at the same time unless told otherwise, and `limit: 1` makes it run them one after another, like a `for` loop. The `async` version is usually split the same way. Paying a customer's unpaid invoices in order, and stopping at the first that fails:
209
225
 
210
226
  ```js
@@ -570,7 +586,7 @@ Bugs in the flow are not collected. An `EffectTypeError`, thrown for a malformed
570
586
 
571
587
  `limit` caps how many branches run at once; the rest start as others finish. Results and recorded paths stay in array order, so a limit changes only the pacing, and a trace recorded with a limit replays the same without one. A `limit` that is not a positive integer throws a `TypeError`.
572
588
 
573
- **Cancelling is a request, not a guarantee.** A cancelled branch starts no new Commands: a three-step branch whose first step is running when a sibling fails finishes that step and stops. To stop the running step itself, its function has to accept the `AbortSignal` it is given and pass it on to the I/O:
589
+ **Cancelling is a request, not a guarantee.** A cancelled branch starts no new Commands: a three-step branch whose first step is running when a sibling fails finishes that step and stops. To stop the running step itself, its function has to take the `AbortSignal` as a parameter, written without a default value, and pass it on to the I/O:
574
590
 
575
591
  ```js
576
592
  // Cancellable: the request is aborted the moment a sibling branch fails.
@@ -737,8 +753,6 @@ Retry(
737
753
  | a step returned `Failure(...)`, or an `onBeforeCommand` hook threw | an abort | nothing; it propagates at once, unwrapped | nothing |
738
754
  | a Command's function threw | an I/O fault | retries it | the exhaustion, once the attempts are gone |
739
755
  | a `next` function or a pure step threw, or the flow is malformed | a bug | nothing; it is thrown, not returned | nothing |
740
- | an `onStep` hook threw after the Command's function succeeded | a bug | nothing; it is thrown, not returned | nothing |
741
- | an `onStep` hook returned `undefined` after the Command's function returned a value | a bug | nothing; it is thrown, not returned | nothing |
742
756
 
743
757
  In short, **you can recover from an error your I/O produced, but you cannot catch a `Failure` a step returned.** Only the code that called `runEffect` acts on it. For the same reason, do not throw business errors from a Command's function: a throw there tells `Retry` that the I/O broke, and `Retry` will try again. A throw anywhere else in the flow is treated as a bug: `runEffect` rejects with the thrown error instead of returning a `Failure`.
744
758
 
@@ -817,6 +831,8 @@ Timings are from Node 22 on a MacBook Pro with M4 Pro CPU:
817
831
 
818
832
  ## API Reference
819
833
 
834
+ Every `options` argument below throws for a name it does not read, so a misspelt option fails instead of running with its default.
835
+
820
836
  ### Building blocks
821
837
 
822
838
  #### `Success(value)`
@@ -831,7 +847,7 @@ Returns `{ type: 'Failure', error, initialInput }`. Stops the pipeline immediate
831
847
 
832
848
  Returns `{ type: 'Command', cmd, next, meta }`.
833
849
 
834
- - `cmd`: the function, sync or async, that does the I/O. Inside a `Parallel` branch it gets an `AbortSignal` that fires when a sibling fails, and elsewhere no argument, so wrap a function that takes an optional first argument (see [Limitations](#limitations)).
850
+ - `cmd`: the function, sync or async, that does the I/O. Inside a `Parallel` branch, a function that takes a parameter gets an `AbortSignal` in it that fires when a sibling fails; one with no parameters, or only parameters with default values, gets none, and so does every function outside a `Parallel` (see [Limitations](#limitations)).
835
851
  - `next`: receives `cmd`'s result and returns the next Effect; by default `(result) => Success(result)`.
836
852
  - `meta`: metadata passed to `onBeforeCommand`. A string `meta.name` names the Command.
837
853
 
@@ -931,7 +947,7 @@ remove();
931
947
  - `onRun(effect, pipeline, flowName)`: wraps the whole run. It must `await pipeline()` and return its result.
932
948
  - `onStep(name, type, op, path)`: wraps each Command, and each `Parallel` with `name` and `type` both `'Parallel'`. It must `await op()` and return its result, and pass `path` on to any hook it calls, since a replay matches steps on it.
933
949
  - For a Command, `op()` returns a promise, even for a synchronous function. Returning a value without calling `op()` answers for the Command, which is how replay works, and throwing without calling it counts as the Command failing.
934
- - A throw after `op()` succeeded, or `undefined` returned in place of its value, as a hook that forgot its `return` does, is a bug in the hook: the run rejects, and `Retry` does not run the Command again.
950
+ - A throw after `op()` succeeded, or `undefined` returned in place of its value, as a hook that forgot its `return` or its `await` does, is a bug in the hook: the run rejects, and `Retry` does not run the Command again.
935
951
  - For a `Parallel`, `op()` runs the branches, so a hook must call it or the run rejects with a `TypeError`. It returns which branch, if any, cancelled the others, as in `{ cancelled: true, branch: 0 }`, even when a branch threw.
936
952
  - `onBeforeCommand(command, context)`: runs before each Command. A throw vetoes the Command: the run returns a `Failure` carrying the thrown error, and `Retry` does not retry it.
937
953
 
@@ -1033,8 +1049,8 @@ Replays a trace and narrates each step with its recorded duration, naming any re
1033
1049
  ## Limitations
1034
1050
 
1035
1051
  - **`Retry` repeats everything it wraps, including a Command's `next`.** When a Command's function throws, every Command inside the `Retry` runs again, including the ones that already succeeded. If a retried Command's `next` continues the flow, everything after it is retried too. Give a retried Command the default `next` and continue in a later pipeline step, or make sure every Command it reaches is safe to run more than once. A `Failure` a step returned is not retried at all, so a function that catches its own error and returns it as a value is never retried: see [Which Errors Are Data](#which-errors-are-data).
1036
- - **Cancelling a `Parallel` branch cannot stop everything.** A cancelled branch starts no new Commands, and a function that uses the `AbortSignal` it is given can be stopped while running. A function that ignores the signal runs to completion, so a branch whose _first_ Command is a write can still write after another branch has failed. `Parallel` waits for every branch to finish before returning, so no cancelled work is still running after the `Failure` is returned.
1037
- - **Inside a `Parallel`, a Command's function gets the `AbortSignal` as its first argument.** A function passed by name that has an optional first argument takes the signal for it, so the same Command behaves differently inside a `Parallel` than outside one: `Command(nanoid)` returns an empty ID, since `nanoid(size = 21)` reads the signal as the size, and a repository helper with a default page size gets the signal as its page size. Wrap such a function, as in `Command(() => nanoid())`. In TypeScript, a function whose first parameter cannot be an `AbortSignal` does not compile as a Command.
1052
+ - **Cancelling a `Parallel` branch cannot stop everything.** A cancelled branch starts no new Commands, and a function that uses the `AbortSignal` it is given can be stopped while running. A function that ignores the signal runs to completion, so a branch whose _first_ Command is a write can still write after another branch has failed. So does a function that is never given the signal: one written as `(...args)` or `({ signal } = {})`, or wrapped by a helper that hides its parameters. `Parallel` waits for every branch to finish before returning, so no cancelled work is still running after the `Failure` is returned.
1053
+ - **Inside a `Parallel`, a Command's function that takes a parameter gets the `AbortSignal` in it.** A function passed by name whose first parameter is optional but written without a default value takes the signal for it, so the same Command behaves differently inside a `Parallel` than outside one: `function listUsers(page) { page ??= 1; return db.users(page); }` gets the signal as its page. A parameter with a default value does not count, so `Command(nanoid)`, whose size is written `size = 21`, works everywhere. Wrap such a function, as in `Command(() => listUsers())`. In TypeScript, a function whose first parameter cannot be an `AbortSignal` does not compile as a Command.
1038
1054
  - **A `Failure` carries everything.** It holds the full error and the `initialInput` that `effectPipe` attached, and neither is trimmed, because tests and debugging need both. `redact` keeps sensitive data out of a **trace**. Keeping it out of your **logs** is up to you: log `result.error` rather than the whole `Failure`. The same goes for the outcomes a settled `Parallel` passes to `next`, which can carry their branch's input; for a login or registration flow, that input holds credentials.
1039
1055
  - **Replay checks the path, not the values.** Replaying an old trace cannot check a fix that changes what a flow computes, because every Command's result comes from the recording: the replay hands the step the value from before the fix, reports `Success`, and flags nothing. It can check a fix that changes which Commands run. A removed step shows up in `unreached` only if it was the last step the flow reached; removed from the middle, it shifts the later paths and the replay stops at a `TimeParadox`.
1040
1056
  - **A trace keeps data, not objects.** Recorded results come back without their classes: a money object or a database entity as a plain object, a `Buffer` as a plain byte array, and an error inside a result without properties such as `code`. Through JSON, a `Date` also comes back as a string and a `Map` as `{}`. A flow whose `next` calls a method on a result, or branches on one of those properties, replays differently from production. Return plain data from Commands; see [Recording in Production](#recording-in-production).
package/index.js CHANGED
@@ -1,5 +1,20 @@
1
1
  // @ts-check
2
2
 
3
+ // Contents. Each section is a `#region`, so an editor can fold it.
4
+ //
5
+ // 1. Types: the JSDoc shapes of the nodes a flow is made of.
6
+ // 2. Building flows: the constructors; the checks, error messages and kinds of failure the later sections
7
+ // share; and `effectPipe`, with the `chain` that joins its steps.
8
+ // 3. Configuration: the hook types and the library's defaults, `configureEffect` and its layers, and
9
+ // `chainHooks`, which merges them.
10
+ // 4. Running flows: the helpers `Retry` and `Parallel` run on, the interpreter, and `runEffect`.
11
+ // 5. Recording and replay: the trace format and replay errors, copying values and errors into a trace,
12
+ // `recorder`, built on `observeSteps`, and `recordEffect`, then `fromTrace`, `replayEffect` and `timeTravel`.
13
+ //
14
+ // A new definition goes in the section it serves, which is usually the one that calls it.
15
+
16
+ // #region Types
17
+
3
18
  /** @typedef {{ type: 'Success', value: any, initialInput?: any }} SuccessState */
4
19
  /** @typedef {{ type: 'Failure', error: any, initialInput?: any }} FailureState */
5
20
  /**
@@ -55,6 +70,9 @@
55
70
  * @typedef {SuccessState | FailureState | CommandState | AskState | RetryState | ParallelState} Effect
56
71
  */
57
72
 
73
+ // #endregion
74
+ // #region Building flows
75
+
58
76
  /**
59
77
  * Represents a successful computation
60
78
  * @param {any} value - The result value
@@ -78,9 +96,9 @@ const Failure = (error, initialInput) => ({
78
96
  * Represents a side effect to be executed later.
79
97
  *
80
98
  * @param {(signal?: AbortSignal) => Promise<any>|any} cmd - The side-effect function to execute. Inside a
81
- * `Parallel` branch it receives an `AbortSignal` that fires when a sibling branch fails, so I/O that
82
- * accepts one can be cancelled in flight. Outside a `Parallel` no argument is passed. The signal is the
83
- * first argument, so wrap a function that takes an optional one, as in `() => nanoid()`.
99
+ * `Parallel` branch, a function that declares a parameter receives an `AbortSignal` in it that fires when a
100
+ * sibling branch fails, so I/O that accepts one can be cancelled in flight. A parameter with a default value
101
+ * does not count, so `nanoid(size = 21)` keeps its default. Outside a `Parallel` no argument is passed.
84
102
  * @param {(result: any) => Effect} [next] - Receives the result of `cmd` and returns the next Effect.
85
103
  * Defaults to `(result) => Success(result)`, which is what most Commands want; `null` counts as omitted.
86
104
  * @param {CommandMeta} [meta] - Optional metadata, passed to `onBeforeCommand`. A string `meta.name`
@@ -164,6 +182,9 @@ const Retry = (effect, options) => {
164
182
  const hint = typeof options === 'number' ? `: write Retry(effect, { attempts: ${options} })` : '';
165
183
  throw malformed(`Retry's options must be an object, got ${describeArgument(options)}${hint}.`, options);
166
184
  }
185
+ rejectUnknownOptions(options, 'Retry', ['attempts', 'delay', 'backoff', 'onExhausted'], (m) =>
186
+ malformed(m, options)
187
+ );
167
188
  return { type: 'Retry', effect, options: options ?? {}, next: (value) => Success(value) };
168
189
  };
169
190
 
@@ -221,6 +242,7 @@ const Parallel = (effects, nextOrOptions, maybeOptions) => {
221
242
  if (options != null && !isOptionsObject(options)) {
222
243
  throw malformed(`Parallel's options must be an object, got ${describeArgument(options)}.`, options);
223
244
  }
245
+ rejectUnknownOptions(options, 'Parallel', ['limit', 'settled'], (m) => malformed(m, options));
224
246
  return {
225
247
  type: 'Parallel',
226
248
  effects,
@@ -255,6 +277,22 @@ const isFiniteNonNegative = (value) => Number.isFinite(value) && value >= 0;
255
277
  */
256
278
  const isOptionsObject = (value) => isObject(value) && !Array.isArray(value) && !isEffect(value);
257
279
 
280
+ /**
281
+ * Refuses an option name a function does not read. A misspelt name, or one borrowed from another library, as
282
+ * `concurrency` is from p-limit, otherwise ran with the default and nothing to say so.
283
+ * @param {any} options
284
+ * @param {string} source - The function that takes them, as the message names it
285
+ * @param {string[]} known
286
+ * @param {(message: string) => Error} [raise] - Builds the error; a constructor's argument errors are EffectTypeErrors
287
+ */
288
+ const rejectUnknownOptions = (options, source, known, raise = (message) => new TypeError(message)) => {
289
+ const name = isObject(options) ? Object.keys(options).find((key) => !known.includes(key)) : undefined;
290
+ if (name !== undefined) {
291
+ const list = `${known.slice(0, -1).join(', ')} and ${known[known.length - 1]}`;
292
+ throw raise(`${source} has no option named '${name}'; its options are ${list}.`);
293
+ }
294
+ };
295
+
258
296
  /**
259
297
  * Describes a value for an error message, leading with the mistake it most likely is.
260
298
  * @param {any} value
@@ -448,7 +486,8 @@ const chain = (effect, fn, initialInput, from) => {
448
486
  * Composes a list of functions into a single Effect pipeline.
449
487
  * Each function receives the output of the previous one.
450
488
  *
451
- * @param {...(input: any) => Effect} fns - Functions that return Success, Failure, Command, or Ask.
489
+ * @param {...(input: any) => Effect} fns - Functions that each return an Effect: Success, Failure, Command, Ask,
490
+ * Retry, or Parallel.
452
491
  * @returns {(start: any) => Effect} A function that accepts an initial input and returns the final Effect tree.
453
492
  */
454
493
  const effectPipe = (...fns) => {
@@ -471,6 +510,9 @@ const effectPipe = (...fns) => {
471
510
  };
472
511
  };
473
512
 
513
+ // #endregion
514
+ // #region Configuration
515
+
474
516
  /**
475
517
  * Wraps one Command execution, or one Parallel: `type` is 'Parallel', and `op` runs the branches and
476
518
  * returns the Parallel's decision, even when a branch threw, so a hook must call it. Only a replay passes
@@ -541,7 +583,8 @@ const applyLayers = () => {
541
583
  * Each call adds one layer on top of those already installed and returns a function that removes that
542
584
  * layer, wherever it sits by then. Layers merge the way several configurations passed to one call do:
543
585
  * `onStep` and `onRun` are wrappers, so they nest with the earliest layer outermost and the latest
544
- * closest to the Command; and `onBeforeCommand` interceptors all run, in the order installed. So these
586
+ * closest to the Command; and `onBeforeCommand` interceptors run in the order installed, the first to throw
587
+ * vetoing the Command. So these
545
588
  * are the same:
546
589
  *
547
590
  * configureEffect(telemetryHooks(), recordingHooks({ sink }));
@@ -574,65 +617,13 @@ const configureEffect = (...configs) => {
574
617
  };
575
618
  };
576
619
 
577
- /**
578
- * @typedef {Object} StepStart
579
- * @property {string} name - The Command's identity: `meta.name`, else `cmd.name`, else 'anonymous'.
580
- * @property {string} type - 'Command', or 'Parallel' for a Parallel's decision.
581
- * @property {string} [path] - The Command's position in the Effect tree.
582
- */
583
-
584
- /**
585
- * @typedef {Object} StepEnd
586
- * @property {string} name
587
- * @property {string} type
588
- * @property {string} [path]
589
- * @property {any} [result] - What the Command returned, when it succeeded.
590
- * @property {any} [error] - What it threw, when it did not.
591
- * @property {number} durationMs
592
- */
593
-
594
- const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
595
-
596
- /**
597
- * Wraps a step observer into an `onStep` that cannot change the run: `op` always runs, its result is
598
- * returned, its error propagates, and anything the observer throws is dropped.
599
- *
600
- * @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
601
- * @returns {StepRunner}
602
- */
603
- const observeSteps = (handler) => async (name, type, op, path) => {
604
- /** @type {((end: StepEnd) => void) | undefined} */
605
- let finish;
606
- try {
607
- finish = handler({ name, type, path });
608
- } catch {
609
- finish = undefined;
610
- }
611
- const report = (/** @type {StepEnd} */ end) => {
612
- try {
613
- if (finish) finish(end);
614
- } catch {
615
- // Observation does not get to decide the outcome, so a broken observer is dropped.
616
- }
617
- };
618
-
619
- const started = now();
620
- try {
621
- const result = await op();
622
- report({ name, type, path, result, durationMs: now() - started });
623
- return result;
624
- } catch (error) {
625
- report({ name, type, path, error, durationMs: now() - started });
626
- throw error;
627
- }
628
- };
629
-
630
620
  /**
631
621
  * Merges several configurations into one, so independent concerns can share the hooks.
632
622
  *
633
623
  * `onStep` and `onRun` are wrappers around an `op`, so they nest: the first config given is the
634
624
  * outermost and the last sits closest to the Command, so a thrown Command unwinds from the last back
635
- * to the first. `onBeforeCommand` is an observer, so every interceptor runs in the order given. A hook
625
+ * to the first. `onBeforeCommand` interceptors do not nest: they run in the order given, and the first to
626
+ * throw vetoes the Command, so the ones after it do not run. A hook
636
627
  * no config defines is left unset, so the caller keeps its default for that slot.
637
628
  *
638
629
  * @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
@@ -669,6 +660,9 @@ const chainHooks = (...configs) => {
669
660
  return merged;
670
661
  };
671
662
 
663
+ // #endregion
664
+ // #region Running flows
665
+
672
666
  /**
673
667
  * The Failure a cancelled `Parallel` branch stops with. Its error is named so it is never mistaken for
674
668
  * the failure that triggered the cancellation.
@@ -719,7 +713,8 @@ const asDecision = (value, branches) => {
719
713
 
720
714
  /**
721
715
  * Marks a step a replay reached that production never ran, in a branch a recorded decision cancelled.
722
- * The interpreter stops the branch there, as production did. Only `fromTrace` raises it.
716
+ * Only `fromTrace` raises it, and `runCommand` and `runParallel` catch it and stop the branch there, as
717
+ * production did.
723
718
  */
724
719
  const replayCut = Symbol('pure-effect.replayCut');
725
720
 
@@ -946,6 +941,7 @@ const interpret =
946
941
  throw new TypeError(`Parallel 'limit' must be a positive integer, received ${describeValue(limit)}.`);
947
942
  // Cast rather than annotated, since only `op` assigns it.
948
943
  let run = /** @type {BranchRun | undefined} */ (undefined);
944
+ // A replay passes the recorded decision, from `replayEffect`'s onStep; a live run passes nothing.
949
945
  const op = async (/** @type {any} */ recorded) => {
950
946
  run = await runBranches(parallel.effects, options, signal, branchPath, recorded);
951
947
  return run.decision;
@@ -953,7 +949,7 @@ const interpret =
953
949
  try {
954
950
  await localStepRunner('Parallel', 'Parallel', op, branchPath);
955
951
  } catch (e) {
956
- // A replay found this Parallel inside a branch production had already stopped.
952
+ // A cut from `fromTrace`: a replay found this Parallel inside a branch production had already stopped.
957
953
  if (hasMark(e, replayCut)) return cancelledBranch(parallel.initialInput);
958
954
  throw e;
959
955
  }
@@ -988,7 +984,8 @@ const interpret =
988
984
  * @param {ParallelOptions} options
989
985
  * @param {AbortSignal | undefined} signal - The enclosing Parallel's cancellation, if any
990
986
  * @param {string} branchPath
991
- * @param {any} recorded - The recorded decision, when a replay supplies one
987
+ * @param {any} recorded - The recorded decision, which `replayEffect`'s onStep passes to the Parallel's
988
+ * `op`; undefined in a live run
992
989
  * @returns {Promise<BranchRun>}
993
990
  */
994
991
  async function runBranches(effects, options, signal, branchPath, recorded) {
@@ -999,10 +996,10 @@ const interpret =
999
996
  const pastTheEnd = Number.isInteger(recordedBranch) && recordedBranch >= effects.length;
1000
997
  if (pastTheEnd) {
1001
998
  const count = effects.length === 1 ? '1 branch' : `${effects.length} branches`;
1002
- throw replayError(
1003
- `Time paradox at path '${branchPath}': the recorded run was cancelled by branch ` +
1004
- `${recordedBranch}, but this Parallel has ${count}.`,
1005
- { name: 'TimeParadox', path: branchPath, branch: recordedBranch }
999
+ throw timeParadoxAt(
1000
+ `path '${branchPath}'`,
1001
+ `the recorded run was cancelled by branch ${recordedBranch}, but this Parallel has ${count}.`,
1002
+ { path: branchPath, branch: recordedBranch }
1006
1003
  );
1007
1004
  }
1008
1005
  const forced = asDecision(recorded, effects.length);
@@ -1022,10 +1019,10 @@ const interpret =
1022
1019
  const recordedTriggerSucceeded =
1023
1020
  !thrown && forced.branch !== null && results[forced.branch].type === 'Success';
1024
1021
  if (recordedTriggerSucceeded) {
1025
- throw replayError(
1026
- `Time paradox at path '${branchPath}': the recorded run was cancelled by ` +
1027
- `branch ${forced.branch}, which did not fail in this replay.`,
1028
- { name: 'TimeParadox', path: branchPath, branch: forced.branch }
1022
+ throw timeParadoxAt(
1023
+ `path '${branchPath}'`,
1024
+ `the recorded run was cancelled by branch ${forced.branch}, which did not fail in this replay.`,
1025
+ { path: branchPath, branch: forced.branch }
1029
1026
  );
1030
1027
  }
1031
1028
  return { results, decision: forced, thrown };
@@ -1115,15 +1112,28 @@ const interpret =
1115
1112
  // What it returned, so a hook that loses it is caught.
1116
1113
  /** @type {unknown} */
1117
1114
  let value;
1118
- // The signal is passed only inside a Parallel. There it is still the first argument, so a function
1119
- // passed by name with an optional first parameter, `nanoid(size = 21)` say, takes the signal for it:
1120
- // a documented sharp edge. Async, so a hook always gets a promise, even from a synchronous function.
1121
- const op = async () => {
1115
+ // The signal goes only to a function that declares a parameter for it, and only inside a Parallel. A
1116
+ // parameter with a default value does not count toward `length`, so `nanoid(size = 21)` passed by name
1117
+ // keeps its default rather than reading the signal as its size. A plain first parameter the function
1118
+ // treats as optional still takes the signal: a documented sharp edge.
1119
+ const takesSignal = signal !== undefined && cmd.length > 0;
1120
+ // Async, so a hook always gets a promise, even from a synchronous function. The latest call is kept,
1121
+ // with whether it is still running, so a hook that does not wait for it is caught.
1122
+ /** @type {Promise<unknown> | undefined} */
1123
+ let call;
1124
+ let running = false;
1125
+ const run = async () => {
1122
1126
  succeeded = false;
1123
- value = await (signal ? cmd(signal) : cmd());
1124
- succeeded = true;
1125
- return value;
1127
+ running = true;
1128
+ try {
1129
+ value = await (takesSignal ? cmd(signal) : cmd());
1130
+ succeeded = true;
1131
+ return value;
1132
+ } finally {
1133
+ running = false;
1134
+ }
1126
1135
  };
1136
+ const op = () => (call = run());
1127
1137
  try {
1128
1138
  await localCommandInterceptor(command, context);
1129
1139
  } catch (e) {
@@ -1133,10 +1143,18 @@ const interpret =
1133
1143
  // Again, since an interceptor can wait (a rate limiter, say) while a sibling fails.
1134
1144
  if (signal?.aborted) return cancelledBranch(initialInput);
1135
1145
  let returned;
1146
+ let unwaited = false;
1136
1147
  try {
1137
1148
  returned = await localStepRunner(cmdName, 'Command', op, cmdPath);
1149
+ // A hook that called `op` without waiting for it returned while the Command ran, so the check below
1150
+ // would have nothing to check yet. Waiting here judges the step as though the hook had awaited `op`.
1151
+ if (returned === undefined && running) {
1152
+ unwaited = true;
1153
+ await call;
1154
+ }
1138
1155
  } catch (e) {
1139
- // A step production never ran, in a branch a Parallel cancelled: stop here, as production did.
1156
+ // A cut from `fromTrace`: a step production never ran, in a branch a Parallel cancelled. Stop here,
1157
+ // as production did.
1140
1158
  if (hasMark(e, replayCut)) return cancelledBranch(initialInput);
1141
1159
  if (hasMark(e, harnessError)) throw e;
1142
1160
  // A hook threw after the function returned, which is a bug in the hook. A hook that throws
@@ -1149,8 +1167,9 @@ const interpret =
1149
1167
  // refused, so a hook that returns a copy of the result still works.
1150
1168
  if (returned === undefined && succeeded && value !== undefined) {
1151
1169
  throw new TypeError(
1152
- `An onStep hook called op for '${cmdName}' at path '${cmdPath}' and returned undefined, although ` +
1153
- 'the Command returned a value. A hook has to return what op returns.'
1170
+ `An onStep hook called op for '${cmdName}' at path '${cmdPath}' and returned undefined` +
1171
+ `${unwaited ? ' before op had finished' : ''}, although the Command returned a value. ` +
1172
+ 'A hook has to await op() and return what it returns.'
1154
1173
  );
1155
1174
  }
1156
1175
  return Success(returned);
@@ -1179,6 +1198,9 @@ const interpret =
1179
1198
  */
1180
1199
  const runEffect = (effect, context, callConfig) => interpret(effect, context, callConfig);
1181
1200
 
1201
+ // #endregion
1202
+ // #region Recording and replay
1203
+
1182
1204
  /**
1183
1205
  * The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
1184
1206
  * across runs; `index` is its position in this run's completion order, which is not stable for a flow
@@ -1244,25 +1266,32 @@ const replayError = (message, props = {}) =>
1244
1266
  );
1245
1267
 
1246
1268
  /**
1247
- * Signals that the flow being replayed asked for a different Command than the trace
1248
- * recorded, which means the code has diverged from the recorded run.
1269
+ * Builds a `TimeParadox`: the replay reached a point where the flow no longer matches its recording, which
1270
+ * means the code has diverged from the recorded run.
1271
+ * @param {string} at - Where, as the message names it
1272
+ * @param {string} detail - How the flow and the recording disagree
1273
+ * @param {Object} props - The fields that locate it, such as `path`
1274
+ * @returns {Error}
1275
+ */
1276
+ const timeParadoxAt = (at, detail, props) =>
1277
+ replayError(`Time paradox at ${at}: ${detail}`, { name: 'TimeParadox', ...props });
1278
+
1279
+ /**
1280
+ * Signals that the flow being replayed asked for a different Command than the trace recorded.
1249
1281
  * @param {ReplayStep} step - The step the flow asked for
1250
1282
  * @param {string} recorded - The command name the trace holds at that position
1251
1283
  * @returns {Error}
1252
1284
  */
1253
1285
  const timeParadox = (step, recorded) =>
1254
- replayError(
1255
- `Time paradox at ${step.path !== undefined ? `path '${step.path}'` : `step ${step.index}`}: ` +
1256
- `flow asked for '${step.name}', trace recorded '${recorded}'`,
1257
- {
1258
- name: 'TimeParadox',
1259
- index: step.index,
1260
- path: step.path,
1261
- expected: recorded,
1262
- actual: step.name
1263
- }
1286
+ timeParadoxAt(
1287
+ step.path !== undefined ? `path '${step.path}'` : `step ${step.index}`,
1288
+ `flow asked for '${step.name}', trace recorded '${recorded}'`,
1289
+ { index: step.index, path: step.path, expected: recorded, actual: step.name }
1264
1290
  );
1265
1291
 
1292
+ /** The keys `serializeError` carries whatever their enumerability, which a native Error sets as hidden. */
1293
+ const carriedKeys = ['name', 'message', 'cause'];
1294
+
1266
1295
  /**
1267
1296
  * Converts a thrown value into something JSON can carry. `message` and `stack` are
1268
1297
  * non-enumerable on Error, so a plain `JSON.stringify` would silently drop them.
@@ -1301,9 +1330,6 @@ const serializeError = (e, withStack, ancestors = new Set()) => {
1301
1330
  return out;
1302
1331
  };
1303
1332
 
1304
- /** The keys `serializeError` carries whatever their enumerability, which a native Error sets as hidden. */
1305
- const carriedKeys = ['name', 'message', 'cause'];
1306
-
1307
1333
  /**
1308
1334
  * Rebuilds an Error from `serializeError` output. Non-Error values pass through, so a
1309
1335
  * Command that rejected with a string still replays as a string.
@@ -1409,15 +1435,70 @@ const snapshot = (value) => {
1409
1435
  };
1410
1436
 
1411
1437
  /**
1412
- * Builds an `onStep` hook that records what every Command returned, plus a packager
1413
- * for the reference trace format. Pass `onStep` to `runEffect` as per-call config. A recorder holds the steps
1414
- * of every run it sees, so installed with `configureEffect` for a whole application it mixes requests into one
1415
- * trace with duplicate paths, which a replay refuses; examples/recording-example.js gives each run its own.
1438
+ * @typedef {Object} StepStart
1439
+ * @property {string} name - The Command's identity: `meta.name`, else `cmd.name`, else 'anonymous'.
1440
+ * @property {string} type - 'Command', or 'Parallel' for a Parallel's decision.
1441
+ * @property {string} [path] - The Command's position in the Effect tree.
1442
+ */
1443
+
1444
+ /**
1445
+ * @typedef {Object} StepEnd
1446
+ * @property {string} name
1447
+ * @property {string} type
1448
+ * @property {string} [path]
1449
+ * @property {any} [result] - What the Command returned, when it succeeded.
1450
+ * @property {any} [error] - What it threw, when it did not.
1451
+ * @property {number} durationMs
1452
+ */
1453
+
1454
+ const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
1455
+
1456
+ /**
1457
+ * Wraps a step observer into an `onStep` that cannot change the run: `op` always runs, its result is
1458
+ * returned, its error propagates, and anything the observer throws is dropped.
1459
+ *
1460
+ * @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
1461
+ * @returns {StepRunner}
1462
+ */
1463
+ const observeSteps = (handler) => async (name, type, op, path) => {
1464
+ /** @type {((end: StepEnd) => void) | undefined} */
1465
+ let finish;
1466
+ try {
1467
+ finish = handler({ name, type, path });
1468
+ } catch {
1469
+ finish = undefined;
1470
+ }
1471
+ const report = (/** @type {StepEnd} */ end) => {
1472
+ try {
1473
+ if (finish) finish(end);
1474
+ } catch {
1475
+ // Observation does not get to decide the outcome, so a broken observer is dropped.
1476
+ }
1477
+ };
1478
+
1479
+ const started = now();
1480
+ try {
1481
+ const result = await op();
1482
+ report({ name, type, path, result, durationMs: now() - started });
1483
+ return result;
1484
+ } catch (error) {
1485
+ report({ name, type, path, error, durationMs: now() - started });
1486
+ throw error;
1487
+ }
1488
+ };
1489
+
1490
+ /**
1491
+ * Builds an `onStep` hook that records every Command's result or error and every Parallel's decision, plus
1492
+ * a packager for the reference trace format. Pass `onStep` to `runEffect` as per-call config. A recorder holds
1493
+ * the steps of every run it sees, so installed with `configureEffect` for a whole application it mixes requests
1494
+ * into one trace with duplicate paths, which a replay refuses; examples/recording-example.js gives each run its
1495
+ * own.
1416
1496
  *
1417
1497
  * @param {RecorderOptions} [options] - Redaction and size limits
1418
1498
  * @returns {{ onStep: StepRunner, entries: TraceEntry[], toTrace: (meta?: TraceMeta) => TraceLog }}
1419
1499
  */
1420
1500
  const recorder = (options = {}) => {
1501
+ rejectUnknownOptions(options, 'recorder', ['redact', 'maxEntries', 'stack']);
1421
1502
  const { redact = (/** @type {any} */ r) => r, maxEntries = Infinity, stack = false } = options;
1422
1503
  /** @type {TraceEntry[]} */
1423
1504
  const entries = [];
@@ -1493,6 +1574,7 @@ const recorder = (options = {}) => {
1493
1574
  * @returns {Promise<{ result: SuccessState | FailureState, trace: TraceLog }>}
1494
1575
  */
1495
1576
  const recordEffect = async (flowFn, initialInput, options = {}) => {
1577
+ rejectUnknownOptions(options, 'recordEffect', ['context', 'version', 'redact', 'maxEntries', 'stack']);
1496
1578
  const { context = {}, version, ...recorderOptions } = options;
1497
1579
  const rec = recorder(recorderOptions);
1498
1580
  // Packaged before the run, so a Command that writes to the input or the context, as an ORM save
@@ -1583,7 +1665,8 @@ const fromTrace = (traceLog, options = {}) => {
1583
1665
  /** Recorded Parallel decisions that cancelled branches, by the Parallel's path. */
1584
1666
  const cancellations = entries.filter(isDecisionEntry).filter((e) => e.result?.cancelled === true);
1585
1667
  // A missing step is where production stopped a branch when it lies in a branch a recorded decision
1586
- // cancelled: any branch but the cancelling one, or every branch of a Parallel cancelled from outside.
1668
+ // cancelled: any branch but the cancelling one, or every branch of a Parallel cancelled from outside. Such a
1669
+ // step throws a cut, which `runCommand` or `runParallel` catches to stop the branch.
1587
1670
  const stoppedInProduction = (/** @type {string | undefined} */ stepPath) =>
1588
1671
  typeof stepPath === 'string' &&
1589
1672
  cancellations.some((e) => {
@@ -1595,7 +1678,8 @@ const fromTrace = (traceLog, options = {}) => {
1595
1678
  const resolve = (step) => {
1596
1679
  const entry = byPath.get(step.path);
1597
1680
  if (step.type === 'Parallel') {
1598
- // A decision rather than I/O. With none recorded, the Parallel replays under timing.
1681
+ // A decision rather than I/O. `replayEffect` hands a recorded one to the Parallel's `op`, and
1682
+ // `runBranches` reproduces it; with none recorded, the Parallel replays under timing.
1599
1683
  if (entry && entry.command !== 'Parallel') throw timeParadox(step, entry.command);
1600
1684
  if (entry) return resolveEntry(entry);
1601
1685
  if (stoppedInProduction(step.path)) throw replayCutError(/** @type {string} */ (step.path));
@@ -1635,6 +1719,35 @@ const fromTrace = (traceLog, options = {}) => {
1635
1719
  return { resolve, missing };
1636
1720
  };
1637
1721
 
1722
+ /**
1723
+ * The error for a step a replay has no recorded outcome for, which it refuses to run live.
1724
+ * @param {ReplayStep} step
1725
+ * @param {((step: ReplayStep) => string) | undefined} describe - `fromTrace`'s `missing`; a Resolver has none
1726
+ * @param {number} droppedEntries - How many entries the trace dropped under `maxEntries`
1727
+ * @returns {Error}
1728
+ */
1729
+ const missingStepError = (step, describe, droppedEntries) => {
1730
+ const what = describe ? describe(step) : `No recorded outcome for '${step.name}' at step ${step.index}`;
1731
+ // Why the step may be missing decides the fix. A capped trace needs a higher cap, since the step may be one
1732
+ // production ran; any other missing step may be one production never ran, and running it live against
1733
+ // production would do I/O production refused.
1734
+ const why =
1735
+ droppedEntries > 0
1736
+ ? `The trace dropped ${droppedEntries} entries under maxEntries, so production may have run ` +
1737
+ 'this step and the recorder not kept it; record the flow with a higher maxEntries to ' +
1738
+ 'replay past it.'
1739
+ : 'Production may never have run it: a Command an onBeforeCommand hook vetoed leaves no ' +
1740
+ 'entry, and neither does a step added since the recording or one the flow reaches now that ' +
1741
+ "it did not then. onMissing: 'execute' runs such a step for real, and after an added step " +
1742
+ 'every step that follows it, since they all move to new paths, so pass it only where every ' +
1743
+ 'Command the flow can still reach goes to a test double or only reads.';
1744
+ return replayError(`${what}; refusing to run the real Command. ${why}`, {
1745
+ command: step.name,
1746
+ index: step.index,
1747
+ path: step.path
1748
+ });
1749
+ };
1750
+
1638
1751
  /**
1639
1752
  * @typedef {Object} ReplayOptions
1640
1753
  * @property {any} [context] - Context for `Ask`; defaults to the context a trace recorded.
@@ -1685,6 +1798,7 @@ const fromTrace = (traceLog, options = {}) => {
1685
1798
  */
1686
1799
  // `async` so a malformed trace arrives as a rejection rather than a synchronous throw.
1687
1800
  const replayEffect = async (effect, traceOrResolver, options = {}) => {
1801
+ rejectUnknownOptions(options, 'replayEffect', ['context', 'fastRetry', 'hooks', 'onMissing', 'onResolved']);
1688
1802
  const { fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
1689
1803
  // A trace is data and a Resolver is a function, so nothing else is needed to tell them
1690
1804
  // apart, including the bare entries array that `fromTrace` also accepts.
@@ -1719,7 +1833,7 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1719
1833
  /** @type {StepRunner} */
1720
1834
  const onStep = async (name, type, op, path) => {
1721
1835
  if (type === 'Parallel') {
1722
- // A Parallel's step carries its recorded decision into `op`, which runs the branches under it.
1836
+ // A Parallel's step carries its recorded decision into `op`, where `runBranches` reproduces it.
1723
1837
  // It is not a Command: `index` still counts Commands, and `onResolved` still sees only them.
1724
1838
  const outcome = resolve({ index, name, type, path });
1725
1839
  return await op(outcome && 'result' in outcome ? outcome.result : undefined);
@@ -1735,24 +1849,7 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1735
1849
  }
1736
1850
  }
1737
1851
  if (outcome === undefined) {
1738
- if (onMissing !== 'execute') {
1739
- const what = missing ? missing(step) : `No recorded outcome for '${name}' at step ${step.index}`;
1740
- // Why the step may be missing decides the fix. A capped trace needs a higher cap, since the step
1741
- // may be one production ran; any other missing step may be one production never ran, and
1742
- // running it live against production would do I/O production refused.
1743
- const why = capped
1744
- ? `The trace dropped ${droppedEntries} entries under maxEntries, so production may have run ` +
1745
- 'this step and the recorder not kept it; record the flow with a higher maxEntries to ' +
1746
- 'replay past it.'
1747
- : 'Production may never have run it: a Command an onBeforeCommand hook vetoed leaves no ' +
1748
- "entry, and neither does a step added since the recording. onMissing: 'execute' runs such " +
1749
- 'steps for real, so pass it only where the Commands reach test doubles or only read.';
1750
- throw replayError(`${what}; refusing to run the real Command. ${why}`, {
1751
- command: name,
1752
- index: step.index,
1753
- path
1754
- });
1755
- }
1852
+ if (onMissing !== 'execute') throw missingStepError(step, missing, droppedEntries);
1756
1853
  return await op();
1757
1854
  }
1758
1855
  if ('error' in outcome) throw outcome.error;
@@ -1795,6 +1892,7 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1795
1892
  * a caller who wants the unreached entries as data uses `replayEffect`.
1796
1893
  */
1797
1894
  const timeTravel = async (flowFn, traceLog, options = {}) => {
1895
+ rejectUnknownOptions(options, 'timeTravel', ['log', 'context', 'version']);
1798
1896
  const { log = console.log, context, version } = options;
1799
1897
  const { initialInput, trace, flowName, version: traceVersion } = traceLog;
1800
1898
  // `message` is non-enumerable on Error, so JSON.stringify alone would drop it. JSON.stringify throws on
@@ -1857,8 +1955,9 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
1857
1955
  log(`Replay finished with state: ${result.type}`);
1858
1956
  log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
1859
1957
  // After a replay error the flow never got past the divergence, and the error already names where it
1860
- // split. Only a flow that ended on its own terms with steps left over is worth a warning.
1861
- const haltedByReplay = result.type === 'Failure' && /^(TimeParadox|ReplayError)$/.test(result.error?.name);
1958
+ // split. Only a flow that ended on its own terms with steps left over is worth a warning. Told by the
1959
+ // mark rather than the name, which a flow's own error can share.
1960
+ const haltedByReplay = result.type === 'Failure' && hasMark(result.error, replayFault);
1862
1961
  if (unreached.length > 0 && !haltedByReplay) {
1863
1962
  // Named, not just counted: the step a fix stopped issuing is usually the one under suspicion.
1864
1963
  const names = unreached.map((e) => (hasPath(e) ? `${e.command} (path '${e.path}')` : e.command));
@@ -1868,6 +1967,8 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
1868
1967
  return result;
1869
1968
  };
1870
1969
 
1970
+ // #endregion
1971
+
1871
1972
  export {
1872
1973
  Success,
1873
1974
  Failure,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pure-effect",
3
- "version": "0.16.0",
3
+ "version": "0.17.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
5
  "keywords": [
6
6
  "effect",
@@ -31,7 +31,7 @@
31
31
  "test": "mocha && tsd && tsc -p jsconfig.json",
32
32
  "test:ts-minimum": "npx --yes -p typescript@5.1 tsc -p test/tsconfig.minimum.json",
33
33
  "generate": "node scripts/effect-pipe-overloads.js",
34
- "format": "prettier --write index.js index.d.ts examples/*.js scripts/*.js test/all.js test/types.test-d.ts README.md CLAUDE.md CHANGELOG.md CONTRIBUTING.md"
34
+ "format": "prettier --write index.js index.d.ts examples/*.js scripts/*.js test/all.js test/types.test-d.ts README.md CLAUDE.md DESIGN.md CHANGELOG.md CONTRIBUTING.md"
35
35
  },
36
36
  "tsd": {
37
37
  "directory": "test"