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/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
  }
@@ -311,13 +312,13 @@ const checkOptions = (options, source, rules, raise = (message) => new TypeError
311
312
  const value = options[name];
312
313
  if (rule === null || value === undefined || rule[0](value)) continue;
313
314
  const [, takes, advice] = rule;
314
- const message = `${source} '${name}' must be ${takes}, received ${describeArgument(value)}.`;
315
+ const message = `${source} '${name}' must be ${takes}, received ${describeOption(value)}.`;
315
316
  throw raise(advice ? `${message} ${advice}` : message, value);
316
317
  }
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,13 +326,17 @@ 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 " +
332
333
  "branch with Parallel's settled option."
333
334
  ],
334
- delay: [isFiniteNonNegative, 'a finite number of 0 or more'],
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
+ ],
335
340
  backoff: [isFiniteNonNegative, 'a finite number of 0 or more'],
336
341
  onExhausted: [isFunction, 'a function that returns the fallback']
337
342
  };
@@ -374,6 +379,14 @@ const describeValue = (value) => {
374
379
  const describeArgument = (value) =>
375
380
  value === undefined ? 'undefined' : isEffect(value) ? `an Effect of type '${value.type}'` : describeValue(value);
376
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
+
377
390
  /**
378
391
  * Marks an error as the harness failing rather than the flow: a malformed flow, or a trace that cannot answer a
379
392
  * step. The interpreter rethrows it rather than folding it into a `Failure`, so nothing that handles a domain
@@ -510,6 +523,10 @@ const chain = (effect, fn, from, onStop) => {
510
523
  const waiting = stopListeners.get(effect);
511
524
  if (onStop) stopListeners.set(copy, [...(waiting ?? []), onStop]);
512
525
  else if (waiting) stopListeners.set(copy, waiting);
526
+ const restart = restarts.get(effect);
527
+ if (restart) {
528
+ restarts.set(copy, { built: restart.built, rebuild: () => chain(restart.rebuild(), fn, from, onStop) });
529
+ }
513
530
  return copy;
514
531
  };
515
532
 
@@ -527,7 +544,22 @@ const chain = (effect, fn, from, onStop) => {
527
544
  const stopListeners = new WeakMap();
528
545
 
529
546
  /**
530
- * Copies a flow's root, which `flowInputs` is keyed on, keeping the generator flows waiting on it.
547
+ * The body run a generator flow made as it was built, which the first execution of the flow takes over if nothing
548
+ * has used it. `claimed` is shared by every copy of the flow's root, so only one execution takes it over.
549
+ * @typedef {{ run: BodyRun, position: number, state: BodyRun['state'], claimed: boolean }} BuiltRun
550
+ */
551
+
552
+ /**
553
+ * How to build each generator flow's root again from its input, and the run it was built with. `effectGen` sets one,
554
+ * `chain` and `copyRoot` keep it on their copies, and `execute`'s `ownRun` builds the flow again for every execution
555
+ * after the first, so a `Retry` or a reused flow value runs the body afresh, as a retried `async` function does.
556
+ * @type {WeakMap<Effect, { built: BuiltRun, rebuild: () => Effect }>}
557
+ */
558
+ const restarts = new WeakMap();
559
+
560
+ /**
561
+ * Copies a flow's root, which `flowInputs` is keyed on, keeping the generator flows waiting on it and how to build it
562
+ * again.
531
563
  * @param {Effect} tree
532
564
  * @returns {Effect}
533
565
  */
@@ -535,12 +567,14 @@ const copyRoot = (tree) => {
535
567
  const root = { ...tree };
536
568
  const waiting = stopListeners.get(tree);
537
569
  if (waiting) stopListeners.set(root, waiting);
570
+ const restart = restarts.get(tree);
571
+ if (restart) restarts.set(root, { built: restart.built, rebuild: () => copyRoot(restart.rebuild()) });
538
572
  return root;
539
573
  };
540
574
 
541
575
  /**
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.
576
+ * The input each flow `effectPipe` or `effectGen` built was called with, keyed on the flow's root, for `interpret` to
577
+ * hand `onRun` and for `recordEffect` to store in its trace. Kept off the flow, so no node or outcome carries it.
544
578
  * @type {WeakMap<Effect, any>}
545
579
  */
546
580
  const flowInputs = new WeakMap();
@@ -671,8 +705,9 @@ const asyncGeneratorFunction = Object.getPrototypeOf(async function* () {});
671
705
  */
672
706
 
673
707
  /**
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
708
+ * One run of a body: its generator, the position of the step it is waiting at, whether it can still resume, and
709
+ * whether an execution took it over (`takeBuiltRun`), which only ever moves it forward.
710
+ * @typedef {{ generator: Generator<any, any, any>, position: number, state: 'suspended' | 'done', owned: boolean }} BodyRun
676
711
  */
677
712
 
678
713
  /**
@@ -681,6 +716,13 @@ const asyncGeneratorFunction = Object.getPrototypeOf(async function* () {});
681
716
  * @typedef {{ signature: string, answer: any, earlier: Trail } | null} Trail
682
717
  */
683
718
 
719
+ /**
720
+ * Where a run an execution owns keeps its earlier answers: nowhere, since running it again builds the flow again from
721
+ * its input, so a long loop holds only the answer it is on. `rerun` refuses a trail that reaches it.
722
+ * @type {NonNullable<Trail>}
723
+ */
724
+ const droppedAnswers = Object.freeze({ signature: '', answer: undefined, earlier: null });
725
+
684
726
  /**
685
727
  * @typedef {(generator: Generator<any, any, any>) => IteratorResult<any, any>} Resume
686
728
  */
@@ -704,7 +746,7 @@ const startRun = (flow) => {
704
746
  generator
705
747
  );
706
748
  }
707
- return { generator, position: 0, state: 'suspended' };
749
+ return { generator, position: 0, state: 'suspended', owned: false };
708
750
  };
709
751
 
710
752
  /**
@@ -825,7 +867,17 @@ const rerun = (flow, trail, signature) => {
825
867
  };
826
868
  /** @type {{ signature: string, answer: any }[]} */
827
869
  const answers = [];
828
- for (let cell = trail; cell; cell = cell.earlier) answers.unshift(cell);
870
+ for (let cell = trail; cell; cell = cell.earlier) {
871
+ if (cell === droppedAnswers) {
872
+ throw malformed(
873
+ `A step of a generator flow was answered again after a run had gone past it, at ${signature}, and ` +
874
+ 'that run keeps no earlier answers to run the body again with. Walk a flow before running it, ' +
875
+ 'not a step a run handed a hook.',
876
+ undefined
877
+ );
878
+ }
879
+ answers.unshift(cell);
880
+ }
829
881
  let result = resumeBody(run, (g) => g.next());
830
882
  for (const { signature: asked, answer } of answers) {
831
883
  const effect = expect(result, asked);
@@ -840,7 +892,7 @@ const rerun = (flow, trail, signature) => {
840
892
 
841
893
  /**
842
894
  * 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.
895
+ * when a test walks a second branch.
844
896
  * @param {GenFlow} flow
845
897
  * @param {BodyRun} run
846
898
  * @param {number} position
@@ -897,7 +949,8 @@ const advance = (flow, run, trail, resume) => {
897
949
  const continueBody = (flow, run, position, trail, signature, answer) => {
898
950
  const current = runAt(flow, run, position, trail, signature);
899
951
  current.position = position + 1;
900
- return advance(flow, current, { signature, answer, earlier: trail }, (g) => g.next(answer));
952
+ const after = current.owned ? droppedAnswers : { signature, answer, earlier: trail };
953
+ return advance(flow, current, after, (g) => g.next(answer));
901
954
  };
902
955
 
903
956
  /**
@@ -945,7 +998,8 @@ const stopBody = (flow, run, position, trail, signature, node) => {
945
998
  /**
946
999
  * Builds a flow function from a generator function, which writes `yield* step(effect)` where `async` code writes
947
1000
  * `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.
1001
+ * nodes, so it records, replays and tests as a pipeline does. The first execution of the flow takes over that run of
1002
+ * the body, and every later one builds the flow again from its input, as calling an `async` function again does.
949
1003
  *
950
1004
  * @param {(input: any) => Generator<any, any, any>} body
951
1005
  * @returns {(input: any) => Effect}
@@ -963,13 +1017,18 @@ const effectGen = (body) => {
963
1017
  body
964
1018
  );
965
1019
  }
966
- return (input) => {
1020
+ /** @param {any} input */
1021
+ const build = (input) => {
967
1022
  const flow = { body, input };
1023
+ const run = startRun(flow);
968
1024
  // 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()));
1025
+ const root = copyRoot(advance(flow, run, null, (g) => g.next()));
970
1026
  flowInputs.set(root, input);
1027
+ const built = { run, position: run.position, state: run.state, claimed: false };
1028
+ restarts.set(root, { built, rebuild: () => build(input) });
971
1029
  return root;
972
1030
  };
1031
+ return build;
973
1032
  };
974
1033
 
975
1034
  // #endregion
@@ -978,7 +1037,7 @@ const effectGen = (body) => {
978
1037
  /**
979
1038
  * Wraps one Command, or one Parallel, whose `op` runs the branches and returns the decision, so a hook must call it.
980
1039
  * 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
1040
+ * @typedef {(name: string, type: 'Command' | 'Parallel', op: function, path?: string) => Promise<any>} StepRunner
982
1041
  */
983
1042
  /** @type StepRunner */
984
1043
  const defaultStepRunner = async (name, type, op) => await op();
@@ -1362,11 +1421,38 @@ const stopAt = (node, result, signal) => {
1362
1421
  * @returns {SuccessState | FailureState | IoFaultState}
1363
1422
  */
1364
1423
  const stopSkipped = (node, result, signal) => {
1424
+ // A generator flow another execution took over, or a test walked, is not this one's to stop.
1425
+ const restart = restarts.get(node);
1426
+ if (restart && !takeBuiltRun(restart.built)) return result;
1365
1427
  if (node.type === 'Retry') stopSkipped(node.effect, result, signal);
1366
1428
  if (node.type === 'Parallel') node.effects.forEach((branch) => stopSkipped(branch, result, signal));
1367
1429
  return stopAt(node, result, signal);
1368
1430
  };
1369
1431
 
1432
+ /**
1433
+ * Takes over the run a generator flow made as it was built, if nothing has: no execution took it over, and no test
1434
+ * walked it on from where it was built.
1435
+ * @param {BuiltRun} built
1436
+ * @returns {boolean} Whether the run was free
1437
+ */
1438
+ const takeBuiltRun = (built) => {
1439
+ const free = !built.claimed && built.run.position === built.position && built.run.state === built.state;
1440
+ built.claimed = true;
1441
+ if (free) built.run.owned = true;
1442
+ return free;
1443
+ };
1444
+
1445
+ /**
1446
+ * The node to run in place of `eff`: `eff` itself, unless it is a generator flow's root whose built run is taken, when
1447
+ * it is the flow built again from its input. The other end is `restarts`, which `effectGen` fills.
1448
+ * @param {Effect} eff
1449
+ * @returns {Effect}
1450
+ */
1451
+ const ownRun = (eff) => {
1452
+ const restart = restarts.get(eff);
1453
+ return restart && !takeBuiltRun(restart.built) ? restart.rebuild() : eff;
1454
+ };
1455
+
1370
1456
  /**
1371
1457
  * Walks a subtree until it reaches a Success, a Failure or an I/O fault. Every node but `Ask` runs in its
1372
1458
  * own function, which returns a Success carrying the value for the node's `next`, or what stops the subtree.
@@ -1385,6 +1471,11 @@ const execute = async (runtime, eff, signal, path = '') => {
1385
1471
  while (isPending(eff)) {
1386
1472
  // A Command already in flight cannot be stopped, but the next one never starts.
1387
1473
  if (signal?.aborted) return stopSkipped(eff, cancelledBranch(), signal);
1474
+ const own = ownRun(eff);
1475
+ if (own !== eff) {
1476
+ eff = own;
1477
+ continue;
1478
+ }
1388
1479
  if (eff.type === 'Ask') {
1389
1480
  from = eff;
1390
1481
  eff = eff.next(runtime.context);
@@ -1423,9 +1514,9 @@ const runRetry = async (runtime, retry, signal, stepPath) => {
1423
1514
  ...defaultRetryOptions,
1424
1515
  ...Object.fromEntries(given)
1425
1516
  });
1426
- const { attempts, onExhausted } = opts;
1517
+ const { retries, onExhausted } = opts;
1427
1518
  let lastError;
1428
- for (let attempt = 0; attempt <= attempts; attempt++) {
1519
+ for (let attempt = 0; attempt <= retries; attempt++) {
1429
1520
  // `fastRetry` waits for no time rather than skipping the wait, so branches replayed by timing still
1430
1521
  // interleave.
1431
1522
  if (attempt > 0) {
@@ -1438,7 +1529,7 @@ const runRetry = async (runtime, retry, signal, stepPath) => {
1438
1529
  if (result.type !== 'IoFault') return result;
1439
1530
  lastError = result.error;
1440
1531
  }
1441
- const exhausted = { retryExhausted: true, lastError, attempts };
1532
+ const exhausted = { retryExhausted: true, lastError, retries };
1442
1533
  // A fault too, so an enclosing Retry retries this one.
1443
1534
  if (typeof onExhausted !== 'function') return IoFault(exhausted);
1444
1535
  if (signal?.aborted) return cancelledBranch();
@@ -1748,7 +1839,7 @@ const runEffect = (effect, context, callConfig) => interpret(effect, context, ca
1748
1839
  * position in completion order, which is not stable for a flow with a `Parallel`. A step whose `type` is 'Parallel'
1749
1840
  * asks for the recorded decision and does not advance `index`; an answer that holds no decision replays that
1750
1841
  * Parallel by timing.
1751
- * @typedef {{ index: number, name: string, type: string, path?: string }} ReplayStep
1842
+ * @typedef {{ index: number, name: string, type: 'Command' | 'Parallel', path?: string }} ReplayStep
1752
1843
  */
1753
1844
 
1754
1845
  /**
@@ -2123,24 +2214,26 @@ const packTrace = (settings, meta, entries, dropped) => {
2123
2214
  };
2124
2215
 
2125
2216
  /**
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.
2217
+ * Runs a flow for real while recording it, and returns the outcome and a replayable trace. The trace holds the input
2218
+ * of a flow `effectPipe` or `effectGen` built, read from `flowInputs`. Convenient in tests and scripts; to record an
2219
+ * application, give each run its own recorder, as examples/recording-example.js does.
2128
2220
  *
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
2221
+ * @param {Effect} effect - The flow, called with its input
2131
2222
  * @param {RecorderOptions & { context?: any, version?: string }} [options] - Recorder options, plus the
2132
2223
  * `context` given to `runEffect` (stored so `Ask` can be replayed) and a build id
2133
2224
  * @returns {Promise<{ result: SuccessState | FailureState, trace: TraceLog }>}
2134
2225
  */
2135
- const recordEffect = async (flowFn, initialInput, options = {}) => {
2226
+ const recordEffect = async (effect, options = {}) => {
2227
+ // Before the options, so a call written the old way, with the flow function and its input, is named as that.
2228
+ asEffect(effect, 'The flow');
2136
2229
  checkOptions(options, 'recordEffect', { context: null, version: null, ...recorderOptionRules });
2137
- checkFlowFn(flowFn, 'recordEffect');
2138
2230
  const { context = {}, version, ...recorderOptions } = options ?? {};
2139
2231
  const rec = recorder(recorderOptions);
2140
2232
  // Before the run, so a Command that writes to the input or the context cannot rewrite what the trace received.
2233
+ const initialInput = flowInputs.get(effect);
2141
2234
  const head = rec.toTrace({ initialInput, flowName: context?.flowName, context, version });
2142
2235
  // 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 });
2236
+ const result = await runEffect(effect, context, { onStep: rec.onStep });
2144
2237
  const { dropped, trace } = rec.toTrace();
2145
2238
  return { result, trace: { ...head, dropped, trace } };
2146
2239
  };
@@ -2294,7 +2387,7 @@ const fromTrace = (traceLog, options = {}) => {
2294
2387
 
2295
2388
  /**
2296
2389
  * The outcome a recorded entry hands its step. An entry the recorder could not record stops the replay rather than
2297
- * run live under `onMissing`, since production ran the step.
2390
+ * run live under `onMissing`, since the recorded run ran the step.
2298
2391
  * @param {TraceEntry} entry
2299
2392
  * @param {ReplayStep} step
2300
2393
  * @returns {ReplayOutcome}
@@ -2304,7 +2397,7 @@ const recordedOutcome = (entry, step) => {
2304
2397
  throw unrecordedError(
2305
2398
  `outcome for '${step.name}' at path '${step.path}'`,
2306
2399
  entry.unrecorded,
2307
- "Production ran the step, so onMissing: 'execute' does not run it either. To replay past it,",
2400
+ "The recorded run ran the step, so onMissing: 'execute' does not run it again. To replay past it,",
2308
2401
  { command: step.name, index: step.index, path: step.path }
2309
2402
  );
2310
2403
  }
@@ -2459,25 +2552,28 @@ const resolveInOrder = (entries, answerFrom) => {
2459
2552
  };
2460
2553
 
2461
2554
  /**
2462
- * The error for a step a replay has no recorded outcome for, which it refuses to run live. It says whether a cap
2463
- * may have dropped the step or production may never have run it, since the fix differs.
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.
2464
2558
  * @param {ReplayStep} step
2465
2559
  * @param {((step: ReplayStep) => string) | undefined} describe - `fromTrace`'s `missing`; a Resolver has none
2466
2560
  * @param {number} droppedEntries - How many entries the trace dropped under `maxEntries`
2467
2561
  * @returns {Error}
2468
2562
  */
2469
2563
  const missingStepError = (step, describe, droppedEntries) => {
2470
- const what = describe ? describe(step) : `No recorded outcome for '${step.name}' at step ${step.index}`;
2471
- const why =
2472
- droppedEntries > 0
2473
- ? `The trace dropped ${droppedEntries} entries under maxEntries, so production may have run ` +
2474
- 'this step and the recorder not kept it; record the flow with a higher maxEntries to ' +
2475
- 'replay past it.'
2476
- : 'Production may never have run it: a Command an onBeforeCommand hook vetoed leaves no ' +
2477
- 'entry, and neither does a step the flow reaches now that it did not then, such as one added ' +
2478
- "past the end of the recording. onMissing: 'execute' runs such a step for real, and every step " +
2479
- 'after it that the trace also lacks, so pass it only where every Command the flow can still ' +
2480
- 'reach goes to a test double or only reads.';
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}`;
2481
2577
  return replayError(`${what}; refusing to run the real Command. ${why}`, {
2482
2578
  command: step.name,
2483
2579
  index: step.index,
@@ -2610,7 +2706,7 @@ const replayContext = (traceLog, given) => {
2610
2706
  };
2611
2707
 
2612
2708
  /**
2613
- * How many entries a trace dropped under `maxEntries`. A capped trace lacks steps production ran, so it refuses
2709
+ * How many entries a trace dropped under `maxEntries`. A capped trace lacks steps the recorded run ran, so it refuses
2614
2710
  * `onMissing: 'execute'`, which could run one of them again.
2615
2711
  * @param {TraceLog | undefined} traceLog
2616
2712
  * @param {'throw' | 'execute'} onMissing
@@ -2622,7 +2718,7 @@ const droppedEntriesOf = (traceLog, onMissing) => {
2622
2718
  if (capped && onMissing === 'execute') {
2623
2719
  throw replayError(
2624
2720
  `The trace dropped ${dropped} entries under maxEntries, so a step it lacks may be one ` +
2625
- "production ran, and onMissing: 'execute' would run it again. Replay without it to stop at the " +
2721
+ "the recorded run reached, and onMissing: 'execute' would run it again. Replay without it to stop at the " +
2626
2722
  'first missing step, or record the flow with a higher maxEntries.'
2627
2723
  );
2628
2724
  }
@@ -2719,8 +2815,7 @@ const replayHook = ({ resolve, missing, onResolved, onMissing, droppedEntries })
2719
2815
  * @param {(...args: any[]) => void} [options.log] - Defaults to `console.log`
2720
2816
  * @param {any} [options.context] - Overrides the context stored on the trace
2721
2817
  * @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`.
2818
+ * @returns {Promise<Replay>} `{ result, unreached }`, as `replayEffect` returns for a trace
2724
2819
  */
2725
2820
  const timeTravel = async (flowFn, traceLog, options = {}) => {
2726
2821
  checkOptions(options, 'timeTravel', { log: [isFunction, 'a function'], context: null, version: null });
@@ -2757,7 +2852,7 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
2757
2852
  log(result.type === 'Failure' ? `Error: ${formatForLog(result.error)}` : `Result: ${formatForLog(result.value)}`);
2758
2853
  const warning = unreachedWarning(result, unreached);
2759
2854
  if (warning !== undefined) log(warning);
2760
- return result;
2855
+ return { result, unreached };
2761
2856
  };
2762
2857
 
2763
2858
  /**
@@ -2775,8 +2870,8 @@ const traceWarnings = (traceLog, trace, version) => {
2775
2870
  }
2776
2871
  if (traceLog.initialInput === undefined) {
2777
2872
  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.'
2873
+ 'Warning: the trace holds no initial input, so the flow is rebuilt from undefined. A trace holds ' +
2874
+ 'the input only of a flow built with effectPipe or effectGen.'
2780
2875
  );
2781
2876
  }
2782
2877
  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.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",