pure-effect 0.20.0 → 0.20.1
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 +15 -1
- package/README.md +711 -438
- package/RULES.md +43 -0
- package/examples/recording-example.js +6 -3
- package/index.d.ts +3 -1
- package/index.js +34 -19
- package/package.json +4 -3
package/RULES.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Rules for Writing a Pure Effect Flow
|
|
2
|
+
|
|
3
|
+
<!-- Generated by scripts/rules-file.js from README.md: edit those, then run npm run generate. -->
|
|
4
|
+
|
|
5
|
+
The rules for writing a correct flow, and where the README covers each task.
|
|
6
|
+
|
|
7
|
+
## Rules
|
|
8
|
+
|
|
9
|
+
1. **Put every call that does I/O inside a Command, and keep its function to that one call:** `Command(() => db.findUser(email))`, never `step(db.findUser(email))`. Tests never run the function, so anything else in it goes untested. See [Testing Without Mocks](README.md#testing-without-mocks).
|
|
10
|
+
2. **Name every Command,** with a named function such as `const cmdFindUser = () => ...`, or with `meta.name`. Traces, replay and telemetry use the name, and a minifier can strip a function's name. See [`Command`](README.md#commandcmdfn-nextfn-meta).
|
|
11
|
+
3. **Put each decision in a step function** that receives one value and returns `Success`, `Failure` or the next Command, so a test can call it directly. See [Coming from async/await](README.md#coming-from-asyncawait).
|
|
12
|
+
4. **Return an outcome the caller should see as `Failure(error)`; never throw it.** A `Failure` stops the flow, is never retried, and only the code that calls `runEffect` acts on it. See [Which Errors Are Data](README.md#which-errors-are-data).
|
|
13
|
+
5. **A `catch` in a generator flow cannot recover.** It can log and rethrow, and it sees a `FlowStopped` error saying where the flow stopped, not the original. To handle an error, catch it inside the Command's function and return it as data. See [How a generator flow stops](README.md#how-a-generator-flow-stops).
|
|
14
|
+
6. **Let a Command's function throw only when `Retry` should try again.** An error it catches and returns is data, and `Retry` never sees it. To retry some errors and not others, rethrow the first kind and return the second. A throw also skips any undo the Command's `next` would have run. See [Which Errors Are Data](README.md#which-errors-are-data).
|
|
15
|
+
7. **Wrap only the Command that fails in `Retry`, and continue in a later step.** `Retry` runs everything it wraps again, the Command's `next` included. `retries: 2` means up to three calls, and `onExhausted` is where you give up. See [Retrying Transient Failures](README.md#retrying-transient-failures).
|
|
16
|
+
8. **Write a loop as a step that gets the list, then `Parallel(list.map(f), { limit: 1 })`.** `limit` caps how many run at once, and `settled: true` keeps going past a failure. A generator flow can use a `for` loop with `yield*`. See [Writing Loops](README.md#writing-loops).
|
|
17
|
+
9. **Get the time, random IDs and anything else that changes between runs from a Command.** A replay then gets the recorded value, and a generator body, which can run more than once, sees the same value each time. See [What runs once, and what runs again](README.md#what-runs-once-and-what-runs-again).
|
|
18
|
+
10. **Return plain data from Commands, and keep the context to plain values.** A trace stores JSON, so a `Date` comes back as a string. Reach a service from the Command's function, and hand it to the function that builds the flow. See [Passing Runtime Context](README.md#passing-runtime-context).
|
|
19
|
+
11. **Clean up in a `try`/`finally` around `runEffect`,** not inside the flow. See [How a generator flow stops](README.md#how-a-generator-flow-stops).
|
|
20
|
+
12. **Check a replay's error, not only its type.** A replay of a flow that changed shape ends in a `Failure` holding a `TimeParadox`, the error naming where the flow and the trace part ways, so a test that only expects a `Failure` passes by accident. See [Time-Travel Debugging](README.md#time-travel-debugging).
|
|
21
|
+
|
|
22
|
+
## Find It by Task
|
|
23
|
+
|
|
24
|
+
- Test a decision or a branch without mocks: [Testing Without Mocks](README.md#testing-without-mocks)
|
|
25
|
+
- Run a whole flow in a test, a retry that fails and then succeeds included: [Testing a whole flow](README.md#testing-a-whole-flow)
|
|
26
|
+
- Write a flow that reads like `async` code: [Writing Flows as Generators](README.md#writing-flows-as-generators)
|
|
27
|
+
- Port an `async` function, `try`/`catch` included: [Coming from async/await](README.md#coming-from-asyncawait), [Translating `try`/`catch`](README.md#translating-trycatch)
|
|
28
|
+
- Read request values such as a tenant or a request ID: [Passing Runtime Context](README.md#passing-runtime-context)
|
|
29
|
+
- Run a flow against fakes or a test database: [Passing Runtime Context](README.md#passing-runtime-context)
|
|
30
|
+
- Retry a flaky call, or only some of its errors: [Retrying Transient Failures](README.md#retrying-transient-failures), [Which Errors Are Data](README.md#which-errors-are-data)
|
|
31
|
+
- Wait as long as a server asks before trying again: [Waiting as long as the server asks](README.md#waiting-as-long-as-the-server-asks)
|
|
32
|
+
- Run steps at the same time, with a limit: [Running Effects in Parallel](README.md#running-effects-in-parallel)
|
|
33
|
+
- Undo what succeeded when a branch fails: [Undoing what succeeded when a branch fails](README.md#undoing-what-succeeded-when-a-branch-fails)
|
|
34
|
+
- Loop over a list: [Writing Loops](README.md#writing-loops)
|
|
35
|
+
- End a flow early with a result that isn't an error: [Ending a flow early without an error](README.md#ending-a-flow-early-without-an-error)
|
|
36
|
+
- Record every run in production: [Recording in Production](README.md#recording-in-production)
|
|
37
|
+
- Keep secrets out of a trace: [Keeping secrets out of a trace](README.md#keeping-secrets-out-of-a-trace)
|
|
38
|
+
- Turn a production incident into a test: [Time-Travel Debugging](README.md#time-travel-debugging)
|
|
39
|
+
- Prove a fix with a recorded trace: [Proving a fix that changes the flow's shape](README.md#proving-a-fix-that-changes-the-flows-shape)
|
|
40
|
+
- Add telemetry: [Hooks](README.md#hooks)
|
|
41
|
+
- Type a flow's errors and context: [TypeScript: Typed Errors and Context](README.md#typescript-typed-errors-and-context)
|
|
42
|
+
- Handle every error a flow can end with, in TypeScript: [Handling every error](README.md#handling-every-error)
|
|
43
|
+
- Test a flow in TypeScript: [Testing a flow in TypeScript](README.md#testing-a-flow-in-typescript)
|
|
@@ -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 { EffectConfiguration, RunWrapper, StepRunner, CommandInterceptor, TraceEntry, TraceLog, SuccessState, FailureState } from "pure-effect" */
|
|
6
|
+
/** @import { EffectConfiguration, RunWrapper, StepRunner, CommandInterceptor, TraceEntry, TraceLog, SuccessState, FailureState, RecorderOptions } 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
|
|
@@ -21,12 +21,14 @@ import { configureEffect, recorder, Failure } from 'pure-effect';
|
|
|
21
21
|
* @typedef {Object} RecordingOptions
|
|
22
22
|
* @property {(trace: TraceLog) => Promise<void> | void} [sink] - Receives each kept trace, to write to S3 or a
|
|
23
23
|
* database. Hand slow writes to a queue rather than awaiting them inside a request.
|
|
24
|
-
* @property {
|
|
24
|
+
* @property {RecorderOptions['redact']} [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
26
|
* It receives a copy, so changing it in place never reaches the run. A value it throws on is left out of
|
|
27
27
|
* the trace, which marks where, as is one the recorder cannot copy.
|
|
28
28
|
* @property {number} [maxEntries] - Caps trace length, 500 by default; the overflow is reported as `dropped`.
|
|
29
29
|
* @property {boolean} [stack] - Records stack traces for thrown errors.
|
|
30
|
+
* @property {string | undefined} [version] - Commit or build id, attached to each trace, so `timeTravel` can warn
|
|
31
|
+
* when it replays a trace against another build.
|
|
30
32
|
* @property {(result: SuccessState<any> | FailureState<any>) => boolean} [keep] - Decides which runs reach the sink:
|
|
31
33
|
* failures only by default. Return `true` to keep everything, or sample. A run whose own code threw is
|
|
32
34
|
* offered as a Failure carrying the thrown error.
|
|
@@ -50,6 +52,7 @@ export function recordingHooks(options = {}) {
|
|
|
50
52
|
redact,
|
|
51
53
|
maxEntries = 500,
|
|
52
54
|
stack,
|
|
55
|
+
version,
|
|
53
56
|
keep = (result) => result.type === 'Failure',
|
|
54
57
|
onSinkError = (error, flowName) => console.error(`Recording failed for '${flowName || 'flow'}':`, error),
|
|
55
58
|
onWarning = (message) => console.warn(message)
|
|
@@ -66,7 +69,7 @@ export function recordingHooks(options = {}) {
|
|
|
66
69
|
// Packaged before the run, so a Command that changes its input cannot rewrite what the trace says it received.
|
|
67
70
|
// `toTrace` never throws, which matters here, where a throw would stop the run before it started: an input it
|
|
68
71
|
// cannot record is left out and named in the trace's `unrecorded`.
|
|
69
|
-
const head = rec.toTrace({ flowName, initialInput });
|
|
72
|
+
const head = rec.toTrace({ flowName, initialInput, version });
|
|
70
73
|
return scope.run({ rec, head, contextCaptured: false }, async () => {
|
|
71
74
|
/** @type {{ result: SuccessState<any> | FailureState<any> } | { error: unknown }} */
|
|
72
75
|
let outcome;
|
package/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// Rules for writing a correct flow, and a task index: RULES.md in this package.
|
|
2
|
+
|
|
1
3
|
export type SuccessState<T> = {
|
|
2
4
|
type: 'Success';
|
|
3
5
|
value: T;
|
|
@@ -197,7 +199,7 @@ export declare function commandName(command: CommandState<any, any, any, any>):
|
|
|
197
199
|
/** Reads the context passed to `runEffect`. With no argument it succeeds with the context; name its type as `Ask<AppCtx>()`. */
|
|
198
200
|
export declare function Ask<Ctx = unknown>(): AskState<Ctx, never, Ctx>;
|
|
199
201
|
|
|
200
|
-
/** Reads the context passed to `runEffect`. Give the value, error and context types, or none
|
|
202
|
+
/** Reads the context passed to `runEffect`. Give the value, error and context types, or none and annotate `ctx`. */
|
|
201
203
|
export declare function Ask<T = never, E = never, Ctx = unknown, N extends AnyEffect = Effect<T, E, Ctx>>(
|
|
202
204
|
next: (context: Ctx) => N
|
|
203
205
|
): AskState<EffectValue<N>, EffectError<N>, Ctx & EffectContext<N>>;
|
package/index.js
CHANGED
|
@@ -312,7 +312,7 @@ const checkOptions = (options, source, rules, raise = (message) => new TypeError
|
|
|
312
312
|
const value = options[name];
|
|
313
313
|
if (rule === null || value === undefined || rule[0](value)) continue;
|
|
314
314
|
const [, takes, advice] = rule;
|
|
315
|
-
const message = `${source} '${name}' must be ${takes}, received ${
|
|
315
|
+
const message = `${source} '${name}' must be ${takes}, received ${describeOption(value)}.`;
|
|
316
316
|
throw raise(advice ? `${message} ${advice}` : message, value);
|
|
317
317
|
}
|
|
318
318
|
};
|
|
@@ -332,7 +332,11 @@ const retryOptionRules = {
|
|
|
332
332
|
"To handle an outcome without retrying, branch on it as data in the Command's next, or isolate a failing " +
|
|
333
333
|
"branch with Parallel's settled option."
|
|
334
334
|
],
|
|
335
|
-
delay: [
|
|
335
|
+
delay: [
|
|
336
|
+
isFiniteNonNegative,
|
|
337
|
+
'a finite number of 0 or more',
|
|
338
|
+
'To wait as long as a server asks, return its answer as data and wait in a Command.'
|
|
339
|
+
],
|
|
336
340
|
backoff: [isFiniteNonNegative, 'a finite number of 0 or more'],
|
|
337
341
|
onExhausted: [isFunction, 'a function that returns the fallback']
|
|
338
342
|
};
|
|
@@ -375,6 +379,14 @@ const describeValue = (value) => {
|
|
|
375
379
|
const describeArgument = (value) =>
|
|
376
380
|
value === undefined ? 'undefined' : isEffect(value) ? `an Effect of type '${value.type}'` : describeValue(value);
|
|
377
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Describes an option's value for an error message. A function there is a callback in the wrong place, not a flow
|
|
384
|
+
* missing its input.
|
|
385
|
+
* @param {any} value
|
|
386
|
+
* @returns {string}
|
|
387
|
+
*/
|
|
388
|
+
const describeOption = (value) => (typeof value === 'function' ? 'a function' : describeArgument(value));
|
|
389
|
+
|
|
378
390
|
/**
|
|
379
391
|
* Marks an error as the harness failing rather than the flow: a malformed flow, or a trace that cannot answer a
|
|
380
392
|
* step. The interpreter rethrows it rather than folding it into a `Failure`, so nothing that handles a domain
|
|
@@ -2375,7 +2387,7 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
2375
2387
|
|
|
2376
2388
|
/**
|
|
2377
2389
|
* The outcome a recorded entry hands its step. An entry the recorder could not record stops the replay rather than
|
|
2378
|
-
* run live under `onMissing`, since
|
|
2390
|
+
* run live under `onMissing`, since the recorded run ran the step.
|
|
2379
2391
|
* @param {TraceEntry} entry
|
|
2380
2392
|
* @param {ReplayStep} step
|
|
2381
2393
|
* @returns {ReplayOutcome}
|
|
@@ -2385,7 +2397,7 @@ const recordedOutcome = (entry, step) => {
|
|
|
2385
2397
|
throw unrecordedError(
|
|
2386
2398
|
`outcome for '${step.name}' at path '${step.path}'`,
|
|
2387
2399
|
entry.unrecorded,
|
|
2388
|
-
"
|
|
2400
|
+
"The recorded run ran the step, so onMissing: 'execute' does not run it again. To replay past it,",
|
|
2389
2401
|
{ command: step.name, index: step.index, path: step.path }
|
|
2390
2402
|
);
|
|
2391
2403
|
}
|
|
@@ -2540,25 +2552,28 @@ const resolveInOrder = (entries, answerFrom) => {
|
|
|
2540
2552
|
};
|
|
2541
2553
|
|
|
2542
2554
|
/**
|
|
2543
|
-
* The error for a step a replay has no
|
|
2544
|
-
* may have dropped the step or
|
|
2555
|
+
* The error for a step a replay has no outcome for, which it refuses to run live. For a trace it says whether a cap
|
|
2556
|
+
* may have dropped the step or the recorded run may never have reached it, since the fix differs. A Resolver is
|
|
2557
|
+
* usually a test's answers, so for one it names the step to answer.
|
|
2545
2558
|
* @param {ReplayStep} step
|
|
2546
2559
|
* @param {((step: ReplayStep) => string) | undefined} describe - `fromTrace`'s `missing`; a Resolver has none
|
|
2547
2560
|
* @param {number} droppedEntries - How many entries the trace dropped under `maxEntries`
|
|
2548
2561
|
* @returns {Error}
|
|
2549
2562
|
*/
|
|
2550
2563
|
const missingStepError = (step, describe, droppedEntries) => {
|
|
2551
|
-
const what = describe ? describe(step) : `
|
|
2552
|
-
const
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2564
|
+
const what = describe ? describe(step) : `The Resolver has no answer for '${step.name}' at path '${step.path}'`;
|
|
2565
|
+
const lacking = describe ? 'the trace also lacks' : 'the Resolver has no answer for either';
|
|
2566
|
+
const runsLive =
|
|
2567
|
+
`onMissing: 'execute' runs such a step for real, and every step after it that ${lacking}, so pass it ` +
|
|
2568
|
+
'only where every Command the flow can still reach goes to a test double or only reads.';
|
|
2569
|
+
const why = !describe
|
|
2570
|
+
? `Give the Resolver an answer for this step. ${runsLive}`
|
|
2571
|
+
: droppedEntries > 0
|
|
2572
|
+
? `The trace dropped ${droppedEntries} entries under maxEntries, so the recorded run may have reached ` +
|
|
2573
|
+
'this step and the recorder not kept it; record the flow with a higher maxEntries to replay past it.'
|
|
2574
|
+
: 'The recorded run may never have reached it: a Command an onBeforeCommand hook vetoed leaves no ' +
|
|
2575
|
+
'entry, and neither does a step the flow reaches now that it did not then, such as one added ' +
|
|
2576
|
+
`past the end of the recording. ${runsLive}`;
|
|
2562
2577
|
return replayError(`${what}; refusing to run the real Command. ${why}`, {
|
|
2563
2578
|
command: step.name,
|
|
2564
2579
|
index: step.index,
|
|
@@ -2691,7 +2706,7 @@ const replayContext = (traceLog, given) => {
|
|
|
2691
2706
|
};
|
|
2692
2707
|
|
|
2693
2708
|
/**
|
|
2694
|
-
* How many entries a trace dropped under `maxEntries`. A capped trace lacks steps
|
|
2709
|
+
* How many entries a trace dropped under `maxEntries`. A capped trace lacks steps the recorded run ran, so it refuses
|
|
2695
2710
|
* `onMissing: 'execute'`, which could run one of them again.
|
|
2696
2711
|
* @param {TraceLog | undefined} traceLog
|
|
2697
2712
|
* @param {'throw' | 'execute'} onMissing
|
|
@@ -2703,7 +2718,7 @@ const droppedEntriesOf = (traceLog, onMissing) => {
|
|
|
2703
2718
|
if (capped && onMissing === 'execute') {
|
|
2704
2719
|
throw replayError(
|
|
2705
2720
|
`The trace dropped ${dropped} entries under maxEntries, so a step it lacks may be one ` +
|
|
2706
|
-
"
|
|
2721
|
+
"the recorded run reached, and onMissing: 'execute' would run it again. Replay without it to stop at the " +
|
|
2707
2722
|
'first missing step, or record the flow with a higher maxEntries.'
|
|
2708
2723
|
);
|
|
2709
2724
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pure-effect",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"description": "Records what your business logic did in production and replays it anywhere: time-travel debugging for JavaScript and TypeScript, with zero dependencies. Business logic is plain data you can test without mocks.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"effect",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"files": [
|
|
21
21
|
"index.js",
|
|
22
22
|
"index.d.ts",
|
|
23
|
+
"RULES.md",
|
|
23
24
|
"CHANGELOG.md",
|
|
24
25
|
"examples"
|
|
25
26
|
],
|
|
@@ -31,8 +32,8 @@
|
|
|
31
32
|
"test": "mocha && tsd && tsc -p jsconfig.json",
|
|
32
33
|
"test:ts-minimum": "npx --yes -p typescript@5.1 tsc -p test/tsconfig.minimum.json",
|
|
33
34
|
"test:fuzz": "FUZZ_SCALE=20 mocha test/fuzz.js",
|
|
34
|
-
"generate": "node scripts/effect-pipe-overloads.js",
|
|
35
|
-
"format": "prettier --write index.js index.d.ts examples/*.js scripts/*.js test/*.js test/types.test-d.ts README.md CLAUDE.md DESIGN.md CHANGELOG.md CONTRIBUTING.md"
|
|
35
|
+
"generate": "node scripts/effect-pipe-overloads.js && node scripts/rules-file.js",
|
|
36
|
+
"format": "prettier --write index.js index.d.ts examples/*.js scripts/*.js test/*.js test/types.test-d.ts README.md CLAUDE.md DESIGN.md CHANGELOG.md CONTRIBUTING.md RULES.md"
|
|
36
37
|
},
|
|
37
38
|
"tsd": {
|
|
38
39
|
"directory": "test",
|