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/test/all.js DELETED
@@ -1,2265 +0,0 @@
1
- // @ts-check
2
-
3
- import { strict as assert } from 'assert';
4
- import {
5
- Success,
6
- Failure,
7
- Command,
8
- Ask,
9
- Retry,
10
- Parallel,
11
- effectPipe,
12
- runEffect,
13
- configureEffect,
14
- recorder,
15
- recordEffect,
16
- replayEffect,
17
- timeTravel
18
- } from '../index.js';
19
- import * as lib from '../index.js';
20
- import ts from 'typescript';
21
- import { enableTelemetry, telemetryHooks } from '../examples/opentelemetry-example.js';
22
- import { enableRecording, recordingHooks } from '../examples/recording-example.js';
23
-
24
- /** @import { CommandInterceptor } from "../index.js" */
25
-
26
- /** @typedef {{id?: number, email: string, password: string}} User */
27
-
28
- const db = {
29
- users: new Map(),
30
- async findUserByEmail(/** @type string */ email) {
31
- return this.users.get(email) || null;
32
- },
33
- async saveUser(/** @type {User} */ user) {
34
- const u = { ...user, id: Date.now() };
35
- this.users.set(user.email, u);
36
- return u;
37
- }
38
- };
39
-
40
- function validateRegistration(/** @type {User} */ input) {
41
- const { email, password } = input;
42
- if (!email?.includes('@')) {
43
- return Failure('Invalid email format.');
44
- }
45
- if (password?.length < 8) {
46
- return Failure('Password must be at least 8 characters long.');
47
- }
48
- return Success(input);
49
- }
50
-
51
- // The lookup is a guard, so it passes the input along rather than the user it found.
52
- // Every step then accepts and returns the piped value, which keeps the chain checkable.
53
- function ensureEmailIsAvailable(/** @type {User} */ input) {
54
- const cmdFindUser = () => db.findUserByEmail(input.email);
55
- const next = (/** @type {User | null} */ foundUser) =>
56
- foundUser ? Failure('Email already in use.') : Success(input);
57
- return Command(cmdFindUser, next);
58
- }
59
-
60
- function saveUser(/** @type {User} */ input) {
61
- const { email, password } = input;
62
- const userToSave = { email, password: `hashed_${password}` };
63
- // No continuation: the saved user passes straight through.
64
- const cmdSaveUser = () => db.saveUser(userToSave);
65
- return Command(cmdSaveUser);
66
- }
67
-
68
- const registerUserFlow = (/** @type {User} */ input) =>
69
- effectPipe(validateRegistration, ensureEmailIsAvailable, saveUser)(input);
70
-
71
- async function registerUser(/** @type {User} */ input) {
72
- return await runEffect(registerUserFlow(input), { flowName: 'registerUser' });
73
- }
74
-
75
- describe('Core', function () {
76
- // Global hooks outlive a suite, so this guards against inheriting another suite's wiring.
77
- beforeEach(() => configureEffect());
78
-
79
- it('should return Failure when e-mail is invalid', async function () {
80
- const badInput = { email: 'bad-email', password: '123' };
81
- const result = registerUserFlow(badInput);
82
- assert.deepEqual(result, Failure('Invalid email format.', badInput));
83
- });
84
-
85
- it('should walk through the call tree', async function () {
86
- const input = { email: 'test@test.com', password: 'password123' };
87
- const step1 = registerUserFlow(input);
88
- assert.equal(step1.type, 'Command');
89
- assert.equal(step1.cmd.name, 'cmdFindUser');
90
-
91
- const step2 = step1.next(null);
92
- assert.equal(step2.type, 'Command');
93
- assert.equal(step2.cmd.name, 'cmdSaveUser');
94
- });
95
-
96
- it('should access context through onBeforeCommand', async function () {
97
- const input = { email: 'context@test.com', password: 'password123' };
98
- await runEffect(
99
- registerUserFlow(input),
100
- { env: 'test' },
101
- {
102
- onBeforeCommand: /** @type CommandInterceptor */ async (command, context) =>
103
- assert.equal(context.env, 'test')
104
- }
105
- );
106
- });
107
-
108
- it('should access context through Ask', async function () {
109
- /** @type {any} */
110
- let capturedCtx;
111
- const step = () =>
112
- Ask((ctx) => {
113
- capturedCtx = ctx;
114
- return Success(null);
115
- });
116
- await runEffect(step(), { env: 'test' });
117
- assert.equal(capturedCtx.env, 'test');
118
- });
119
-
120
- it('should work with Ask at any point in the pipeline', async function () {
121
- const flow = effectPipe(
122
- () =>
123
- Command(
124
- () => 'value',
125
- (r) => Success(r)
126
- ),
127
- (value) => Ask((/** @type {any} */ ctx) => Success({ value, env: ctx.env }))
128
- );
129
- const result = await runEffect(flow(null), { env: 'test' });
130
- assert.equal(result.type, 'Success');
131
- assert.deepEqual(result.value, { value: 'value', env: 'test' });
132
- });
133
-
134
- it('should return a Retry data structure', function () {
135
- const inner = Command(
136
- () => 'x',
137
- (r) => Success(r)
138
- );
139
- const effect = Retry(inner, { attempts: 5 });
140
- assert.equal(effect.type, 'Retry');
141
- assert.deepEqual(effect.options, { attempts: 5 });
142
- assert.strictEqual(effect.effect, inner);
143
- assert.equal(typeof effect.next, 'function');
144
- });
145
-
146
- it('should succeed after transient failures', async function () {
147
- let calls = 0;
148
- const effect = Retry(
149
- Command(
150
- function flakyCmd() {
151
- if (++calls < 3) throw new Error('transient');
152
- return 'ok';
153
- },
154
- (r) => Success(r)
155
- ),
156
- { attempts: 3, delay: 0 }
157
- );
158
- const result = await runEffect(effect);
159
- assert.equal(result.type, 'Success');
160
- assert.equal(result.value, 'ok');
161
- assert.equal(calls, 3);
162
- });
163
-
164
- it('should return rich Failure when retries are exhausted', async function () {
165
- const effect = Retry(
166
- Command(
167
- function alwaysFails() {
168
- throw new Error('boom');
169
- },
170
- (/** @type {any} */ r) => Success(r)
171
- ),
172
- { attempts: 2, delay: 0 }
173
- );
174
- const result = await runEffect(effect);
175
- assert.equal(result.type, 'Failure');
176
- if (result.type !== 'Failure') throw new Error('expected Failure');
177
- const error = /** @type {import('../index.js').RetryExhaustedError<Error>} */ (result.error);
178
- assert.equal(error.retryExhausted, true);
179
- assert.equal(error.attempts, 2);
180
- assert.equal(error.lastError.message, 'boom');
181
- });
182
-
183
- it('should apply delay and backoff between retries', async function () {
184
- this.timeout(2000);
185
- let calls = 0;
186
- const start = Date.now();
187
- const effect = Retry(
188
- Command(
189
- function flakyCmd() {
190
- if (++calls < 3) throw new Error('transient');
191
- return 'ok';
192
- },
193
- (r) => Success(r)
194
- ),
195
- { attempts: 3, delay: 30, backoff: 1 }
196
- );
197
- const result = await runEffect(effect);
198
- const elapsed = Date.now() - start;
199
- assert.equal(result.type, 'Success');
200
- // 2 retries × 30 ms = at least 55 ms (5 ms margin for timing variance)
201
- assert.ok(elapsed >= 55, `Expected ≥ 55 ms elapsed, got ${elapsed} ms`);
202
- });
203
-
204
- it('should merge per-use Retry options with call-level defaults', async function () {
205
- // Call-level: attempts 1 (would exhaust on 2nd try)
206
- // Per-use: attempts 3 (overrides call-level, so it should succeed on the 3rd try)
207
- let calls = 0;
208
- const effect = Retry(
209
- Command(
210
- function flakyCmd() {
211
- if (++calls < 3) throw new Error('x');
212
- return 'ok';
213
- },
214
- (r) => Success(r)
215
- ),
216
- { attempts: 3 }
217
- );
218
- const result = await runEffect(effect, {}, { retry: { attempts: 1, delay: 0, backoff: 1 } });
219
- assert.equal(result.type, 'Success');
220
- assert.equal(calls, 3);
221
- });
222
-
223
- it('should work at any step inside effectPipe', async function () {
224
- const flow = effectPipe(
225
- (input) =>
226
- Retry(
227
- Command(
228
- function fetchCmd() {
229
- return input.toUpperCase();
230
- },
231
- (r) => Success(r)
232
- ),
233
- { attempts: 2, delay: 0 }
234
- ),
235
- (upper) => Success(`${upper}!`)
236
- );
237
- const result = await runEffect(flow('hello'));
238
- assert.equal(result.type, 'Success');
239
- assert.equal(result.value, 'HELLO!');
240
- });
241
-
242
- it('should return a Parallel data structure', () => {
243
- const e1 = Success(1);
244
- const e2 = Success(2);
245
- const next = (/** @type {any[]} */ values) => Success(values);
246
- const result = Parallel([e1, e2], next);
247
- assert.equal(result.type, 'Parallel');
248
- assert.deepEqual(result.effects, [e1, e2]);
249
- assert.equal(result.next, next);
250
- });
251
-
252
- it('should default next to Success of the values array when omitted', async () => {
253
- const e1 = Command(async () => 'a');
254
- const e2 = Command(async () => 'b');
255
- const result = await runEffect(Parallel([e1, e2]));
256
- assert.equal(result.type, 'Success');
257
- assert.deepEqual(result.value, ['a', 'b']);
258
- });
259
-
260
- it('should run effects concurrently and pass results to next', async () => {
261
- const e1 = Command(
262
- async () => 'a',
263
- (v) => Success(v)
264
- );
265
- const e2 = Command(
266
- async () => 'b',
267
- (v) => Success(v)
268
- );
269
- const flow = Parallel([e1, e2], ([a, b]) => Success({ a, b }));
270
- const result = await runEffect(flow);
271
- assert.equal(result.type, 'Success');
272
- assert.deepEqual(result.value, { a: 'a', b: 'b' });
273
- });
274
-
275
- it('should return Failure if any parallel effect fails', async () => {
276
- const e1 = Success('ok');
277
- const e2 = Failure('oops');
278
- const flow = Parallel([e1, e2], ([a, b]) => Success({ a, b }));
279
- const result = await runEffect(flow);
280
- assert.equal(result.type, 'Failure');
281
- assert.equal(result.error, 'oops');
282
- });
283
-
284
- it('should work inside effectPipe', async () => {
285
- const flow = effectPipe((input) =>
286
- Parallel(
287
- [
288
- Command(
289
- async () => input.a,
290
- (v) => Success(v)
291
- ),
292
- Command(
293
- async () => input.b,
294
- (v) => Success(v)
295
- )
296
- ],
297
- ([a, b]) => Success({ a, b })
298
- )
299
- );
300
- const result = await runEffect(flow({ a: 1, b: 2 }));
301
- assert.equal(result.type, 'Success');
302
- assert.deepEqual(result.value, { a: 1, b: 2 });
303
- });
304
-
305
- it('should pass context to parallel branches via Ask', async () => {
306
- const flow = Parallel(
307
- [Ask((/** @type {any} */ ctx) => Success(ctx.x)), Ask((/** @type {any} */ ctx) => Success(ctx.y))],
308
- ([x, y]) => Success({ x, y })
309
- );
310
- const result = await runEffect(flow, { x: 10, y: 20 });
311
- assert.equal(result.type, 'Success');
312
- assert.deepEqual(result.value, { x: 10, y: 20 });
313
- });
314
-
315
- it('should return Success after runEffect with telemetry disabled', async function () {
316
- const input = { email: 'test-no-telemetry@test.com', password: 'password123' };
317
- const result = await registerUser(input);
318
- assert.equal(result.type, 'Success');
319
- });
320
- });
321
-
322
- describe('Retry onExhausted', function () {
323
- beforeEach(() => configureEffect());
324
-
325
- it('should run the fallback on exhaustion and feed its value downstream', async function () {
326
- let liveCalls = 0;
327
- /** @type {any} */
328
- let sawError;
329
- const flow = effectPipe(
330
- (/** @type {any} */ sku) =>
331
- Retry(
332
- Command(function cmdFetchLive() {
333
- liveCalls++;
334
- return Promise.reject(new Error('down'));
335
- }),
336
- {
337
- attempts: 2,
338
- delay: 0,
339
- onExhausted: (/** @type {any} */ err) => {
340
- sawError = err;
341
- return Command(function cmdFetchCached() {
342
- return Promise.resolve({ sku, amount: 95 });
343
- });
344
- }
345
- }
346
- ),
347
- (/** @type {any} */ price) => Success({ ...price, stale: true })
348
- );
349
- const result = /** @type {any} */ (await runEffect(flow('sku-1')));
350
- assert.equal(result.type, 'Success');
351
- assert.deepEqual(result.value, { sku: 'sku-1', amount: 95, stale: true });
352
- assert.equal(liveCalls, 3, 'the primary still runs the full retry schedule first');
353
- assert.equal(sawError.retryExhausted, true);
354
- assert.equal(sawError.attempts, 2);
355
- assert.equal(sawError.lastError.message, 'down');
356
- });
357
-
358
- it('should propagate a failing fallback unwrapped', async function () {
359
- const flow = Retry(
360
- Command(function cmdPrimary() {
361
- return Promise.reject('primary down');
362
- }),
363
- { attempts: 0, delay: 0, onExhausted: () => Failure('cache empty') }
364
- );
365
- const result = /** @type {any} */ (await runEffect(flow));
366
- assert.equal(result.type, 'Failure');
367
- assert.equal(result.error, 'cache empty', 'the fallback failure must not be wrapped in retryExhausted');
368
- });
369
-
370
- it('should not run the fallback in a cancelled Parallel branch', async function () {
371
- let fallbackRuns = 0;
372
- const slowFailing = Retry(
373
- Command(async function cmdSlowFail() {
374
- await new Promise((resolve) => setTimeout(resolve, 30));
375
- throw new Error('slow branch failed');
376
- }),
377
- {
378
- attempts: 0,
379
- delay: 0,
380
- onExhausted: () => {
381
- fallbackRuns++;
382
- return Success('recovered');
383
- }
384
- }
385
- );
386
- const fastFailing = Command(async function cmdFastFail() {
387
- await new Promise((resolve) => setTimeout(resolve, 5));
388
- throw new Error('fast branch failed');
389
- });
390
- const result = /** @type {any} */ (await runEffect(Parallel([fastFailing, slowFailing])));
391
- assert.equal(result.type, 'Failure');
392
- assert.equal(result.error.message, 'fast branch failed');
393
- assert.equal(fallbackRuns, 0, 'a cancelled branch must not start its fallback');
394
- });
395
-
396
- it('should record the fallback under its own path and replay it with zero I/O', async function () {
397
- const calls = { live: 0, cached: 0 };
398
- const flow = (/** @type {any} */ sku) =>
399
- Retry(
400
- Command(function cmdFetchLive() {
401
- calls.live++;
402
- return Promise.reject(new Error('down'));
403
- }),
404
- {
405
- attempts: 1,
406
- delay: 0,
407
- onExhausted: () =>
408
- Command(function cmdFetchCached() {
409
- calls.cached++;
410
- return Promise.resolve(95);
411
- })
412
- }
413
- );
414
- const { result, trace } = await recordEffect(flow, 'sku-1');
415
- assert.equal(result.type, 'Success');
416
- assert.ok(
417
- trace.trace.some((/** @type {any} */ e) => e.command === 'cmdFetchCached' && /f\//.test(e.path)),
418
- 'the fallback step must be recorded under a fallback path prefix'
419
- );
420
- const before = { ...calls };
421
- const replayed = /** @type {any} */ (await replayEffect(flow('sku-1'), trace));
422
- assert.equal(replayed.type, 'Success');
423
- assert.deepEqual(replayed.value, /** @type {any} */ (result).value);
424
- assert.deepEqual(calls, before, 'replay must perform no I/O');
425
- });
426
-
427
- it('should strip Retry delays inside the fallback during replay', async function () {
428
- const flow = (/** @type {any} */ n) =>
429
- Retry(
430
- Command(function cmdPrimary() {
431
- return Promise.reject('nope');
432
- }),
433
- {
434
- attempts: 0,
435
- delay: 0,
436
- onExhausted: () =>
437
- Retry(
438
- Command(function cmdSecondary() {
439
- return Promise.reject('still nope');
440
- }),
441
- { attempts: 2, delay: 200 }
442
- )
443
- }
444
- );
445
- const { trace } = await recordEffect(flow, 1);
446
- const started = Date.now();
447
- const replayed = /** @type {any} */ (await replayEffect(flow(1), trace));
448
- const elapsed = Date.now() - started;
449
- assert.equal(replayed.type, 'Failure');
450
- assert.ok(elapsed < 150, `replay must not wait out the fallback backoff, took ${elapsed}ms`);
451
- });
452
- });
453
-
454
- /** Reads `.value` off a runEffect result once its type has been asserted. */
455
- const valueOf = (/** @type {any} */ result) => result.value;
456
-
457
- /** Reads `.error` off a runEffect result once its type has been asserted. */
458
- const errorOf = (/** @type {any} */ result) => result.error;
459
-
460
- describe('Recording and replay', function () {
461
- beforeEach(() => configureEffect({}));
462
-
463
- it('should record and replay the registration flow end to end', async function () {
464
- const input = { email: 'replay@test.com', password: 'password123' };
465
- const { result, trace } = await recordEffect(registerUserFlow, input);
466
- assert.equal(result.type, 'Success');
467
- assert.deepEqual(
468
- trace.trace.map((e) => e.command),
469
- ['cmdFindUser', 'cmdSaveUser'],
470
- 'every Command in the flow is recorded, including the guard'
471
- );
472
-
473
- const replayed = await replayEffect(registerUserFlow(/** @type {User} */ (trace.initialInput)), trace);
474
- assert.equal(replayed.type, 'Success');
475
- assert.deepEqual(valueOf(replayed), valueOf(result));
476
- });
477
-
478
- /** A flow whose Commands count their own invocations, so tests can assert zero I/O. */
479
- const makeFlow = () => {
480
- const calls = { read: 0, write: 0 };
481
- const flow = (/** @type {any} */ input) =>
482
- effectPipe(
483
- (/** @type {any} */ i) => (i.id ? Success(i) : Failure('no_id')),
484
- (/** @type {any} */ i) =>
485
- Command(
486
- function cmdRead() {
487
- calls.read++;
488
- return { row: i.id };
489
- },
490
- (/** @type {any} */ row) => Success({ ...i, ...row })
491
- ),
492
- (/** @type {any} */ acc) =>
493
- Command(
494
- function cmdWrite() {
495
- calls.write++;
496
- return { written: acc.row };
497
- },
498
- (/** @type {any} */ w) => Success(w)
499
- )
500
- )(input);
501
- return { flow, calls };
502
- };
503
-
504
- it('should replay a recorded flow without performing any I/O', async function () {
505
- const a = makeFlow();
506
- const { result, trace } = await recordEffect(a.flow, { id: 'x1' }, { version: 'abc123' });
507
- assert.equal(result.type, 'Success');
508
- assert.deepEqual(a.calls, { read: 1, write: 1 });
509
- assert.equal(trace.version, 'abc123');
510
-
511
- const b = makeFlow();
512
- const replayed = await replayEffect(b.flow(trace.initialInput), trace);
513
- assert.equal(replayed.type, 'Success');
514
- assert.deepEqual(valueOf(replayed), valueOf(result));
515
- assert.deepEqual(b.calls, { read: 0, write: 0 }, 'replay must not touch the world');
516
- });
517
-
518
- it('should consume duplicate Command names in recorded order', async function () {
519
- let n = 0;
520
- const readTwice = (/** @type {any} */ input) =>
521
- effectPipe(
522
- () =>
523
- Command(
524
- function cmdRead() {
525
- return ++n;
526
- },
527
- (/** @type {any} */ v) => Success(v)
528
- ),
529
- (/** @type {any} */ first) =>
530
- Command(
531
- function cmdRead() {
532
- return ++n;
533
- },
534
- (/** @type {any} */ second) => Success([first, second])
535
- )
536
- )(input);
537
-
538
- const { trace } = await recordEffect(readTwice, null);
539
- assert.deepEqual(
540
- trace.trace.map((e) => e.result),
541
- [1, 2]
542
- );
543
-
544
- n = 100;
545
- const replayed = await replayEffect(readTwice(null), trace);
546
- assert.deepEqual(valueOf(replayed), [1, 2], 'each call got its own recorded value');
547
- assert.equal(n, 100, 'the real command never ran');
548
- });
549
-
550
- it('should restore the recorded context so Ask replays faithfully', async function () {
551
- const flow = (/** @type {any} */ id) =>
552
- Ask((/** @type {any} */ ctx) =>
553
- Command(
554
- function cmdFindProduct() {
555
- return { id, tenant: ctx.tenant };
556
- },
557
- (/** @type {any} */ p) => Success(p)
558
- )
559
- );
560
-
561
- const { trace } = await recordEffect(flow, 'sku-1', { context: { tenant: 'acme', flowName: 'lookup' } });
562
- assert.equal(/** @type {any} */ (trace.context).tenant, 'acme');
563
-
564
- const replayed = await replayEffect(flow('sku-1'), trace, { context: trace.context });
565
- assert.deepEqual(valueOf(replayed), { id: 'sku-1', tenant: 'acme' });
566
- });
567
-
568
- it('should replay recorded Retry attempts without waiting out the backoff', async function () {
569
- this.timeout(2000);
570
- let attempt = 0;
571
- const flow = () =>
572
- Retry(
573
- Command(
574
- function cmdFetch() {
575
- if (++attempt < 3) throw new Error(`transient ${attempt}`);
576
- return { tempC: 21 };
577
- },
578
- (/** @type {any} */ d) => Success(d)
579
- ),
580
- { attempts: 3, delay: 200, backoff: 2 }
581
- );
582
-
583
- const { result, trace } = await recordEffect(flow, null);
584
- assert.equal(result.type, 'Success');
585
- assert.equal(trace.trace.length, 3, 'every attempt is a recorded step');
586
-
587
- attempt = 0;
588
- const start = Date.now();
589
- const replayed = await replayEffect(flow(), trace);
590
- const elapsed = Date.now() - start;
591
- assert.deepEqual(valueOf(replayed), { tempC: 21 });
592
- assert.equal(attempt, 0, 'no attempt was re-executed');
593
- assert.ok(elapsed < 100, `replay skipped 200 ms + 400 ms of backoff (took ${elapsed} ms)`);
594
- });
595
-
596
- it('should replay retry exhaustion as the same structured Failure', async function () {
597
- const flow = () =>
598
- Retry(
599
- Command(
600
- function cmdFlaky() {
601
- throw new Error('down');
602
- },
603
- (/** @type {any} */ v) => Success(v)
604
- ),
605
- { attempts: 2, delay: 0 }
606
- );
607
-
608
- const { result, trace } = await recordEffect(flow, null);
609
- assert.equal(result.type, 'Failure');
610
-
611
- const replayed = await replayEffect(flow(), trace);
612
- assert.equal(replayed.type, 'Failure');
613
- const error = /** @type {import('../index.js').RetryExhaustedError<Error>} */ (errorOf(replayed));
614
- assert.equal(error.retryExhausted, true);
615
- assert.equal(error.attempts, 2);
616
- assert.equal(error.lastError.message, 'down');
617
- });
618
-
619
- it('should replay a Parallel flow whose branches finish out of order', async function () {
620
- const flow = (/** @type {any} */ id) =>
621
- Parallel(
622
- [
623
- Command(
624
- function cmdSlow() {
625
- return new Promise((r) => setTimeout(() => r({ id }), 20));
626
- },
627
- (/** @type {any} */ v) => Success(v)
628
- ),
629
- Command(
630
- function cmdFast() {
631
- return Promise.resolve('fast');
632
- },
633
- (/** @type {any} */ v) => Success(v)
634
- )
635
- ],
636
- ([slow, fast]) => Success({ slow, fast })
637
- );
638
-
639
- const { result, trace } = await recordEffect(flow, 'u1');
640
- assert.deepEqual(
641
- trace.trace.map((e) => e.command),
642
- ['cmdFast', 'cmdSlow'],
643
- 'recording order follows completion, not the effects array'
644
- );
645
- assert.deepEqual(
646
- trace.trace.map((e) => e.path),
647
- ['0p1/0', '0p0/0'],
648
- 'each entry is labelled by its branch, so completion order does not matter'
649
- );
650
-
651
- // No `strict: false` needed: paths are order-independent, so the default replays correctly.
652
- const replayed = await replayEffect(flow('u1'), trace);
653
- assert.equal(replayed.type, 'Success');
654
- assert.deepEqual(valueOf(replayed), valueOf(result));
655
- });
656
-
657
- it('should keep two Parallel branches that call the same Command apart', async function () {
658
- // The failure this pins is silent: per-Command queues pair branches by completion order, so
659
- // reversing the branch latencies between recording and replay swapped one branch's result
660
- // onto the other and the replay still reported Success.
661
- const flow = (/** @type {[number, number]} */ delays) =>
662
- Parallel(
663
- [
664
- Command(
665
- function cmdFetch() {
666
- return new Promise((r) => setTimeout(() => r('A'), delays[0]));
667
- },
668
- (/** @type {any} */ v) => Success(v)
669
- ),
670
- Command(
671
- function cmdFetch() {
672
- return new Promise((r) => setTimeout(() => r('B'), delays[1]));
673
- },
674
- (/** @type {any} */ v) => Success(v)
675
- )
676
- ],
677
- (/** @type {any} */ vals) => Success(vals)
678
- );
679
-
680
- const { result, trace } = await recordEffect(flow, [40, 5]);
681
- assert.deepEqual(valueOf(result), ['A', 'B']);
682
-
683
- // Replay with the latencies reversed, so completion order is the opposite of the recorded run.
684
- const replayed = await replayEffect(flow([5, 40]), trace);
685
- assert.equal(replayed.type, 'Success');
686
- assert.deepEqual(valueOf(replayed), ['A', 'B'], 'branch results were not swapped');
687
- });
688
-
689
- it('should still resolve a legacy trace that carries no paths', async function () {
690
- const a = makeFlow();
691
- const { result, trace } = await recordEffect(a.flow, { id: 'legacy' });
692
- const legacy = {
693
- ...trace,
694
- trace: trace.trace.map(({ path, ...rest }) => rest)
695
- };
696
- assert.ok(
697
- legacy.trace.every((e) => e.path === undefined),
698
- 'the fixture really has no paths'
699
- );
700
-
701
- const b = makeFlow();
702
- const replayed = await replayEffect(b.flow(trace.initialInput), legacy);
703
- assert.equal(replayed.type, 'Success');
704
- assert.deepEqual(valueOf(replayed), valueOf(result));
705
- assert.deepEqual(b.calls, { read: 0, write: 0 }, 'a legacy trace still performs no I/O');
706
- });
707
-
708
- it('should raise a TimeParadox naming the path when a flow diverges', async function () {
709
- const original = (/** @type {any} */ id) =>
710
- effectPipe(
711
- () =>
712
- Command(
713
- function cmdRead() {
714
- return id;
715
- },
716
- (/** @type {any} */ v) => Success(v)
717
- ),
718
- (/** @type {any} */ v) =>
719
- Command(
720
- function cmdWrite() {
721
- return v;
722
- },
723
- (/** @type {any} */ w) => Success(w)
724
- )
725
- )(id);
726
- const { trace } = await recordEffect(original, 'x1');
727
-
728
- // The second step is a different Command than the trace recorded at that path.
729
- const diverged = (/** @type {any} */ id) =>
730
- effectPipe(
731
- () =>
732
- Command(
733
- function cmdRead() {
734
- return id;
735
- },
736
- (/** @type {any} */ v) => Success(v)
737
- ),
738
- (/** @type {any} */ v) =>
739
- Command(
740
- function cmdAudit() {
741
- return v;
742
- },
743
- (/** @type {any} */ w) => Success(w)
744
- )
745
- )(id);
746
-
747
- const replayed = await replayEffect(diverged('x1'), trace);
748
- assert.equal(replayed.type, 'Failure');
749
- const error = /** @type {any} */ (errorOf(replayed));
750
- assert.equal(error.name, 'TimeParadox');
751
- assert.equal(error.path, '1');
752
- assert.equal(error.expected, 'cmdWrite');
753
- assert.equal(error.actual, 'cmdAudit');
754
- });
755
-
756
- it('should accept a trace directly, without building a Resolver', async function () {
757
- const a = makeFlow();
758
- const { result, trace } = await recordEffect(a.flow, { id: 'direct' });
759
-
760
- const b = makeFlow();
761
- const replayed = await replayEffect(b.flow(trace.initialInput), trace);
762
- assert.equal(replayed.type, 'Success');
763
- assert.deepEqual(valueOf(replayed), valueOf(result));
764
- assert.deepEqual(b.calls, { read: 0, write: 0 });
765
-
766
- // A bare entries array is a valid trace too, without the surrounding TraceLog.
767
- const c = makeFlow();
768
- const fromEntries = await replayEffect(c.flow(trace.initialInput), trace.trace);
769
- assert.equal(fromEntries.type, 'Success');
770
- assert.deepEqual(valueOf(fromEntries), valueOf(result));
771
- });
772
-
773
- it('should carry an error cause through a trace and back', async function () {
774
- const flow = (/** @type {any} */ input) =>
775
- Command(function cmdCharge() {
776
- return Promise.reject(new Error('charge declined', { cause: new Error('card expired') }));
777
- });
778
-
779
- const { result, trace } = await recordEffect(flow, { id: 'cause' });
780
- assert.equal(result.type, 'Failure');
781
-
782
- // The trace must survive JSON, which is where a non-enumerable cause would silently vanish.
783
- const stored = JSON.parse(JSON.stringify(trace));
784
- const replayed = await replayEffect(flow(stored.initialInput), stored);
785
- assert.equal(replayed.type, 'Failure');
786
- const error = /** @type {any} */ (errorOf(replayed));
787
- assert.equal(error.message, 'charge declined');
788
- assert.ok(error.cause instanceof Error, 'the revived error must keep its cause');
789
- assert.equal(error.cause.message, 'card expired');
790
- });
791
-
792
- it('should carry a nested cause chain and a non-Error cause', async function () {
793
- const flow = (/** @type {any} */ input) =>
794
- Command(function cmdFetch() {
795
- return Promise.reject(
796
- new Error('fetch failed', { cause: new Error('socket closed', { cause: 'ECONNRESET' }) })
797
- );
798
- });
799
-
800
- const { trace } = await recordEffect(flow, { id: 'chain' });
801
- const stored = JSON.parse(JSON.stringify(trace));
802
- const replayed = await replayEffect(flow(stored.initialInput), stored);
803
- const error = /** @type {any} */ (errorOf(replayed));
804
- assert.equal(error.cause.message, 'socket closed');
805
- assert.equal(error.cause.cause, 'ECONNRESET', 'a non-Error cause must pass through unchanged');
806
- });
807
-
808
- it('should reject a malformed trace as a ReplayError, not a TypeError', async function () {
809
- const { flow } = makeFlow();
810
- for (const bad of [undefined, null, 42, {}, { trace: 'nope' }]) {
811
- await assert.rejects(
812
- () => replayEffect(flow({ id: 'x' }), /** @type {any} */ (bad)),
813
- (/** @type {any} */ e) => e.name === 'ReplayError' && /no `trace` array/.test(e.message),
814
- `expected a ReplayError for ${JSON.stringify(bad)}`
815
- );
816
- }
817
- });
818
-
819
- it('should detect a trace recorded from a different flow', async function () {
820
- const flowA = () =>
821
- Command(
822
- function cmdAlpha() {
823
- return 1;
824
- },
825
- (/** @type {any} */ v) => Success(v)
826
- );
827
- const flowB = () =>
828
- Command(
829
- function cmdBeta() {
830
- return 2;
831
- },
832
- (/** @type {any} */ v) => Success(v)
833
- );
834
-
835
- const { trace } = await recordEffect(flowA, null);
836
- const replayed = await replayEffect(flowB(), trace);
837
- assert.equal(replayed.type, 'Failure');
838
- assert.equal(/** @type {Error} */ (errorOf(replayed)).name, 'TimeParadox');
839
- assert.match(
840
- /** @type {Error} */ (errorOf(replayed)).message,
841
- /asked for 'cmdBeta', trace recorded 'cmdAlpha'/
842
- );
843
- });
844
-
845
- it('should report an exhausted trace rather than silently succeeding', async function () {
846
- const { flow } = makeFlow();
847
- const replayed = await replayEffect(flow({ id: 'x' }), { trace: [] });
848
- assert.equal(replayed.type, 'Failure');
849
- assert.match(/** @type {Error} */ (errorOf(replayed)).message, /Trace exhausted/);
850
- });
851
-
852
- it('should refuse to run an unrecorded Command by default', async function () {
853
- const { flow, calls } = makeFlow();
854
- const replayed = await replayEffect(flow({ id: 'x' }), () => undefined);
855
- assert.equal(replayed.type, 'Failure');
856
- assert.match(/** @type {Error} */ (errorOf(replayed)).message, /refusing to run the real Command/);
857
- assert.deepEqual(calls, { read: 0, write: 0 });
858
- });
859
-
860
- it('should allow a recorded prefix and a live tail when onMissing is execute', async function () {
861
- const { flow, calls } = makeFlow();
862
- const recorded = [{ command: 'cmdRead', result: { row: 'FROM_TRACE' } }];
863
- /** @type {import('../index.js').Resolver} */
864
- const resolve = (step) => (step.index === 0 ? { result: recorded[0].result } : undefined);
865
-
866
- const replayed = await replayEffect(flow({ id: 'x' }), resolve, { onMissing: 'execute' });
867
- assert.equal(replayed.type, 'Success');
868
- assert.deepEqual(valueOf(replayed), { written: 'FROM_TRACE' });
869
- assert.deepEqual(calls, { read: 0, write: 1 }, 'only the unrecorded step performed I/O');
870
- });
871
-
872
- it('should not let a throwing redact fail the run or corrupt the trace', async function () {
873
- const rec = recorder({
874
- redact: () => {
875
- throw new Error('redact blew up');
876
- }
877
- });
878
- const result = await runEffect(
879
- Command(
880
- function cmdWork() {
881
- return 'ok';
882
- },
883
- (/** @type {any} */ v) => Success(v)
884
- ),
885
- {},
886
- { onStep: rec.onStep }
887
- );
888
- assert.equal(result.type, 'Success', "a redaction bug is not the flow's problem");
889
- assert.equal(valueOf(result), 'ok');
890
- assert.deepEqual(
891
- rec.entries.map(({ command, result }) => ({ command, result })),
892
- [{ command: 'cmdWork', result: '[redaction failed]' }]
893
- );
894
- });
895
-
896
- it('should keep redacted values out of the trace', async function () {
897
- const flow = () =>
898
- Command(
899
- function cmdLoadUser() {
900
- return { id: 7, email: 'ada@example.com', card: '4111111111111111' };
901
- },
902
- (/** @type {any} */ u) => Success(u)
903
- );
904
-
905
- const { trace } = await recordEffect(flow, null, {
906
- redact: (result, name) =>
907
- name === 'cmdLoadUser'
908
- ? { .../** @type {any} */ (result), email: '[redacted]', card: '[redacted]' }
909
- : result
910
- });
911
- assert.deepEqual(trace.trace[0].result, { id: 7, email: '[redacted]', card: '[redacted]' });
912
- assert.ok(!JSON.stringify(trace).includes('4111111111111111'));
913
- });
914
-
915
- it('should offer redact every value a trace holds', async function () {
916
- /** @type {any[]} */
917
- const seen = [];
918
- const flow = (/** @type {any} */ input) =>
919
- effectPipe(
920
- (/** @type {any} */ i) =>
921
- Command(
922
- function cmdRead() {
923
- return { id: 1, email: i.email };
924
- },
925
- (/** @type {any} */ r) => Success({ ...i, ...r })
926
- ),
927
- (/** @type {any} */ i) =>
928
- Command(
929
- function cmdSave() {
930
- throw Object.assign(new Error('duplicate'), { attempted: { password: i.password } });
931
- },
932
- (/** @type {any} */ r) => Success(r)
933
- )
934
- )(input);
935
-
936
- const { trace } = await recordEffect(
937
- flow,
938
- { email: 'user@test.com', password: 'hunter2' },
939
- {
940
- context: { flowName: 'register', authToken: 'bearer-abc123' },
941
- redact: (/** @type {any} */ value, /** @type {string} */ name, /** @type {string} */ kind) => {
942
- seen.push([kind, name]);
943
- if (kind === 'initialInput') return { ...value, password: '[redacted]' };
944
- if (kind === 'context') return { ...value, authToken: '[redacted]' };
945
- if (kind === 'error') return { ...value, attempted: '[redacted]' };
946
- return value;
947
- }
948
- }
949
- );
950
-
951
- assert.deepEqual(
952
- seen,
953
- [
954
- ['result', 'cmdRead'],
955
- ['error', 'cmdSave'],
956
- ['initialInput', 'initialInput'],
957
- ['context', 'context']
958
- ],
959
- 'results, errors, and the trace-level fields all pass through redact'
960
- );
961
- const json = JSON.stringify(trace);
962
- assert.ok(!json.includes('hunter2'), 'no password anywhere in the trace');
963
- assert.ok(!json.includes('bearer-abc123'), 'no token anywhere in the trace');
964
- });
965
-
966
- it('should leave an absent initialInput or context undefined rather than redacting nothing into an object', function () {
967
- const rec = recorder({ redact: (/** @type {any} */ value) => ({ ...value, added: true }) });
968
- const trace = rec.toTrace({ flowName: 'bare' });
969
- assert.equal(trace.initialInput, undefined);
970
- assert.equal(trace.context, undefined);
971
- });
972
-
973
- it('should still replay after initialInput is redacted, when no step branches on the redacted field', async function () {
974
- // Replay feeds recorded results rather than running Commands, so stripping a field only matters
975
- // if the flow's control flow reads it.
976
- const flow = (/** @type {any} */ input) =>
977
- effectPipe(
978
- (/** @type {any} */ i) => (i.email ? Success(i) : Failure('no_email')),
979
- (/** @type {any} */ i) =>
980
- Command(
981
- function cmdHash() {
982
- return { hash: `hashed_${i.password}` };
983
- },
984
- (/** @type {any} */ r) => Success(r)
985
- )
986
- )(input);
987
-
988
- const { result, trace } = await recordEffect(
989
- flow,
990
- { email: 'a@b.c', password: 'hunter2' },
991
- {
992
- redact: (/** @type {any} */ value, /** @type {string} */ name, /** @type {string} */ kind) =>
993
- kind === 'initialInput' ? { ...value, password: '[redacted]' } : value
994
- }
995
- );
996
- assert.equal(result.type, 'Success');
997
- assert.equal(/** @type {any} */ (trace.initialInput).password, '[redacted]');
998
-
999
- const replayed = await replayEffect(flow(trace.initialInput), trace);
1000
- assert.equal(replayed.type, 'Success', 'the redacted input still rebuilds a matching flow');
1001
- assert.deepEqual(valueOf(replayed), valueOf(result), 'and the recorded hash comes back intact');
1002
- });
1003
-
1004
- it('should cap a runaway trace and report how many steps were dropped', async function () {
1005
- const { flow } = makeFlow();
1006
- const rec = recorder({ maxEntries: 1 });
1007
- await runEffect(flow({ id: 'x' }), {}, { onStep: rec.onStep });
1008
- const trace = rec.toTrace({ initialInput: { id: 'x' } });
1009
- assert.equal(trace.trace.length, 1);
1010
- assert.equal(trace.dropped, 1);
1011
- });
1012
-
1013
- it('should give each recorder its own independent trace', async function () {
1014
- const { flow } = makeFlow();
1015
- const first = recorder();
1016
- const second = recorder();
1017
- await Promise.all([
1018
- runEffect(flow({ id: 'a' }), {}, { onStep: first.onStep }),
1019
- runEffect(flow({ id: 'b' }), {}, { onStep: second.onStep })
1020
- ]);
1021
- assert.equal(first.entries.length, 2);
1022
- assert.equal(second.entries.length, 2);
1023
- assert.equal(/** @type {any} */ (first.entries[0].result).row, 'a');
1024
- assert.equal(/** @type {any} */ (second.entries[0].result).row, 'b');
1025
- });
1026
-
1027
- it('should narrate a replay and flag a flow that diverged', async function () {
1028
- /** @type {string[]} */
1029
- const lines = [];
1030
- const traceLog = {
1031
- flowName: 'checkout',
1032
- version: 'deadbee',
1033
- initialInput: { id: 1 },
1034
- trace: [
1035
- { command: 'cmdRead', result: { row: 1 } },
1036
- { command: 'cmdWrite', result: { written: 1 } }
1037
- ]
1038
- };
1039
- // This flow stops after one Command, so the second recorded step is unreachable.
1040
- const shortFlow = (/** @type {any} */ input) =>
1041
- effectPipe(() =>
1042
- Command(
1043
- function cmdRead() {
1044
- return { row: 1 };
1045
- },
1046
- () => Success('stopped early')
1047
- )
1048
- )(input);
1049
-
1050
- const result = await timeTravel(shortFlow, traceLog, { log: (l) => lines.push(l), version: 'cafe123' });
1051
- assert.equal(result.type, 'Success');
1052
- const out = lines.join('\n');
1053
- assert.match(out, /trace was recorded at deadbee, replaying against cafe123/);
1054
- assert.match(out, /Replaying 'checkout' \(2 recorded steps\)/);
1055
- assert.match(out, /Step 1: cmdRead returned/);
1056
- assert.doesNotMatch(out, / in \d/, 'a hand-written trace has no timings to narrate');
1057
- assert.match(out, /1 recorded steps were never reached/);
1058
- });
1059
-
1060
- it('should narrate a Parallel flow when strict is off', async function () {
1061
- const flow = (/** @type {any} */ id) =>
1062
- Parallel(
1063
- [
1064
- Command(
1065
- function cmdSlow() {
1066
- return new Promise((r) => setTimeout(() => r({ id }), 20));
1067
- },
1068
- (/** @type {any} */ v) => Success(v)
1069
- ),
1070
- Command(
1071
- function cmdFast() {
1072
- return Promise.resolve('fast');
1073
- },
1074
- (/** @type {any} */ v) => Success(v)
1075
- )
1076
- ],
1077
- ([slow, fast]) => Success({ slow, fast })
1078
- );
1079
-
1080
- const { trace } = await recordEffect(flow, 'u1');
1081
- /** @type {string[]} */
1082
- const lines = [];
1083
- const result = await timeTravel(flow, trace, { strict: false, log: (l) => lines.push(l) });
1084
-
1085
- assert.equal(result.type, 'Success', 'strict must reach the trace resolution');
1086
- const out = lines.join('\n');
1087
- assert.match(out, /cmdSlow returned/);
1088
- assert.match(out, /cmdFast returned/);
1089
- assert.doesNotMatch(out, /never reached/, 'both recorded steps were consumed');
1090
- });
1091
-
1092
- it('should not execute any Command across every primitive during replay', async function () {
1093
- let invocations = 0;
1094
- const trap = (/** @type {string} */ name, /** @type {any} */ value) => {
1095
- const thunk = () => {
1096
- invocations++;
1097
- return value;
1098
- };
1099
- Object.defineProperty(thunk, 'name', { value: name });
1100
- return Command(thunk, (/** @type {any} */ v) => Success(v));
1101
- };
1102
-
1103
- const flow = (/** @type {any} */ input) =>
1104
- effectPipe(
1105
- (/** @type {any} */ i) => Success(i),
1106
- (/** @type {any} */ i) =>
1107
- Ask((/** @type {any} */ ctx) =>
1108
- Parallel(
1109
- [trap('cmdLoadUser', { tenant: ctx.tenant }), Retry(trap('cmdLoadQuota', { quota: 10 }))],
1110
- ([user, quota]) => Success({ ...i, user, quota })
1111
- )
1112
- ),
1113
- (/** @type {any} */ acc) => trap('cmdWriteAudit', { audited: acc.user.tenant })
1114
- )(input);
1115
-
1116
- const { result, trace } = await recordEffect(flow, { id: 1 }, { context: { tenant: 'acme' } });
1117
- assert.equal(result.type, 'Success');
1118
- assert.equal(invocations, 3);
1119
-
1120
- invocations = 0;
1121
- const replayed = await replayEffect(flow({ id: 1 }), trace, {
1122
- strict: false,
1123
- context: trace.context
1124
- });
1125
- assert.equal(replayed.type, 'Success');
1126
- assert.deepEqual(valueOf(replayed), valueOf(result));
1127
- assert.equal(invocations, 0, 'no Command thunk was applied');
1128
- });
1129
- });
1130
-
1131
- describe('configureEffect merging', function () {
1132
- beforeEach(() => configureEffect({}));
1133
-
1134
- /** A Command whose thunk is named, so hooks can be asserted by name. */
1135
- const work = (/** @type {any} */ value = 'ok') =>
1136
- Command(
1137
- function cmdWork() {
1138
- return value;
1139
- },
1140
- (/** @type {any} */ v) => Success(v)
1141
- );
1142
-
1143
- it('should nest onStep wrappers with the first config outermost', async function () {
1144
- /** @type {string[]} */
1145
- const order = [];
1146
- const outer = async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => {
1147
- order.push('outer:in');
1148
- const r = await op();
1149
- order.push('outer:out');
1150
- return r;
1151
- };
1152
- const inner = async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => {
1153
- order.push('inner:in');
1154
- const r = await op();
1155
- order.push('inner:out');
1156
- return r;
1157
- };
1158
- configureEffect({ onStep: outer }, { onStep: inner });
1159
- const result = await runEffect(work('ok'));
1160
- assert.equal(result.type, 'Success');
1161
- assert.deepEqual(order, ['outer:in', 'inner:in', 'inner:out', 'outer:out']);
1162
- });
1163
-
1164
- it('should let recording and telemetry share the single onStep slot', async function () {
1165
- /** @type {string[]} */
1166
- const spans = [];
1167
- const telemetry = async (/** @type {any} */ name, /** @type {any} */ t, /** @type {any} */ op) => {
1168
- spans.push(name);
1169
- return await op();
1170
- };
1171
- const rec = recorder();
1172
- configureEffect({ onStep: telemetry }, { onStep: rec.onStep });
1173
- const result = await runEffect(work('ok'));
1174
- assert.equal(valueOf(result), 'ok');
1175
- assert.deepEqual(spans, ['cmdWork']);
1176
- assert.deepEqual(
1177
- rec.entries.map(({ command, result }) => ({ command, result })),
1178
- [{ command: 'cmdWork', result: 'ok' }]
1179
- );
1180
- });
1181
-
1182
- it('should propagate a thrown Command through every wrapper', async function () {
1183
- /** @type {string[]} */
1184
- const saw = [];
1185
- const watcher =
1186
- (/** @type {string} */ label) =>
1187
- async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => {
1188
- try {
1189
- return await op();
1190
- } catch (e) {
1191
- saw.push(label);
1192
- throw e;
1193
- }
1194
- };
1195
- const rec = recorder();
1196
- configureEffect({ onStep: watcher('a') }, { onStep: rec.onStep }, { onStep: watcher('b') });
1197
- const result = await runEffect(
1198
- Command(
1199
- function cmdBoom() {
1200
- throw new Error('boom');
1201
- },
1202
- (/** @type {any} */ v) => Success(v)
1203
- )
1204
- );
1205
- assert.equal(result.type, 'Failure');
1206
- assert.deepEqual(saw, ['b', 'a'], 'the error unwinds from innermost to outermost');
1207
- assert.equal(rec.entries.length, 1, 'the recorder still captured the failing step');
1208
- assert.ok('error' in rec.entries[0]);
1209
- });
1210
-
1211
- it('should nest onRun wrappers and preserve flowName and the result', async function () {
1212
- /** @type {any[]} */
1213
- const seen = [];
1214
- const wrap =
1215
- (/** @type {string} */ label) =>
1216
- async (/** @type {any} */ effect, /** @type {any} */ op, /** @type {any} */ flowName) => {
1217
- seen.push([label, flowName]);
1218
- return await op();
1219
- };
1220
- configureEffect({ onRun: wrap('a') }, { onRun: wrap('b') });
1221
- const result = await runEffect(work('ok'), { flowName: 'checkout' });
1222
- assert.equal(valueOf(result), 'ok');
1223
- assert.deepEqual(seen, [
1224
- ['a', 'checkout'],
1225
- ['b', 'checkout']
1226
- ]);
1227
- });
1228
-
1229
- it('should run every onBeforeCommand interceptor in order', async function () {
1230
- /** @type {string[]} */
1231
- const calls = [];
1232
- configureEffect(
1233
- { onBeforeCommand: async (/** @type {any} */ c) => void calls.push(`a:${c.cmd.name}`) },
1234
- { onBeforeCommand: async (/** @type {any} */ c) => void calls.push(`b:${c.cmd.name}`) }
1235
- );
1236
- await runEffect(work('ok'));
1237
- assert.deepEqual(calls, ['a:cmdWork', 'b:cmdWork']);
1238
- });
1239
-
1240
- it('should merge retry defaults with later configs winning', async function () {
1241
- // attempts from the first, delay from the second: a slow default would make this test crawl.
1242
- configureEffect({ retry: { attempts: 5, delay: 200 } }, { retry: { delay: 0 } });
1243
- let calls = 0;
1244
- const started = Date.now();
1245
- const result = await runEffect(
1246
- Retry(
1247
- Command(
1248
- function cmdFlaky() {
1249
- if (++calls < 5) throw new Error('transient');
1250
- return 'ok';
1251
- },
1252
- (/** @type {any} */ v) => Success(v)
1253
- )
1254
- )
1255
- );
1256
- assert.equal(result.type, 'Success', 'the higher attempts count from the first config applied');
1257
- assert.equal(calls, 5);
1258
- assert.ok(Date.now() - started < 150, 'and the zero delay from the second config won');
1259
- });
1260
-
1261
- it('should leave slots no config defines at their defaults, and tolerate gaps', async function () {
1262
- /** @type {string[]} */
1263
- const intercepted = [];
1264
- configureEffect({ onBeforeCommand: async (/** @type {any} */ c) => void intercepted.push(c.cmd.name) });
1265
-
1266
- // Only onStep is named here, so the interceptor above must be gone rather than merged in.
1267
- configureEffect(
1268
- { onStep: async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => await op() },
1269
- undefined,
1270
- {}
1271
- );
1272
- assert.equal(valueOf(await runEffect(work('ok'))), 'ok');
1273
- assert.deepEqual(intercepted, [], 'merging is per call and does not accumulate across calls');
1274
- });
1275
-
1276
- it('should return a restore function that puts the previous wiring back', async function () {
1277
- /** @type {string[]} */
1278
- const first = [];
1279
- /** @type {string[]} */
1280
- const second = [];
1281
- configureEffect({
1282
- onStep: async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => (
1283
- first.push(n),
1284
- await op()
1285
- )
1286
- });
1287
-
1288
- const restore = configureEffect({
1289
- onStep: async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => (
1290
- second.push(n),
1291
- await op()
1292
- )
1293
- });
1294
- await runEffect(work('ok'));
1295
- assert.deepEqual(second, ['cmdWork'], 'the second wiring took over');
1296
- assert.deepEqual(first, [], 'and displaced the first');
1297
-
1298
- restore();
1299
- await runEffect(work('ok'));
1300
- assert.deepEqual(first, ['cmdWork'], 'restore brought the first wiring back');
1301
- assert.deepEqual(second, ['cmdWork'], 'without reactivating the second');
1302
- });
1303
-
1304
- it('should restore back to no hooks when nothing was configured before', async function () {
1305
- /** @type {string[]} */
1306
- const seen = [];
1307
- const restore = configureEffect({
1308
- onStep: async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => (
1309
- seen.push(n),
1310
- await op()
1311
- )
1312
- });
1313
- await runEffect(work('ok'));
1314
- restore();
1315
- await runEffect(work('ok'));
1316
- assert.deepEqual(seen, ['cmdWork'], 'the second run had no hooks at all');
1317
- });
1318
-
1319
- it('should restore retry defaults too', async function () {
1320
- configureEffect({ retry: { attempts: 1, delay: 0 } });
1321
- const restore = configureEffect({ retry: { attempts: 4, delay: 0 } });
1322
-
1323
- const flaky = () => {
1324
- let calls = 0;
1325
- return {
1326
- count: () => calls,
1327
- effect: () =>
1328
- Retry(
1329
- Command(
1330
- function cmdFlaky() {
1331
- if (++calls < 3) throw new Error('transient');
1332
- return 'ok';
1333
- },
1334
- (/** @type {any} */ v) => Success(v)
1335
- )
1336
- )
1337
- };
1338
- };
1339
-
1340
- const generous = flaky();
1341
- assert.equal((await runEffect(generous.effect())).type, 'Success', 'four attempts are enough');
1342
-
1343
- restore();
1344
- const stingy = flaky();
1345
- const result = await runEffect(stingy.effect());
1346
- assert.equal(result.type, 'Failure', 'the restored single attempt is not');
1347
- assert.equal(/** @type {any} */ (errorOf(result)).attempts, 1);
1348
- });
1349
-
1350
- it('should not clobber a newer wiring when restoring out of order', async function () {
1351
- /** @type {string[]} */
1352
- const a = [];
1353
- /** @type {string[]} */
1354
- const b = [];
1355
- const restoreA = configureEffect({
1356
- onStep: async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => (a.push(n), await op())
1357
- });
1358
- configureEffect({
1359
- onStep: async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => (b.push(n), await op())
1360
- });
1361
-
1362
- restoreA();
1363
- await runEffect(work('ok'));
1364
- assert.deepEqual(b, ['cmdWork'], "B's wiring survived A's late restore");
1365
- assert.deepEqual(a, [], 'and A did not come back');
1366
- });
1367
-
1368
- it('should reset every slot when called with nothing', async function () {
1369
- /** @type {string[]} */
1370
- const seen = [];
1371
- configureEffect({
1372
- onStep: async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => (
1373
- seen.push(n),
1374
- await op()
1375
- )
1376
- });
1377
- await runEffect(work('ok'));
1378
- configureEffect();
1379
- await runEffect(work('ok'));
1380
- assert.deepEqual(seen, ['cmdWork'], 'the second run ran with no hooks at all');
1381
- });
1382
- });
1383
-
1384
- describe('examples/recording-example.js', function () {
1385
- beforeEach(() => configureEffect({}));
1386
- afterEach(() => configureEffect({}));
1387
-
1388
- const failing = (/** @type {any} */ input) =>
1389
- effectPipe(
1390
- (/** @type {any} */ i) =>
1391
- Command(
1392
- function cmdRead() {
1393
- return { row: i.id };
1394
- },
1395
- (/** @type {any} */ r) => Success(r)
1396
- ),
1397
- () =>
1398
- Command(
1399
- function cmdWrite() {
1400
- throw new Error('write failed');
1401
- },
1402
- (/** @type {any} */ r) => Success(r)
1403
- )
1404
- )(input);
1405
-
1406
- it('should send a trace to the sink when a flow fails', async function () {
1407
- /** @type {any[]} */
1408
- const written = [];
1409
- enableRecording({ sink: async (/** @type {any} */ trace) => void written.push(trace) });
1410
- const result = await runEffect(failing({ id: 1 }), { flowName: 'writer', tenant: 'acme' });
1411
- assert.equal(result.type, 'Failure');
1412
- assert.equal(written.length, 1);
1413
- assert.deepEqual(
1414
- written[0].trace.map((/** @type {any} */ e) => e.command),
1415
- ['cmdRead', 'cmdWrite']
1416
- );
1417
- assert.equal(written[0].flowName, 'writer');
1418
- assert.deepEqual(written[0].context, { flowName: 'writer', tenant: 'acme' }, 'context captured for Ask replay');
1419
- assert.deepEqual(written[0].initialInput, { id: 1 });
1420
- });
1421
-
1422
- it('should keep successful runs out of the sink by default', async function () {
1423
- /** @type {any[]} */
1424
- const written = [];
1425
- enableRecording({ sink: async (/** @type {any} */ t) => void written.push(t) });
1426
- const ok = (/** @type {any} */ i) =>
1427
- Command(
1428
- function cmdOk() {
1429
- return i;
1430
- },
1431
- (/** @type {any} */ v) => Success(v)
1432
- );
1433
- assert.equal((await runEffect(ok({ id: 2 }))).type, 'Success');
1434
- assert.deepEqual(written, []);
1435
- });
1436
-
1437
- it('should give concurrent runs separate traces', async function () {
1438
- /** @type {any[]} */
1439
- const written = [];
1440
- enableRecording({ sink: async (/** @type {any} */ t) => void written.push(t) });
1441
- const slowFail = (/** @type {any} */ label) => (/** @type {any} */ input) =>
1442
- effectPipe(
1443
- () =>
1444
- Command(
1445
- function cmdSlow() {
1446
- return new Promise((r) => setTimeout(() => r(label), label === 'A' ? 20 : 5));
1447
- },
1448
- (/** @type {any} */ v) => Success(v)
1449
- ),
1450
- () =>
1451
- Command(
1452
- function cmdFail() {
1453
- throw new Error(`${label} failed`);
1454
- },
1455
- (/** @type {any} */ v) => Success(v)
1456
- )
1457
- )(input);
1458
- await Promise.all([
1459
- runEffect(slowFail('A')({ id: 'A' }), { flowName: 'A' }),
1460
- runEffect(slowFail('B')({ id: 'B' }), { flowName: 'B' })
1461
- ]);
1462
- assert.equal(written.length, 2);
1463
- for (const trace of written) {
1464
- assert.deepEqual(
1465
- trace.trace.map((/** @type {any} */ e) => e.command),
1466
- ['cmdSlow', 'cmdFail'],
1467
- 'each trace holds only its own run'
1468
- );
1469
- assert.equal(trace.trace[0].result, trace.flowName);
1470
- }
1471
- });
1472
-
1473
- it('should compose with a telemetry hook in one configureEffect call', async function () {
1474
- /** @type {string[]} */
1475
- const spans = [];
1476
- /** @type {any[]} */
1477
- const written = [];
1478
- configureEffect(
1479
- {
1480
- onStep: async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => (
1481
- spans.push(n),
1482
- await op()
1483
- )
1484
- },
1485
- recordingHooks({ sink: async (/** @type {any} */ t) => void written.push(t) })
1486
- );
1487
- const result = await runEffect(failing({ id: 3 }), { flowName: 'both' });
1488
- assert.equal(result.type, 'Failure');
1489
- assert.deepEqual(spans, ['cmdRead', 'cmdWrite'], 'telemetry still saw every step');
1490
- assert.equal(written.length, 1, 'and the trace was still written');
1491
- });
1492
-
1493
- it('should redact recorded results before they reach the sink', async function () {
1494
- /** @type {any[]} */
1495
- const written = [];
1496
- enableRecording({
1497
- sink: async (/** @type {any} */ t) => void written.push(t),
1498
- redact: (/** @type {any} */ result) => (result && result.row ? { row: '[redacted]' } : result)
1499
- });
1500
- await runEffect(failing({ id: 4 }));
1501
- assert.deepEqual(written[0].trace[0].result, { row: '[redacted]' });
1502
- });
1503
- });
1504
-
1505
- describe('examples/opentelemetry-example.js', function () {
1506
- beforeEach(() => configureEffect({}));
1507
- afterEach(() => configureEffect({}));
1508
-
1509
- /** A tracer stub, so the example is testable without standing up an SDK. */
1510
- const fakeTracer = () => {
1511
- /** @type {any[]} */
1512
- const spans = [];
1513
- return {
1514
- spans,
1515
- tracer: /** @type {any} */ ({
1516
- startActiveSpan: (/** @type {string} */ name, /** @type {any} */ fn) => {
1517
- const span = {
1518
- name,
1519
- /** @type {Record<string, any>} */ attributes: {},
1520
- /** @type {any} */ status: undefined,
1521
- exceptions: /** @type {any[]} */ ([]),
1522
- ended: false,
1523
- setAttribute(/** @type {string} */ k, /** @type {any} */ v) {
1524
- this.attributes[k] = v;
1525
- },
1526
- setStatus(/** @type {any} */ st) {
1527
- this.status = st;
1528
- },
1529
- recordException(/** @type {any} */ e) {
1530
- this.exceptions.push(e);
1531
- },
1532
- end() {
1533
- this.ended = true;
1534
- }
1535
- };
1536
- spans.push(span);
1537
- return fn(span);
1538
- }
1539
- })
1540
- };
1541
- };
1542
-
1543
- const workFlow = (/** @type {any} */ input) =>
1544
- effectPipe((/** @type {any} */ i) =>
1545
- Command(
1546
- function cmdWork() {
1547
- return 'done';
1548
- },
1549
- (/** @type {any} */ v) => Success(v)
1550
- )
1551
- )(input);
1552
-
1553
- it('should not let an unserializable input stop the flow', async function () {
1554
- // A request object or database client in the input is ordinary, and both hold cycles.
1555
- // Nothing serializes the input any more, so this guards against reintroducing that.
1556
- /** @type {any} */
1557
- const input = { id: 1 };
1558
- input.self = input;
1559
-
1560
- const { tracer, spans } = fakeTracer();
1561
- let ran = 0;
1562
- const flow = (/** @type {any} */ i) =>
1563
- effectPipe(() =>
1564
- Command(
1565
- function cmdWork() {
1566
- ran++;
1567
- return 'done';
1568
- },
1569
- (/** @type {any} */ v) => Success(v)
1570
- )
1571
- )(i);
1572
-
1573
- configureEffect(telemetryHooks({ tracer }));
1574
- const result = await runEffect(flow(input));
1575
-
1576
- assert.equal(result.type, 'Success', 'telemetry must not decide whether the flow runs');
1577
- assert.equal(ran, 1);
1578
- assert.ok(spans.every((/** @type {any} */ sp) => sp.ended));
1579
- });
1580
-
1581
- it('should never put Command values on spans', async function () {
1582
- const secret = { email: 'user@test.com', password: 'plaintext' };
1583
- const { tracer, spans } = fakeTracer();
1584
- configureEffect(telemetryHooks({ tracer }));
1585
- await runEffect(workFlow(secret), { flowName: 'register' });
1586
-
1587
- const attributes = JSON.stringify(spans.map((/** @type {any} */ sp) => sp.attributes));
1588
- assert.ok(!attributes.includes('plaintext'), 'no input values on spans');
1589
- assert.ok(!attributes.includes('user@test.com'), 'not even harmless-looking ones');
1590
- assert.ok(!attributes.includes('done'), 'no Command output on spans');
1591
- assert.deepEqual(Object.keys(spans[0].attributes), ['effect.flow'], 'the root span carries the flow name');
1592
- assert.deepEqual(Object.keys(spans[1].attributes), ['effect.type'], 'and a step span its type');
1593
- });
1594
-
1595
- it('should name the root span after the flow and open a child span per Command', async function () {
1596
- const { tracer, spans } = fakeTracer();
1597
- configureEffect(telemetryHooks({ tracer }));
1598
- await runEffect(workFlow({ id: 1 }), { flowName: 'checkout' });
1599
- assert.deepEqual(
1600
- spans.map((/** @type {any} */ s) => s.name),
1601
- ['checkout', 'cmdWork']
1602
- );
1603
- assert.equal(spans[1].attributes['effect.type'], 'Command');
1604
- });
1605
-
1606
- it('should mark a Failure on the root span and record a thrown Command', async function () {
1607
- const { tracer, spans } = fakeTracer();
1608
- configureEffect(telemetryHooks({ tracer }));
1609
- const boom = (/** @type {any} */ input) =>
1610
- effectPipe(() =>
1611
- Command(
1612
- function cmdBoom() {
1613
- throw new Error('boom');
1614
- },
1615
- (/** @type {any} */ v) => Success(v)
1616
- )
1617
- )(input);
1618
- const result = await runEffect(boom({ id: 1 }));
1619
- assert.equal(result.type, 'Failure');
1620
- assert.equal(spans[1].exceptions.length, 1, 'the step span recorded the exception');
1621
- assert.equal(spans[0].status.code, 2, 'and the root span is ERROR');
1622
- assert.ok(spans.every((/** @type {any} */ s) => s.ended));
1623
- });
1624
-
1625
- it('should still work through enableTelemetry with no SDK started', async function () {
1626
- enableTelemetry();
1627
- const result = await runEffect(workFlow({ id: 1 }), { flowName: 'noop-tracer' });
1628
- assert.equal(result.type, 'Success');
1629
- });
1630
-
1631
- it('should compose with recording in one configureEffect call', async function () {
1632
- const { tracer, spans } = fakeTracer();
1633
- /** @type {any[]} */
1634
- const written = [];
1635
- configureEffect(
1636
- telemetryHooks({ tracer }),
1637
- recordingHooks({ keep: () => true, sink: async (/** @type {any} */ t) => void written.push(t) })
1638
- );
1639
- const result = await runEffect(workFlow({ id: 1 }), { flowName: 'both' });
1640
- assert.equal(result.type, 'Success');
1641
- assert.deepEqual(
1642
- spans.map((/** @type {any} */ s) => s.name),
1643
- ['both', 'cmdWork']
1644
- );
1645
- assert.deepEqual(
1646
- written[0].trace.map((/** @type {any} */ e) => e.command),
1647
- ['cmdWork']
1648
- );
1649
- });
1650
- });
1651
-
1652
- describe('Recorded step timings', function () {
1653
- beforeEach(() => configureEffect({}));
1654
-
1655
- const slow = (/** @type {number} */ ms) =>
1656
- Command(
1657
- function cmdSlow() {
1658
- return new Promise((r) => setTimeout(() => r('done'), ms));
1659
- },
1660
- (/** @type {any} */ v) => Success(v)
1661
- );
1662
-
1663
- it('should record how long each Command took', async function () {
1664
- const rec = recorder();
1665
- await runEffect(slow(15), {}, { onStep: rec.onStep });
1666
- assert.equal(rec.entries.length, 1);
1667
- const { durationMs } = rec.entries[0];
1668
- assert.equal(typeof durationMs, 'number');
1669
- assert.ok(/** @type {number} */ (durationMs) >= 10, `expected at least 10ms, got ${durationMs}`);
1670
- assert.equal(
1671
- durationMs,
1672
- Math.round(/** @type {number} */ (durationMs) * 1000) / 1000,
1673
- 'rounded to microseconds'
1674
- );
1675
- });
1676
-
1677
- it('should record a duration for a Command that threw', async function () {
1678
- const rec = recorder();
1679
- const result = await runEffect(
1680
- Command(
1681
- function cmdBoom() {
1682
- throw new Error('boom');
1683
- },
1684
- (/** @type {any} */ v) => Success(v)
1685
- ),
1686
- {},
1687
- { onStep: rec.onStep }
1688
- );
1689
- assert.equal(result.type, 'Failure');
1690
- assert.ok('error' in rec.entries[0]);
1691
- assert.equal(typeof rec.entries[0].durationMs, 'number');
1692
- });
1693
-
1694
- it('should keep timings out of the way of replay', async function () {
1695
- const flow = (/** @type {any} */ input) => effectPipe(() => slow(5))(input);
1696
- const { result, trace } = await recordEffect(flow, { id: 1 });
1697
- assert.equal(typeof trace.trace[0].durationMs, 'number');
1698
- const replayed = await replayEffect(flow({ id: 1 }), trace);
1699
- assert.equal(replayed.type, 'Success');
1700
- assert.deepEqual(valueOf(replayed), valueOf(result));
1701
- });
1702
-
1703
- it('should not let an observation failure change the outcome', async function () {
1704
- // The guarantee `observeSteps` exists for, reached through its only caller.
1705
- let ran = 0;
1706
- const rec = recorder({
1707
- redact: () => {
1708
- throw new Error('redact blew up');
1709
- }
1710
- });
1711
- const result = await runEffect(
1712
- Command(
1713
- function cmdWork() {
1714
- ran++;
1715
- return 'ok';
1716
- },
1717
- (/** @type {any} */ v) => Success(v)
1718
- ),
1719
- {},
1720
- { onStep: rec.onStep }
1721
- );
1722
- assert.equal(result.type, 'Success');
1723
- assert.equal(valueOf(result), 'ok');
1724
- assert.equal(ran, 1);
1725
- });
1726
- });
1727
-
1728
- describe('Command identity', function () {
1729
- beforeEach(() => configureEffect({}));
1730
-
1731
- /** Captures the name each Command is executed under. */
1732
- const capture = () => {
1733
- /** @type {string[]} */
1734
- const names = [];
1735
- return {
1736
- names,
1737
- onStep: async (/** @type {any} */ name, /** @type {any} */ t, /** @type {any} */ op) => (
1738
- names.push(name),
1739
- await op()
1740
- )
1741
- };
1742
- };
1743
-
1744
- it('should default next to Success so a result passes straight through', async function () {
1745
- const result = await runEffect(Command(() => 'ok'));
1746
- assert.equal(result.type, 'Success');
1747
- assert.equal(valueOf(result), 'ok');
1748
- });
1749
-
1750
- it('should prefer meta.name over the thunk name', async function () {
1751
- const { names, onStep } = capture();
1752
- const effect = Command(
1753
- function cmdInternalName() {
1754
- return 'ok';
1755
- },
1756
- (/** @type {any} */ v) => Success(v),
1757
- { name: 'chargeCard' }
1758
- );
1759
- assert.equal(valueOf(await runEffect(effect, {}, { onStep })), 'ok');
1760
- assert.deepEqual(names, ['chargeCard']);
1761
- });
1762
-
1763
- it('should let an inline thunk be identified through meta.name', async function () {
1764
- const { names, onStep } = capture();
1765
- const effect = Command(
1766
- () => 'ok',
1767
- (/** @type {any} */ v) => Success(v),
1768
- { name: 'cmdInline' }
1769
- );
1770
- await runEffect(effect, {}, { onStep });
1771
- assert.deepEqual(names, ['cmdInline'], 'no longer anonymous');
1772
- });
1773
-
1774
- it('should fall back to the thunk name when meta carries no name', async function () {
1775
- const { names, onStep } = capture();
1776
- const effect = Command(
1777
- function cmdNamed() {
1778
- return 'ok';
1779
- },
1780
- (/** @type {any} */ v) => Success(v),
1781
- { attempt: 1 }
1782
- );
1783
- await runEffect(effect, {}, { onStep });
1784
- assert.deepEqual(names, ['cmdNamed']);
1785
- });
1786
-
1787
- it('should fall back to anonymous when neither is available', async function () {
1788
- const { names, onStep } = capture();
1789
- await runEffect(
1790
- Command(
1791
- () => 'ok',
1792
- (/** @type {any} */ v) => Success(v)
1793
- ),
1794
- {},
1795
- { onStep }
1796
- );
1797
- assert.deepEqual(names, ['anonymous']);
1798
- });
1799
-
1800
- it('should ignore a meta that is not an object or has a non-string name', async function () {
1801
- const { names, onStep } = capture();
1802
- const named = () =>
1803
- Command(
1804
- function cmdFallback() {
1805
- return 'ok';
1806
- },
1807
- (/** @type {any} */ v) => Success(v)
1808
- );
1809
- for (const meta of ['a string', 42, null, { name: 7 }, { name: '' }]) {
1810
- const effect = Command(named().cmd, (/** @type {any} */ v) => Success(v), meta);
1811
- await runEffect(effect, {}, { onStep });
1812
- }
1813
- assert.deepEqual(names, ['cmdFallback', 'cmdFallback', 'cmdFallback', 'cmdFallback', 'cmdFallback']);
1814
- });
1815
-
1816
- it('should carry meta.name through effectPipe into a trace and a replay', async function () {
1817
- const flow = (/** @type {any} */ input) =>
1818
- effectPipe(
1819
- (/** @type {any} */ i) =>
1820
- Command(
1821
- () => ({ row: i.id }),
1822
- (/** @type {any} */ r) => Success(r),
1823
- { name: 'readRow' }
1824
- ),
1825
- (/** @type {any} */ r) =>
1826
- Command(
1827
- () => ({ written: r.row }),
1828
- (/** @type {any} */ w) => Success(w),
1829
- { name: 'writeRow' }
1830
- )
1831
- )(input);
1832
-
1833
- const { result, trace } = await recordEffect(flow, { id: 'x1' });
1834
- assert.deepEqual(
1835
- trace.trace.map((e) => e.command),
1836
- ['readRow', 'writeRow'],
1837
- 'meta.name survives the rebuild chain performs on each pipe step'
1838
- );
1839
-
1840
- const replayed = await replayEffect(flow({ id: 'x1' }), trace);
1841
- assert.equal(replayed.type, 'Success', 'strict matching lines up on meta.name');
1842
- assert.deepEqual(valueOf(replayed), valueOf(result));
1843
- });
1844
- });
1845
-
1846
- describe('Recorded values are snapshots', function () {
1847
- beforeEach(() => configureEffect());
1848
-
1849
- it('should not let a later mutation rewrite what an earlier step returned', async function () {
1850
- const shared = { total: 100 };
1851
- const flow = (/** @type {any} */ input) =>
1852
- effectPipe(
1853
- () =>
1854
- Command(
1855
- function cmdRead() {
1856
- return shared;
1857
- },
1858
- (/** @type {any} */ r) => Success(r)
1859
- ),
1860
- (/** @type {any} */ r) =>
1861
- Command(
1862
- function cmdApplyDiscount() {
1863
- r.total = 0;
1864
- return { applied: true };
1865
- },
1866
- (/** @type {any} */ v) => Success(v)
1867
- )
1868
- )(input);
1869
-
1870
- const { trace } = await recordEffect(flow, { cartId: 1 });
1871
- assert.deepEqual(trace.trace[0].result, { total: 100 }, 'the trace holds what production saw');
1872
- assert.equal(shared.total, 0, 'while the live object was still mutated by the flow');
1873
- });
1874
-
1875
- it('should keep the trace stable when the caller mutates the value afterwards', async function () {
1876
- const row = { id: 1, tags: ['a'] };
1877
- const rec = recorder();
1878
- await runEffect(
1879
- Command(
1880
- function cmdRead() {
1881
- return row;
1882
- },
1883
- (/** @type {any} */ r) => Success(r)
1884
- ),
1885
- {},
1886
- { onStep: rec.onStep }
1887
- );
1888
- row.tags.push('b');
1889
- row.id = 99;
1890
- assert.deepEqual(rec.entries[0].result, { id: 1, tags: ['a'] }, 'nested values are snapshotted too');
1891
- });
1892
-
1893
- it('should fall back to the reference for values that cannot be cloned', async function () {
1894
- const rec = recorder();
1895
- const result = await runEffect(
1896
- Command(
1897
- function cmdWithFunction() {
1898
- return { ok: true, callback: () => 'not cloneable' };
1899
- },
1900
- (/** @type {any} */ r) => Success(r)
1901
- ),
1902
- {},
1903
- { onStep: rec.onStep }
1904
- );
1905
- assert.equal(result.type, 'Success', 'an uncloneable result must not fail the run');
1906
- assert.equal(/** @type {any} */ (rec.entries[0].result).ok, true, 'and the entry is still recorded');
1907
- });
1908
- });
1909
-
1910
- describe('Documented sharp edges', function () {
1911
- beforeEach(() => configureEffect());
1912
-
1913
- it('should re-run already-succeeded Commands on every Retry attempt', async function () {
1914
- // Pinned deliberately: `Retry` repeats the whole wrapped tree, which is why the docs say to
1915
- // wrap the one Command that fails transiently rather than a pipeline.
1916
- /** @type {string[]} */
1917
- const charges = [];
1918
- let receiptFailures = 0;
1919
- const effect = Retry(
1920
- effectPipe(
1921
- () =>
1922
- Command(
1923
- function cmdCharge() {
1924
- charges.push('charged');
1925
- return { id: charges.length };
1926
- },
1927
- (/** @type {any} */ r) => Success(r)
1928
- ),
1929
- () =>
1930
- Command(
1931
- function cmdSendReceipt() {
1932
- if (++receiptFailures < 3) throw new Error('smtp down');
1933
- return 'sent';
1934
- },
1935
- (/** @type {any} */ v) => Success(v)
1936
- )
1937
- )({ order: 1 }),
1938
- { attempts: 3, delay: 0 }
1939
- );
1940
-
1941
- const result = await runEffect(effect);
1942
- assert.equal(result.type, 'Success');
1943
- assert.equal(charges.length, 3, 'one order, three charges: wrap the Command, not the pipeline');
1944
- });
1945
-
1946
- it('should not stop an in-flight Command whose thunk ignores the signal', async function () {
1947
- // Pinned deliberately: cancellation is cooperative. The sibling's write is already in flight
1948
- // inside the branch's first Command, and a thunk that ignores its signal cannot be interrupted,
1949
- // so the write still lands. What cancellation does buy is in the suite below: no *later*
1950
- // Command in that branch starts, and a thunk that honours the signal is cut off.
1951
- /** @type {string[]} */
1952
- const written = [];
1953
- const result = await runEffect(
1954
- Parallel(
1955
- [
1956
- Command(
1957
- function cmdValidate() {
1958
- return null;
1959
- },
1960
- () => Failure('validation_failed')
1961
- ),
1962
- Command(
1963
- function cmdSlowWrite() {
1964
- return new Promise((r) =>
1965
- setTimeout(() => {
1966
- written.push('wrote');
1967
- r('ok');
1968
- }, 20)
1969
- );
1970
- },
1971
- (/** @type {any} */ v) => Success(v)
1972
- )
1973
- ],
1974
- (/** @type {any} */ vals) => Success(vals)
1975
- )
1976
- );
1977
- assert.equal(result.type, 'Failure');
1978
- assert.equal(errorOf(result), 'validation_failed');
1979
- assert.deepEqual(written, ['wrote'], 'an uninterruptible in-flight write still performed');
1980
- });
1981
-
1982
- it('should carry the initial input on every Failure', async function () {
1983
- // Pinned deliberately: convenient for tests, and a PII surface for anything that logs the
1984
- // whole Failure rather than its `error`.
1985
- const input = { email: 'user@test.com', password: 'plaintext' };
1986
- const failing = (/** @type {any} */ i) => effectPipe(() => Failure('invalid'))(i);
1987
- const result = await runEffect(failing(input));
1988
- assert.equal(result.type, 'Failure');
1989
- assert.deepEqual(/** @type {any} */ (result).initialInput, input);
1990
- });
1991
- });
1992
-
1993
- describe('Malformed flows', function () {
1994
- beforeEach(() => configureEffect());
1995
-
1996
- /** @param {() => any} fn */
1997
- const errorFrom = async (fn) => {
1998
- try {
1999
- await fn();
2000
- return null;
2001
- } catch (e) {
2002
- return /** @type {any} */ (e);
2003
- }
2004
- };
2005
-
2006
- it('should name the step that returned something other than an Effect', async function () {
2007
- function validateRegistration(/** @type {any} */ input) {
2008
- return { ...input, ok: true }; // forgot Success()
2009
- }
2010
- const e = await errorFrom(() =>
2011
- runEffect(effectPipe(validateRegistration, (/** @type {any} */ i) => Success(i))({ id: 1 }))
2012
- );
2013
- assert.equal(e?.name, 'EffectTypeError');
2014
- assert.match(e.message, /Step 'validateRegistration' returned a plain object/);
2015
- assert.match(e.message, /Success\(value\)/, 'the message says what to do about it');
2016
- });
2017
-
2018
- it('should call a missing return what it usually is', async function () {
2019
- function ensureEmailAvailable() {}
2020
- const e = await errorFrom(() => runEffect(effectPipe(ensureEmailAvailable)({ id: 1 })));
2021
- assert.equal(e?.name, 'EffectTypeError');
2022
- assert.match(e.message, /returned undefined, which usually means a missing return/);
2023
- });
2024
-
2025
- it('should reject a Command continuation that returns a plain value', async function () {
2026
- // This used to resolve to 6, so `result.type === 'Success'` quietly took the else branch.
2027
- const e = await errorFrom(() =>
2028
- runEffect(
2029
- Command(
2030
- function cmdRead() {
2031
- return 5;
2032
- },
2033
- (/** @type {any} */ r) => r + 1
2034
- )
2035
- )
2036
- );
2037
- assert.equal(e?.name, 'EffectTypeError');
2038
- assert.match(e.message, /returned the number 6/);
2039
- });
2040
-
2041
- it('should recognise a flow that was never called with its input', async function () {
2042
- // runEffect(effectPipe(...)) rather than runEffect(effectPipe(...)(input)).
2043
- const e = await errorFrom(() =>
2044
- runEffect(/** @type {any} */ (effectPipe((/** @type {any} */ i) => Success(i))))
2045
- );
2046
- assert.equal(e?.name, 'EffectTypeError');
2047
- assert.match(e.message, /a function, which usually means a flow was passed without being called/);
2048
- });
2049
-
2050
- it('should not disguise a malformed flow as a domain Failure', async function () {
2051
- // The interpreter turns a thrown Command into a Failure; a bug in the flow must still throw.
2052
- const flow = (/** @type {any} */ input) =>
2053
- effectPipe(
2054
- () =>
2055
- Command(
2056
- function cmdRead() {
2057
- return { id: 1 };
2058
- },
2059
- (/** @type {any} */ r) => Success(r)
2060
- ),
2061
- function afterRead(/** @type {any} */ r) {
2062
- return r; // forgot Success()
2063
- }
2064
- )(input);
2065
- const e = await errorFrom(() => runEffect(flow({ id: 1 })));
2066
- assert.equal(e?.name, 'EffectTypeError', 'thrown rather than folded into a Failure');
2067
- assert.match(e.message, /Step 'afterRead'/);
2068
- });
2069
-
2070
- it('should still turn a thrown Command into a Failure', async function () {
2071
- const result = await runEffect(
2072
- Command(
2073
- function cmdBoom() {
2074
- throw new Error('boom');
2075
- },
2076
- (/** @type {any} */ v) => Success(v)
2077
- )
2078
- );
2079
- assert.equal(result.type, 'Failure', 'domain failures are unaffected by the new guard');
2080
- assert.equal(/** @type {Error} */ (errorOf(result)).message, 'boom');
2081
- });
2082
- });
2083
-
2084
- describe('Parallel cancellation', function () {
2085
- beforeEach(function () {
2086
- configureEffect();
2087
- });
2088
-
2089
- /** Resolves after `ms`, or rejects as soon as `signal` aborts. */
2090
- const cancellableSleep = (/** @type {number} */ ms, /** @type {AbortSignal} */ signal) =>
2091
- new Promise((resolve, reject) => {
2092
- const timer = setTimeout(resolve, ms);
2093
- signal?.addEventListener(
2094
- 'abort',
2095
- () => {
2096
- clearTimeout(timer);
2097
- reject(new Error('aborted'));
2098
- },
2099
- { once: true }
2100
- );
2101
- });
2102
-
2103
- it('should cancel a branch whose thunk honours the signal', async function () {
2104
- let completed = false;
2105
- const started = Date.now();
2106
- const result = await runEffect(
2107
- Parallel(
2108
- [
2109
- Command(function cmdFail() {
2110
- throw new Error('fail_fast');
2111
- }),
2112
- Command(async function cmdSlow(/** @type {AbortSignal} */ signal) {
2113
- await cancellableSleep(400, signal);
2114
- completed = true;
2115
- return 'wrote';
2116
- })
2117
- ],
2118
- (/** @type {any} */ v) => Success(v)
2119
- )
2120
- );
2121
- assert.equal(result.type, 'Failure');
2122
- assert.equal(/** @type {Error} */ (errorOf(result)).message, 'fail_fast');
2123
- assert.equal(completed, false, 'the cancelled branch never finished its work');
2124
- assert.ok(Date.now() - started < 200, 'the run did not wait out the cancelled branch');
2125
- });
2126
-
2127
- it('should not start a later Command in a cancelled branch', async function () {
2128
- /** @type {string[]} */
2129
- const ran = [];
2130
- const step = (/** @type {string} */ name) => () =>
2131
- Command(
2132
- async function cmdWrite() {
2133
- await cancellableSleep(30, undefined);
2134
- ran.push(name);
2135
- return name;
2136
- },
2137
- (/** @type {any} */ v) => Success(v)
2138
- );
2139
- const result = await runEffect(
2140
- Parallel(
2141
- [
2142
- Command(async function cmdFail() {
2143
- await cancellableSleep(5, undefined);
2144
- throw new Error('validation_failed');
2145
- }),
2146
- effectPipe(step('write1'), step('write2'), step('write3'))(null)
2147
- ],
2148
- (/** @type {any} */ v) => Success(v)
2149
- )
2150
- );
2151
- assert.equal(result.type, 'Failure');
2152
- assert.deepEqual(ran, ['write1'], 'the in-flight write landed; the two after it never started');
2153
- });
2154
-
2155
- it('should stop retrying in a cancelled branch', async function () {
2156
- let attempts = 0;
2157
- const result = await runEffect(
2158
- Parallel(
2159
- [
2160
- Command(async function cmdFail() {
2161
- await cancellableSleep(30, undefined);
2162
- throw new Error('primary_failed');
2163
- }),
2164
- Retry(
2165
- Command(function cmdFlaky() {
2166
- attempts++;
2167
- throw new Error('flaky');
2168
- }),
2169
- { attempts: 5, delay: 100 }
2170
- )
2171
- ],
2172
- (/** @type {any} */ v) => Success(v)
2173
- )
2174
- );
2175
- assert.equal(/** @type {Error} */ (errorOf(result)).message, 'primary_failed');
2176
- assert.ok(attempts < 5, `the cancelled branch stopped retrying (made ${attempts} attempts, not 6)`);
2177
- });
2178
-
2179
- it('should propagate cancellation into a nested Parallel', async function () {
2180
- let inner = false;
2181
- const result = await runEffect(
2182
- Parallel(
2183
- [
2184
- Command(function cmdOuterFail() {
2185
- throw new Error('outer_failed');
2186
- }),
2187
- Parallel(
2188
- [
2189
- Command(async function cmdInner(/** @type {AbortSignal} */ signal) {
2190
- await cancellableSleep(300, signal);
2191
- inner = true;
2192
- return 1;
2193
- })
2194
- ],
2195
- (/** @type {any} */ v) => Success(v)
2196
- )
2197
- ],
2198
- (/** @type {any} */ v) => Success(v)
2199
- )
2200
- );
2201
- assert.equal(/** @type {Error} */ (errorOf(result)).message, 'outer_failed');
2202
- assert.equal(inner, false, 'the inner branch was cancelled with the outer one');
2203
- });
2204
-
2205
- it('should return the triggering Failure rather than the cancellation', async function () {
2206
- const result = await runEffect(
2207
- Parallel(
2208
- [
2209
- Command(async function cmdSlowOk(/** @type {AbortSignal} */ signal) {
2210
- await cancellableSleep(200, signal);
2211
- return 'ok';
2212
- }),
2213
- Command(function cmdRealFailure() {
2214
- throw new Error('THE_REAL_ERROR');
2215
- })
2216
- ],
2217
- (/** @type {any} */ v) => Success(v)
2218
- )
2219
- );
2220
- assert.equal(
2221
- /** @type {Error} */ (errorOf(result)).message,
2222
- 'THE_REAL_ERROR',
2223
- 'a cancelled sibling must not displace the failure that caused the cancellation'
2224
- );
2225
- });
2226
-
2227
- it('should still pick the first Failure by array order when branches fail together', async function () {
2228
- const result = await runEffect(Parallel([Failure('a'), Failure('b')], (/** @type {any} */ v) => Success(v)));
2229
- assert.equal(errorOf(result), 'a');
2230
- });
2231
-
2232
- it('should pass no argument to a Command outside a Parallel', async function () {
2233
- /** @type {any[]} */
2234
- const args = [];
2235
- const result = await runEffect(
2236
- Command(function cmdRecordArgs() {
2237
- args.push([...arguments]);
2238
- return 'done';
2239
- })
2240
- );
2241
- assert.equal(result.type, 'Success');
2242
- assert.deepEqual(args, [[]], 'a thunk outside a Parallel is called with no arguments at all');
2243
- });
2244
- });
2245
-
2246
- describe('Declaration parity', function () {
2247
- it('should declare exactly the exports the runtime ships', function () {
2248
- // index.d.ts is hand-maintained beside index.js, so the two can drift silently: a
2249
- // declaration once vanished as edit collateral while every check stayed green. This pins
2250
- // existence in both directions; signatures remain tsd's job.
2251
- const program = ts.createProgram(['index.d.ts'], { noEmit: true });
2252
- const source = program.getSourceFile('index.d.ts');
2253
- assert.ok(source, 'index.d.ts must be part of the program');
2254
- const checker = program.getTypeChecker();
2255
- const moduleSymbol = checker.getSymbolAtLocation(/** @type {import('typescript').Node} */ (source));
2256
- assert.ok(moduleSymbol, 'index.d.ts must be a module');
2257
- const declared = checker
2258
- .getExportsOfModule(moduleSymbol)
2259
- .filter((s) => s.flags & ts.SymbolFlags.Value)
2260
- .map((s) => s.name)
2261
- .sort();
2262
- const shipped = Object.keys(lib).sort();
2263
- assert.deepEqual(declared, shipped);
2264
- });
2265
- });