pure-effect 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -111,11 +111,15 @@ const Command = (cmd, next, meta) => {
111
111
  /**
112
112
  * The name a Command is known by: what a trace records, what replay matches on, and what a
113
113
  * telemetry span is called. A non-empty string `meta.name`, else `cmd.name`, else 'anonymous'.
114
+ * Exported so a test walking a flow checks each step by the same rule.
114
115
  *
115
116
  * @param {CommandState} eff
116
117
  * @returns {string}
117
118
  */
118
119
  const commandName = (eff) => {
120
+ if (eff?.type !== 'Command') {
121
+ throw malformed(`commandName expects a Command, got ${describeArgument(eff)}.`, eff);
122
+ }
119
123
  const meta = eff.meta;
120
124
  const named = isObject(meta) ? meta.name : undefined;
121
125
  return typeof named === 'string' && named !== '' ? named : eff.cmd.name || 'anonymous';
@@ -198,11 +202,22 @@ const Parallel = (effects, nextOrOptions, maybeOptions) => {
198
202
  const hasNext = typeof nextOrOptions === 'function';
199
203
  // A caller forwarding an absent `next` still passes its options third, as the signature reads.
200
204
  const nextSkipped = nextOrOptions == null;
201
- if (!hasNext && !nextSkipped && !isOptionsObject(nextOrOptions)) {
205
+ const optionsSecond = !hasNext && !nextSkipped;
206
+ if (optionsSecond && !isOptionsObject(nextOrOptions)) {
202
207
  const got = describeArgument(nextOrOptions);
203
208
  throw malformed(`Parallel's second argument must be next or the options, got ${got}.`, nextOrOptions);
204
209
  }
205
- const options = hasNext || nextSkipped ? maybeOptions : nextOrOptions;
210
+ // Nothing reads a third argument after the options, so a `next` passed there would silently be skipped.
211
+ if (optionsSecond && maybeOptions != null) {
212
+ throw malformed(
213
+ typeof maybeOptions === 'function'
214
+ ? "Parallel's next goes second and its options third: Parallel(effects, next, options)."
215
+ : `Parallel takes one options object, and with the options second its third argument, ` +
216
+ `${describeArgument(maybeOptions)}, would be ignored.`,
217
+ maybeOptions
218
+ );
219
+ }
220
+ const options = optionsSecond ? nextOrOptions : maybeOptions;
206
221
  if (options != null && !isOptionsObject(options)) {
207
222
  throw malformed(`Parallel's options must be an object, got ${describeArgument(options)}.`, options);
208
223
  }
@@ -343,6 +358,16 @@ const effectTypeError = (value, source) =>
343
358
  value
344
359
  );
345
360
 
361
+ /**
362
+ * Names the node whose `next` returned a value, for an error message: a Command by its name, any other by its type.
363
+ * @param {Effect} node
364
+ * @returns {string}
365
+ */
366
+ const nextOf = (node) =>
367
+ node.type === 'Command'
368
+ ? `The next of Command '${commandName(node)}'`
369
+ : `The next of ${node.type === 'Ask' ? 'an' : 'a'} ${node.type}`;
370
+
346
371
  /**
347
372
  * A Success or a Failure: a flow with nothing left to run.
348
373
  * @param {any} value
@@ -380,16 +405,18 @@ const asEffect = (value, source) => {
380
405
  * @param {Effect} effect - The current Effect object
381
406
  * @param {(value: any) => Effect} fn - The next function to run if the current effect is a Success
382
407
  * @param {any} [initialInput] - The pipeline's starting value, stamped on every node but a Success
408
+ * @param {Effect} [from] - The node whose `next` returned `effect`, which an error names
383
409
  * @returns {Effect} The composed Effect
384
410
  */
385
- const chain = (effect, fn, initialInput) => {
411
+ const chain = (effect, fn, initialInput, from) => {
412
+ const source = () => (from ? nextOf(from) : 'A continuation');
386
413
  // Overwrites a sub-pipeline's own stamp, so the root and a Failure at any depth carry the input of the
387
414
  // flow that was actually called. A Success stays bare, so `deepEqual(result, Success(v))` always holds.
388
415
  const withII = (/** @type {Effect} */ e) =>
389
416
  initialInput !== undefined && e.type !== 'Success' ? { ...e, initialInput } : e;
390
417
 
391
418
  // Checked before `effect.type` is read, which would otherwise throw a bare TypeError naming no step.
392
- if (effect == null) return asEffect(effect, 'A continuation');
419
+ if (effect == null) return asEffect(effect, source());
393
420
 
394
421
  switch (effect.type) {
395
422
  case 'Success':
@@ -397,23 +424,23 @@ const chain = (effect, fn, initialInput) => {
397
424
  case 'Failure':
398
425
  return withII(effect);
399
426
  case 'Command': {
400
- const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
427
+ const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
401
428
  return withII(Command(effect.cmd, next, effect.meta));
402
429
  }
403
430
  case 'Ask': {
404
- const next = (/** @type {any} */ ctx) => chain(effect.next(ctx), fn, initialInput);
431
+ const next = (/** @type {any} */ ctx) => chain(effect.next(ctx), fn, initialInput, effect);
405
432
  return withII(Ask(next));
406
433
  }
407
434
  case 'Retry': {
408
- const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
435
+ const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
409
436
  return withII({ ...effect, next });
410
437
  }
411
438
  case 'Parallel': {
412
- const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
439
+ const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
413
440
  return withII({ ...effect, next });
414
441
  }
415
442
  default:
416
- return asEffect(effect, 'A continuation');
443
+ return asEffect(effect, source());
417
444
  }
418
445
  };
419
446
 
@@ -817,10 +844,13 @@ const interpret =
817
844
  */
818
845
  async function execute(eff, signal, path = '') {
819
846
  let step = 0;
847
+ /** @type {Effect | undefined} The node whose `next` returned `eff`, which an error names. */
848
+ let from;
820
849
  while (isPending(eff)) {
821
850
  // Checked before every node: a Command already in flight cannot be stopped, but the next never starts.
822
851
  if (signal?.aborted) return cancelledBranch(eff.initialInput);
823
852
  if (eff.type === 'Ask') {
853
+ from = eff;
824
854
  eff = eff.next(context);
825
855
  continue;
826
856
  }
@@ -834,10 +864,11 @@ const interpret =
834
864
  if (outcome.type !== 'Success') return outcome;
835
865
  // Outside every catch: `next` and the pure steps it reaches are code, not I/O, so a throw there
836
866
  // rejects the run.
867
+ from = eff;
837
868
  eff = eff.next(outcome.value);
838
869
  }
839
870
  if (isOutcome(eff)) return eff;
840
- throw effectTypeError(eff, 'The flow');
871
+ throw effectTypeError(eff, from ? nextOf(from) : 'The flow');
841
872
  }
842
873
 
843
874
  /**
@@ -1615,7 +1646,9 @@ const fromTrace = (traceLog, options = {}) => {
1615
1646
  * reach a telemetry backend or a guardrail that performs I/O.
1616
1647
  * @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
1617
1648
  * `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
1618
- * `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
1649
+ * `'execute'` runs the real Command, so pass it only where the Commands reach test doubles or only
1650
+ * read. A trace that dropped entries under `maxEntries` refuses it, since a step it lacks may be one
1651
+ * production ran.
1619
1652
  * @property {(step: ReplayStep, outcome: ReplayOutcome | undefined) => void} [onResolved] - Observes each step.
1620
1653
  */
1621
1654
 
@@ -1661,10 +1694,22 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1661
1694
  const { resolve, missing } = fromResolver
1662
1695
  ? { resolve: traceOrResolver, missing: undefined }
1663
1696
  : fromTrace(traceOrResolver, { onEntry: (entry) => void reached.add(entry) });
1697
+ // Only a trace log carries metadata; a bare entries array and a Resolver carry none.
1698
+ const traceLog = fromResolver || Array.isArray(traceOrResolver) ? undefined : traceOrResolver;
1664
1699
  // Defaults to the trace's context: an `Ask` gate replayed with another one takes another branch, and
1665
1700
  // no paradox flags it.
1666
- const recorded = fromResolver || Array.isArray(traceOrResolver) ? undefined : traceOrResolver.context;
1667
- const context = options.context ?? recorded ?? {};
1701
+ const context = options.context ?? traceLog?.context ?? {};
1702
+ // A trace capped by `maxEntries` lacks steps production ran, so running a missing step live repeats
1703
+ // production's I/O: a billing batch replayed that way charged and invoiced subscriptions again.
1704
+ const droppedEntries = Number(traceLog?.dropped) || 0;
1705
+ const capped = droppedEntries > 0;
1706
+ if (capped && onMissing === 'execute') {
1707
+ throw replayError(
1708
+ `The trace dropped ${droppedEntries} entries under maxEntries, so a step it lacks may be one ` +
1709
+ "production ran, and onMissing: 'execute' would run it again. Replay without it to stop at the " +
1710
+ 'first missing step, or record the flow with a higher maxEntries.'
1711
+ );
1712
+ }
1668
1713
  let index = 0;
1669
1714
  // What `onResolved` threw. It stops the replay, which rejects with it, rather than counting as the
1670
1715
  // Command failing, which would let a Retry ask for an attempt production never made. Cast rather than
@@ -1692,15 +1737,21 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
1692
1737
  if (outcome === undefined) {
1693
1738
  if (onMissing !== 'execute') {
1694
1739
  const what = missing ? missing(step) : `No recorded outcome for '${name}' at step ${step.index}`;
1695
- // The option is named with its one safe use, since a step with no entry may be one production
1696
- // never ran, and running it live would do I/O production refused.
1697
- throw replayError(
1698
- `${what}; refusing to run the real Command. Production may never have run it: a Command ` +
1699
- 'an onBeforeCommand hook vetoed leaves no entry, and neither does a step added since the ' +
1700
- "recording. Pass onMissing: 'execute' to run unrecorded steps live only when the trace was " +
1701
- 'cut short, as by maxEntries.',
1702
- { command: name, index: step.index, path }
1703
- );
1740
+ // Why the step may be missing decides the fix. A capped trace needs a higher cap, since the step
1741
+ // may be one production ran; any other missing step may be one production never ran, and
1742
+ // running it live against production would do I/O production refused.
1743
+ const why = capped
1744
+ ? `The trace dropped ${droppedEntries} entries under maxEntries, so production may have run ` +
1745
+ 'this step and the recorder not kept it; record the flow with a higher maxEntries to ' +
1746
+ 'replay past it.'
1747
+ : 'Production may never have run it: a Command an onBeforeCommand hook vetoed leaves no ' +
1748
+ "entry, and neither does a step added since the recording. onMissing: 'execute' runs such " +
1749
+ 'steps for real, so pass it only where the Commands reach test doubles or only read.';
1750
+ throw replayError(`${what}; refusing to run the real Command. ${why}`, {
1751
+ command: name,
1752
+ index: step.index,
1753
+ path
1754
+ });
1704
1755
  }
1705
1756
  return await op();
1706
1757
  }
@@ -1767,6 +1818,15 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
1767
1818
  'installed as a hook finds the input only on a flow built with effectPipe.'
1768
1819
  );
1769
1820
  }
1821
+ // A replay tells steps apart by name, so two anonymous Commands that swapped places replay without complaint.
1822
+ const anonymous = trace.filter((e) => e.command === 'anonymous').length;
1823
+ if (anonymous > 0) {
1824
+ log(
1825
+ `Warning: ${anonymous} of the recorded steps are named 'anonymous', usually inline arrow Commands, so ` +
1826
+ 'this replay cannot tell them apart and would not notice two of them trading places. Name them with ' +
1827
+ 'a const or meta.name.'
1828
+ );
1829
+ }
1770
1830
  // Parallel decisions are not narrated as steps, so the header counts Commands to match the lines below.
1771
1831
  const commandCount = trace.filter((e) => !isDecisionEntry(e)).length;
1772
1832
  const stepsText = commandCount === 1 ? 'step' : 'steps';
@@ -1821,5 +1881,6 @@ export {
1821
1881
  recorder,
1822
1882
  recordEffect,
1823
1883
  replayEffect,
1824
- timeTravel
1884
+ timeTravel,
1885
+ commandName
1825
1886
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pure-effect",
3
- "version": "0.15.0",
3
+ "version": "0.16.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",
@@ -20,15 +20,18 @@
20
20
  "files": [
21
21
  "index.js",
22
22
  "index.d.ts",
23
- "CHANGELOG.md"
23
+ "CHANGELOG.md",
24
+ "examples"
24
25
  ],
25
26
  "sideEffects": false,
26
27
  "engines": {
27
- "node": ">=18"
28
+ "node": ">=22"
28
29
  },
29
30
  "scripts": {
30
31
  "test": "mocha && tsd && tsc -p jsconfig.json",
31
- "format": "prettier --write index.js index.d.ts examples/*.js test/all.js test/types.test-d.ts README.md CLAUDE.md CHANGELOG.md CONTRIBUTING.md"
32
+ "test:ts-minimum": "npx --yes -p typescript@5.1 tsc -p test/tsconfig.minimum.json",
33
+ "generate": "node scripts/effect-pipe-overloads.js",
34
+ "format": "prettier --write index.js index.d.ts examples/*.js scripts/*.js test/all.js test/types.test-d.ts README.md CLAUDE.md CHANGELOG.md CONTRIBUTING.md"
32
35
  },
33
36
  "tsd": {
34
37
  "directory": "test"