pure-effect 0.19.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/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 {(value: any, name: string, kind: string) => any} [redact] - Scrubs every value before it enters the
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;
@@ -35,7 +37,7 @@ export type AskState<T, E = unknown, Ctx = unknown> = {
35
37
  // Every optional option and hook takes `undefined`, which keeps its default, under `exactOptionalPropertyTypes` too.
36
38
  export type RetryOptions = {
37
39
  /** Tries after the first, so `2` makes at most 3 calls. A positive integer, 3 by default. */
38
- attempts?: number | undefined;
40
+ retries?: number | undefined;
39
41
  /** Milliseconds before the first retry, 100 by default. */
40
42
  delay?: number | undefined;
41
43
  /** Multiplies the wait before each later retry, so `2` doubles it. 1 by default, the same wait each time. */
@@ -58,8 +60,8 @@ export type RetryState<T, E = unknown, Ctx = unknown, R = T> = {
58
60
  export type RetryExhaustedError<Thrown = unknown> = {
59
61
  retryExhausted: true;
60
62
  lastError: Thrown;
61
- /** The `attempts` option: tries after the first, so the retried steps ran one more time than this. */
62
- attempts: number;
63
+ /** The `retries` option, so the retried steps ran one more time than this. */
64
+ retries: number;
63
65
  };
64
66
 
65
67
  export type ParallelOptions = {
@@ -82,7 +84,7 @@ export type ParallelState<
82
84
  effects: { [K in keyof T]: Effect<T[K], BranchError, Ctx> };
83
85
  next(values: [...V]): Effect<R, E, Ctx>;
84
86
  /** A frozen copy of the options the `Parallel` was built with. */
85
- options?: Readonly<ParallelOptions>;
87
+ options: Readonly<ParallelOptions>;
86
88
  };
87
89
 
88
90
  export type Effect<T, E = unknown, Ctx = unknown> =
@@ -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>>;
@@ -1300,7 +1302,7 @@ export declare function effectGen<I, Y extends Step<any, any>, R>(
1300
1302
  */
1301
1303
  export type StepRunner = (
1302
1304
  name: string,
1303
- type: string,
1305
+ type: 'Command' | 'Parallel',
1304
1306
  op: (decision?: ParallelDecision) => Promise<unknown>,
1305
1307
  path: string
1306
1308
  ) => Promise<unknown>;
@@ -1367,7 +1369,7 @@ export type ReplayStep = {
1367
1369
  /** `meta.name`, `cmd.name`, or 'anonymous'; 'Parallel' for a `Parallel`'s step. */
1368
1370
  name: string;
1369
1371
  /** `'Command'`, or `'Parallel'` for a `Parallel`'s decision. */
1370
- type: string;
1372
+ type: 'Command' | 'Parallel';
1371
1373
  /** The step's position in the flow, the same in every run; a Resolver should key off it. */
1372
1374
  path: string;
1373
1375
  };
@@ -1442,16 +1444,15 @@ export type RecordOptions<Ctx = unknown> = RecorderOptions & {
1442
1444
  };
1443
1445
 
1444
1446
  /**
1445
- * Runs a flow for real and returns its outcome with a trace, which keeps the input's type for the replay. That type is
1446
- * wrong when `redact` changes the input, or the trace marks it `unrecorded`.
1447
+ * Runs a flow for real and returns its outcome with a trace. The trace holds the flow's input when `effectPipe` or
1448
+ * `effectGen` built it.
1447
1449
  */
1448
- export declare function recordEffect<I, T, E = unknown, Ctx = unknown>(
1449
- flowFn: (input: I) => Effect<T, E, Ctx>,
1450
- initialInput: I,
1450
+ export declare function recordEffect<T, E = unknown, Ctx = unknown>(
1451
+ effect: Effect<T, E, Ctx>,
1451
1452
  ...options: unknown extends Ctx
1452
1453
  ? [options?: RecordOptions<CheckedContext<Ctx>>]
1453
1454
  : [options: RecordOptions<CheckedContext<Ctx>> & { context: RunContext<CheckedContext<Ctx>> }]
1454
- ): Promise<{ result: SuccessState<T> | FailureState<E>; trace: TraceLog<I, Ctx> & { initialInput: I } }>;
1455
+ ): Promise<{ result: SuccessState<T> | FailureState<E>; trace: TraceLog<unknown, Ctx> }>;
1455
1456
 
1456
1457
  export interface ReplayOptions<Ctx = unknown> {
1457
1458
  /** Context for `Ask`; defaults to the one the trace recorded. */
@@ -1498,16 +1499,24 @@ export declare function replayEffect<T, E = unknown, Ctx = unknown>(
1498
1499
  : [options: ReplayOptions<CheckedContext<Ctx>> & { context: RunContext<CheckedContext<Ctx>> }]
1499
1500
  ): Promise<Omit<Replay<T, E>, 'unreached'> & { unreached?: TraceEntry[] }>;
1500
1501
 
1501
- /** Replays a trace and logs each step with its recorded timing, warning about what will not replay as recorded. */
1502
+ export interface TimeTravelOptions<Ctx = unknown> {
1503
+ /** Receives each narrated line; `console.log` by default. */
1504
+ log?: ((...args: any[]) => void) | undefined;
1505
+ /** Context for `Ask`; defaults to the one the trace recorded. */
1506
+ context?: RunContext<Ctx> | undefined;
1507
+ /** The build replaying the trace; a trace recorded at another build gets a warning. */
1508
+ version?: string | undefined;
1509
+ }
1510
+
1511
+ /**
1512
+ * Replays a trace and logs each step with its recorded timing, warning about what will not replay as recorded. Returns
1513
+ * what `replayEffect` returns for a trace.
1514
+ */
1502
1515
  export declare function timeTravel<T, E = unknown, Ctx = unknown>(
1503
1516
  flowFn: (input: any) => Effect<T, E, Ctx>,
1504
1517
  traceLog: TraceLog,
1505
- options?: {
1506
- log?: ((...args: any[]) => void) | undefined;
1507
- context?: RunContext<CheckedContext<Ctx>> | undefined;
1508
- version?: string | undefined;
1509
- }
1510
- ): Promise<SuccessState<T> | FailureState<E>>;
1518
+ options?: TimeTravelOptions<CheckedContext<Ctx>>
1519
+ ): Promise<Replay<T, E>>;
1511
1520
 
1512
1521
  // Only the declarations marked `export` are public; without this, a declaration file exports every top-level one.
1513
1522
  export {};