pure-effect 0.9.0 → 0.11.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/CLAUDE.md +20 -9
- package/README.md +135 -26
- package/index.d.ts +68 -12
- package/index.js +249 -47
- package/package.json +4 -4
- package/test/all.js +472 -8
- package/test/types.test-d.ts +41 -4
- package/tsconfig.check.json +12 -0
- package/.claude/settings.local.json +0 -17
package/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
/**
|
|
11
11
|
* @typedef {{
|
|
12
12
|
* type: 'Command',
|
|
13
|
-
* cmd: () => Promise<any>|any,
|
|
13
|
+
* cmd: (signal?: AbortSignal) => Promise<any>|any,
|
|
14
14
|
* next: (result: any) => Effect,
|
|
15
15
|
* meta?: any,
|
|
16
16
|
* initialInput?: any
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* @typedef {{
|
|
29
29
|
* type: 'Retry',
|
|
30
30
|
* effect: Effect,
|
|
31
|
-
* options: { attempts?: number, delay?: number, backoff?: number },
|
|
31
|
+
* options: { attempts?: number, delay?: number, backoff?: number, onExhausted?: (error: any) => Effect },
|
|
32
32
|
* next: (value: any) => Effect,
|
|
33
33
|
* initialInput?: any
|
|
34
34
|
* }} RetryState
|
|
@@ -70,7 +70,10 @@ const Failure = (error, initialInput) => ({
|
|
|
70
70
|
/**
|
|
71
71
|
* Represents a side effect to be executed later.
|
|
72
72
|
*
|
|
73
|
-
* @param {() => Promise<any>|any} cmd - The side-effect function to execute
|
|
73
|
+
* @param {(signal?: AbortSignal) => Promise<any>|any} cmd - The side-effect function to execute. Inside a
|
|
74
|
+
* `Parallel` branch it receives an `AbortSignal` that fires when a sibling branch fails, so I/O that
|
|
75
|
+
* accepts one can be cancelled in flight. Ignoring it is fine: the interpreter still refuses to start
|
|
76
|
+
* any later Command in a cancelled branch. Outside a `Parallel` no argument is passed.
|
|
74
77
|
* @param {(result: any) => Effect} [next] - Receives the result of `cmd` and returns the next Effect.
|
|
75
78
|
* Defaults to `(result) => Success(result)`, which is what most Commands want.
|
|
76
79
|
* @param {CommandMeta} [meta] - Optional metadata, passed to `onBeforeCommand`. A string `meta.name`
|
|
@@ -122,6 +125,10 @@ const Ask = (next) => ({ type: 'Ask', next });
|
|
|
122
125
|
* @param {number} [options.attempts] - Max retries (not counting first try)
|
|
123
126
|
* @param {number} [options.delay] - Ms before first retry
|
|
124
127
|
* @param {number} [options.backoff] - Multiplier applied to delay on each subsequent retry
|
|
128
|
+
* @param {(error: any) => Effect} [options.onExhausted] - Runs a fallback Effect when every attempt has
|
|
129
|
+
* failed, receiving `{ retryExhausted, lastError, attempts }`. The fallback's success feeds `next`
|
|
130
|
+
* exactly as the primary's would have; its failure propagates unwrapped. Per-use only, never a
|
|
131
|
+
* global default, and a fallback never starts in a `Parallel` branch a sibling has cancelled.
|
|
125
132
|
* @returns {RetryState}
|
|
126
133
|
*/
|
|
127
134
|
const Retry = (effect, options = {}) => ({
|
|
@@ -132,12 +139,25 @@ const Retry = (effect, options = {}) => ({
|
|
|
132
139
|
});
|
|
133
140
|
|
|
134
141
|
/**
|
|
135
|
-
* Runs multiple Effect trees concurrently.
|
|
142
|
+
* Runs multiple Effect trees concurrently. The first branch to fail cancels its siblings, and that
|
|
143
|
+
* branch's Failure is what the Parallel returns; `next` is skipped. When several branches fail in the
|
|
144
|
+
* same tick, the first by array order wins.
|
|
145
|
+
*
|
|
146
|
+
* Cancellation is cooperative and works at two levels. A cancelled branch starts no further Commands,
|
|
147
|
+
* which needs nothing from the caller. Stopping the Command already in flight needs its thunk to accept
|
|
148
|
+
* the `AbortSignal` it is passed and hand it to whatever performs the I/O; a thunk that ignores the
|
|
149
|
+
* signal runs to completion, so a branch's first Command can still write after a sibling has failed.
|
|
150
|
+
*
|
|
136
151
|
* @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
|
|
152
|
+
* @param {(values: any[]) => Effect} [next] - Receives array of success values in order, returns next Effect.
|
|
153
|
+
* Defaults to `(values) => Success(values)`, same as `Command`'s default.
|
|
138
154
|
* @returns {ParallelState}
|
|
139
155
|
*/
|
|
140
|
-
const Parallel = (effects, next
|
|
156
|
+
const Parallel = (effects, next = (/** @type {any[]} */ values) => Success(values)) => ({
|
|
157
|
+
type: 'Parallel',
|
|
158
|
+
effects,
|
|
159
|
+
next
|
|
160
|
+
});
|
|
141
161
|
|
|
142
162
|
/**
|
|
143
163
|
* Describes a value for an error message, leading with the mistake it most likely is.
|
|
@@ -244,7 +264,13 @@ const effectPipe = (...fns) => {
|
|
|
244
264
|
};
|
|
245
265
|
};
|
|
246
266
|
|
|
247
|
-
/**
|
|
267
|
+
/**
|
|
268
|
+
* Wraps one Command execution. `path` identifies the Command's position in the Effect tree rather than
|
|
269
|
+
* its position in completion order, so it is the same in a replay as it was in the recorded run even
|
|
270
|
+
* when `Parallel` branches finish in a different order. Hooks written before `path` existed take three
|
|
271
|
+
* parameters and are unaffected.
|
|
272
|
+
* @typedef {(name: string, type: string, op: function, path?: string) => Promise<any>} StepRunner
|
|
273
|
+
*/
|
|
248
274
|
/** @type StepRunner */
|
|
249
275
|
const defaultStepRunner = async (name, type, op) => await op();
|
|
250
276
|
|
|
@@ -336,12 +362,14 @@ const configureEffect = (...configs) => {
|
|
|
336
362
|
* @typedef {Object} StepStart
|
|
337
363
|
* @property {string} name - `cmd.name`, or 'anonymous'.
|
|
338
364
|
* @property {string} type - Always 'Command' today.
|
|
365
|
+
* @property {string} [path] - The Command's position in the Effect tree.
|
|
339
366
|
*/
|
|
340
367
|
|
|
341
368
|
/**
|
|
342
369
|
* @typedef {Object} StepEnd
|
|
343
370
|
* @property {string} name
|
|
344
371
|
* @property {string} type
|
|
372
|
+
* @property {string} [path]
|
|
345
373
|
* @property {any} [result] - What the Command returned, when it succeeded.
|
|
346
374
|
* @property {any} [error] - What it threw, when it did not.
|
|
347
375
|
* @property {number} durationMs
|
|
@@ -355,11 +383,11 @@ const now = () => (typeof performance === 'object' ? performance.now() : Date.no
|
|
|
355
383
|
* @param {(start: StepStart) => (end: StepEnd) => void} handler - Returns a finisher for the outcome
|
|
356
384
|
* @returns {StepRunner}
|
|
357
385
|
*/
|
|
358
|
-
const observeSteps = (handler) => async (name, type, op) => {
|
|
386
|
+
const observeSteps = (handler) => async (name, type, op, path) => {
|
|
359
387
|
/** @type {((end: StepEnd) => void) | undefined} */
|
|
360
388
|
let finish;
|
|
361
389
|
try {
|
|
362
|
-
finish = handler({ name, type });
|
|
390
|
+
finish = handler({ name, type, path });
|
|
363
391
|
} catch {
|
|
364
392
|
finish = undefined;
|
|
365
393
|
}
|
|
@@ -374,10 +402,10 @@ const observeSteps = (handler) => async (name, type, op) => {
|
|
|
374
402
|
const started = now();
|
|
375
403
|
try {
|
|
376
404
|
const result = await op();
|
|
377
|
-
report({ name, type, result, durationMs: now() - started });
|
|
405
|
+
report({ name, type, path, result, durationMs: now() - started });
|
|
378
406
|
return result;
|
|
379
407
|
} catch (error) {
|
|
380
|
-
report({ name, type, error, durationMs: now() - started });
|
|
408
|
+
report({ name, type, path, error, durationMs: now() - started });
|
|
381
409
|
throw error;
|
|
382
410
|
}
|
|
383
411
|
};
|
|
@@ -411,7 +439,7 @@ const chainHooks = (...configs) => {
|
|
|
411
439
|
|
|
412
440
|
if (steps.length) {
|
|
413
441
|
merged.onStep = steps.reduceRight(
|
|
414
|
-
(inner, outer) => (name, type, op) => outer(name, type, () => inner(name, type, op))
|
|
442
|
+
(inner, outer) => (name, type, op, path) => outer(name, type, () => inner(name, type, op, path), path)
|
|
415
443
|
);
|
|
416
444
|
}
|
|
417
445
|
if (runs.length) {
|
|
@@ -428,6 +456,45 @@ const chainHooks = (...configs) => {
|
|
|
428
456
|
return merged;
|
|
429
457
|
};
|
|
430
458
|
|
|
459
|
+
/**
|
|
460
|
+
* The Failure a `Parallel` branch resolves to when a sibling branch has already failed.
|
|
461
|
+
*
|
|
462
|
+
* Branches are cancelled, not merely ignored: this is what `Parallel` returns for a branch that was
|
|
463
|
+
* still in flight, and it is deliberately distinguishable so it can be told apart from the genuine
|
|
464
|
+
* failure that triggered the cancellation.
|
|
465
|
+
* @returns {Error}
|
|
466
|
+
*/
|
|
467
|
+
const parallelCancelled = () =>
|
|
468
|
+
Object.assign(new Error('Parallel branch cancelled.'), {
|
|
469
|
+
name: 'ParallelCancelled'
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Sleeps, but gives up early when the surrounding branch is cancelled, so a sibling's failure does not
|
|
474
|
+
* have to wait out a retry backoff that is now pointless. Resolves either way: the caller's abort check
|
|
475
|
+
* is what turns a cancelled wait into a Failure.
|
|
476
|
+
*
|
|
477
|
+
* @param {number} ms
|
|
478
|
+
* @param {AbortSignal} [signal]
|
|
479
|
+
* @returns {Promise<void>}
|
|
480
|
+
*/
|
|
481
|
+
const delayFor = (ms, signal) =>
|
|
482
|
+
new Promise((resolve) => {
|
|
483
|
+
if (!signal) {
|
|
484
|
+
setTimeout(resolve, ms);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const onAbort = () => {
|
|
488
|
+
clearTimeout(timer);
|
|
489
|
+
resolve();
|
|
490
|
+
};
|
|
491
|
+
const timer = setTimeout(() => {
|
|
492
|
+
signal.removeEventListener('abort', onAbort);
|
|
493
|
+
resolve();
|
|
494
|
+
}, ms);
|
|
495
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
496
|
+
});
|
|
497
|
+
|
|
431
498
|
const runEffect =
|
|
432
499
|
/**
|
|
433
500
|
* The Interpreter
|
|
@@ -451,13 +518,25 @@ const runEffect =
|
|
|
451
518
|
|
|
452
519
|
/**
|
|
453
520
|
* @param {Effect} eff
|
|
521
|
+
* @param {AbortSignal} [signal] - Cancellation for this subtree, set for `Parallel` branches.
|
|
522
|
+
* A branch stops starting new Commands once it is aborted, and the signal is handed to
|
|
523
|
+
* each Command's thunk so I/O that accepts one can be cancelled in flight.
|
|
524
|
+
* @param {string} [path] - Prefix identifying this subtree's position in the Effect tree.
|
|
525
|
+
* Steps are numbered sequentially within a subtree and each `Parallel` branch and `Retry`
|
|
526
|
+
* attempt opens its own prefix, so a Command's full path depends only on the shape of the
|
|
527
|
+
* tree and not on the order branches happen to finish in. That is what lets a replay line
|
|
528
|
+
* a recorded step up with the step that asked for it.
|
|
454
529
|
* @returns {Promise<SuccessState | FailureState>}
|
|
455
530
|
*/
|
|
456
|
-
async function execute(eff) {
|
|
531
|
+
async function execute(eff, signal, path = '') {
|
|
532
|
+
let step = 0;
|
|
457
533
|
while (
|
|
458
534
|
eff &&
|
|
459
535
|
(eff.type === 'Command' || eff.type === 'Ask' || eff.type === 'Retry' || eff.type === 'Parallel')
|
|
460
536
|
) {
|
|
537
|
+
// Checked before every node, which is what short-circuits a branch whose Commands
|
|
538
|
+
// ignore the signal: the one in flight cannot be stopped, but the next never starts.
|
|
539
|
+
if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
|
|
461
540
|
if (eff.type === 'Ask') {
|
|
462
541
|
eff = eff.next(context);
|
|
463
542
|
continue;
|
|
@@ -467,14 +546,24 @@ const runEffect =
|
|
|
467
546
|
const { attempts } = opts;
|
|
468
547
|
let lastError;
|
|
469
548
|
let succeeded = false;
|
|
549
|
+
// Each attempt gets its own prefix, so the Commands of attempt 2 cannot be mistaken
|
|
550
|
+
// for the Commands of attempt 1 when a trace is matched back up.
|
|
551
|
+
const retryPath = `${path}${step++}r`;
|
|
552
|
+
// Captured while `eff` is still narrowed to a Retry node: the loop reassigns `eff`
|
|
553
|
+
// on success, after which the checker cannot prove the fallback path still holds it.
|
|
554
|
+
const retryNext = eff.next;
|
|
470
555
|
|
|
471
556
|
for (let attempt = 0; attempt <= attempts; attempt++) {
|
|
472
557
|
if (attempt > 0) {
|
|
473
|
-
await
|
|
558
|
+
await delayFor(opts.delay * Math.pow(opts.backoff, attempt - 1), signal);
|
|
474
559
|
}
|
|
475
|
-
|
|
560
|
+
// Checked after the wait, so a branch cancelled mid-backoff stops here rather
|
|
561
|
+
// than buying one more attempt, and a sibling's failure does not pay for the
|
|
562
|
+
// rest of this branch's retry schedule.
|
|
563
|
+
if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
|
|
564
|
+
const result = await execute(eff.effect, signal, `${retryPath}${attempt}/`);
|
|
476
565
|
if (result.type === 'Success') {
|
|
477
|
-
eff =
|
|
566
|
+
eff = retryNext(result.value);
|
|
478
567
|
succeeded = true;
|
|
479
568
|
break;
|
|
480
569
|
}
|
|
@@ -482,22 +571,81 @@ const runEffect =
|
|
|
482
571
|
}
|
|
483
572
|
|
|
484
573
|
if (!succeeded) {
|
|
485
|
-
|
|
574
|
+
const exhausted = { retryExhausted: true, lastError, attempts };
|
|
575
|
+
const { onExhausted } = opts;
|
|
576
|
+
if (typeof onExhausted !== 'function') return Failure(exhausted, eff.initialInput);
|
|
577
|
+
// A cancelled branch must not start its fallback, for the same reason it starts
|
|
578
|
+
// no further Commands: recovery must not resurrect work a sibling's failure ended.
|
|
579
|
+
if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
|
|
580
|
+
// The fallback gets its own path prefix, so a fallback-path trace can never be
|
|
581
|
+
// confused with a success-path one when it is matched back up on replay.
|
|
582
|
+
const fallback = await execute(
|
|
583
|
+
asEffect(onExhausted(exhausted), "Retry option 'onExhausted'"),
|
|
584
|
+
signal,
|
|
585
|
+
`${retryPath}f/`
|
|
586
|
+
);
|
|
587
|
+
// A failing fallback propagates as-is: the flow's last word was the fallback's
|
|
588
|
+
// error, not the exhaustion it was already told about.
|
|
589
|
+
if (fallback.type === 'Failure') return fallback;
|
|
590
|
+
eff = retryNext(fallback.value);
|
|
486
591
|
}
|
|
487
592
|
continue;
|
|
488
593
|
}
|
|
489
594
|
if (eff.type === 'Parallel') {
|
|
490
|
-
|
|
491
|
-
|
|
595
|
+
// Branch prefixes come from the branch's index in the array, never from the order
|
|
596
|
+
// branches complete in, which is the whole point: two branches calling the same
|
|
597
|
+
// Command are told apart by where they are rather than by who finished first.
|
|
598
|
+
const branchPath = `${path}${step++}p`;
|
|
599
|
+
// One scope per Parallel, linked to the enclosing one so cancellation nests.
|
|
600
|
+
// Absent AbortController (very old runtimes), `branchSignal` stays undefined and
|
|
601
|
+
// the old run-everything-to-completion behaviour is what happens.
|
|
602
|
+
const scope = typeof AbortController === 'function' ? new AbortController() : undefined;
|
|
603
|
+
const branchSignal = scope?.signal;
|
|
604
|
+
const relay = () => scope?.abort();
|
|
605
|
+
if (signal && scope) {
|
|
606
|
+
if (signal.aborted) scope.abort();
|
|
607
|
+
else signal.addEventListener('abort', relay, { once: true });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** @type {(SuccessState | FailureState)[]} */
|
|
611
|
+
const results = new Array(eff.effects.length);
|
|
612
|
+
// Which branch failed on its own account rather than because it was cancelled.
|
|
613
|
+
const triggered = new Array(eff.effects.length).fill(false);
|
|
614
|
+
try {
|
|
615
|
+
// Still awaits every branch, so no cancelled work is left running unobserved
|
|
616
|
+
// after the Failure is returned. Cancelled branches settle promptly; a branch
|
|
617
|
+
// whose in-flight Command ignores the signal is the one case that does not.
|
|
618
|
+
await Promise.all(
|
|
619
|
+
eff.effects.map(async (e, i) => {
|
|
620
|
+
const result = await execute(e, branchSignal, `${branchPath}${i}/`);
|
|
621
|
+
results[i] = result;
|
|
622
|
+
// Read-then-abort is atomic here, so exactly one branch is the trigger.
|
|
623
|
+
if (result.type === 'Failure' && !branchSignal?.aborted) {
|
|
624
|
+
triggered[i] = true;
|
|
625
|
+
scope?.abort();
|
|
626
|
+
}
|
|
627
|
+
})
|
|
628
|
+
);
|
|
629
|
+
} finally {
|
|
630
|
+
if (signal && scope) signal.removeEventListener('abort', relay);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const trigger = triggered.indexOf(true);
|
|
634
|
+
const failure = trigger >= 0 ? results[trigger] : results.find((r) => r.type === 'Failure');
|
|
492
635
|
if (failure) return failure;
|
|
493
636
|
eff = eff.next(results.map((r) => /** @type {SuccessState} */ (r).value));
|
|
494
637
|
continue;
|
|
495
638
|
}
|
|
496
639
|
const cmdName = commandName(eff);
|
|
497
640
|
const initialInput = eff.initialInput;
|
|
641
|
+
const cmdPath = `${path}${step++}`;
|
|
642
|
+
const cmd = eff.cmd;
|
|
643
|
+
// The signal reaches the thunk only inside a Parallel, so a thunk written to take a
|
|
644
|
+
// parameter is not handed an argument it never expected anywhere else.
|
|
645
|
+
const op = signal ? () => cmd(signal) : cmd;
|
|
498
646
|
try {
|
|
499
647
|
await localCommandInterceptor(eff, context);
|
|
500
|
-
const result = await localStepRunner(cmdName, 'Command',
|
|
648
|
+
const result = await localStepRunner(cmdName, 'Command', op, cmdPath);
|
|
501
649
|
eff = eff.next(result);
|
|
502
650
|
} catch (e) {
|
|
503
651
|
// A malformed flow is a bug, not a domain failure, so it must not masquerade as one.
|
|
@@ -512,7 +660,12 @@ const runEffect =
|
|
|
512
660
|
return localRunWrapper(effect, () => execute(effect), context?.flowName || '');
|
|
513
661
|
};
|
|
514
662
|
|
|
515
|
-
/**
|
|
663
|
+
/**
|
|
664
|
+
* The step a replay is asking about. `path` is the Command's position in the Effect tree and is stable
|
|
665
|
+
* across runs; `index` is its position in this run's completion order, which is not stable for a flow
|
|
666
|
+
* containing `Parallel`. Prefer `path` when writing a Resolver.
|
|
667
|
+
* @typedef {{ index: number, name: string, type: string, path?: string }} ReplayStep
|
|
668
|
+
*/
|
|
516
669
|
|
|
517
670
|
/**
|
|
518
671
|
* What production observed for a step. `{ result }` is handed to the Command's
|
|
@@ -527,7 +680,9 @@ const runEffect =
|
|
|
527
680
|
/**
|
|
528
681
|
* A recorded step. `durationMs` is how long the Command took in production, rounded to microseconds,
|
|
529
682
|
* which is the one question a trace could not answer before: which step was slow.
|
|
530
|
-
*
|
|
683
|
+
* `path` locates the Command in the Effect tree, which is what a replay matches on: it does not move
|
|
684
|
+
* when `Parallel` branches finish in a different order than they did in production.
|
|
685
|
+
* @typedef {{ command: string, path?: string, result?: any, error?: any, durationMs?: number }} TraceEntry
|
|
531
686
|
*/
|
|
532
687
|
|
|
533
688
|
/**
|
|
@@ -560,12 +715,17 @@ const replayError = (message, props = {}) => Object.assign(new Error(message), {
|
|
|
560
715
|
* @returns {Error}
|
|
561
716
|
*/
|
|
562
717
|
const timeParadox = (step, recorded) =>
|
|
563
|
-
replayError(
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
718
|
+
replayError(
|
|
719
|
+
`Time paradox at ${step.path !== undefined ? `path '${step.path}'` : `step ${step.index}`}: ` +
|
|
720
|
+
`flow asked for '${step.name}', trace recorded '${recorded}'`,
|
|
721
|
+
{
|
|
722
|
+
name: 'TimeParadox',
|
|
723
|
+
index: step.index,
|
|
724
|
+
path: step.path,
|
|
725
|
+
expected: recorded,
|
|
726
|
+
actual: step.name
|
|
727
|
+
}
|
|
728
|
+
);
|
|
569
729
|
|
|
570
730
|
/**
|
|
571
731
|
* Converts a thrown value into something JSON can carry. `message` and `stack` are
|
|
@@ -580,6 +740,8 @@ const serializeError = (e, withStack) => {
|
|
|
580
740
|
/** @type {any} */
|
|
581
741
|
const out = { __error: true, name: e.name, message: e.message };
|
|
582
742
|
if (withStack) out.stack = e.stack;
|
|
743
|
+
// `cause` is non-enumerable too, and it can itself be an Error, so it is carried recursively.
|
|
744
|
+
if ('cause' in e) out.cause = serializeError(e.cause, withStack);
|
|
583
745
|
for (const k of Object.keys(e)) out[k] = /** @type {any} */ (e)[k];
|
|
584
746
|
return out;
|
|
585
747
|
};
|
|
@@ -595,7 +757,9 @@ const reviveError = (v) => {
|
|
|
595
757
|
const e = new Error(v.message);
|
|
596
758
|
e.name = v.name;
|
|
597
759
|
for (const [k, val] of Object.entries(v)) {
|
|
598
|
-
if (k !== '__error' && k !== 'name' && k !== 'message')
|
|
760
|
+
if (k !== '__error' && k !== 'name' && k !== 'message') {
|
|
761
|
+
/** @type {any} */ (e)[k] = k === 'cause' ? reviveError(val) : val;
|
|
762
|
+
}
|
|
599
763
|
}
|
|
600
764
|
return e;
|
|
601
765
|
};
|
|
@@ -678,16 +842,17 @@ const recorder = (options = {}) => {
|
|
|
678
842
|
|
|
679
843
|
// Built on `observe` so the hook contract lives in one place: `op` always runs, its result is
|
|
680
844
|
// always returned, and its error always propagates.
|
|
681
|
-
const onStep = observeSteps(({ name }) => (end) => {
|
|
845
|
+
const onStep = observeSteps(({ name, path }) => (end) => {
|
|
682
846
|
const durationMs = Math.round(end.durationMs * 1000) / 1000;
|
|
683
847
|
push(
|
|
684
848
|
'error' in end
|
|
685
849
|
? {
|
|
686
850
|
command: name,
|
|
851
|
+
path,
|
|
687
852
|
error: snapshot(safeRedact(serializeError(end.error, stack), name, 'error')),
|
|
688
853
|
durationMs
|
|
689
854
|
}
|
|
690
|
-
: { command: name, result: snapshot(safeRedact(end.result, name, 'result')), durationMs }
|
|
855
|
+
: { command: name, path, result: snapshot(safeRedact(end.result, name, 'result')), durationMs }
|
|
691
856
|
);
|
|
692
857
|
});
|
|
693
858
|
|
|
@@ -741,18 +906,21 @@ const entryToOutcome = (entry) => ('error' in entry ? { error: reviveError(entry
|
|
|
741
906
|
* when handed a trace instead of a Resolver, and is the only caller. Callers with traces
|
|
742
907
|
* in some other shape write a Resolver instead, which is the extension point.
|
|
743
908
|
*
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
909
|
+
* Path matching is used whenever every entry carries a `path`, which is every trace this
|
|
910
|
+
* version records. A path is the Command's position in the Effect tree, so it is order-independent
|
|
911
|
+
* and still paradox-detecting: it works for `Parallel` without `strict: false`, and it tells two
|
|
912
|
+
* branches that call the same Command apart by where they are rather than by which finished first.
|
|
913
|
+
* `strict` does not apply, since path matching is neither positional nor lenient.
|
|
747
914
|
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
*
|
|
915
|
+
* The two older modes remain for traces recorded before paths existed. Strict matching consumes
|
|
916
|
+
* entries in recorded order and verifies the flow asks for the Command the trace expects. Name
|
|
917
|
+
* matching resolves per-Command FIFO queues, which was the only option for `Parallel` and is the
|
|
918
|
+
* mode that could pair a branch with a sibling's result when both called the same Command.
|
|
752
919
|
*
|
|
753
920
|
* @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
|
|
754
921
|
* @param {Object} [options]
|
|
755
|
-
* @param {boolean} [options.strict] - Positional matching with paradox detection (default `true`)
|
|
922
|
+
* @param {boolean} [options.strict] - Positional matching with paradox detection (default `true`).
|
|
923
|
+
* Ignored for a trace carrying paths.
|
|
756
924
|
* @returns {Resolver}
|
|
757
925
|
*/
|
|
758
926
|
const fromTrace = (traceLog, options = {}) => {
|
|
@@ -760,6 +928,30 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
760
928
|
const entries = Array.isArray(traceLog) ? traceLog : traceLog?.trace;
|
|
761
929
|
if (!Array.isArray(entries)) throw replayError('Trace has no `trace` array.');
|
|
762
930
|
|
|
931
|
+
// Paths win when the trace has them, whatever `strict` says: a caller who passed `strict: false`
|
|
932
|
+
// to work around Parallel should get the correct matching once their traces carry paths, rather
|
|
933
|
+
// than keeping the mode that option existed to work around.
|
|
934
|
+
if (entries.length > 0 && entries.every((e) => typeof e.path === 'string')) {
|
|
935
|
+
const byPath = new Map(entries.map((e) => [e.path, e]));
|
|
936
|
+
// Paths are derived from tree position, so they are unique by construction. A collision means
|
|
937
|
+
// either a hand-built trace or a bug in path derivation, and silently keeping the last entry
|
|
938
|
+
// would reintroduce exactly the wrong-result-per-branch failure paths exist to prevent.
|
|
939
|
+
if (byPath.size !== entries.length) {
|
|
940
|
+
throw replayError('Trace has duplicate step paths, so it cannot be matched by path.');
|
|
941
|
+
}
|
|
942
|
+
return (step) => {
|
|
943
|
+
const entry = byPath.get(step.path);
|
|
944
|
+
if (!entry) {
|
|
945
|
+
throw replayError(`Trace has no step at path '${step.path}' for '${step.name}'.`, {
|
|
946
|
+
command: step.name,
|
|
947
|
+
path: step.path
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
if (entry.command !== step.name) throw timeParadox(step, entry.command);
|
|
951
|
+
return entryToOutcome(entry);
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
763
955
|
if (strict) {
|
|
764
956
|
return (step) => {
|
|
765
957
|
const entry = entries[step.index];
|
|
@@ -796,13 +988,21 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
796
988
|
const zeroRetryDelays = (eff) => {
|
|
797
989
|
if (!eff || typeof eff.type !== 'string') return eff;
|
|
798
990
|
switch (eff.type) {
|
|
799
|
-
case 'Retry':
|
|
991
|
+
case 'Retry': {
|
|
992
|
+
const options = { ...eff.options, delay: 0, backoff: 1 };
|
|
993
|
+
// The fallback tree only exists once onExhausted runs, so it is rewritten lazily too;
|
|
994
|
+
// otherwise a Retry inside the fallback keeps its production backoff during replay.
|
|
995
|
+
if (typeof options.onExhausted === 'function') {
|
|
996
|
+
const original = options.onExhausted;
|
|
997
|
+
options.onExhausted = (/** @type {any} */ error) => zeroRetryDelays(original(error));
|
|
998
|
+
}
|
|
800
999
|
return {
|
|
801
1000
|
...eff,
|
|
802
|
-
options
|
|
1001
|
+
options,
|
|
803
1002
|
effect: zeroRetryDelays(eff.effect),
|
|
804
1003
|
next: (/** @type {any} */ value) => zeroRetryDelays(eff.next(value))
|
|
805
1004
|
};
|
|
1005
|
+
}
|
|
806
1006
|
case 'Command':
|
|
807
1007
|
return { ...eff, next: (/** @type {any} */ result) => zeroRetryDelays(eff.next(result)) };
|
|
808
1008
|
case 'Ask':
|
|
@@ -863,8 +1063,8 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
863
1063
|
let index = 0;
|
|
864
1064
|
|
|
865
1065
|
/** @type {StepRunner} */
|
|
866
|
-
const onStep = async (name, type, op) => {
|
|
867
|
-
const step = { index: index++, name, type };
|
|
1066
|
+
const onStep = async (name, type, op, path) => {
|
|
1067
|
+
const step = { index: index++, name, type, path };
|
|
868
1068
|
const outcome = resolve(step);
|
|
869
1069
|
if (onResolved) onResolved(step, outcome);
|
|
870
1070
|
if (outcome === undefined) {
|
|
@@ -918,17 +1118,19 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
918
1118
|
if (version && traceVersion && version !== traceVersion) {
|
|
919
1119
|
log(`Warning: trace was recorded at ${traceVersion}, replaying against ${version}.`);
|
|
920
1120
|
}
|
|
921
|
-
const stepsText =
|
|
1121
|
+
const stepsText = trace.length === 0 || trace.length > 1 ? 'steps' : 'step';
|
|
922
1122
|
log(`Replaying '${flowName || 'flow'}' (${trace.length} recorded ${stepsText})`);
|
|
923
1123
|
log(`Initial input: ${format(initialInput)}`);
|
|
924
1124
|
|
|
925
1125
|
// Narration goes through `onResolved` rather than a wrapped Resolver: observing each
|
|
926
1126
|
// step is all this needs, and `replayEffect` already resolves the trace itself.
|
|
927
1127
|
let consumed = 0;
|
|
928
|
-
//
|
|
929
|
-
// are narrated
|
|
1128
|
+
// A path maps a replayed step back to its recorded entry whatever order branches finished in, so
|
|
1129
|
+
// timings are narrated for Parallel too. Positional lookup is the fallback for a legacy trace with
|
|
1130
|
+
// no paths, and only under strict matching, since per-Command queues carry no position.
|
|
1131
|
+
const byPath = new Map(trace.filter((e) => typeof e.path === 'string').map((e) => [e.path, e]));
|
|
930
1132
|
const timing = (/** @type {ReplayStep} */ step) => {
|
|
931
|
-
const recorded = strict ? trace[step.index] : undefined;
|
|
1133
|
+
const recorded = byPath.get(step.path) ?? (strict ? trace[step.index] : undefined);
|
|
932
1134
|
return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
|
|
933
1135
|
};
|
|
934
1136
|
const result = await replayEffect(flowFn(initialInput), traceLog, {
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pure-effect",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.11.0",
|
|
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
|
"type": "module",
|
|
6
6
|
"exports": "./index.js",
|
|
7
7
|
"types": "./index.d.ts",
|
|
8
8
|
"scripts": {
|
|
9
|
-
"test": "mocha && tsd",
|
|
9
|
+
"test": "mocha && tsd && tsc -p tsconfig.check.json",
|
|
10
10
|
"format": "prettier --write index.js index.d.ts examples/*.js test/all.js README.md CLAUDE.md"
|
|
11
11
|
},
|
|
12
12
|
"tsd": {
|
|
13
13
|
"directory": "test"
|
|
14
14
|
},
|
|
15
15
|
"author": "Aycan Gulez",
|
|
16
|
-
"homepage": "https://
|
|
16
|
+
"homepage": "https://pure-effect.org",
|
|
17
17
|
"license": "MIT",
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@opentelemetry/api": "^1.9.1",
|