pure-effect 0.19.0 → 0.20.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.
Files changed (5) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +148 -119
  3. package/index.d.ts +26 -19
  4. package/index.js +119 -39
  5. package/package.json +1 -1
package/index.d.ts CHANGED
@@ -35,7 +35,7 @@ export type AskState<T, E = unknown, Ctx = unknown> = {
35
35
  // Every optional option and hook takes `undefined`, which keeps its default, under `exactOptionalPropertyTypes` too.
36
36
  export type RetryOptions = {
37
37
  /** Tries after the first, so `2` makes at most 3 calls. A positive integer, 3 by default. */
38
- attempts?: number | undefined;
38
+ retries?: number | undefined;
39
39
  /** Milliseconds before the first retry, 100 by default. */
40
40
  delay?: number | undefined;
41
41
  /** Multiplies the wait before each later retry, so `2` doubles it. 1 by default, the same wait each time. */
@@ -58,8 +58,8 @@ export type RetryState<T, E = unknown, Ctx = unknown, R = T> = {
58
58
  export type RetryExhaustedError<Thrown = unknown> = {
59
59
  retryExhausted: true;
60
60
  lastError: Thrown;
61
- /** The `attempts` option: tries after the first, so the retried steps ran one more time than this. */
62
- attempts: number;
61
+ /** The `retries` option, so the retried steps ran one more time than this. */
62
+ retries: number;
63
63
  };
64
64
 
65
65
  export type ParallelOptions = {
@@ -82,7 +82,7 @@ export type ParallelState<
82
82
  effects: { [K in keyof T]: Effect<T[K], BranchError, Ctx> };
83
83
  next(values: [...V]): Effect<R, E, Ctx>;
84
84
  /** A frozen copy of the options the `Parallel` was built with. */
85
- options?: Readonly<ParallelOptions>;
85
+ options: Readonly<ParallelOptions>;
86
86
  };
87
87
 
88
88
  export type Effect<T, E = unknown, Ctx = unknown> =
@@ -1300,7 +1300,7 @@ export declare function effectGen<I, Y extends Step<any, any>, R>(
1300
1300
  */
1301
1301
  export type StepRunner = (
1302
1302
  name: string,
1303
- type: string,
1303
+ type: 'Command' | 'Parallel',
1304
1304
  op: (decision?: ParallelDecision) => Promise<unknown>,
1305
1305
  path: string
1306
1306
  ) => Promise<unknown>;
@@ -1367,7 +1367,7 @@ export type ReplayStep = {
1367
1367
  /** `meta.name`, `cmd.name`, or 'anonymous'; 'Parallel' for a `Parallel`'s step. */
1368
1368
  name: string;
1369
1369
  /** `'Command'`, or `'Parallel'` for a `Parallel`'s decision. */
1370
- type: string;
1370
+ type: 'Command' | 'Parallel';
1371
1371
  /** The step's position in the flow, the same in every run; a Resolver should key off it. */
1372
1372
  path: string;
1373
1373
  };
@@ -1442,16 +1442,15 @@ export type RecordOptions<Ctx = unknown> = RecorderOptions & {
1442
1442
  };
1443
1443
 
1444
1444
  /**
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`.
1445
+ * Runs a flow for real and returns its outcome with a trace. The trace holds the flow's input when `effectPipe` or
1446
+ * `effectGen` built it.
1447
1447
  */
1448
- export declare function recordEffect<I, T, E = unknown, Ctx = unknown>(
1449
- flowFn: (input: I) => Effect<T, E, Ctx>,
1450
- initialInput: I,
1448
+ export declare function recordEffect<T, E = unknown, Ctx = unknown>(
1449
+ effect: Effect<T, E, Ctx>,
1451
1450
  ...options: unknown extends Ctx
1452
1451
  ? [options?: RecordOptions<CheckedContext<Ctx>>]
1453
1452
  : [options: RecordOptions<CheckedContext<Ctx>> & { context: RunContext<CheckedContext<Ctx>> }]
1454
- ): Promise<{ result: SuccessState<T> | FailureState<E>; trace: TraceLog<I, Ctx> & { initialInput: I } }>;
1453
+ ): Promise<{ result: SuccessState<T> | FailureState<E>; trace: TraceLog<unknown, Ctx> }>;
1455
1454
 
1456
1455
  export interface ReplayOptions<Ctx = unknown> {
1457
1456
  /** Context for `Ask`; defaults to the one the trace recorded. */
@@ -1498,16 +1497,24 @@ export declare function replayEffect<T, E = unknown, Ctx = unknown>(
1498
1497
  : [options: ReplayOptions<CheckedContext<Ctx>> & { context: RunContext<CheckedContext<Ctx>> }]
1499
1498
  ): Promise<Omit<Replay<T, E>, 'unreached'> & { unreached?: TraceEntry[] }>;
1500
1499
 
1501
- /** Replays a trace and logs each step with its recorded timing, warning about what will not replay as recorded. */
1500
+ export interface TimeTravelOptions<Ctx = unknown> {
1501
+ /** Receives each narrated line; `console.log` by default. */
1502
+ log?: ((...args: any[]) => void) | undefined;
1503
+ /** Context for `Ask`; defaults to the one the trace recorded. */
1504
+ context?: RunContext<Ctx> | undefined;
1505
+ /** The build replaying the trace; a trace recorded at another build gets a warning. */
1506
+ version?: string | undefined;
1507
+ }
1508
+
1509
+ /**
1510
+ * Replays a trace and logs each step with its recorded timing, warning about what will not replay as recorded. Returns
1511
+ * what `replayEffect` returns for a trace.
1512
+ */
1502
1513
  export declare function timeTravel<T, E = unknown, Ctx = unknown>(
1503
1514
  flowFn: (input: any) => Effect<T, E, Ctx>,
1504
1515
  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>>;
1516
+ options?: TimeTravelOptions<CheckedContext<Ctx>>
1517
+ ): Promise<Replay<T, E>>;
1511
1518
 
1512
1519
  // Only the declarations marked `export` are public; without this, a declaration file exports every top-level one.
1513
1520
  export {};
package/index.js CHANGED
@@ -5,13 +5,14 @@
5
5
  // 1. Types: the JSDoc shapes of the nodes a flow is made of.
6
6
  // 2. Building flows: the constructors; the checks, error messages and kinds of failure the later sections
7
7
  // share, `checkOptions` and `Retry`'s defaults among them; `effectPipe`, with the `chain` that joins its
8
- // steps, the stop listeners `chain` keeps for generator flows, and the `flowInputs` it records for `onRun`;
9
- // and `step` and `effectGen`, which build a flow from a generator function.
8
+ // steps, the stop listeners and `restarts` `chain` keeps for generator flows, and the `flowInputs` it records
9
+ // for `onRun` and `recordEffect`; and `step` and `effectGen`, which build a flow from a generator function.
10
10
  // 3. Configuration: the hook types and their defaults, `configureEffect` and its layers, and `chainHooks`, which
11
11
  // merges them.
12
12
  // 4. Running flows: the helpers `Retry`, `Parallel` and Commands run on; `interpret`, which builds a run's
13
- // `Runtime`, then `stopAt`, which tells a waiting generator flow its step stopped, `execute` and a function
14
- // for each kind of node, which each take it; and `runEffect`.
13
+ // `Runtime`, then `stopAt`, which tells a waiting generator flow its step stopped, `ownRun`, which builds a
14
+ // generator flow again for each execution after its first, `execute` and a function for each kind of node,
15
+ // which each take it; and `runEffect`.
15
16
  // 5. Recording and replay: the trace format and replay errors, copying values and errors into a trace,
16
17
  // `recorder` and `recordEffect`, then `fromTrace`, `replayEffect` and `timeTravel`, each followed by the
17
18
  // functions it hands its parts to.
@@ -47,7 +48,7 @@
47
48
  * @typedef {{
48
49
  * type: 'Retry',
49
50
  * effect: Effect,
50
- * options: Readonly<{ attempts?: number, delay?: number, backoff?: number, onExhausted?: (error: any) => Effect }>,
51
+ * options: Readonly<{ retries?: number, delay?: number, backoff?: number, onExhausted?: (error: any) => Effect }>,
51
52
  * next: (value: any) => Effect
52
53
  * }} RetryState
53
54
  */
@@ -162,12 +163,12 @@ const Ask = (...args) => {
162
163
  *
163
164
  * @param {Effect} effect - The inner Effect tree to retry
164
165
  * @param {Object} [options] - Merged over the defaults when it runs; an option set to `undefined` keeps its default.
165
- * @param {number} [options.attempts] - Retries after the first try, a positive integer
166
+ * @param {number} [options.retries] - Tries after the first, a positive integer
166
167
  * @param {number} [options.delay] - Ms before the first retry, a finite number of 0 or more
167
168
  * @param {number} [options.backoff] - Multiplier applied to the delay on each later retry, a finite number of 0 or
168
169
  * more
169
170
  * @param {(error: any) => Effect} [options.onExhausted] - Builds a fallback from `{ retryExhausted, lastError,
170
- * attempts }` once every attempt has failed. Its success feeds `next`; its failure propagates unwrapped.
171
+ * retries }` once every attempt has failed. Its success feeds `next`; its failure propagates unwrapped.
171
172
  * @returns {RetryState}
172
173
  */
173
174
  const Retry = (effect, options) => {
@@ -175,7 +176,7 @@ const Retry = (effect, options) => {
175
176
  if (typeof options === 'number') {
176
177
  const got = describeArgument(options);
177
178
  throw malformed(
178
- `Retry's options must be an object, got ${got}: write Retry(effect, { attempts: ${options} }).`,
179
+ `Retry's options must be an object, got ${got}: write Retry(effect, { retries: ${options} }).`,
179
180
  options
180
181
  );
181
182
  }
@@ -317,7 +318,7 @@ const checkOptions = (options, source, rules, raise = (message) => new TypeError
317
318
  };
318
319
 
319
320
  /** What `Retry` uses for an option left out or set to `undefined`. */
320
- const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
321
+ const defaultRetryOptions = { retries: 3, delay: 100, backoff: 1 };
321
322
 
322
323
  /**
323
324
  * Checked when the Retry is built, which keeps a frozen copy of the options, so they cannot change before it runs.
@@ -325,7 +326,7 @@ const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
325
326
  */
326
327
  const retryOptionRules = {
327
328
  // Not 0, which would make `onExhausted` a catch.
328
- attempts: [
329
+ retries: [
329
330
  isPositiveInteger,
330
331
  'a positive integer',
331
332
  "To handle an outcome without retrying, branch on it as data in the Command's next, or isolate a failing " +
@@ -510,6 +511,10 @@ const chain = (effect, fn, from, onStop) => {
510
511
  const waiting = stopListeners.get(effect);
511
512
  if (onStop) stopListeners.set(copy, [...(waiting ?? []), onStop]);
512
513
  else if (waiting) stopListeners.set(copy, waiting);
514
+ const restart = restarts.get(effect);
515
+ if (restart) {
516
+ restarts.set(copy, { built: restart.built, rebuild: () => chain(restart.rebuild(), fn, from, onStop) });
517
+ }
513
518
  return copy;
514
519
  };
515
520
 
@@ -527,7 +532,22 @@ const chain = (effect, fn, from, onStop) => {
527
532
  const stopListeners = new WeakMap();
528
533
 
529
534
  /**
530
- * Copies a flow's root, which `flowInputs` is keyed on, keeping the generator flows waiting on it.
535
+ * The body run a generator flow made as it was built, which the first execution of the flow takes over if nothing
536
+ * has used it. `claimed` is shared by every copy of the flow's root, so only one execution takes it over.
537
+ * @typedef {{ run: BodyRun, position: number, state: BodyRun['state'], claimed: boolean }} BuiltRun
538
+ */
539
+
540
+ /**
541
+ * How to build each generator flow's root again from its input, and the run it was built with. `effectGen` sets one,
542
+ * `chain` and `copyRoot` keep it on their copies, and `execute`'s `ownRun` builds the flow again for every execution
543
+ * after the first, so a `Retry` or a reused flow value runs the body afresh, as a retried `async` function does.
544
+ * @type {WeakMap<Effect, { built: BuiltRun, rebuild: () => Effect }>}
545
+ */
546
+ const restarts = new WeakMap();
547
+
548
+ /**
549
+ * Copies a flow's root, which `flowInputs` is keyed on, keeping the generator flows waiting on it and how to build it
550
+ * again.
531
551
  * @param {Effect} tree
532
552
  * @returns {Effect}
533
553
  */
@@ -535,12 +555,14 @@ const copyRoot = (tree) => {
535
555
  const root = { ...tree };
536
556
  const waiting = stopListeners.get(tree);
537
557
  if (waiting) stopListeners.set(root, waiting);
558
+ const restart = restarts.get(tree);
559
+ if (restart) restarts.set(root, { built: restart.built, rebuild: () => copyRoot(restart.rebuild()) });
538
560
  return root;
539
561
  };
540
562
 
541
563
  /**
542
- * The input each flow `effectPipe` built was called with, keyed on the flow's root, for `interpret` to hand `onRun`.
543
- * Kept off the flow, so no node or outcome carries it.
564
+ * The input each flow `effectPipe` or `effectGen` built was called with, keyed on the flow's root, for `interpret` to
565
+ * hand `onRun` and for `recordEffect` to store in its trace. Kept off the flow, so no node or outcome carries it.
544
566
  * @type {WeakMap<Effect, any>}
545
567
  */
546
568
  const flowInputs = new WeakMap();
@@ -671,8 +693,9 @@ const asyncGeneratorFunction = Object.getPrototypeOf(async function* () {});
671
693
  */
672
694
 
673
695
  /**
674
- * One run of a body: its generator, the position of the step it is waiting at, and whether it can still resume.
675
- * @typedef {{ generator: Generator<any, any, any>, position: number, state: 'suspended' | 'done' }} BodyRun
696
+ * One run of a body: its generator, the position of the step it is waiting at, whether it can still resume, and
697
+ * whether an execution took it over (`takeBuiltRun`), which only ever moves it forward.
698
+ * @typedef {{ generator: Generator<any, any, any>, position: number, state: 'suspended' | 'done', owned: boolean }} BodyRun
676
699
  */
677
700
 
678
701
  /**
@@ -681,6 +704,13 @@ const asyncGeneratorFunction = Object.getPrototypeOf(async function* () {});
681
704
  * @typedef {{ signature: string, answer: any, earlier: Trail } | null} Trail
682
705
  */
683
706
 
707
+ /**
708
+ * Where a run an execution owns keeps its earlier answers: nowhere, since running it again builds the flow again from
709
+ * its input, so a long loop holds only the answer it is on. `rerun` refuses a trail that reaches it.
710
+ * @type {NonNullable<Trail>}
711
+ */
712
+ const droppedAnswers = Object.freeze({ signature: '', answer: undefined, earlier: null });
713
+
684
714
  /**
685
715
  * @typedef {(generator: Generator<any, any, any>) => IteratorResult<any, any>} Resume
686
716
  */
@@ -704,7 +734,7 @@ const startRun = (flow) => {
704
734
  generator
705
735
  );
706
736
  }
707
- return { generator, position: 0, state: 'suspended' };
737
+ return { generator, position: 0, state: 'suspended', owned: false };
708
738
  };
709
739
 
710
740
  /**
@@ -825,7 +855,17 @@ const rerun = (flow, trail, signature) => {
825
855
  };
826
856
  /** @type {{ signature: string, answer: any }[]} */
827
857
  const answers = [];
828
- for (let cell = trail; cell; cell = cell.earlier) answers.unshift(cell);
858
+ for (let cell = trail; cell; cell = cell.earlier) {
859
+ if (cell === droppedAnswers) {
860
+ throw malformed(
861
+ `A step of a generator flow was answered again after a run had gone past it, at ${signature}, and ` +
862
+ 'that run keeps no earlier answers to run the body again with. Walk a flow before running it, ' +
863
+ 'not a step a run handed a hook.',
864
+ undefined
865
+ );
866
+ }
867
+ answers.unshift(cell);
868
+ }
829
869
  let result = resumeBody(run, (g) => g.next());
830
870
  for (const { signature: asked, answer } of answers) {
831
871
  const effect = expect(result, asked);
@@ -840,7 +880,7 @@ const rerun = (flow, trail, signature) => {
840
880
 
841
881
  /**
842
882
  * The run waiting at `position`: the live one if it is there, else a new one the earlier answers bring there, as
843
- * when a test walks a second branch or a `Retry` repeats the flow.
883
+ * when a test walks a second branch.
844
884
  * @param {GenFlow} flow
845
885
  * @param {BodyRun} run
846
886
  * @param {number} position
@@ -897,7 +937,8 @@ const advance = (flow, run, trail, resume) => {
897
937
  const continueBody = (flow, run, position, trail, signature, answer) => {
898
938
  const current = runAt(flow, run, position, trail, signature);
899
939
  current.position = position + 1;
900
- return advance(flow, current, { signature, answer, earlier: trail }, (g) => g.next(answer));
940
+ const after = current.owned ? droppedAnswers : { signature, answer, earlier: trail };
941
+ return advance(flow, current, after, (g) => g.next(answer));
901
942
  };
902
943
 
903
944
  /**
@@ -945,7 +986,8 @@ const stopBody = (flow, run, position, trail, signature, node) => {
945
986
  /**
946
987
  * Builds a flow function from a generator function, which writes `yield* step(effect)` where `async` code writes
947
988
  * `await`. Calling the flow function runs the body up to its first step, and the flow it returns is made of ordinary
948
- * nodes, so it records, replays and tests as a pipeline does.
989
+ * nodes, so it records, replays and tests as a pipeline does. The first execution of the flow takes over that run of
990
+ * the body, and every later one builds the flow again from its input, as calling an `async` function again does.
949
991
  *
950
992
  * @param {(input: any) => Generator<any, any, any>} body
951
993
  * @returns {(input: any) => Effect}
@@ -963,13 +1005,18 @@ const effectGen = (body) => {
963
1005
  body
964
1006
  );
965
1007
  }
966
- return (input) => {
1008
+ /** @param {any} input */
1009
+ const build = (input) => {
967
1010
  const flow = { body, input };
1011
+ const run = startRun(flow);
968
1012
  // Keyed on a copy, as `effectPipe`'s root is, since a body can return a shared object.
969
- const root = copyRoot(advance(flow, startRun(flow), null, (g) => g.next()));
1013
+ const root = copyRoot(advance(flow, run, null, (g) => g.next()));
970
1014
  flowInputs.set(root, input);
1015
+ const built = { run, position: run.position, state: run.state, claimed: false };
1016
+ restarts.set(root, { built, rebuild: () => build(input) });
971
1017
  return root;
972
1018
  };
1019
+ return build;
973
1020
  };
974
1021
 
975
1022
  // #endregion
@@ -978,7 +1025,7 @@ const effectGen = (body) => {
978
1025
  /**
979
1026
  * Wraps one Command, or one Parallel, whose `op` runs the branches and returns the decision, so a hook must call it.
980
1027
  * Only a replay passes `op` an argument, the recorded decision. `path` is the step's position in the Effect tree.
981
- * @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
1028
+ * @typedef {(name: string, type: 'Command' | 'Parallel', op: function, path?: string) => Promise<any>} StepRunner
982
1029
  */
983
1030
  /** @type StepRunner */
984
1031
  const defaultStepRunner = async (name, type, op) => await op();
@@ -1362,11 +1409,38 @@ const stopAt = (node, result, signal) => {
1362
1409
  * @returns {SuccessState | FailureState | IoFaultState}
1363
1410
  */
1364
1411
  const stopSkipped = (node, result, signal) => {
1412
+ // A generator flow another execution took over, or a test walked, is not this one's to stop.
1413
+ const restart = restarts.get(node);
1414
+ if (restart && !takeBuiltRun(restart.built)) return result;
1365
1415
  if (node.type === 'Retry') stopSkipped(node.effect, result, signal);
1366
1416
  if (node.type === 'Parallel') node.effects.forEach((branch) => stopSkipped(branch, result, signal));
1367
1417
  return stopAt(node, result, signal);
1368
1418
  };
1369
1419
 
1420
+ /**
1421
+ * Takes over the run a generator flow made as it was built, if nothing has: no execution took it over, and no test
1422
+ * walked it on from where it was built.
1423
+ * @param {BuiltRun} built
1424
+ * @returns {boolean} Whether the run was free
1425
+ */
1426
+ const takeBuiltRun = (built) => {
1427
+ const free = !built.claimed && built.run.position === built.position && built.run.state === built.state;
1428
+ built.claimed = true;
1429
+ if (free) built.run.owned = true;
1430
+ return free;
1431
+ };
1432
+
1433
+ /**
1434
+ * The node to run in place of `eff`: `eff` itself, unless it is a generator flow's root whose built run is taken, when
1435
+ * it is the flow built again from its input. The other end is `restarts`, which `effectGen` fills.
1436
+ * @param {Effect} eff
1437
+ * @returns {Effect}
1438
+ */
1439
+ const ownRun = (eff) => {
1440
+ const restart = restarts.get(eff);
1441
+ return restart && !takeBuiltRun(restart.built) ? restart.rebuild() : eff;
1442
+ };
1443
+
1370
1444
  /**
1371
1445
  * Walks a subtree until it reaches a Success, a Failure or an I/O fault. Every node but `Ask` runs in its
1372
1446
  * own function, which returns a Success carrying the value for the node's `next`, or what stops the subtree.
@@ -1385,6 +1459,11 @@ const execute = async (runtime, eff, signal, path = '') => {
1385
1459
  while (isPending(eff)) {
1386
1460
  // A Command already in flight cannot be stopped, but the next one never starts.
1387
1461
  if (signal?.aborted) return stopSkipped(eff, cancelledBranch(), signal);
1462
+ const own = ownRun(eff);
1463
+ if (own !== eff) {
1464
+ eff = own;
1465
+ continue;
1466
+ }
1388
1467
  if (eff.type === 'Ask') {
1389
1468
  from = eff;
1390
1469
  eff = eff.next(runtime.context);
@@ -1423,9 +1502,9 @@ const runRetry = async (runtime, retry, signal, stepPath) => {
1423
1502
  ...defaultRetryOptions,
1424
1503
  ...Object.fromEntries(given)
1425
1504
  });
1426
- const { attempts, onExhausted } = opts;
1505
+ const { retries, onExhausted } = opts;
1427
1506
  let lastError;
1428
- for (let attempt = 0; attempt <= attempts; attempt++) {
1507
+ for (let attempt = 0; attempt <= retries; attempt++) {
1429
1508
  // `fastRetry` waits for no time rather than skipping the wait, so branches replayed by timing still
1430
1509
  // interleave.
1431
1510
  if (attempt > 0) {
@@ -1438,7 +1517,7 @@ const runRetry = async (runtime, retry, signal, stepPath) => {
1438
1517
  if (result.type !== 'IoFault') return result;
1439
1518
  lastError = result.error;
1440
1519
  }
1441
- const exhausted = { retryExhausted: true, lastError, attempts };
1520
+ const exhausted = { retryExhausted: true, lastError, retries };
1442
1521
  // A fault too, so an enclosing Retry retries this one.
1443
1522
  if (typeof onExhausted !== 'function') return IoFault(exhausted);
1444
1523
  if (signal?.aborted) return cancelledBranch();
@@ -1748,7 +1827,7 @@ const runEffect = (effect, context, callConfig) => interpret(effect, context, ca
1748
1827
  * position in completion order, which is not stable for a flow with a `Parallel`. A step whose `type` is 'Parallel'
1749
1828
  * asks for the recorded decision and does not advance `index`; an answer that holds no decision replays that
1750
1829
  * Parallel by timing.
1751
- * @typedef {{ index: number, name: string, type: string, path?: string }} ReplayStep
1830
+ * @typedef {{ index: number, name: string, type: 'Command' | 'Parallel', path?: string }} ReplayStep
1752
1831
  */
1753
1832
 
1754
1833
  /**
@@ -2123,24 +2202,26 @@ const packTrace = (settings, meta, entries, dropped) => {
2123
2202
  };
2124
2203
 
2125
2204
  /**
2126
- * Runs a flow for real while recording it, and returns the outcome and a replayable trace. Convenient in tests and
2127
- * scripts; to record an application, give each run its own recorder, as examples/recording-example.js does.
2205
+ * Runs a flow for real while recording it, and returns the outcome and a replayable trace. The trace holds the input
2206
+ * of a flow `effectPipe` or `effectGen` built, read from `flowInputs`. Convenient in tests and scripts; to record an
2207
+ * application, give each run its own recorder, as examples/recording-example.js does.
2128
2208
  *
2129
- * @param {(input: any) => Effect} flowFn - Builds the Effect tree from its input
2130
- * @param {any} initialInput - The value the flow is called with; stored so a replay can rebuild it
2209
+ * @param {Effect} effect - The flow, called with its input
2131
2210
  * @param {RecorderOptions & { context?: any, version?: string }} [options] - Recorder options, plus the
2132
2211
  * `context` given to `runEffect` (stored so `Ask` can be replayed) and a build id
2133
2212
  * @returns {Promise<{ result: SuccessState | FailureState, trace: TraceLog }>}
2134
2213
  */
2135
- const recordEffect = async (flowFn, initialInput, options = {}) => {
2214
+ const recordEffect = async (effect, options = {}) => {
2215
+ // Before the options, so a call written the old way, with the flow function and its input, is named as that.
2216
+ asEffect(effect, 'The flow');
2136
2217
  checkOptions(options, 'recordEffect', { context: null, version: null, ...recorderOptionRules });
2137
- checkFlowFn(flowFn, 'recordEffect');
2138
2218
  const { context = {}, version, ...recorderOptions } = options ?? {};
2139
2219
  const rec = recorder(recorderOptions);
2140
2220
  // Before the run, so a Command that writes to the input or the context cannot rewrite what the trace received.
2221
+ const initialInput = flowInputs.get(effect);
2141
2222
  const head = rec.toTrace({ initialInput, flowName: context?.flowName, context, version });
2142
2223
  // Merged over the global wiring, so recording inside an instrumented application keeps its spans.
2143
- const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
2224
+ const result = await runEffect(effect, context, { onStep: rec.onStep });
2144
2225
  const { dropped, trace } = rec.toTrace();
2145
2226
  return { result, trace: { ...head, dropped, trace } };
2146
2227
  };
@@ -2719,8 +2800,7 @@ const replayHook = ({ resolve, missing, onResolved, onMissing, droppedEntries })
2719
2800
  * @param {(...args: any[]) => void} [options.log] - Defaults to `console.log`
2720
2801
  * @param {any} [options.context] - Overrides the context stored on the trace
2721
2802
  * @param {string} [options.version] - Current build id; warns when it differs from the trace's
2722
- * @returns {Promise<SuccessState | FailureState>} The flow's outcome. For the unreached entries as data, use
2723
- * `replayEffect`.
2803
+ * @returns {Promise<Replay>} `{ result, unreached }`, as `replayEffect` returns for a trace
2724
2804
  */
2725
2805
  const timeTravel = async (flowFn, traceLog, options = {}) => {
2726
2806
  checkOptions(options, 'timeTravel', { log: [isFunction, 'a function'], context: null, version: null });
@@ -2757,7 +2837,7 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
2757
2837
  log(result.type === 'Failure' ? `Error: ${formatForLog(result.error)}` : `Result: ${formatForLog(result.value)}`);
2758
2838
  const warning = unreachedWarning(result, unreached);
2759
2839
  if (warning !== undefined) log(warning);
2760
- return result;
2840
+ return { result, unreached };
2761
2841
  };
2762
2842
 
2763
2843
  /**
@@ -2775,8 +2855,8 @@ const traceWarnings = (traceLog, trace, version) => {
2775
2855
  }
2776
2856
  if (traceLog.initialInput === undefined) {
2777
2857
  warnings.push(
2778
- 'Warning: the trace holds no initial input, so the flow is rebuilt from undefined. A recorder ' +
2779
- 'installed as a hook finds the input only on a flow built with effectPipe.'
2858
+ 'Warning: the trace holds no initial input, so the flow is rebuilt from undefined. A trace holds ' +
2859
+ 'the input only of a flow built with effectPipe or effectGen.'
2780
2860
  );
2781
2861
  }
2782
2862
  const anonymous = trace.filter((e) => e.command === 'anonymous').length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pure-effect",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
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",