pure-effect 0.8.0 → 0.9.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
@@ -2,6 +2,11 @@
2
2
 
3
3
  /** @typedef {{ type: 'Success', value: any, initialInput?: any }} SuccessState */
4
4
  /** @typedef {{ type: 'Failure', error: any, initialInput?: any }} FailureState */
5
+ /**
6
+ * Metadata attached to a Command. A string `name` is read by the interpreter as the Command's
7
+ * identity; every other key is carried through untouched for `onBeforeCommand`.
8
+ * @typedef {{ name?: string } & Record<string, any>} CommandMeta
9
+ */
5
10
  /**
6
11
  * @typedef {{
7
12
  * type: 'Command',
@@ -63,13 +68,39 @@ const Failure = (error, initialInput) => ({
63
68
  });
64
69
 
65
70
  /**
66
- * Represents a side effect to be executed later
71
+ * Represents a side effect to be executed later.
72
+ *
67
73
  * @param {() => Promise<any>|any} cmd - The side-effect function to execute
68
- * @param {(result: any) => Effect} next - A function that receives the result of `cmd` and returns the next Effect
69
- * @param {any} [meta] - Optional metadata
74
+ * @param {(result: any) => Effect} [next] - Receives the result of `cmd` and returns the next Effect.
75
+ * Defaults to `(result) => Success(result)`, which is what most Commands want.
76
+ * @param {CommandMeta} [meta] - Optional metadata, passed to `onBeforeCommand`. A string `meta.name`
77
+ * becomes this Command's identity for traces, replay matching, and telemetry spans, which makes
78
+ * the identity independent of how `cmd` was declared and immune to minification.
70
79
  * @returns {CommandState}
71
80
  */
72
- const Command = (cmd, next, meta) => ({ type: 'Command', cmd, next, meta });
81
+ const Command = (cmd, next = (/** @type {any} */ result) => Success(result), meta) => ({
82
+ type: 'Command',
83
+ cmd,
84
+ next,
85
+ meta
86
+ });
87
+
88
+ /**
89
+ * The name a Command is known by: what a trace records, what strict replay matches on, and what a
90
+ * telemetry span is called.
91
+ *
92
+ * `meta.name` wins when it is a non-empty string. Otherwise the identity is the thunk's own `name`,
93
+ * so an inline arrow records as 'anonymous' and a minifier that mangles function names silently
94
+ * renames every step of every trace. Passing `meta.name` avoids both.
95
+ *
96
+ * @param {CommandState} eff
97
+ * @returns {string}
98
+ */
99
+ const commandName = (eff) => {
100
+ const meta = eff.meta;
101
+ const named = meta && typeof meta === 'object' ? meta.name : undefined;
102
+ return typeof named === 'string' && named !== '' ? named : eff.cmd.name || 'anonymous';
103
+ };
73
104
 
74
105
  /**
75
106
  * Reads the context object from the current `runEffect` call.
@@ -80,6 +111,12 @@ const Ask = (next) => ({ type: 'Ask', next });
80
111
 
81
112
  /**
82
113
  * Wraps an Effect tree with retry-on-failure semantics.
114
+ *
115
+ * Each attempt runs the **entire** wrapped tree again, including Commands that already succeeded, so
116
+ * wrap the one Command that fails transiently rather than a pipeline. `Retry(effectPipe(charge, receipt))`
117
+ * charges the customer again every time the receipt step fails. Wrapping a pipeline is only safe when
118
+ * every Command in it is idempotent.
119
+ *
83
120
  * @param {Effect} effect - The inner Effect tree to retry
84
121
  * @param {Object} [options] - Per-use retry options; merged over global defaults at runtime
85
122
  * @param {number} [options.attempts] - Max retries (not counting first try)
@@ -102,6 +139,53 @@ const Retry = (effect, options = {}) => ({
102
139
  */
103
140
  const Parallel = (effects, next) => ({ type: 'Parallel', effects, next });
104
141
 
142
+ /**
143
+ * Describes a value for an error message, leading with the mistake it most likely is.
144
+ * @param {any} value
145
+ * @returns {string}
146
+ */
147
+ const describeValue = (value) =>
148
+ value === undefined
149
+ ? 'undefined, which usually means a missing return'
150
+ : value === null
151
+ ? 'null'
152
+ : typeof value === 'function'
153
+ ? 'a function, which usually means a flow was passed without being called with its input'
154
+ : typeof value === 'object'
155
+ ? typeof value.type === 'string'
156
+ ? `an object with an unrecognised type '${value.type}'`
157
+ : 'a plain object'
158
+ : `the ${typeof value} ${JSON.stringify(value)}`;
159
+
160
+ /**
161
+ * Checks that a value is an Effect, and explains the mistake when it is not.
162
+ *
163
+ * `EffectTypeError` is a bug in the flow rather than a domain failure, so it is thrown instead of
164
+ * becoming a `Failure`, and the interpreter rethrows it rather than folding it into one.
165
+ *
166
+ * @param {any} value
167
+ * @param {string} source - What produced the value, named where it is known
168
+ * @returns {Error}
169
+ */
170
+ const effectTypeError = (value, source) =>
171
+ Object.assign(
172
+ new Error(
173
+ `${source} returned ${describeValue(value)}. Return Success, Failure, Command, Ask, Retry, or Parallel: ` +
174
+ 'a plain value has to be wrapped, as in Success(value).'
175
+ ),
176
+ { name: 'EffectTypeError' }
177
+ );
178
+
179
+ /**
180
+ * @param {any} value
181
+ * @param {string} source
182
+ * @returns {Effect}
183
+ */
184
+ const asEffect = (value, source) => {
185
+ if (value && ['Success', 'Failure', 'Command', 'Ask', 'Retry', 'Parallel'].includes(value.type)) return value;
186
+ throw effectTypeError(value, source);
187
+ };
188
+
105
189
  /**
106
190
  * Connects an Effect to the next function in the pipeline.
107
191
  * Handles the branching logic for Success, Failure, Command, Ask, and Retry.
@@ -122,7 +206,7 @@ const chain = (effect, fn, initialInput) => {
122
206
 
123
207
  switch (effect.type) {
124
208
  case 'Success':
125
- return withII(fn(effect.value));
209
+ return withII(asEffect(fn(effect.value), `Step '${fn.name || 'anonymous'}'`));
126
210
  case 'Failure':
127
211
  return withII(effect);
128
212
  case 'Command': {
@@ -141,6 +225,8 @@ const chain = (effect, fn, initialInput) => {
141
225
  const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
142
226
  return withII({ ...effect, next });
143
227
  }
228
+ default:
229
+ return asEffect(effect, 'A continuation');
144
230
  }
145
231
  };
146
232
 
@@ -179,8 +265,8 @@ let retryDefaults = { ...defaultRetryOptions };
179
265
 
180
266
  /**
181
267
  * @typedef {Object} EffectConfiguration
182
- * @property {StepRunner} [onStep] - Fires once per runEffect call. It wraps the entire workflow execution.
183
- * @property {RunWrapper} [onRun] - Fires every time a Command is executed.
268
+ * @property {StepRunner} [onStep] - Fires every time a Command is executed. It wraps the `cmd` call.
269
+ * @property {RunWrapper} [onRun] - Fires once per runEffect call. It wraps the entire workflow execution.
184
270
  * @property {CommandInterceptor} [onBeforeCommand] - Intercepts a Command and any context passed to runEffect before execution.
185
271
  * @property {{ attempts?: number, delay?: number, backoff?: number }} [retry] - Global Retry defaults; merged under per-use options.
186
272
  */
@@ -188,13 +274,158 @@ let retryDefaults = { ...defaultRetryOptions };
188
274
  /**
189
275
  * Configures the global behavior of the Effect runner, including the command interceptor and telemetry.
190
276
  *
191
- * @param {EffectConfiguration} options - The configuration object for the effect pipeline.
277
+ * Several configurations can be passed and are merged, which is how independent concerns share the
278
+ * one slot each hook has:
279
+ *
280
+ * configureEffect(telemetryHooks(), recordingHooks({ sink }))
281
+ *
282
+ * `onStep` and `onRun` are wrappers, so they nest with the first configuration outermost and the last
283
+ * closest to the Command. `onBeforeCommand` interceptors all run, in the order given, and `retry`
284
+ * merges with later configurations winning.
285
+ *
286
+ * Merging happens per call and does not accumulate across calls: a later `configureEffect` still
287
+ * replaces the previous wiring entirely, and calling it with nothing resets every slot to its default.
288
+ *
289
+ * Returns a function that puts back whatever was installed when this call was made, so a caller can
290
+ * install hooks without owning the wiring forever. That is what makes this usable from a test, a
291
+ * request-scoped experiment, or a library that wraps this one: without it, installing anything means
292
+ * clobbering the host's telemetry permanently, since there is no way to read the current wiring.
293
+ * Restoring undoes this call only while its wiring is still in effect. If a later `configureEffect` has
294
+ * run since, restoring does nothing rather than discarding that newer wiring, so two callers installing
295
+ * and releasing in interleaved order cannot clobber each other.
296
+ *
297
+ * @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
298
+ * @returns {() => void} Restores the wiring that was in place before this call
192
299
  */
193
- const configureEffect = (options) => {
300
+ const configureEffect = (...configs) => {
301
+ const previousStepRunner = stepRunner;
302
+ const previousRunWrapper = runWrapper;
303
+ const previousCommandInterceptor = commandInterceptor;
304
+ const previousRetryDefaults = retryDefaults;
305
+
306
+ const options = chainHooks(...configs);
194
307
  stepRunner = options.onStep ? options.onStep : defaultStepRunner;
195
308
  runWrapper = options.onRun ? options.onRun : defaultRunWrapper;
196
309
  commandInterceptor = options.onBeforeCommand ? options.onBeforeCommand : defaultCommandInterceptor;
197
310
  retryDefaults = options.retry ? { ...defaultRetryOptions, ...options.retry } : defaultRetryOptions;
311
+
312
+ const installedStepRunner = stepRunner;
313
+ const installedRunWrapper = runWrapper;
314
+ const installedCommandInterceptor = commandInterceptor;
315
+ const installedRetryDefaults = retryDefaults;
316
+
317
+ return () => {
318
+ // Only undo an install that is still in effect. Without this check, two callers that install
319
+ // and release in interleaved order silently discard each other's hooks: A installs, B installs,
320
+ // A restores, and B's wiring is gone though B never released it.
321
+ const unchanged =
322
+ stepRunner === installedStepRunner &&
323
+ runWrapper === installedRunWrapper &&
324
+ commandInterceptor === installedCommandInterceptor &&
325
+ retryDefaults === installedRetryDefaults;
326
+ if (!unchanged) return;
327
+
328
+ stepRunner = previousStepRunner;
329
+ runWrapper = previousRunWrapper;
330
+ commandInterceptor = previousCommandInterceptor;
331
+ retryDefaults = previousRetryDefaults;
332
+ };
333
+ };
334
+
335
+ /**
336
+ * @typedef {Object} StepStart
337
+ * @property {string} name - `cmd.name`, or 'anonymous'.
338
+ * @property {string} type - Always 'Command' today.
339
+ */
340
+
341
+ /**
342
+ * @typedef {Object} StepEnd
343
+ * @property {string} name
344
+ * @property {string} type
345
+ * @property {any} [result] - What the Command returned, when it succeeded.
346
+ * @property {any} [error] - What it threw, when it did not.
347
+ * @property {number} durationMs
348
+ */
349
+
350
+ const now = () => (typeof performance === 'object' ? performance.now() : Date.now());
351
+
352
+ /**
353
+ * Wraps a step observer into an `onStep` that cannot change the run.
354
+ *
355
+ * @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
356
+ * @returns {StepRunner}
357
+ */
358
+ const observeSteps = (handler) => async (name, type, op) => {
359
+ /** @type {((end: StepEnd) => void) | undefined} */
360
+ let finish;
361
+ try {
362
+ finish = handler({ name, type });
363
+ } catch {
364
+ finish = undefined;
365
+ }
366
+ const report = (/** @type {StepEnd} */ end) => {
367
+ try {
368
+ if (finish) finish(end);
369
+ } catch {
370
+ // Observation does not get to decide the outcome, so a broken observer is dropped.
371
+ }
372
+ };
373
+
374
+ const started = now();
375
+ try {
376
+ const result = await op();
377
+ report({ name, type, result, durationMs: now() - started });
378
+ return result;
379
+ } catch (error) {
380
+ report({ name, type, error, durationMs: now() - started });
381
+ throw error;
382
+ }
383
+ };
384
+
385
+ /**
386
+ * Merges several configurations into one, so independent concerns can share the hooks.
387
+ *
388
+ * `onStep` and `onRun` are wrappers around an `op`, so they nest. The first config given is
389
+ * the outermost wrapper and the last sits closest to the Command, which also means a thrown
390
+ * Command unwinds from the last config back to the first. `onBeforeCommand` is an observer,
391
+ * so every interceptor runs in the order given. `retry` is plain data and merges with later
392
+ * configs winning. A hook no config defines is left unset, so `configureEffect` keeps its
393
+ * default for that slot.
394
+ *
395
+ * @param {...(EffectConfiguration | undefined)} configs - Configurations to merge, outermost first
396
+ * @returns {EffectConfiguration}
397
+ */
398
+ const chainHooks = (...configs) => {
399
+ const present = (/** @type {string} */ key) =>
400
+ configs
401
+ .filter(Boolean)
402
+ .map((c) => /** @type {any} */ (c)[key])
403
+ .filter(Boolean);
404
+
405
+ /** @type {EffectConfiguration} */
406
+ const merged = {};
407
+ const steps = /** @type {StepRunner[]} */ (present('onStep'));
408
+ const runs = /** @type {RunWrapper[]} */ (present('onRun'));
409
+ const interceptors = /** @type {CommandInterceptor[]} */ (present('onBeforeCommand'));
410
+ const retries = present('retry');
411
+
412
+ if (steps.length) {
413
+ merged.onStep = steps.reduceRight(
414
+ (inner, outer) => (name, type, op) => outer(name, type, () => inner(name, type, op))
415
+ );
416
+ }
417
+ if (runs.length) {
418
+ merged.onRun = runs.reduceRight(
419
+ (inner, outer) => (effect, op, flowName) => outer(effect, () => inner(effect, op, flowName), flowName)
420
+ );
421
+ }
422
+ if (interceptors.length) {
423
+ merged.onBeforeCommand = async (command, context) => {
424
+ for (const intercept of interceptors) await intercept(command, context);
425
+ };
426
+ }
427
+ if (retries.length) merged.retry = Object.assign({}, ...retries);
428
+ return merged;
198
429
  };
199
430
 
200
431
  const runEffect =
@@ -204,7 +435,7 @@ const runEffect =
204
435
  * Ask effects are resolved synchronously with the context object.
205
436
  *
206
437
  * Per-call config takes precedence over global configureEffect defaults.
207
- * onRun fires exactly once per runEffect call — Retry attempts run inside that
438
+ * onRun fires exactly once per runEffect call. Retry attempts run inside that
208
439
  * single span rather than spawning their own, keeping telemetry non-duplicated.
209
440
  *
210
441
  * @param {Effect} effect - The Effect tree returned by a pipeline
@@ -223,7 +454,10 @@ const runEffect =
223
454
  * @returns {Promise<SuccessState | FailureState>}
224
455
  */
225
456
  async function execute(eff) {
226
- while (eff.type === 'Command' || eff.type === 'Ask' || eff.type === 'Retry' || eff.type === 'Parallel') {
457
+ while (
458
+ eff &&
459
+ (eff.type === 'Command' || eff.type === 'Ask' || eff.type === 'Retry' || eff.type === 'Parallel')
460
+ ) {
227
461
  if (eff.type === 'Ask') {
228
462
  eff = eff.next(context);
229
463
  continue;
@@ -259,20 +493,479 @@ const runEffect =
259
493
  eff = eff.next(results.map((r) => /** @type {SuccessState} */ (r).value));
260
494
  continue;
261
495
  }
262
- const cmdName = eff.cmd.name || 'anonymous';
496
+ const cmdName = commandName(eff);
263
497
  const initialInput = eff.initialInput;
264
498
  try {
265
499
  await localCommandInterceptor(eff, context);
266
500
  const result = await localStepRunner(cmdName, 'Command', eff.cmd);
267
501
  eff = eff.next(result);
268
502
  } catch (e) {
503
+ // A malformed flow is a bug, not a domain failure, so it must not masquerade as one.
504
+ if (e instanceof Error && e.name === 'EffectTypeError') throw e;
269
505
  return Failure(e, initialInput);
270
506
  }
271
507
  }
272
- return eff;
508
+ if (eff && (eff.type === 'Success' || eff.type === 'Failure')) return eff;
509
+ throw effectTypeError(eff, 'The flow');
273
510
  }
274
511
 
275
512
  return localRunWrapper(effect, () => execute(effect), context?.flowName || '');
276
513
  };
277
514
 
278
- export { Success, Failure, Command, Ask, Retry, Parallel, effectPipe, runEffect, configureEffect };
515
+ /** @typedef {{ index: number, name: string, type: string }} ReplayStep */
516
+
517
+ /**
518
+ * What production observed for a step. `{ result }` is handed to the Command's
519
+ * `next`; `{ error }` is thrown so the interpreter produces a Failure. A resolver
520
+ * returning `undefined` means "not recorded".
521
+ *
522
+ * @typedef {{ result: any } | { error: any }} ReplayOutcome
523
+ */
524
+
525
+ /** @typedef {(step: ReplayStep) => ReplayOutcome | undefined} Resolver */
526
+
527
+ /**
528
+ * A recorded step. `durationMs` is how long the Command took in production, rounded to microseconds,
529
+ * which is the one question a trace could not answer before: which step was slow.
530
+ * @typedef {{ command: string, result?: any, error?: any, durationMs?: number }} TraceEntry
531
+ */
532
+
533
+ /**
534
+ * The reference trace format produced by `recorder`. A convenience, not a contract:
535
+ * `replayEffect` takes a Resolver, so any storage shape works.
536
+ * @typedef {{
537
+ * flowName?: string,
538
+ * version?: string,
539
+ * initialInput?: any,
540
+ * context?: any,
541
+ * dropped?: number,
542
+ * trace: TraceEntry[]
543
+ * }} TraceLog
544
+ */
545
+
546
+ /**
547
+ * Creates a replay error. Thrown from a resolver, it is caught by the interpreter
548
+ * and returned as a Failure, exactly like a rejected Command.
549
+ * @param {string} message - Human-readable reason
550
+ * @param {Object} [props] - Extra fields such as `name`, `index`, `expected`, `actual`
551
+ * @returns {Error}
552
+ */
553
+ const replayError = (message, props = {}) => Object.assign(new Error(message), { name: 'ReplayError' }, props);
554
+
555
+ /**
556
+ * Signals that the flow being replayed asked for a different Command than the trace
557
+ * recorded, which means the code has diverged from the recorded run.
558
+ * @param {ReplayStep} step - The step the flow asked for
559
+ * @param {string} recorded - The command name the trace holds at that position
560
+ * @returns {Error}
561
+ */
562
+ const timeParadox = (step, recorded) =>
563
+ replayError(`Time paradox at step ${step.index}: flow asked for '${step.name}', trace recorded '${recorded}'`, {
564
+ name: 'TimeParadox',
565
+ index: step.index,
566
+ expected: recorded,
567
+ actual: step.name
568
+ });
569
+
570
+ /**
571
+ * Converts a thrown value into something JSON can carry. `message` and `stack` are
572
+ * non-enumerable on Error, so a plain `JSON.stringify` would silently drop them.
573
+ *
574
+ * @param {any} e - The thrown value
575
+ * @param {boolean} [withStack] - Include the stack (off by default: noisy, leaks paths)
576
+ * @returns {any}
577
+ */
578
+ const serializeError = (e, withStack) => {
579
+ if (!(e instanceof Error)) return e;
580
+ /** @type {any} */
581
+ const out = { __error: true, name: e.name, message: e.message };
582
+ if (withStack) out.stack = e.stack;
583
+ for (const k of Object.keys(e)) out[k] = /** @type {any} */ (e)[k];
584
+ return out;
585
+ };
586
+
587
+ /**
588
+ * Rebuilds an Error from `serializeError` output. Non-Error values pass through, so a
589
+ * Command that rejected with a string still replays as a string.
590
+ * @param {any} v - A serialized error, or any other recorded value
591
+ * @returns {any}
592
+ */
593
+ const reviveError = (v) => {
594
+ if (!v || typeof v !== 'object' || v.__error !== true) return v;
595
+ const e = new Error(v.message);
596
+ e.name = v.name;
597
+ for (const [k, val] of Object.entries(v)) {
598
+ if (k !== '__error' && k !== 'name' && k !== 'message') /** @type {any} */ (e)[k] = val;
599
+ }
600
+ return e;
601
+ };
602
+
603
+ /**
604
+ * @typedef {Object} RecorderOptions
605
+ * @property {(value: any, name: string, kind: string) => any} [redact] - Scrubs every value a trace holds:
606
+ * each Command's result, each serialized error, and the `initialInput` and `context` stored on the
607
+ * trace itself. `kind` is `'result'`, `'error'`, `'initialInput'`, or `'context'`, and `name` is the
608
+ * Command's name for the first two and the kind for the last two. It is the single place PII is kept
609
+ * out of a trace, so it has to see all four.
610
+ * @property {number} [maxEntries] - Caps trace length; further steps are counted in `dropped`, not stored.
611
+ * @property {boolean} [stack] - Records stack traces for thrown errors.
612
+ */
613
+
614
+ /**
615
+ * @typedef {Object} TraceMeta
616
+ * @property {any} [initialInput] - The value the flow was called with.
617
+ * @property {string} [flowName]
618
+ * @property {any} [context] - Context passed to `runEffect`; required to replay `Ask`.
619
+ * @property {string} [version] - Commit or build id, so a replay can detect a stale trace.
620
+ */
621
+
622
+ /**
623
+ * Snapshots a value on its way into a trace.
624
+ *
625
+ * Commands return live objects, and a later step that mutates one would otherwise rewrite what the
626
+ * trace says an earlier step returned: `toTrace` runs after the flow finishes, so by serialization
627
+ * time the mutation is already baked in and the trace records a value production never saw. Values
628
+ * that cannot be cloned fall back to the reference.
629
+ *
630
+ * @param {any} value
631
+ * @returns {any}
632
+ */
633
+ const snapshot = (value) => {
634
+ if (value === null || typeof value !== 'object') return value;
635
+ try {
636
+ return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
637
+ } catch {
638
+ return value;
639
+ }
640
+ };
641
+
642
+ /**
643
+ * Builds an `onStep` hook that records what every Command returned, plus a packager
644
+ * for the reference trace format. Pass `onStep` to `runEffect` as per-call config, or
645
+ * to `configureEffect` to record globally.
646
+ *
647
+ * @param {RecorderOptions} [options] - Redaction and size limits
648
+ * @returns {{ onStep: StepRunner, entries: TraceEntry[], toTrace: (meta?: TraceMeta) => TraceLog }}
649
+ */
650
+ const recorder = (options = {}) => {
651
+ const { redact = (/** @type {any} */ r) => r, maxEntries = Infinity, stack = false } = options;
652
+ /** @type {TraceEntry[]} */
653
+ const entries = [];
654
+ let dropped = 0;
655
+
656
+ const push = (/** @type {TraceEntry} */ entry) => {
657
+ if (entries.length < maxEntries) entries.push(entry);
658
+ else dropped++;
659
+ };
660
+
661
+ /**
662
+ * `redact` is the caller's code.
663
+ */
664
+ const safeRedact = (/** @type {any} */ value, /** @type {string} */ name, /** @type {string} */ kind) => {
665
+ try {
666
+ return redact(value, name, kind);
667
+ } catch {
668
+ return '[redaction failed]';
669
+ }
670
+ };
671
+
672
+ /**
673
+ * Redacts and snapshots one of the trace's own fields. `undefined` is left alone so a flow with no
674
+ * context does not acquire an empty object from a redact function that spreads its argument.
675
+ */
676
+ const redactField = (/** @type {any} */ value, /** @type {string} */ kind) =>
677
+ value === undefined ? undefined : snapshot(safeRedact(value, kind, kind));
678
+
679
+ // Built on `observe` so the hook contract lives in one place: `op` always runs, its result is
680
+ // always returned, and its error always propagates.
681
+ const onStep = observeSteps(({ name }) => (end) => {
682
+ const durationMs = Math.round(end.durationMs * 1000) / 1000;
683
+ push(
684
+ 'error' in end
685
+ ? {
686
+ command: name,
687
+ error: snapshot(safeRedact(serializeError(end.error, stack), name, 'error')),
688
+ durationMs
689
+ }
690
+ : { command: name, result: snapshot(safeRedact(end.result, name, 'result')), durationMs }
691
+ );
692
+ });
693
+
694
+ /**
695
+ * @param {TraceMeta} [meta]
696
+ * @returns {TraceLog}
697
+ */
698
+ const toTrace = (meta = {}) => ({
699
+ flowName: meta.flowName,
700
+ version: meta.version,
701
+ initialInput: redactField(meta.initialInput, 'initialInput'),
702
+ context: redactField(meta.context, 'context'),
703
+ dropped,
704
+ trace: entries.slice()
705
+ });
706
+
707
+ return { onStep, entries, toTrace };
708
+ };
709
+
710
+ /**
711
+ * Runs a flow for real while recording every Command result, and returns both the
712
+ * outcome and a replayable trace. Convenient in tests and scripts; in an application,
713
+ * install `recorder().onStep` once via `configureEffect` instead of changing call sites.
714
+ *
715
+ * @param {(input: any) => Effect} flowFn - Builds the Effect tree from its input
716
+ * @param {any} initialInput - The value the flow is called with; stored so a replay can rebuild it
717
+ * @param {RecorderOptions & { context?: any, version?: string }} [options] - Recorder options, plus the
718
+ * `context` given to `runEffect` (stored so `Ask` can be replayed) and a build id
719
+ * @returns {Promise<{ result: SuccessState | FailureState, trace: TraceLog }>}
720
+ */
721
+ const recordEffect = async (flowFn, initialInput, options = {}) => {
722
+ const { context = {}, version, ...recorderOptions } = options;
723
+ const rec = recorder(recorderOptions);
724
+ const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
725
+ return {
726
+ result,
727
+ trace: rec.toTrace({ initialInput, flowName: context.flowName, context, version })
728
+ };
729
+ };
730
+
731
+ /**
732
+ * Turns a recorded entry into the outcome a Resolver must return.
733
+ *
734
+ * @param {TraceEntry} entry
735
+ * @returns {ReplayOutcome}
736
+ */
737
+ const entryToOutcome = (entry) => ('error' in entry ? { error: reviveError(entry.error) } : { result: entry.result });
738
+
739
+ /**
740
+ * Builds a Resolver for the reference trace format. Internal: `replayEffect` calls this
741
+ * when handed a trace instead of a Resolver, and is the only caller. Callers with traces
742
+ * in some other shape write a Resolver instead, which is the extension point.
743
+ *
744
+ * Strict matching consumes entries in recorded order and verifies that the flow asks
745
+ * for the Command the trace expects, which is what detects divergence between the
746
+ * recorded run and the code being replayed.
747
+ *
748
+ * Name matching is required for flows containing `Parallel`: branches run through
749
+ * `Promise.all` and therefore complete out of array order, so recorded positions are
750
+ * not stable. It resolves per-Command FIFO queues instead, which is order-independent
751
+ * and still keeps duplicate Command names apart.
752
+ *
753
+ * @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
754
+ * @param {Object} [options]
755
+ * @param {boolean} [options.strict] - Positional matching with paradox detection (default `true`)
756
+ * @returns {Resolver}
757
+ */
758
+ const fromTrace = (traceLog, options = {}) => {
759
+ const { strict = true } = options;
760
+ const entries = Array.isArray(traceLog) ? traceLog : traceLog?.trace;
761
+ if (!Array.isArray(entries)) throw replayError('Trace has no `trace` array.');
762
+
763
+ if (strict) {
764
+ return (step) => {
765
+ const entry = entries[step.index];
766
+ if (!entry) throw replayError(`Trace exhausted: no entry #${step.index} for '${step.name}'.`);
767
+ if (entry.command !== step.name) throw timeParadox(step, entry.command);
768
+ return entryToOutcome(entry);
769
+ };
770
+ }
771
+
772
+ /** @type {Map<string, TraceEntry[]>} */
773
+ const queues = new Map();
774
+ for (const entry of entries) {
775
+ if (!queues.has(entry.command)) queues.set(entry.command, []);
776
+ /** @type {TraceEntry[]} */ (queues.get(entry.command)).push(entry);
777
+ }
778
+ return (step) => {
779
+ const queue = queues.get(step.name);
780
+ if (!queue || queue.length === 0) throw replayError(`No recorded result left for '${step.name}'.`);
781
+ return entryToOutcome(/** @type {TraceEntry} */ (queue.shift()));
782
+ };
783
+ };
784
+
785
+ /**
786
+ * Rewrites `Retry` nodes to zero delay, lazily, through their `next` continuations.
787
+ *
788
+ * Needed because the interpreter merges per-use options over call config
789
+ * (`{ ...localRetryDefaults, ...eff.options }`), so a `callConfig.retry` cannot
790
+ * override a delay written at the call site. Without this, replaying a flow that
791
+ * retried in production waits out the production backoff.
792
+ *
793
+ * @param {any} eff - Any Effect node
794
+ * @returns {any} The same tree with Retry delays removed
795
+ */
796
+ const zeroRetryDelays = (eff) => {
797
+ if (!eff || typeof eff.type !== 'string') return eff;
798
+ switch (eff.type) {
799
+ case 'Retry':
800
+ return {
801
+ ...eff,
802
+ options: { ...eff.options, delay: 0, backoff: 1 },
803
+ effect: zeroRetryDelays(eff.effect),
804
+ next: (/** @type {any} */ value) => zeroRetryDelays(eff.next(value))
805
+ };
806
+ case 'Command':
807
+ return { ...eff, next: (/** @type {any} */ result) => zeroRetryDelays(eff.next(result)) };
808
+ case 'Ask':
809
+ return { ...eff, next: (/** @type {any} */ ctx) => zeroRetryDelays(eff.next(ctx)) };
810
+ case 'Parallel':
811
+ return {
812
+ ...eff,
813
+ effects: eff.effects.map(zeroRetryDelays),
814
+ next: (/** @type {any} */ values) => zeroRetryDelays(eff.next(values))
815
+ };
816
+ default:
817
+ return eff;
818
+ }
819
+ };
820
+
821
+ /**
822
+ * @typedef {Object} ReplayOptions
823
+ * @property {any} [context] - Context for `Ask`; pass the recorded context to reproduce a run faithfully.
824
+ * @property {boolean} [fastRetry] - Strips Retry delays so a replay does not wait out production backoff.
825
+ * @property {boolean} [hooks] - Lets configured `onRun` and `onBeforeCommand` fire. Off by default, so a
826
+ * replay cannot reach a telemetry backend or a guardrail that performs I/O.
827
+ * @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
828
+ * `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
829
+ * `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
830
+ * @property {(step: ReplayStep, outcome: ReplayOutcome | undefined) => void} [onResolved] - Observes each step.
831
+ * @property {boolean} [strict] - Matching mode used when a trace is passed instead of a Resolver.
832
+ * `true` (default) consumes entries positionally and raises a `TimeParadox` on divergence;
833
+ * `false` matches per-Command FIFO queues, which is required for flows containing `Parallel`.
834
+ * Ignored when a Resolver is supplied, since that Resolver has already chosen how it matches.
835
+ */
836
+
837
+ /**
838
+ * Replays an Effect tree, feeding recorded results to Commands instead of running them.
839
+ *
840
+ * No side effect can occur by default. The interpreter's only execution point is
841
+ * `await localStepRunner(cmdName, 'Command', eff.cmd)`, which hands the Command thunk
842
+ * to `onStep` as `op` rather than calling it. The `onStep` installed here never invokes
843
+ * `op` unless `onMissing: 'execute'` is set, so `eff.cmd` is never applied and the I/O
844
+ * it describes does not happen.
845
+ *
846
+ * Driving the interpreter instead of walking the tree is what makes `Ask`, `Retry` and
847
+ * `Parallel` work, and means replay cannot drift from execution semantics.
848
+ *
849
+ * @param {Effect} effect - The Effect tree, rebuilt from the recorded initial input
850
+ * @param {Resolver | TraceLog | TraceEntry[]} traceOrResolver - A reference-format trace, resolved here, or a
851
+ * Resolver supplying each Command's recorded outcome (`undefined` if it has none). Write a Resolver when
852
+ * traces are stored in some other shape; to observe a replay without one, use `onResolved`.
853
+ * @param {ReplayOptions} [options]
854
+ * @returns {Promise<SuccessState | FailureState>}
855
+ */
856
+ // `async` so a malformed trace arrives as a rejection rather than a synchronous throw:
857
+ // the function otherwise returns a promise, and callers should not have to handle both.
858
+ const replayEffect = async (effect, traceOrResolver, options = {}) => {
859
+ const { context = {}, fastRetry = true, hooks = false, onMissing = 'throw', onResolved, strict = true } = options;
860
+ // A trace is data and a Resolver is a function, so nothing else is needed to tell them
861
+ // apart, including the bare entries array that `fromTrace` also accepts.
862
+ const resolve = typeof traceOrResolver === 'function' ? traceOrResolver : fromTrace(traceOrResolver, { strict });
863
+ let index = 0;
864
+
865
+ /** @type {StepRunner} */
866
+ const onStep = async (name, type, op) => {
867
+ const step = { index: index++, name, type };
868
+ const outcome = resolve(step);
869
+ if (onResolved) onResolved(step, outcome);
870
+ if (outcome === undefined) {
871
+ if (onMissing !== 'execute') {
872
+ throw replayError(
873
+ `No recorded outcome for '${name}' at step ${step.index}; refusing to run the real Command. ` +
874
+ `Pass onMissing: 'execute' to allow live I/O for unrecorded steps.`,
875
+ { command: name, index: step.index }
876
+ );
877
+ }
878
+ return await op();
879
+ }
880
+ if ('error' in outcome) throw outcome.error;
881
+ return outcome.result;
882
+ };
883
+
884
+ /** @type {EffectConfiguration} */
885
+ const callConfig = { onStep };
886
+ if (!hooks) {
887
+ callConfig.onRun = async (effect, op) => await op();
888
+ callConfig.onBeforeCommand = async () => {};
889
+ }
890
+
891
+ return runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
892
+ };
893
+
894
+ /**
895
+ * Replays a reference-format trace and narrates each step. Rebuilds the flow from the
896
+ * recorded input, reports the outcome, and warns when recorded steps were never
897
+ * reached, which means the current code issued fewer Commands than production did.
898
+ *
899
+ * @param {(input: any) => Effect} flowFn - The same flow function that produced the trace
900
+ * @param {TraceLog} traceLog - A trace from `recordEffect` or a `recorder`
901
+ * @param {Object} [options]
902
+ * @param {boolean} [options.strict] - Positional matching with paradox detection; set `false` for `Parallel`
903
+ * @param {(...args: any[]) => void} [options.log] - Defaults to `console.log`
904
+ * @param {any} [options.context] - Overrides the context stored on the trace
905
+ * @param {string} [options.version] - Current build id; warns when it differs from the trace's
906
+ * @returns {Promise<SuccessState | FailureState>}
907
+ */
908
+ const timeTravel = async (flowFn, traceLog, options = {}) => {
909
+ const { strict = true, log = console.log, context, version } = options;
910
+ const { initialInput, trace, flowName, version: traceVersion } = traceLog;
911
+ // `message` and `stack` are non-enumerable on Error, so JSON.stringify alone would
912
+ // drop the most useful line of the report.
913
+ const format = (/** @type {any} */ v) =>
914
+ v instanceof Error
915
+ ? JSON.stringify({ ...v, name: v.name, message: v.message }, null, 2)
916
+ : JSON.stringify(v, null, 2);
917
+
918
+ if (version && traceVersion && version !== traceVersion) {
919
+ log(`Warning: trace was recorded at ${traceVersion}, replaying against ${version}.`);
920
+ }
921
+ const stepsText = (trace.length === 0 || trace.length > 1) ? 'steps' : 'step';
922
+ log(`Replaying '${flowName || 'flow'}' (${trace.length} recorded ${stepsText})`);
923
+ log(`Initial input: ${format(initialInput)}`);
924
+
925
+ // Narration goes through `onResolved` rather than a wrapped Resolver: observing each
926
+ // step is all this needs, and `replayEffect` already resolves the trace itself.
927
+ let consumed = 0;
928
+ // Only strict matching maps a replayed step back to its recorded entry by position, so timings
929
+ // are narrated there and omitted when per-Command queues are matching by name.
930
+ const timing = (/** @type {ReplayStep} */ step) => {
931
+ const recorded = strict ? trace[step.index] : undefined;
932
+ return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
933
+ };
934
+ const result = await replayEffect(flowFn(initialInput), traceLog, {
935
+ strict,
936
+ context: context !== undefined ? context : traceLog.context || {},
937
+ onResolved: (step, outcome) => {
938
+ // Resolving from a trace either answers or throws, so this is never undefined.
939
+ if (outcome === undefined) return;
940
+ consumed++;
941
+ log(
942
+ 'error' in outcome
943
+ ? `Step ${step.index + 1}: ${step.name} threw${timing(step)} ${format(outcome.error)}`
944
+ : `Step ${step.index + 1}: ${step.name} returned${timing(step)} ${format(outcome.result)}`
945
+ );
946
+ }
947
+ });
948
+
949
+ log(`Replay finished with state: ${result.type}`);
950
+ log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
951
+ if (consumed < trace.length) {
952
+ log(`Warning: ${trace.length - consumed} recorded ${stepsText} were never reached. The flow diverged.`);
953
+ }
954
+ return result;
955
+ };
956
+
957
+ export {
958
+ Success,
959
+ Failure,
960
+ Command,
961
+ Ask,
962
+ Retry,
963
+ Parallel,
964
+ effectPipe,
965
+ runEffect,
966
+ configureEffect,
967
+ recorder,
968
+ recordEffect,
969
+ replayEffect,
970
+ timeTravel
971
+ };