pure-effect 0.7.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',
@@ -29,9 +34,18 @@
29
34
  * }} RetryState
30
35
  */
31
36
 
37
+ /**
38
+ * @typedef {{
39
+ * type: 'Parallel',
40
+ * effects: Effect[],
41
+ * next: (values: any[]) => Effect,
42
+ * initialInput?: any
43
+ * }} ParallelState
44
+ */
45
+
32
46
  /**
33
47
  * The Union type for all possible states
34
- * @typedef {SuccessState | FailureState | CommandState | AskState | RetryState} Effect
48
+ * @typedef {SuccessState | FailureState | CommandState | AskState | RetryState | ParallelState} Effect
35
49
  */
36
50
 
37
51
  /**
@@ -54,13 +68,39 @@ const Failure = (error, initialInput) => ({
54
68
  });
55
69
 
56
70
  /**
57
- * Represents a side effect to be executed later
71
+ * Represents a side effect to be executed later.
72
+ *
58
73
  * @param {() => Promise<any>|any} cmd - The side-effect function to execute
59
- * @param {(result: any) => Effect} next - A function that receives the result of `cmd` and returns the next Effect
60
- * @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.
61
79
  * @returns {CommandState}
62
80
  */
63
- 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
+ };
64
104
 
65
105
  /**
66
106
  * Reads the context object from the current `runEffect` call.
@@ -71,6 +111,12 @@ const Ask = (next) => ({ type: 'Ask', next });
71
111
 
72
112
  /**
73
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
+ *
74
120
  * @param {Effect} effect - The inner Effect tree to retry
75
121
  * @param {Object} [options] - Per-use retry options; merged over global defaults at runtime
76
122
  * @param {number} [options.attempts] - Max retries (not counting first try)
@@ -85,6 +131,61 @@ const Retry = (effect, options = {}) => ({
85
131
  next: (value) => Success(value)
86
132
  });
87
133
 
134
+ /**
135
+ * Runs multiple Effect trees concurrently. If any effect fails, returns the first Failure by array order and skips next.
136
+ * @param {Effect[]} effects - Array of Effect trees to run concurrently
137
+ * @param {(values: any[]) => Effect} next - Receives array of success values in order, returns next Effect
138
+ * @returns {ParallelState}
139
+ */
140
+ const Parallel = (effects, next) => ({ type: 'Parallel', effects, next });
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
+
88
189
  /**
89
190
  * Connects an Effect to the next function in the pipeline.
90
191
  * Handles the branching logic for Success, Failure, Command, Ask, and Retry.
@@ -105,7 +206,7 @@ const chain = (effect, fn, initialInput) => {
105
206
 
106
207
  switch (effect.type) {
107
208
  case 'Success':
108
- return withII(fn(effect.value));
209
+ return withII(asEffect(fn(effect.value), `Step '${fn.name || 'anonymous'}'`));
109
210
  case 'Failure':
110
211
  return withII(effect);
111
212
  case 'Command': {
@@ -120,6 +221,12 @@ const chain = (effect, fn, initialInput) => {
120
221
  const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
121
222
  return withII({ ...effect, next });
122
223
  }
224
+ case 'Parallel': {
225
+ const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
226
+ return withII({ ...effect, next });
227
+ }
228
+ default:
229
+ return asEffect(effect, 'A continuation');
123
230
  }
124
231
  };
125
232
 
@@ -158,8 +265,8 @@ let retryDefaults = { ...defaultRetryOptions };
158
265
 
159
266
  /**
160
267
  * @typedef {Object} EffectConfiguration
161
- * @property {StepRunner} [onStep] - Fires once per runEffect call. It wraps the entire workflow execution.
162
- * @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.
163
270
  * @property {CommandInterceptor} [onBeforeCommand] - Intercepts a Command and any context passed to runEffect before execution.
164
271
  * @property {{ attempts?: number, delay?: number, backoff?: number }} [retry] - Global Retry defaults; merged under per-use options.
165
272
  */
@@ -167,13 +274,158 @@ let retryDefaults = { ...defaultRetryOptions };
167
274
  /**
168
275
  * Configures the global behavior of the Effect runner, including the command interceptor and telemetry.
169
276
  *
170
- * @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
171
299
  */
172
- 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);
173
307
  stepRunner = options.onStep ? options.onStep : defaultStepRunner;
174
308
  runWrapper = options.onRun ? options.onRun : defaultRunWrapper;
175
309
  commandInterceptor = options.onBeforeCommand ? options.onBeforeCommand : defaultCommandInterceptor;
176
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;
177
429
  };
178
430
 
179
431
  const runEffect =
@@ -183,7 +435,7 @@ const runEffect =
183
435
  * Ask effects are resolved synchronously with the context object.
184
436
  *
185
437
  * Per-call config takes precedence over global configureEffect defaults.
186
- * 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
187
439
  * single span rather than spawning their own, keeping telemetry non-duplicated.
188
440
  *
189
441
  * @param {Effect} effect - The Effect tree returned by a pipeline
@@ -202,7 +454,10 @@ const runEffect =
202
454
  * @returns {Promise<SuccessState | FailureState>}
203
455
  */
204
456
  async function execute(eff) {
205
- while (eff.type === 'Command' || eff.type === 'Ask' || eff.type === 'Retry') {
457
+ while (
458
+ eff &&
459
+ (eff.type === 'Command' || eff.type === 'Ask' || eff.type === 'Retry' || eff.type === 'Parallel')
460
+ ) {
206
461
  if (eff.type === 'Ask') {
207
462
  eff = eff.next(context);
208
463
  continue;
@@ -231,20 +486,486 @@ const runEffect =
231
486
  }
232
487
  continue;
233
488
  }
234
- const cmdName = eff.cmd.name || 'anonymous';
489
+ if (eff.type === 'Parallel') {
490
+ const results = await Promise.all(eff.effects.map((e) => execute(e)));
491
+ const failure = results.find((r) => r.type === 'Failure');
492
+ if (failure) return failure;
493
+ eff = eff.next(results.map((r) => /** @type {SuccessState} */ (r).value));
494
+ continue;
495
+ }
496
+ const cmdName = commandName(eff);
235
497
  const initialInput = eff.initialInput;
236
498
  try {
237
499
  await localCommandInterceptor(eff, context);
238
500
  const result = await localStepRunner(cmdName, 'Command', eff.cmd);
239
501
  eff = eff.next(result);
240
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;
241
505
  return Failure(e, initialInput);
242
506
  }
243
507
  }
244
- return eff;
508
+ if (eff && (eff.type === 'Success' || eff.type === 'Failure')) return eff;
509
+ throw effectTypeError(eff, 'The flow');
245
510
  }
246
511
 
247
512
  return localRunWrapper(effect, () => execute(effect), context?.flowName || '');
248
513
  };
249
514
 
250
- export { Success, Failure, Command, Ask, Retry, 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
+ };