pure-effect 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +73 -51
- package/examples/opentelemetry-example.js +5 -1
- package/examples/recording-example.js +31 -12
- package/index.d.ts +83 -47
- package/index.js +1381 -1011
- package/package.json +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pure-effect are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and from 1.0.0 the project follows [Semantic Versioning](https://semver.org/). Before 1.0.0 a minor version could change behaviour; each such change is marked below.
|
|
4
4
|
|
|
5
|
+
## [0.18.0] - 2026-10-09
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **A `Failure` carries only its error, and the flow's input goes to `onRun`.** Every `Failure` carried `initialInput`, the input the flow was called with, so a `Failure` from a flow compared unequal to the same one from a step tested alone, and logging a `Failure`, or the outcomes a settled `Parallel` hands `next`, logged the input too, which for a registration is its credentials. A `Failure` is now `{ type: 'Failure', error }` wherever it comes from, `Failure` takes one argument, and no part of a flow carries the input. An `onRun` hook receives it as a fourth argument, `onRun(effect, op, flowName, initialInput)`, for a flow `effectPipe` built, and a hook that calls another passes it on. An `onRun` hook that read `effect.initialInput` reads that argument instead, and code that read `result.initialInput` uses the input it called the flow with. An assertion written as `Failure(error, input)` still passes, since the second argument is ignored, but TypeScript refuses it.
|
|
10
|
+
- **A trace has one form, JSON, so a replay from memory gets what a replay from storage gets.** The recorder copied values with `structuredClone`, which kept a `Date`, a `Map` and class data in memory, while a sink stores JSON, which keeps none of them. So a test that replayed a recording from memory passed where the same trace read back from storage made the flow throw, and a value JSON cannot encode, such as a `BigInt` or an HTTP client's request and response that point at each other, recorded fine and then made the sink throw, losing the whole trace. Every recorded value is now copied through JSON: a `Date` replays as a string and a `Map` as `{}` from memory too, a reference back to an enclosing object is cut, and a value JSON cannot encode is left out and marked `unrecorded: 'copy'`, so a sink can always write the trace. A value with a getter that throws is now left out whole, since the copy reads it before `redact` runs, where a `redact` could once drop the getter and keep the rest. A trace built by hand that holds a value JSON cannot encode stops the replay with a `ReplayError` naming the entry, rather than a bare `TypeError`.
|
|
11
|
+
- **A hook configuration is checked.** `configureEffect` and a per-call `callConfig` ignored a key they do not read, so a misspelt `onstep` switched its hook off without a word, and accepted a hook that is not a function, so `onStep: 42` turned every Command into an I/O fault that `Retry` retried and reported as exhausted, without the Command ever running. A key other than `onStep`, `onRun` and `onBeforeCommand` (and `inherit` in a `callConfig`), a hook that is not a function, and a configuration that is not an object now throw a `TypeError`: from `configureEffect` before anything is installed, and from `runEffect` before the run. Only `undefined` leaves a configuration or a hook out: `null`, `false`, `0` and `''`, which `configureEffect` skipped, now throw as well, as the types already refused them. A `retry` key, which had a message of its own pointing to `Retry(effect, options)`, is now refused with the message any other key gets.
|
|
12
|
+
- **An option value a function cannot use throws, and `Retry` and `Parallel` check theirs where they are built.** An option's name was checked and its value was not, except `Retry`'s, which threw a `TypeError` only when the `Retry` ran, after the Commands ahead of it. So `Parallel(batch, { settled: 'false' })` ran the batch settled, `recorder({ maxEntries: null })` kept no entries, `stack: 'no'` recorded stacks, `Retry(effect, { onExhausted: fetchCachedPrice(sku) })`, the Effect rather than a function returning it, ran with no fallback, and `replayEffect`'s `hooks: 'false'` was refused as `callConfig.inherit`. `Retry`'s `attempts`, `delay`, `backoff` and `onExhausted`, and `Parallel`'s `limit` and `settled`, now throw an `EffectTypeError` where the node is built, which keeps a frozen copy of them, so a later change to the caller's options object no longer reaches the node, and assigning to a node's `options` throws in strict mode, which TypeScript now refuses as well, since `RetryState` and `ParallelState` declare their `options` read-only; `recorder`, `recordEffect`, `replayEffect` and `timeTravel` throw a `TypeError` naming the option. A test that expected a `TypeError` from `runEffect` for a bad `attempts`, `delay`, `backoff` or `limit` now gets an `EffectTypeError` from the constructor. `maxEntries` takes a positive integer or `Infinity`, so `0` and a numeric string are refused too. An option set to `undefined` still keeps its default.
|
|
13
|
+
- **TypeScript checks a run's context against the one the flow reads.** `runEffect`, `recordEffect`, `replayEffect` and `timeTravel` inferred the context's type from the context they were given as well as from the flow, so a variable typed with fewer fields than the flow reads, such as a shell's `const ctx: { tenant: string }` for a flow that also reads `requestId`, compiled, and `Ask` got `undefined` for the rest. The type now comes from the flow alone, so such a call fails to compile and names the missing field, as a literal already did. `replayEffect` given a `Resolver` now requires a context when the flow reads one, as `runEffect` does, since a `Resolver` holds none; a replay from a trace still defaults to the trace's.
|
|
14
|
+
- **A replay of a flow that changed shape around a `Retry` or `Parallel` stops at a `TimeParadox`.** A step added in front of a recorded `Retry` or `Parallel`, a step removed in front of one, and a Command newly wrapped in `Retry` all asked for a path the trace never had, so the replay ended in a `ReplayError` for a missing step, and `onMissing: 'execute'` ran the reshaped steps for real. Such a replay now ends in a `TimeParadox` at the position where the trace recorded another kind of node, with `expected` and `actual` naming each side, and runs nothing live. A step past the end of the recording is still a missing step. A `Parallel` that gained or lost a branch was not caught either: a step moved into a new branch asked for paths the trace never had, so it was a missing step that `onMissing: 'execute'` ran live before the step after the `Parallel` showed the change, and a branch removed from the end replayed as a `Success` with its steps unreached. A `Parallel`'s decision now records how many branches it had, as `branches`, which `onStep` hooks see in what `op` returns, and a replay of a `Parallel` with a different count ends in a `TimeParadox` before any of its branches run. A decision recorded without a count, as in a trace from 0.17.0, is not checked.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **Recording no longer changes a run through a value it cannot clone.** A value `structuredClone` refuses, such as an instance of a class with an arrow-function field, was kept as it was rather than copied, so `redact` was handed the object the run held, and one that deleted a field in place, as `delete value.password` does, changed what the run's `next` received. The trace held that live object too, so a later change to it rewrote what the trace said the step returned, and a replay from memory handed the flow the same object. Such a value is now copied as plain data, as every recorded value is (see the entry on a trace's one form, under Changed), so a context holding a logger replays without the logger's methods.
|
|
19
|
+
- **A `Resolver` that throws, or answers with anything but an outcome or `undefined`, rejects the replay.** Either counted as the Command failing: a `Resolver` that answered `null` for a step it had no record of made a `Retry` ask again for attempts production never made and then run its fallback, so the replay reported a `Success` production never had, and one that answered `{}` replayed the Command as returning `undefined`. The replay now rejects with what the `Resolver` threw, or with a `TypeError` naming the step and the answer, as it does for a throw from `onResolved`. A `Resolver` that answered a `Parallel`'s step with `null` replayed it by timing and is refused too; `undefined` still means no record.
|
|
20
|
+
- **A value the recorder cannot record is left out of the trace and marked, and a replay stops there.** A value `redact` threw on was stored as `'[redaction failed]'`, and a replay handed that string to the flow as what production saw, so a `redact` that read a field of a lookup that found nothing replayed the lookup as one that found something, with no error. A value the recorder could not copy, such as a thrown error whose `cause` getter throws, lost its whole entry: the trace lacked a step production ran, `dropped` stayed 0, and a replay under `onMissing: 'execute'` ran the step again for real. Such a step's entry now holds no `result` or `error` and is marked with why, `unrecorded: 'redact'` or `unrecorded: 'copy'`, and a replay stops at it with a `ReplayError` naming the cause, even under `onMissing: 'execute'`. An `initialInput` or `context` the recorder cannot record is left out, and the trace's new `unrecorded` field says why, as in `{ context: 'copy' }`; `timeTravel` refuses a trace without its input, and `replayEffect` one without its context unless `options.context` is passed. The run still keeps its own outcome. A trace recorded before this change still holds the string.
|
|
21
|
+
- **An input or context the recorder cannot copy no longer decides the run.** `toTrace` threw on a getter that throws when read, such as a lazy client's, although a part that cannot be copied is documented as kept as it is, so `recordEffect` rejected before running the flow, and the reference recording wiring vetoed the run's first Command and failed the run. `toTrace` now never throws: an input or context it cannot copy, such as one holding that getter, is left out and marked `unrecorded`, so `recordEffect` runs the flow and the wiring lets the run go on and warns through `onWarning`.
|
|
22
|
+
- **An error `redact` rebuilt replays as an `Error`.** A `redact` that returned a fresh object for a thrown error, such as `{ name: value.name, message: value.message }`, dropped the mark that told a replay the value was an `Error`, so production handed the flow an `Error` and the replay a plain object, with nothing to say so. The recorder now keeps any object `redact` returns for a thrown `Error` marked as one, and drops the fields it left `undefined`, as JSON does, so a replay from memory matches one from storage. A value that is not an object, such as `'[redacted]'`, is stored and replayed as it is.
|
|
23
|
+
- **An option or hook set to `undefined` compiles under `exactOptionalPropertyTypes`.** Such an option keeps its default, as the README says, but with that compiler flag on the declarations refused it, so `Retry(command, { attempts: config.attempts })`, with `attempts` read from configuration as `number | undefined`, failed to compile, and so did a TypeScript project that copied the recording example, which passes `redact` and `stack` on as it gets them. The options of `Retry`, `Parallel`, `recorder`, `recordEffect`, `replayEffect` and `timeTravel`, the hooks given to `configureEffect` or a `callConfig`, `inherit`, and the `flowName` of a context or of `toTrace` now take `undefined`. The telemetry example no longer sets a span status `message` to `undefined`, which OpenTelemetry's types refuse under the same flag.
|
|
24
|
+
- **An options argument that is not an object is refused.** Only the options inside were checked, so `recorder(42)`, `recordEffect(flow, input, 42)` and `replayEffect(effect, trace, 42)` ran with every default, which for a recorder means no `redact`, and a `null` made `recorder`, `recordEffect`, `replayEffect` and `timeTravel` throw a bare `TypeError` reading their first option. Options that are not an object now throw a `TypeError` naming the function and what it got, and `null` counts as no options, as it already did for `Retry` and `Parallel`.
|
|
25
|
+
- **`recordEffect` records a run whose context is `null`, and a replay hands `Ask` that `null`.** `runEffect` runs a flow with a `null` context, but `recordEffect` read the context's `flowName` and threw a `TypeError` before running the flow, and `replayEffect` replaced a recorded `null` context with `{}`, so `Ask` got a context production never had. A recorded `null` now replays as `null`. A trace that holds no context still replays against `{}`, and a context passed in `options` still wins.
|
|
26
|
+
- **`replayEffect` and `timeTravel` reject a malformed trace with a `ReplayError` that names what is wrong.** `timeTravel` read the trace's own fields before `replayEffect` checked its shape, so an object with no `trace` array, a bare array of entries, or `null` failed with a bare `TypeError` from inside `timeTravel`, and neither checked the entries: a `null` entry threw a bare `TypeError`, and an entry such as `42` replayed as a `TimeParadox` saying the trace recorded `undefined`. Both now reject with a `ReplayError` that names what they got, or the entry that is not an object with a string `command`. For an array of entries, `timeTravel` says to pass the whole trace, since the flow is rebuilt from its `initialInput`, or to replay the entries with `replayEffect`.
|
|
27
|
+
- **`recordEffect` and `timeTravel` name a flow that is not a function.** Each called what it was given, so a number failed with "flowFn is not a function", naming a parameter the caller never wrote, and a flow already called with its input did the same. Both now throw a `TypeError` that names what they got, as in `got an Effect of type 'Command'`.
|
|
28
|
+
- **A `callConfig.inherit` that is not a boolean is named in the error.** The message printed the value with `JSON.stringify`, so a function or a symbol showed as `undefined`, and a `BigInt` made it throw `Do not know how to serialize a BigInt` in place of the intended message. It now names the value as the other option errors do, as in `got the string "false"`.
|
|
29
|
+
|
|
5
30
|
## [0.17.0] - 2026-10-06
|
|
6
31
|
|
|
7
32
|
### Changed
|
|
@@ -236,6 +261,7 @@ All notable changes to pure-effect are recorded here. The format follows [Keep a
|
|
|
236
261
|
|
|
237
262
|
- Initial release: `Success`, `Failure`, `Command`, `effectPipe`, and `runEffect`.
|
|
238
263
|
|
|
264
|
+
[0.18.0]: https://github.com/aycangulez/pure-effect/compare/v0.17.0...v0.18.0
|
|
239
265
|
[0.17.0]: https://github.com/aycangulez/pure-effect/compare/v0.16.0...v0.17.0
|
|
240
266
|
[0.16.0]: https://github.com/aycangulez/pure-effect/compare/v0.15.0...v0.16.0
|
|
241
267
|
[0.15.0]: https://github.com/aycangulez/pure-effect/compare/v0.14.0...v0.15.0
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
- Inject context without touching function signatures
|
|
10
10
|
- Built-in retry, plus parallel execution that cancels sibling branches on the first failure
|
|
11
11
|
- OpenTelemetry-ready via lifecycle hooks
|
|
12
|
-
- Zero dependencies,
|
|
12
|
+
- Zero dependencies, about 9 KB minified and gzipped
|
|
13
13
|
- Works in JavaScript, and in TypeScript 5.1 or later (full generics, bundled `.d.ts`)
|
|
14
14
|
|
|
15
15
|
## Table of Contents
|
|
@@ -86,7 +86,7 @@ Validation runs as soon as you build the flow, so testing it needs nothing else:
|
|
|
86
86
|
|
|
87
87
|
```js
|
|
88
88
|
const badInput = { email: 'bad-email', password: '123' };
|
|
89
|
-
assert.deepEqual(registerUserFlow(badInput), Failure('Invalid email.'
|
|
89
|
+
assert.deepEqual(registerUserFlow(badInput), Failure('Invalid email.'));
|
|
90
90
|
```
|
|
91
91
|
|
|
92
92
|
Next, test the steps. Hand a step an answer and check what it returns:
|
|
@@ -118,8 +118,6 @@ Write both kinds, because they catch different bugs. Only step tests see the val
|
|
|
118
118
|
|
|
119
119
|
For example, if the email guard returned `Success(true)` instead of `Success(input)`, the step test would fail: it expects the input back and gets `true`. The flow test would still pass, because `cmdFindUser` is still the first call and `cmdSaveUser` the second, while the flow saves `true` to the database instead of the user.
|
|
120
120
|
|
|
121
|
-
A step tested on its own returns a `Failure` without `initialInput`. That is why the assertion above is `Failure('Email already in use.')`, while the validation one is `Failure('Invalid email.', badInput)`: `effectPipe` adds the input when it builds a flow.
|
|
122
|
-
|
|
123
121
|
A walk stops at each Command to be handed its answer. To run the whole flow instead, `Retry` and `Parallel` included, hand `replayEffect` a function that answers each Command by name. Nothing is called and a retry does not wait, and answering `{ error }` makes that Command throw, which is how a test reaches a `Retry`'s fallback:
|
|
124
122
|
|
|
125
123
|
```js
|
|
@@ -380,22 +378,23 @@ This works when the removed step is the last one the flow reaches. Remove a step
|
|
|
380
378
|
|
|
381
379
|
**Put anything that varies between runs inside a Command.** For replay, the current time or a random ID counts as I/O. Wrapped in a Command, it is recorded and replayed like any other result. A step that calls `Date.now()` directly gets a new value on every replay and drifts from the trace.
|
|
382
380
|
|
|
383
|
-
**To check for this,** record a flow,
|
|
381
|
+
**To check for this,** record a flow, replay the trace, and compare the outcomes. A step that computes a new value on each run shows up as a `TimeParadox` if it changes which Commands run, or as a different final value. A trace holds each result in the form JSON stores it, so the same check catches a result your storage cannot keep, such as a `Date` the flow calls a method on, with no trip through storage:
|
|
384
382
|
|
|
385
383
|
```js
|
|
386
384
|
const { result, trace } = await recordEffect(registerUserFlow, input);
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
assert.deepEqual(replayed, result); // fails if a step computed a fresh value, or a result did not survive JSON
|
|
385
|
+
const { result: replayed } = await replayEffect(registerUserFlow(input), trace);
|
|
386
|
+
assert.deepEqual(replayed, result); // fails if a step computed a fresh value, or a result did not survive storage
|
|
390
387
|
```
|
|
391
388
|
|
|
392
|
-
This also works for a `Failure`: an error read back from a trace keeps its message, name, cause, custom properties, and an `AggregateError`'s list of errors, and
|
|
389
|
+
This also works for a `Failure` when the Command threw a plain `Error`: an error read back from a trace keeps its message, name, cause, custom properties, and an `AggregateError`'s list of errors, and a plain `Error` comes back deep-equal to the one the Command threw, including one whose `name` or `cause` was set after it was created. An error of any other class comes back as a plain `Error` with that class's name, and that includes the built-in classes: the `TypeError` that `fetch` rejects with when the network fails replays as an `Error` named `'TypeError'`, which `deepEqual` from `node:assert/strict` refuses. Where a Command can throw one, compare `error.name` and `error.message` instead.
|
|
393
390
|
|
|
394
|
-
**You choose the trace format.** `replayEffect` also accepts a resolver function instead of a trace, so you can replay from OpenTelemetry spans, a log pipeline, or a database table, not only from the JSON that `recorder` produces.
|
|
391
|
+
**You choose the trace format.** `replayEffect` also accepts a resolver function instead of a trace, so you can replay from OpenTelemetry spans, a log pipeline, or a database table, not only from the JSON that `recorder` produces. Whatever you store needs each step's `path`, which `onStep` receives, and its result; the telemetry example records neither, since it keeps values off spans.
|
|
395
392
|
|
|
396
393
|
```js
|
|
397
|
-
// A resolver answers one question: what did production get back for this step?
|
|
398
|
-
|
|
394
|
+
// A resolver answers one question: what did production get back for this step? Look the step up by its path, its
|
|
395
|
+
// position in the flow. `step.index` counts steps in the order they finished, which shifts around a Parallel.
|
|
396
|
+
const outputs = new Map(mySpans.map((span) => [span.attributes.path, span.attributes.output]));
|
|
397
|
+
const resolve = (step) => (outputs.has(step.path) ? { result: outputs.get(step.path) } : undefined);
|
|
399
398
|
await replayEffect(checkoutFlow(input), resolve);
|
|
400
399
|
```
|
|
401
400
|
|
|
@@ -422,11 +421,11 @@ configureEffect(
|
|
|
422
421
|
);
|
|
423
422
|
```
|
|
424
423
|
|
|
425
|
-
By default, successful runs are held in memory and then discarded. A run that rejects because your own code threw, such as a `TypeError` after an API changed the shape of its response, is offered to `keep` as a `Failure` carrying the thrown error, so by default its trace is kept, and the run still rejects. Replaying that trace reproduces the throw. If `keep` or the sink throws, for example because
|
|
424
|
+
By default, successful runs are held in memory and then discarded. A run that rejects because your own code threw, such as a `TypeError` after an API changed the shape of its response, is offered to `keep` as a `Failure` carrying the thrown error, so by default its trace is kept, and the run still rejects. Replaying that trace reproduces the throw. A step before the flow's first Command runs when the flow is built, outside `runEffect`, so its throw reaches no hook and is not recorded; that run did no I/O, so its input reproduces it. If `keep` or the sink throws, for example because storage is unreachable, the run keeps its own outcome and the error goes to `onSinkError`, which defaults to `console.error`. `redact` runs before anything enters the trace, including the stored `initialInput` and `context`. A value it throws on, or one JSON cannot hold, such as a `BigInt`, is left out of the trace, which marks where with `unrecorded`. `maxEntries` caps the length of a trace and reports the overflow as `dropped`; a capped trace replays only up to the first step it lacks, so the example warns through `onWarning` the first time it keeps one for a flow. It warns the same way about a trace holding values it could not record, which replays only up to the first of them, and about a flow whose steps include any named `'anonymous'`. A trace already holds only what JSON can, so the sink can write it to S3 or a database column with `JSON.stringify`.
|
|
426
425
|
|
|
427
|
-
The example
|
|
426
|
+
The example takes the input from `onRun`, which is handed it only for a flow `effectPipe` built, so build the outermost flow with `effectPipe`. A flow that starts with a bare `Command`, `Ask`, `Retry`, or `Parallel` records no input, and `timeTravel` would rebuild it from `undefined`. A one-step pipeline is enough, as in `runEffect(effectPipe(loadProfile)(userId))`. The example warns through `onWarning`, which defaults to `console.warn`, the first time it keeps a trace of such a flow, and `timeTravel` warns when it replays one. The example also takes the context from the run's first Command, so a run that stops before any Command, for example at an `Ask` check, records no context. Such a run did no I/O, so running the flow again with its input and the context from your logs reproduces it.
|
|
428
427
|
|
|
429
|
-
**A trace keeps data, not objects.** A
|
|
428
|
+
**A trace keeps data, not objects.** A value is copied into the form JSON stores when it is recorded, and copied again each time a replay hands it to the flow, so neither a later step nor a replay can change what the trace says, and a replay from memory gets what a replay from storage gets. The copy keeps values but not classes: a money object or a database entity comes back as a plain object without its methods, a `Date` as a string, a `Map` or a `Set` as `{}`, a `Buffer` as `{ type: 'Buffer', data }`, `undefined` in an array as `null`, and an error returned inside a result with its own fields, such as `code`, but without its message. A reference back to an object the value is inside of, as between an HTTP client's request and response, is cut. A value JSON cannot hold at all, such as a `BigInt` or an object whose getter throws, is left out of the trace and marked `unrecorded` before `redact` sees it, so a context holding a lazy client is left out whole.
|
|
430
429
|
|
|
431
430
|
So return plain data from a Command's function. Turn a class instance into a plain object before returning it, with a small named function you can test on its own, as in `() => db.findCart(id).then(cartToData)`, and rebuild the object in `next` if the flow needs its methods. When you catch an error to return it as data, copy the fields the flow branches on, as in `.catch((error) => ({ ok: false, code: error.code }))`, rather than returning the error itself.
|
|
432
431
|
|
|
@@ -446,7 +445,7 @@ const findProduct = (productId) =>
|
|
|
446
445
|
app.post('/checkout', async (req, res) => {
|
|
447
446
|
const result = await runEffect(checkoutFlow(req.body.productId), { tenant: req.tenant });
|
|
448
447
|
if (result.type === 'Success') return res.json(result.value);
|
|
449
|
-
// A Failure
|
|
448
|
+
// A Failure holds the full error, so send the client only an error meant for it.
|
|
450
449
|
// Answer the errors the flow returns by name, and anything else, such as a database outage, as a server error.
|
|
451
450
|
if (result.error === 'Product not found.') return res.status(400).json({ error: result.error });
|
|
452
451
|
res.status(500).json({ error: 'Checkout failed.' });
|
|
@@ -455,6 +454,8 @@ app.post('/checkout', async (req, res) => {
|
|
|
455
454
|
|
|
456
455
|
Recording stores the context alongside the trace, so `Ask` replays with the values the original request saw. It is copied once per run, so read a value that can change while a run is under way, such as a switch an operator can flip to stop a batch, in a Command, whose result the trace records each time.
|
|
457
456
|
|
|
457
|
+
Keep the context to plain values such as IDs, flags and settings. Recording copies it into every trace, so a database client or connection pool in it puts its connection string in the trace, and one whose properties throw when read, as a lazy client's can, leaves the whole context out, so a replay needs it passed in. Reach a service from the Command's function, as `findProduct` reaches `db`. To run a flow against fakes, have the function that builds it take the services, as in `checkoutFlow(services)(productId)`, and pass fakes in the test.
|
|
458
|
+
|
|
458
459
|
## Retrying Transient Failures
|
|
459
460
|
|
|
460
461
|
`Retry` runs part of a flow again when its I/O fails, meaning a Command's function throws. It does not retry a `Failure` that a step returned: that means the flow decided to stop, so the `Failure` is passed on at once, unchanged. Options are given where `Retry` is used, since they describe one dependency. An option set to `undefined` keeps its default, so settings read from configuration can leave a key out. To reuse the same settings, share an object:
|
|
@@ -570,7 +571,7 @@ Without `settled`, one failing branch cancels the rest and becomes the result of
|
|
|
570
571
|
Parallel(subscriptions.map(billOne), (outcomes) => Success(outcomes.map(summarize)), { limit: 5, settled: true });
|
|
571
572
|
```
|
|
572
573
|
|
|
573
|
-
A `Failure` in the list
|
|
574
|
+
A `Failure` in the list holds only its error, so match outcomes to records by their position in the list. To report them, read `value` from a `Success` and `error` from a `Failure`:
|
|
574
575
|
|
|
575
576
|
```js
|
|
576
577
|
Parallel(work, (outcomes) => Success(outcomes.map((o) => (o.type === 'Success' ? o.value : o.error.message))), {
|
|
@@ -578,13 +579,29 @@ Parallel(work, (outcomes) => Success(outcomes.map((o) => (o.type === 'Success' ?
|
|
|
578
579
|
});
|
|
579
580
|
```
|
|
580
581
|
|
|
581
|
-
Logging the outcomes as they are would include each branch's input, which for a registration or login contains credentials.
|
|
582
|
-
|
|
583
582
|
The outcomes are plain `Success` and `Failure` objects. Returning one of them from `next` is the same as returning any other `Failure`: the flow stops, and `Retry` does not run it again.
|
|
584
583
|
|
|
584
|
+
A `Failure` among them can also hold an error the branch never returned: what one of its Commands threw, or what an `onBeforeCommand` hook vetoed it with. Neither is in the branch's declared error type, so give the code that reads the outcomes a case for an error it does not recognise, as for a run's result (see [TypeScript](#typescript-typed-errors-and-context)), rather than letting it fall into the last error it expects, which would report a vetoed charge as a declined card.
|
|
585
|
+
|
|
586
|
+
Fewer such errors reach the outcomes when a branch returns what it can as data. Catch an error the flow can handle inside the Command's function and return it (see [Which Errors Are Data](#which-errors-are-data)), and stop a batch by reading the operator's switch in a Command whose `next` returns a `Failure`, rather than by vetoing in `onBeforeCommand`:
|
|
587
|
+
|
|
588
|
+
```js
|
|
589
|
+
// Read before each charge, so a switch flipped mid-batch stops the charges that have not started.
|
|
590
|
+
const cmdBillingHalted = () => flags.get('billing.halted');
|
|
591
|
+
const billUnlessHalted = (subscription) =>
|
|
592
|
+
Command(cmdBillingHalted, (halted) => (halted ? Failure('halted') : billOne(subscription)));
|
|
593
|
+
|
|
594
|
+
Parallel(subscriptions.map(billUnlessHalted), (outcomes) => Success(outcomes.map(summarize)), {
|
|
595
|
+
limit: 5,
|
|
596
|
+
settled: true
|
|
597
|
+
});
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
The halt is then one of the branch's declared errors, typed like a declined card, and the trace records each read of the switch, so the batch replays. A vetoed Command leaves no entry in the trace, so a replay of a vetoed batch fails at the first veto.
|
|
601
|
+
|
|
585
602
|
Bugs in the flow are not collected. An `EffectTypeError`, thrown for a malformed flow, and an error thrown by a `next` function or a pure step both still escape a settled `Parallel`, after the other branches are cancelled.
|
|
586
603
|
|
|
587
|
-
`limit` caps how many branches run at once; the rest start as others finish. Results and recorded paths stay in array order, so a limit changes only the pacing, and a trace recorded with a limit replays the same without one. A `limit` that is not a positive integer throws
|
|
604
|
+
`limit` caps how many branches run at once; the rest start as others finish. Results and recorded paths stay in array order, so a limit changes only the pacing, and a trace recorded with a limit replays the same without one. A `limit` that is not a positive integer throws an `EffectTypeError` where the `Parallel` is built.
|
|
588
605
|
|
|
589
606
|
**Cancelling is a request, not a guarantee.** A cancelled branch starts no new Commands: a three-step branch whose first step is running when a sibling fails finishes that step and stops. To stop the running step itself, its function has to take the `AbortSignal` as a parameter, written without a default value, and pass it on to the I/O:
|
|
590
607
|
|
|
@@ -598,7 +615,7 @@ const fetchProfileUncancellable = (userId) => Command(() => fetch(`/users/${user
|
|
|
598
615
|
|
|
599
616
|
Outside a `Parallel`, the function is called with no arguments. A `Retry` inside a cancelled branch stops retrying.
|
|
600
617
|
|
|
601
|
-
Which branch failed first and cancelled the others depends on timing, so it is recorded with the trace. A replay of a cancelled `Parallel` returns the same failure production did, and stops each other branch where production stopped it, rather than letting whichever branch the replay reaches first decide. The same holds when the branch that cancelled the others was a `next` function or a pure step that threw: the replay throws the same error. If the branch that cancelled the others no longer fails, or the `Parallel` no longer has that branch, the replay ends in a `TimeParadox` naming it.
|
|
618
|
+
Which branch failed first and cancelled the others depends on timing, so it is recorded with the trace. A replay of a cancelled `Parallel` returns the same failure production did, and stops each other branch where production stopped it, rather than letting whichever branch the replay reaches first decide. The same holds when the branch that cancelled the others was a `next` function or a pure step that threw: the replay throws the same error. If the branch that cancelled the others no longer fails, or the `Parallel` no longer has that branch, the replay ends in a `TimeParadox` naming it. The trace also records how many branches each `Parallel` had, so a replay of one that has gained or lost a branch ends in a `TimeParadox` before any of its branches run.
|
|
602
619
|
|
|
603
620
|
**Undoing what succeeded when a branch fails.** Without `settled`, the failing branch's `Failure` is the whole result, and the values of the branches that succeeded are dropped. When one of them did something that has to be undone, such as charging a card for an order whose stock then ran out, the code that called `runEffect` no longer has the charge to refund. Use `settled: true`, so `next` sees every outcome, and have it return the steps that undo whatever succeeded, followed by the failure:
|
|
604
621
|
|
|
@@ -717,7 +734,7 @@ const fetchPrice = (sku) => {
|
|
|
717
734
|
};
|
|
718
735
|
```
|
|
719
736
|
|
|
720
|
-
The failed attempt is recorded as that Command's result, with the error's `code` and `message`, so a replay takes the same fallback branch. Copy the fields you need rather than returning the error itself: a trace keeps data, not objects, so an error stored inside a result loses its
|
|
737
|
+
The failed attempt is recorded as that Command's result, with the error's `code` and `message`, so a replay takes the same fallback branch. Copy the fields you need rather than returning the error itself: a trace keeps data, not objects, so an error stored inside a result loses its message (see [Recording in Production](#recording-in-production)).
|
|
721
738
|
|
|
722
739
|
**A `Failure` means abort.** It stops the flow and goes back to the code that called `runEffect`, which decides what it means: an HTTP status, a queue retry, an alert. Nothing inside the flow can catch a `Failure`, so a reader never has to look elsewhere for a handler.
|
|
723
740
|
|
|
@@ -754,7 +771,7 @@ Retry(
|
|
|
754
771
|
| a Command's function threw | an I/O fault | retries it | the exhaustion, once the attempts are gone |
|
|
755
772
|
| a `next` function or a pure step threw, or the flow is malformed | a bug | nothing; it is thrown, not returned | nothing |
|
|
756
773
|
|
|
757
|
-
In short, **you can recover from an error your I/O produced, but you cannot catch a `Failure` a step returned.** Only the code that called `runEffect` acts on it. For the same reason, do not throw business errors from a Command's function: a throw there tells `Retry` that the I/O broke, and `Retry` will try again. A throw anywhere else in the flow is treated as a bug
|
|
774
|
+
In short, **you can recover from an error your I/O produced, but you cannot catch a `Failure` a step returned.** Only the code that called `runEffect` acts on it. For the same reason, do not throw business errors from a Command's function: a throw there tells `Retry` that the I/O broke, and `Retry` will try again. A throw anywhere else in the flow is treated as a bug and thrown as it is instead of returned as a `Failure`: `runEffect` rejects with it, or, from a step before the first Command, the call that builds the flow throws it.
|
|
758
775
|
|
|
759
776
|
## TypeScript: Typed Errors and Context
|
|
760
777
|
|
|
@@ -777,12 +794,14 @@ if (result.type === 'Failure') {
|
|
|
777
794
|
}
|
|
778
795
|
```
|
|
779
796
|
|
|
780
|
-
The annotations are optional. Without them, the union holds whatever each step can pass to `Failure`, each error kept exactly as written, whether a string or an object, and a step that cannot fail, such as a Command without a `next`, adds nothing. The union covers the `Failure`s your steps return. A Command whose function throws, or that an `onBeforeCommand` hook vetoes, also ends the run with a `Failure` the union does not name, so where the result is handled, give it one fallback, such as a `default` in a `switch` over `result.error`, and treat what reaches it as a server error. One fallback covers the whole flow: catch inside a Command's function only what the flow can handle, and let the rest throw, which is also what lets `Retry` act on it. To check for a thrown error, copy it into a variable typed `unknown` first, as in `const error: unknown = result.error`, since TypeScript refuses `instanceof Error` on an error type made only of strings, or on a flow that declares none.
|
|
797
|
+
The annotations are optional. Without them, the union holds whatever each step can pass to `Failure`, each error kept exactly as written, whether a string or an object, and a step that cannot fail, such as a Command without a `next`, adds nothing. The union covers the `Failure`s your steps return. A Command whose function throws, or that an `onBeforeCommand` hook vetoes, also ends the run with a `Failure` the union does not name, so where the result is handled, give it one fallback, such as a `default` in a `switch` over `result.error`, and treat what reaches it as a server error. The same goes for the outcomes a settled `Parallel` hands `next`. One fallback covers the whole flow: catch inside a Command's function only what the flow can handle, and let the rest throw, which is also what lets `Retry` act on it. To check for a thrown error, copy it into a variable typed `unknown` first, as in `const error: unknown = result.error`, since TypeScript refuses `instanceof Error` on an error type made only of strings, or on a flow that declares none.
|
|
781
798
|
|
|
782
799
|
To type what a Command's function returns, such as a JSON response, write the function's result type, as in `Command((): Promise<User | null> => fetchJson(url), next)`. `Command<User | null>(…)` also compiles, but with some type arguments given TypeScript infers none of the others, so that Command's errors are typed `unknown`, which hides the rest of the union.
|
|
783
800
|
|
|
784
801
|
Value types are checked through the pipeline too, so a step that reads a field the previous step does not return is a compile error. This only works while every step uses the value it receives. A step written as `() => doSomething(outer)` ignores it, and the types stop being checked at that point.
|
|
785
802
|
|
|
803
|
+
`Failure` keeps a literal exactly as written, but `Success` widens it, as TypeScript does for any object: `Success({ status: 'queued' })` holds a `status` of type `string`. A later step that tells values apart by such a field then refuses it, often several steps from where the value was built, so give the type there, as in `Success<Queued>({ status: 'queued', jobId })`.
|
|
804
|
+
|
|
786
805
|
`effectPipe` is typed for up to 20 steps. A pipeline is itself a step, so a longer one nests, which is usually easier to read anyway: `effectPipe(effectPipe(s1, s2), effectPipe(s3, s4))`.
|
|
787
806
|
|
|
788
807
|
### Typed context with `Ask`
|
|
@@ -798,7 +817,7 @@ const findProduct = (productId: string): Effect<Product, 'not_found', AppContext
|
|
|
798
817
|
const result = await runEffect(findProduct('abc'), { tenant: 'acme', requestId: '123' });
|
|
799
818
|
```
|
|
800
819
|
|
|
801
|
-
Every step's context counts, so a pipeline needs all the contexts its steps read, even when the first step reads none, and a `Parallel` needs every context its branches read. `runEffect` requires a context whenever the flow reads one, since the flow would otherwise get an empty object.
|
|
820
|
+
Every step's context counts, so a pipeline needs all the contexts its steps read, even when the first step reads none, and a `Parallel` needs every context its branches read. `runEffect` requires a context whenever the flow reads one, since the flow would otherwise get an empty object, and checks one passed as a variable against what the flow reads, as it checks one written in place, so a variable whose type lacks a field the flow reads does not compile. `recordEffect`, `replayEffect` and `timeTravel` check a context the same way, and `replayEffect` given a resolver requires one, since a resolver holds none.
|
|
802
821
|
|
|
803
822
|
## Why Pure Effect
|
|
804
823
|
|
|
@@ -831,7 +850,7 @@ Timings are from Node 22 on a MacBook Pro with M4 Pro CPU:
|
|
|
831
850
|
|
|
832
851
|
## API Reference
|
|
833
852
|
|
|
834
|
-
Every `options` argument below throws for a name it does not read, so a misspelt option fails instead of running with its default.
|
|
853
|
+
Every `options` argument and hook configuration below throws for a name it does not read or a value it cannot use, so a misspelt option fails instead of running with its default. `Retry` and `Parallel` throw an `EffectTypeError` where they are built.
|
|
835
854
|
|
|
836
855
|
### Building blocks
|
|
837
856
|
|
|
@@ -839,9 +858,9 @@ Every `options` argument below throws for a name it does not read, so a misspelt
|
|
|
839
858
|
|
|
840
859
|
Returns `{ type: 'Success', value }`.
|
|
841
860
|
|
|
842
|
-
#### `Failure(error
|
|
861
|
+
#### `Failure(error)`
|
|
843
862
|
|
|
844
|
-
Returns `{ type: 'Failure', error
|
|
863
|
+
Returns `{ type: 'Failure', error }`. Stops the pipeline immediately.
|
|
845
864
|
|
|
846
865
|
#### `Command(cmdFn, nextFn?, meta?)`
|
|
847
866
|
|
|
@@ -875,9 +894,9 @@ Returns `{ type: 'Ask', next }`. Passes the `context` from `runEffect` into `nex
|
|
|
875
894
|
|
|
876
895
|
Returns `{ type: 'Retry', effect, options, next }`.
|
|
877
896
|
|
|
878
|
-
- `options.attempts`: Max retries, not counting the first try (default: `3`). Must be a positive integer; anything else, including `0`, throws
|
|
879
|
-
- `options.delay`: Ms before the first retry (default: `100`). Must be a finite number of 0 or more
|
|
880
|
-
- `options.backoff`: Multiplier applied to the delay on each attempt (default: `1`, flat). Must be a finite number of 0 or more
|
|
897
|
+
- `options.attempts`: Max retries, not counting the first try (default: `3`). Must be a positive integer; anything else, including `0`, throws. To handle an outcome without retrying, branch on it in the Command's `next`, or isolate a failing branch with `Parallel`'s `settled`.
|
|
898
|
+
- `options.delay`: Ms before the first retry (default: `100`). Must be a finite number of 0 or more.
|
|
899
|
+
- `options.backoff`: Multiplier applied to the delay on each attempt (default: `1`, flat). Must be a finite number of 0 or more.
|
|
881
900
|
|
|
882
901
|
An option set to `undefined` keeps its default.
|
|
883
902
|
|
|
@@ -923,20 +942,20 @@ Return the Command, and have its `next` return the value the rest of the pipelin
|
|
|
923
942
|
Runs a flow and resolves with its `Success` or `Failure`.
|
|
924
943
|
|
|
925
944
|
- `context`: what `Ask` reads, also passed to `onBeforeCommand`. `context.flowName` names the run in traces and telemetry.
|
|
926
|
-
- `callConfig`: `onStep`, `onRun` and `onBeforeCommand` for this call only, added to the `configureEffect` wiring, or used alone with `inherit: false`; see [`configureEffect`](#configureeffectconfigs). `onRun` wraps the run once, retries included. A
|
|
945
|
+
- `callConfig`: `onStep`, `onRun` and `onBeforeCommand` for this call only, added to the `configureEffect` wiring, or used alone with `inherit: false`; see [`configureEffect`](#configureeffectconfigs). `onRun` wraps the run once, retries included. A key it does not read or a hook that is not a function throws a `TypeError`, as in `configureEffect`, and so does a `retry` key, since retry options belong to each `Retry`.
|
|
927
946
|
|
|
928
|
-
A flow that is not made of Effects is a bug, so
|
|
947
|
+
A flow that is not made of Effects is a bug, so it throws an `EffectTypeError` that says where, rather than returning a `Failure`. The steps before a flow's first Command run as it is built, so a mistake in one of them, as in this first step, is thrown by the call that builds the flow, `registerUserFlow(input)`; a mistake after the first Command makes `runEffect` reject:
|
|
929
948
|
|
|
930
949
|
```
|
|
931
950
|
Step 'validateRegistration' returned a plain object. Return Success, Failure, Command, Ask,
|
|
932
951
|
Retry, or Parallel: a plain value has to be wrapped, as in Success(value).
|
|
933
952
|
```
|
|
934
953
|
|
|
935
|
-
That covers a missing `return`, a `next` that returns a plain value, a step or `next` written as `async` (do the awaited work in a Command instead), and `runEffect(flow)` where `runEffect(flow(input))` was meant. The building blocks check their arguments
|
|
954
|
+
That covers a missing `return`, a `next` that returns a plain value, a step or `next` written as `async` (do the awaited work in a Command instead), and `runEffect(flow)` where `runEffect(flow(input))` was meant. The building blocks check their arguments where they are built, before their own I/O runs, so `Command(db.findUser(email))`, which makes the call while the flow is built, `Command(fn, { name })`, with the metadata in `next`'s place, and a `Parallel` or `effectPipe` given the wrong thing each throw an `EffectTypeError` there; one built inside a `next` is checked when that `next` runs. A throw from a Command's function, or from an `onBeforeCommand` veto, is a `Failure`; a throw from a `next` or a pure step is a bug too, and is thrown as it is, in the same two places.
|
|
936
955
|
|
|
937
956
|
#### `configureEffect(...configs)`
|
|
938
957
|
|
|
939
|
-
Adds a layer of hooks for the whole process and returns a function that removes it. Several configurations passed to one call form one layer, the same as installing each on its own. Removing a layer removes only that layer, even when others were installed after it, so a library can add and remove its own hooks without touching the application's. `configureEffect()` with no arguments removes every layer, and a call whose arguments are all `undefined`, such as `configureEffect(flag ? hooks : undefined)`, changes nothing. A `
|
|
958
|
+
Adds a layer of hooks for the whole process and returns a function that removes it. Several configurations passed to one call form one layer, the same as installing each on its own. Removing a layer removes only that layer, even when others were installed after it, so a library can add and remove its own hooks without touching the application's. `configureEffect()` with no arguments removes every layer, and a call whose arguments are all `undefined`, such as `configureEffect(flag ? hooks : undefined)`, changes nothing. A configuration that is not an object, a key other than `onStep`, `onRun` and `onBeforeCommand`, and a hook that is not a function each throw a `TypeError` before anything is installed, so a misspelt hook fails instead of switching itself off. Only `undefined` leaves a configuration or a hook out; `null` throws. A `retry` key throws too, since retry options belong to each `Retry`.
|
|
940
959
|
|
|
941
960
|
```js
|
|
942
961
|
const remove = configureEffect(telemetryHooks(), recordingHooks({ sink }));
|
|
@@ -944,12 +963,12 @@ const remove = configureEffect(telemetryHooks(), recordingHooks({ sink }));
|
|
|
944
963
|
remove();
|
|
945
964
|
```
|
|
946
965
|
|
|
947
|
-
- `onRun(effect, pipeline, flowName)`: wraps the whole run. It must `await pipeline()` and return its result.
|
|
966
|
+
- `onRun(effect, pipeline, flowName, initialInput)`: wraps the whole run. It must `await pipeline()` and return its result. `initialInput` is what the flow was called with when `effectPipe` built it, and `undefined` otherwise; pass it on to any hook it calls.
|
|
948
967
|
- `onStep(name, type, op, path)`: wraps each Command, and each `Parallel` with `name` and `type` both `'Parallel'`. It must `await op()` and return its result, and pass `path` on to any hook it calls, since a replay matches steps on it.
|
|
949
968
|
- For a Command, `op()` returns a promise, even for a synchronous function. Returning a value without calling `op()` answers for the Command, which is how replay works, and throwing without calling it counts as the Command failing.
|
|
950
969
|
- A throw after `op()` succeeded, or `undefined` returned in place of its value, as a hook that forgot its `return` or its `await` does, is a bug in the hook: the run rejects, and `Retry` does not run the Command again.
|
|
951
|
-
- For a `Parallel`, `op()` runs the branches, so a hook must call it or the run rejects with a `TypeError`. It returns which branch, if any, cancelled the others, as in `{ cancelled: true, branch: 0 }`, even when a branch threw.
|
|
952
|
-
- `onBeforeCommand(command, context)`: runs before each Command. A throw vetoes the Command: the run returns a `Failure` carrying the thrown error, and `Retry` does not retry it.
|
|
970
|
+
- For a `Parallel`, `op()` runs the branches, so a hook must call it or the run rejects with a `TypeError`. It returns which branch, if any, cancelled the others, and how many branches there were, as in `{ cancelled: true, branch: 0, branches: 2 }`, even when a branch threw.
|
|
971
|
+
- `onBeforeCommand(command, context)`: runs before each Command. A throw vetoes the Command: the run returns a `Failure` carrying the thrown error, and `Retry` does not retry it. A vetoed Command leaves no entry in a trace, so a replay of that run fails where it was vetoed; to stop a batch, read a switch in a Command instead, as [Running Effects in Parallel](#running-effects-in-parallel) shows.
|
|
953
972
|
|
|
954
973
|
Layers run in the order they were installed, the first outermost, so a result or a thrown error unwinds from the innermost hook out:
|
|
955
974
|
|
|
@@ -990,17 +1009,20 @@ onStep C.onStep( K.onStep( cmd ) ) K.onStep
|
|
|
990
1009
|
|
|
991
1010
|
Returns `{ onStep, entries, toTrace }`, a hook that records every step of a run, passed as `runEffect(flow, context, { onStep })`. Give each run its own recorder: one shared by a whole application mixes requests into one trace, which a replay refuses. `recordEffect` and the [recording example](https://github.com/aycangulez/pure-effect/blob/main/examples/recording-example.js) both do this for you.
|
|
992
1011
|
|
|
993
|
-
- `onStep`: the hook. Recording never changes a run's outcome
|
|
1012
|
+
- `onStep`: the hook. Recording never changes a run's outcome. A value `redact` throws on, or one the recorder cannot copy, is left out and its entry marked, so a replay stops there with a `ReplayError` rather than hand the flow a stand-in.
|
|
994
1013
|
- `entries`: the steps recorded so far, one per Command and one per `Parallel`. `path` is the step's position in the flow, which a replay matches on; an older entry with `error` and no `threw` still counts as a throw.
|
|
995
1014
|
|
|
996
1015
|
```text
|
|
997
|
-
{ command, path, result, durationMs }
|
|
998
|
-
{ command, path, threw: true, error, durationMs }
|
|
999
|
-
{ command: 'Parallel', path, result: { cancelled, branch } } which branch, if any, cancelled the others
|
|
1016
|
+
{ command, path, result, durationMs } a Command that returned
|
|
1017
|
+
{ command, path, threw: true, error, durationMs } a Command that threw
|
|
1018
|
+
{ command: 'Parallel', path, result: { cancelled, branch, branches } } which branch, if any, cancelled the others,
|
|
1019
|
+
and how many branches there were
|
|
1020
|
+
{ command, path, unrecorded, durationMs } a Command whose result or error could not be recorded,
|
|
1021
|
+
because 'redact' threw on it or the 'copy' failed
|
|
1000
1022
|
```
|
|
1001
1023
|
|
|
1002
|
-
- `toTrace(meta)`: the entries as a trace, with `meta`'s `initialInput`, `context`, `flowName` and `version`, copied when it is called
|
|
1003
|
-
- `options.redact(value, name, kind)`: returns what the trace stores in place of `value`. `kind` is `'result'`, `'error'`, `'initialInput'` or `'context'`, and `name` is the Command's name, or the kind. It gets a copy, so changing it in place is safe.
|
|
1024
|
+
- `toTrace(meta)`: the entries as a trace, with `meta`'s `initialInput`, `context`, `flowName` and `version`, copied into the form JSON stores when it is called. An `initialInput` or `context` that `redact` throws on, or that JSON cannot hold, is left out, and the trace's `unrecorded` says why, as in `{ context: 'copy' }`. If a Command can change the input or the context, as an ORM save that adds an id does, call it before the run for those and again afterwards for the entries, as `recordEffect` does.
|
|
1025
|
+
- `options.redact(value, name, kind)`: returns what the trace stores in place of `value`. `kind` is `'result'`, `'error'`, `'initialInput'` or `'context'`, and `name` is the Command's name, or the kind. It gets a copy in the form the trace stores, so changing it in place is safe, and what it returns is stored in that form too. A thrown `Error` arrives as a plain object holding its `name`, `message` and own fields, and any object returned for it replays as an `Error`.
|
|
1004
1026
|
- `options.maxEntries`: the most entries a trace keeps; the rest are counted in `dropped`, and a replay stops at the first step the trace lacks.
|
|
1005
1027
|
- `options.stack`: record stack traces for thrown errors (off by default).
|
|
1006
1028
|
|
|
@@ -1033,27 +1055,27 @@ Runs a flow for real while recording, returning `{ result, trace }`. Accepts `re
|
|
|
1033
1055
|
|
|
1034
1056
|
Runs a flow with each Command answered from a trace instead of run, and returns `{ result, unreached }`: the flow's outcome, and the recorded steps it never asked for, which is where a flow that stops early shows up. A flow that no longer matches the trace ends in a `Failure` whose `error` is a `TimeParadox`, or a `ReplayError` for a step the trace lacks. Neither is thrown, so a test checks `error.name`, which tells them apart, and not only `result.type`. A malformed trace rejects with a `ReplayError`.
|
|
1035
1057
|
|
|
1036
|
-
- `traceOrResolver`: a trace, its bare entries, or a resolver, a function that answers each step with `{ result }`, `{ error }`, or `undefined` for a step it has no record of, so a trace stored in any shape can be replayed. With a resolver, `unreached` is absent, since a resolver cannot list what it holds. A resolver is also asked about each `Parallel`, with `step.type` set to `'Parallel'`: answering with the recorded cancellation as `{ result }` replays it as production decided, and
|
|
1037
|
-
- `options.context`: the context for `Ask`, by default the one the trace recorded. A resolver has none, so pass one if the flow reads `Ask
|
|
1038
|
-
- `options.onMissing`: `'throw'` (default) stops at a step the trace lacks. `'execute'` runs its Command for real, so use it only where Commands reach test doubles or only read. A trace can lack a step because a hook vetoed it in production, because it was added
|
|
1058
|
+
- `traceOrResolver`: a trace, its bare entries, or a resolver, a function that answers each step with `{ result }`, `{ error }`, or `undefined` for a step it has no record of, so a trace stored in any shape can be replayed. Any other answer, `null` included, or a throw from the resolver, rejects the replay. With a resolver, `unreached` is absent, since a resolver cannot list what it holds. A resolver is also asked about each `Parallel`, with `step.type` set to `'Parallel'`: answering with the recorded cancellation as `{ result }` replays it as production decided, and `undefined` or any other outcome replays it by timing.
|
|
1059
|
+
- `options.context`: the context for `Ask`, by default the one the trace recorded. A resolver has none, so pass one if the flow reads `Ask`, and a trace whose context could not be recorded refuses to replay without one.
|
|
1060
|
+
- `options.onMissing`: `'throw'` (default) stops at a step the trace lacks. `'execute'` runs its Command for real, so use it only where Commands reach test doubles or only read. A trace can lack a step because a hook vetoed it in production, because it was added past the end of the recording, or because the recorder dropped it under `maxEntries`; a trace that dropped entries refuses `'execute'`, so record the flow again with a higher `maxEntries`. A flow that changed shape, as when a Command is newly wrapped in `Retry` or moved into a new `Parallel` branch, stops at a `TimeParadox` instead, even under `'execute'`, so record it again.
|
|
1039
1061
|
- `options.fastRetry` (default `true`): strip `Retry` delays.
|
|
1040
1062
|
- `options.hooks` (default `false`): run the replay inside the configured hooks, a configured recorder included, so they see the replayed steps. Off, a replay cannot reach a telemetry backend or a trace sink.
|
|
1041
1063
|
- `options.onResolved(step, outcome)`: observes each replayed step. A throw stops the replay, and `replayEffect` rejects with it; it never changes an outcome.
|
|
1042
1064
|
|
|
1043
|
-
A trace whose entries have no `path`, written by hand or recorded before paths existed, is matched by position. That works for a sequential flow
|
|
1065
|
+
A trace whose entries have no `path`, written by hand or recorded before paths existed, is matched by position. That works for a sequential flow; a Command inside a `Parallel` is refused with a `ReplayError`, since the order branches finish in cannot tell them apart.
|
|
1044
1066
|
|
|
1045
1067
|
#### `timeTravel(flowFn, traceLog, options?)`
|
|
1046
1068
|
|
|
1047
|
-
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` or when steps are named `'anonymous'`. 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`.
|
|
1069
|
+
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` or when steps are named `'anonymous'`. Returns the flow's outcome; for the unreached entries as data, use `replayEffect`. A trace whose input could not be recorded is refused, since the flow is rebuilt from it. Takes `options.context` to override the trace's, and `options.log` in place of `console.log`.
|
|
1048
1070
|
|
|
1049
1071
|
## Limitations
|
|
1050
1072
|
|
|
1051
1073
|
- **`Retry` repeats everything it wraps, including a Command's `next`.** When a Command's function throws, every Command inside the `Retry` runs again, including the ones that already succeeded. If a retried Command's `next` continues the flow, everything after it is retried too. Give a retried Command the default `next` and continue in a later pipeline step, or make sure every Command it reaches is safe to run more than once. A `Failure` a step returned is not retried at all, so a function that catches its own error and returns it as a value is never retried: see [Which Errors Are Data](#which-errors-are-data).
|
|
1052
1074
|
- **Cancelling a `Parallel` branch cannot stop everything.** A cancelled branch starts no new Commands, and a function that uses the `AbortSignal` it is given can be stopped while running. A function that ignores the signal runs to completion, so a branch whose _first_ Command is a write can still write after another branch has failed. So does a function that is never given the signal: one written as `(...args)` or `({ signal } = {})`, or wrapped by a helper that hides its parameters. `Parallel` waits for every branch to finish before returning, so no cancelled work is still running after the `Failure` is returned.
|
|
1053
1075
|
- **Inside a `Parallel`, a Command's function that takes a parameter gets the `AbortSignal` in it.** A function passed by name whose first parameter is optional but written without a default value takes the signal for it, so the same Command behaves differently inside a `Parallel` than outside one: `function listUsers(page) { page ??= 1; return db.users(page); }` gets the signal as its page. A parameter with a default value does not count, so `Command(nanoid)`, whose size is written `size = 21`, works everywhere. Wrap such a function, as in `Command(() => listUsers())`. In TypeScript, a function whose first parameter cannot be an `AbortSignal` does not compile as a Command.
|
|
1054
|
-
-
|
|
1076
|
+
- **`redact` covers a trace, not a result.** A `Failure` holds the full error, untrimmed, because tests and debugging need all of it, and an error can quote what a Command sent, as a database's duplicate-key error quotes the key. `redact` keeps sensitive data out of a **trace**. Keeping it out of your **logs** is up to you.
|
|
1055
1077
|
- **Replay checks the path, not the values.** Replaying an old trace cannot check a fix that changes what a flow computes, because every Command's result comes from the recording: the replay hands the step the value from before the fix, reports `Success`, and flags nothing. It can check a fix that changes which Commands run. A removed step shows up in `unreached` only if it was the last step the flow reached; removed from the middle, it shifts the later paths and the replay stops at a `TimeParadox`.
|
|
1056
|
-
- **A trace keeps data, not objects.** Recorded results come back
|
|
1078
|
+
- **A trace keeps data, not objects.** Recorded results come back as JSON stores them, from memory as from storage: a money object or a database entity as a plain object without its methods, a `Date` as a string, a `Map` as `{}`, and an error inside a result without its message. A thrown error comes back as a plain `Error` with its class's name, a `TypeError` included. A flow whose `next` calls a method on a result, or reads what the copy drops, or branches on a thrown error's class, as a settled `Parallel` or an `onExhausted` checking `instanceof TypeError` would, replays differently from production; check `error.name` instead. Return plain data from Commands; see [Recording in Production](#recording-in-production).
|
|
1057
1079
|
- **Replay reproduces what one flow saw, not timing.** A stale read replays exactly. A race between two concurrent requests does not, because a trace records one flow. `Parallel` branches replay with the results each branch saw, and a cancelled `Parallel` with the branch that cancelled it, but not the order they ran in, so replay cannot settle a race between branches that share state.
|
|
1058
1080
|
- **Configuration belongs to one copy of the library.** `configureEffect` stores hooks in module state, so two copies of the library in one process (from a dual ESM and CJS setup, two versions in the dependency tree, or a worker thread) each have their own. Hooks configured in one do not apply to the other, and nothing warns you.
|
|
1059
1081
|
- **A Command needs a name.** `meta.name` survives minification. `cmd.name` does not, unless the minifier is set to keep function names.
|
|
@@ -72,7 +72,11 @@ export function telemetryHooks(options = {}) {
|
|
|
72
72
|
try {
|
|
73
73
|
const result = await pipeline();
|
|
74
74
|
if (result.type === 'Failure') {
|
|
75
|
-
|
|
75
|
+
// Left out rather than set to undefined, which a project with exactOptionalPropertyTypes refuses.
|
|
76
|
+
const message = statusMessage(result.error);
|
|
77
|
+
rootSpan.setStatus(
|
|
78
|
+
message === undefined ? { code: SpanStatusCode.ERROR } : { code: SpanStatusCode.ERROR, message }
|
|
79
|
+
);
|
|
76
80
|
} else {
|
|
77
81
|
rootSpan.setStatus({ code: SpanStatusCode.OK });
|
|
78
82
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
4
|
import { configureEffect, recorder, Failure } from 'pure-effect';
|
|
5
5
|
|
|
6
|
-
/** @import {
|
|
6
|
+
/** @import { EffectConfiguration, RunWrapper, StepRunner, CommandInterceptor, TraceEntry, TraceLog, SuccessState, FailureState } from "pure-effect" */
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Records every run of an application without touching any call site, one trace per run. `recordEffect` covers
|
|
@@ -23,7 +23,8 @@ import { configureEffect, recorder, Failure } from 'pure-effect';
|
|
|
23
23
|
* database. Hand slow writes to a queue rather than awaiting them inside a request.
|
|
24
24
|
* @property {(value: any, name: string, kind: string) => any} [redact] - Scrubs every value before it enters the
|
|
25
25
|
* trace: results, serialized errors, and the stored `initialInput` and `context`, told apart by `kind`.
|
|
26
|
-
* It receives a copy, so changing it in place never reaches the run.
|
|
26
|
+
* It receives a copy, so changing it in place never reaches the run. A value it throws on is left out of
|
|
27
|
+
* the trace, which marks where, as is one the recorder cannot copy.
|
|
27
28
|
* @property {number} [maxEntries] - Caps trace length, 500 by default; the overflow is reported as `dropped`.
|
|
28
29
|
* @property {boolean} [stack] - Records stack traces for thrown errors.
|
|
29
30
|
* @property {(result: SuccessState<any> | FailureState<any>) => boolean} [keep] - Decides which runs reach the sink:
|
|
@@ -32,8 +33,8 @@ import { configureEffect, recorder, Failure } from 'pure-effect';
|
|
|
32
33
|
* @property {(error: unknown, flowName?: string) => void} [onSinkError] - Receives an error thrown by `keep` or
|
|
33
34
|
* `sink`. Defaults to `console.error`.
|
|
34
35
|
* @property {(message: string, flowName?: string) => void} [onWarning] - Receives a warning, once per flow, about a
|
|
35
|
-
* kept trace that will not replay as recorded: it has no input, `maxEntries` cut it short,
|
|
36
|
-
* steps are named 'anonymous'. Defaults to `console.warn`.
|
|
36
|
+
* kept trace that will not replay as recorded: it has no input, `maxEntries` cut it short, some of its values
|
|
37
|
+
* could not be recorded, or some of its steps are named 'anonymous'. Defaults to `console.warn`.
|
|
37
38
|
*/
|
|
38
39
|
|
|
39
40
|
/**
|
|
@@ -60,10 +61,12 @@ export function recordingHooks(options = {}) {
|
|
|
60
61
|
const warned = new Set();
|
|
61
62
|
|
|
62
63
|
/** @type {RunWrapper} */
|
|
63
|
-
const onRun = async (effect, pipeline, flowName) => {
|
|
64
|
+
const onRun = async (effect, pipeline, flowName, initialInput) => {
|
|
64
65
|
const rec = recorder({ redact, maxEntries, stack });
|
|
65
66
|
// Packaged before the run, so a Command that changes its input cannot rewrite what the trace says it received.
|
|
66
|
-
|
|
67
|
+
// `toTrace` never throws, which matters here, where a throw would stop the run before it started: an input it
|
|
68
|
+
// cannot record is left out and named in the trace's `unrecorded`.
|
|
69
|
+
const head = rec.toTrace({ flowName, initialInput });
|
|
67
70
|
return scope.run({ rec, head, contextCaptured: false }, async () => {
|
|
68
71
|
/** @type {{ result: SuccessState<any> | FailureState<any> } | { error: unknown }} */
|
|
69
72
|
let outcome;
|
|
@@ -78,7 +81,10 @@ export function recordingHooks(options = {}) {
|
|
|
78
81
|
try {
|
|
79
82
|
if (keep(result)) {
|
|
80
83
|
const { dropped = 0, trace } = rec.toTrace();
|
|
81
|
-
|
|
84
|
+
const unrecorded =
|
|
85
|
+
Object.keys(head.unrecorded ?? {}).length +
|
|
86
|
+
trace.filter((e) => e.unrecorded !== undefined).length;
|
|
87
|
+
warnAboutReplay(flowName, initialInput, dropped, trace, unrecorded);
|
|
82
88
|
await sink({ ...head, dropped, trace });
|
|
83
89
|
}
|
|
84
90
|
} catch (error) {
|
|
@@ -92,17 +98,18 @@ export function recordingHooks(options = {}) {
|
|
|
92
98
|
/**
|
|
93
99
|
* Warns, once per flow, about a kept trace that will not replay as recorded.
|
|
94
100
|
* @param {string} flowName
|
|
95
|
-
* @param {
|
|
101
|
+
* @param {unknown} initialInput
|
|
96
102
|
* @param {number} dropped
|
|
97
103
|
* @param {TraceEntry[]} trace
|
|
104
|
+
* @param {number} unrecorded - How many values the trace leaves out because they could not be recorded
|
|
98
105
|
*/
|
|
99
|
-
const warnAboutReplay = (flowName,
|
|
106
|
+
const warnAboutReplay = (flowName, initialInput, dropped, trace, unrecorded) => {
|
|
100
107
|
const warnOnce = (/** @type {string} */ kind, /** @type {string} */ message) => {
|
|
101
108
|
if (warned.has(`${kind}:${flowName}`)) return;
|
|
102
109
|
warned.add(`${kind}:${flowName}`);
|
|
103
110
|
onWarning(`Recording '${flowName || 'flow'}': ${message}`, flowName);
|
|
104
111
|
};
|
|
105
|
-
if (
|
|
112
|
+
if (initialInput === undefined) {
|
|
106
113
|
warnOnce(
|
|
107
114
|
'input',
|
|
108
115
|
'the flow carries no input, so its traces hold none and timeTravel rebuilds it from undefined. ' +
|
|
@@ -116,6 +123,14 @@ export function recordingHooks(options = {}) {
|
|
|
116
123
|
'the first step it lacks. Raise maxEntries for this flow to replay whole runs.'
|
|
117
124
|
);
|
|
118
125
|
}
|
|
126
|
+
if (unrecorded > 0) {
|
|
127
|
+
warnOnce(
|
|
128
|
+
'unrecorded',
|
|
129
|
+
`${unrecorded} of the values a kept trace holds could not be recorded, because redact threw on them or ` +
|
|
130
|
+
'they could not be copied, so it replays only up to the first of them. Make redact handle every ' +
|
|
131
|
+
"value it is given, null included, and keep Commands' results to plain data."
|
|
132
|
+
);
|
|
133
|
+
}
|
|
119
134
|
const anonymous = trace.filter((entry) => entry.command === 'anonymous').length;
|
|
120
135
|
if (anonymous > 0) {
|
|
121
136
|
warnOnce(
|
|
@@ -146,14 +161,18 @@ export function recordingHooks(options = {}) {
|
|
|
146
161
|
|
|
147
162
|
/**
|
|
148
163
|
* `onRun` never sees the context, so it is copied from the run's first Command, before that Command can change
|
|
149
|
-
* it. A run that stops before any Command records none.
|
|
164
|
+
* it. A run that stops before any Command records none. `toTrace` never throws, which matters here, where a throw
|
|
165
|
+
* would veto the Command.
|
|
150
166
|
* @type {CommandInterceptor}
|
|
151
167
|
*/
|
|
152
168
|
const onBeforeCommand = async (command, context) => {
|
|
153
169
|
const store = scope.getStore();
|
|
154
170
|
if (!store || store.contextCaptured) return;
|
|
155
171
|
store.contextCaptured = true;
|
|
156
|
-
|
|
172
|
+
const withContext = store.rec.toTrace({ context });
|
|
173
|
+
store.head.context = withContext.context;
|
|
174
|
+
// A context it could not record is left out, and the trace the run started with has to say so too.
|
|
175
|
+
if (withContext.unrecorded) store.head.unrecorded = { ...store.head.unrecorded, ...withContext.unrecorded };
|
|
157
176
|
};
|
|
158
177
|
|
|
159
178
|
return { onRun, onStep, onBeforeCommand };
|