pure-effect 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +17 -8
- package/README.md +111 -13
- package/index.d.ts +33 -3
- package/index.js +48 -8
- package/package.json +4 -4
- package/test/all.js +198 -0
- package/test/types.test-d.ts +41 -4
- package/tsconfig.check.json +12 -0
- package/.claude/settings.local.json +0 -17
package/CLAUDE.md
CHANGED
|
@@ -14,10 +14,12 @@ npx esbuild index.js --minify --format=esm | gzip -9 | wc -c # the size the R
|
|
|
14
14
|
|
|
15
15
|
No build or lint step: the library ships as plain ES modules with no transpilation. Formatting is Prettier, configured in `.prettierrc`; match it when editing.
|
|
16
16
|
|
|
17
|
-
The README's badge and feature bullet both state a minified-and-gzipped ceiling, currently
|
|
17
|
+
The README's badge and feature bullet both state a minified-and-gzipped ceiling, currently around 4 KB. Re-measure with the command above after adding anything to `index.js`, because the badge is a static `img.shields.io/badge/` URL with the number written into it rather than a live query, so it cannot correct itself.
|
|
18
18
|
|
|
19
19
|
**Markdown prose has no hard line breaks.** Write each paragraph and each list item as one line and let the editor soft-wrap; never wrap prose at a column. Hard breaks make later edits reflow whole paragraphs, turning a one-word change into a multi-line diff. `proseWrap: never` enforces this, so `npm run format` unwraps any prose that arrives wrapped. Even so, write it unwrapped in the first place rather than relying on the sweep, since a wrapped paragraph reads as an intended shape until something reflows it. Fenced code blocks are untouched by the setting, and table rows stay one per line; only prose joins.
|
|
20
20
|
|
|
21
|
+
**No `thunk` in the README.** Reader-facing prose says the Command's function, or `cmd`. The word remains fine in this file, in JSDoc, and in code comments, where precision for maintainers matters more than approachability, but a README reader should never need to know it. `grep -n "thunk" README.md` checks a change before it lands.
|
|
22
|
+
|
|
21
23
|
**No em dashes.** The `—` character does not appear anywhere in this repository: not in `README.md`, not in this file, not in JSDoc, code comments, test names, or assertion messages. Reach for the punctuation that carries the actual relationship instead of a dash that blurs it. A colon when the second half explains or names the first. Parentheses for an aside the sentence could survive without. A semicolon between two linked independent clauses. A comma for a light pause or a trailing "which" clause. A full stop when the clause can stand on its own, which is usually the honest fix for a dash joining two complete thoughts. Nothing enforces this mechanically, so it is on the author; `grep -rn "—" --include="*.js" --include="*.ts" --include="*.md" .` checks a change before it lands. Table cells that need a "not applicable" placeholder use `n/a`.
|
|
22
24
|
|
|
23
25
|
## Architecture
|
|
@@ -32,15 +34,17 @@ The README's badge and feature bullet both state a minified-and-gzipped ceiling,
|
|
|
32
34
|
| `Failure(error, initialInput)` | `{ type: 'Failure', error, initialInput }` | Short-circuits the pipeline |
|
|
33
35
|
| `Command(cmdFn, nextFn, meta)` | `{ type: 'Command', cmd, next, meta }` | Defers a side effect for the interpreter; `meta.name` sets the Command's identity |
|
|
34
36
|
| `Ask(nextFn)` | `{ type: 'Ask', next }` | Reads the `context` passed to `runEffect`; passes it to `nextFn` |
|
|
35
|
-
| `Retry(effect, options)` | `{ type: 'Retry', effect, options, next }` | Wraps any Effect tree with retry-on-failure semantics; handled natively by the interpreter |
|
|
36
|
-
| `Parallel(effects, next)` | `{ type: 'Parallel', effects, next }` | Runs multiple Effect trees concurrently; the first branch to fail cancels its siblings and its Failure is returned; context flows into all branches |
|
|
37
|
+
| `Retry(effect, options)` | `{ type: 'Retry', effect, options, next }` | Wraps any Effect tree with retry-on-failure semantics; handled natively by the interpreter; a per-use `options.onExhausted` runs a fallback Effect when every attempt has failed |
|
|
38
|
+
| `Parallel(effects, next?)` | `{ type: 'Parallel', effects, next }` | Runs multiple Effect trees concurrently; the first branch to fail cancels its siblings and its Failure is returned; context flows into all branches |
|
|
37
39
|
| `effectPipe(...fns)` | n/a | Composes functions into a sequential pipeline via `chain` |
|
|
38
40
|
| `runEffect(effect, context, callConfig?)` | async | Interpreter: traverses the effect tree, executes Commands; resolves `Ask` and `Retry`; per-call `callConfig` overrides global defaults |
|
|
39
41
|
| `configureEffect(...configs)` | n/a | Sets process-wide telemetry hooks (`onStep`, `onRun`, `onBeforeCommand`) and global `retry` defaults; merges several configurations into one wiring; returns a restore function; overridden by per-call `callConfig` |
|
|
40
42
|
|
|
41
43
|
The recording and replay exports are a 2x2 rather than four loose helpers: `recorder` and `replayEffect` are the capabilities, at the hook and tree level, while `recordEffect` and `timeTravel` are the flow-level pair that also own trace metadata. That metadata is why the conveniences exist and should not be cut as mere sugar: `initialInput` lets a flow be rebuilt, `context` lets `Ask` resolve on replay, and `version` makes a stale trace detectable. A caller who assembles a trace by hand and forgets `context` gets one that silently diverges. `timeTravel` is presentation over `replayEffect` and could be rebuilt from `onResolved` in about ten lines, but it stays: the one-line form is what makes the feature graspable.
|
|
42
44
|
|
|
43
|
-
`Command(cmd, next, meta)` keeps that parameter list, and `next` is optional because most of Commands in this repo pass their result straight through: it defaults to `(result) => Success(result)`.
|
|
45
|
+
`Command(cmd, next, meta)` keeps that parameter list, and `next` is optional because most of Commands in this repo pass their result straight through: it defaults to `(result) => Success(result)`. `Parallel`'s `next` follows the same convention, defaulting to `(values) => Success(values)`, so a bare `Parallel(effects)` resolves to the ordered array of success values. The README's `Composing Larger Flows` section documents the composition patterns the primitives already support (sub-pipeline branching, `Parallel` fan-in, local joins that carry a minimal purpose-shaped value); those shapes need no combinators, which is why none were added. The no-accumulator guidance there is deliberate bug avoidance, not taste: a growing scope object hides which step depends on what, lets stale fields outlive their producers, and widens what carries PII, so keep the section's examples threading minimal values if they are ever reworked.
|
|
46
|
+
|
|
47
|
+
There is deliberately no catch combinator (an `Attempt` node was designed and rejected), and the README's `Which Errors Are Data` section is the guidance that replaced it: an outcome the flow handles is data (a `next` branches on the result, and a rejecting `cmd` function catches into a result shape at the boundary), while `Failure` means abort and is reserved for the shell. Fallbacks written this way are fully traced and replayed; the failed attempt is recorded as that Command's result with the error as data, so nothing is lost, only categorized as handled. Catch was rejected because it reintroduces non-local control flow into a paradigm whose point is explicit data flow, and because every motivating case except one was already expressible. The one exception, recovering from `Retry` exhaustion in-flow, is now covered by the scoped per-use option `Retry(effect, { onExhausted })`. It was added, where general catch was rejected, for three reasons worth keeping: it was the only composition with no in-library expression (the shell workaround split one logical operation across two runs and two traces); retry-then-fallback is a canonical resilience pairing (Polly, resilience4j), so demand was predictable rather than speculative; and it completes `Retry`'s own failure handling rather than adding catch semantics, since `Retry` was already the node that reacts to failures and previously abdicated on the last one. The ceiling stands: no general catch node, nothing but exhaustion is catchable, `onExhausted` is per-use only (the global `retry` defaults cannot carry one, pinned by an `expectError`), and a cancelled branch skips the fallback.
|
|
44
48
|
|
|
45
49
|
A Command's identity is resolved by the internal `commandName(eff)`: a non-empty string `meta.name`, else `cmd.name`, else `'anonymous'`. That identity is used in three places at once, which is why it has its own function rather than being inlined at the call site: trace entries are keyed on it, strict replay matching compares it against the recorded name, and telemetry spans are titled with it. `meta.name` exists because the previous rule, `cmd.name` alone, made the flagship feature depend on syntax: an inline arrow silently recorded as `'anonymous'`, and any minifier that mangles function names renamed every step of every trace. Both failures are silent, which is the worst property an identity can have. `cmd.name` remains supported and is what the examples use, since naming the thunk reads well and needs no second argument.
|
|
46
50
|
|
|
@@ -75,7 +79,10 @@ runEffect(tree, context, callConfig?)
|
|
|
75
79
|
→ resolves Ask by calling next(context), continues
|
|
76
80
|
→ resolves Retry via an inner execute() loop (not recursive runEffect),
|
|
77
81
|
so onRun fires exactly once per runEffect call regardless of attempts;
|
|
78
|
-
on exhaustion returns Failure({ retryExhausted: true, lastError, attempts })
|
|
82
|
+
on exhaustion returns Failure({ retryExhausted: true, lastError, attempts }),
|
|
83
|
+
unless a per-use onExhausted returns a fallback Effect: it executes under the
|
|
84
|
+
retry's `f` path prefix, its success feeds next, its failure propagates
|
|
85
|
+
unwrapped, and a cancelled Parallel branch skips it
|
|
79
86
|
→ resolves Parallel by running all effects via Promise.all with the same
|
|
80
87
|
context; if any effect returns Failure, returns the first Failure by
|
|
81
88
|
array index and skips next; otherwise calls next with the array of
|
|
@@ -135,13 +142,13 @@ Because a flow is data, a production run can be recorded and replayed later. Rec
|
|
|
135
142
|
Four invariants hold the design together; breaking any of them breaks replay in a way tests may not catch:
|
|
136
143
|
|
|
137
144
|
- **Replay drives the real interpreter.** `replayEffect` calls `runEffect` rather than walking the tree itself, which is why `Ask`, `Retry`, and `Parallel` replay correctly and why replay semantics cannot drift from execution semantics. The interpreter's only execution point is `await localStepRunner(cmdName, 'Command', eff.cmd)`, which hands the Command thunk to `onStep` as `op` instead of calling it. Replay's `onStep` never invokes `op` unless `onMissing: 'execute'`, so **no side effect can occur by default**. Preserve that single-execution-point property: a Command executed anywhere else in the interpreter would be invisible to both recording and replay.
|
|
138
|
-
- **Path matching, with the two older modes kept for legacy traces.** Every step carries a `path`: its position in the Effect tree, numbered sequentially within a subtree, with each `Parallel` branch and each `Retry` attempt opening its own prefix (`0p1/0r2/0` is branch 1, retry attempt 2, first Command). A path depends only on the shape of the tree, never on the order branches finish in, so it is order-independent **and** paradox-detecting at the same time. `fromTrace` matches by path whenever every entry has one, and it does so whatever `strict` says, deliberately: a caller who passed `strict: false` to work around `Parallel` should get correct matching once their traces carry paths rather than keeping the mode that option existed to work around. Duplicate paths throw, since a collision would reintroduce the failure paths exist to prevent. The older modes remain only for traces recorded before paths existed: `strict: true` consumes entries positionally, `strict: false` resolves per-Command FIFO queues. **The name-queue mode is the bug paths fixed.** It paired a step with the next recorded entry of the same name, which is completion order, so two `Parallel` branches calling the same Command swapped results whenever replay timing differed from production, and the replay still reported `Success`. `strict` is ignored when a `Resolver` is passed, because that `Resolver` has already chosen its own matching.
|
|
145
|
+
- **Path matching, with the two older modes kept for legacy traces.** Every step carries a `path`: its position in the Effect tree, numbered sequentially within a subtree, with each `Parallel` branch and each `Retry` attempt opening its own prefix (`0p1/0r2/0` is branch 1, retry attempt 2, first Command; a retry's `onExhausted` fallback opens `0rf/`). A path depends only on the shape of the tree, never on the order branches finish in, so it is order-independent **and** paradox-detecting at the same time. `fromTrace` matches by path whenever every entry has one, and it does so whatever `strict` says, deliberately: a caller who passed `strict: false` to work around `Parallel` should get correct matching once their traces carry paths rather than keeping the mode that option existed to work around. Duplicate paths throw, since a collision would reintroduce the failure paths exist to prevent. The older modes remain only for traces recorded before paths existed: `strict: true` consumes entries positionally, `strict: false` resolves per-Command FIFO queues. **The name-queue mode is the bug paths fixed.** It paired a step with the next recorded entry of the same name, which is completion order, so two `Parallel` branches calling the same Command swapped results whenever replay timing differed from production, and the replay still reported `Success`. `strict` is ignored when a `Resolver` is passed, because that `Resolver` has already chosen its own matching.
|
|
139
146
|
|
|
140
147
|
- **The hook contract carries the path as a fourth parameter.** `onStep(name, type, op, path)`. Three-parameter hooks keep working, and `chainHooks` forwards the path through nested wrappers. A hand-rolled wrapper that calls `inner(name, type, op)` without forwarding `path` silently produces a trace with no paths, which then falls back to legacy positional matching: correct for sequential flows, and the old swapping bug for `Parallel`. Forward the fourth argument in anything that wraps `onStep`.
|
|
141
148
|
- **`fastRetry` exists because of merge order.** The interpreter merges per-use options over call config (`{ ...localRetryDefaults, ...eff.options }`), so a `callConfig.retry` cannot override a delay written at the call site. `zeroRetryDelays` therefore rewrites `Retry` nodes to `delay: 0, backoff: 1` lazily, through their `next` continuations; without it, replaying a flow that retried in production waits out the production backoff.
|
|
142
149
|
- **The outcome wrapper is load-bearing.** A `Resolver` returns `{ result }`, `{ error }`, or `undefined` for "not recorded". The wrapper keeps a Command that legitimately returned `undefined` (`{ result: undefined }`) distinct from having no recording, which is what makes the `onMissing` guard trustworthy.
|
|
143
150
|
|
|
144
|
-
Two further details: `hooks` defaults to `false`, so a replay cannot reach a telemetry backend or a guardrail that performs I/O; and errors cross the trace via `serializeError` / `reviveError` because `message` and `
|
|
151
|
+
Two further details: `hooks` defaults to `false`, so a replay cannot reach a telemetry backend or a guardrail that performs I/O; and errors cross the trace via `serializeError` / `reviveError` because `message`, `stack`, and `cause` are non-enumerable on `Error` and a plain `JSON.stringify` would silently drop them; `cause` is carried recursively since it is often an `Error` itself. Non-`Error` values pass through unchanged, so a Command that rejected with a string still replays as a string.
|
|
145
152
|
|
|
146
153
|
Recorded results are snapshotted by the internal `snapshot`, not stored by reference. Without it a later step that mutates a returned object rewrote what the trace said an earlier step returned, and because `toTrace` runs after the flow finishes, the mutation was already baked in by the time a sink serialized it: the trace recorded a value production never saw, and a replay fed that value back. It uses `structuredClone` where available, falls back to a JSON round trip, and falls back again to the reference for values that can be neither, since dropping the entry would be worse. Redaction runs first, so nothing sensitive is cloned before being stripped.
|
|
147
154
|
|
|
@@ -153,7 +160,7 @@ Recorded results are snapshotted by the internal `snapshot`, not stored by refer
|
|
|
153
160
|
|
|
154
161
|
Full generic type declarations are in `index.d.ts` and referenced via the `types` field in `package.json`. Type-level tests live in `test/types.test-d.ts` and run via `tsd` as part of `npm test`.
|
|
155
162
|
|
|
156
|
-
`index.d.ts` is hand-maintained beside `index.js`, so the two drift
|
|
163
|
+
`index.d.ts` is hand-maintained beside `index.js`, so the two can drift. Two mechanisms guard the drift: the `Declaration parity` test pins the export lists to each other in both directions, and `npm test` now runs `tsc -p tsconfig.check.json` (checkJs over `index.js`), which keeps the JSDoc sound; it still never opens the declarations, so signature-level drift remains `tsd`'s job. The tsc leg was added after a narrowing error in the interpreter shipped through a green gate and was found in an editor, the second time a lens existed but was not in the gate. **Check `npm test` by exit code.** `tsd` failures print neither "passing" nor "failing", so piping the output through `grep -E "passing|failing"` hides them entirely, which is how `runEffect`'s declaration stayed deleted through several edits here while every other check reported green. It was removed as collateral by a slice that targeted an adjacent block, so after any structural edit to `index.d.ts`, compare the runtime exports against the declared ones rather than trusting the diff.
|
|
157
164
|
|
|
158
165
|
Raw `tsc` over `test/types.test-d.ts` is the wrong lens and will always report errors: `expectError(...)` blocks contain deliberate type errors that `tsd` requires to be errors.
|
|
159
166
|
|
|
@@ -161,6 +168,8 @@ Raw `tsc` over `test/types.test-d.ts` is the wrong lens and will always report e
|
|
|
161
168
|
|
|
162
169
|
Error unions need explicit return annotations. A step written as `(i: User): Effect<User, 'taken'> => ...` contributes `'taken'` to the pipeline's union, but the same step left to inference from a bare `Success(x) | Failure(y)` return type yields `E = unknown`, and one `unknown` absorbs every other member of the union, so `result.error` degrades to `unknown` for the whole pipeline. Annotate `Effect<T, E>` on any step whose error type is meant to survive, and write type-level examples that way.
|
|
163
170
|
|
|
171
|
+
`Retry`'s declared error is `RetryExhaustedError<E>`, not `E`, and that is deliberate: the interpreter never lets an inner attempt's error escape unwrapped, so on exhaustion the Failure carries `{ retryExhausted, lastError, attempts }` and the inner `E` survives only as `lastError`. `RetryState`'s `effect` field is typed `Effect<T, any, Ctx>` for the same reason, since the wrapped tree's error type is consumed by the retry loop rather than exposed. The declaration previously claimed `E`, which made `result.error` lie for every flow containing a `Retry`; the tsd assertions in the Retry section pin the corrected shape. With a per-use `onExhausted` the declared error is the fallback's own error type instead, since exhaustion then never escapes; that overload comes first, and `onExhausted` is typed on the per-use options only, so a global `configureEffect` retry default cannot carry one.
|
|
172
|
+
|
|
164
173
|
The recording and replay exports are declared alongside the primitives (`ReplayStep`, `ReplayOutcome`, `Resolver`, `TraceEntry`, `TraceLog`, `RecorderOptions`, `TraceMeta`, `ReplayOptions<Ctx>`). Known gap: `test/types.test-d.ts` has no coverage for any of the replay exports; their declarations are checked only by `tsc` over the JSDoc in `index.js`. Add `tsd` assertions there when touching those types.
|
|
165
174
|
|
|
166
175
|
### Observability
|
package/README.md
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/pure-effect) [](https://bundlephobia.com/package/pure-effect) [](https://github.com/aycangulez/pure-effect/blob/main/LICENSE)
|
|
4
4
|
|
|
5
|
-
**Pure Effect**
|
|
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
|
|
|
7
7
|
- Replay a production failure locally, with no database and no network
|
|
8
8
|
- No mocks needed to test async pipelines
|
|
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,
|
|
12
|
+
- Zero dependencies, 4 KB minified and gzipped
|
|
13
13
|
- Works in JavaScript and TypeScript (full generics, bundled `.d.ts`)
|
|
14
14
|
|
|
15
15
|
## Table of Contents
|
|
@@ -17,11 +17,14 @@
|
|
|
17
17
|
- [Installation](#installation)
|
|
18
18
|
- [Quick Start](#quick-start)
|
|
19
19
|
- [Testing Without Mocks](#testing-without-mocks)
|
|
20
|
+
- [How It Works](#how-it-works)
|
|
20
21
|
- [Time-Travel Debugging](#time-travel-debugging)
|
|
21
22
|
- [Recording in Production](#recording-in-production)
|
|
22
23
|
- [Passing Runtime Context](#passing-runtime-context)
|
|
23
24
|
- [Retrying Transient Failures](#retrying-transient-failures)
|
|
24
25
|
- [Running Effects in Parallel](#running-effects-in-parallel)
|
|
26
|
+
- [Composing Larger Flows](#composing-larger-flows)
|
|
27
|
+
- [Which Errors Are Data](#which-errors-are-data)
|
|
25
28
|
- [TypeScript: Typed Errors and Context](#typescript-typed-errors-and-context)
|
|
26
29
|
- [Why Pure Effect](#why-pure-effect)
|
|
27
30
|
- [API Reference](#api-reference)
|
|
@@ -35,7 +38,7 @@ npm install pure-effect
|
|
|
35
38
|
|
|
36
39
|
## Quick Start
|
|
37
40
|
|
|
38
|
-
A complete user registration flow. Every step receives the value the previous step produced, so the pipeline reads top-to-bottom as the flow of data:
|
|
41
|
+
A complete user registration flow. Every step receives the value the previous step produced, so the pipeline reads top-to-bottom as the flow of data. Each I/O step is a pair: the call to make, plus a `next` function that receives its answer and decides what follows.
|
|
39
42
|
|
|
40
43
|
```js
|
|
41
44
|
import { Success, Failure, Command, effectPipe, runEffect } from 'pure-effect';
|
|
@@ -55,13 +58,13 @@ const ensureEmailAvailable = (input) => {
|
|
|
55
58
|
return Command(cmdFindUser, next);
|
|
56
59
|
};
|
|
57
60
|
|
|
58
|
-
// With no
|
|
61
|
+
// With no next function, the result passes straight through.
|
|
59
62
|
const saveUser = (input) => {
|
|
60
63
|
const cmdSaveUser = () => db.saveUser(input);
|
|
61
64
|
return Command(cmdSaveUser);
|
|
62
65
|
};
|
|
63
66
|
|
|
64
|
-
//
|
|
67
|
+
// Build the flow. Validation runs now; no I/O happens until runEffect.
|
|
65
68
|
const registerUserFlow = (input) => effectPipe(validateRegistration, ensureEmailAvailable, saveUser)(input);
|
|
66
69
|
|
|
67
70
|
// Imperative shell: this is the only place side effects run
|
|
@@ -94,6 +97,14 @@ assert.equal(step2.cmd.name, 'cmdSaveUser');
|
|
|
94
97
|
// The full flow is verified. The database was never touched.
|
|
95
98
|
```
|
|
96
99
|
|
|
100
|
+
## How It Works
|
|
101
|
+
|
|
102
|
+
A flow is a chain of pairs: an I/O call to make, plus a `next` function that receives its answer and returns whatever comes after, another Command, a Success, or a Failure. `runEffect` walks that chain in a loop.
|
|
103
|
+
|
|
104
|
+
Recording and replay attach at the single point where a Command's function is called: recording wraps that call to write down each answer, and replay substitutes the recorded answer without making the call, which is why a replayed flow performs no I/O.
|
|
105
|
+
|
|
106
|
+
Building a flow runs the pure steps immediately: `registerUserFlow(badInput)` returns the validation `Failure` synchronously, which is why the tests above do not need `runEffect`. Only the I/O inside Commands requires it.
|
|
107
|
+
|
|
97
108
|
## Time-Travel Debugging
|
|
98
109
|
|
|
99
110
|
Record what each Command returned, then feed those results back into the same flow to retrace the exact path a request took, with no database and no network attached.
|
|
@@ -131,6 +142,14 @@ The test verifies that the flow still takes the recorded path and handles the re
|
|
|
131
142
|
|
|
132
143
|
**Nondeterminism belongs inside a Command.** A value that varies between runs (the current time, a random ID) is I/O as far as replay is concerned. Wrap it in a Command and it is recorded and replayed like any other response; a step that calls `Date.now()` directly computes a fresh value on every replay and silently diverges from the trace.
|
|
133
144
|
|
|
145
|
+
**A determinism check is 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
|
+
|
|
147
|
+
```js
|
|
148
|
+
const { result, trace } = await recordEffect(registerUserFlow, input);
|
|
149
|
+
const replayed = await replayEffect(registerUserFlow(input), trace);
|
|
150
|
+
assert.deepEqual(replayed, result); // fails if any step computed a fresh value
|
|
151
|
+
```
|
|
152
|
+
|
|
134
153
|
**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.
|
|
135
154
|
|
|
136
155
|
```js
|
|
@@ -221,7 +240,21 @@ When all attempts are exhausted, `runEffect` returns a structured `Failure`:
|
|
|
221
240
|
{ retryExhausted: true, lastError: <the last error>, attempts: 3 }
|
|
222
241
|
```
|
|
223
242
|
|
|
224
|
-
|
|
243
|
+
In TypeScript, a `Retry` contributes `RetryExhaustedError<E>` to the pipeline's error union rather than the inner `E` itself, matching what the Failure actually carries: the wrapped tree's error type survives as `result.error.lastError`.
|
|
244
|
+
|
|
245
|
+
**Recover from exhaustion in-flow with `onExhausted`.** When every attempt has failed, the fallback Effect runs instead of returning the structured Failure. Its success feeds the rest of the pipeline exactly as the primary's would have, and its failure propagates unwrapped:
|
|
246
|
+
|
|
247
|
+
```js
|
|
248
|
+
const fetchPrice = (sku) =>
|
|
249
|
+
Retry(fetchLivePrice(sku), {
|
|
250
|
+
attempts: 3,
|
|
251
|
+
onExhausted: (err) => fetchCachedPrice(sku) // err = { retryExhausted, lastError, attempts }
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
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 node contributes the fallback's error type to the pipeline's union instead of `RetryExhaustedError<E>`.
|
|
256
|
+
|
|
257
|
+
Every attempt is a recorded step, so a replay reproduces the exact sequence of failures. It also skips the delays.
|
|
225
258
|
|
|
226
259
|
## Running Effects in Parallel
|
|
227
260
|
|
|
@@ -234,6 +267,8 @@ const loadProfile = (userId) =>
|
|
|
234
267
|
Parallel([getUser(userId), getPermissions(userId)], ([user, permissions]) => Success({ user, permissions }));
|
|
235
268
|
```
|
|
236
269
|
|
|
270
|
+
`next` is optional, defaulting to `(values) => Success(values)` just like `Command`'s, so a bare `Parallel(effects)` resolves to the ordered array of success values.
|
|
271
|
+
|
|
237
272
|
`Ask` context flows into all parallel branches without any extra wiring.
|
|
238
273
|
|
|
239
274
|
**Cancellation is cooperative, 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:
|
|
@@ -248,6 +283,68 @@ const fetchProfileUncancellable = (userId) => Command(() => fetch(`/users/${user
|
|
|
248
283
|
|
|
249
284
|
Outside a `Parallel` the function is called with no arguments at all, so nothing changes for a Command that never runs in a branch. A `Retry` inside a cancelled branch stops retrying rather than working through the rest of its backoff schedule.
|
|
250
285
|
|
|
286
|
+
## Composing Larger Flows
|
|
287
|
+
|
|
288
|
+
`effectPipe` is a straight line, but flows rarely are. Branching and fan-in work with the existing primitives. Here is how:
|
|
289
|
+
|
|
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` short-circuits the outer pipeline exactly like a local one:
|
|
291
|
+
|
|
292
|
+
```js
|
|
293
|
+
const processOrder = (order) => (order.isGift ? giftFlow(order) : standardFlow(order));
|
|
294
|
+
|
|
295
|
+
const fulfillment = effectPipe(validateOrder, processOrder, scheduleShipping);
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
**Independent fan-in: `Parallel`.** When a later step needs several values that do not depend on each other, run them concurrently and join structurally. With `next` omitted, the branch results arrive as an ordered array:
|
|
299
|
+
|
|
300
|
+
```js
|
|
301
|
+
const loadCheckout = effectPipe(
|
|
302
|
+
(input) => Parallel([fetchCart(input.cartId), fetchUser(input.userId)]),
|
|
303
|
+
([cart, user]) => Success({ cart, user, total: cartTotal(cart) })
|
|
304
|
+
);
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**Dependent fan-in: 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
|
+
|
|
309
|
+
```js
|
|
310
|
+
const applyLoyaltyDiscount = (orderId) =>
|
|
311
|
+
effectPipe(
|
|
312
|
+
fetchOrder,
|
|
313
|
+
(order) => effectPipe(fetchCustomer, (customer) => Success({ order, customer }))(order.customerId),
|
|
314
|
+
({ order, customer }) => Success(discountedTotal(order, customer))
|
|
315
|
+
)(orderId);
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
**Carry exactly what downstream needs, never an accumulator.** Passing a minimal, purpose-shaped value between steps, rather than a scope object that grows with everything ever computed, is a strategy for avoiding bugs, not a style preference. Each step's input is its complete contract, so a step cannot quietly depend on a value produced far upstream, a stale field cannot outlive the step that should have replaced it, and two steps cannot collide on a key neither knows the other uses. It also keeps data alive no longer than the flow needs it, which matters when the values are credentials or PII. In TypeScript the same discipline is what keeps the pipeline checked end to end: every step consumes the value it receives, so changing what an upstream step produces is a compile error at exactly the step that reads it, instead of an `undefined` at runtime.
|
|
319
|
+
|
|
320
|
+
## Which Errors Are Data
|
|
321
|
+
|
|
322
|
+
There is no catch combinator, and that is deliberate. A flow's outcomes divide into two kinds, and the division decides how each is written.
|
|
323
|
+
|
|
324
|
+
**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
|
+
|
|
326
|
+
```js
|
|
327
|
+
const fetchCachedPrice = (sku) => {
|
|
328
|
+
const cmdFetchCachedPrice = () => cache.get(sku);
|
|
329
|
+
return Command(cmdFetchCachedPrice, (price) => Success({ ...price, stale: true }));
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const fetchPrice = (sku) => {
|
|
333
|
+
const cmdFetchLivePrice = () =>
|
|
334
|
+
pricing
|
|
335
|
+
.get(sku)
|
|
336
|
+
.then((price) => ({ ok: true, price }))
|
|
337
|
+
.catch((error) => ({ ok: false, error }));
|
|
338
|
+
return Command(cmdFetchLivePrice, (r) => (r.ok ? Success({ ...r.price, stale: false }) : fetchCachedPrice(sku)));
|
|
339
|
+
};
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
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
|
+
|
|
344
|
+
**A `Failure` means abort.** It short-circuits 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 combinator would reintroduce exactly the non-local control flow that writing effects as data is meant to eliminate.
|
|
345
|
+
|
|
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 the `Retry` node; that case has its own in-flow form, the `onExhausted` option in [Retrying Transient Failures](#retrying-transient-failures).
|
|
347
|
+
|
|
251
348
|
## TypeScript: Typed Errors and Context
|
|
252
349
|
|
|
253
350
|
### Error union across pipeline steps
|
|
@@ -284,9 +381,9 @@ const result = await runEffect(findProduct('abc'), { tenant: 'acme', requestId:
|
|
|
284
381
|
|
|
285
382
|
## Why Pure Effect
|
|
286
383
|
|
|
287
|
-
**vs.
|
|
384
|
+
**vs. Temporal and durable execution (Restate, Inngest):** The closest relatives, because they are built on the same idea: record what every step returned, replay it deterministically. Those engines run it as a managed execution guarantee: histories persist server-side, and workflows resume automatically after a crash. The difference is who operates it. An engine stores histories and restarts dead runs for you; with Pure Effect that is your application's job, and there is no infrastructure to run.
|
|
288
385
|
|
|
289
|
-
**vs.
|
|
386
|
+
**vs. Effect-TS (and fp-ts):** A full functional programming ecosystem with fibers, streaming, schema validation, structured concurrency, and more, though it comes with a steep learning curve and a vocabulary of its own. Pure Effect borrows only the concept of effects as data, and covers a narrower scope: testable pipelines, context injection, retry, parallel execution, and replayable traces. If you need fibers, in-flight cancellation, or streaming, Effect-TS is the right tool.
|
|
290
387
|
|
|
291
388
|
**vs. plain async/await with mocks:** A mock that passes all your tests but diverges from what the real driver does is worse than no test. Business logic never executes I/O, so there is nothing to mock.
|
|
292
389
|
|
|
@@ -333,12 +430,13 @@ Returns `{ type: 'Retry', effect, options, next }`.
|
|
|
333
430
|
- `options.attempts`: Max retries, not counting the first try (default: `3`).
|
|
334
431
|
- `options.delay`: Ms before the first retry (default: `100`).
|
|
335
432
|
- `options.backoff`: Multiplier applied to delay on each attempt (default: `1`, flat).
|
|
433
|
+
- `options.onExhausted(error)`: Runs a fallback Effect when every attempt has failed, receiving `{ retryExhausted, lastError, attempts }`. The fallback's success feeds `next`; its failure propagates unwrapped. Per-use only. See [Retrying Transient Failures](#retrying-transient-failures).
|
|
336
434
|
|
|
337
|
-
#### `Parallel(effects, next)`
|
|
435
|
+
#### `Parallel(effects, next?)`
|
|
338
436
|
|
|
339
|
-
Returns `{ type: 'Parallel', effects, next }`. Runs all effects concurrently. The first branch to fail cancels its siblings and its `Failure` is returned; `next` is not called. When several branches fail in the same tick, the first by array order wins. Each branch's Commands receive an `AbortSignal` as their only argument, so I/O that accepts one is cancelled in flight; see [Running Effects in Parallel](#running-effects-in-parallel).
|
|
437
|
+
Returns `{ type: 'Parallel', effects, next }`. Runs all effects concurrently. `next` receives the ordered array of success values and is optional, defaulting to `(values) => Success(values)` like `Command`'s. The first branch to fail cancels its siblings and its `Failure` is returned; `next` is not called. When several branches fail in the same tick, the first by array order wins. Each branch's Commands receive an `AbortSignal` as their only argument, so I/O that accepts one is cancelled in flight; see [Running Effects in Parallel](#running-effects-in-parallel).
|
|
340
438
|
|
|
341
|
-
###
|
|
439
|
+
### Building pipelines
|
|
342
440
|
|
|
343
441
|
#### `effectPipe(...functions)`
|
|
344
442
|
|
|
@@ -370,7 +468,7 @@ Commands are data, so a Command that is constructed and discarded never runs, an
|
|
|
370
468
|
|
|
371
469
|
Traverses the effect tree, executes Commands with `async/await`, resolves `Ask` with the supplied `context`, and returns the final `Success` or `Failure`.
|
|
372
470
|
|
|
373
|
-
- `context`: Passed to `Ask`
|
|
471
|
+
- `context`: Passed to `Ask`'s next function and to `onBeforeCommand`. `context.flowName` names the workflow in telemetry.
|
|
374
472
|
- `callConfig`: Per-call overrides for `onStep`, `onRun`, `onBeforeCommand`, and `retry`. Takes precedence over `configureEffect` globals.
|
|
375
473
|
- `onRun` fires exactly once per `runEffect` call. Retry attempts run inside that single span.
|
|
376
474
|
|
|
@@ -381,7 +479,7 @@ Step 'validateRegistration' returned a plain object. Return Success, Failure, Co
|
|
|
381
479
|
Retry, or Parallel: a plain value has to be wrapped, as in Success(value).
|
|
382
480
|
```
|
|
383
481
|
|
|
384
|
-
The same check catches a missing `return`, a Command
|
|
482
|
+
The same check catches a missing `return`, a Command's next function returning a plain value, and `runEffect(flow)` where `runEffect(flow(input))` was meant. A Command that throws is still a `Failure`.
|
|
385
483
|
|
|
386
484
|
#### `configureEffect(...configs)`
|
|
387
485
|
|
package/index.d.ts
CHANGED
|
@@ -44,10 +44,15 @@ export type RetryOptions = {
|
|
|
44
44
|
backoff?: number;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* `E` is the error the node contributes to its pipeline, which for a Retry is the exhaustion failure:
|
|
49
|
+
* the interpreter never lets an inner attempt's error escape unwrapped, so the wrapped tree's own
|
|
50
|
+
* error type is consumed by the retry loop rather than exposed here.
|
|
51
|
+
*/
|
|
47
52
|
export type RetryState<T, E = unknown, Ctx = unknown> = {
|
|
48
53
|
type: 'Retry';
|
|
49
|
-
effect: Effect<T,
|
|
50
|
-
options: RetryOptions;
|
|
54
|
+
effect: Effect<T, any, Ctx>;
|
|
55
|
+
options: RetryOptions & { onExhausted?: (error: RetryExhaustedError<any>) => Effect<T, any, Ctx> };
|
|
51
56
|
next: (value: T) => Effect<T, E, Ctx>;
|
|
52
57
|
initialInput?: unknown;
|
|
53
58
|
};
|
|
@@ -93,10 +98,35 @@ export declare function Ask<T, E = unknown, Ctx = unknown>(
|
|
|
93
98
|
next: (context: Ctx) => Effect<T, E, Ctx>
|
|
94
99
|
): AskState<T, E, Ctx>;
|
|
95
100
|
|
|
101
|
+
/**
|
|
102
|
+
* With `onExhausted`, the exhaustion failure never escapes: the fallback Effect runs instead, its
|
|
103
|
+
* success feeds `next`, and its failure propagates unwrapped, so the node's declared error is the
|
|
104
|
+
* fallback's own error type. `onExhausted` is a per-use option only; the global `retry` defaults in
|
|
105
|
+
* `EffectConfiguration` deliberately cannot carry one.
|
|
106
|
+
*/
|
|
107
|
+
export declare function Retry<T, E = unknown, E2 = unknown, Ctx = unknown>(
|
|
108
|
+
effect: Effect<T, E, Ctx>,
|
|
109
|
+
options: RetryOptions & { onExhausted: (error: RetryExhaustedError<E>) => Effect<T, E2, Ctx> }
|
|
110
|
+
): RetryState<T, E2, Ctx>;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Without `onExhausted`, the declared error type is `RetryExhaustedError<E>`, because that is what
|
|
114
|
+
* a Retry's Failure actually carries: on exhaustion the interpreter returns
|
|
115
|
+
* `Failure({ retryExhausted: true, lastError, attempts })` rather than the inner error itself,
|
|
116
|
+
* so `result.error.lastError` is where the wrapped tree's `E` survives.
|
|
117
|
+
*/
|
|
96
118
|
export declare function Retry<T, E = unknown, Ctx = unknown>(
|
|
97
119
|
effect: Effect<T, E, Ctx>,
|
|
98
120
|
options?: RetryOptions
|
|
99
|
-
): RetryState<T, E
|
|
121
|
+
): RetryState<T, RetryExhaustedError<E>, Ctx>;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `next` is optional and defaults to `(values) => Success(values)`, same as `Command`'s default,
|
|
125
|
+
* so a bare `Parallel(effects)` resolves to the ordered array of success values.
|
|
126
|
+
*/
|
|
127
|
+
export declare function Parallel<T extends readonly unknown[], E = unknown, Ctx = unknown>(effects: {
|
|
128
|
+
[K in keyof T]: Effect<T[K], E, Ctx>;
|
|
129
|
+
}): ParallelState<[...T], [...T], E, Ctx>;
|
|
100
130
|
|
|
101
131
|
export declare function Parallel<T extends readonly unknown[], R, E = unknown, Ctx = unknown>(
|
|
102
132
|
effects: { [K in keyof T]: Effect<T[K], E, Ctx> },
|
package/index.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* @typedef {{
|
|
29
29
|
* type: 'Retry',
|
|
30
30
|
* effect: Effect,
|
|
31
|
-
* options: { attempts?: number, delay?: number, backoff?: number },
|
|
31
|
+
* options: { attempts?: number, delay?: number, backoff?: number, onExhausted?: (error: any) => Effect },
|
|
32
32
|
* next: (value: any) => Effect,
|
|
33
33
|
* initialInput?: any
|
|
34
34
|
* }} RetryState
|
|
@@ -125,6 +125,10 @@ const Ask = (next) => ({ type: 'Ask', next });
|
|
|
125
125
|
* @param {number} [options.attempts] - Max retries (not counting first try)
|
|
126
126
|
* @param {number} [options.delay] - Ms before first retry
|
|
127
127
|
* @param {number} [options.backoff] - Multiplier applied to delay on each subsequent retry
|
|
128
|
+
* @param {(error: any) => Effect} [options.onExhausted] - Runs a fallback Effect when every attempt has
|
|
129
|
+
* failed, receiving `{ retryExhausted, lastError, attempts }`. The fallback's success feeds `next`
|
|
130
|
+
* exactly as the primary's would have; its failure propagates unwrapped. Per-use only, never a
|
|
131
|
+
* global default, and a fallback never starts in a `Parallel` branch a sibling has cancelled.
|
|
128
132
|
* @returns {RetryState}
|
|
129
133
|
*/
|
|
130
134
|
const Retry = (effect, options = {}) => ({
|
|
@@ -145,10 +149,15 @@ const Retry = (effect, options = {}) => ({
|
|
|
145
149
|
* signal runs to completion, so a branch's first Command can still write after a sibling has failed.
|
|
146
150
|
*
|
|
147
151
|
* @param {Effect[]} effects - Array of Effect trees to run concurrently
|
|
148
|
-
* @param {(values: any[]) => Effect} next - Receives array of success values in order, returns next Effect
|
|
152
|
+
* @param {(values: any[]) => Effect} [next] - Receives array of success values in order, returns next Effect.
|
|
153
|
+
* Defaults to `(values) => Success(values)`, same as `Command`'s default.
|
|
149
154
|
* @returns {ParallelState}
|
|
150
155
|
*/
|
|
151
|
-
const Parallel = (effects, next
|
|
156
|
+
const Parallel = (effects, next = (/** @type {any[]} */ values) => Success(values)) => ({
|
|
157
|
+
type: 'Parallel',
|
|
158
|
+
effects,
|
|
159
|
+
next
|
|
160
|
+
});
|
|
152
161
|
|
|
153
162
|
/**
|
|
154
163
|
* Describes a value for an error message, leading with the mistake it most likely is.
|
|
@@ -540,6 +549,9 @@ const runEffect =
|
|
|
540
549
|
// Each attempt gets its own prefix, so the Commands of attempt 2 cannot be mistaken
|
|
541
550
|
// for the Commands of attempt 1 when a trace is matched back up.
|
|
542
551
|
const retryPath = `${path}${step++}r`;
|
|
552
|
+
// Captured while `eff` is still narrowed to a Retry node: the loop reassigns `eff`
|
|
553
|
+
// on success, after which the checker cannot prove the fallback path still holds it.
|
|
554
|
+
const retryNext = eff.next;
|
|
543
555
|
|
|
544
556
|
for (let attempt = 0; attempt <= attempts; attempt++) {
|
|
545
557
|
if (attempt > 0) {
|
|
@@ -551,7 +563,7 @@ const runEffect =
|
|
|
551
563
|
if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
|
|
552
564
|
const result = await execute(eff.effect, signal, `${retryPath}${attempt}/`);
|
|
553
565
|
if (result.type === 'Success') {
|
|
554
|
-
eff =
|
|
566
|
+
eff = retryNext(result.value);
|
|
555
567
|
succeeded = true;
|
|
556
568
|
break;
|
|
557
569
|
}
|
|
@@ -559,7 +571,23 @@ const runEffect =
|
|
|
559
571
|
}
|
|
560
572
|
|
|
561
573
|
if (!succeeded) {
|
|
562
|
-
|
|
574
|
+
const exhausted = { retryExhausted: true, lastError, attempts };
|
|
575
|
+
const { onExhausted } = opts;
|
|
576
|
+
if (typeof onExhausted !== 'function') return Failure(exhausted, eff.initialInput);
|
|
577
|
+
// A cancelled branch must not start its fallback, for the same reason it starts
|
|
578
|
+
// no further Commands: recovery must not resurrect work a sibling's failure ended.
|
|
579
|
+
if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
|
|
580
|
+
// The fallback gets its own path prefix, so a fallback-path trace can never be
|
|
581
|
+
// confused with a success-path one when it is matched back up on replay.
|
|
582
|
+
const fallback = await execute(
|
|
583
|
+
asEffect(onExhausted(exhausted), "Retry option 'onExhausted'"),
|
|
584
|
+
signal,
|
|
585
|
+
`${retryPath}f/`
|
|
586
|
+
);
|
|
587
|
+
// A failing fallback propagates as-is: the flow's last word was the fallback's
|
|
588
|
+
// error, not the exhaustion it was already told about.
|
|
589
|
+
if (fallback.type === 'Failure') return fallback;
|
|
590
|
+
eff = retryNext(fallback.value);
|
|
563
591
|
}
|
|
564
592
|
continue;
|
|
565
593
|
}
|
|
@@ -712,6 +740,8 @@ const serializeError = (e, withStack) => {
|
|
|
712
740
|
/** @type {any} */
|
|
713
741
|
const out = { __error: true, name: e.name, message: e.message };
|
|
714
742
|
if (withStack) out.stack = e.stack;
|
|
743
|
+
// `cause` is non-enumerable too, and it can itself be an Error, so it is carried recursively.
|
|
744
|
+
if ('cause' in e) out.cause = serializeError(e.cause, withStack);
|
|
715
745
|
for (const k of Object.keys(e)) out[k] = /** @type {any} */ (e)[k];
|
|
716
746
|
return out;
|
|
717
747
|
};
|
|
@@ -727,7 +757,9 @@ const reviveError = (v) => {
|
|
|
727
757
|
const e = new Error(v.message);
|
|
728
758
|
e.name = v.name;
|
|
729
759
|
for (const [k, val] of Object.entries(v)) {
|
|
730
|
-
if (k !== '__error' && k !== 'name' && k !== 'message')
|
|
760
|
+
if (k !== '__error' && k !== 'name' && k !== 'message') {
|
|
761
|
+
/** @type {any} */ (e)[k] = k === 'cause' ? reviveError(val) : val;
|
|
762
|
+
}
|
|
731
763
|
}
|
|
732
764
|
return e;
|
|
733
765
|
};
|
|
@@ -956,13 +988,21 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
956
988
|
const zeroRetryDelays = (eff) => {
|
|
957
989
|
if (!eff || typeof eff.type !== 'string') return eff;
|
|
958
990
|
switch (eff.type) {
|
|
959
|
-
case 'Retry':
|
|
991
|
+
case 'Retry': {
|
|
992
|
+
const options = { ...eff.options, delay: 0, backoff: 1 };
|
|
993
|
+
// The fallback tree only exists once onExhausted runs, so it is rewritten lazily too;
|
|
994
|
+
// otherwise a Retry inside the fallback keeps its production backoff during replay.
|
|
995
|
+
if (typeof options.onExhausted === 'function') {
|
|
996
|
+
const original = options.onExhausted;
|
|
997
|
+
options.onExhausted = (/** @type {any} */ error) => zeroRetryDelays(original(error));
|
|
998
|
+
}
|
|
960
999
|
return {
|
|
961
1000
|
...eff,
|
|
962
|
-
options
|
|
1001
|
+
options,
|
|
963
1002
|
effect: zeroRetryDelays(eff.effect),
|
|
964
1003
|
next: (/** @type {any} */ value) => zeroRetryDelays(eff.next(value))
|
|
965
1004
|
};
|
|
1005
|
+
}
|
|
966
1006
|
case 'Command':
|
|
967
1007
|
return { ...eff, next: (/** @type {any} */ result) => zeroRetryDelays(eff.next(result)) };
|
|
968
1008
|
case 'Ask':
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pure-effect",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.11.0",
|
|
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
|
"type": "module",
|
|
6
6
|
"exports": "./index.js",
|
|
7
7
|
"types": "./index.d.ts",
|
|
8
8
|
"scripts": {
|
|
9
|
-
"test": "mocha && tsd",
|
|
9
|
+
"test": "mocha && tsd && tsc -p tsconfig.check.json",
|
|
10
10
|
"format": "prettier --write index.js index.d.ts examples/*.js test/all.js README.md CLAUDE.md"
|
|
11
11
|
},
|
|
12
12
|
"tsd": {
|
|
13
13
|
"directory": "test"
|
|
14
14
|
},
|
|
15
15
|
"author": "Aycan Gulez",
|
|
16
|
-
"homepage": "https://
|
|
16
|
+
"homepage": "https://pure-effect.org",
|
|
17
17
|
"license": "MIT",
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@opentelemetry/api": "^1.9.1",
|
package/test/all.js
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
replayEffect,
|
|
17
17
|
timeTravel
|
|
18
18
|
} from '../index.js';
|
|
19
|
+
import * as lib from '../index.js';
|
|
20
|
+
import ts from 'typescript';
|
|
19
21
|
import { enableTelemetry, telemetryHooks } from '../examples/opentelemetry-example.js';
|
|
20
22
|
import { enableRecording, recordingHooks } from '../examples/recording-example.js';
|
|
21
23
|
|
|
@@ -247,6 +249,14 @@ describe('Core', function () {
|
|
|
247
249
|
assert.equal(result.next, next);
|
|
248
250
|
});
|
|
249
251
|
|
|
252
|
+
it('should default next to Success of the values array when omitted', async () => {
|
|
253
|
+
const e1 = Command(async () => 'a');
|
|
254
|
+
const e2 = Command(async () => 'b');
|
|
255
|
+
const result = await runEffect(Parallel([e1, e2]));
|
|
256
|
+
assert.equal(result.type, 'Success');
|
|
257
|
+
assert.deepEqual(result.value, ['a', 'b']);
|
|
258
|
+
});
|
|
259
|
+
|
|
250
260
|
it('should run effects concurrently and pass results to next', async () => {
|
|
251
261
|
const e1 = Command(
|
|
252
262
|
async () => 'a',
|
|
@@ -309,6 +319,138 @@ describe('Core', function () {
|
|
|
309
319
|
});
|
|
310
320
|
});
|
|
311
321
|
|
|
322
|
+
describe('Retry onExhausted', function () {
|
|
323
|
+
beforeEach(() => configureEffect());
|
|
324
|
+
|
|
325
|
+
it('should run the fallback on exhaustion and feed its value downstream', async function () {
|
|
326
|
+
let liveCalls = 0;
|
|
327
|
+
/** @type {any} */
|
|
328
|
+
let sawError;
|
|
329
|
+
const flow = effectPipe(
|
|
330
|
+
(/** @type {any} */ sku) =>
|
|
331
|
+
Retry(
|
|
332
|
+
Command(function cmdFetchLive() {
|
|
333
|
+
liveCalls++;
|
|
334
|
+
return Promise.reject(new Error('down'));
|
|
335
|
+
}),
|
|
336
|
+
{
|
|
337
|
+
attempts: 2,
|
|
338
|
+
delay: 0,
|
|
339
|
+
onExhausted: (/** @type {any} */ err) => {
|
|
340
|
+
sawError = err;
|
|
341
|
+
return Command(function cmdFetchCached() {
|
|
342
|
+
return Promise.resolve({ sku, amount: 95 });
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
),
|
|
347
|
+
(/** @type {any} */ price) => Success({ ...price, stale: true })
|
|
348
|
+
);
|
|
349
|
+
const result = /** @type {any} */ (await runEffect(flow('sku-1')));
|
|
350
|
+
assert.equal(result.type, 'Success');
|
|
351
|
+
assert.deepEqual(result.value, { sku: 'sku-1', amount: 95, stale: true });
|
|
352
|
+
assert.equal(liveCalls, 3, 'the primary still runs the full retry schedule first');
|
|
353
|
+
assert.equal(sawError.retryExhausted, true);
|
|
354
|
+
assert.equal(sawError.attempts, 2);
|
|
355
|
+
assert.equal(sawError.lastError.message, 'down');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('should propagate a failing fallback unwrapped', async function () {
|
|
359
|
+
const flow = Retry(
|
|
360
|
+
Command(function cmdPrimary() {
|
|
361
|
+
return Promise.reject('primary down');
|
|
362
|
+
}),
|
|
363
|
+
{ attempts: 0, delay: 0, onExhausted: () => Failure('cache empty') }
|
|
364
|
+
);
|
|
365
|
+
const result = /** @type {any} */ (await runEffect(flow));
|
|
366
|
+
assert.equal(result.type, 'Failure');
|
|
367
|
+
assert.equal(result.error, 'cache empty', 'the fallback failure must not be wrapped in retryExhausted');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('should not run the fallback in a cancelled Parallel branch', async function () {
|
|
371
|
+
let fallbackRuns = 0;
|
|
372
|
+
const slowFailing = Retry(
|
|
373
|
+
Command(async function cmdSlowFail() {
|
|
374
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
375
|
+
throw new Error('slow branch failed');
|
|
376
|
+
}),
|
|
377
|
+
{
|
|
378
|
+
attempts: 0,
|
|
379
|
+
delay: 0,
|
|
380
|
+
onExhausted: () => {
|
|
381
|
+
fallbackRuns++;
|
|
382
|
+
return Success('recovered');
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
);
|
|
386
|
+
const fastFailing = Command(async function cmdFastFail() {
|
|
387
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
388
|
+
throw new Error('fast branch failed');
|
|
389
|
+
});
|
|
390
|
+
const result = /** @type {any} */ (await runEffect(Parallel([fastFailing, slowFailing])));
|
|
391
|
+
assert.equal(result.type, 'Failure');
|
|
392
|
+
assert.equal(result.error.message, 'fast branch failed');
|
|
393
|
+
assert.equal(fallbackRuns, 0, 'a cancelled branch must not start its fallback');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should record the fallback under its own path and replay it with zero I/O', async function () {
|
|
397
|
+
const calls = { live: 0, cached: 0 };
|
|
398
|
+
const flow = (/** @type {any} */ sku) =>
|
|
399
|
+
Retry(
|
|
400
|
+
Command(function cmdFetchLive() {
|
|
401
|
+
calls.live++;
|
|
402
|
+
return Promise.reject(new Error('down'));
|
|
403
|
+
}),
|
|
404
|
+
{
|
|
405
|
+
attempts: 1,
|
|
406
|
+
delay: 0,
|
|
407
|
+
onExhausted: () =>
|
|
408
|
+
Command(function cmdFetchCached() {
|
|
409
|
+
calls.cached++;
|
|
410
|
+
return Promise.resolve(95);
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
);
|
|
414
|
+
const { result, trace } = await recordEffect(flow, 'sku-1');
|
|
415
|
+
assert.equal(result.type, 'Success');
|
|
416
|
+
assert.ok(
|
|
417
|
+
trace.trace.some((/** @type {any} */ e) => e.command === 'cmdFetchCached' && /f\//.test(e.path)),
|
|
418
|
+
'the fallback step must be recorded under a fallback path prefix'
|
|
419
|
+
);
|
|
420
|
+
const before = { ...calls };
|
|
421
|
+
const replayed = /** @type {any} */ (await replayEffect(flow('sku-1'), trace));
|
|
422
|
+
assert.equal(replayed.type, 'Success');
|
|
423
|
+
assert.deepEqual(replayed.value, /** @type {any} */ (result).value);
|
|
424
|
+
assert.deepEqual(calls, before, 'replay must perform no I/O');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('should strip Retry delays inside the fallback during replay', async function () {
|
|
428
|
+
const flow = (/** @type {any} */ n) =>
|
|
429
|
+
Retry(
|
|
430
|
+
Command(function cmdPrimary() {
|
|
431
|
+
return Promise.reject('nope');
|
|
432
|
+
}),
|
|
433
|
+
{
|
|
434
|
+
attempts: 0,
|
|
435
|
+
delay: 0,
|
|
436
|
+
onExhausted: () =>
|
|
437
|
+
Retry(
|
|
438
|
+
Command(function cmdSecondary() {
|
|
439
|
+
return Promise.reject('still nope');
|
|
440
|
+
}),
|
|
441
|
+
{ attempts: 2, delay: 200 }
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
);
|
|
445
|
+
const { trace } = await recordEffect(flow, 1);
|
|
446
|
+
const started = Date.now();
|
|
447
|
+
const replayed = /** @type {any} */ (await replayEffect(flow(1), trace));
|
|
448
|
+
const elapsed = Date.now() - started;
|
|
449
|
+
assert.equal(replayed.type, 'Failure');
|
|
450
|
+
assert.ok(elapsed < 150, `replay must not wait out the fallback backoff, took ${elapsed}ms`);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
312
454
|
/** Reads `.value` off a runEffect result once its type has been asserted. */
|
|
313
455
|
const valueOf = (/** @type {any} */ result) => result.value;
|
|
314
456
|
|
|
@@ -628,6 +770,41 @@ describe('Recording and replay', function () {
|
|
|
628
770
|
assert.deepEqual(valueOf(fromEntries), valueOf(result));
|
|
629
771
|
});
|
|
630
772
|
|
|
773
|
+
it('should carry an error cause through a trace and back', async function () {
|
|
774
|
+
const flow = (/** @type {any} */ input) =>
|
|
775
|
+
Command(function cmdCharge() {
|
|
776
|
+
return Promise.reject(new Error('charge declined', { cause: new Error('card expired') }));
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
const { result, trace } = await recordEffect(flow, { id: 'cause' });
|
|
780
|
+
assert.equal(result.type, 'Failure');
|
|
781
|
+
|
|
782
|
+
// The trace must survive JSON, which is where a non-enumerable cause would silently vanish.
|
|
783
|
+
const stored = JSON.parse(JSON.stringify(trace));
|
|
784
|
+
const replayed = await replayEffect(flow(stored.initialInput), stored);
|
|
785
|
+
assert.equal(replayed.type, 'Failure');
|
|
786
|
+
const error = /** @type {any} */ (errorOf(replayed));
|
|
787
|
+
assert.equal(error.message, 'charge declined');
|
|
788
|
+
assert.ok(error.cause instanceof Error, 'the revived error must keep its cause');
|
|
789
|
+
assert.equal(error.cause.message, 'card expired');
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it('should carry a nested cause chain and a non-Error cause', async function () {
|
|
793
|
+
const flow = (/** @type {any} */ input) =>
|
|
794
|
+
Command(function cmdFetch() {
|
|
795
|
+
return Promise.reject(
|
|
796
|
+
new Error('fetch failed', { cause: new Error('socket closed', { cause: 'ECONNRESET' }) })
|
|
797
|
+
);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
const { trace } = await recordEffect(flow, { id: 'chain' });
|
|
801
|
+
const stored = JSON.parse(JSON.stringify(trace));
|
|
802
|
+
const replayed = await replayEffect(flow(stored.initialInput), stored);
|
|
803
|
+
const error = /** @type {any} */ (errorOf(replayed));
|
|
804
|
+
assert.equal(error.cause.message, 'socket closed');
|
|
805
|
+
assert.equal(error.cause.cause, 'ECONNRESET', 'a non-Error cause must pass through unchanged');
|
|
806
|
+
});
|
|
807
|
+
|
|
631
808
|
it('should reject a malformed trace as a ReplayError, not a TypeError', async function () {
|
|
632
809
|
const { flow } = makeFlow();
|
|
633
810
|
for (const bad of [undefined, null, 42, {}, { trace: 'nope' }]) {
|
|
@@ -2065,3 +2242,24 @@ describe('Parallel cancellation', function () {
|
|
|
2065
2242
|
assert.deepEqual(args, [[]], 'a thunk outside a Parallel is called with no arguments at all');
|
|
2066
2243
|
});
|
|
2067
2244
|
});
|
|
2245
|
+
|
|
2246
|
+
describe('Declaration parity', function () {
|
|
2247
|
+
it('should declare exactly the exports the runtime ships', function () {
|
|
2248
|
+
// index.d.ts is hand-maintained beside index.js, so the two can drift silently: a
|
|
2249
|
+
// declaration once vanished as edit collateral while every check stayed green. This pins
|
|
2250
|
+
// existence in both directions; signatures remain tsd's job.
|
|
2251
|
+
const program = ts.createProgram(['index.d.ts'], { noEmit: true });
|
|
2252
|
+
const source = program.getSourceFile('index.d.ts');
|
|
2253
|
+
assert.ok(source, 'index.d.ts must be part of the program');
|
|
2254
|
+
const checker = program.getTypeChecker();
|
|
2255
|
+
const moduleSymbol = checker.getSymbolAtLocation(/** @type {import('typescript').Node} */ (source));
|
|
2256
|
+
assert.ok(moduleSymbol, 'index.d.ts must be a module');
|
|
2257
|
+
const declared = checker
|
|
2258
|
+
.getExportsOfModule(moduleSymbol)
|
|
2259
|
+
.filter((s) => s.flags & ts.SymbolFlags.Value)
|
|
2260
|
+
.map((s) => s.name)
|
|
2261
|
+
.sort();
|
|
2262
|
+
const shipped = Object.keys(lib).sort();
|
|
2263
|
+
assert.deepEqual(declared, shipped);
|
|
2264
|
+
});
|
|
2265
|
+
});
|
package/test/types.test-d.ts
CHANGED
|
@@ -104,13 +104,14 @@ const innerCmd = Command(
|
|
|
104
104
|
(n) => Success(n)
|
|
105
105
|
);
|
|
106
106
|
|
|
107
|
-
// Retry with options preserves T
|
|
107
|
+
// Retry with options preserves T; the error type is the exhaustion failure, not the inner error,
|
|
108
|
+
// because the interpreter wraps the last inner error rather than letting it escape
|
|
108
109
|
const retried = Retry(innerCmd, { attempts: 3 });
|
|
109
|
-
expectType<RetryState<number, unknown
|
|
110
|
+
expectType<RetryState<number, RetryExhaustedError<unknown>>>(retried);
|
|
110
111
|
|
|
111
112
|
// Retry without options is valid
|
|
112
113
|
const retriedNoOpts = Retry(innerCmd);
|
|
113
|
-
expectType<RetryState<number, unknown
|
|
114
|
+
expectType<RetryState<number, RetryExhaustedError<unknown>>>(retriedNoOpts);
|
|
114
115
|
|
|
115
116
|
// Retry in effectPipe preserves type flow
|
|
116
117
|
const retryFlow = effectPipe((input: User) =>
|
|
@@ -122,7 +123,35 @@ const retryFlow = effectPipe((input: User) =>
|
|
|
122
123
|
{ attempts: 2 }
|
|
123
124
|
)
|
|
124
125
|
);
|
|
125
|
-
expectType<Effect<SavedUser
|
|
126
|
+
expectType<Effect<SavedUser, RetryExhaustedError<unknown>>>(retryFlow({ email: 'a@b.com', password: 'secret123' }));
|
|
127
|
+
|
|
128
|
+
// The inner error type survives inside the exhaustion failure, so lastError is typed
|
|
129
|
+
const retriedTyped = Retry(Success(1) as Effect<number, 'net_down'>);
|
|
130
|
+
expectType<RetryState<number, RetryExhaustedError<'net_down'>>>(retriedTyped);
|
|
131
|
+
|
|
132
|
+
const retryResult = await runEffect(Retry(Success(1) as Effect<number, 'flaky'>, { attempts: 1 }));
|
|
133
|
+
if (retryResult.type === 'Failure') {
|
|
134
|
+
expectType<RetryExhaustedError<'flaky'>>(retryResult.error);
|
|
135
|
+
expectType<'flaky'>(retryResult.error.lastError);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// onExhausted consumes the exhaustion, so the fallback's error type is what remains
|
|
139
|
+
const recovered = Retry(Success(1) as Effect<number, 'flaky'>, {
|
|
140
|
+
attempts: 1,
|
|
141
|
+
onExhausted: (err) => {
|
|
142
|
+
expectType<RetryExhaustedError<'flaky'>>(err);
|
|
143
|
+
return Success(0) as Effect<number, 'cache_miss'>;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
expectType<RetryState<number, 'cache_miss'>>(recovered);
|
|
147
|
+
|
|
148
|
+
const recoveredResult = await runEffect(recovered);
|
|
149
|
+
if (recoveredResult.type === 'Failure') {
|
|
150
|
+
expectType<'cache_miss'>(recoveredResult.error);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// onExhausted is per-use only: global retry defaults cannot carry a fallback
|
|
154
|
+
expectError(configureEffect({ retry: { attempts: 2, onExhausted: () => Success(1) } }));
|
|
126
155
|
|
|
127
156
|
// RetryExhaustedError shape is usable for narrowing exhaustion failures
|
|
128
157
|
const exhaustedErr: RetryExhaustedError<Error> = {
|
|
@@ -168,6 +197,14 @@ expectType<Effect<{ email: string; password: string }>>(parallelFlow({ email: 'a
|
|
|
168
197
|
const parallelResult = await runEffect(Parallel([Success(1), Success('x')], ([n, s]) => Success({ n, s })));
|
|
169
198
|
expectType<SuccessState<{ n: number; s: string }> | FailureState<unknown>>(parallelResult);
|
|
170
199
|
|
|
200
|
+
// With next omitted, the Parallel resolves to the values tuple itself
|
|
201
|
+
const parBare = Parallel([Success(42), Success('hello')]);
|
|
202
|
+
expectType<ParallelState<[number, string], [number, string]>>(parBare);
|
|
203
|
+
const parBareResult = await runEffect(parBare);
|
|
204
|
+
if (parBareResult.type === 'Success') {
|
|
205
|
+
expectType<[number, string]>(parBareResult.value);
|
|
206
|
+
}
|
|
207
|
+
|
|
171
208
|
// --- Ctx (context type) ---
|
|
172
209
|
|
|
173
210
|
interface AppCtx {
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(npm test *)",
|
|
5
|
-
"Bash(npm view *)",
|
|
6
|
-
"Bash(npx mocha *)",
|
|
7
|
-
"Bash(echo \"exit: $?\")",
|
|
8
|
-
"Bash(npx prettier *)",
|
|
9
|
-
"Bash(echo \"npm test exit: $?\")",
|
|
10
|
-
"Bash(npx tsc *)",
|
|
11
|
-
"Bash(echo \"tsc exit: $?\")",
|
|
12
|
-
"Read(//tmp/**)",
|
|
13
|
-
"Bash(npx esbuild *)",
|
|
14
|
-
"Bash(gzip -9)"
|
|
15
|
-
]
|
|
16
|
-
}
|
|
17
|
-
}
|