pure-effect 0.10.0 → 0.12.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 +33 -21
- package/README.md +134 -17
- package/index.d.ts +68 -17
- package/index.js +130 -64
- package/jsconfig.json +13 -0
- package/package.json +5 -5
- package/test/all.js +416 -37
- package/test/types.test-d.ts +117 -13
- package/.claude/settings.local.json +0 -17
package/index.d.ts
CHANGED
|
@@ -27,14 +27,19 @@ export type CommandState<R, T, E = unknown, Ctx = unknown> = {
|
|
|
27
27
|
* `Parallel` no argument is passed at all.
|
|
28
28
|
*/
|
|
29
29
|
cmd: (signal?: AbortSignal) => Promise<R> | R;
|
|
30
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Method syntax, not a function-typed property, on every state's `next`: `strictFunctionTypes`
|
|
32
|
+
* checks a property's parameter contravariantly, which made a `CommandState<never, ...>` (a `cmd`
|
|
33
|
+
* that only throws) unassignable to `Effect`. Method parameters are bivariant, so it is accepted.
|
|
34
|
+
*/
|
|
35
|
+
next(result: R): Effect<T, E, Ctx>;
|
|
31
36
|
meta?: CommandMeta;
|
|
32
37
|
initialInput?: unknown;
|
|
33
38
|
};
|
|
34
39
|
|
|
35
40
|
export type AskState<T, E = unknown, Ctx = unknown> = {
|
|
36
41
|
type: 'Ask';
|
|
37
|
-
next
|
|
42
|
+
next(context: Ctx): Effect<T, E, Ctx>;
|
|
38
43
|
initialInput?: unknown;
|
|
39
44
|
};
|
|
40
45
|
|
|
@@ -44,11 +49,16 @@ export type RetryOptions = {
|
|
|
44
49
|
backoff?: number;
|
|
45
50
|
};
|
|
46
51
|
|
|
52
|
+
/**
|
|
53
|
+
* `E` is the error the node contributes to its pipeline, which for a Retry is the exhaustion failure:
|
|
54
|
+
* the interpreter never lets an inner attempt's error escape unwrapped, so the wrapped tree's own
|
|
55
|
+
* error type is consumed by the retry loop rather than exposed here.
|
|
56
|
+
*/
|
|
47
57
|
export type RetryState<T, E = unknown, Ctx = unknown> = {
|
|
48
58
|
type: 'Retry';
|
|
49
|
-
effect: Effect<T,
|
|
50
|
-
options: RetryOptions;
|
|
51
|
-
next
|
|
59
|
+
effect: Effect<T, any, Ctx>;
|
|
60
|
+
options: RetryOptions & { onExhausted?: (error: RetryExhaustedError<any>) => Effect<T, any, Ctx> };
|
|
61
|
+
next(value: T): Effect<T, E, Ctx>;
|
|
52
62
|
initialInput?: unknown;
|
|
53
63
|
};
|
|
54
64
|
|
|
@@ -61,7 +71,7 @@ export type RetryExhaustedError<E = unknown> = {
|
|
|
61
71
|
export type ParallelState<T extends readonly unknown[], R, E = unknown, Ctx = unknown> = {
|
|
62
72
|
type: 'Parallel';
|
|
63
73
|
effects: { [K in keyof T]: Effect<T[K], E, Ctx> };
|
|
64
|
-
next
|
|
74
|
+
next(values: [...T]): Effect<R, E, Ctx>;
|
|
65
75
|
initialInput?: unknown;
|
|
66
76
|
};
|
|
67
77
|
|
|
@@ -93,10 +103,35 @@ export declare function Ask<T, E = unknown, Ctx = unknown>(
|
|
|
93
103
|
next: (context: Ctx) => Effect<T, E, Ctx>
|
|
94
104
|
): AskState<T, E, Ctx>;
|
|
95
105
|
|
|
106
|
+
/**
|
|
107
|
+
* With `onExhausted`, the exhaustion failure never escapes: the fallback Effect runs instead, its
|
|
108
|
+
* success feeds `next`, and its failure propagates unwrapped, so the node's declared error is the
|
|
109
|
+
* fallback's own error type. `onExhausted` is a per-use option only; the global `retry` defaults in
|
|
110
|
+
* `EffectConfiguration` deliberately cannot carry one.
|
|
111
|
+
*/
|
|
112
|
+
export declare function Retry<T, E = unknown, E2 = unknown, Ctx = unknown>(
|
|
113
|
+
effect: Effect<T, E, Ctx>,
|
|
114
|
+
options: RetryOptions & { onExhausted: (error: RetryExhaustedError<E>) => Effect<T, E2, Ctx> }
|
|
115
|
+
): RetryState<T, E2, Ctx>;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Without `onExhausted`, the declared error type is `RetryExhaustedError<E>`, because that is what
|
|
119
|
+
* a Retry's Failure actually carries: on exhaustion the interpreter returns
|
|
120
|
+
* `Failure({ retryExhausted: true, lastError, attempts })` rather than the inner error itself,
|
|
121
|
+
* so `result.error.lastError` is where the wrapped tree's `E` survives.
|
|
122
|
+
*/
|
|
96
123
|
export declare function Retry<T, E = unknown, Ctx = unknown>(
|
|
97
124
|
effect: Effect<T, E, Ctx>,
|
|
98
125
|
options?: RetryOptions
|
|
99
|
-
): RetryState<T, E
|
|
126
|
+
): RetryState<T, RetryExhaustedError<E>, Ctx>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `next` is optional and defaults to `(values) => Success(values)`, same as `Command`'s default,
|
|
130
|
+
* so a bare `Parallel(effects)` resolves to the ordered array of success values.
|
|
131
|
+
*/
|
|
132
|
+
export declare function Parallel<T extends readonly unknown[], E = unknown, Ctx = unknown>(effects: {
|
|
133
|
+
[K in keyof T]: Effect<T[K], E, Ctx>;
|
|
134
|
+
}): ParallelState<[...T], [...T], E, Ctx>;
|
|
100
135
|
|
|
101
136
|
export declare function Parallel<T extends readonly unknown[], R, E = unknown, Ctx = unknown>(
|
|
102
137
|
effects: { [K in keyof T]: Effect<T[K], E, Ctx> },
|
|
@@ -376,29 +411,45 @@ export interface ReplayOptions<Ctx = unknown> {
|
|
|
376
411
|
*/
|
|
377
412
|
onMissing?: 'throw' | 'execute';
|
|
378
413
|
onResolved?: (step: ReplayStep, outcome: ReplayOutcome | undefined) => void;
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* What a replay returns: the flow's own outcome and, for a trace, the recorded entries the flow
|
|
418
|
+
* never asked for. A flow that stops issuing Commands early mismatches nothing, so `result` can be a
|
|
419
|
+
* `Success` with steps left over; `unreached` is where that shows. It is absent for a Resolver,
|
|
420
|
+
* since only a trace knows what it holds.
|
|
421
|
+
*/
|
|
422
|
+
export interface Replay<T, E = unknown> {
|
|
423
|
+
result: SuccessState<T> | FailureState<E>;
|
|
424
|
+
unreached: TraceEntry[];
|
|
387
425
|
}
|
|
388
426
|
|
|
389
427
|
/**
|
|
390
428
|
* Pass a trace to replay it directly, or a Resolver when traces are stored in some other
|
|
391
429
|
* shape. To observe a replay rather than change how it resolves, use `onResolved`.
|
|
392
430
|
* A malformed trace rejects with a `ReplayError`.
|
|
431
|
+
*
|
|
432
|
+
* Three overloads: a trace yields `unreached`, a Resolver yields none, and a source only known
|
|
433
|
+
* as the union of the two (a wrapper forwarding whatever it was handed) yields it as optional.
|
|
393
434
|
*/
|
|
435
|
+
export declare function replayEffect<T, E = unknown, Ctx = unknown>(
|
|
436
|
+
effect: Effect<T, E, Ctx>,
|
|
437
|
+
trace: TraceLog | TraceEntry[],
|
|
438
|
+
options?: ReplayOptions<Ctx>
|
|
439
|
+
): Promise<Replay<T, E>>;
|
|
440
|
+
export declare function replayEffect<T, E = unknown, Ctx = unknown>(
|
|
441
|
+
effect: Effect<T, E, Ctx>,
|
|
442
|
+
resolver: Resolver,
|
|
443
|
+
options?: ReplayOptions<Ctx>
|
|
444
|
+
): Promise<Omit<Replay<T, E>, 'unreached'>>;
|
|
394
445
|
export declare function replayEffect<T, E = unknown, Ctx = unknown>(
|
|
395
446
|
effect: Effect<T, E, Ctx>,
|
|
396
447
|
traceOrResolver: Resolver | TraceLog | TraceEntry[],
|
|
397
448
|
options?: ReplayOptions<Ctx>
|
|
398
|
-
): Promise<
|
|
449
|
+
): Promise<Omit<Replay<T, E>, 'unreached'> & { unreached?: TraceEntry[] }>;
|
|
399
450
|
|
|
400
451
|
export declare function timeTravel<T, E = unknown, Ctx = unknown>(
|
|
401
452
|
flowFn: (input: any) => Effect<T, E, Ctx>,
|
|
402
453
|
traceLog: TraceLog,
|
|
403
|
-
options?: {
|
|
454
|
+
options?: { log?: (...args: any[]) => void; context?: Ctx; version?: string }
|
|
404
455
|
): Promise<SuccessState<T> | FailureState<E>>;
|
package/index.js
CHANGED
|
@@ -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
|
|
@@ -89,7 +89,7 @@ const Command = (cmd, next = (/** @type {any} */ result) => Success(result), met
|
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
* The name a Command is known by: what a trace records, what
|
|
92
|
+
* The name a Command is known by: what a trace records, what replay matches on, and what a
|
|
93
93
|
* telemetry span is called.
|
|
94
94
|
*
|
|
95
95
|
* `meta.name` wins when it is a non-empty string. Otherwise the identity is the thunk's own `name`,
|
|
@@ -125,6 +125,10 @@ const Ask = (next) => ({ type: 'Ask', next });
|
|
|
125
125
|
* @param {number} [options.attempts] - Max retries (not counting first try)
|
|
126
126
|
* @param {number} [options.delay] - Ms before first retry
|
|
127
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.
|
|
128
132
|
* @returns {RetryState}
|
|
129
133
|
*/
|
|
130
134
|
const Retry = (effect, options = {}) => ({
|
|
@@ -145,10 +149,15 @@ const Retry = (effect, options = {}) => ({
|
|
|
145
149
|
* signal runs to completion, so a branch's first Command can still write after a sibling has failed.
|
|
146
150
|
*
|
|
147
151
|
* @param {Effect[]} effects - Array of Effect trees to run concurrently
|
|
148
|
-
* @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.
|
|
149
154
|
* @returns {ParallelState}
|
|
150
155
|
*/
|
|
151
|
-
const Parallel = (effects, next
|
|
156
|
+
const Parallel = (effects, next = (/** @type {any[]} */ values) => Success(values)) => ({
|
|
157
|
+
type: 'Parallel',
|
|
158
|
+
effects,
|
|
159
|
+
next
|
|
160
|
+
});
|
|
152
161
|
|
|
153
162
|
/**
|
|
154
163
|
* Describes a value for an error message, leading with the mistake it most likely is.
|
|
@@ -540,6 +549,9 @@ const runEffect =
|
|
|
540
549
|
// Each attempt gets its own prefix, so the Commands of attempt 2 cannot be mistaken
|
|
541
550
|
// for the Commands of attempt 1 when a trace is matched back up.
|
|
542
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;
|
|
543
555
|
|
|
544
556
|
for (let attempt = 0; attempt <= attempts; attempt++) {
|
|
545
557
|
if (attempt > 0) {
|
|
@@ -551,7 +563,7 @@ const runEffect =
|
|
|
551
563
|
if (signal?.aborted) return Failure(parallelCancelled(), eff.initialInput);
|
|
552
564
|
const result = await execute(eff.effect, signal, `${retryPath}${attempt}/`);
|
|
553
565
|
if (result.type === 'Success') {
|
|
554
|
-
eff =
|
|
566
|
+
eff = retryNext(result.value);
|
|
555
567
|
succeeded = true;
|
|
556
568
|
break;
|
|
557
569
|
}
|
|
@@ -559,7 +571,23 @@ const runEffect =
|
|
|
559
571
|
}
|
|
560
572
|
|
|
561
573
|
if (!succeeded) {
|
|
562
|
-
|
|
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);
|
|
563
591
|
}
|
|
564
592
|
continue;
|
|
565
593
|
}
|
|
@@ -712,6 +740,8 @@ const serializeError = (e, withStack) => {
|
|
|
712
740
|
/** @type {any} */
|
|
713
741
|
const out = { __error: true, name: e.name, message: e.message };
|
|
714
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);
|
|
715
745
|
for (const k of Object.keys(e)) out[k] = /** @type {any} */ (e)[k];
|
|
716
746
|
return out;
|
|
717
747
|
};
|
|
@@ -725,9 +755,17 @@ const serializeError = (e, withStack) => {
|
|
|
725
755
|
const reviveError = (v) => {
|
|
726
756
|
if (!v || typeof v !== 'object' || v.__error !== true) return v;
|
|
727
757
|
const e = new Error(v.message);
|
|
728
|
-
|
|
758
|
+
// `name` and `cause` are non-enumerable on a native Error (`name` is inherited, `cause` is set by the
|
|
759
|
+
// constructor), so they are defined the same way here. Assigning them would make them own enumerable
|
|
760
|
+
// keys, and the revived error would then never compare deep-equal to the one the Command threw, which
|
|
761
|
+
// is what the record-then-replay determinism check relies on. Everything else was enumerable on the
|
|
762
|
+
// original, since `serializeError` read it through `Object.keys`, so plain assignment is faithful.
|
|
763
|
+
const hidden = { enumerable: false, configurable: true, writable: true };
|
|
764
|
+
Object.defineProperty(e, 'name', { ...hidden, value: v.name });
|
|
729
765
|
for (const [k, val] of Object.entries(v)) {
|
|
730
|
-
if (k
|
|
766
|
+
if (k === '__error' || k === 'name' || k === 'message') continue;
|
|
767
|
+
if (k === 'cause') Object.defineProperty(e, 'cause', { ...hidden, value: reviveError(val) });
|
|
768
|
+
else /** @type {any} */ (e)[k] = val;
|
|
731
769
|
}
|
|
732
770
|
return e;
|
|
733
771
|
};
|
|
@@ -876,36 +914,36 @@ const entryToOutcome = (entry) => ('error' in entry ? { error: reviveError(entry
|
|
|
876
914
|
*
|
|
877
915
|
* Path matching is used whenever every entry carries a `path`, which is every trace this
|
|
878
916
|
* version records. A path is the Command's position in the Effect tree, so it is order-independent
|
|
879
|
-
* and still paradox-detecting: it works for `Parallel`
|
|
880
|
-
*
|
|
881
|
-
* `strict` does not apply, since path matching is neither positional nor lenient.
|
|
917
|
+
* and still paradox-detecting: it works for `Parallel` as it is, and it tells two branches that
|
|
918
|
+
* call the same Command apart by where they are rather than by which finished first.
|
|
882
919
|
*
|
|
883
|
-
*
|
|
884
|
-
* entries in recorded order and
|
|
885
|
-
*
|
|
886
|
-
*
|
|
920
|
+
* A trace whose entries carry no path (recorded before paths existed, or written by hand) is
|
|
921
|
+
* matched positionally: entries are consumed in recorded order and each must name the Command the
|
|
922
|
+
* flow asks for. That is exact for sequential flows and cannot tell `Parallel` branches apart, which
|
|
923
|
+
* is what paths were added to fix.
|
|
887
924
|
*
|
|
888
925
|
* @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
|
|
889
926
|
* @param {Object} [options]
|
|
890
|
-
* @param {
|
|
891
|
-
*
|
|
927
|
+
* @param {(entry: TraceEntry) => void} [options.onEntry] - Observes each entry as it is handed to a step,
|
|
928
|
+
* which is how `replayEffect` learns which recorded entries the flow never asked for.
|
|
892
929
|
* @returns {Resolver}
|
|
893
930
|
*/
|
|
894
931
|
const fromTrace = (traceLog, options = {}) => {
|
|
895
|
-
const {
|
|
932
|
+
const { onEntry } = options;
|
|
896
933
|
const entries = Array.isArray(traceLog) ? traceLog : traceLog?.trace;
|
|
897
934
|
if (!Array.isArray(entries)) throw replayError('Trace has no `trace` array.');
|
|
935
|
+
const resolveEntry = (/** @type {TraceEntry} */ entry) => {
|
|
936
|
+
if (onEntry) onEntry(entry);
|
|
937
|
+
return entryToOutcome(entry);
|
|
938
|
+
};
|
|
898
939
|
|
|
899
|
-
// Paths win when the trace has them, whatever `strict` says: a caller who passed `strict: false`
|
|
900
|
-
// to work around Parallel should get the correct matching once their traces carry paths, rather
|
|
901
|
-
// than keeping the mode that option existed to work around.
|
|
902
940
|
if (entries.length > 0 && entries.every((e) => typeof e.path === 'string')) {
|
|
903
941
|
const byPath = new Map(entries.map((e) => [e.path, e]));
|
|
904
942
|
// Paths are derived from tree position, so they are unique by construction. A collision means
|
|
905
943
|
// either a hand-built trace or a bug in path derivation, and silently keeping the last entry
|
|
906
944
|
// would reintroduce exactly the wrong-result-per-branch failure paths exist to prevent.
|
|
907
945
|
if (byPath.size !== entries.length) {
|
|
908
|
-
throw replayError('Trace has duplicate step paths
|
|
946
|
+
throw replayError('Trace has duplicate step paths.');
|
|
909
947
|
}
|
|
910
948
|
return (step) => {
|
|
911
949
|
const entry = byPath.get(step.path);
|
|
@@ -916,29 +954,24 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
916
954
|
});
|
|
917
955
|
}
|
|
918
956
|
if (entry.command !== step.name) throw timeParadox(step, entry.command);
|
|
919
|
-
return
|
|
920
|
-
};
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
if (strict) {
|
|
924
|
-
return (step) => {
|
|
925
|
-
const entry = entries[step.index];
|
|
926
|
-
if (!entry) throw replayError(`Trace exhausted: no entry #${step.index} for '${step.name}'.`);
|
|
927
|
-
if (entry.command !== step.name) throw timeParadox(step, entry.command);
|
|
928
|
-
return entryToOutcome(entry);
|
|
957
|
+
return resolveEntry(entry);
|
|
929
958
|
};
|
|
930
959
|
}
|
|
931
960
|
|
|
932
|
-
/** @type {Map<string, TraceEntry[]>} */
|
|
933
|
-
const queues = new Map();
|
|
934
|
-
for (const entry of entries) {
|
|
935
|
-
if (!queues.has(entry.command)) queues.set(entry.command, []);
|
|
936
|
-
/** @type {TraceEntry[]} */ (queues.get(entry.command)).push(entry);
|
|
937
|
-
}
|
|
938
961
|
return (step) => {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
962
|
+
// Positional matching pairs steps by completion order, which is exactly what makes Parallel
|
|
963
|
+
// branches indistinguishable. Refusing is better than a result that is right only when the
|
|
964
|
+
// replay happens to finish in production's order.
|
|
965
|
+
if (typeof step.path === 'string' && /p\d+\//.test(step.path)) {
|
|
966
|
+
throw replayError(
|
|
967
|
+
`Trace carries no paths, so '${step.name}' inside a Parallel cannot be matched positionally.`,
|
|
968
|
+
{ command: step.name, path: step.path }
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
const entry = entries[step.index];
|
|
972
|
+
if (!entry) throw replayError(`Trace exhausted: no entry #${step.index} for '${step.name}'.`);
|
|
973
|
+
if (entry.command !== step.name) throw timeParadox(step, entry.command);
|
|
974
|
+
return resolveEntry(entry);
|
|
942
975
|
};
|
|
943
976
|
};
|
|
944
977
|
|
|
@@ -956,13 +989,21 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
956
989
|
const zeroRetryDelays = (eff) => {
|
|
957
990
|
if (!eff || typeof eff.type !== 'string') return eff;
|
|
958
991
|
switch (eff.type) {
|
|
959
|
-
case 'Retry':
|
|
992
|
+
case 'Retry': {
|
|
993
|
+
const options = { ...eff.options, delay: 0, backoff: 1 };
|
|
994
|
+
// The fallback tree only exists once onExhausted runs, so it is rewritten lazily too;
|
|
995
|
+
// otherwise a Retry inside the fallback keeps its production backoff during replay.
|
|
996
|
+
if (typeof options.onExhausted === 'function') {
|
|
997
|
+
const original = options.onExhausted;
|
|
998
|
+
options.onExhausted = (/** @type {any} */ error) => zeroRetryDelays(original(error));
|
|
999
|
+
}
|
|
960
1000
|
return {
|
|
961
1001
|
...eff,
|
|
962
|
-
options
|
|
1002
|
+
options,
|
|
963
1003
|
effect: zeroRetryDelays(eff.effect),
|
|
964
1004
|
next: (/** @type {any} */ value) => zeroRetryDelays(eff.next(value))
|
|
965
1005
|
};
|
|
1006
|
+
}
|
|
966
1007
|
case 'Command':
|
|
967
1008
|
return { ...eff, next: (/** @type {any} */ result) => zeroRetryDelays(eff.next(result)) };
|
|
968
1009
|
case 'Ask':
|
|
@@ -988,10 +1029,19 @@ const zeroRetryDelays = (eff) => {
|
|
|
988
1029
|
* `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
|
|
989
1030
|
* `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
|
|
990
1031
|
* @property {(step: ReplayStep, outcome: ReplayOutcome | undefined) => void} [onResolved] - Observes each step.
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
*
|
|
1032
|
+
*/
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* What a replay returns: the flow's own outcome, and, when a trace was supplied, the recorded
|
|
1036
|
+
* entries the flow never asked for. `unreached` is absent for a Resolver, since only a trace
|
|
1037
|
+
* knows what it holds.
|
|
1038
|
+
*
|
|
1039
|
+
* A flow that stops issuing Commands before its recording ends mismatches nothing, so no
|
|
1040
|
+
* `TimeParadox` fires and `result` can be a `Success` with recorded steps left over. That is
|
|
1041
|
+
* sometimes the point of a fix and sometimes a fix that quietly dropped a step; `unreached` is
|
|
1042
|
+
* the only place the difference shows, which is why it travels with the result rather than
|
|
1043
|
+
* behind an option.
|
|
1044
|
+
* @typedef {{ result: SuccessState | FailureState, unreached?: TraceEntry[] }} Replay
|
|
995
1045
|
*/
|
|
996
1046
|
|
|
997
1047
|
/**
|
|
@@ -1011,15 +1061,20 @@ const zeroRetryDelays = (eff) => {
|
|
|
1011
1061
|
* Resolver supplying each Command's recorded outcome (`undefined` if it has none). Write a Resolver when
|
|
1012
1062
|
* traces are stored in some other shape; to observe a replay without one, use `onResolved`.
|
|
1013
1063
|
* @param {ReplayOptions} [options]
|
|
1014
|
-
* @returns {Promise<
|
|
1064
|
+
* @returns {Promise<Replay>} `{ result, unreached }` for a trace, `{ result }` for a Resolver
|
|
1015
1065
|
*/
|
|
1016
1066
|
// `async` so a malformed trace arrives as a rejection rather than a synchronous throw:
|
|
1017
1067
|
// the function otherwise returns a promise, and callers should not have to handle both.
|
|
1018
1068
|
const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
1019
|
-
const { context = {}, fastRetry = true, hooks = false, onMissing = 'throw', onResolved
|
|
1069
|
+
const { context = {}, fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
|
|
1020
1070
|
// A trace is data and a Resolver is a function, so nothing else is needed to tell them
|
|
1021
1071
|
// apart, including the bare entries array that `fromTrace` also accepts.
|
|
1022
|
-
const
|
|
1072
|
+
const fromResolver = typeof traceOrResolver === 'function';
|
|
1073
|
+
/** @type {Set<TraceEntry>} */
|
|
1074
|
+
const reached = new Set();
|
|
1075
|
+
const resolve = fromResolver
|
|
1076
|
+
? traceOrResolver
|
|
1077
|
+
: fromTrace(traceOrResolver, { onEntry: (entry) => void reached.add(entry) });
|
|
1023
1078
|
let index = 0;
|
|
1024
1079
|
|
|
1025
1080
|
/** @type {StepRunner} */
|
|
@@ -1048,25 +1103,29 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1048
1103
|
callConfig.onBeforeCommand = async () => {};
|
|
1049
1104
|
}
|
|
1050
1105
|
|
|
1051
|
-
|
|
1106
|
+
const result = await runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
|
|
1107
|
+
if (fromResolver) return { result };
|
|
1108
|
+
// `fromTrace` has already validated the shape, so the entries are here in one form or the other.
|
|
1109
|
+
const entries = Array.isArray(traceOrResolver) ? traceOrResolver : traceOrResolver.trace;
|
|
1110
|
+
return { result, unreached: entries.filter((entry) => !reached.has(entry)) };
|
|
1052
1111
|
};
|
|
1053
1112
|
|
|
1054
1113
|
/**
|
|
1055
1114
|
* Replays a reference-format trace and narrates each step. Rebuilds the flow from the
|
|
1056
|
-
* recorded input, reports the outcome, and
|
|
1115
|
+
* recorded input, reports the outcome, and names any recorded steps that were never
|
|
1057
1116
|
* reached, which means the current code issued fewer Commands than production did.
|
|
1058
1117
|
*
|
|
1059
1118
|
* @param {(input: any) => Effect} flowFn - The same flow function that produced the trace
|
|
1060
1119
|
* @param {TraceLog} traceLog - A trace from `recordEffect` or a `recorder`
|
|
1061
1120
|
* @param {Object} [options]
|
|
1062
|
-
* @param {boolean} [options.strict] - Positional matching with paradox detection; set `false` for `Parallel`
|
|
1063
1121
|
* @param {(...args: any[]) => void} [options.log] - Defaults to `console.log`
|
|
1064
1122
|
* @param {any} [options.context] - Overrides the context stored on the trace
|
|
1065
1123
|
* @param {string} [options.version] - Current build id; warns when it differs from the trace's
|
|
1066
|
-
* @returns {Promise<SuccessState | FailureState>}
|
|
1124
|
+
* @returns {Promise<SuccessState | FailureState>} The flow's outcome. Narration is the point of this function;
|
|
1125
|
+
* a caller who wants the unreached entries as data uses `replayEffect`.
|
|
1067
1126
|
*/
|
|
1068
1127
|
const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
1069
|
-
const {
|
|
1128
|
+
const { log = console.log, context, version } = options;
|
|
1070
1129
|
const { initialInput, trace, flowName, version: traceVersion } = traceLog;
|
|
1071
1130
|
// `message` and `stack` are non-enumerable on Error, so JSON.stringify alone would
|
|
1072
1131
|
// drop the most useful line of the report.
|
|
@@ -1084,22 +1143,19 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1084
1143
|
|
|
1085
1144
|
// Narration goes through `onResolved` rather than a wrapped Resolver: observing each
|
|
1086
1145
|
// step is all this needs, and `replayEffect` already resolves the trace itself.
|
|
1087
|
-
let consumed = 0;
|
|
1088
1146
|
// A path maps a replayed step back to its recorded entry whatever order branches finished in, so
|
|
1089
|
-
// timings are narrated for Parallel too. Positional lookup is the fallback for a
|
|
1090
|
-
//
|
|
1147
|
+
// timings are narrated for Parallel too. Positional lookup is the fallback for a trace with no paths,
|
|
1148
|
+
// which is how such a trace is matched anyway.
|
|
1091
1149
|
const byPath = new Map(trace.filter((e) => typeof e.path === 'string').map((e) => [e.path, e]));
|
|
1092
1150
|
const timing = (/** @type {ReplayStep} */ step) => {
|
|
1093
|
-
const recorded = byPath.get(step.path) ??
|
|
1151
|
+
const recorded = byPath.get(step.path) ?? trace[step.index];
|
|
1094
1152
|
return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
|
|
1095
1153
|
};
|
|
1096
|
-
const
|
|
1097
|
-
strict,
|
|
1154
|
+
const replay = await replayEffect(flowFn(initialInput), traceLog, {
|
|
1098
1155
|
context: context !== undefined ? context : traceLog.context || {},
|
|
1099
1156
|
onResolved: (step, outcome) => {
|
|
1100
1157
|
// Resolving from a trace either answers or throws, so this is never undefined.
|
|
1101
1158
|
if (outcome === undefined) return;
|
|
1102
|
-
consumed++;
|
|
1103
1159
|
log(
|
|
1104
1160
|
'error' in outcome
|
|
1105
1161
|
? `Step ${step.index + 1}: ${step.name} threw${timing(step)} ${format(outcome.error)}`
|
|
@@ -1107,11 +1163,21 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1107
1163
|
);
|
|
1108
1164
|
}
|
|
1109
1165
|
});
|
|
1166
|
+
const { result, unreached = [] } = replay;
|
|
1110
1167
|
|
|
1111
1168
|
log(`Replay finished with state: ${result.type}`);
|
|
1112
1169
|
log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
|
|
1113
|
-
|
|
1114
|
-
|
|
1170
|
+
// After a replay error the flow never got past the divergence, so the entries behind it are not
|
|
1171
|
+
// news; the error already names where the timeline split. Only a flow that ended on its own terms
|
|
1172
|
+
// with steps left over is worth a warning.
|
|
1173
|
+
const haltedByReplay = result.type === 'Failure' && /^(TimeParadox|ReplayError)$/.test(result.error?.name);
|
|
1174
|
+
if (unreached.length > 0 && !haltedByReplay) {
|
|
1175
|
+
// Named, not just counted: the step a fix stopped issuing is usually the one under suspicion.
|
|
1176
|
+
const names = unreached.map((e) =>
|
|
1177
|
+
typeof e.path === 'string' ? `${e.command} (path '${e.path}')` : e.command
|
|
1178
|
+
);
|
|
1179
|
+
const count = unreached.length === 1 ? '1 recorded step was' : `${unreached.length} recorded steps were`;
|
|
1180
|
+
log(`Warning: ${count} never reached: ${names.join(', ')}. The flow diverged.`);
|
|
1115
1181
|
}
|
|
1116
1182
|
return result;
|
|
1117
1183
|
};
|
package/jsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"allowJs": true,
|
|
4
|
+
"checkJs": true,
|
|
5
|
+
"strict": true,
|
|
6
|
+
"noEmit": true,
|
|
7
|
+
"target": "es2022",
|
|
8
|
+
"module": "nodenext",
|
|
9
|
+
"moduleResolution": "nodenext",
|
|
10
|
+
"lib": ["es2022", "dom"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["index.js", "test/all.js", "test/types.test-d.ts", "examples/*.js"]
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pure-effect",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.12.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",
|
|
10
|
-
"format": "prettier --write index.js index.d.ts examples/*.js test/all.js README.md CLAUDE.md"
|
|
9
|
+
"test": "mocha && tsd && tsc -p jsconfig.json",
|
|
10
|
+
"format": "prettier --write index.js index.d.ts examples/*.js test/all.js test/types.test-d.ts 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",
|