pure-effect 0.10.0 → 0.12.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 +33 -21
- package/README.md +134 -17
- package/index.d.ts +68 -17
- package/index.js +130 -64
- package/jsconfig.json +13 -0
- package/package.json +5 -5
- package/test/all.js +416 -37
- package/test/types.test-d.ts +117 -13
- package/.claude/settings.local.json +0 -17
package/CLAUDE.md
CHANGED
|
@@ -7,17 +7,20 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|
|
7
7
|
```bash
|
|
8
8
|
npm test # run all tests
|
|
9
9
|
npx mocha test/all.js --grep "pattern" # run a single test by name
|
|
10
|
-
npm run format # Prettier over index.js, index.d.ts, examples/*.js, test/all.js, README.md, CLAUDE.md
|
|
11
|
-
npx tsd # type-level gate
|
|
10
|
+
npm run format # Prettier over index.js, index.d.ts, examples/*.js, test/all.js, test/types.test-d.ts, README.md, CLAUDE.md
|
|
11
|
+
npx tsd # type-level gate over test/types.test-d.ts
|
|
12
|
+
npx tsc -p jsconfig.json # strict tsc over index.js, test/all.js, test/types.test-d.ts, and examples/*.js, reading index.d.ts as a consumer; the same config VS Code uses
|
|
12
13
|
npx esbuild index.js --minify --format=esm | gzip -9 | wc -c # the size the README claims
|
|
13
14
|
```
|
|
14
15
|
|
|
15
16
|
No build or lint step: the library ships as plain ES modules with no transpilation. Formatting is Prettier, configured in `.prettierrc`; match it when editing.
|
|
16
17
|
|
|
17
|
-
The README's badge and feature bullet both state a minified-and-gzipped ceiling, currently
|
|
18
|
+
The README's badge and feature bullet both state a minified-and-gzipped ceiling, currently around 4 KB. Re-measure with the command above after adding anything to `index.js`, because the badge is a static `img.shields.io/badge/` URL with the number written into it rather than a live query, so it cannot correct itself.
|
|
18
19
|
|
|
19
20
|
**Markdown prose has no hard line breaks.** Write each paragraph and each list item as one line and let the editor soft-wrap; never wrap prose at a column. Hard breaks make later edits reflow whole paragraphs, turning a one-word change into a multi-line diff. `proseWrap: never` enforces this, so `npm run format` unwraps any prose that arrives wrapped. Even so, write it unwrapped in the first place rather than relying on the sweep, since a wrapped paragraph reads as an intended shape until something reflows it. Fenced code blocks are untouched by the setting, and table rows stay one per line; only prose joins.
|
|
20
21
|
|
|
22
|
+
**No `thunk` in the README.** Reader-facing prose says the Command's function, or `cmd`. The word remains fine in this file, in JSDoc, and in code comments, where precision for maintainers matters more than approachability, but a README reader should never need to know it. `grep -n "thunk" README.md` checks a change before it lands.
|
|
23
|
+
|
|
21
24
|
**No em dashes.** The `—` character does not appear anywhere in this repository: not in `README.md`, not in this file, not in JSDoc, code comments, test names, or assertion messages. Reach for the punctuation that carries the actual relationship instead of a dash that blurs it. A colon when the second half explains or names the first. Parentheses for an aside the sentence could survive without. A semicolon between two linked independent clauses. A comma for a light pause or a trailing "which" clause. A full stop when the clause can stand on its own, which is usually the honest fix for a dash joining two complete thoughts. Nothing enforces this mechanically, so it is on the author; `grep -rn "—" --include="*.js" --include="*.ts" --include="*.md" .` checks a change before it lands. Table cells that need a "not applicable" placeholder use `n/a`.
|
|
22
25
|
|
|
23
26
|
## Architecture
|
|
@@ -32,17 +35,19 @@ The README's badge and feature bullet both state a minified-and-gzipped ceiling,
|
|
|
32
35
|
| `Failure(error, initialInput)` | `{ type: 'Failure', error, initialInput }` | Short-circuits the pipeline |
|
|
33
36
|
| `Command(cmdFn, nextFn, meta)` | `{ type: 'Command', cmd, next, meta }` | Defers a side effect for the interpreter; `meta.name` sets the Command's identity |
|
|
34
37
|
| `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 |
|
|
38
|
+
| `Retry(effect, options)` | `{ type: 'Retry', effect, options, next }` | Wraps any Effect tree with retry-on-failure semantics; handled natively by the interpreter; a per-use `options.onExhausted` runs a fallback Effect when every attempt has failed |
|
|
39
|
+
| `Parallel(effects, next?)` | `{ type: 'Parallel', effects, next }` | Runs multiple Effect trees concurrently; the first branch to fail cancels its siblings and its Failure is returned; context flows into all branches |
|
|
37
40
|
| `effectPipe(...fns)` | n/a | Composes functions into a sequential pipeline via `chain` |
|
|
38
41
|
| `runEffect(effect, context, callConfig?)` | async | Interpreter: traverses the effect tree, executes Commands; resolves `Ask` and `Retry`; per-call `callConfig` overrides global defaults |
|
|
39
42
|
| `configureEffect(...configs)` | n/a | Sets process-wide telemetry hooks (`onStep`, `onRun`, `onBeforeCommand`) and global `retry` defaults; merges several configurations into one wiring; returns a restore function; overridden by per-call `callConfig` |
|
|
40
43
|
|
|
41
44
|
The recording and replay exports are a 2x2 rather than four loose helpers: `recorder` and `replayEffect` are the capabilities, at the hook and tree level, while `recordEffect` and `timeTravel` are the flow-level pair that also own trace metadata. That metadata is why the conveniences exist and should not be cut as mere sugar: `initialInput` lets a flow be rebuilt, `context` lets `Ask` resolve on replay, and `version` makes a stale trace detectable. A caller who assembles a trace by hand and forgets `context` gets one that silently diverges. `timeTravel` is presentation over `replayEffect` and could be rebuilt from `onResolved` in about ten lines, but it stays: the one-line form is what makes the feature graspable.
|
|
42
45
|
|
|
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)`.
|
|
46
|
+
`Command(cmd, next, meta)` keeps that parameter list, and `next` is optional because most of Commands in this repo pass their result straight through: it defaults to `(result) => Success(result)`. `Parallel`'s `next` follows the same convention, defaulting to `(values) => Success(values)`, so a bare `Parallel(effects)` resolves to the ordered array of success values. The README's `Composing Larger Flows` section documents the composition patterns the primitives already support (sub-pipeline branching, `Parallel` fan-in, local joins that carry a minimal purpose-shaped value); those shapes need no combinators, which is why none were added. The no-accumulator guidance there is deliberate bug avoidance, not taste: a growing scope object hides which step depends on what, lets stale fields outlive their producers, and widens what carries PII, so keep the section's examples threading minimal values if they are ever reworked.
|
|
47
|
+
|
|
48
|
+
There is deliberately no catch combinator (an `Attempt` node was designed and rejected), and the README's `Which Errors Are Data` section is the guidance that replaced it: an outcome the flow handles is data (a `next` branches on the result, and a rejecting `cmd` function catches into a result shape at the boundary), while `Failure` means abort and is reserved for the shell. Fallbacks written this way are fully traced and replayed; the failed attempt is recorded as that Command's result with the error as data, so nothing is lost, only categorized as handled. Catch was rejected because it reintroduces non-local control flow into a paradigm whose point is explicit data flow, and because every motivating case except one was already expressible. The one exception, recovering from `Retry` exhaustion in-flow, is now covered by the scoped per-use option `Retry(effect, { onExhausted })`. It was added, where general catch was rejected, for three reasons worth keeping: it was the only composition with no in-library expression (the shell workaround split one logical operation across two runs and two traces); retry-then-fallback is a canonical resilience pairing (Polly, resilience4j), so demand was predictable rather than speculative; and it completes `Retry`'s own failure handling rather than adding catch semantics, since `Retry` was already the node that reacts to failures and previously abdicated on the last one. The ceiling stands: no general catch node, nothing but exhaustion is catchable, `onExhausted` is per-use only (the global `retry` defaults cannot carry one, pinned by an `expectError`), and a cancelled branch skips the fallback.
|
|
44
49
|
|
|
45
|
-
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,
|
|
50
|
+
A Command's identity is resolved by the internal `commandName(eff)`: a non-empty string `meta.name`, else `cmd.name`, else `'anonymous'`. That identity is used in three places at once, which is why it has its own function rather than being inlined at the call site: trace entries are keyed on it, replay matching compares it against the recorded name, and telemetry spans are titled with it. `meta.name` exists because the previous rule, `cmd.name` alone, made the flagship feature depend on syntax: an inline arrow silently recorded as `'anonymous'`, and any minifier that mangles function names renamed every step of every trace. Both failures are silent, which is the worst property an identity can have. `cmd.name` remains supported and is what the examples use, since naming the thunk reads well and needs no second argument.
|
|
46
51
|
|
|
47
52
|
Anything that is not an Effect is rejected by `asEffect` / `effectTypeError` with a message naming its source, and the distinction they encode is deliberate: a malformed flow is a bug and throws, while a Command that rejects is a domain outcome and becomes a `Failure`. The interpreter's catch rethrows `EffectTypeError` specifically so a flow bug cannot masquerade as a business error. Two details are important. `chain` validates the return of each pipeline step, which is the only place the offending step's name is known (`fn.name`), so that is where the useful message comes from. And the interpreter's `while` condition guards on `eff &&` before reading `eff.type`, because a continuation returning `undefined` otherwise threw a bare TypeError from the condition itself, outside any handler.
|
|
48
53
|
|
|
@@ -54,10 +59,10 @@ Before this, every one of those mistakes was silent or cryptic: forgetting `Succ
|
|
|
54
59
|
| --- | --- |
|
|
55
60
|
| `recorder(options?)` | Returns `{ onStep, entries, toTrace }`; the `onStep` hook records every Command's result or error. Options: `redact`, `maxEntries`, `stack` |
|
|
56
61
|
| `recordEffect(flowFn, initialInput, opts?)` | Runs a flow for real while recording; returns `{ result, trace }`. Convenience for tests and scripts |
|
|
57
|
-
| `replayEffect(effect, traceOrResolver, opts?)` | Replays a tree, feeding recorded outcomes to Commands instead of executing them. Takes a trace or a `Resolver`. Options: `context`, `
|
|
58
|
-
| `timeTravel(flowFn, traceLog, options?)` | Narrated replay: rebuilds the flow from the recorded input, logs each step, warns on version mismatch and unreached steps |
|
|
62
|
+
| `replayEffect(effect, traceOrResolver, opts?)` | Replays a tree, feeding recorded outcomes to Commands instead of executing them. Takes a trace or a `Resolver`; returns `{ result, unreached }` for a trace and `{ result }` for a `Resolver`. Options: `context`, `fastRetry`, `hooks`, `onMissing`, `onResolved` |
|
|
63
|
+
| `timeTravel(flowFn, traceLog, options?)` | Narrated replay: rebuilds the flow from the recorded input, logs each step, warns on version mismatch and names any unreached steps; returns the bare outcome, since narration is its job |
|
|
59
64
|
|
|
60
|
-
`fromTrace(traceLog, {
|
|
65
|
+
`fromTrace(traceLog, { onEntry })`, which builds a `Resolver` over the reference trace format, is **internal**, and `replayEffect` is its only caller. Deliberately not exported: a caller whose traces live in another shape writes a `Resolver` instead, and a caller who only wants to watch a replay uses `onResolved`. The `onEntry` observer is how `replayEffect` learns which entries were handed out; it subtracts those from the trace after the run to produce `unreached`, which is why that field exists for a trace and is absent for a `Resolver`. Between them those cover every known need, so exporting it would only widen the public surface. The one thing neither covers is _rewriting_ outcomes from a reference-format trace (substituting one step's result, or chaining a fallback), since `onResolved` is an observer whose return value is ignored. Export it again if that comes up; nothing depends on it staying private.
|
|
61
66
|
|
|
62
67
|
See **Time-travel debugging** below for the invariants these depend on, and the README for each option in full.
|
|
63
68
|
|
|
@@ -75,7 +80,10 @@ runEffect(tree, context, callConfig?)
|
|
|
75
80
|
→ resolves Ask by calling next(context), continues
|
|
76
81
|
→ resolves Retry via an inner execute() loop (not recursive runEffect),
|
|
77
82
|
so onRun fires exactly once per runEffect call regardless of attempts;
|
|
78
|
-
on exhaustion returns Failure({ retryExhausted: true, lastError, attempts })
|
|
83
|
+
on exhaustion returns Failure({ retryExhausted: true, lastError, attempts }),
|
|
84
|
+
unless a per-use onExhausted returns a fallback Effect: it executes under the
|
|
85
|
+
retry's `f` path prefix, its success feeds next, its failure propagates
|
|
86
|
+
unwrapped, and a cancelled Parallel branch skips it
|
|
79
87
|
→ resolves Parallel by running all effects via Promise.all with the same
|
|
80
88
|
context; if any effect returns Failure, returns the first Failure by
|
|
81
89
|
array index and skips next; otherwise calls next with the array of
|
|
@@ -83,7 +91,7 @@ runEffect(tree, context, callConfig?)
|
|
|
83
91
|
→ resolves to final Success or Failure
|
|
84
92
|
|
|
85
93
|
replayEffect(tree, traceOrResolver, options?)
|
|
86
|
-
→ a trace goes through fromTrace(trace, {
|
|
94
|
+
→ a trace goes through fromTrace(trace, { onEntry }); a function is used as the
|
|
87
95
|
Resolver as-is (typeof check: an entries array is data, so it takes the
|
|
88
96
|
trace path); a malformed trace rejects with a ReplayError
|
|
89
97
|
→ installs an onStep that answers each step from resolve(step) instead of
|
|
@@ -108,7 +116,7 @@ There is one slot per hook, and `configureEffect` merges the configurations of a
|
|
|
108
116
|
|
|
109
117
|
The raw hooks are powerful: a hook decides whether the Command runs at all, which is how replay suppresses I/O, and anything it throws becomes the flow's outcome. That power is wrong for anything that only watches, and it caused two real bugs here: the tracing example serialized its input before calling `pipeline()`, so a cyclic input left flows unexecuted, and `recorder` let a throwing `redact` turn a successful run into a `Failure` carrying the redaction bug while recording an entry that claimed the Command had thrown. The internal `observeSteps(handler)` now holds that contract in one place: `op` always runs, its result is always returned, its error always propagates, and anything the observer throws is dropped. Keep it minimal and unexported. `recorder` is its only caller and needs only `onStep`, so there is no `onRun` branch and no error-reporting channel, and an exported second route to the raw hooks would widen the surface for integrations nobody has written. It briefly was exported, as `observe`, with both branches; that cost 270 bytes gzipped for one internal call site and no consumer, which is the same argument that removed `tap`.
|
|
110
118
|
|
|
111
|
-
Trace entries carry `durationMs`, rounded to microseconds, because `observeSteps` measures the span around `op` anyway and nothing else recorded timings. `timeTravel` narrates it,
|
|
119
|
+
Trace entries carry `durationMs`, rounded to microseconds, because `observeSteps` measures the span around `op` anyway and nothing else recorded timings. `timeTravel` narrates it, looking the entry up by path, or by position for a trace that carries none.
|
|
112
120
|
|
|
113
121
|
`configureEffect` returns a function that restores the wiring in place when it was called, captured as a snapshot of the resolved runners rather than of the options, and it reverts only while that wiring is still installed. The guard matters because the first version reverted unconditionally: two callers installing and releasing in interleaved order silently discarded each other's hooks, which is the same class of silent clobbering the restore function was added to prevent. It exists because the global was otherwise write-only: there is no getter, so anything that installed hooks clobbered its host permanently and could not put things back, which made the global unusable from a test, a scoped experiment, or a library wrapping this one. Restoring is a snapshot and not a stack, so a restore that runs after a later `configureEffect` reverts to the older wiring and discards the newer one. The two are not interchangeable: `restore()` returns to whatever was installed before, while a bare `configureEffect()` asserts a known-clean slate. Suites here want the clean slate, so they reset; `restore()` is for code that installs hooks over someone else's wiring and has to hand it back.
|
|
114
122
|
|
|
@@ -135,32 +143,36 @@ Because a flow is data, a production run can be recorded and replayed later. Rec
|
|
|
135
143
|
Four invariants hold the design together; breaking any of them breaks replay in a way tests may not catch:
|
|
136
144
|
|
|
137
145
|
- **Replay drives the real interpreter.** `replayEffect` calls `runEffect` rather than walking the tree itself, which is why `Ask`, `Retry`, and `Parallel` replay correctly and why replay semantics cannot drift from execution semantics. The interpreter's only execution point is `await localStepRunner(cmdName, 'Command', eff.cmd)`, which hands the Command thunk to `onStep` as `op` instead of calling it. Replay's `onStep` never invokes `op` unless `onMissing: 'execute'`, so **no side effect can occur by default**. Preserve that single-execution-point property: a Command executed anywhere else in the interpreter would be invisible to both recording and replay.
|
|
138
|
-
- **Path matching, with
|
|
146
|
+
- **Path matching, with positional matching for traces that carry no paths.** Every step carries a `path`: its position in the Effect tree, numbered sequentially within a subtree, with each `Parallel` branch and each `Retry` attempt opening its own prefix (`0p1/0r2/0` is branch 1, retry attempt 2, first Command; a retry's `onExhausted` fallback opens `0rf/`). A path depends only on the shape of the tree, never on the order branches finish in, so it is order-independent **and** paradox-detecting at the same time. `fromTrace` matches by path whenever every entry has one. Duplicate paths throw, since a collision would reintroduce the failure paths exist to prevent. A trace whose entries carry no path (recorded before paths existed, or written by hand) is consumed positionally, each entry checked against the Command the flow asks for; exact for sequential flows, and unable to tell `Parallel` branches apart. There is no matching option any more. `strict` used to choose between that positional mode and a per-Command name-queue mode, which was the only way to replay `Parallel` before paths, and **the name-queue mode was the bug paths fixed**: it paired a step with the next recorded entry of the same name, which is completion order, so two `Parallel` branches calling the same Command swapped results whenever replay timing differed from production, and the replay still reported `Success`. Once paths made it unnecessary the option was removed rather than left as a footgun; a `Resolver` remains the way to match by any other rule. The positional resolver refuses a step whose path shows it is inside a `Parallel` (a `p` segment), with a `ReplayError` that says to add paths, because a positional answer there would be right only when the replay happened to finish in production's order, and silently wrong otherwise.
|
|
139
147
|
|
|
140
148
|
- **The hook contract carries the path as a fourth parameter.** `onStep(name, type, op, path)`. Three-parameter hooks keep working, and `chainHooks` forwards the path through nested wrappers. A hand-rolled wrapper that calls `inner(name, type, op)` without forwarding `path` silently produces a trace with no paths, which then falls back to legacy positional matching: correct for sequential flows, and the old swapping bug for `Parallel`. Forward the fourth argument in anything that wraps `onStep`.
|
|
141
149
|
- **`fastRetry` exists because of merge order.** The interpreter merges per-use options over call config (`{ ...localRetryDefaults, ...eff.options }`), so a `callConfig.retry` cannot override a delay written at the call site. `zeroRetryDelays` therefore rewrites `Retry` nodes to `delay: 0, backoff: 1` lazily, through their `next` continuations; without it, replaying a flow that retried in production waits out the production backoff.
|
|
142
150
|
- **The outcome wrapper is load-bearing.** A `Resolver` returns `{ result }`, `{ error }`, or `undefined` for "not recorded". The wrapper keeps a Command that legitimately returned `undefined` (`{ result: undefined }`) distinct from having no recording, which is what makes the `onMissing` guard trustworthy.
|
|
143
151
|
|
|
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 `
|
|
152
|
+
Two further details: `hooks` defaults to `false`, so a replay cannot reach a telemetry backend or a guardrail that performs I/O; and errors cross the trace via `serializeError` / `reviveError` because `message`, `stack`, and `cause` are non-enumerable on `Error` and a plain `JSON.stringify` would silently drop them; `cause` is carried recursively since it is often an `Error` itself. Non-`Error` values pass through unchanged, so a Command that rejected with a string still replays as a string. `reviveError` defines `name` and `cause` as non-enumerable own properties, matching where a native Error keeps them, because the README's determinism check is `assert.deepEqual(replayed, result)` and Node compares own enumerable keys: assigning `e.name = ...` made every revived error carry an extra key and the check failed for every `Failure` while passing for every `Success`, which is exactly the half of the check that matters. A subclass instance still revives as a plain `Error` (the trace carries a name, not a prototype), so the check is documented as holding for plain errors.
|
|
145
153
|
|
|
146
154
|
Recorded results are snapshotted by the internal `snapshot`, not stored by reference. Without it a later step that mutates a returned object rewrote what the trace said an earlier step returned, and because `toTrace` runs after the flow finishes, the mutation was already baked in by the time a sink serialized it: the trace recorded a value production never saw, and a replay fed that value back. It uses `structuredClone` where available, falls back to a JSON round trip, and falls back again to the reference for values that can be neither, since dropping the entry would be worse. Redaction runs first, so nothing sensitive is cloned before being stripped.
|
|
147
155
|
|
|
148
156
|
`TraceLog` is the reference trace format, not a contract: `replayEffect` also takes a `Resolver`, so any storage shape works by writing one.
|
|
149
157
|
|
|
150
|
-
`timeTravel` narrates through `replayEffect`'s `onResolved` hook rather than by wrapping a `Resolver`, which is why it can pass the trace straight through and why `fromTrace` needs no public export. Keep that split: `onResolved` is the seam for _observing_ a replay (logging, counting, step assertions), and a `Resolver` is the seam for _supplying_ outcomes. Narration that reaches for a `Resolver` is a sign the wrong seam is being used.
|
|
158
|
+
`timeTravel` narrates through `replayEffect`'s `onResolved` hook rather than by wrapping a `Resolver`, which is why it can pass the trace straight through and why `fromTrace` needs no public export. Keep that split: `onResolved` is the seam for _observing_ a replay (logging, counting, step assertions), and a `Resolver` is the seam for _supplying_ outcomes. `replayEffect` returns `{ result, unreached }` rather than the bare state, mirroring `recordEffect`'s `{ result, trace }`; `timeTravel` narrates the same list and returns the bare outcome, because narration is its whole job and a caller who wants the entries as data has `replayEffect`. `unreached` exists because a flow that stops issuing Commands early mismatches nothing: no `TimeParadox` fires, the replay ends in `Success`, and the recorded steps it never asked for are the only evidence of the divergence. A regression test for a fix that deliberately skips a step asserts which step; every other recording asserts the list is empty. It rides beside the result rather than on it so the flow's outcome stays exactly what production would have returned, and rather than behind an observer option (which it briefly was) because a value handed to a callback exactly once after completion is a return value in disguise, and the `let unreached` it forced into every test was the tell. It is absent, not empty, for a `Resolver`: an empty list would claim knowledge the replay does not have. `timeTravel` still narrates the list, but not after a `TimeParadox` or `ReplayError`, since the flow never got past the divergence and the error already names it. Narration that reaches for a `Resolver` is a sign the wrong seam is being used.
|
|
151
159
|
|
|
152
160
|
### TypeScript
|
|
153
161
|
|
|
154
162
|
Full generic type declarations are in `index.d.ts` and referenced via the `types` field in `package.json`. Type-level tests live in `test/types.test-d.ts` and run via `tsd` as part of `npm test`.
|
|
155
163
|
|
|
156
|
-
`index.d.ts` is hand-maintained beside `index.js`, so the two drift
|
|
164
|
+
`index.d.ts` is hand-maintained beside `index.js`, so the two can drift. Three mechanisms guard the drift: the `Declaration parity` test pins the export lists to each other in both directions; `tsd` checks the declarations against `test/types.test-d.ts`; and `npm test` runs `tsc -p jsconfig.json`, strict `checkJs` over `index.js`, `test/all.js`, and `examples/*.js`. That last leg reads the declarations too, not only the JSDoc: `test/all.js` imports `../index.js`, and TypeScript resolves that to the sibling `index.d.ts` before it considers the JavaScript, so the test suite is checked as a strict consumer of the public types. The leg was added after a narrowing error in the interpreter shipped through a green gate and was found in an editor, the second time a lens existed but was not in the gate. It then had to be widened twice for the same reason. The config listed `index.js` alone while `test/all.js` opened with `// @ts-check` and was annotated to typecheck, so an editor showed errors there that the gate never saw. And it ran without `strict`, while VS Code checks JavaScript with `strictNullChecks` and `strictFunctionTypes` by default and consults only `tsconfig.json` or `jsconfig.json`, so it ignored the old `tsconfig.check.json` and reported errors against a green gate. The file is named `jsconfig.json` and is strict precisely so that the editor and the gate read one configuration; keep both properties, and add any checked JavaScript that lives in this repo to its `include` list. `test/types.test-d.ts` is in the list too, which is possible only because its deliberate errors are `@ts-expect-error` directives rather than `expectError` calls (see below). **Check `npm test` by exit code.** `tsd` failures print neither "passing" nor "failing", so piping the output through `grep -E "passing|failing"` hides them entirely, which is how `runEffect`'s declaration stayed deleted through several edits here while every other check reported green. It was removed as collateral by a slice that targeted an adjacent block, so after any structural edit to `index.d.ts`, compare the runtime exports against the declared ones rather than trusting the diff.
|
|
157
165
|
|
|
158
|
-
|
|
166
|
+
Deliberate type errors in `test/types.test-d.ts` are written as `// @ts-expect-error` directives, never as `tsd`'s `expectError(...)`. Both assert that the next expression fails to typecheck, but only the directive is understood by the compiler itself: `expectError` leaves a real diagnostic in the file that `tsd` swallows and every other tool reports, so with it the file showed ten red lines in VS Code and could not be part of the `tsc` gate, which is how a green `tsd` and a red editor coexisted for a while. With the directive, TypeScript suppresses the error and reports `Unused '@ts-expect-error' directive` if it ever stops occurring, and `tsd` fails on that diagnostic, so the assertion is as strong as before (verified by making a suppressed line valid and watching `tsd` fail). What is given up is `expectError`'s check that the error is one of a few specific codes: a directive is satisfied by any error on the line, so keep each suppressed expression small enough that only the intended mistake can fail it, and give every directive a reason. The directive covers exactly one line, so a suppressed expression stays on one line.
|
|
159
167
|
|
|
160
168
|
`Effect<T, E, Ctx>` carries three type parameters: value type, error union, and context type. `Ctx` flows through `AskState`, `CommandState`, `RetryState`, and all `effectPipe` overloads. `Ask<T, E, Ctx>` types the context callback parameter, and `runEffect<T, E, Ctx>` enforces that the supplied context matches. `Ctx` defaults to `unknown` so existing code without context typing is unaffected.
|
|
161
169
|
|
|
162
170
|
Error unions need explicit return annotations. A step written as `(i: User): Effect<User, 'taken'> => ...` contributes `'taken'` to the pipeline's union, but the same step left to inference from a bare `Success(x) | Failure(y)` return type yields `E = unknown`, and one `unknown` absorbs every other member of the union, so `result.error` degrades to `unknown` for the whole pipeline. Annotate `Effect<T, E>` on any step whose error type is meant to survive, and write type-level examples that way.
|
|
163
171
|
|
|
172
|
+
`Retry`'s declared error is `RetryExhaustedError<E>`, not `E`, and that is deliberate: the interpreter never lets an inner attempt's error escape unwrapped, so on exhaustion the Failure carries `{ retryExhausted, lastError, attempts }` and the inner `E` survives only as `lastError`. `RetryState`'s `effect` field is typed `Effect<T, any, Ctx>` for the same reason, since the wrapped tree's error type is consumed by the retry loop rather than exposed. The declaration previously claimed `E`, which made `result.error` lie for every flow containing a `Retry`; the tsd assertions in the Retry section pin the corrected shape. With a per-use `onExhausted` the declared error is the fallback's own error type instead, since exhaustion then never escapes; that overload comes first, and `onExhausted` is typed on the per-use options only, so a global `configureEffect` retry default cannot carry one.
|
|
173
|
+
|
|
174
|
+
Every state type declares `next` as a method signature (`next(result: R): Effect<...>`) rather than a function-typed property, and that is load-bearing under `strictFunctionTypes`. A property's parameter is checked contravariantly, so a `CommandState<never, ...>`, which is what `Command` infers for a function that only throws or returns `Promise.reject(...)`, could not be assigned to `Effect`, whose member is `CommandState<any, ...>`: `any` is not assignable to `never`. Method parameters are checked bivariantly, so the same node is accepted. Nothing is lost, since `Effect` already erases the result type to `any`; what changed is that the throwing Commands in `test/all.js` stopped being type errors under a strict editor. The `tsd` Command section pins a throwing thunk as assignable to `Effect`, directly, under `Retry`, and through `effectPipe`, so a tidy-up that turns the methods back into properties fails the gate.
|
|
175
|
+
|
|
164
176
|
The recording and replay exports are declared alongside the primitives (`ReplayStep`, `ReplayOutcome`, `Resolver`, `TraceEntry`, `TraceLog`, `RecorderOptions`, `TraceMeta`, `ReplayOptions<Ctx>`). Known gap: `test/types.test-d.ts` has no coverage for any of the replay exports; their declarations are checked only by `tsc` over the JSDoc in `index.js`. Add `tsd` assertions there when touching those types.
|
|
165
177
|
|
|
166
178
|
### Observability
|
|
@@ -177,11 +189,11 @@ Two constraints in that file are load-bearing. Telemetry must never decide wheth
|
|
|
177
189
|
|
|
178
190
|
`test/all.js` contains all runtime tests and uses a user-registration domain as the running example. Tests assert on the _returned data structures_ (Commands, Failures) rather than on side effects, which is the core usage pattern to preserve. `registerUserFlow` is the same shape as the README's Quick Start, on purpose: `ensureEmailIsAvailable` is a `Command` whose `next` returns `Success(input)`, so the tests and the documentation cannot drift into demonstrating different idioms.
|
|
179
191
|
|
|
180
|
-
The `Recording and replay` suite builds flows whose Commands count their own invocations, which is how it asserts that a replay performs **zero** I/O, including a case that exercises every primitive at once. It also covers duplicate Command names, recorded-context `Ask`, retry replay and exhaustion, `Parallel`
|
|
192
|
+
The `Recording and replay` suite builds flows whose Commands count their own invocations, which is how it asserts that a replay performs **zero** I/O, including a case that exercises every primitive at once. It also covers duplicate Command names, recorded-context `Ask`, retry replay and exhaustion, `Parallel` replaying by path in either finishing order, replaying a trace directly and as a bare entries array, malformed traces rejecting with a `ReplayError`, divergence and exhausted traces, `unreached` (a shortened flow, full consumption, a paradox mid-replay, legacy and bare-array traces, and absence under a `Resolver`), a path-less `Parallel` trace refused, `onMissing`, redaction, `maxEntries` with `dropped`, recorder independence, and `timeTravel`'s narration, including a `Parallel` narration case and a paradox that suppresses the unreached warning.
|
|
181
193
|
|
|
182
|
-
The `Malformed flows` suite covers the guard: the offending step named, a missing return called what it usually is, a continuation returning a plain value, a flow passed without its input, a flow bug thrown rather than folded into a `Failure`, and a thrown Command still becoming one. The `Command identity` suite pins the resolution order: `meta.name` winning over a named thunk, an inline arrow becoming identifiable through `meta.name`, both fallbacks, non-object and non-string metas ignored, and `meta.name` surviving the rebuild that `chain` performs on every pipe step so it reaches both a trace and a
|
|
194
|
+
The `Malformed flows` suite covers the guard: the offending step named, a missing return called what it usually is, a continuation returning a plain value, a flow passed without its input, a flow bug thrown rather than folded into a `Failure`, and a thrown Command still becoming one. The `Command identity` suite pins the resolution order: `meta.name` winning over a named thunk, an inline arrow becoming identifiable through `meta.name`, both fallbacks, non-object and non-string metas ignored, and `meta.name` surviving the rebuild that `chain` performs on every pipe step so it reaches both a trace and a replay. The `Recorded step timings` suite reaches `observeSteps` through its only public consumer rather than testing an internal directly: durations recorded for both successful and thrown Commands, rounding, timings not disturbing replay, and an observation failure leaving the outcome alone. The `examples/opentelemetry-example.js` suite injects a stub tracer through the `tracer` option, which is why that option exists, and covers the cyclic input, values never reaching spans, span naming and nesting, error status on a thrown Command, and composition with recording. The `configureEffect merging` suite pins the composition semantics behaviourally rather than by inspecting a merged object: wrapper nesting order, unwind order on a thrown Command, interceptors all running, `retry` merging where attempts come from one configuration and the delay from another, slots left at their defaults, no accumulation across calls, a bare call resetting everything, and the restore function putting back a previous wiring, returning to no hooks, and reverting `retry` defaults. The `examples/recording-example.js` suite runs the reference wiring itself, covering a trace reaching the sink on failure, successes staying out of it, concurrent runs keeping separate traces, redaction before the sink, and recording composing with a telemetry hook.
|
|
183
195
|
|
|
184
|
-
`test/types.test-d.ts` contains type-level tests using `tsd`, verifying that generic type parameters flow correctly through `effectPipe` and `runEffect`.
|
|
196
|
+
`test/types.test-d.ts` contains type-level tests using `tsd`, verifying that generic type parameters flow correctly through `effectPipe` and `runEffect`. Its negative cases are `@ts-expect-error` directives rather than `expectError` calls, for the reason given in the TypeScript section.
|
|
185
197
|
|
|
186
198
|
### Example code in the README
|
|
187
199
|
|
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.
|
|
@@ -121,16 +132,41 @@ A production incident becomes a permanent test of the flow's logic, with no mock
|
|
|
121
132
|
|
|
122
133
|
```js
|
|
123
134
|
it('prod incident 8f3a: a 100% promo produces a $0 charge', async () => {
|
|
124
|
-
const result = await replayEffect(checkoutFlow(trace.initialInput), trace);
|
|
135
|
+
const { result } = await replayEffect(checkoutFlow(trace.initialInput), trace);
|
|
125
136
|
assert.equal(result.type, 'Failure');
|
|
126
137
|
assert.equal(result.error.code, 'invalid_amount');
|
|
127
138
|
});
|
|
128
139
|
```
|
|
129
140
|
|
|
130
|
-
The test verifies that the flow still takes the recorded path and handles the recorded outcomes the same way: a refactor that reorders or
|
|
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 replaces a step raises a `TimeParadox` naming the path it diverged at, and changed error handling fails the assertion.
|
|
142
|
+
|
|
143
|
+
One divergence is quieter. A flow that stops issuing Commands before the recording ends (a fix that skips the charge, say) mismatches nothing, so no paradox fires 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
|
+
|
|
145
|
+
```js
|
|
146
|
+
it('incident 8f3a fixed: a 100% promo checks out without a charge', async () => {
|
|
147
|
+
const { result, unreached } = await replayEffect(checkoutFlow(trace.initialInput), trace);
|
|
148
|
+
assert.equal(result.type, 'Success');
|
|
149
|
+
assert.deepEqual(
|
|
150
|
+
unreached.map((e) => e.command),
|
|
151
|
+
['cmdChargeCard']
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
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.
|
|
131
157
|
|
|
132
158
|
**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
159
|
|
|
160
|
+
**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:
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
const { result, trace } = await recordEffect(registerUserFlow, input);
|
|
164
|
+
const { result: replayed } = await replayEffect(registerUserFlow(input), trace);
|
|
165
|
+
assert.deepEqual(replayed, result); // fails if any step computed a fresh value
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
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.
|
|
169
|
+
|
|
134
170
|
**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
171
|
|
|
136
172
|
```js
|
|
@@ -221,7 +257,21 @@ When all attempts are exhausted, `runEffect` returns a structured `Failure`:
|
|
|
221
257
|
{ retryExhausted: true, lastError: <the last error>, attempts: 3 }
|
|
222
258
|
```
|
|
223
259
|
|
|
224
|
-
|
|
260
|
+
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`.
|
|
261
|
+
|
|
262
|
+
**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:
|
|
263
|
+
|
|
264
|
+
```js
|
|
265
|
+
const fetchPrice = (sku) =>
|
|
266
|
+
Retry(fetchLivePrice(sku), {
|
|
267
|
+
attempts: 3,
|
|
268
|
+
onExhausted: (err) => fetchCachedPrice(sku) // err = { retryExhausted, lastError, attempts }
|
|
269
|
+
});
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Fallback steps are recorded under their own trace paths, so a replay reproduces the fallback exactly, and a fallback never starts in a `Parallel` branch that a sibling's failure has already cancelled. With `onExhausted` set, the exhaustion error never escapes, so in TypeScript the node contributes the fallback's error type to the pipeline's union instead of `RetryExhaustedError<E>`.
|
|
273
|
+
|
|
274
|
+
Every attempt is a recorded step, so a replay reproduces the exact sequence of failures. It also skips the delays.
|
|
225
275
|
|
|
226
276
|
## Running Effects in Parallel
|
|
227
277
|
|
|
@@ -234,6 +284,8 @@ const loadProfile = (userId) =>
|
|
|
234
284
|
Parallel([getUser(userId), getPermissions(userId)], ([user, permissions]) => Success({ user, permissions }));
|
|
235
285
|
```
|
|
236
286
|
|
|
287
|
+
`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.
|
|
288
|
+
|
|
237
289
|
`Ask` context flows into all parallel branches without any extra wiring.
|
|
238
290
|
|
|
239
291
|
**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 +300,68 @@ const fetchProfileUncancellable = (userId) => Command(() => fetch(`/users/${user
|
|
|
248
300
|
|
|
249
301
|
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
302
|
|
|
303
|
+
## Composing Larger Flows
|
|
304
|
+
|
|
305
|
+
`effectPipe` is a straight line, but flows rarely are. Branching and fan-in work with the existing primitives. Here is how:
|
|
306
|
+
|
|
307
|
+
**A step can return a sub-pipeline.** `effectPipe(...)(value)` returns an Effect like any other, so a step can branch into a whole sub-flow, and the sub-flow's `Failure` short-circuits the outer pipeline exactly like a local one:
|
|
308
|
+
|
|
309
|
+
```js
|
|
310
|
+
const processOrder = (order) => (order.isGift ? giftFlow(order) : standardFlow(order));
|
|
311
|
+
|
|
312
|
+
const fulfillment = effectPipe(validateOrder, processOrder, scheduleShipping);
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
**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:
|
|
316
|
+
|
|
317
|
+
```js
|
|
318
|
+
const loadCheckout = effectPipe(
|
|
319
|
+
(input) => Parallel([fetchCart(input.cartId), fetchUser(input.userId)]),
|
|
320
|
+
([cart, user]) => Success({ cart, user, total: cartTotal(cart) })
|
|
321
|
+
);
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
**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:
|
|
325
|
+
|
|
326
|
+
```js
|
|
327
|
+
const applyLoyaltyDiscount = (orderId) =>
|
|
328
|
+
effectPipe(
|
|
329
|
+
fetchOrder,
|
|
330
|
+
(order) => effectPipe(fetchCustomer, (customer) => Success({ order, customer }))(order.customerId),
|
|
331
|
+
({ order, customer }) => Success(discountedTotal(order, customer))
|
|
332
|
+
)(orderId);
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
**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.
|
|
336
|
+
|
|
337
|
+
## Which Errors Are Data
|
|
338
|
+
|
|
339
|
+
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.
|
|
340
|
+
|
|
341
|
+
**An outcome the flow handles is data.** A Command's `next` receives the result and can branch into any Effect, including a whole fallback sub-pipeline. When the I/O itself can reject, catch inside the Command's `cmd` function and return the miss as a value:
|
|
342
|
+
|
|
343
|
+
```js
|
|
344
|
+
const fetchCachedPrice = (sku) => {
|
|
345
|
+
const cmdFetchCachedPrice = () => cache.get(sku);
|
|
346
|
+
return Command(cmdFetchCachedPrice, (price) => Success({ ...price, stale: true }));
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const fetchPrice = (sku) => {
|
|
350
|
+
const cmdFetchLivePrice = () =>
|
|
351
|
+
pricing
|
|
352
|
+
.get(sku)
|
|
353
|
+
.then((price) => ({ ok: true, price }))
|
|
354
|
+
.catch((error) => ({ ok: false, error }));
|
|
355
|
+
return Command(cmdFetchLivePrice, (r) => (r.ok ? Success({ ...r.price, stale: false }) : fetchCachedPrice(sku)));
|
|
356
|
+
};
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
The failed live attempt is recorded as that Command's result, error included, so a replay takes the same fallback branch and an incident trace still becomes a regression test. Nothing is hidden; the miss is simply categorized as what it is, an outcome the flow was written to handle.
|
|
360
|
+
|
|
361
|
+
**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.
|
|
362
|
+
|
|
363
|
+
The rule of thumb: if you would handle it, return it; if you would only report it, fail with it. The one handled outcome that cannot be modeled as data is retry exhaustion, since the failing happens inside the `Retry` node; that case has its own in-flow form, the `onExhausted` option in [Retrying Transient Failures](#retrying-transient-failures).
|
|
364
|
+
|
|
251
365
|
## TypeScript: Typed Errors and Context
|
|
252
366
|
|
|
253
367
|
### Error union across pipeline steps
|
|
@@ -284,9 +398,9 @@ const result = await runEffect(findProduct('abc'), { tenant: 'acme', requestId:
|
|
|
284
398
|
|
|
285
399
|
## Why Pure Effect
|
|
286
400
|
|
|
287
|
-
**vs.
|
|
401
|
+
**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
402
|
|
|
289
|
-
**vs.
|
|
403
|
+
**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
404
|
|
|
291
405
|
**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
406
|
|
|
@@ -333,12 +447,13 @@ Returns `{ type: 'Retry', effect, options, next }`.
|
|
|
333
447
|
- `options.attempts`: Max retries, not counting the first try (default: `3`).
|
|
334
448
|
- `options.delay`: Ms before the first retry (default: `100`).
|
|
335
449
|
- `options.backoff`: Multiplier applied to delay on each attempt (default: `1`, flat).
|
|
450
|
+
- `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
451
|
|
|
337
|
-
#### `Parallel(effects, next)`
|
|
452
|
+
#### `Parallel(effects, next?)`
|
|
338
453
|
|
|
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).
|
|
454
|
+
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
455
|
|
|
341
|
-
###
|
|
456
|
+
### Building pipelines
|
|
342
457
|
|
|
343
458
|
#### `effectPipe(...functions)`
|
|
344
459
|
|
|
@@ -370,7 +485,7 @@ Commands are data, so a Command that is constructed and discarded never runs, an
|
|
|
370
485
|
|
|
371
486
|
Traverses the effect tree, executes Commands with `async/await`, resolves `Ask` with the supplied `context`, and returns the final `Success` or `Failure`.
|
|
372
487
|
|
|
373
|
-
- `context`: Passed to `Ask`
|
|
488
|
+
- `context`: Passed to `Ask`'s next function and to `onBeforeCommand`. `context.flowName` names the workflow in telemetry.
|
|
374
489
|
- `callConfig`: Per-call overrides for `onStep`, `onRun`, `onBeforeCommand`, and `retry`. Takes precedence over `configureEffect` globals.
|
|
375
490
|
- `onRun` fires exactly once per `runEffect` call. Retry attempts run inside that single span.
|
|
376
491
|
|
|
@@ -381,7 +496,7 @@ Step 'validateRegistration' returned a plain object. Return Success, Failure, Co
|
|
|
381
496
|
Retry, or Parallel: a plain value has to be wrapped, as in Success(value).
|
|
382
497
|
```
|
|
383
498
|
|
|
384
|
-
The same check catches a missing `return`, a Command
|
|
499
|
+
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
500
|
|
|
386
501
|
#### `configureEffect(...configs)`
|
|
387
502
|
|
|
@@ -442,7 +557,7 @@ Runs a flow for real while recording, returning `{ result, trace }`. Accepts `re
|
|
|
442
557
|
|
|
443
558
|
#### `replayEffect(effect, traceOrResolver, options?)`
|
|
444
559
|
|
|
445
|
-
Replays an effect tree, feeding recorded results to Commands instead of running them.
|
|
560
|
+
Replays an effect tree, 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.
|
|
446
561
|
|
|
447
562
|
- `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`.
|
|
448
563
|
- `options.context`: context for `Ask`; pass the recorded context.
|
|
@@ -451,9 +566,11 @@ Replays an effect tree, feeding recorded results to Commands instead of running
|
|
|
451
566
|
- `options.hooks` (default `false`): whether configured `onRun` / `onBeforeCommand` may fire. Off so a replay cannot reach a telemetry backend.
|
|
452
567
|
- `options.onResolved(step, outcome)`: observe each replayed step.
|
|
453
568
|
|
|
569
|
+
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.
|
|
570
|
+
|
|
454
571
|
#### `timeTravel(flowFn, traceLog, options?)`
|
|
455
572
|
|
|
456
|
-
Replays a trace and narrates each step with its recorded duration,
|
|
573
|
+
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`.
|
|
457
574
|
|
|
458
575
|
## Limitations
|
|
459
576
|
|