pure-effect 0.15.0 → 0.16.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 +30 -0
- package/README.md +330 -104
- package/examples/opentelemetry-example.js +173 -0
- package/examples/recording-example.js +170 -0
- package/index.d.ts +1067 -1089
- package/index.js +84 -23
- package/package.json +7 -4
package/index.js
CHANGED
|
@@ -111,11 +111,15 @@ const Command = (cmd, next, meta) => {
|
|
|
111
111
|
/**
|
|
112
112
|
* The name a Command is known by: what a trace records, what replay matches on, and what a
|
|
113
113
|
* telemetry span is called. A non-empty string `meta.name`, else `cmd.name`, else 'anonymous'.
|
|
114
|
+
* Exported so a test walking a flow checks each step by the same rule.
|
|
114
115
|
*
|
|
115
116
|
* @param {CommandState} eff
|
|
116
117
|
* @returns {string}
|
|
117
118
|
*/
|
|
118
119
|
const commandName = (eff) => {
|
|
120
|
+
if (eff?.type !== 'Command') {
|
|
121
|
+
throw malformed(`commandName expects a Command, got ${describeArgument(eff)}.`, eff);
|
|
122
|
+
}
|
|
119
123
|
const meta = eff.meta;
|
|
120
124
|
const named = isObject(meta) ? meta.name : undefined;
|
|
121
125
|
return typeof named === 'string' && named !== '' ? named : eff.cmd.name || 'anonymous';
|
|
@@ -198,11 +202,22 @@ const Parallel = (effects, nextOrOptions, maybeOptions) => {
|
|
|
198
202
|
const hasNext = typeof nextOrOptions === 'function';
|
|
199
203
|
// A caller forwarding an absent `next` still passes its options third, as the signature reads.
|
|
200
204
|
const nextSkipped = nextOrOptions == null;
|
|
201
|
-
|
|
205
|
+
const optionsSecond = !hasNext && !nextSkipped;
|
|
206
|
+
if (optionsSecond && !isOptionsObject(nextOrOptions)) {
|
|
202
207
|
const got = describeArgument(nextOrOptions);
|
|
203
208
|
throw malformed(`Parallel's second argument must be next or the options, got ${got}.`, nextOrOptions);
|
|
204
209
|
}
|
|
205
|
-
|
|
210
|
+
// Nothing reads a third argument after the options, so a `next` passed there would silently be skipped.
|
|
211
|
+
if (optionsSecond && maybeOptions != null) {
|
|
212
|
+
throw malformed(
|
|
213
|
+
typeof maybeOptions === 'function'
|
|
214
|
+
? "Parallel's next goes second and its options third: Parallel(effects, next, options)."
|
|
215
|
+
: `Parallel takes one options object, and with the options second its third argument, ` +
|
|
216
|
+
`${describeArgument(maybeOptions)}, would be ignored.`,
|
|
217
|
+
maybeOptions
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const options = optionsSecond ? nextOrOptions : maybeOptions;
|
|
206
221
|
if (options != null && !isOptionsObject(options)) {
|
|
207
222
|
throw malformed(`Parallel's options must be an object, got ${describeArgument(options)}.`, options);
|
|
208
223
|
}
|
|
@@ -343,6 +358,16 @@ const effectTypeError = (value, source) =>
|
|
|
343
358
|
value
|
|
344
359
|
);
|
|
345
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Names the node whose `next` returned a value, for an error message: a Command by its name, any other by its type.
|
|
363
|
+
* @param {Effect} node
|
|
364
|
+
* @returns {string}
|
|
365
|
+
*/
|
|
366
|
+
const nextOf = (node) =>
|
|
367
|
+
node.type === 'Command'
|
|
368
|
+
? `The next of Command '${commandName(node)}'`
|
|
369
|
+
: `The next of ${node.type === 'Ask' ? 'an' : 'a'} ${node.type}`;
|
|
370
|
+
|
|
346
371
|
/**
|
|
347
372
|
* A Success or a Failure: a flow with nothing left to run.
|
|
348
373
|
* @param {any} value
|
|
@@ -380,16 +405,18 @@ const asEffect = (value, source) => {
|
|
|
380
405
|
* @param {Effect} effect - The current Effect object
|
|
381
406
|
* @param {(value: any) => Effect} fn - The next function to run if the current effect is a Success
|
|
382
407
|
* @param {any} [initialInput] - The pipeline's starting value, stamped on every node but a Success
|
|
408
|
+
* @param {Effect} [from] - The node whose `next` returned `effect`, which an error names
|
|
383
409
|
* @returns {Effect} The composed Effect
|
|
384
410
|
*/
|
|
385
|
-
const chain = (effect, fn, initialInput) => {
|
|
411
|
+
const chain = (effect, fn, initialInput, from) => {
|
|
412
|
+
const source = () => (from ? nextOf(from) : 'A continuation');
|
|
386
413
|
// Overwrites a sub-pipeline's own stamp, so the root and a Failure at any depth carry the input of the
|
|
387
414
|
// flow that was actually called. A Success stays bare, so `deepEqual(result, Success(v))` always holds.
|
|
388
415
|
const withII = (/** @type {Effect} */ e) =>
|
|
389
416
|
initialInput !== undefined && e.type !== 'Success' ? { ...e, initialInput } : e;
|
|
390
417
|
|
|
391
418
|
// Checked before `effect.type` is read, which would otherwise throw a bare TypeError naming no step.
|
|
392
|
-
if (effect == null) return asEffect(effect,
|
|
419
|
+
if (effect == null) return asEffect(effect, source());
|
|
393
420
|
|
|
394
421
|
switch (effect.type) {
|
|
395
422
|
case 'Success':
|
|
@@ -397,23 +424,23 @@ const chain = (effect, fn, initialInput) => {
|
|
|
397
424
|
case 'Failure':
|
|
398
425
|
return withII(effect);
|
|
399
426
|
case 'Command': {
|
|
400
|
-
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
|
|
427
|
+
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
|
|
401
428
|
return withII(Command(effect.cmd, next, effect.meta));
|
|
402
429
|
}
|
|
403
430
|
case 'Ask': {
|
|
404
|
-
const next = (/** @type {any} */ ctx) => chain(effect.next(ctx), fn, initialInput);
|
|
431
|
+
const next = (/** @type {any} */ ctx) => chain(effect.next(ctx), fn, initialInput, effect);
|
|
405
432
|
return withII(Ask(next));
|
|
406
433
|
}
|
|
407
434
|
case 'Retry': {
|
|
408
|
-
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
|
|
435
|
+
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
|
|
409
436
|
return withII({ ...effect, next });
|
|
410
437
|
}
|
|
411
438
|
case 'Parallel': {
|
|
412
|
-
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput);
|
|
439
|
+
const next = (/** @type {any} */ result) => chain(effect.next(result), fn, initialInput, effect);
|
|
413
440
|
return withII({ ...effect, next });
|
|
414
441
|
}
|
|
415
442
|
default:
|
|
416
|
-
return asEffect(effect,
|
|
443
|
+
return asEffect(effect, source());
|
|
417
444
|
}
|
|
418
445
|
};
|
|
419
446
|
|
|
@@ -817,10 +844,13 @@ const interpret =
|
|
|
817
844
|
*/
|
|
818
845
|
async function execute(eff, signal, path = '') {
|
|
819
846
|
let step = 0;
|
|
847
|
+
/** @type {Effect | undefined} The node whose `next` returned `eff`, which an error names. */
|
|
848
|
+
let from;
|
|
820
849
|
while (isPending(eff)) {
|
|
821
850
|
// Checked before every node: a Command already in flight cannot be stopped, but the next never starts.
|
|
822
851
|
if (signal?.aborted) return cancelledBranch(eff.initialInput);
|
|
823
852
|
if (eff.type === 'Ask') {
|
|
853
|
+
from = eff;
|
|
824
854
|
eff = eff.next(context);
|
|
825
855
|
continue;
|
|
826
856
|
}
|
|
@@ -834,10 +864,11 @@ const interpret =
|
|
|
834
864
|
if (outcome.type !== 'Success') return outcome;
|
|
835
865
|
// Outside every catch: `next` and the pure steps it reaches are code, not I/O, so a throw there
|
|
836
866
|
// rejects the run.
|
|
867
|
+
from = eff;
|
|
837
868
|
eff = eff.next(outcome.value);
|
|
838
869
|
}
|
|
839
870
|
if (isOutcome(eff)) return eff;
|
|
840
|
-
throw effectTypeError(eff, 'The flow');
|
|
871
|
+
throw effectTypeError(eff, from ? nextOf(from) : 'The flow');
|
|
841
872
|
}
|
|
842
873
|
|
|
843
874
|
/**
|
|
@@ -1615,7 +1646,9 @@ const fromTrace = (traceLog, options = {}) => {
|
|
|
1615
1646
|
* reach a telemetry backend or a guardrail that performs I/O.
|
|
1616
1647
|
* @property {'throw' | 'execute'} [onMissing] - What to do when the resolver has no recording for a step.
|
|
1617
1648
|
* `'throw'` (default) fails the replay, which makes side effects impossible for the whole run.
|
|
1618
|
-
* `'execute'` runs the real Command,
|
|
1649
|
+
* `'execute'` runs the real Command, so pass it only where the Commands reach test doubles or only
|
|
1650
|
+
* read. A trace that dropped entries under `maxEntries` refuses it, since a step it lacks may be one
|
|
1651
|
+
* production ran.
|
|
1619
1652
|
* @property {(step: ReplayStep, outcome: ReplayOutcome | undefined) => void} [onResolved] - Observes each step.
|
|
1620
1653
|
*/
|
|
1621
1654
|
|
|
@@ -1661,10 +1694,22 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1661
1694
|
const { resolve, missing } = fromResolver
|
|
1662
1695
|
? { resolve: traceOrResolver, missing: undefined }
|
|
1663
1696
|
: fromTrace(traceOrResolver, { onEntry: (entry) => void reached.add(entry) });
|
|
1697
|
+
// Only a trace log carries metadata; a bare entries array and a Resolver carry none.
|
|
1698
|
+
const traceLog = fromResolver || Array.isArray(traceOrResolver) ? undefined : traceOrResolver;
|
|
1664
1699
|
// Defaults to the trace's context: an `Ask` gate replayed with another one takes another branch, and
|
|
1665
1700
|
// no paradox flags it.
|
|
1666
|
-
const
|
|
1667
|
-
|
|
1701
|
+
const context = options.context ?? traceLog?.context ?? {};
|
|
1702
|
+
// A trace capped by `maxEntries` lacks steps production ran, so running a missing step live repeats
|
|
1703
|
+
// production's I/O: a billing batch replayed that way charged and invoiced subscriptions again.
|
|
1704
|
+
const droppedEntries = Number(traceLog?.dropped) || 0;
|
|
1705
|
+
const capped = droppedEntries > 0;
|
|
1706
|
+
if (capped && onMissing === 'execute') {
|
|
1707
|
+
throw replayError(
|
|
1708
|
+
`The trace dropped ${droppedEntries} entries under maxEntries, so a step it lacks may be one ` +
|
|
1709
|
+
"production ran, and onMissing: 'execute' would run it again. Replay without it to stop at the " +
|
|
1710
|
+
'first missing step, or record the flow with a higher maxEntries.'
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1668
1713
|
let index = 0;
|
|
1669
1714
|
// What `onResolved` threw. It stops the replay, which rejects with it, rather than counting as the
|
|
1670
1715
|
// Command failing, which would let a Retry ask for an attempt production never made. Cast rather than
|
|
@@ -1692,15 +1737,21 @@ const replayEffect = async (effect, traceOrResolver, options = {}) => {
|
|
|
1692
1737
|
if (outcome === undefined) {
|
|
1693
1738
|
if (onMissing !== 'execute') {
|
|
1694
1739
|
const what = missing ? missing(step) : `No recorded outcome for '${name}' at step ${step.index}`;
|
|
1695
|
-
//
|
|
1696
|
-
//
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1740
|
+
// Why the step may be missing decides the fix. A capped trace needs a higher cap, since the step
|
|
1741
|
+
// may be one production ran; any other missing step may be one production never ran, and
|
|
1742
|
+
// running it live against production would do I/O production refused.
|
|
1743
|
+
const why = capped
|
|
1744
|
+
? `The trace dropped ${droppedEntries} entries under maxEntries, so production may have run ` +
|
|
1745
|
+
'this step and the recorder not kept it; record the flow with a higher maxEntries to ' +
|
|
1746
|
+
'replay past it.'
|
|
1747
|
+
: 'Production may never have run it: a Command an onBeforeCommand hook vetoed leaves no ' +
|
|
1748
|
+
"entry, and neither does a step added since the recording. onMissing: 'execute' runs such " +
|
|
1749
|
+
'steps for real, so pass it only where the Commands reach test doubles or only read.';
|
|
1750
|
+
throw replayError(`${what}; refusing to run the real Command. ${why}`, {
|
|
1751
|
+
command: name,
|
|
1752
|
+
index: step.index,
|
|
1753
|
+
path
|
|
1754
|
+
});
|
|
1704
1755
|
}
|
|
1705
1756
|
return await op();
|
|
1706
1757
|
}
|
|
@@ -1767,6 +1818,15 @@ const timeTravel = async (flowFn, traceLog, options = {}) => {
|
|
|
1767
1818
|
'installed as a hook finds the input only on a flow built with effectPipe.'
|
|
1768
1819
|
);
|
|
1769
1820
|
}
|
|
1821
|
+
// A replay tells steps apart by name, so two anonymous Commands that swapped places replay without complaint.
|
|
1822
|
+
const anonymous = trace.filter((e) => e.command === 'anonymous').length;
|
|
1823
|
+
if (anonymous > 0) {
|
|
1824
|
+
log(
|
|
1825
|
+
`Warning: ${anonymous} of the recorded steps are named 'anonymous', usually inline arrow Commands, so ` +
|
|
1826
|
+
'this replay cannot tell them apart and would not notice two of them trading places. Name them with ' +
|
|
1827
|
+
'a const or meta.name.'
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1770
1830
|
// Parallel decisions are not narrated as steps, so the header counts Commands to match the lines below.
|
|
1771
1831
|
const commandCount = trace.filter((e) => !isDecisionEntry(e)).length;
|
|
1772
1832
|
const stepsText = commandCount === 1 ? 'step' : 'steps';
|
|
@@ -1821,5 +1881,6 @@ export {
|
|
|
1821
1881
|
recorder,
|
|
1822
1882
|
recordEffect,
|
|
1823
1883
|
replayEffect,
|
|
1824
|
-
timeTravel
|
|
1884
|
+
timeTravel,
|
|
1885
|
+
commandName
|
|
1825
1886
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pure-effect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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
5
|
"keywords": [
|
|
6
6
|
"effect",
|
|
@@ -20,15 +20,18 @@
|
|
|
20
20
|
"files": [
|
|
21
21
|
"index.js",
|
|
22
22
|
"index.d.ts",
|
|
23
|
-
"CHANGELOG.md"
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"examples"
|
|
24
25
|
],
|
|
25
26
|
"sideEffects": false,
|
|
26
27
|
"engines": {
|
|
27
|
-
"node": ">=
|
|
28
|
+
"node": ">=22"
|
|
28
29
|
},
|
|
29
30
|
"scripts": {
|
|
30
31
|
"test": "mocha && tsd && tsc -p jsconfig.json",
|
|
31
|
-
"
|
|
32
|
+
"test:ts-minimum": "npx --yes -p typescript@5.1 tsc -p test/tsconfig.minimum.json",
|
|
33
|
+
"generate": "node scripts/effect-pipe-overloads.js",
|
|
34
|
+
"format": "prettier --write index.js index.d.ts examples/*.js scripts/*.js test/all.js test/types.test-d.ts README.md CLAUDE.md CHANGELOG.md CONTRIBUTING.md"
|
|
32
35
|
},
|
|
33
36
|
"tsd": {
|
|
34
37
|
"directory": "test"
|