pure-effect 0.17.0 → 0.19.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 CHANGED
@@ -2,6 +2,39 @@
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.19.0] - 2026-10-11
6
+
7
+ ### Added
8
+
9
+ - **Flows can be written as generator functions.** `effectGen(function* (input) { ... })` builds a flow function from a generator that writes `yield* step(effect)` where `async` code writes `await`, so `if`s and `for` loops wrap steps as they wrap `await`. A plain return value is the flow's `Success`, a returned `Failure` stops it, and a returned Effect runs. The flow is made of the same nodes a pipeline is, so it records, replays, retries and runs in a `Parallel` as one does, and a recorded trace of a generator flow matches its pipeline's. When a step stops the flow, the body's `catch` and `finally` blocks run; a `catch` that recovers or throws another error, or a `finally` that runs a step, rejects the run. `step` throws an `EffectTypeError` when it is used other than with `yield*`, and so do the other common mistakes: a missing star, a yielded Promise, and an `async function*`. TypeScript infers each step's value, the error union and the context from the body.
10
+ - **A `Parallel` in which a branch threw records `threw: true` in its decision.** An `onStep` hook sees it in what `op` returns, and a replay of such a trace stops no generator flow, as production did not; a trace recorded without it replays as before.
11
+ - **`Ask()` with no argument reads the context.** It succeeds with the context itself, so a generator flow reads it with `yield* step(Ask())`, and TypeScript names its type with `Ask<AppContext>()`. `Ask(undefined)` still throws, since an explicit `undefined` is usually a misspelt name.
12
+
13
+ ## [0.18.0] - 2026-10-09
14
+
15
+ ### Changed
16
+
17
+ - **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.
18
+ - **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`.
19
+ - **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.
20
+ - **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.
21
+ - **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.
22
+ - **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.
23
+
24
+ ### Fixed
25
+
26
+ - **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.
27
+ - **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.
28
+ - **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.
29
+ - **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`.
30
+ - **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.
31
+ - **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.
32
+ - **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`.
33
+ - **`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.
34
+ - **`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`.
35
+ - **`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'`.
36
+ - **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"`.
37
+
5
38
  ## [0.17.0] - 2026-10-06
6
39
 
7
40
  ### Changed
@@ -236,6 +269,8 @@ All notable changes to pure-effect are recorded here. The format follows [Keep a
236
269
 
237
270
  - Initial release: `Success`, `Failure`, `Command`, `effectPipe`, and `runEffect`.
238
271
 
272
+ [0.19.0]: https://github.com/aycangulez/pure-effect/compare/v0.18.0...v0.19.0
273
+ [0.18.0]: https://github.com/aycangulez/pure-effect/compare/v0.17.0...v0.18.0
239
274
  [0.17.0]: https://github.com/aycangulez/pure-effect/compare/v0.16.0...v0.17.0
240
275
  [0.16.0]: https://github.com/aycangulez/pure-effect/compare/v0.15.0...v0.16.0
241
276
  [0.15.0]: https://github.com/aycangulez/pure-effect/compare/v0.14.0...v0.15.0