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/CHANGELOG.md +148 -0
- package/README.md +109 -48
- package/index.d.ts +18 -11
- package/index.js +130 -71
- package/package.json +30 -2
- package/.prettierrc +0 -12
- package/CLAUDE.md +0 -205
- package/examples/opentelemetry-example.js +0 -164
- package/examples/recording-example.js +0 -104
- package/jsconfig.json +0 -13
- package/test/all.js +0 -2446
- package/test/types.test-d.ts +0 -335
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
## [0.13.0] - 2026-09-22
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **`configureEffect` is additive.** Installing hooks no longer switches off hooks someone else installed: each call adds a layer on top of those already there and returns a function that removes that one layer, wherever it sits by then. Layers merge the way several configurations passed to one call always did: `onStep` and `onRun` nest with the earliest layer outermost, `onBeforeCommand` interceptors run in installation order, and `retry` merges with later layers winning. Calling it with no arguments removes every layer, while a call whose arguments are all `undefined` installs and removes nothing. Previously a later call replaced the hooks an earlier one had installed, and the returned function put back whatever had been configured before it.
|
|
10
|
+
- **A per-call `callConfig` merges over the hooks already installed** instead of replacing them one at a time, with the call innermost. Passing an `onStep` to `runEffect` used to switch the configured `onStep` off for that run, which is how `recordEffect` inside an application that already had tracing silently produced runs with no spans.
|
|
11
|
+
- **Every `Failure` carries the input the flow was called with**, however deep it came from. A `Failure` escaping a `Parallel` branch, a `Retry` fallback, or a sub-pipeline previously carried that subtree's own input or none.
|
|
12
|
+
- A Command continuation that returns nothing, at any position in a pipeline, rejects with an `EffectTypeError` naming the missing return, rather than resolving to a `Failure` carrying a bare `TypeError`.
|
|
13
|
+
- A `Success` no longer carries `initialInput`, so `assert.deepEqual(result, Success(v))` holds for every flow rather than only those whose last step returned a `Success` directly.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`runEffect`'s third argument takes `inherit`, a boolean.** `true`, the default, is the merge above; `false` leaves the installed hooks out of the run entirely, so anything the call does not set falls back to the library defaults, `retry` included. Anything other than a boolean throws a `TypeError`.
|
|
18
|
+
- A GitHub Actions workflow runs the test gate on Node 18 through 24, plus Prettier's format check.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **`replayEffect` under the default `hooks: false` still applies global `retry` defaults.** Ignoring the installed hooks for a replay dropped `retry` along with them, so a `Retry` that production ran under a configured `attempts` exhausted early on replay and reported a `Failure` production never saw.
|
|
23
|
+
|
|
24
|
+
## [0.12.0] - 2026-09-04
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **`replayEffect` returns `{ result, unreached }`** for a trace and `{ result }` for a `Resolver`, mirroring `recordEffect`'s `{ result, trace }`. `unreached` is the recorded entries the flow never asked for: a flow that stops issuing Commands before its recording ends mismatches nothing, so no `TimeParadox` fires and the replay can end in `Success` with steps left over. Previously the bare outcome was returned.
|
|
29
|
+
- **The `strict` replay option is removed.** Every trace since 0.9.0 carries paths, which are order-independent and paradox-detecting, so the option had nothing left to choose. A path-less trace is matched positionally, and a `Parallel` step in one is refused with a `ReplayError`.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Revived errors define `name` and `cause` as non-enumerable own properties, as a native `Error` keeps them, so `assert.deepEqual(replayed, result)` holds for a `Failure` carrying a plain error.
|
|
34
|
+
|
|
35
|
+
## [0.11.0] - 2026-09-01
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **`Retry(effect, { onExhausted })`** runs a fallback Effect when every attempt has failed, instead of returning the structured exhaustion `Failure`. The fallback's success feeds `next` and its failure propagates unwrapped. It executes under the retry's own path prefix so replays reproduce it, a cancelled `Parallel` branch skips it, and `fastRetry` rewrites it. Per-use only: the global `retry` defaults cannot carry one.
|
|
40
|
+
- `Parallel`'s `next` is optional, defaulting to `(values) => Success(values)`, matching `Command`.
|
|
41
|
+
- README sections **Composing Larger Flows** and **Which Errors Are Data**, documenting sub-pipeline branching, joining results from `Parallel`, local joins, and why there is no catch.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- Serialized errors carry `cause` recursively. It is non-enumerable on `Error`, so traces silently dropped it.
|
|
46
|
+
- `Retry`'s declared error type matches what `runEffect` returns: `RetryExhaustedError<E>` without `onExhausted`, the fallback's own error type with it. The declaration previously claimed `E`.
|
|
47
|
+
|
|
48
|
+
## [0.10.0] - 2026-08-28
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- **`Parallel` cancels its other branches when one fails.** The first branch to fail aborts an `AbortController` scoped to that `Parallel` and linked to any enclosing one. Cancelling is a request rather than a guarantee: a cancelled branch starts no further Commands, and a Command function that accepts the `AbortSignal` it is passed can be cut off in flight. Inside a `Parallel` the function is called as `cmd(signal)`; anywhere else it is called with no argument. `Parallel` still awaits every branch before returning, and the triggering `Failure` is returned rather than a cancellation.
|
|
53
|
+
|
|
54
|
+
## [0.9.0] - 2026-08-08
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
- **Time-travel debugging**: `recorder(options)` returns an `onStep` hook and a trace packager; `recordEffect(flow, input)` runs a flow for real and returns `{ result, trace }`; `replayEffect(effect, traceOrResolver)` replays a flow from a trace or a `Resolver` with no I/O; `timeTravel(flow, trace)` is a narrated replay with timings and divergence warnings. Every recorded step carries a `path`, its position in the flow, and a `durationMs`.
|
|
59
|
+
- `Command(cmd, next, meta)`: a non-empty `meta.name` is the Command's identity, then `cmd.name`, then `'anonymous'`, so an inline arrow or a minified function can still be named in a trace.
|
|
60
|
+
- `Command`'s `next` is optional, defaulting to `(result) => Success(result)`.
|
|
61
|
+
- `configureEffect` accepts several configurations and merges them, and returns a function that puts back whatever was configured before.
|
|
62
|
+
- A pipeline step returning something other than an Effect throws an `EffectTypeError` naming the step, instead of failing later with a bare `TypeError`.
|
|
63
|
+
- `examples/recording-example.js`, a reference for production recording with one recorder per run in an `AsyncLocalStorage` scope. Both examples are covered by tests.
|
|
64
|
+
|
|
65
|
+
## [0.8.0] - 2026-05-03
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- **`Parallel(effects, next)`** runs several flows at the same time with the same context. The first `Failure` by array index is returned; otherwise `next` receives the array of unwrapped success values.
|
|
70
|
+
|
|
71
|
+
## [0.7.0] - 2026-05-02
|
|
72
|
+
|
|
73
|
+
### Added
|
|
74
|
+
|
|
75
|
+
- **`Retry(effect, options)`** repeats the flow it wraps on failure with `attempts`, `delay`, and `backoff`, handled natively by `runEffect`. On exhaustion it returns `Failure({ retryExhausted: true, lastError, attempts })`. `configureEffect({ retry })` sets global defaults.
|
|
76
|
+
- `runEffect(effect, context, callConfig)`: per-call configuration overriding the global hooks and retry defaults.
|
|
77
|
+
- A `Ctx` type parameter flows through every Effect type and `effectPipe`, so `runEffect` checks the supplied context against what `Ask` reads.
|
|
78
|
+
|
|
79
|
+
### Changed
|
|
80
|
+
|
|
81
|
+
- `initialInput` is threaded through the pipeline by `chain` rather than attached after the flow is built.
|
|
82
|
+
|
|
83
|
+
### Fixed
|
|
84
|
+
|
|
85
|
+
- `onRun` fires once per `runEffect` call rather than once per `Retry` attempt.
|
|
86
|
+
|
|
87
|
+
## [0.6.0] - 2026-05-01
|
|
88
|
+
|
|
89
|
+
### Added
|
|
90
|
+
|
|
91
|
+
- **`Ask(next)`** reads the `context` passed to `runEffect` from any pipeline step, for ambient values such as tenant id or auth info.
|
|
92
|
+
|
|
93
|
+
## [0.5.0] - 2026-04-27
|
|
94
|
+
|
|
95
|
+
### Added
|
|
96
|
+
|
|
97
|
+
- **TypeScript declarations** in `index.d.ts` with full generics: `Success<T>`, `Failure<E>`, `Command`, `effectPipe` overloads, and `runEffect`'s return type. Type-level tests run under `tsd` as part of `npm test`.
|
|
98
|
+
|
|
99
|
+
## [0.4.0] - 2026-03-31
|
|
100
|
+
|
|
101
|
+
### Added
|
|
102
|
+
|
|
103
|
+
- `Command(cmd, next, meta)`: a `meta` object carried on the Command for hooks to read.
|
|
104
|
+
- `runEffect(effect, context)`: a context object passed to `onBeforeCommand`.
|
|
105
|
+
- `onBeforeCommand` hook, intercepting each Command before it runs.
|
|
106
|
+
|
|
107
|
+
### Changed
|
|
108
|
+
|
|
109
|
+
- `configureTelemetry` is renamed `configureEffect`.
|
|
110
|
+
|
|
111
|
+
## [0.3.0] - 2026-02-21
|
|
112
|
+
|
|
113
|
+
### Added
|
|
114
|
+
|
|
115
|
+
- `configureTelemetry({ onStep, onRun })`: hooks wrapping each Command and each run, with an OpenTelemetry example.
|
|
116
|
+
|
|
117
|
+
## [0.2.0] - 2025-11-29
|
|
118
|
+
|
|
119
|
+
### Added
|
|
120
|
+
|
|
121
|
+
- JSDoc type annotations throughout `index.js`.
|
|
122
|
+
|
|
123
|
+
## [0.1.2] - 2025-11-27
|
|
124
|
+
|
|
125
|
+
### Changed
|
|
126
|
+
|
|
127
|
+
- README revisions.
|
|
128
|
+
|
|
129
|
+
## [0.1.0] - 2025-11-24
|
|
130
|
+
|
|
131
|
+
### Added
|
|
132
|
+
|
|
133
|
+
- Initial release: `Success`, `Failure`, `Command`, `effectPipe`, and `runEffect`.
|
|
134
|
+
|
|
135
|
+
[0.13.0]: https://github.com/aycangulez/pure-effect/compare/v0.12.0...v0.13.0
|
|
136
|
+
[0.12.0]: https://github.com/aycangulez/pure-effect/compare/v0.11.0...v0.12.0
|
|
137
|
+
[0.11.0]: https://github.com/aycangulez/pure-effect/compare/v0.10.0...v0.11.0
|
|
138
|
+
[0.10.0]: https://github.com/aycangulez/pure-effect/compare/v0.9.0...v0.10.0
|
|
139
|
+
[0.9.0]: https://github.com/aycangulez/pure-effect/compare/v0.8.0...v0.9.0
|
|
140
|
+
[0.8.0]: https://github.com/aycangulez/pure-effect/compare/v0.7.0...v0.8.0
|
|
141
|
+
[0.7.0]: https://github.com/aycangulez/pure-effect/compare/v0.6.0...v0.7.0
|
|
142
|
+
[0.6.0]: https://github.com/aycangulez/pure-effect/compare/v0.5.0...v0.6.0
|
|
143
|
+
[0.5.0]: https://github.com/aycangulez/pure-effect/compare/v0.4.0...v0.5.0
|
|
144
|
+
[0.4.0]: https://github.com/aycangulez/pure-effect/compare/v0.3.0...v0.4.0
|
|
145
|
+
[0.3.0]: https://github.com/aycangulez/pure-effect/compare/v0.2.0...v0.3.0
|
|
146
|
+
[0.2.0]: https://github.com/aycangulez/pure-effect/compare/v0.1.2...v0.2.0
|
|
147
|
+
[0.1.2]: https://github.com/aycangulez/pure-effect/compare/v0.1.0...v0.1.2
|
|
148
|
+
[0.1.0]: https://github.com/aycangulez/pure-effect/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Pure Effect
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/pure-effect) [](https://www.npmjs.com/package/pure-effect) [](https://bundlejs.com/?q=pure-effect) [](https://github.com/aycangulez/pure-effect/blob/main/LICENSE)
|
|
4
4
|
|
|
5
5
|
**Pure Effect** 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.
|
|
6
6
|
|
|
@@ -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, 4 KB minified and gzipped
|
|
12
|
+
- Zero dependencies, about 4 KB minified and gzipped
|
|
13
13
|
- Works in JavaScript and TypeScript (full generics, bundled `.d.ts`)
|
|
14
14
|
|
|
15
15
|
## Table of Contents
|
|
@@ -67,7 +67,7 @@ const saveUser = (input) => {
|
|
|
67
67
|
// Build the flow. Validation runs now; no I/O happens until runEffect.
|
|
68
68
|
const registerUserFlow = (input) => effectPipe(validateRegistration, ensureEmailAvailable, saveUser)(input);
|
|
69
69
|
|
|
70
|
-
//
|
|
70
|
+
// This is the only place side effects actually run
|
|
71
71
|
async function registerUser(input) {
|
|
72
72
|
const result = await runEffect(registerUserFlow(input));
|
|
73
73
|
|
|
@@ -81,22 +81,48 @@ async function registerUser(input) {
|
|
|
81
81
|
|
|
82
82
|
## Testing Without Mocks
|
|
83
83
|
|
|
84
|
-
Because pipelines return plain objects, you can assert on _what the code intends to do_ without executing any of it
|
|
84
|
+
Because pipelines return plain objects, you can assert on _what the code intends to do_ without executing any of it.
|
|
85
|
+
|
|
86
|
+
Validation runs as soon as you build the flow, so testing it needs nothing else:
|
|
85
87
|
|
|
86
88
|
```js
|
|
87
|
-
// 1. Test validation failure synchronously
|
|
88
89
|
const badInput = { email: 'bad-email', password: '123' };
|
|
89
90
|
assert.deepEqual(registerUserFlow(badInput), Failure('Invalid email.', badInput));
|
|
91
|
+
```
|
|
90
92
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
Next, test the steps. Hand a step an answer and check what it returns:
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
const input = { email: 'test@test.com', password: 'password123' };
|
|
97
|
+
|
|
98
|
+
// The email is free, so the step passes the input on.
|
|
99
|
+
assert.deepEqual(ensureEmailAvailable(input).next(null), Success(input));
|
|
100
|
+
|
|
101
|
+
// The email is taken, so the flow stops here.
|
|
102
|
+
assert.deepEqual(ensureEmailAvailable(input).next({ id: 1 }), Failure('Email already in use.'));
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Then test the flow: the right calls, in the right order.
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
const step1 = registerUserFlow(input);
|
|
93
109
|
assert.equal(step1.cmd.name, 'cmdFindUser');
|
|
94
110
|
|
|
95
|
-
const step2 = step1.next(null); //
|
|
111
|
+
const step2 = step1.next(null); // pretend no user was found
|
|
96
112
|
assert.equal(step2.cmd.name, 'cmdSaveUser');
|
|
97
|
-
// The
|
|
113
|
+
// The database was never touched.
|
|
98
114
|
```
|
|
99
115
|
|
|
116
|
+
Write both kinds. They catch different things, and the step tests are the ones that catch real bugs, because they are the only place you can see the value moving from one step to the next.
|
|
117
|
+
|
|
118
|
+
Say the email guard returned `Success(true)` by mistake, instead of `Success(input)`. The step test fails at once: it asked for the input back and got `true`. The flow test does not fail. `cmdFindUser` is still the first call and `cmdSaveUser` is still the second, so both assertions hold, and the flow saves `true` to the database instead of the user. That bug was in this README once, and it looked fine.
|
|
119
|
+
|
|
120
|
+
Two smaller things to know.
|
|
121
|
+
|
|
122
|
+
A step tested on its own has no `initialInput` on its `Failure`. That is why the assertion above is `Failure('Email already in use.')` with nothing after the message, while the validation one is `Failure('Invalid email.', badInput)`. `effectPipe` adds that value while it builds a flow, so a `Failure` only carries it when it came out of a flow.
|
|
123
|
+
|
|
124
|
+
Nothing can see inside a Command's function without running it. If `cmdFindUser` said `db.findUser(input.name)` instead of `input.email`, every test on this page would still pass. Keep those functions to a single call, and let an integration test cover them.
|
|
125
|
+
|
|
100
126
|
## How It Works
|
|
101
127
|
|
|
102
128
|
A flow is a chain of pairs: an I/O call to make, plus a `next` function that receives its answer and returns whatever comes after, another Command, a Success, or a Failure. `runEffect` walks that chain in a loop.
|
|
@@ -124,7 +150,7 @@ Replaying 'checkout' (3 recorded steps)
|
|
|
124
150
|
Initial input: { "cartId": "cart_abc123", "promoCode": "FREE_YEAR_VIP" }
|
|
125
151
|
Step 1: cmdFetchCart returned { "totalAmount": "120.00" }
|
|
126
152
|
Step 2: cmdValidatePromo returned { "isValid": true, "discountValue": 100 }
|
|
127
|
-
Step 3:
|
|
153
|
+
Step 3: cmdChargeCard threw { "message": "Amount must be non-zero.", "code": "invalid_amount" }
|
|
128
154
|
Replay finished with state: Failure
|
|
129
155
|
```
|
|
130
156
|
|
|
@@ -140,7 +166,7 @@ it('prod incident 8f3a: a 100% promo produces a $0 charge', async () => {
|
|
|
140
166
|
|
|
141
167
|
The test verifies that the flow still takes the recorded path and handles the recorded outcomes the same way: a refactor that reorders or replaces a step raises a `TimeParadox` naming the path it diverged at, and changed error handling fails the assertion.
|
|
142
168
|
|
|
143
|
-
One divergence is quieter. A flow that stops issuing Commands before the recording ends (a fix that skips the charge, say) mismatches nothing, so
|
|
169
|
+
One divergence is quieter. A flow that stops issuing Commands before the recording ends (a fix that skips the charge, say) mismatches nothing, so nothing mismatches and the replay can end in `Success` with recorded steps left over. `replayEffect` returns those steps as `unreached` beside the result, so a test can say which ones it expects to skip, and fail if the answer changes:
|
|
144
170
|
|
|
145
171
|
```js
|
|
146
172
|
it('incident 8f3a fixed: a 100% promo checks out without a charge', async () => {
|
|
@@ -155,9 +181,11 @@ it('incident 8f3a fixed: a 100% promo checks out without a charge', async () =>
|
|
|
155
181
|
|
|
156
182
|
For every other recording of the flow, `unreached` should be empty: a fix that skips a step it was not meant to skip then fails loudly instead of passing.
|
|
157
183
|
|
|
158
|
-
**
|
|
184
|
+
**A trace records what each Command returned, not what it was asked.** It does not need to. Given the recorded input and the recorded results, the flow does the same thing again, so replaying to a step rebuilds the exact arguments that Command ran with, in the real code, under a debugger if you want one. Storing them as well would only double what `redact` has to cover, since arguments are usually the sensitive half of a call.
|
|
159
185
|
|
|
160
|
-
**
|
|
186
|
+
**Anything that varies between runs belongs inside a Command.** The current time or a random ID counts as I/O as far as replay is concerned. Wrap it in a Command and it is recorded and replayed like any other response; a step that calls `Date.now()` directly computes a fresh value on every replay and silently diverges from the trace.
|
|
187
|
+
|
|
188
|
+
**Checking this takes one round trip.** Record a flow, replay it immediately, and compare the outcomes. A step that computes a fresh nondeterministic value surfaces as a `TimeParadox` when it changes which Commands run, or as a mismatch in the final value:
|
|
161
189
|
|
|
162
190
|
```js
|
|
163
191
|
const { result, trace } = await recordEffect(registerUserFlow, input);
|
|
@@ -220,7 +248,7 @@ Recording stores the context alongside the trace, so `Ask` replays with the valu
|
|
|
220
248
|
|
|
221
249
|
## Retrying Transient Failures
|
|
222
250
|
|
|
223
|
-
`Retry`
|
|
251
|
+
`Retry` runs part of a flow again when it fails. Like everything else in Pure Effect, the retry configuration is a plain object you can inspect and assert on without running anything.
|
|
224
252
|
|
|
225
253
|
**Wrap the Command that fails, not the pipeline.** Every attempt re-runs the whole wrapped tree, including Commands that already succeeded:
|
|
226
254
|
|
|
@@ -232,7 +260,7 @@ Retry(effectPipe(chargeCard, sendReceipt)(order), { attempts: 3 });
|
|
|
232
260
|
effectPipe(chargeCard, (charge) => Retry(sendReceipt(charge), { attempts: 3 }))(order);
|
|
233
261
|
```
|
|
234
262
|
|
|
235
|
-
Wrapping a pipeline is safe only when every Command in it is idempotent.
|
|
263
|
+
Wrapping a pipeline is safe only when every Command in it is idempotent (safe to run more than once).
|
|
236
264
|
|
|
237
265
|
```js
|
|
238
266
|
import { Success, Failure, Command, Retry, runEffect } from 'pure-effect';
|
|
@@ -253,7 +281,7 @@ assert.equal(weatherFn.options.attempts, 3);
|
|
|
253
281
|
|
|
254
282
|
When all attempts are exhausted, `runEffect` returns a structured `Failure`:
|
|
255
283
|
|
|
256
|
-
```
|
|
284
|
+
```text
|
|
257
285
|
{ retryExhausted: true, lastError: <the last error>, attempts: 3 }
|
|
258
286
|
```
|
|
259
287
|
|
|
@@ -269,13 +297,13 @@ const fetchPrice = (sku) =>
|
|
|
269
297
|
});
|
|
270
298
|
```
|
|
271
299
|
|
|
272
|
-
Fallback steps are recorded under their own trace paths, so a replay reproduces the fallback exactly, and a fallback never starts in a `Parallel` branch that a sibling's failure has already cancelled. With `onExhausted` set, the exhaustion error never escapes, so in TypeScript the
|
|
300
|
+
Fallback steps are recorded under their own trace paths, so a replay reproduces the fallback exactly, and a fallback never starts in a `Parallel` branch that a sibling's failure has already cancelled. With `onExhausted` set, the exhaustion error never escapes, so in TypeScript the `Retry` contributes the fallback's error type to the pipeline's union instead of `RetryExhaustedError<E>`.
|
|
273
301
|
|
|
274
302
|
Every attempt is a recorded step, so a replay reproduces the exact sequence of failures. It also skips the delays.
|
|
275
303
|
|
|
276
304
|
## Running Effects in Parallel
|
|
277
305
|
|
|
278
|
-
`Parallel` runs
|
|
306
|
+
`Parallel` runs several flows at the same time and passes their results to `next` as an ordered array. The first branch to fail cancels its siblings, `next` is not called, and that branch's `Failure` is what propagates.
|
|
279
307
|
|
|
280
308
|
```js
|
|
281
309
|
import { Success, Command, Parallel } from 'pure-effect';
|
|
@@ -288,7 +316,7 @@ const loadProfile = (userId) =>
|
|
|
288
316
|
|
|
289
317
|
`Ask` context flows into all parallel branches without any extra wiring.
|
|
290
318
|
|
|
291
|
-
**
|
|
319
|
+
**Cancelling is a request, not a guarantee, and it works at two levels.** A cancelled branch starts no further Commands. For example, a three-step branch whose first step is in flight when a sibling fails runs that step and stops. Cancelling the step already in flight needs the function to accept the `AbortSignal` it is handed and pass it to whatever performs the I/O:
|
|
292
320
|
|
|
293
321
|
```js
|
|
294
322
|
// Cancellable: the request is aborted the moment a sibling branch fails.
|
|
@@ -302,9 +330,9 @@ Outside a `Parallel` the function is called with no arguments at all, so nothing
|
|
|
302
330
|
|
|
303
331
|
## Composing Larger Flows
|
|
304
332
|
|
|
305
|
-
`effectPipe` is a straight line, but flows rarely are. Branching and
|
|
333
|
+
`effectPipe` is a straight line, but flows rarely are. Branching and joining work with the pieces already here. Here is how:
|
|
306
334
|
|
|
307
|
-
**A step can return a sub-pipeline.** `effectPipe(...)(value)` returns an Effect like any other, so a step can branch into a whole sub-flow, and the sub-flow's `Failure`
|
|
335
|
+
**A step can return a sub-pipeline.** `effectPipe(...)(value)` returns an Effect like any other, so a step can branch into a whole sub-flow, and the sub-flow's `Failure` stops the outer pipeline exactly like a local one:
|
|
308
336
|
|
|
309
337
|
```js
|
|
310
338
|
const processOrder = (order) => (order.isGift ? giftFlow(order) : standardFlow(order));
|
|
@@ -312,7 +340,7 @@ const processOrder = (order) => (order.isGift ? giftFlow(order) : standardFlow(o
|
|
|
312
340
|
const fulfillment = effectPipe(validateOrder, processOrder, scheduleShipping);
|
|
313
341
|
```
|
|
314
342
|
|
|
315
|
-
**
|
|
343
|
+
**Joining values that do not depend on each other: `Parallel`.** When a later step needs several such values, run them at the same time and collect the results. With `next` omitted, the branch results arrive as an ordered array:
|
|
316
344
|
|
|
317
345
|
```js
|
|
318
346
|
const loadCheckout = effectPipe(
|
|
@@ -321,7 +349,7 @@ const loadCheckout = effectPipe(
|
|
|
321
349
|
);
|
|
322
350
|
```
|
|
323
351
|
|
|
324
|
-
**
|
|
352
|
+
**Joining values that do depend on each other: join locally.** When step B needs step A's result and step C needs both, carry both forward in a value shaped for the next step. A local sub-pipeline that closes over its own parameter makes the join without nested callbacks:
|
|
325
353
|
|
|
326
354
|
```js
|
|
327
355
|
const applyLoyaltyDiscount = (orderId) =>
|
|
@@ -336,7 +364,7 @@ const applyLoyaltyDiscount = (orderId) =>
|
|
|
336
364
|
|
|
337
365
|
## Which Errors Are Data
|
|
338
366
|
|
|
339
|
-
There is no catch
|
|
367
|
+
There is no catch, and that is deliberate. A flow's outcomes divide into two kinds, and the division decides how each is written.
|
|
340
368
|
|
|
341
369
|
**An outcome the flow handles is data.** A Command's `next` receives the result and can branch into any Effect, including a whole fallback sub-pipeline. When the I/O itself can reject, catch inside the Command's `cmd` function and return the miss as a value:
|
|
342
370
|
|
|
@@ -358,9 +386,9 @@ const fetchPrice = (sku) => {
|
|
|
358
386
|
|
|
359
387
|
The failed live attempt is recorded as that Command's result, error included, so a replay takes the same fallback branch and an incident trace still becomes a regression test. Nothing is hidden; the miss is simply categorized as what it is, an outcome the flow was written to handle.
|
|
360
388
|
|
|
361
|
-
**A `Failure` means abort.** It
|
|
389
|
+
**A `Failure` means abort.** It stops everything and lands in the shell, which is the one place that decides what a dead flow means: an HTTP status, a queue retry, an alert. Reserving `Failure` for outcomes the flow cannot handle keeps its meaning brutally simple; a reader never has to scan up the tree for a handler, because there is none. A catch would reintroduce exactly the non-local control flow that writing effects as data is meant to eliminate.
|
|
362
390
|
|
|
363
|
-
The rule of thumb: if you would handle it, return it; if you would only report it, fail with it. The one handled outcome that cannot be modeled as data is retry exhaustion, since the failing happens inside
|
|
391
|
+
The rule of thumb: if you would handle it, return it; if you would only report it, fail with it. The one handled outcome that cannot be modeled as data is retry exhaustion, since the failing happens inside `Retry`; that case has its own in-flow form, the `onExhausted` option in [Retrying Transient Failures](#retrying-transient-failures).
|
|
364
392
|
|
|
365
393
|
## TypeScript: Typed Errors and Context
|
|
366
394
|
|
|
@@ -408,7 +436,7 @@ const result = await runEffect(findProduct('abc'), { tenant: 'acme', requestId:
|
|
|
408
436
|
|
|
409
437
|
## API Reference
|
|
410
438
|
|
|
411
|
-
###
|
|
439
|
+
### Building blocks
|
|
412
440
|
|
|
413
441
|
#### `Success(value)`
|
|
414
442
|
|
|
@@ -430,7 +458,9 @@ Returns `{ type: 'Command', cmd, next, meta }`.
|
|
|
430
458
|
|
|
431
459
|
```js
|
|
432
460
|
Command(cmdFn, next, { name: 'chargeCard' }); // 1. meta.name, independent of how cmdFn was written
|
|
433
|
-
Command(function cmdChargeCard() {
|
|
461
|
+
Command(function cmdChargeCard() {
|
|
462
|
+
return api.charge();
|
|
463
|
+
}, next); // 2. the function's own name
|
|
434
464
|
Command(() => api.charge(), next); // 3. neither, so 'anonymous'
|
|
435
465
|
```
|
|
436
466
|
|
|
@@ -479,14 +509,14 @@ One shape to avoid in either language:
|
|
|
479
509
|
|
|
480
510
|
Commands are data, so a Command that is constructed and discarded never runs, and a `Failure` it would have produced is swallowed. Return the effect itself, or have that Command's own `next` return the value the rest of the pipeline needs.
|
|
481
511
|
|
|
482
|
-
###
|
|
512
|
+
### Running a flow
|
|
483
513
|
|
|
484
514
|
#### `runEffect(effect, context?, callConfig?)`
|
|
485
515
|
|
|
486
|
-
|
|
516
|
+
Walks the flow, runs each Command with `async/await`, resolves `Ask` with the supplied `context`, and returns the final `Success` or `Failure`.
|
|
487
517
|
|
|
488
518
|
- `context`: Passed to `Ask`'s next function and to `onBeforeCommand`. `context.flowName` names the workflow in telemetry.
|
|
489
|
-
- `callConfig`: Per-call
|
|
519
|
+
- `callConfig`: Per-call `onStep`, `onRun`, `onBeforeCommand`, and `retry`, added to the `configureEffect` wiring unless `inherit: false`, which ignores that wiring for the run. See [`configureEffect`](#configureeffectconfigs).
|
|
490
520
|
- `onRun` fires exactly once per `runEffect` call. Retry attempts run inside that single span.
|
|
491
521
|
|
|
492
522
|
A step that returns something other than an Effect is a bug in the flow, not a domain failure, so it throws an `EffectTypeError` naming the step rather than resolving to a `Failure`:
|
|
@@ -505,25 +535,57 @@ The same check catches a missing `return`, a Command's next function returning a
|
|
|
505
535
|
- `onBeforeCommand(command, context)` fires before each Command; throw to abort.
|
|
506
536
|
- `retry: { attempts?, delay?, backoff? }` global retry defaults.
|
|
507
537
|
|
|
508
|
-
|
|
538
|
+
Each call adds a layer of hooks on top of those already installed and returns a function that removes that layer. Layers merge, and several configurations passed to one call are merged the same way, so the two forms below are equivalent. Everything about the merge is visible in what runs during a single `runEffect`:
|
|
509
539
|
|
|
510
540
|
```js
|
|
511
541
|
configureEffect(telemetryHooks(), recordingHooks({ sink }));
|
|
542
|
+
|
|
543
|
+
// or, as two layers:
|
|
544
|
+
configureEffect(telemetryHooks());
|
|
545
|
+
configureEffect(recordingHooks({ sink }));
|
|
512
546
|
```
|
|
513
547
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
548
|
+
```
|
|
549
|
+
runEffect(flow(input))
|
|
550
|
+
β
|
|
551
|
+
ββ telemetry.onRun first configuration: outermost
|
|
552
|
+
β ββ recording.onRun last configuration: innermost
|
|
553
|
+
β β
|
|
554
|
+
β β for each Command:
|
|
555
|
+
β ββ telemetry.onBeforeCommand interceptors all run, in the order given
|
|
556
|
+
β ββ recording.onBeforeCommand
|
|
557
|
+
β ββ telemetry.onStep
|
|
558
|
+
β β ββ recording.onStep closest to the Command
|
|
559
|
+
β β ββ cmd()
|
|
560
|
+
β β ββ recording.onStep returns a result, or a thrown error, unwinds
|
|
561
|
+
β ββ telemetry.onStep returns from the inside out
|
|
562
|
+
β β
|
|
563
|
+
β ββ recording.onRun returns
|
|
564
|
+
ββ telemetry.onRun returns
|
|
565
|
+
β
|
|
566
|
+
retry: { ...telemetry.retry, ...recording.retry } later configurations win
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
Removing a layer takes out exactly that layer, whatever was installed after it, so a library can add its own hooks without touching its host's and give them back when it is done. Calling `configureEffect()` with no arguments removes every layer. A call whose arguments are all `undefined`, such as a conditional `configureEffect(flag ? hooks : undefined)`, installs nothing and removes nothing.
|
|
519
570
|
|
|
520
571
|
```js
|
|
521
|
-
const
|
|
572
|
+
const remove = configureEffect(telemetryHooks());
|
|
522
573
|
// ... later
|
|
523
|
-
|
|
574
|
+
remove();
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
**A per-call `callConfig` is added to the configured wiring, or ignores it.** By default the call's hooks merge over the configured ones by the same rules as above: wrappers nest with the configured one outside, interceptors run configured first, and `retry` combines with the call's values winning. `inherit: false` leaves the configured wiring out of the run entirely. Given a configured wiring `C` and a call `K` that supplies only `onStep` and `retry`:
|
|
578
|
+
|
|
579
|
+
```
|
|
580
|
+
inherit: true (default) inherit: false
|
|
581
|
+
|
|
582
|
+
onRun C.onRun (none)
|
|
583
|
+
onBeforeCommand C.onBeforeCommand (none)
|
|
584
|
+
onStep C.onStep( K.onStep( cmd ) ) K.onStep
|
|
585
|
+
retry { ...C.retry, ...K.retry } { ...defaults, ...K.retry }
|
|
524
586
|
```
|
|
525
587
|
|
|
526
|
-
|
|
588
|
+
`recordEffect` inherits, so recording inside an application that already has tracing keeps its spans. `replayEffect` passes `inherit: false` unless `hooks: true`.
|
|
527
589
|
|
|
528
590
|
### Recording and replay
|
|
529
591
|
|
|
@@ -531,7 +593,7 @@ restore();
|
|
|
531
593
|
|
|
532
594
|
Returns `{ onStep, entries, toTrace }`. Pass `onStep` to `runEffect` or `configureEffect` to record what every Command returned.
|
|
533
595
|
|
|
534
|
-
Each entry is `{ command, path, result, durationMs }`, or `{ command, path, error, durationMs }` when the Command threw, so a trace also answers which step was slow. `path` is the Command's position in the
|
|
596
|
+
Each entry is `{ command, path, result, durationMs }`, or `{ command, path, error, durationMs }` when the Command threw, so a trace also answers which step was slow. `path` is the Command's position in the flow, which is what a replay matches on. Results are snapshotted on capture, so a later step that mutates a returned object cannot rewrite what the trace says an earlier step saw. Values that cannot be structurally cloned, such as an object holding a function, are stored by reference instead. Recording cannot change a run: a `redact` that throws records `'[redaction failed]'` for that step instead of failing the flow.
|
|
535
597
|
|
|
536
598
|
- `options.redact(value, name, kind)`: The single place PII is kept out of a trace. It sees every value a trace holds, with `kind` distinguishing them:
|
|
537
599
|
|
|
@@ -557,13 +619,13 @@ Runs a flow for real while recording, returning `{ result, trace }`. Accepts `re
|
|
|
557
619
|
|
|
558
620
|
#### `replayEffect(effect, traceOrResolver, options?)`
|
|
559
621
|
|
|
560
|
-
Replays
|
|
622
|
+
Replays a flow, feeding recorded results to Commands instead of running them. Returns `{ result, unreached }`: the flow's outcome, and the recorded entries the flow never asked for (empty when every step was reached). A flow that stops early mismatches nothing and raises no `TimeParadox`, so `unreached` is where that divergence shows. For a resolver only `{ result }` is returned, since only a trace knows what it holds.
|
|
561
623
|
|
|
562
624
|
- `traceOrResolver`: a trace (or bare entries array) to replay directly, or a resolver function for traces stored in some other shape. A resolver returns `{ result }`, `{ error }`, or `undefined` if the step is unrecorded. A malformed trace rejects with a `ReplayError`.
|
|
563
625
|
- `options.context`: context for `Ask`; pass the recorded context.
|
|
564
626
|
- `options.onMissing`: `'throw'` (default) fails on an unrecorded step; `'execute'` runs the real Command, giving a recorded prefix with a live tail.
|
|
565
627
|
- `options.fastRetry` (default `true`): strip `Retry` delays.
|
|
566
|
-
- `options.hooks` (default `false`):
|
|
628
|
+
- `options.hooks` (default `false`): run the replay inside the global hooks, resolver innermost, so configured hooks observe it, a global recorder included. Off ignores the global hooks, so a replay cannot reach a telemetry backend or a trace sink. Global `retry` defaults apply either way, since a replay has to make the attempts production made.
|
|
567
629
|
- `options.onResolved(step, outcome)`: observe each replayed step.
|
|
568
630
|
|
|
569
631
|
A trace whose entries carry no `path` (written by hand, or recorded before paths existed) is matched positionally, which is exact for a sequential flow; a `Parallel` step in such a trace is refused with a `ReplayError`, since completion order cannot tell its branches apart.
|
|
@@ -574,11 +636,10 @@ Replays a trace and narrates each step with its recorded duration, naming any re
|
|
|
574
636
|
|
|
575
637
|
## Limitations
|
|
576
638
|
|
|
577
|
-
- **`Retry` repeats the whole wrapped tree.** Commands that already succeeded run again on every attempt, so wrapping a pipeline re-executes its side effects. Wrap the single Command that fails transiently unless every Command in
|
|
578
|
-
-
|
|
579
|
-
- **A `Failure` is complete by design.** It carries the full error and the `initialInput` that `effectPipe`
|
|
639
|
+
- **`Retry` repeats the whole wrapped tree.** Commands that already succeeded run again on every attempt, so wrapping a pipeline re-executes its side effects. Wrap the single Command that fails transiently unless every Command in it is safe to run more than once.
|
|
640
|
+
- **Cancelling a `Parallel` branch is a request, so it cannot stop everything.** A cancelled branch starts no further Commands, and a function that accepts the `AbortSignal` it is passed can be cut off in flight. A function that ignores the signal cannot: it runs to completion, so a branch whose _first_ Command is a write can still write after a sibling has failed. `Parallel` also waits for every branch to settle before returning, deliberately, so no cancelled work is left running unobserved after the `Failure` is returned.
|
|
641
|
+
- **A `Failure` is complete by design.** It carries the full error and the `initialInput` that `effectPipe` attached to it, and neither is trimmed, because a test asserting on a Failure and a developer debugging one both need everything. Keeping PII out of a **trace** is `redact`'s job. Keeping it out of your **logs** is the shell's: log `result.error` rather than serializing the whole `Failure`, which for a login or registration flow holds the credentials that flow received.
|
|
580
642
|
- **Replay reproduces observed inputs, not concurrency.** A stale read replays exactly. A race between two concurrent requests does not: a trace records one flow's view. `Parallel` branches replay with the results their own branch saw, since every step is matched by its position in the tree, but the interleaving between them is not reproduced, so a flow whose branches race each other through shared state is not something a replay can settle.
|
|
581
|
-
- **A trace records responses, not requests.** Command arguments live in a closure and are unreachable, so a trace shows what a call returned but not what was passed to it. This also bounds what a replay verifies: the flow's path and its handling of recorded outcomes. The upside is that arguments can never leak into a trace.
|
|
582
643
|
- **Global configuration is per module instance.** `configureEffect` writes to module-level state, so two copies of the library in one process, from a dual ESM and CJS resolution, two versions in a dependency tree, or a worker thread, each carry their own wiring. Code that configures one gets nothing in the other, with no error.
|
|
583
644
|
- **A Command needs an identity.** `meta.name` is stable under minification; relying on `cmd.name` instead means a mangler renames every step of every trace, so mangling has to be disabled or names preserved.
|
|
584
|
-
- **`Retry` delays cannot be overridden from `callConfig`.** Per-use options are merged over call config, so a delay written at the call site always wins. `replayEffect` works around this by rewriting `Retry`
|
|
645
|
+
- **`Retry` delays cannot be overridden from `callConfig`.** Per-use options are merged over call config, so a delay written at the call site always wins. `replayEffect` works around this by rewriting the `Retry`s in the flow; other callers cannot.
|
package/index.d.ts
CHANGED
|
@@ -296,22 +296,26 @@ export interface EffectConfiguration {
|
|
|
296
296
|
}
|
|
297
297
|
|
|
298
298
|
/**
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
* Returns a function that puts back whatever was installed when this call was made, so a caller can
|
|
306
|
-
* install hooks without owning the global wiring forever. Restoring is a snapshot rather than a stack:
|
|
307
|
-
* if a later call has run since, restoring reverts to the older snapshot and discards the newer one.
|
|
299
|
+
* Adds a layer to the global runner's wiring and returns a function that removes it. Layers merge:
|
|
300
|
+
* `onStep` and `onRun` nest with the earliest layer outermost, `onBeforeCommand` interceptors all run
|
|
301
|
+
* in the order installed, and `retry` merges with later layers winning. Several configurations passed
|
|
302
|
+
* to one call form one layer, which is the same as installing them in separate calls. Calling with no
|
|
303
|
+
* arguments at all removes every layer; a call whose arguments are all `undefined` adds and removes nothing.
|
|
308
304
|
*/
|
|
309
305
|
export declare function configureEffect(...configs: (EffectConfiguration | undefined)[]): () => void;
|
|
310
306
|
|
|
307
|
+
/**
|
|
308
|
+
* A per-call configuration for `runEffect`. With `inherit: true` (default) the call's hooks are added to
|
|
309
|
+
* the wiring `configureEffect` installed: where both define a hook the two nest, global outermost, and
|
|
310
|
+
* `retry` merges with the call winning. With `inherit: false` the global wiring is ignored, so unset
|
|
311
|
+
* slots fall back to the library defaults.
|
|
312
|
+
*/
|
|
313
|
+
export type CallConfiguration = EffectConfiguration & { inherit?: boolean };
|
|
314
|
+
|
|
311
315
|
export declare function runEffect<T, E = unknown, Ctx = unknown>(
|
|
312
316
|
effect: Effect<T, E, Ctx>,
|
|
313
317
|
context?: Ctx,
|
|
314
|
-
callConfig?:
|
|
318
|
+
callConfig?: CallConfiguration
|
|
315
319
|
): Promise<SuccessState<T> | FailureState<E>>;
|
|
316
320
|
|
|
317
321
|
export type ReplayStep = {
|
|
@@ -402,7 +406,10 @@ export interface ReplayOptions<Ctx = unknown> {
|
|
|
402
406
|
context?: Ctx;
|
|
403
407
|
/** Strip `Retry` delays so a replay does not wait out production backoff (default `true`). */
|
|
404
408
|
fastRetry?: boolean;
|
|
405
|
-
/**
|
|
409
|
+
/**
|
|
410
|
+
* Run the replay inside the global hooks, resolver innermost, so configured hooks observe it
|
|
411
|
+
* (default `false`, which ignores the global hooks). Global `retry` defaults apply either way.
|
|
412
|
+
*/
|
|
406
413
|
hooks?: boolean;
|
|
407
414
|
/**
|
|
408
415
|
* What to do when the Resolver has no recording for a step.
|