pure-effect 0.11.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 +132 -52
- package/index.d.ts +53 -25
- package/index.js +216 -131
- package/package.json +31 -3
- package/.prettierrc +0 -12
- package/CLAUDE.md +0 -202
- package/examples/opentelemetry-example.js +0 -164
- package/examples/recording-example.js +0 -104
- package/test/all.js +0 -2265
- package/test/types.test-d.ts +0 -268
- package/tsconfig.check.json +0 -12
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,25 +81,51 @@ 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
|
+
```
|
|
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.
|
|
90
106
|
|
|
91
|
-
|
|
92
|
-
const step1 = registerUserFlow(
|
|
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
|
-
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.
|
|
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.
|
|
103
129
|
|
|
104
130
|
Recording and replay attach at the single point where a Command's function is called: recording wraps that call to write down each answer, and replay substitutes the recorded answer without making the call, which is why a replayed flow performs no I/O.
|
|
105
131
|
|
|
@@ -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
|
|
|
@@ -132,24 +158,43 @@ A production incident becomes a permanent test of the flow's logic, with no mock
|
|
|
132
158
|
|
|
133
159
|
```js
|
|
134
160
|
it('prod incident 8f3a: a 100% promo produces a $0 charge', async () => {
|
|
135
|
-
const result = await replayEffect(checkoutFlow(trace.initialInput), trace);
|
|
161
|
+
const { result } = await replayEffect(checkoutFlow(trace.initialInput), trace);
|
|
136
162
|
assert.equal(result.type, 'Failure');
|
|
137
163
|
assert.equal(result.error.code, 'invalid_amount');
|
|
138
164
|
});
|
|
139
165
|
```
|
|
140
166
|
|
|
141
|
-
The test verifies that the flow still takes the recorded path and handles the recorded outcomes the same way: a refactor that reorders or
|
|
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
|
-
|
|
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
|
|
172
|
+
it('incident 8f3a fixed: a 100% promo checks out without a charge', async () => {
|
|
173
|
+
const { result, unreached } = await replayEffect(checkoutFlow(trace.initialInput), trace);
|
|
174
|
+
assert.equal(result.type, 'Success');
|
|
175
|
+
assert.deepEqual(
|
|
176
|
+
unreached.map((e) => e.command),
|
|
177
|
+
['cmdChargeCard']
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
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.
|
|
183
|
+
|
|
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.
|
|
185
|
+
|
|
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:
|
|
146
189
|
|
|
147
190
|
```js
|
|
148
191
|
const { result, trace } = await recordEffect(registerUserFlow, input);
|
|
149
|
-
const replayed = await replayEffect(registerUserFlow(input), trace);
|
|
192
|
+
const { result: replayed } = await replayEffect(registerUserFlow(input), trace);
|
|
150
193
|
assert.deepEqual(replayed, result); // fails if any step computed a fresh value
|
|
151
194
|
```
|
|
152
195
|
|
|
196
|
+
The comparison holds for a `Failure` as well: an error that crossed the trace is revived with the same message, name, cause, and custom properties, and compares deep-equal to the one the Command threw. The one exception is an error class of your own, which revives as a plain `Error` carrying that class's name; compare `error.name` and `error.message` there.
|
|
197
|
+
|
|
153
198
|
**The trace format is yours.** `replayEffect` also takes a resolver function in place of a trace, so OpenTelemetry spans, a log pipeline, or a database table work as well as the JSON that `recorder` produces.
|
|
154
199
|
|
|
155
200
|
```js
|
|
@@ -203,7 +248,7 @@ Recording stores the context alongside the trace, so `Ask` replays with the valu
|
|
|
203
248
|
|
|
204
249
|
## Retrying Transient Failures
|
|
205
250
|
|
|
206
|
-
`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.
|
|
207
252
|
|
|
208
253
|
**Wrap the Command that fails, not the pipeline.** Every attempt re-runs the whole wrapped tree, including Commands that already succeeded:
|
|
209
254
|
|
|
@@ -215,7 +260,7 @@ Retry(effectPipe(chargeCard, sendReceipt)(order), { attempts: 3 });
|
|
|
215
260
|
effectPipe(chargeCard, (charge) => Retry(sendReceipt(charge), { attempts: 3 }))(order);
|
|
216
261
|
```
|
|
217
262
|
|
|
218
|
-
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).
|
|
219
264
|
|
|
220
265
|
```js
|
|
221
266
|
import { Success, Failure, Command, Retry, runEffect } from 'pure-effect';
|
|
@@ -236,7 +281,7 @@ assert.equal(weatherFn.options.attempts, 3);
|
|
|
236
281
|
|
|
237
282
|
When all attempts are exhausted, `runEffect` returns a structured `Failure`:
|
|
238
283
|
|
|
239
|
-
```
|
|
284
|
+
```text
|
|
240
285
|
{ retryExhausted: true, lastError: <the last error>, attempts: 3 }
|
|
241
286
|
```
|
|
242
287
|
|
|
@@ -252,13 +297,13 @@ const fetchPrice = (sku) =>
|
|
|
252
297
|
});
|
|
253
298
|
```
|
|
254
299
|
|
|
255
|
-
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>`.
|
|
256
301
|
|
|
257
302
|
Every attempt is a recorded step, so a replay reproduces the exact sequence of failures. It also skips the delays.
|
|
258
303
|
|
|
259
304
|
## Running Effects in Parallel
|
|
260
305
|
|
|
261
|
-
`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.
|
|
262
307
|
|
|
263
308
|
```js
|
|
264
309
|
import { Success, Command, Parallel } from 'pure-effect';
|
|
@@ -271,7 +316,7 @@ const loadProfile = (userId) =>
|
|
|
271
316
|
|
|
272
317
|
`Ask` context flows into all parallel branches without any extra wiring.
|
|
273
318
|
|
|
274
|
-
**
|
|
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:
|
|
275
320
|
|
|
276
321
|
```js
|
|
277
322
|
// Cancellable: the request is aborted the moment a sibling branch fails.
|
|
@@ -285,9 +330,9 @@ Outside a `Parallel` the function is called with no arguments at all, so nothing
|
|
|
285
330
|
|
|
286
331
|
## Composing Larger Flows
|
|
287
332
|
|
|
288
|
-
`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:
|
|
289
334
|
|
|
290
|
-
**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:
|
|
291
336
|
|
|
292
337
|
```js
|
|
293
338
|
const processOrder = (order) => (order.isGift ? giftFlow(order) : standardFlow(order));
|
|
@@ -295,7 +340,7 @@ const processOrder = (order) => (order.isGift ? giftFlow(order) : standardFlow(o
|
|
|
295
340
|
const fulfillment = effectPipe(validateOrder, processOrder, scheduleShipping);
|
|
296
341
|
```
|
|
297
342
|
|
|
298
|
-
**
|
|
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:
|
|
299
344
|
|
|
300
345
|
```js
|
|
301
346
|
const loadCheckout = effectPipe(
|
|
@@ -304,7 +349,7 @@ const loadCheckout = effectPipe(
|
|
|
304
349
|
);
|
|
305
350
|
```
|
|
306
351
|
|
|
307
|
-
**
|
|
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:
|
|
308
353
|
|
|
309
354
|
```js
|
|
310
355
|
const applyLoyaltyDiscount = (orderId) =>
|
|
@@ -319,7 +364,7 @@ const applyLoyaltyDiscount = (orderId) =>
|
|
|
319
364
|
|
|
320
365
|
## Which Errors Are Data
|
|
321
366
|
|
|
322
|
-
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.
|
|
323
368
|
|
|
324
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:
|
|
325
370
|
|
|
@@ -341,9 +386,9 @@ const fetchPrice = (sku) => {
|
|
|
341
386
|
|
|
342
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.
|
|
343
388
|
|
|
344
|
-
**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.
|
|
345
390
|
|
|
346
|
-
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).
|
|
347
392
|
|
|
348
393
|
## TypeScript: Typed Errors and Context
|
|
349
394
|
|
|
@@ -391,7 +436,7 @@ const result = await runEffect(findProduct('abc'), { tenant: 'acme', requestId:
|
|
|
391
436
|
|
|
392
437
|
## API Reference
|
|
393
438
|
|
|
394
|
-
###
|
|
439
|
+
### Building blocks
|
|
395
440
|
|
|
396
441
|
#### `Success(value)`
|
|
397
442
|
|
|
@@ -413,7 +458,9 @@ Returns `{ type: 'Command', cmd, next, meta }`.
|
|
|
413
458
|
|
|
414
459
|
```js
|
|
415
460
|
Command(cmdFn, next, { name: 'chargeCard' }); // 1. meta.name, independent of how cmdFn was written
|
|
416
|
-
Command(function cmdChargeCard() {
|
|
461
|
+
Command(function cmdChargeCard() {
|
|
462
|
+
return api.charge();
|
|
463
|
+
}, next); // 2. the function's own name
|
|
417
464
|
Command(() => api.charge(), next); // 3. neither, so 'anonymous'
|
|
418
465
|
```
|
|
419
466
|
|
|
@@ -462,14 +509,14 @@ One shape to avoid in either language:
|
|
|
462
509
|
|
|
463
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.
|
|
464
511
|
|
|
465
|
-
###
|
|
512
|
+
### Running a flow
|
|
466
513
|
|
|
467
514
|
#### `runEffect(effect, context?, callConfig?)`
|
|
468
515
|
|
|
469
|
-
|
|
516
|
+
Walks the flow, runs each Command with `async/await`, resolves `Ask` with the supplied `context`, and returns the final `Success` or `Failure`.
|
|
470
517
|
|
|
471
518
|
- `context`: Passed to `Ask`'s next function and to `onBeforeCommand`. `context.flowName` names the workflow in telemetry.
|
|
472
|
-
- `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).
|
|
473
520
|
- `onRun` fires exactly once per `runEffect` call. Retry attempts run inside that single span.
|
|
474
521
|
|
|
475
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`:
|
|
@@ -488,25 +535,57 @@ The same check catches a missing `return`, a Command's next function returning a
|
|
|
488
535
|
- `onBeforeCommand(command, context)` fires before each Command; throw to abort.
|
|
489
536
|
- `retry: { attempts?, delay?, backoff? }` global retry defaults.
|
|
490
537
|
|
|
491
|
-
|
|
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`:
|
|
492
539
|
|
|
493
540
|
```js
|
|
494
541
|
configureEffect(telemetryHooks(), recordingHooks({ sink }));
|
|
542
|
+
|
|
543
|
+
// or, as two layers:
|
|
544
|
+
configureEffect(telemetryHooks());
|
|
545
|
+
configureEffect(recordingHooks({ sink }));
|
|
495
546
|
```
|
|
496
547
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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.
|
|
502
570
|
|
|
503
571
|
```js
|
|
504
|
-
const
|
|
572
|
+
const remove = configureEffect(telemetryHooks());
|
|
505
573
|
// ... later
|
|
506
|
-
|
|
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 }
|
|
507
586
|
```
|
|
508
587
|
|
|
509
|
-
|
|
588
|
+
`recordEffect` inherits, so recording inside an application that already has tracing keeps its spans. `replayEffect` passes `inherit: false` unless `hooks: true`.
|
|
510
589
|
|
|
511
590
|
### Recording and replay
|
|
512
591
|
|
|
@@ -514,7 +593,7 @@ restore();
|
|
|
514
593
|
|
|
515
594
|
Returns `{ onStep, entries, toTrace }`. Pass `onStep` to `runEffect` or `configureEffect` to record what every Command returned.
|
|
516
595
|
|
|
517
|
-
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.
|
|
518
597
|
|
|
519
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:
|
|
520
599
|
|
|
@@ -540,26 +619,27 @@ Runs a flow for real while recording, returning `{ result, trace }`. Accepts `re
|
|
|
540
619
|
|
|
541
620
|
#### `replayEffect(effect, traceOrResolver, options?)`
|
|
542
621
|
|
|
543
|
-
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.
|
|
544
623
|
|
|
545
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`.
|
|
546
625
|
- `options.context`: context for `Ask`; pass the recorded context.
|
|
547
626
|
- `options.onMissing`: `'throw'` (default) fails on an unrecorded step; `'execute'` runs the real Command, giving a recorded prefix with a live tail.
|
|
548
627
|
- `options.fastRetry` (default `true`): strip `Retry` delays.
|
|
549
|
-
- `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.
|
|
550
629
|
- `options.onResolved(step, outcome)`: observe each replayed step.
|
|
551
630
|
|
|
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.
|
|
632
|
+
|
|
552
633
|
#### `timeTravel(flowFn, traceLog, options?)`
|
|
553
634
|
|
|
554
|
-
Replays a trace and narrates each step with its recorded duration,
|
|
635
|
+
Replays a trace and narrates each step with its recorded duration, naming any recorded steps that were never reached and warning when the trace's `version` differs from `options.version`. Returns the flow's outcome; for the unreached entries as data, use `replayEffect`. Takes `options.context` to override the trace's, and `options.log` in place of `console.log`.
|
|
555
636
|
|
|
556
637
|
## Limitations
|
|
557
638
|
|
|
558
|
-
- **`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
|
|
559
|
-
-
|
|
560
|
-
- **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.
|
|
561
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.
|
|
562
|
-
- **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.
|
|
563
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.
|
|
564
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.
|
|
565
|
-
- **`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.
|