pure-effect 0.11.0 → 0.13.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/CHANGELOG.md +148 -0
- package/README.md +132 -52
- package/index.d.ts +53 -25
- package/index.js +216 -131
- package/package.json +31 -3
- package/.prettierrc +0 -12
- package/CLAUDE.md +0 -202
- package/examples/opentelemetry-example.js +0 -164
- package/examples/recording-example.js +0 -104
- package/test/all.js +0 -2265
- package/test/types.test-d.ts +0 -268
- package/tsconfig.check.json +0 -12
package/index.js
CHANGED
|
@@ -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`,
|
|
@@ -221,8 +221,22 @@ const asEffect = (value, source) => {
|
|
|
221
221
|
* @returns {Effect}
|
|
222
222
|
*/
|
|
223
223
|
const chain = (effect, fn, initialInput) => {
|
|
224
|
+
// The outermost pipeline wins. A step can return a sub-pipeline whose nodes already carry that
|
|
225
|
+
// sub-pipeline's start, and an outer chain reaches every one of them through the wrapped `next`
|
|
226
|
+
// continuations, so overwriting here is what makes the whole tree, a Failure from any depth, and
|
|
227
|
+
// the root a hook-based recorder reads carry the input of the flow that was actually called. The
|
|
228
|
+
// previous rule, stamp only when unset, let a Retry- or Parallel-headed sub-pipeline leave its own
|
|
229
|
+
// input on the root, and a trace recorded from it rebuilt the flow from the wrong value. A Success
|
|
230
|
+
// is never stamped: nothing reads the input off a terminal value, and leaving it bare is what lets
|
|
231
|
+
// `assert.deepEqual(result, Success(v))` hold whatever shape the flow had.
|
|
224
232
|
const withII = (/** @type {Effect} */ e) =>
|
|
225
|
-
initialInput !== undefined && e.
|
|
233
|
+
initialInput !== undefined && e.type !== 'Success' ? { ...e, initialInput } : e;
|
|
234
|
+
|
|
235
|
+
// A continuation that returned nothing has to be caught before `effect.type` is read. Reading it off
|
|
236
|
+
// `undefined` throws a bare TypeError, and since every continuation runs inside the interpreter's try
|
|
237
|
+
// block, that TypeError became a domain Failure: the flow bug took the business-error branch and no
|
|
238
|
+
// step was named. The `default` arm below never sees it, because the switch itself is what throws.
|
|
239
|
+
if (effect == null) return asEffect(effect, 'A continuation');
|
|
226
240
|
|
|
227
241
|
switch (effect.type) {
|
|
228
242
|
case 'Success':
|
|
@@ -260,7 +274,14 @@ const chain = (effect, fn, initialInput) => {
|
|
|
260
274
|
const effectPipe = (...fns) => {
|
|
261
275
|
return (start) => {
|
|
262
276
|
const chainWithII = (/** @type {Effect} */ eff, /** @type {(v: any) => Effect} */ fn) => chain(eff, fn, start);
|
|
263
|
-
|
|
277
|
+
const tree = fns.reduce(chainWithII, /** @type {Effect} */ (Success(start)));
|
|
278
|
+
// One identity pass over the finished tree. `chain` re-stamps a sub-pipeline's nodes only as
|
|
279
|
+
// it wraps the continuations that lead to them, and the last step has no later step to do
|
|
280
|
+
// that wrapping, so a sub-pipeline returned by the last step would keep its own input on
|
|
281
|
+
// everything past its head. The pass wraps those continuations too, so every node reachable
|
|
282
|
+
// from here, a Failure at any depth included, carries this pipeline's `start`. `Success` is the
|
|
283
|
+
// identity step: it can never return a non-Effect, so no message is ever attributed to it.
|
|
284
|
+
return chain(tree, Success, start);
|
|
264
285
|
};
|
|
265
286
|
};
|
|
266
287
|
|
|
@@ -282,12 +303,7 @@ const defaultRunWrapper = async (effect, op, flowName) => await op();
|
|
|
282
303
|
/** @type CommandInterceptor */
|
|
283
304
|
const defaultCommandInterceptor = async (command, context) => {};
|
|
284
305
|
|
|
285
|
-
let stepRunner = defaultStepRunner;
|
|
286
|
-
let runWrapper = defaultRunWrapper;
|
|
287
|
-
let commandInterceptor = defaultCommandInterceptor;
|
|
288
|
-
|
|
289
306
|
const defaultRetryOptions = { attempts: 3, delay: 100, backoff: 1 };
|
|
290
|
-
let retryDefaults = { ...defaultRetryOptions };
|
|
291
307
|
|
|
292
308
|
/**
|
|
293
309
|
* @typedef {Object} EffectConfiguration
|
|
@@ -298,63 +314,77 @@ let retryDefaults = { ...defaultRetryOptions };
|
|
|
298
314
|
*/
|
|
299
315
|
|
|
300
316
|
/**
|
|
301
|
-
*
|
|
317
|
+
* A per-call configuration: an `EffectConfiguration` plus `inherit`. With `inherit: true` (the default)
|
|
318
|
+
* the call's hooks are added to the wiring `configureEffect` installed, by the merge `configureEffect`
|
|
319
|
+
* applies to several configurations: global outermost, interceptors in order, `retry` merging with the
|
|
320
|
+
* call winning. With `inherit: false` the global wiring is not consulted at all, so any slot the call
|
|
321
|
+
* leaves unset falls back to the library default. A per-call hook cannot replace a single global slot on
|
|
322
|
+
* its own; that was the previous default, and it is how a per-call recorder silently switched off an
|
|
323
|
+
* application's tracing.
|
|
302
324
|
*
|
|
303
|
-
*
|
|
304
|
-
|
|
325
|
+
* @typedef {EffectConfiguration & { inherit?: boolean }} CallConfiguration
|
|
326
|
+
*/
|
|
327
|
+
|
|
328
|
+
/** @type {EffectConfiguration[]} */
|
|
329
|
+
let layers = [];
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The installed layers merged into one configuration, empty when nothing is configured. This is the
|
|
333
|
+
* only derived form of `layers`: `runEffect` reads it and merges the call's configuration over it,
|
|
334
|
+
* and a slot no layer defines is simply absent, so the library default is chosen at the one place the
|
|
335
|
+
* hook is used rather than held in a second set of variables kept in step by hand.
|
|
336
|
+
* @type {EffectConfiguration}
|
|
337
|
+
*/
|
|
338
|
+
let globalConfig = {};
|
|
339
|
+
|
|
340
|
+
/** Recomputes the effective wiring from the installed layers, earlier layers outermost. */
|
|
341
|
+
const applyLayers = () => {
|
|
342
|
+
globalConfig = chainHooks(...layers);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Adds a configuration to the global wiring of the Effect runner: telemetry, the command interceptor,
|
|
347
|
+
* and retry defaults.
|
|
305
348
|
*
|
|
306
|
-
*
|
|
349
|
+
* Each call adds one layer on top of those already installed and returns a function that removes that
|
|
350
|
+
* layer, wherever it sits by then. Layers merge the way several configurations passed to one call do:
|
|
351
|
+
* `onStep` and `onRun` are wrappers, so they nest with the earliest layer outermost and the latest
|
|
352
|
+
* closest to the Command; `onBeforeCommand` interceptors all run, in the order installed; and `retry`
|
|
353
|
+
* merges with later layers winning. So these are the same:
|
|
307
354
|
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
* merges with later configurations winning.
|
|
355
|
+
* configureEffect(telemetryHooks(), recordingHooks({ sink }));
|
|
356
|
+
* configureEffect(telemetryHooks()); configureEffect(recordingHooks({ sink }));
|
|
311
357
|
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
358
|
+
* Calling it with no arguments at all removes every layer. A call whose arguments are all `undefined`
|
|
359
|
+
* is a conditional install that installed nothing: it adds no layer and removes none.
|
|
314
360
|
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* run since, restoring does nothing rather than discarding that newer wiring, so two callers installing
|
|
321
|
-
* and releasing in interleaved order cannot clobber each other.
|
|
361
|
+
* Layers replaced the previous one-slot rule, under which a later call displaced the earlier wiring and
|
|
362
|
+
* the returned function restored a snapshot, guarded so that interleaved installs could not clobber
|
|
363
|
+
* each other. Removing a layer needs no guard: A installs, B installs, A removes, and B is still there.
|
|
364
|
+
* It also means a library can install its own hooks without erasing its host's, which under the old rule
|
|
365
|
+
* was possible only if the host passed the library's configuration into its own call.
|
|
322
366
|
*
|
|
323
|
-
* @param {...(EffectConfiguration | undefined)} configs - Configurations
|
|
324
|
-
* @returns {() => void}
|
|
367
|
+
* @param {...(EffectConfiguration | undefined)} configs - Configurations merged into one layer, outermost first
|
|
368
|
+
* @returns {() => void} Removes the layer this call added; a second call does nothing
|
|
325
369
|
*/
|
|
326
370
|
const configureEffect = (...configs) => {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const installedCommandInterceptor = commandInterceptor;
|
|
341
|
-
const installedRetryDefaults = retryDefaults;
|
|
342
|
-
|
|
371
|
+
// Only a call with no arguments at all is a reset. A call whose arguments are all absent, such as
|
|
372
|
+
// `configureEffect(flag ? hooks : undefined)`, is a conditional install that happened not to install
|
|
373
|
+
// anything, so it adds no layer and must not remove anyone else's.
|
|
374
|
+
if (configs.length === 0) {
|
|
375
|
+
layers = [];
|
|
376
|
+
applyLayers();
|
|
377
|
+
return () => {};
|
|
378
|
+
}
|
|
379
|
+
const present = configs.filter(Boolean);
|
|
380
|
+
if (present.length === 0) return () => {};
|
|
381
|
+
const layer = chainHooks(...present);
|
|
382
|
+
layers = [...layers, layer];
|
|
383
|
+
applyLayers();
|
|
343
384
|
return () => {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const unchanged =
|
|
348
|
-
stepRunner === installedStepRunner &&
|
|
349
|
-
runWrapper === installedRunWrapper &&
|
|
350
|
-
commandInterceptor === installedCommandInterceptor &&
|
|
351
|
-
retryDefaults === installedRetryDefaults;
|
|
352
|
-
if (!unchanged) return;
|
|
353
|
-
|
|
354
|
-
stepRunner = previousStepRunner;
|
|
355
|
-
runWrapper = previousRunWrapper;
|
|
356
|
-
commandInterceptor = previousCommandInterceptor;
|
|
357
|
-
retryDefaults = previousRetryDefaults;
|
|
385
|
+
if (!layers.includes(layer)) return;
|
|
386
|
+
layers = layers.filter((l) => l !== layer);
|
|
387
|
+
applyLayers();
|
|
358
388
|
};
|
|
359
389
|
};
|
|
360
390
|
|
|
@@ -501,20 +531,32 @@ const runEffect =
|
|
|
501
531
|
* Iterates through the Effect tree, executing Commands and handling async flow.
|
|
502
532
|
* Ask effects are resolved synchronously with the context object.
|
|
503
533
|
*
|
|
504
|
-
* Per-call config
|
|
505
|
-
* onRun fires exactly once per runEffect call. Retry attempts run inside that
|
|
534
|
+
* Per-call config is merged over the global configureEffect wiring, or over nothing under
|
|
535
|
+
* `inherit: false`. onRun fires exactly once per runEffect call. Retry attempts run inside that
|
|
506
536
|
* single span rather than spawning their own, keeping telemetry non-duplicated.
|
|
507
537
|
*
|
|
508
538
|
* @param {Effect} effect - The Effect tree returned by a pipeline
|
|
509
539
|
* @param {any} [context] - Optional context object. Passed to Ask continuations and the Command Interceptor.
|
|
510
|
-
* @param {
|
|
540
|
+
* @param {CallConfiguration} [callConfig] - Per-call configuration, added to the wiring `configureEffect`
|
|
541
|
+
* installed unless `inherit: false`, which ignores that wiring for this run.
|
|
511
542
|
* @returns {Promise<SuccessState | FailureState>}
|
|
512
543
|
*/
|
|
513
544
|
async function runEffect(effect, context = {}, callConfig = {}) {
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
545
|
+
const { inherit = true, ...local } = callConfig;
|
|
546
|
+
// A value that is not a boolean, such as a string left over from an older API, must not be
|
|
547
|
+
// coerced: `'false'` inheriting everything is exactly the silent behaviour this option removes.
|
|
548
|
+
if (typeof inherit !== 'boolean') {
|
|
549
|
+
throw new TypeError(`callConfig.inherit must be true or false, got ${JSON.stringify(inherit)}.`);
|
|
550
|
+
}
|
|
551
|
+
// The call's configuration is merged over the installed wiring, or over nothing when the call
|
|
552
|
+
// inherits nothing, by the same merge `configureEffect` applies to several configurations. Most
|
|
553
|
+
// calls carry no configuration of their own, and those use the installed wiring as it is.
|
|
554
|
+
const base = inherit ? globalConfig : {};
|
|
555
|
+
const resolved = Object.keys(local).length ? chainHooks(base, local) : base;
|
|
556
|
+
const localStepRunner = resolved.onStep || defaultStepRunner;
|
|
557
|
+
const localRunWrapper = resolved.onRun || defaultRunWrapper;
|
|
558
|
+
const localCommandInterceptor = resolved.onBeforeCommand || defaultCommandInterceptor;
|
|
559
|
+
const localRetryDefaults = { ...defaultRetryOptions, ...resolved.retry };
|
|
518
560
|
|
|
519
561
|
/**
|
|
520
562
|
* @param {Effect} eff
|
|
@@ -657,7 +699,17 @@ const runEffect =
|
|
|
657
699
|
throw effectTypeError(eff, 'The flow');
|
|
658
700
|
}
|
|
659
701
|
|
|
660
|
-
|
|
702
|
+
// Every Failure leaves through here, including one a Parallel branch or a Retry fallback handed
|
|
703
|
+
// back. `chain` stamps only what passes through the continuations it wraps, and those subtrees
|
|
704
|
+
// are executed directly rather than reached through a continuation, so their Failures arrive
|
|
705
|
+
// carrying the subtree's own input or none. Restamping the outcome with the root's input is
|
|
706
|
+
// what makes "a Failure carries the input of the flow that was called" true at any depth.
|
|
707
|
+
const rootInput = effect?.initialInput;
|
|
708
|
+
const run = async () => {
|
|
709
|
+
const result = await execute(effect);
|
|
710
|
+
return result.type === 'Failure' && rootInput !== undefined ? Failure(result.error, rootInput) : result;
|
|
711
|
+
};
|
|
712
|
+
return localRunWrapper(effect, run, context?.flowName || '');
|
|
661
713
|
};
|
|
662
714
|
|
|
663
715
|
/**
|
|
@@ -755,11 +807,17 @@ const serializeError = (e, withStack) => {
|
|
|
755
807
|
const reviveError = (v) => {
|
|
756
808
|
if (!v || typeof v !== 'object' || v.__error !== true) return v;
|
|
757
809
|
const e = new Error(v.message);
|
|
758
|
-
|
|
810
|
+
// `name` and `cause` are non-enumerable on a native Error (`name` is inherited, `cause` is set by the
|
|
811
|
+
// constructor), so they are defined the same way here. Assigning them would make them own enumerable
|
|
812
|
+
// keys, and the revived error would then never compare deep-equal to the one the Command threw, which
|
|
813
|
+
// is what the record-then-replay determinism check relies on. Everything else was enumerable on the
|
|
814
|
+
// original, since `serializeError` read it through `Object.keys`, so plain assignment is faithful.
|
|
815
|
+
const hidden = { enumerable: false, configurable: true, writable: true };
|
|
816
|
+
Object.defineProperty(e, 'name', { ...hidden, value: v.name });
|
|
759
817
|
for (const [k, val] of Object.entries(v)) {
|
|
760
|
-
if (k
|
|
761
|
-
|
|
762
|
-
}
|
|
818
|
+
if (k === '__error' || k === 'name' || k === 'message') continue;
|
|
819
|
+
if (k === 'cause') Object.defineProperty(e, 'cause', { ...hidden, value: reviveError(val) });
|
|
820
|
+
else /** @type {any} */ (e)[k] = val;
|
|
763
821
|
}
|
|
764
822
|
return e;
|
|
765
823
|
};
|
|
@@ -886,6 +944,8 @@ const recorder = (options = {}) => {
|
|
|
886
944
|
const recordEffect = async (flowFn, initialInput, options = {}) => {
|
|
887
945
|
const { context = {}, version, ...recorderOptions } = options;
|
|
888
946
|
const rec = recorder(recorderOptions);
|
|
947
|
+
// The recorder is added to the global wiring, so recording inside an instrumented application keeps
|
|
948
|
+
// its spans.
|
|
889
949
|
const result = await runEffect(flowFn(initialInput), context, { onStep: rec.onStep });
|
|
890
950
|
return {
|
|
891
951
|
result,
|
|
@@ -908,36 +968,36 @@ const entryToOutcome = (entry) => ('error' in entry ? { error: reviveError(entry
|
|
|
908
968
|
*
|
|
909
969
|
* Path matching is used whenever every entry carries a `path`, which is every trace this
|
|
910
970
|
* 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`
|
|
912
|
-
*
|
|
913
|
-
* `strict` does not apply, since path matching is neither positional nor lenient.
|
|
971
|
+
* and still paradox-detecting: it works for `Parallel` as it is, and it tells two branches that
|
|
972
|
+
* call the same Command apart by where they are rather than by which finished first.
|
|
914
973
|
*
|
|
915
|
-
*
|
|
916
|
-
* entries in recorded order and
|
|
917
|
-
*
|
|
918
|
-
*
|
|
974
|
+
* A trace whose entries carry no path (recorded before paths existed, or written by hand) is
|
|
975
|
+
* matched positionally: entries are consumed in recorded order and each must name the Command the
|
|
976
|
+
* flow asks for. That is exact for sequential flows and cannot tell `Parallel` branches apart, which
|
|
977
|
+
* is what paths were added to fix.
|
|
919
978
|
*
|
|
920
979
|
* @param {TraceLog | TraceEntry[]} traceLog - A reference-format trace, or a bare array of entries
|
|
921
980
|
* @param {Object} [options]
|
|
922
|
-
* @param {
|
|
923
|
-
*
|
|
981
|
+
* @param {(entry: TraceEntry) => void} [options.onEntry] - Observes each entry as it is handed to a step,
|
|
982
|
+
* which is how `replayEffect` learns which recorded entries the flow never asked for.
|
|
924
983
|
* @returns {Resolver}
|
|
925
984
|
*/
|
|
926
985
|
const fromTrace = (traceLog, options = {}) => {
|
|
927
|
-
const {
|
|
986
|
+
const { onEntry } = options;
|
|
928
987
|
const entries = Array.isArray(traceLog) ? traceLog : traceLog?.trace;
|
|
929
988
|
if (!Array.isArray(entries)) throw replayError('Trace has no `trace` array.');
|
|
989
|
+
const resolveEntry = (/** @type {TraceEntry} */ entry) => {
|
|
990
|
+
if (onEntry) onEntry(entry);
|
|
991
|
+
return entryToOutcome(entry);
|
|
992
|
+
};
|
|
930
993
|
|
|
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
994
|
if (entries.length > 0 && entries.every((e) => typeof e.path === 'string')) {
|
|
935
995
|
const byPath = new Map(entries.map((e) => [e.path, e]));
|
|
936
996
|
// Paths are derived from tree position, so they are unique by construction. A collision means
|
|
937
997
|
// either a hand-built trace or a bug in path derivation, and silently keeping the last entry
|
|
938
998
|
// would reintroduce exactly the wrong-result-per-branch failure paths exist to prevent.
|
|
939
999
|
if (byPath.size !== entries.length) {
|
|
940
|
-
throw replayError('Trace has duplicate step paths
|
|
1000
|
+
throw replayError('Trace has duplicate step paths.');
|
|
941
1001
|
}
|
|
942
1002
|
return (step) => {
|
|
943
1003
|
const entry = byPath.get(step.path);
|
|
@@ -948,29 +1008,24 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
948
1008
|
});
|
|
949
1009
|
}
|
|
950
1010
|
if (entry.command !== step.name) throw timeParadox(step, entry.command);
|
|
951
|
-
return
|
|
1011
|
+
return resolveEntry(entry);
|
|
952
1012
|
};
|
|
953
1013
|
}
|
|
954
1014
|
|
|
955
|
-
if (strict) {
|
|
956
|
-
return (step) => {
|
|
957
|
-
const entry = entries[step.index];
|
|
958
|
-
if (!entry) throw replayError(`Trace exhausted: no entry #${step.index} for '${step.name}'.`);
|
|
959
|
-
if (entry.command !== step.name) throw timeParadox(step, entry.command);
|
|
960
|
-
return entryToOutcome(entry);
|
|
961
|
-
};
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
/** @type {Map<string, TraceEntry[]>} */
|
|
965
|
-
const queues = new Map();
|
|
966
|
-
for (const entry of entries) {
|
|
967
|
-
if (!queues.has(entry.command)) queues.set(entry.command, []);
|
|
968
|
-
/** @type {TraceEntry[]} */ (queues.get(entry.command)).push(entry);
|
|
969
|
-
}
|
|
970
1015
|
return (step) => {
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1016
|
+
// Positional matching pairs steps by completion order, which is exactly what makes Parallel
|
|
1017
|
+
// branches indistinguishable. Refusing is better than a result that is right only when the
|
|
1018
|
+
// replay happens to finish in production's order.
|
|
1019
|
+
if (typeof step.path === 'string' && /p\d+\//.test(step.path)) {
|
|
1020
|
+
throw replayError(
|
|
1021
|
+
`Trace carries no paths, so '${step.name}' inside a Parallel cannot be matched positionally.`,
|
|
1022
|
+
{ command: step.name, path: step.path }
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
const entry = entries[step.index];
|
|
1026
|
+
if (!entry) throw replayError(`Trace exhausted: no entry #${step.index} for '${step.name}'.`);
|
|
1027
|
+
if (entry.command !== step.name) throw timeParadox(step, entry.command);
|
|
1028
|
+
return resolveEntry(entry);
|
|
974
1029
|
};
|
|
975
1030
|
};
|
|
976
1031
|
|
|
@@ -1022,16 +1077,28 @@ const zeroRetryDelays = (eff) => {
|
|
|
1022
1077
|
* @typedef {Object} ReplayOptions
|
|
1023
1078
|
* @property {any} [context] - Context for `Ask`; pass the recorded context to reproduce a run faithfully.
|
|
1024
1079
|
* @property {boolean} [fastRetry] - Strips Retry delays so a replay does not wait out production backoff.
|
|
1025
|
-
* @property {boolean} [hooks] -
|
|
1026
|
-
*
|
|
1080
|
+
* @property {boolean} [hooks] - Runs the replay inside the hooks `configureEffect` installed, with the
|
|
1081
|
+
* resolver innermost, so a configured `onStep` observes each replayed step and `onRun` and
|
|
1082
|
+
* `onBeforeCommand` fire. Off by default, which ignores the global hooks, so a replay cannot
|
|
1083
|
+
* reach a telemetry backend or a guardrail that performs I/O. Global `retry` defaults apply
|
|
1084
|
+
* either way, since a replay has to make the attempts production made.
|
|
1027
1085
|
* @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
|
|
1028
1086
|
* `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
|
|
1029
1087
|
* `'execute'` runs the real Command, giving partial replay: recorded prefix, live tail.
|
|
1030
1088
|
* @property {(step: ReplayStep, outcome: ReplayOutcome | undefined) => void} [onResolved] - Observes each step.
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
*
|
|
1089
|
+
*/
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* What a replay returns: the flow's own outcome, and, when a trace was supplied, the recorded
|
|
1093
|
+
* entries the flow never asked for. `unreached` is absent for a Resolver, since only a trace
|
|
1094
|
+
* knows what it holds.
|
|
1095
|
+
*
|
|
1096
|
+
* A flow that stops issuing Commands before its recording ends mismatches nothing, so no
|
|
1097
|
+
* `TimeParadox` fires and `result` can be a `Success` with recorded steps left over. That is
|
|
1098
|
+
* sometimes the point of a fix and sometimes a fix that quietly dropped a step; `unreached` is
|
|
1099
|
+
* the only place the difference shows, which is why it travels with the result rather than
|
|
1100
|
+
* behind an option.
|
|
1101
|
+
* @typedef {{ result: SuccessState | FailureState, unreached?: TraceEntry[] }} Replay
|
|
1035
1102
|
*/
|
|
1036
1103
|
|
|
1037
1104
|
/**
|
|
@@ -1051,15 +1118,20 @@ const zeroRetryDelays = (eff) => {
|
|
|
1051
1118
|
* Resolver supplying each Command's recorded outcome (`undefined` if it has none). Write a Resolver when
|
|
1052
1119
|
* traces are stored in some other shape; to observe a replay without one, use `onResolved`.
|
|
1053
1120
|
* @param {ReplayOptions} [options]
|
|
1054
|
-
* @returns {Promise<
|
|
1121
|
+
* @returns {Promise<Replay>} `{ result, unreached }` for a trace, `{ result }` for a Resolver
|
|
1055
1122
|
*/
|
|
1056
1123
|
// `async` so a malformed trace arrives as a rejection rather than a synchronous throw:
|
|
1057
1124
|
// the function otherwise returns a promise, and callers should not have to handle both.
|
|
1058
1125
|
const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
1059
|
-
const { context = {}, fastRetry = true, hooks = false, onMissing = 'throw', onResolved
|
|
1126
|
+
const { context = {}, fastRetry = true, hooks = false, onMissing = 'throw', onResolved } = options;
|
|
1060
1127
|
// A trace is data and a Resolver is a function, so nothing else is needed to tell them
|
|
1061
1128
|
// apart, including the bare entries array that `fromTrace` also accepts.
|
|
1062
|
-
const
|
|
1129
|
+
const fromResolver = typeof traceOrResolver === 'function';
|
|
1130
|
+
/** @type {Set<TraceEntry>} */
|
|
1131
|
+
const reached = new Set();
|
|
1132
|
+
const resolve = fromResolver
|
|
1133
|
+
? traceOrResolver
|
|
1134
|
+
: fromTrace(traceOrResolver, { onEntry: (entry) => void reached.add(entry) });
|
|
1063
1135
|
let index = 0;
|
|
1064
1136
|
|
|
1065
1137
|
/** @type {StepRunner} */
|
|
@@ -1081,32 +1153,38 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1081
1153
|
return outcome.result;
|
|
1082
1154
|
};
|
|
1083
1155
|
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1156
|
+
// Off, a replay ignores the global hooks, so it can reach neither a telemetry backend nor a guardrail
|
|
1157
|
+
// that performs I/O. On, it runs inside them with the resolver innermost, so a configured onStep
|
|
1158
|
+
// observes each replayed step and the Command still never executes. The global retry defaults are
|
|
1159
|
+
// kept either way: `inherit: false` would drop them with the hooks, and a Retry that production ran
|
|
1160
|
+
// under a configured `attempts` would then exhaust early on replay and report a Failure production
|
|
1161
|
+
// never saw. Retry shape is part of what a replay reproduces; the hooks are not.
|
|
1162
|
+
/** @type {CallConfiguration} */
|
|
1163
|
+
const callConfig = { onStep, inherit: hooks, retry: globalConfig.retry };
|
|
1164
|
+
|
|
1165
|
+
const result = await runEffect(fastRetry ? zeroRetryDelays(effect) : effect, context, callConfig);
|
|
1166
|
+
if (fromResolver) return { result };
|
|
1167
|
+
// `fromTrace` has already validated the shape, so the entries are here in one form or the other.
|
|
1168
|
+
const entries = Array.isArray(traceOrResolver) ? traceOrResolver : traceOrResolver.trace;
|
|
1169
|
+
return { result, unreached: entries.filter((entry) => !reached.has(entry)) };
|
|
1092
1170
|
};
|
|
1093
1171
|
|
|
1094
1172
|
/**
|
|
1095
1173
|
* Replays a reference-format trace and narrates each step. Rebuilds the flow from the
|
|
1096
|
-
* recorded input, reports the outcome, and
|
|
1174
|
+
* recorded input, reports the outcome, and names any recorded steps that were never
|
|
1097
1175
|
* reached, which means the current code issued fewer Commands than production did.
|
|
1098
1176
|
*
|
|
1099
1177
|
* @param {(input: any) => Effect} flowFn - The same flow function that produced the trace
|
|
1100
1178
|
* @param {TraceLog} traceLog - A trace from `recordEffect` or a `recorder`
|
|
1101
1179
|
* @param {Object} [options]
|
|
1102
|
-
* @param {boolean} [options.strict] - Positional matching with paradox detection; set `false` for `Parallel`
|
|
1103
1180
|
* @param {(...args: any[]) => void} [options.log] - Defaults to `console.log`
|
|
1104
1181
|
* @param {any} [options.context] - Overrides the context stored on the trace
|
|
1105
1182
|
* @param {string} [options.version] - Current build id; warns when it differs from the trace's
|
|
1106
|
-
* @returns {Promise<SuccessState | FailureState>}
|
|
1183
|
+
* @returns {Promise<SuccessState | FailureState>} The flow's outcome. Narration is the point of this function;
|
|
1184
|
+
* a caller who wants the unreached entries as data uses `replayEffect`.
|
|
1107
1185
|
*/
|
|
1108
1186
|
const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
1109
|
-
const {
|
|
1187
|
+
const { log = console.log, context, version } = options;
|
|
1110
1188
|
const { initialInput, trace, flowName, version: traceVersion } = traceLog;
|
|
1111
1189
|
// `message` and `stack` are non-enumerable on Error, so JSON.stringify alone would
|
|
1112
1190
|
// drop the most useful line of the report.
|
|
@@ -1124,22 +1202,19 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1124
1202
|
|
|
1125
1203
|
// Narration goes through `onResolved` rather than a wrapped Resolver: observing each
|
|
1126
1204
|
// step is all this needs, and `replayEffect` already resolves the trace itself.
|
|
1127
|
-
let consumed = 0;
|
|
1128
1205
|
// 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
|
|
1130
|
-
//
|
|
1206
|
+
// timings are narrated for Parallel too. Positional lookup is the fallback for a trace with no paths,
|
|
1207
|
+
// which is how such a trace is matched anyway.
|
|
1131
1208
|
const byPath = new Map(trace.filter((e) => typeof e.path === 'string').map((e) => [e.path, e]));
|
|
1132
1209
|
const timing = (/** @type {ReplayStep} */ step) => {
|
|
1133
|
-
const recorded = byPath.get(step.path) ??
|
|
1210
|
+
const recorded = byPath.get(step.path) ?? trace[step.index];
|
|
1134
1211
|
return typeof recorded?.durationMs === 'number' ? ` in ${recorded.durationMs}ms` : '';
|
|
1135
1212
|
};
|
|
1136
|
-
const
|
|
1137
|
-
strict,
|
|
1213
|
+
const replay = await replayEffect(flowFn(initialInput), traceLog, {
|
|
1138
1214
|
context: context !== undefined ? context : traceLog.context || {},
|
|
1139
1215
|
onResolved: (step, outcome) => {
|
|
1140
1216
|
// Resolving from a trace either answers or throws, so this is never undefined.
|
|
1141
1217
|
if (outcome === undefined) return;
|
|
1142
|
-
consumed++;
|
|
1143
1218
|
log(
|
|
1144
1219
|
'error' in outcome
|
|
1145
1220
|
? `Step ${step.index + 1}: ${step.name} threw${timing(step)} ${format(outcome.error)}`
|
|
@@ -1147,11 +1222,21 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1147
1222
|
);
|
|
1148
1223
|
}
|
|
1149
1224
|
});
|
|
1225
|
+
const { result, unreached = [] } = replay;
|
|
1150
1226
|
|
|
1151
1227
|
log(`Replay finished with state: ${result.type}`);
|
|
1152
1228
|
log(result.type === 'Failure' ? `Error: ${format(result.error)}` : `Result: ${format(result.value)}`);
|
|
1153
|
-
|
|
1154
|
-
|
|
1229
|
+
// After a replay error the flow never got past the divergence, so the entries behind it are not
|
|
1230
|
+
// news; the error already names where the timeline split. Only a flow that ended on its own terms
|
|
1231
|
+
// with steps left over is worth a warning.
|
|
1232
|
+
const haltedByReplay = result.type === 'Failure' && /^(TimeParadox|ReplayError)$/.test(result.error?.name);
|
|
1233
|
+
if (unreached.length > 0 && !haltedByReplay) {
|
|
1234
|
+
// Named, not just counted: the step a fix stopped issuing is usually the one under suspicion.
|
|
1235
|
+
const names = unreached.map((e) =>
|
|
1236
|
+
typeof e.path === 'string' ? `${e.command} (path '${e.path}')` : e.command
|
|
1237
|
+
);
|
|
1238
|
+
const count = unreached.length === 1 ? '1 recorded step was' : `${unreached.length} recorded steps were`;
|
|
1239
|
+
log(`Warning: ${count} never reached: ${names.join(', ')}. The flow diverged.`);
|
|
1155
1240
|
}
|
|
1156
1241
|
return result;
|
|
1157
1242
|
};
|
package/package.json
CHANGED
|
@@ -1,19 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pure-effect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Records what your business logic did in production and replays it anywhere: time-travel debugging for JavaScript and TypeScript, with zero dependencies. Business logic is plain data you can test without mocks.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"effect",
|
|
7
|
+
"effect-system",
|
|
8
|
+
"functional-core",
|
|
9
|
+
"imperative-shell",
|
|
10
|
+
"time-travel",
|
|
11
|
+
"replay",
|
|
12
|
+
"tracing",
|
|
13
|
+
"testing",
|
|
14
|
+
"zero-dependencies",
|
|
15
|
+
"typescript"
|
|
16
|
+
],
|
|
5
17
|
"type": "module",
|
|
6
18
|
"exports": "./index.js",
|
|
7
19
|
"types": "./index.d.ts",
|
|
20
|
+
"files": [
|
|
21
|
+
"index.js",
|
|
22
|
+
"index.d.ts",
|
|
23
|
+
"CHANGELOG.md"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
8
29
|
"scripts": {
|
|
9
|
-
"test": "mocha && tsd && tsc -p
|
|
10
|
-
"format": "prettier --write index.js index.d.ts examples/*.js test/all.js README.md CLAUDE.md"
|
|
30
|
+
"test": "mocha && tsd && tsc -p jsconfig.json",
|
|
31
|
+
"format": "prettier --write index.js index.d.ts examples/*.js test/all.js test/types.test-d.ts README.md CLAUDE.md CHANGELOG.md CONTRIBUTING.md"
|
|
11
32
|
},
|
|
12
33
|
"tsd": {
|
|
13
34
|
"directory": "test"
|
|
14
35
|
},
|
|
15
36
|
"author": "Aycan Gulez",
|
|
16
37
|
"homepage": "https://pure-effect.org",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/aycangulez/pure-effect.git"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/aycangulez/pure-effect/issues"
|
|
44
|
+
},
|
|
17
45
|
"license": "MIT",
|
|
18
46
|
"devDependencies": {
|
|
19
47
|
"@opentelemetry/api": "^1.9.1",
|
package/.prettierrc
DELETED