pure-effect 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +9 -4
- package/.prettierrc +2 -1
- package/CLAUDE.md +139 -20
- package/README.md +272 -135
- package/examples/opentelemetry-example.js +164 -0
- package/examples/recording-example.js +104 -0
- package/index.d.ts +153 -6
- package/index.js +736 -15
- package/package.json +12 -6
- package/test/all.js +1584 -25
- package/test/types.test-d.ts +100 -2
- package/.dirac-symbol-index/data.db +0 -0
- package/opentelemetry-example.js +0 -91
package/test/all.js
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import { strict as assert } from 'assert';
|
|
4
|
-
import {
|
|
5
|
-
|
|
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 { enableTelemetry, telemetryHooks } from '../examples/opentelemetry-example.js';
|
|
20
|
+
import { enableRecording, recordingHooks } from '../examples/recording-example.js';
|
|
6
21
|
|
|
7
22
|
/** @import { CommandInterceptor } from "../index.js" */
|
|
8
23
|
|
|
@@ -31,38 +46,34 @@ function validateRegistration(/** @type {User} */ input) {
|
|
|
31
46
|
return Success(input);
|
|
32
47
|
}
|
|
33
48
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
49
|
+
// The lookup is a guard, so it passes the input along rather than the user it found.
|
|
50
|
+
// Every step then accepts and returns the piped value, which keeps the chain checkable.
|
|
51
|
+
function ensureEmailIsAvailable(/** @type {User} */ input) {
|
|
52
|
+
const cmdFindUser = () => db.findUserByEmail(input.email);
|
|
53
|
+
const next = (/** @type {User | null} */ foundUser) =>
|
|
54
|
+
foundUser ? Failure('Email already in use.') : Success(input);
|
|
37
55
|
return Command(cmdFindUser, next);
|
|
38
56
|
}
|
|
39
57
|
|
|
40
|
-
function ensureEmailIsAvailable(/** @type {User} */ foundUser) {
|
|
41
|
-
return foundUser ? Failure('Email already in use.') : Success(true);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
58
|
function saveUser(/** @type {User} */ input) {
|
|
45
59
|
const { email, password } = input;
|
|
46
|
-
const
|
|
47
|
-
|
|
60
|
+
const userToSave = { email, password: `hashed_${password}` };
|
|
61
|
+
// No continuation: the saved user passes straight through.
|
|
48
62
|
const cmdSaveUser = () => db.saveUser(userToSave);
|
|
49
|
-
|
|
50
|
-
return Command(cmdSaveUser, next);
|
|
63
|
+
return Command(cmdSaveUser);
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
const registerUserFlow = (/** @type {User} */ input) =>
|
|
54
|
-
effectPipe(
|
|
55
|
-
validateRegistration,
|
|
56
|
-
() => findUserByEmail(input.email),
|
|
57
|
-
ensureEmailIsAvailable,
|
|
58
|
-
() => saveUser(input)
|
|
59
|
-
)(input);
|
|
67
|
+
effectPipe(validateRegistration, ensureEmailIsAvailable, saveUser)(input);
|
|
60
68
|
|
|
61
69
|
async function registerUser(/** @type {User} */ input) {
|
|
62
70
|
return await runEffect(registerUserFlow(input), { flowName: 'registerUser' });
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
describe('
|
|
73
|
+
describe('Core', function () {
|
|
74
|
+
// Global hooks outlive a suite, so this guards against inheriting another suite's wiring.
|
|
75
|
+
beforeEach(() => configureEffect());
|
|
76
|
+
|
|
66
77
|
it('should return Failure when e-mail is invalid', async function () {
|
|
67
78
|
const badInput = { email: 'bad-email', password: '123' };
|
|
68
79
|
const result = registerUserFlow(badInput);
|
|
@@ -153,7 +164,6 @@ describe('Pure Effect', function () {
|
|
|
153
164
|
Command(
|
|
154
165
|
function alwaysFails() {
|
|
155
166
|
throw new Error('boom');
|
|
156
|
-
return /** @type {any} */ (null);
|
|
157
167
|
},
|
|
158
168
|
(/** @type {any} */ r) => Success(r)
|
|
159
169
|
),
|
|
@@ -191,7 +201,7 @@ describe('Pure Effect', function () {
|
|
|
191
201
|
|
|
192
202
|
it('should merge per-use Retry options with call-level defaults', async function () {
|
|
193
203
|
// Call-level: attempts 1 (would exhaust on 2nd try)
|
|
194
|
-
// Per-use: attempts 3 (overrides call-level
|
|
204
|
+
// Per-use: attempts 3 (overrides call-level, so it should succeed on the 3rd try)
|
|
195
205
|
let calls = 0;
|
|
196
206
|
const effect = Retry(
|
|
197
207
|
Command(
|
|
@@ -227,16 +237,1565 @@ describe('Pure Effect', function () {
|
|
|
227
237
|
assert.equal(result.value, 'HELLO!');
|
|
228
238
|
});
|
|
229
239
|
|
|
240
|
+
it('should return a Parallel data structure', () => {
|
|
241
|
+
const e1 = Success(1);
|
|
242
|
+
const e2 = Success(2);
|
|
243
|
+
const next = (/** @type {any[]} */ values) => Success(values);
|
|
244
|
+
const result = Parallel([e1, e2], next);
|
|
245
|
+
assert.equal(result.type, 'Parallel');
|
|
246
|
+
assert.deepEqual(result.effects, [e1, e2]);
|
|
247
|
+
assert.equal(result.next, next);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should run effects concurrently and pass results to next', async () => {
|
|
251
|
+
const e1 = Command(
|
|
252
|
+
async () => 'a',
|
|
253
|
+
(v) => Success(v)
|
|
254
|
+
);
|
|
255
|
+
const e2 = Command(
|
|
256
|
+
async () => 'b',
|
|
257
|
+
(v) => Success(v)
|
|
258
|
+
);
|
|
259
|
+
const flow = Parallel([e1, e2], ([a, b]) => Success({ a, b }));
|
|
260
|
+
const result = await runEffect(flow);
|
|
261
|
+
assert.equal(result.type, 'Success');
|
|
262
|
+
assert.deepEqual(result.value, { a: 'a', b: 'b' });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should return Failure if any parallel effect fails', async () => {
|
|
266
|
+
const e1 = Success('ok');
|
|
267
|
+
const e2 = Failure('oops');
|
|
268
|
+
const flow = Parallel([e1, e2], ([a, b]) => Success({ a, b }));
|
|
269
|
+
const result = await runEffect(flow);
|
|
270
|
+
assert.equal(result.type, 'Failure');
|
|
271
|
+
assert.equal(result.error, 'oops');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should work inside effectPipe', async () => {
|
|
275
|
+
const flow = effectPipe((input) =>
|
|
276
|
+
Parallel(
|
|
277
|
+
[
|
|
278
|
+
Command(
|
|
279
|
+
async () => input.a,
|
|
280
|
+
(v) => Success(v)
|
|
281
|
+
),
|
|
282
|
+
Command(
|
|
283
|
+
async () => input.b,
|
|
284
|
+
(v) => Success(v)
|
|
285
|
+
)
|
|
286
|
+
],
|
|
287
|
+
([a, b]) => Success({ a, b })
|
|
288
|
+
)
|
|
289
|
+
);
|
|
290
|
+
const result = await runEffect(flow({ a: 1, b: 2 }));
|
|
291
|
+
assert.equal(result.type, 'Success');
|
|
292
|
+
assert.deepEqual(result.value, { a: 1, b: 2 });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should pass context to parallel branches via Ask', async () => {
|
|
296
|
+
const flow = Parallel(
|
|
297
|
+
[Ask((/** @type {any} */ ctx) => Success(ctx.x)), Ask((/** @type {any} */ ctx) => Success(ctx.y))],
|
|
298
|
+
([x, y]) => Success({ x, y })
|
|
299
|
+
);
|
|
300
|
+
const result = await runEffect(flow, { x: 10, y: 20 });
|
|
301
|
+
assert.equal(result.type, 'Success');
|
|
302
|
+
assert.deepEqual(result.value, { x: 10, y: 20 });
|
|
303
|
+
});
|
|
304
|
+
|
|
230
305
|
it('should return Success after runEffect with telemetry disabled', async function () {
|
|
231
306
|
const input = { email: 'test-no-telemetry@test.com', password: 'password123' };
|
|
232
307
|
const result = await registerUser(input);
|
|
233
308
|
assert.equal(result.type, 'Success');
|
|
234
309
|
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
/** Reads `.value` off a runEffect result once its type has been asserted. */
|
|
313
|
+
const valueOf = (/** @type {any} */ result) => result.value;
|
|
314
|
+
|
|
315
|
+
/** Reads `.error` off a runEffect result once its type has been asserted. */
|
|
316
|
+
const errorOf = (/** @type {any} */ result) => result.error;
|
|
317
|
+
|
|
318
|
+
describe('Recording and replay', function () {
|
|
319
|
+
beforeEach(() => configureEffect({}));
|
|
320
|
+
|
|
321
|
+
it('should record and replay the registration flow end to end', async function () {
|
|
322
|
+
const input = { email: 'replay@test.com', password: 'password123' };
|
|
323
|
+
const { result, trace } = await recordEffect(registerUserFlow, input);
|
|
324
|
+
assert.equal(result.type, 'Success');
|
|
325
|
+
assert.deepEqual(
|
|
326
|
+
trace.trace.map((e) => e.command),
|
|
327
|
+
['cmdFindUser', 'cmdSaveUser'],
|
|
328
|
+
'every Command in the flow is recorded, including the guard'
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const replayed = await replayEffect(registerUserFlow(/** @type {User} */ (trace.initialInput)), trace);
|
|
332
|
+
assert.equal(replayed.type, 'Success');
|
|
333
|
+
assert.deepEqual(valueOf(replayed), valueOf(result));
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
/** A flow whose Commands count their own invocations, so tests can assert zero I/O. */
|
|
337
|
+
const makeFlow = () => {
|
|
338
|
+
const calls = { read: 0, write: 0 };
|
|
339
|
+
const flow = (/** @type {any} */ input) =>
|
|
340
|
+
effectPipe(
|
|
341
|
+
(/** @type {any} */ i) => (i.id ? Success(i) : Failure('no_id')),
|
|
342
|
+
(/** @type {any} */ i) =>
|
|
343
|
+
Command(
|
|
344
|
+
function cmdRead() {
|
|
345
|
+
calls.read++;
|
|
346
|
+
return { row: i.id };
|
|
347
|
+
},
|
|
348
|
+
(/** @type {any} */ row) => Success({ ...i, ...row })
|
|
349
|
+
),
|
|
350
|
+
(/** @type {any} */ acc) =>
|
|
351
|
+
Command(
|
|
352
|
+
function cmdWrite() {
|
|
353
|
+
calls.write++;
|
|
354
|
+
return { written: acc.row };
|
|
355
|
+
},
|
|
356
|
+
(/** @type {any} */ w) => Success(w)
|
|
357
|
+
)
|
|
358
|
+
)(input);
|
|
359
|
+
return { flow, calls };
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
it('should replay a recorded flow without performing any I/O', async function () {
|
|
363
|
+
const a = makeFlow();
|
|
364
|
+
const { result, trace } = await recordEffect(a.flow, { id: 'x1' }, { version: 'abc123' });
|
|
365
|
+
assert.equal(result.type, 'Success');
|
|
366
|
+
assert.deepEqual(a.calls, { read: 1, write: 1 });
|
|
367
|
+
assert.equal(trace.version, 'abc123');
|
|
368
|
+
|
|
369
|
+
const b = makeFlow();
|
|
370
|
+
const replayed = await replayEffect(b.flow(trace.initialInput), trace);
|
|
371
|
+
assert.equal(replayed.type, 'Success');
|
|
372
|
+
assert.deepEqual(valueOf(replayed), valueOf(result));
|
|
373
|
+
assert.deepEqual(b.calls, { read: 0, write: 0 }, 'replay must not touch the world');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('should consume duplicate Command names in recorded order', async function () {
|
|
377
|
+
let n = 0;
|
|
378
|
+
const readTwice = (/** @type {any} */ input) =>
|
|
379
|
+
effectPipe(
|
|
380
|
+
() =>
|
|
381
|
+
Command(
|
|
382
|
+
function cmdRead() {
|
|
383
|
+
return ++n;
|
|
384
|
+
},
|
|
385
|
+
(/** @type {any} */ v) => Success(v)
|
|
386
|
+
),
|
|
387
|
+
(/** @type {any} */ first) =>
|
|
388
|
+
Command(
|
|
389
|
+
function cmdRead() {
|
|
390
|
+
return ++n;
|
|
391
|
+
},
|
|
392
|
+
(/** @type {any} */ second) => Success([first, second])
|
|
393
|
+
)
|
|
394
|
+
)(input);
|
|
395
|
+
|
|
396
|
+
const { trace } = await recordEffect(readTwice, null);
|
|
397
|
+
assert.deepEqual(
|
|
398
|
+
trace.trace.map((e) => e.result),
|
|
399
|
+
[1, 2]
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
n = 100;
|
|
403
|
+
const replayed = await replayEffect(readTwice(null), trace);
|
|
404
|
+
assert.deepEqual(valueOf(replayed), [1, 2], 'each call got its own recorded value');
|
|
405
|
+
assert.equal(n, 100, 'the real command never ran');
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('should restore the recorded context so Ask replays faithfully', async function () {
|
|
409
|
+
const flow = (/** @type {any} */ id) =>
|
|
410
|
+
Ask((/** @type {any} */ ctx) =>
|
|
411
|
+
Command(
|
|
412
|
+
function cmdFindProduct() {
|
|
413
|
+
return { id, tenant: ctx.tenant };
|
|
414
|
+
},
|
|
415
|
+
(/** @type {any} */ p) => Success(p)
|
|
416
|
+
)
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
const { trace } = await recordEffect(flow, 'sku-1', { context: { tenant: 'acme', flowName: 'lookup' } });
|
|
420
|
+
assert.equal(/** @type {any} */ (trace.context).tenant, 'acme');
|
|
421
|
+
|
|
422
|
+
const replayed = await replayEffect(flow('sku-1'), trace, { context: trace.context });
|
|
423
|
+
assert.deepEqual(valueOf(replayed), { id: 'sku-1', tenant: 'acme' });
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('should replay recorded Retry attempts without waiting out the backoff', async function () {
|
|
427
|
+
this.timeout(2000);
|
|
428
|
+
let attempt = 0;
|
|
429
|
+
const flow = () =>
|
|
430
|
+
Retry(
|
|
431
|
+
Command(
|
|
432
|
+
function cmdFetch() {
|
|
433
|
+
if (++attempt < 3) throw new Error(`transient ${attempt}`);
|
|
434
|
+
return { tempC: 21 };
|
|
435
|
+
},
|
|
436
|
+
(/** @type {any} */ d) => Success(d)
|
|
437
|
+
),
|
|
438
|
+
{ attempts: 3, delay: 200, backoff: 2 }
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
const { result, trace } = await recordEffect(flow, null);
|
|
442
|
+
assert.equal(result.type, 'Success');
|
|
443
|
+
assert.equal(trace.trace.length, 3, 'every attempt is a recorded step');
|
|
444
|
+
|
|
445
|
+
attempt = 0;
|
|
446
|
+
const start = Date.now();
|
|
447
|
+
const replayed = await replayEffect(flow(), trace);
|
|
448
|
+
const elapsed = Date.now() - start;
|
|
449
|
+
assert.deepEqual(valueOf(replayed), { tempC: 21 });
|
|
450
|
+
assert.equal(attempt, 0, 'no attempt was re-executed');
|
|
451
|
+
assert.ok(elapsed < 100, `replay skipped 200 ms + 400 ms of backoff (took ${elapsed} ms)`);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('should replay retry exhaustion as the same structured Failure', async function () {
|
|
455
|
+
const flow = () =>
|
|
456
|
+
Retry(
|
|
457
|
+
Command(
|
|
458
|
+
function cmdFlaky() {
|
|
459
|
+
throw new Error('down');
|
|
460
|
+
},
|
|
461
|
+
(/** @type {any} */ v) => Success(v)
|
|
462
|
+
),
|
|
463
|
+
{ attempts: 2, delay: 0 }
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const { result, trace } = await recordEffect(flow, null);
|
|
467
|
+
assert.equal(result.type, 'Failure');
|
|
468
|
+
|
|
469
|
+
const replayed = await replayEffect(flow(), trace);
|
|
470
|
+
assert.equal(replayed.type, 'Failure');
|
|
471
|
+
const error = /** @type {import('../index.js').RetryExhaustedError<Error>} */ (errorOf(replayed));
|
|
472
|
+
assert.equal(error.retryExhausted, true);
|
|
473
|
+
assert.equal(error.attempts, 2);
|
|
474
|
+
assert.equal(error.lastError.message, 'down');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('should require name matching for Parallel, whose branches finish out of order', async function () {
|
|
478
|
+
const flow = (/** @type {any} */ id) =>
|
|
479
|
+
Parallel(
|
|
480
|
+
[
|
|
481
|
+
Command(
|
|
482
|
+
function cmdSlow() {
|
|
483
|
+
return new Promise((r) => setTimeout(() => r({ id }), 20));
|
|
484
|
+
},
|
|
485
|
+
(/** @type {any} */ v) => Success(v)
|
|
486
|
+
),
|
|
487
|
+
Command(
|
|
488
|
+
function cmdFast() {
|
|
489
|
+
return Promise.resolve('fast');
|
|
490
|
+
},
|
|
491
|
+
(/** @type {any} */ v) => Success(v)
|
|
492
|
+
)
|
|
493
|
+
],
|
|
494
|
+
([slow, fast]) => Success({ slow, fast })
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
const { result, trace } = await recordEffect(flow, 'u1');
|
|
498
|
+
assert.deepEqual(
|
|
499
|
+
trace.trace.map((e) => e.command),
|
|
500
|
+
['cmdFast', 'cmdSlow'],
|
|
501
|
+
'recording order follows completion, not the effects array'
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
const strictReplay = await replayEffect(flow('u1'), trace);
|
|
505
|
+
assert.equal(strictReplay.type, 'Failure', 'positional matching reports a false paradox');
|
|
506
|
+
assert.equal(/** @type {Error} */ (errorOf(strictReplay)).name, 'TimeParadox');
|
|
507
|
+
|
|
508
|
+
const replayed = await replayEffect(flow('u1'), trace, { strict: false });
|
|
509
|
+
assert.equal(replayed.type, 'Success');
|
|
510
|
+
assert.deepEqual(valueOf(replayed), valueOf(result));
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('should accept a trace directly, without building a Resolver', async function () {
|
|
514
|
+
const a = makeFlow();
|
|
515
|
+
const { result, trace } = await recordEffect(a.flow, { id: 'direct' });
|
|
516
|
+
|
|
517
|
+
const b = makeFlow();
|
|
518
|
+
const replayed = await replayEffect(b.flow(trace.initialInput), trace);
|
|
519
|
+
assert.equal(replayed.type, 'Success');
|
|
520
|
+
assert.deepEqual(valueOf(replayed), valueOf(result));
|
|
521
|
+
assert.deepEqual(b.calls, { read: 0, write: 0 });
|
|
522
|
+
|
|
523
|
+
// A bare entries array is a valid trace too, without the surrounding TraceLog.
|
|
524
|
+
const c = makeFlow();
|
|
525
|
+
const fromEntries = await replayEffect(c.flow(trace.initialInput), trace.trace);
|
|
526
|
+
assert.equal(fromEntries.type, 'Success');
|
|
527
|
+
assert.deepEqual(valueOf(fromEntries), valueOf(result));
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('should reject a malformed trace as a ReplayError, not a TypeError', async function () {
|
|
531
|
+
const { flow } = makeFlow();
|
|
532
|
+
for (const bad of [undefined, null, 42, {}, { trace: 'nope' }]) {
|
|
533
|
+
await assert.rejects(
|
|
534
|
+
() => replayEffect(flow({ id: 'x' }), /** @type {any} */ (bad)),
|
|
535
|
+
(/** @type {any} */ e) => e.name === 'ReplayError' && /no `trace` array/.test(e.message),
|
|
536
|
+
`expected a ReplayError for ${JSON.stringify(bad)}`
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('should detect a trace recorded from a different flow', async function () {
|
|
542
|
+
const flowA = () =>
|
|
543
|
+
Command(
|
|
544
|
+
function cmdAlpha() {
|
|
545
|
+
return 1;
|
|
546
|
+
},
|
|
547
|
+
(/** @type {any} */ v) => Success(v)
|
|
548
|
+
);
|
|
549
|
+
const flowB = () =>
|
|
550
|
+
Command(
|
|
551
|
+
function cmdBeta() {
|
|
552
|
+
return 2;
|
|
553
|
+
},
|
|
554
|
+
(/** @type {any} */ v) => Success(v)
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
const { trace } = await recordEffect(flowA, null);
|
|
558
|
+
const replayed = await replayEffect(flowB(), trace);
|
|
559
|
+
assert.equal(replayed.type, 'Failure');
|
|
560
|
+
assert.equal(/** @type {Error} */ (errorOf(replayed)).name, 'TimeParadox');
|
|
561
|
+
assert.match(
|
|
562
|
+
/** @type {Error} */ (errorOf(replayed)).message,
|
|
563
|
+
/asked for 'cmdBeta', trace recorded 'cmdAlpha'/
|
|
564
|
+
);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('should report an exhausted trace rather than silently succeeding', async function () {
|
|
568
|
+
const { flow } = makeFlow();
|
|
569
|
+
const replayed = await replayEffect(flow({ id: 'x' }), { trace: [] });
|
|
570
|
+
assert.equal(replayed.type, 'Failure');
|
|
571
|
+
assert.match(/** @type {Error} */ (errorOf(replayed)).message, /Trace exhausted/);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('should refuse to run an unrecorded Command by default', async function () {
|
|
575
|
+
const { flow, calls } = makeFlow();
|
|
576
|
+
const replayed = await replayEffect(flow({ id: 'x' }), () => undefined);
|
|
577
|
+
assert.equal(replayed.type, 'Failure');
|
|
578
|
+
assert.match(/** @type {Error} */ (errorOf(replayed)).message, /refusing to run the real Command/);
|
|
579
|
+
assert.deepEqual(calls, { read: 0, write: 0 });
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('should allow a recorded prefix and a live tail when onMissing is execute', async function () {
|
|
583
|
+
const { flow, calls } = makeFlow();
|
|
584
|
+
const recorded = [{ command: 'cmdRead', result: { row: 'FROM_TRACE' } }];
|
|
585
|
+
/** @type {import('../index.js').Resolver} */
|
|
586
|
+
const resolve = (step) => (step.index === 0 ? { result: recorded[0].result } : undefined);
|
|
587
|
+
|
|
588
|
+
const replayed = await replayEffect(flow({ id: 'x' }), resolve, { onMissing: 'execute' });
|
|
589
|
+
assert.equal(replayed.type, 'Success');
|
|
590
|
+
assert.deepEqual(valueOf(replayed), { written: 'FROM_TRACE' });
|
|
591
|
+
assert.deepEqual(calls, { read: 0, write: 1 }, 'only the unrecorded step performed I/O');
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('should not let a throwing redact fail the run or corrupt the trace', async function () {
|
|
595
|
+
const rec = recorder({
|
|
596
|
+
redact: () => {
|
|
597
|
+
throw new Error('redact blew up');
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
const result = await runEffect(
|
|
601
|
+
Command(
|
|
602
|
+
function cmdWork() {
|
|
603
|
+
return 'ok';
|
|
604
|
+
},
|
|
605
|
+
(/** @type {any} */ v) => Success(v)
|
|
606
|
+
),
|
|
607
|
+
{},
|
|
608
|
+
{ onStep: rec.onStep }
|
|
609
|
+
);
|
|
610
|
+
assert.equal(result.type, 'Success', "a redaction bug is not the flow's problem");
|
|
611
|
+
assert.equal(valueOf(result), 'ok');
|
|
612
|
+
assert.deepEqual(
|
|
613
|
+
rec.entries.map(({ command, result }) => ({ command, result })),
|
|
614
|
+
[{ command: 'cmdWork', result: '[redaction failed]' }]
|
|
615
|
+
);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('should keep redacted values out of the trace', async function () {
|
|
619
|
+
const flow = () =>
|
|
620
|
+
Command(
|
|
621
|
+
function cmdLoadUser() {
|
|
622
|
+
return { id: 7, email: 'ada@example.com', card: '4111111111111111' };
|
|
623
|
+
},
|
|
624
|
+
(/** @type {any} */ u) => Success(u)
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
const { trace } = await recordEffect(flow, null, {
|
|
628
|
+
redact: (result, name) =>
|
|
629
|
+
name === 'cmdLoadUser'
|
|
630
|
+
? { .../** @type {any} */ (result), email: '[redacted]', card: '[redacted]' }
|
|
631
|
+
: result
|
|
632
|
+
});
|
|
633
|
+
assert.deepEqual(trace.trace[0].result, { id: 7, email: '[redacted]', card: '[redacted]' });
|
|
634
|
+
assert.ok(!JSON.stringify(trace).includes('4111111111111111'));
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('should offer redact every value a trace holds', async function () {
|
|
638
|
+
/** @type {any[]} */
|
|
639
|
+
const seen = [];
|
|
640
|
+
const flow = (/** @type {any} */ input) =>
|
|
641
|
+
effectPipe(
|
|
642
|
+
(/** @type {any} */ i) =>
|
|
643
|
+
Command(
|
|
644
|
+
function cmdRead() {
|
|
645
|
+
return { id: 1, email: i.email };
|
|
646
|
+
},
|
|
647
|
+
(/** @type {any} */ r) => Success({ ...i, ...r })
|
|
648
|
+
),
|
|
649
|
+
(/** @type {any} */ i) =>
|
|
650
|
+
Command(
|
|
651
|
+
function cmdSave() {
|
|
652
|
+
throw Object.assign(new Error('duplicate'), { attempted: { password: i.password } });
|
|
653
|
+
},
|
|
654
|
+
(/** @type {any} */ r) => Success(r)
|
|
655
|
+
)
|
|
656
|
+
)(input);
|
|
657
|
+
|
|
658
|
+
const { trace } = await recordEffect(
|
|
659
|
+
flow,
|
|
660
|
+
{ email: 'user@test.com', password: 'hunter2' },
|
|
661
|
+
{
|
|
662
|
+
context: { flowName: 'register', authToken: 'bearer-abc123' },
|
|
663
|
+
redact: (/** @type {any} */ value, /** @type {string} */ name, /** @type {string} */ kind) => {
|
|
664
|
+
seen.push([kind, name]);
|
|
665
|
+
if (kind === 'initialInput') return { ...value, password: '[redacted]' };
|
|
666
|
+
if (kind === 'context') return { ...value, authToken: '[redacted]' };
|
|
667
|
+
if (kind === 'error') return { ...value, attempted: '[redacted]' };
|
|
668
|
+
return value;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
assert.deepEqual(
|
|
674
|
+
seen,
|
|
675
|
+
[
|
|
676
|
+
['result', 'cmdRead'],
|
|
677
|
+
['error', 'cmdSave'],
|
|
678
|
+
['initialInput', 'initialInput'],
|
|
679
|
+
['context', 'context']
|
|
680
|
+
],
|
|
681
|
+
'results, errors, and the trace-level fields all pass through redact'
|
|
682
|
+
);
|
|
683
|
+
const json = JSON.stringify(trace);
|
|
684
|
+
assert.ok(!json.includes('hunter2'), 'no password anywhere in the trace');
|
|
685
|
+
assert.ok(!json.includes('bearer-abc123'), 'no token anywhere in the trace');
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it('should leave an absent initialInput or context undefined rather than redacting nothing into an object', function () {
|
|
689
|
+
const rec = recorder({ redact: (/** @type {any} */ value) => ({ ...value, added: true }) });
|
|
690
|
+
const trace = rec.toTrace({ flowName: 'bare' });
|
|
691
|
+
assert.equal(trace.initialInput, undefined);
|
|
692
|
+
assert.equal(trace.context, undefined);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it('should still replay after initialInput is redacted, when no step branches on the redacted field', async function () {
|
|
696
|
+
// Replay feeds recorded results rather than running Commands, so stripping a field only matters
|
|
697
|
+
// if the flow's control flow reads it.
|
|
698
|
+
const flow = (/** @type {any} */ input) =>
|
|
699
|
+
effectPipe(
|
|
700
|
+
(/** @type {any} */ i) => (i.email ? Success(i) : Failure('no_email')),
|
|
701
|
+
(/** @type {any} */ i) =>
|
|
702
|
+
Command(
|
|
703
|
+
function cmdHash() {
|
|
704
|
+
return { hash: `hashed_${i.password}` };
|
|
705
|
+
},
|
|
706
|
+
(/** @type {any} */ r) => Success(r)
|
|
707
|
+
)
|
|
708
|
+
)(input);
|
|
709
|
+
|
|
710
|
+
const { result, trace } = await recordEffect(
|
|
711
|
+
flow,
|
|
712
|
+
{ email: 'a@b.c', password: 'hunter2' },
|
|
713
|
+
{
|
|
714
|
+
redact: (/** @type {any} */ value, /** @type {string} */ name, /** @type {string} */ kind) =>
|
|
715
|
+
kind === 'initialInput' ? { ...value, password: '[redacted]' } : value
|
|
716
|
+
}
|
|
717
|
+
);
|
|
718
|
+
assert.equal(result.type, 'Success');
|
|
719
|
+
assert.equal(/** @type {any} */ (trace.initialInput).password, '[redacted]');
|
|
720
|
+
|
|
721
|
+
const replayed = await replayEffect(flow(trace.initialInput), trace);
|
|
722
|
+
assert.equal(replayed.type, 'Success', 'the redacted input still rebuilds a matching flow');
|
|
723
|
+
assert.deepEqual(valueOf(replayed), valueOf(result), 'and the recorded hash comes back intact');
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('should cap a runaway trace and report how many steps were dropped', async function () {
|
|
727
|
+
const { flow } = makeFlow();
|
|
728
|
+
const rec = recorder({ maxEntries: 1 });
|
|
729
|
+
await runEffect(flow({ id: 'x' }), {}, { onStep: rec.onStep });
|
|
730
|
+
const trace = rec.toTrace({ initialInput: { id: 'x' } });
|
|
731
|
+
assert.equal(trace.trace.length, 1);
|
|
732
|
+
assert.equal(trace.dropped, 1);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it('should give each recorder its own independent trace', async function () {
|
|
736
|
+
const { flow } = makeFlow();
|
|
737
|
+
const first = recorder();
|
|
738
|
+
const second = recorder();
|
|
739
|
+
await Promise.all([
|
|
740
|
+
runEffect(flow({ id: 'a' }), {}, { onStep: first.onStep }),
|
|
741
|
+
runEffect(flow({ id: 'b' }), {}, { onStep: second.onStep })
|
|
742
|
+
]);
|
|
743
|
+
assert.equal(first.entries.length, 2);
|
|
744
|
+
assert.equal(second.entries.length, 2);
|
|
745
|
+
assert.equal(/** @type {any} */ (first.entries[0].result).row, 'a');
|
|
746
|
+
assert.equal(/** @type {any} */ (second.entries[0].result).row, 'b');
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it('should narrate a replay and flag a flow that diverged', async function () {
|
|
750
|
+
/** @type {string[]} */
|
|
751
|
+
const lines = [];
|
|
752
|
+
const traceLog = {
|
|
753
|
+
flowName: 'checkout',
|
|
754
|
+
version: 'deadbee',
|
|
755
|
+
initialInput: { id: 1 },
|
|
756
|
+
trace: [
|
|
757
|
+
{ command: 'cmdRead', result: { row: 1 } },
|
|
758
|
+
{ command: 'cmdWrite', result: { written: 1 } }
|
|
759
|
+
]
|
|
760
|
+
};
|
|
761
|
+
// This flow stops after one Command, so the second recorded step is unreachable.
|
|
762
|
+
const shortFlow = (/** @type {any} */ input) =>
|
|
763
|
+
effectPipe(() =>
|
|
764
|
+
Command(
|
|
765
|
+
function cmdRead() {
|
|
766
|
+
return { row: 1 };
|
|
767
|
+
},
|
|
768
|
+
() => Success('stopped early')
|
|
769
|
+
)
|
|
770
|
+
)(input);
|
|
771
|
+
|
|
772
|
+
const result = await timeTravel(shortFlow, traceLog, { log: (l) => lines.push(l), version: 'cafe123' });
|
|
773
|
+
assert.equal(result.type, 'Success');
|
|
774
|
+
const out = lines.join('\n');
|
|
775
|
+
assert.match(out, /trace was recorded at deadbee, replaying against cafe123/);
|
|
776
|
+
assert.match(out, /Replaying 'checkout' \(2 recorded steps\)/);
|
|
777
|
+
assert.match(out, /Step 1: cmdRead returned/);
|
|
778
|
+
assert.doesNotMatch(out, / in \d/, 'a hand-written trace has no timings to narrate');
|
|
779
|
+
assert.match(out, /1 recorded steps were never reached/);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it('should narrate a Parallel flow when strict is off', async function () {
|
|
783
|
+
const flow = (/** @type {any} */ id) =>
|
|
784
|
+
Parallel(
|
|
785
|
+
[
|
|
786
|
+
Command(
|
|
787
|
+
function cmdSlow() {
|
|
788
|
+
return new Promise((r) => setTimeout(() => r({ id }), 20));
|
|
789
|
+
},
|
|
790
|
+
(/** @type {any} */ v) => Success(v)
|
|
791
|
+
),
|
|
792
|
+
Command(
|
|
793
|
+
function cmdFast() {
|
|
794
|
+
return Promise.resolve('fast');
|
|
795
|
+
},
|
|
796
|
+
(/** @type {any} */ v) => Success(v)
|
|
797
|
+
)
|
|
798
|
+
],
|
|
799
|
+
([slow, fast]) => Success({ slow, fast })
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
const { trace } = await recordEffect(flow, 'u1');
|
|
803
|
+
/** @type {string[]} */
|
|
804
|
+
const lines = [];
|
|
805
|
+
const result = await timeTravel(flow, trace, { strict: false, log: (l) => lines.push(l) });
|
|
806
|
+
|
|
807
|
+
assert.equal(result.type, 'Success', 'strict must reach the trace resolution');
|
|
808
|
+
const out = lines.join('\n');
|
|
809
|
+
assert.match(out, /cmdSlow returned/);
|
|
810
|
+
assert.match(out, /cmdFast returned/);
|
|
811
|
+
assert.doesNotMatch(out, /never reached/, 'both recorded steps were consumed');
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it('should not execute any Command across every primitive during replay', async function () {
|
|
815
|
+
let invocations = 0;
|
|
816
|
+
const trap = (/** @type {string} */ name, /** @type {any} */ value) => {
|
|
817
|
+
const thunk = () => {
|
|
818
|
+
invocations++;
|
|
819
|
+
return value;
|
|
820
|
+
};
|
|
821
|
+
Object.defineProperty(thunk, 'name', { value: name });
|
|
822
|
+
return Command(thunk, (/** @type {any} */ v) => Success(v));
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
const flow = (/** @type {any} */ input) =>
|
|
826
|
+
effectPipe(
|
|
827
|
+
(/** @type {any} */ i) => Success(i),
|
|
828
|
+
(/** @type {any} */ i) =>
|
|
829
|
+
Ask((/** @type {any} */ ctx) =>
|
|
830
|
+
Parallel(
|
|
831
|
+
[trap('cmdLoadUser', { tenant: ctx.tenant }), Retry(trap('cmdLoadQuota', { quota: 10 }))],
|
|
832
|
+
([user, quota]) => Success({ ...i, user, quota })
|
|
833
|
+
)
|
|
834
|
+
),
|
|
835
|
+
(/** @type {any} */ acc) => trap('cmdWriteAudit', { audited: acc.user.tenant })
|
|
836
|
+
)(input);
|
|
837
|
+
|
|
838
|
+
const { result, trace } = await recordEffect(flow, { id: 1 }, { context: { tenant: 'acme' } });
|
|
839
|
+
assert.equal(result.type, 'Success');
|
|
840
|
+
assert.equal(invocations, 3);
|
|
841
|
+
|
|
842
|
+
invocations = 0;
|
|
843
|
+
const replayed = await replayEffect(flow({ id: 1 }), trace, {
|
|
844
|
+
strict: false,
|
|
845
|
+
context: trace.context
|
|
846
|
+
});
|
|
847
|
+
assert.equal(replayed.type, 'Success');
|
|
848
|
+
assert.deepEqual(valueOf(replayed), valueOf(result));
|
|
849
|
+
assert.equal(invocations, 0, 'no Command thunk was applied');
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
describe('configureEffect merging', function () {
|
|
854
|
+
beforeEach(() => configureEffect({}));
|
|
855
|
+
|
|
856
|
+
/** A Command whose thunk is named, so hooks can be asserted by name. */
|
|
857
|
+
const work = (/** @type {any} */ value = 'ok') =>
|
|
858
|
+
Command(
|
|
859
|
+
function cmdWork() {
|
|
860
|
+
return value;
|
|
861
|
+
},
|
|
862
|
+
(/** @type {any} */ v) => Success(v)
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
it('should nest onStep wrappers with the first config outermost', async function () {
|
|
866
|
+
/** @type {string[]} */
|
|
867
|
+
const order = [];
|
|
868
|
+
const outer = async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => {
|
|
869
|
+
order.push('outer:in');
|
|
870
|
+
const r = await op();
|
|
871
|
+
order.push('outer:out');
|
|
872
|
+
return r;
|
|
873
|
+
};
|
|
874
|
+
const inner = async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => {
|
|
875
|
+
order.push('inner:in');
|
|
876
|
+
const r = await op();
|
|
877
|
+
order.push('inner:out');
|
|
878
|
+
return r;
|
|
879
|
+
};
|
|
880
|
+
configureEffect({ onStep: outer }, { onStep: inner });
|
|
881
|
+
const result = await runEffect(work('ok'));
|
|
882
|
+
assert.equal(result.type, 'Success');
|
|
883
|
+
assert.deepEqual(order, ['outer:in', 'inner:in', 'inner:out', 'outer:out']);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it('should let recording and telemetry share the single onStep slot', async function () {
|
|
887
|
+
/** @type {string[]} */
|
|
888
|
+
const spans = [];
|
|
889
|
+
const telemetry = async (/** @type {any} */ name, /** @type {any} */ t, /** @type {any} */ op) => {
|
|
890
|
+
spans.push(name);
|
|
891
|
+
return await op();
|
|
892
|
+
};
|
|
893
|
+
const rec = recorder();
|
|
894
|
+
configureEffect({ onStep: telemetry }, { onStep: rec.onStep });
|
|
895
|
+
const result = await runEffect(work('ok'));
|
|
896
|
+
assert.equal(valueOf(result), 'ok');
|
|
897
|
+
assert.deepEqual(spans, ['cmdWork']);
|
|
898
|
+
assert.deepEqual(
|
|
899
|
+
rec.entries.map(({ command, result }) => ({ command, result })),
|
|
900
|
+
[{ command: 'cmdWork', result: 'ok' }]
|
|
901
|
+
);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
it('should propagate a thrown Command through every wrapper', async function () {
|
|
905
|
+
/** @type {string[]} */
|
|
906
|
+
const saw = [];
|
|
907
|
+
const watcher =
|
|
908
|
+
(/** @type {string} */ label) =>
|
|
909
|
+
async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => {
|
|
910
|
+
try {
|
|
911
|
+
return await op();
|
|
912
|
+
} catch (e) {
|
|
913
|
+
saw.push(label);
|
|
914
|
+
throw e;
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
const rec = recorder();
|
|
918
|
+
configureEffect({ onStep: watcher('a') }, { onStep: rec.onStep }, { onStep: watcher('b') });
|
|
919
|
+
const result = await runEffect(
|
|
920
|
+
Command(
|
|
921
|
+
function cmdBoom() {
|
|
922
|
+
throw new Error('boom');
|
|
923
|
+
},
|
|
924
|
+
(/** @type {any} */ v) => Success(v)
|
|
925
|
+
)
|
|
926
|
+
);
|
|
927
|
+
assert.equal(result.type, 'Failure');
|
|
928
|
+
assert.deepEqual(saw, ['b', 'a'], 'the error unwinds from innermost to outermost');
|
|
929
|
+
assert.equal(rec.entries.length, 1, 'the recorder still captured the failing step');
|
|
930
|
+
assert.ok('error' in rec.entries[0]);
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
it('should nest onRun wrappers and preserve flowName and the result', async function () {
|
|
934
|
+
/** @type {any[]} */
|
|
935
|
+
const seen = [];
|
|
936
|
+
const wrap =
|
|
937
|
+
(/** @type {string} */ label) =>
|
|
938
|
+
async (/** @type {any} */ effect, /** @type {any} */ op, /** @type {any} */ flowName) => {
|
|
939
|
+
seen.push([label, flowName]);
|
|
940
|
+
return await op();
|
|
941
|
+
};
|
|
942
|
+
configureEffect({ onRun: wrap('a') }, { onRun: wrap('b') });
|
|
943
|
+
const result = await runEffect(work('ok'), { flowName: 'checkout' });
|
|
944
|
+
assert.equal(valueOf(result), 'ok');
|
|
945
|
+
assert.deepEqual(seen, [
|
|
946
|
+
['a', 'checkout'],
|
|
947
|
+
['b', 'checkout']
|
|
948
|
+
]);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it('should run every onBeforeCommand interceptor in order', async function () {
|
|
952
|
+
/** @type {string[]} */
|
|
953
|
+
const calls = [];
|
|
954
|
+
configureEffect(
|
|
955
|
+
{ onBeforeCommand: async (/** @type {any} */ c) => void calls.push(`a:${c.cmd.name}`) },
|
|
956
|
+
{ onBeforeCommand: async (/** @type {any} */ c) => void calls.push(`b:${c.cmd.name}`) }
|
|
957
|
+
);
|
|
958
|
+
await runEffect(work('ok'));
|
|
959
|
+
assert.deepEqual(calls, ['a:cmdWork', 'b:cmdWork']);
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
it('should merge retry defaults with later configs winning', async function () {
|
|
963
|
+
// attempts from the first, delay from the second: a slow default would make this test crawl.
|
|
964
|
+
configureEffect({ retry: { attempts: 5, delay: 200 } }, { retry: { delay: 0 } });
|
|
965
|
+
let calls = 0;
|
|
966
|
+
const started = Date.now();
|
|
967
|
+
const result = await runEffect(
|
|
968
|
+
Retry(
|
|
969
|
+
Command(
|
|
970
|
+
function cmdFlaky() {
|
|
971
|
+
if (++calls < 5) throw new Error('transient');
|
|
972
|
+
return 'ok';
|
|
973
|
+
},
|
|
974
|
+
(/** @type {any} */ v) => Success(v)
|
|
975
|
+
)
|
|
976
|
+
)
|
|
977
|
+
);
|
|
978
|
+
assert.equal(result.type, 'Success', 'the higher attempts count from the first config applied');
|
|
979
|
+
assert.equal(calls, 5);
|
|
980
|
+
assert.ok(Date.now() - started < 150, 'and the zero delay from the second config won');
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
it('should leave slots no config defines at their defaults, and tolerate gaps', async function () {
|
|
984
|
+
/** @type {string[]} */
|
|
985
|
+
const intercepted = [];
|
|
986
|
+
configureEffect({ onBeforeCommand: async (/** @type {any} */ c) => void intercepted.push(c.cmd.name) });
|
|
987
|
+
|
|
988
|
+
// Only onStep is named here, so the interceptor above must be gone rather than merged in.
|
|
989
|
+
configureEffect(
|
|
990
|
+
{ onStep: async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => await op() },
|
|
991
|
+
undefined,
|
|
992
|
+
{}
|
|
993
|
+
);
|
|
994
|
+
assert.equal(valueOf(await runEffect(work('ok'))), 'ok');
|
|
995
|
+
assert.deepEqual(intercepted, [], 'merging is per call and does not accumulate across calls');
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it('should return a restore function that puts the previous wiring back', async function () {
|
|
999
|
+
/** @type {string[]} */
|
|
1000
|
+
const first = [];
|
|
1001
|
+
/** @type {string[]} */
|
|
1002
|
+
const second = [];
|
|
1003
|
+
configureEffect({
|
|
1004
|
+
onStep: async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => (
|
|
1005
|
+
first.push(n),
|
|
1006
|
+
await op()
|
|
1007
|
+
)
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
const restore = configureEffect({
|
|
1011
|
+
onStep: async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => (
|
|
1012
|
+
second.push(n),
|
|
1013
|
+
await op()
|
|
1014
|
+
)
|
|
1015
|
+
});
|
|
1016
|
+
await runEffect(work('ok'));
|
|
1017
|
+
assert.deepEqual(second, ['cmdWork'], 'the second wiring took over');
|
|
1018
|
+
assert.deepEqual(first, [], 'and displaced the first');
|
|
1019
|
+
|
|
1020
|
+
restore();
|
|
1021
|
+
await runEffect(work('ok'));
|
|
1022
|
+
assert.deepEqual(first, ['cmdWork'], 'restore brought the first wiring back');
|
|
1023
|
+
assert.deepEqual(second, ['cmdWork'], 'without reactivating the second');
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
it('should restore back to no hooks when nothing was configured before', async function () {
|
|
1027
|
+
/** @type {string[]} */
|
|
1028
|
+
const seen = [];
|
|
1029
|
+
const restore = configureEffect({
|
|
1030
|
+
onStep: async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => (
|
|
1031
|
+
seen.push(n),
|
|
1032
|
+
await op()
|
|
1033
|
+
)
|
|
1034
|
+
});
|
|
1035
|
+
await runEffect(work('ok'));
|
|
1036
|
+
restore();
|
|
1037
|
+
await runEffect(work('ok'));
|
|
1038
|
+
assert.deepEqual(seen, ['cmdWork'], 'the second run had no hooks at all');
|
|
1039
|
+
});
|
|
235
1040
|
|
|
236
|
-
it('should
|
|
1041
|
+
it('should restore retry defaults too', async function () {
|
|
1042
|
+
configureEffect({ retry: { attempts: 1, delay: 0 } });
|
|
1043
|
+
const restore = configureEffect({ retry: { attempts: 4, delay: 0 } });
|
|
1044
|
+
|
|
1045
|
+
const flaky = () => {
|
|
1046
|
+
let calls = 0;
|
|
1047
|
+
return {
|
|
1048
|
+
count: () => calls,
|
|
1049
|
+
effect: () =>
|
|
1050
|
+
Retry(
|
|
1051
|
+
Command(
|
|
1052
|
+
function cmdFlaky() {
|
|
1053
|
+
if (++calls < 3) throw new Error('transient');
|
|
1054
|
+
return 'ok';
|
|
1055
|
+
},
|
|
1056
|
+
(/** @type {any} */ v) => Success(v)
|
|
1057
|
+
)
|
|
1058
|
+
)
|
|
1059
|
+
};
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
const generous = flaky();
|
|
1063
|
+
assert.equal((await runEffect(generous.effect())).type, 'Success', 'four attempts are enough');
|
|
1064
|
+
|
|
1065
|
+
restore();
|
|
1066
|
+
const stingy = flaky();
|
|
1067
|
+
const result = await runEffect(stingy.effect());
|
|
1068
|
+
assert.equal(result.type, 'Failure', 'the restored single attempt is not');
|
|
1069
|
+
assert.equal(/** @type {any} */ (errorOf(result)).attempts, 1);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
it('should not clobber a newer wiring when restoring out of order', async function () {
|
|
1073
|
+
/** @type {string[]} */
|
|
1074
|
+
const a = [];
|
|
1075
|
+
/** @type {string[]} */
|
|
1076
|
+
const b = [];
|
|
1077
|
+
const restoreA = configureEffect({
|
|
1078
|
+
onStep: async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => (a.push(n), await op())
|
|
1079
|
+
});
|
|
1080
|
+
configureEffect({
|
|
1081
|
+
onStep: async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => (b.push(n), await op())
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
restoreA();
|
|
1085
|
+
await runEffect(work('ok'));
|
|
1086
|
+
assert.deepEqual(b, ['cmdWork'], "B's wiring survived A's late restore");
|
|
1087
|
+
assert.deepEqual(a, [], 'and A did not come back');
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
it('should reset every slot when called with nothing', async function () {
|
|
1091
|
+
/** @type {string[]} */
|
|
1092
|
+
const seen = [];
|
|
1093
|
+
configureEffect({
|
|
1094
|
+
onStep: async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => (
|
|
1095
|
+
seen.push(n),
|
|
1096
|
+
await op()
|
|
1097
|
+
)
|
|
1098
|
+
});
|
|
1099
|
+
await runEffect(work('ok'));
|
|
1100
|
+
configureEffect();
|
|
1101
|
+
await runEffect(work('ok'));
|
|
1102
|
+
assert.deepEqual(seen, ['cmdWork'], 'the second run ran with no hooks at all');
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
describe('examples/recording-example.js', function () {
|
|
1107
|
+
beforeEach(() => configureEffect({}));
|
|
1108
|
+
afterEach(() => configureEffect({}));
|
|
1109
|
+
|
|
1110
|
+
const failing = (/** @type {any} */ input) =>
|
|
1111
|
+
effectPipe(
|
|
1112
|
+
(/** @type {any} */ i) =>
|
|
1113
|
+
Command(
|
|
1114
|
+
function cmdRead() {
|
|
1115
|
+
return { row: i.id };
|
|
1116
|
+
},
|
|
1117
|
+
(/** @type {any} */ r) => Success(r)
|
|
1118
|
+
),
|
|
1119
|
+
() =>
|
|
1120
|
+
Command(
|
|
1121
|
+
function cmdWrite() {
|
|
1122
|
+
throw new Error('write failed');
|
|
1123
|
+
},
|
|
1124
|
+
(/** @type {any} */ r) => Success(r)
|
|
1125
|
+
)
|
|
1126
|
+
)(input);
|
|
1127
|
+
|
|
1128
|
+
it('should send a trace to the sink when a flow fails', async function () {
|
|
1129
|
+
/** @type {any[]} */
|
|
1130
|
+
const written = [];
|
|
1131
|
+
enableRecording({ sink: async (/** @type {any} */ trace) => void written.push(trace) });
|
|
1132
|
+
const result = await runEffect(failing({ id: 1 }), { flowName: 'writer', tenant: 'acme' });
|
|
1133
|
+
assert.equal(result.type, 'Failure');
|
|
1134
|
+
assert.equal(written.length, 1);
|
|
1135
|
+
assert.deepEqual(
|
|
1136
|
+
written[0].trace.map((/** @type {any} */ e) => e.command),
|
|
1137
|
+
['cmdRead', 'cmdWrite']
|
|
1138
|
+
);
|
|
1139
|
+
assert.equal(written[0].flowName, 'writer');
|
|
1140
|
+
assert.deepEqual(written[0].context, { flowName: 'writer', tenant: 'acme' }, 'context captured for Ask replay');
|
|
1141
|
+
assert.deepEqual(written[0].initialInput, { id: 1 });
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
it('should keep successful runs out of the sink by default', async function () {
|
|
1145
|
+
/** @type {any[]} */
|
|
1146
|
+
const written = [];
|
|
1147
|
+
enableRecording({ sink: async (/** @type {any} */ t) => void written.push(t) });
|
|
1148
|
+
const ok = (/** @type {any} */ i) =>
|
|
1149
|
+
Command(
|
|
1150
|
+
function cmdOk() {
|
|
1151
|
+
return i;
|
|
1152
|
+
},
|
|
1153
|
+
(/** @type {any} */ v) => Success(v)
|
|
1154
|
+
);
|
|
1155
|
+
assert.equal((await runEffect(ok({ id: 2 }))).type, 'Success');
|
|
1156
|
+
assert.deepEqual(written, []);
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
it('should give concurrent runs separate traces', async function () {
|
|
1160
|
+
/** @type {any[]} */
|
|
1161
|
+
const written = [];
|
|
1162
|
+
enableRecording({ sink: async (/** @type {any} */ t) => void written.push(t) });
|
|
1163
|
+
const slowFail = (/** @type {any} */ label) => (/** @type {any} */ input) =>
|
|
1164
|
+
effectPipe(
|
|
1165
|
+
() =>
|
|
1166
|
+
Command(
|
|
1167
|
+
function cmdSlow() {
|
|
1168
|
+
return new Promise((r) => setTimeout(() => r(label), label === 'A' ? 20 : 5));
|
|
1169
|
+
},
|
|
1170
|
+
(/** @type {any} */ v) => Success(v)
|
|
1171
|
+
),
|
|
1172
|
+
() =>
|
|
1173
|
+
Command(
|
|
1174
|
+
function cmdFail() {
|
|
1175
|
+
throw new Error(`${label} failed`);
|
|
1176
|
+
},
|
|
1177
|
+
(/** @type {any} */ v) => Success(v)
|
|
1178
|
+
)
|
|
1179
|
+
)(input);
|
|
1180
|
+
await Promise.all([
|
|
1181
|
+
runEffect(slowFail('A')({ id: 'A' }), { flowName: 'A' }),
|
|
1182
|
+
runEffect(slowFail('B')({ id: 'B' }), { flowName: 'B' })
|
|
1183
|
+
]);
|
|
1184
|
+
assert.equal(written.length, 2);
|
|
1185
|
+
for (const trace of written) {
|
|
1186
|
+
assert.deepEqual(
|
|
1187
|
+
trace.trace.map((/** @type {any} */ e) => e.command),
|
|
1188
|
+
['cmdSlow', 'cmdFail'],
|
|
1189
|
+
'each trace holds only its own run'
|
|
1190
|
+
);
|
|
1191
|
+
assert.equal(trace.trace[0].result, trace.flowName);
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
it('should compose with a telemetry hook in one configureEffect call', async function () {
|
|
1196
|
+
/** @type {string[]} */
|
|
1197
|
+
const spans = [];
|
|
1198
|
+
/** @type {any[]} */
|
|
1199
|
+
const written = [];
|
|
1200
|
+
configureEffect(
|
|
1201
|
+
{
|
|
1202
|
+
onStep: async (/** @type {any} */ n, /** @type {any} */ t, /** @type {any} */ op) => (
|
|
1203
|
+
spans.push(n),
|
|
1204
|
+
await op()
|
|
1205
|
+
)
|
|
1206
|
+
},
|
|
1207
|
+
recordingHooks({ sink: async (/** @type {any} */ t) => void written.push(t) })
|
|
1208
|
+
);
|
|
1209
|
+
const result = await runEffect(failing({ id: 3 }), { flowName: 'both' });
|
|
1210
|
+
assert.equal(result.type, 'Failure');
|
|
1211
|
+
assert.deepEqual(spans, ['cmdRead', 'cmdWrite'], 'telemetry still saw every step');
|
|
1212
|
+
assert.equal(written.length, 1, 'and the trace was still written');
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
it('should redact recorded results before they reach the sink', async function () {
|
|
1216
|
+
/** @type {any[]} */
|
|
1217
|
+
const written = [];
|
|
1218
|
+
enableRecording({
|
|
1219
|
+
sink: async (/** @type {any} */ t) => void written.push(t),
|
|
1220
|
+
redact: (/** @type {any} */ result) => (result && result.row ? { row: '[redacted]' } : result)
|
|
1221
|
+
});
|
|
1222
|
+
await runEffect(failing({ id: 4 }));
|
|
1223
|
+
assert.deepEqual(written[0].trace[0].result, { row: '[redacted]' });
|
|
1224
|
+
});
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
describe('examples/opentelemetry-example.js', function () {
|
|
1228
|
+
beforeEach(() => configureEffect({}));
|
|
1229
|
+
afterEach(() => configureEffect({}));
|
|
1230
|
+
|
|
1231
|
+
/** A tracer stub, so the example is testable without standing up an SDK. */
|
|
1232
|
+
const fakeTracer = () => {
|
|
1233
|
+
/** @type {any[]} */
|
|
1234
|
+
const spans = [];
|
|
1235
|
+
return {
|
|
1236
|
+
spans,
|
|
1237
|
+
tracer: /** @type {any} */ ({
|
|
1238
|
+
startActiveSpan: (/** @type {string} */ name, /** @type {any} */ fn) => {
|
|
1239
|
+
const span = {
|
|
1240
|
+
name,
|
|
1241
|
+
/** @type {Record<string, any>} */ attributes: {},
|
|
1242
|
+
/** @type {any} */ status: undefined,
|
|
1243
|
+
exceptions: /** @type {any[]} */ ([]),
|
|
1244
|
+
ended: false,
|
|
1245
|
+
setAttribute(/** @type {string} */ k, /** @type {any} */ v) {
|
|
1246
|
+
this.attributes[k] = v;
|
|
1247
|
+
},
|
|
1248
|
+
setStatus(/** @type {any} */ st) {
|
|
1249
|
+
this.status = st;
|
|
1250
|
+
},
|
|
1251
|
+
recordException(/** @type {any} */ e) {
|
|
1252
|
+
this.exceptions.push(e);
|
|
1253
|
+
},
|
|
1254
|
+
end() {
|
|
1255
|
+
this.ended = true;
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
spans.push(span);
|
|
1259
|
+
return fn(span);
|
|
1260
|
+
}
|
|
1261
|
+
})
|
|
1262
|
+
};
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
const workFlow = (/** @type {any} */ input) =>
|
|
1266
|
+
effectPipe((/** @type {any} */ i) =>
|
|
1267
|
+
Command(
|
|
1268
|
+
function cmdWork() {
|
|
1269
|
+
return 'done';
|
|
1270
|
+
},
|
|
1271
|
+
(/** @type {any} */ v) => Success(v)
|
|
1272
|
+
)
|
|
1273
|
+
)(input);
|
|
1274
|
+
|
|
1275
|
+
it('should not let an unserializable input stop the flow', async function () {
|
|
1276
|
+
// A request object or database client in the input is ordinary, and both hold cycles.
|
|
1277
|
+
// Nothing serializes the input any more, so this guards against reintroducing that.
|
|
1278
|
+
/** @type {any} */
|
|
1279
|
+
const input = { id: 1 };
|
|
1280
|
+
input.self = input;
|
|
1281
|
+
|
|
1282
|
+
const { tracer, spans } = fakeTracer();
|
|
1283
|
+
let ran = 0;
|
|
1284
|
+
const flow = (/** @type {any} */ i) =>
|
|
1285
|
+
effectPipe(() =>
|
|
1286
|
+
Command(
|
|
1287
|
+
function cmdWork() {
|
|
1288
|
+
ran++;
|
|
1289
|
+
return 'done';
|
|
1290
|
+
},
|
|
1291
|
+
(/** @type {any} */ v) => Success(v)
|
|
1292
|
+
)
|
|
1293
|
+
)(i);
|
|
1294
|
+
|
|
1295
|
+
configureEffect(telemetryHooks({ tracer }));
|
|
1296
|
+
const result = await runEffect(flow(input));
|
|
1297
|
+
|
|
1298
|
+
assert.equal(result.type, 'Success', 'telemetry must not decide whether the flow runs');
|
|
1299
|
+
assert.equal(ran, 1);
|
|
1300
|
+
assert.ok(spans.every((/** @type {any} */ sp) => sp.ended));
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
it('should never put Command values on spans', async function () {
|
|
1304
|
+
const secret = { email: 'user@test.com', password: 'plaintext' };
|
|
1305
|
+
const { tracer, spans } = fakeTracer();
|
|
1306
|
+
configureEffect(telemetryHooks({ tracer }));
|
|
1307
|
+
await runEffect(workFlow(secret), { flowName: 'register' });
|
|
1308
|
+
|
|
1309
|
+
const attributes = JSON.stringify(spans.map((/** @type {any} */ sp) => sp.attributes));
|
|
1310
|
+
assert.ok(!attributes.includes('plaintext'), 'no input values on spans');
|
|
1311
|
+
assert.ok(!attributes.includes('user@test.com'), 'not even harmless-looking ones');
|
|
1312
|
+
assert.ok(!attributes.includes('done'), 'no Command output on spans');
|
|
1313
|
+
assert.deepEqual(Object.keys(spans[0].attributes), ['effect.flow'], 'the root span carries the flow name');
|
|
1314
|
+
assert.deepEqual(Object.keys(spans[1].attributes), ['effect.type'], 'and a step span its type');
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
it('should name the root span after the flow and open a child span per Command', async function () {
|
|
1318
|
+
const { tracer, spans } = fakeTracer();
|
|
1319
|
+
configureEffect(telemetryHooks({ tracer }));
|
|
1320
|
+
await runEffect(workFlow({ id: 1 }), { flowName: 'checkout' });
|
|
1321
|
+
assert.deepEqual(
|
|
1322
|
+
spans.map((/** @type {any} */ s) => s.name),
|
|
1323
|
+
['checkout', 'cmdWork']
|
|
1324
|
+
);
|
|
1325
|
+
assert.equal(spans[1].attributes['effect.type'], 'Command');
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
it('should mark a Failure on the root span and record a thrown Command', async function () {
|
|
1329
|
+
const { tracer, spans } = fakeTracer();
|
|
1330
|
+
configureEffect(telemetryHooks({ tracer }));
|
|
1331
|
+
const boom = (/** @type {any} */ input) =>
|
|
1332
|
+
effectPipe(() =>
|
|
1333
|
+
Command(
|
|
1334
|
+
function cmdBoom() {
|
|
1335
|
+
throw new Error('boom');
|
|
1336
|
+
},
|
|
1337
|
+
(/** @type {any} */ v) => Success(v)
|
|
1338
|
+
)
|
|
1339
|
+
)(input);
|
|
1340
|
+
const result = await runEffect(boom({ id: 1 }));
|
|
1341
|
+
assert.equal(result.type, 'Failure');
|
|
1342
|
+
assert.equal(spans[1].exceptions.length, 1, 'the step span recorded the exception');
|
|
1343
|
+
assert.equal(spans[0].status.code, 2, 'and the root span is ERROR');
|
|
1344
|
+
assert.ok(spans.every((/** @type {any} */ s) => s.ended));
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
it('should still work through enableTelemetry with no SDK started', async function () {
|
|
237
1348
|
enableTelemetry();
|
|
238
|
-
const
|
|
239
|
-
|
|
1349
|
+
const result = await runEffect(workFlow({ id: 1 }), { flowName: 'noop-tracer' });
|
|
1350
|
+
assert.equal(result.type, 'Success');
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
it('should compose with recording in one configureEffect call', async function () {
|
|
1354
|
+
const { tracer, spans } = fakeTracer();
|
|
1355
|
+
/** @type {any[]} */
|
|
1356
|
+
const written = [];
|
|
1357
|
+
configureEffect(
|
|
1358
|
+
telemetryHooks({ tracer }),
|
|
1359
|
+
recordingHooks({ keep: () => true, sink: async (/** @type {any} */ t) => void written.push(t) })
|
|
1360
|
+
);
|
|
1361
|
+
const result = await runEffect(workFlow({ id: 1 }), { flowName: 'both' });
|
|
1362
|
+
assert.equal(result.type, 'Success');
|
|
1363
|
+
assert.deepEqual(
|
|
1364
|
+
spans.map((/** @type {any} */ s) => s.name),
|
|
1365
|
+
['both', 'cmdWork']
|
|
1366
|
+
);
|
|
1367
|
+
assert.deepEqual(
|
|
1368
|
+
written[0].trace.map((/** @type {any} */ e) => e.command),
|
|
1369
|
+
['cmdWork']
|
|
1370
|
+
);
|
|
1371
|
+
});
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
describe('Recorded step timings', function () {
|
|
1375
|
+
beforeEach(() => configureEffect({}));
|
|
1376
|
+
|
|
1377
|
+
const slow = (/** @type {number} */ ms) =>
|
|
1378
|
+
Command(
|
|
1379
|
+
function cmdSlow() {
|
|
1380
|
+
return new Promise((r) => setTimeout(() => r('done'), ms));
|
|
1381
|
+
},
|
|
1382
|
+
(/** @type {any} */ v) => Success(v)
|
|
1383
|
+
);
|
|
1384
|
+
|
|
1385
|
+
it('should record how long each Command took', async function () {
|
|
1386
|
+
const rec = recorder();
|
|
1387
|
+
await runEffect(slow(15), {}, { onStep: rec.onStep });
|
|
1388
|
+
assert.equal(rec.entries.length, 1);
|
|
1389
|
+
const { durationMs } = rec.entries[0];
|
|
1390
|
+
assert.equal(typeof durationMs, 'number');
|
|
1391
|
+
assert.ok(/** @type {number} */ (durationMs) >= 10, `expected at least 10ms, got ${durationMs}`);
|
|
1392
|
+
assert.equal(
|
|
1393
|
+
durationMs,
|
|
1394
|
+
Math.round(/** @type {number} */ (durationMs) * 1000) / 1000,
|
|
1395
|
+
'rounded to microseconds'
|
|
1396
|
+
);
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
it('should record a duration for a Command that threw', async function () {
|
|
1400
|
+
const rec = recorder();
|
|
1401
|
+
const result = await runEffect(
|
|
1402
|
+
Command(
|
|
1403
|
+
function cmdBoom() {
|
|
1404
|
+
throw new Error('boom');
|
|
1405
|
+
},
|
|
1406
|
+
(/** @type {any} */ v) => Success(v)
|
|
1407
|
+
),
|
|
1408
|
+
{},
|
|
1409
|
+
{ onStep: rec.onStep }
|
|
1410
|
+
);
|
|
1411
|
+
assert.equal(result.type, 'Failure');
|
|
1412
|
+
assert.ok('error' in rec.entries[0]);
|
|
1413
|
+
assert.equal(typeof rec.entries[0].durationMs, 'number');
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
it('should keep timings out of the way of replay', async function () {
|
|
1417
|
+
const flow = (/** @type {any} */ input) => effectPipe(() => slow(5))(input);
|
|
1418
|
+
const { result, trace } = await recordEffect(flow, { id: 1 });
|
|
1419
|
+
assert.equal(typeof trace.trace[0].durationMs, 'number');
|
|
1420
|
+
const replayed = await replayEffect(flow({ id: 1 }), trace);
|
|
1421
|
+
assert.equal(replayed.type, 'Success');
|
|
1422
|
+
assert.deepEqual(valueOf(replayed), valueOf(result));
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
it('should not let an observation failure change the outcome', async function () {
|
|
1426
|
+
// The guarantee `observeSteps` exists for, reached through its only caller.
|
|
1427
|
+
let ran = 0;
|
|
1428
|
+
const rec = recorder({
|
|
1429
|
+
redact: () => {
|
|
1430
|
+
throw new Error('redact blew up');
|
|
1431
|
+
}
|
|
1432
|
+
});
|
|
1433
|
+
const result = await runEffect(
|
|
1434
|
+
Command(
|
|
1435
|
+
function cmdWork() {
|
|
1436
|
+
ran++;
|
|
1437
|
+
return 'ok';
|
|
1438
|
+
},
|
|
1439
|
+
(/** @type {any} */ v) => Success(v)
|
|
1440
|
+
),
|
|
1441
|
+
{},
|
|
1442
|
+
{ onStep: rec.onStep }
|
|
1443
|
+
);
|
|
1444
|
+
assert.equal(result.type, 'Success');
|
|
1445
|
+
assert.equal(valueOf(result), 'ok');
|
|
1446
|
+
assert.equal(ran, 1);
|
|
1447
|
+
});
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
describe('Command identity', function () {
|
|
1451
|
+
beforeEach(() => configureEffect({}));
|
|
1452
|
+
|
|
1453
|
+
/** Captures the name each Command is executed under. */
|
|
1454
|
+
const capture = () => {
|
|
1455
|
+
/** @type {string[]} */
|
|
1456
|
+
const names = [];
|
|
1457
|
+
return {
|
|
1458
|
+
names,
|
|
1459
|
+
onStep: async (/** @type {any} */ name, /** @type {any} */ t, /** @type {any} */ op) => (
|
|
1460
|
+
names.push(name),
|
|
1461
|
+
await op()
|
|
1462
|
+
)
|
|
1463
|
+
};
|
|
1464
|
+
};
|
|
1465
|
+
|
|
1466
|
+
it('should default next to Success so a result passes straight through', async function () {
|
|
1467
|
+
const result = await runEffect(Command(() => 'ok'));
|
|
1468
|
+
assert.equal(result.type, 'Success');
|
|
1469
|
+
assert.equal(valueOf(result), 'ok');
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
it('should prefer meta.name over the thunk name', async function () {
|
|
1473
|
+
const { names, onStep } = capture();
|
|
1474
|
+
const effect = Command(
|
|
1475
|
+
function cmdInternalName() {
|
|
1476
|
+
return 'ok';
|
|
1477
|
+
},
|
|
1478
|
+
(/** @type {any} */ v) => Success(v),
|
|
1479
|
+
{ name: 'chargeCard' }
|
|
1480
|
+
);
|
|
1481
|
+
assert.equal(valueOf(await runEffect(effect, {}, { onStep })), 'ok');
|
|
1482
|
+
assert.deepEqual(names, ['chargeCard']);
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
it('should let an inline thunk be identified through meta.name', async function () {
|
|
1486
|
+
const { names, onStep } = capture();
|
|
1487
|
+
const effect = Command(
|
|
1488
|
+
() => 'ok',
|
|
1489
|
+
(/** @type {any} */ v) => Success(v),
|
|
1490
|
+
{ name: 'cmdInline' }
|
|
1491
|
+
);
|
|
1492
|
+
await runEffect(effect, {}, { onStep });
|
|
1493
|
+
assert.deepEqual(names, ['cmdInline'], 'no longer anonymous');
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
it('should fall back to the thunk name when meta carries no name', async function () {
|
|
1497
|
+
const { names, onStep } = capture();
|
|
1498
|
+
const effect = Command(
|
|
1499
|
+
function cmdNamed() {
|
|
1500
|
+
return 'ok';
|
|
1501
|
+
},
|
|
1502
|
+
(/** @type {any} */ v) => Success(v),
|
|
1503
|
+
{ attempt: 1 }
|
|
1504
|
+
);
|
|
1505
|
+
await runEffect(effect, {}, { onStep });
|
|
1506
|
+
assert.deepEqual(names, ['cmdNamed']);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
it('should fall back to anonymous when neither is available', async function () {
|
|
1510
|
+
const { names, onStep } = capture();
|
|
1511
|
+
await runEffect(
|
|
1512
|
+
Command(
|
|
1513
|
+
() => 'ok',
|
|
1514
|
+
(/** @type {any} */ v) => Success(v)
|
|
1515
|
+
),
|
|
1516
|
+
{},
|
|
1517
|
+
{ onStep }
|
|
1518
|
+
);
|
|
1519
|
+
assert.deepEqual(names, ['anonymous']);
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
it('should ignore a meta that is not an object or has a non-string name', async function () {
|
|
1523
|
+
const { names, onStep } = capture();
|
|
1524
|
+
const named = () =>
|
|
1525
|
+
Command(
|
|
1526
|
+
function cmdFallback() {
|
|
1527
|
+
return 'ok';
|
|
1528
|
+
},
|
|
1529
|
+
(/** @type {any} */ v) => Success(v)
|
|
1530
|
+
);
|
|
1531
|
+
for (const meta of ['a string', 42, null, { name: 7 }, { name: '' }]) {
|
|
1532
|
+
const effect = Command(named().cmd, (/** @type {any} */ v) => Success(v), meta);
|
|
1533
|
+
await runEffect(effect, {}, { onStep });
|
|
1534
|
+
}
|
|
1535
|
+
assert.deepEqual(names, ['cmdFallback', 'cmdFallback', 'cmdFallback', 'cmdFallback', 'cmdFallback']);
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
it('should carry meta.name through effectPipe into a trace and a replay', async function () {
|
|
1539
|
+
const flow = (/** @type {any} */ input) =>
|
|
1540
|
+
effectPipe(
|
|
1541
|
+
(/** @type {any} */ i) =>
|
|
1542
|
+
Command(
|
|
1543
|
+
() => ({ row: i.id }),
|
|
1544
|
+
(/** @type {any} */ r) => Success(r),
|
|
1545
|
+
{ name: 'readRow' }
|
|
1546
|
+
),
|
|
1547
|
+
(/** @type {any} */ r) =>
|
|
1548
|
+
Command(
|
|
1549
|
+
() => ({ written: r.row }),
|
|
1550
|
+
(/** @type {any} */ w) => Success(w),
|
|
1551
|
+
{ name: 'writeRow' }
|
|
1552
|
+
)
|
|
1553
|
+
)(input);
|
|
1554
|
+
|
|
1555
|
+
const { result, trace } = await recordEffect(flow, { id: 'x1' });
|
|
1556
|
+
assert.deepEqual(
|
|
1557
|
+
trace.trace.map((e) => e.command),
|
|
1558
|
+
['readRow', 'writeRow'],
|
|
1559
|
+
'meta.name survives the rebuild chain performs on each pipe step'
|
|
1560
|
+
);
|
|
1561
|
+
|
|
1562
|
+
const replayed = await replayEffect(flow({ id: 'x1' }), trace);
|
|
1563
|
+
assert.equal(replayed.type, 'Success', 'strict matching lines up on meta.name');
|
|
1564
|
+
assert.deepEqual(valueOf(replayed), valueOf(result));
|
|
1565
|
+
});
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
describe('Recorded values are snapshots', function () {
|
|
1569
|
+
beforeEach(() => configureEffect());
|
|
1570
|
+
|
|
1571
|
+
it('should not let a later mutation rewrite what an earlier step returned', async function () {
|
|
1572
|
+
const shared = { total: 100 };
|
|
1573
|
+
const flow = (/** @type {any} */ input) =>
|
|
1574
|
+
effectPipe(
|
|
1575
|
+
() =>
|
|
1576
|
+
Command(
|
|
1577
|
+
function cmdRead() {
|
|
1578
|
+
return shared;
|
|
1579
|
+
},
|
|
1580
|
+
(/** @type {any} */ r) => Success(r)
|
|
1581
|
+
),
|
|
1582
|
+
(/** @type {any} */ r) =>
|
|
1583
|
+
Command(
|
|
1584
|
+
function cmdApplyDiscount() {
|
|
1585
|
+
r.total = 0;
|
|
1586
|
+
return { applied: true };
|
|
1587
|
+
},
|
|
1588
|
+
(/** @type {any} */ v) => Success(v)
|
|
1589
|
+
)
|
|
1590
|
+
)(input);
|
|
1591
|
+
|
|
1592
|
+
const { trace } = await recordEffect(flow, { cartId: 1 });
|
|
1593
|
+
assert.deepEqual(trace.trace[0].result, { total: 100 }, 'the trace holds what production saw');
|
|
1594
|
+
assert.equal(shared.total, 0, 'while the live object was still mutated by the flow');
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
it('should keep the trace stable when the caller mutates the value afterwards', async function () {
|
|
1598
|
+
const row = { id: 1, tags: ['a'] };
|
|
1599
|
+
const rec = recorder();
|
|
1600
|
+
await runEffect(
|
|
1601
|
+
Command(
|
|
1602
|
+
function cmdRead() {
|
|
1603
|
+
return row;
|
|
1604
|
+
},
|
|
1605
|
+
(/** @type {any} */ r) => Success(r)
|
|
1606
|
+
),
|
|
1607
|
+
{},
|
|
1608
|
+
{ onStep: rec.onStep }
|
|
1609
|
+
);
|
|
1610
|
+
row.tags.push('b');
|
|
1611
|
+
row.id = 99;
|
|
1612
|
+
assert.deepEqual(rec.entries[0].result, { id: 1, tags: ['a'] }, 'nested values are snapshotted too');
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
it('should fall back to the reference for values that cannot be cloned', async function () {
|
|
1616
|
+
const rec = recorder();
|
|
1617
|
+
const result = await runEffect(
|
|
1618
|
+
Command(
|
|
1619
|
+
function cmdWithFunction() {
|
|
1620
|
+
return { ok: true, callback: () => 'not cloneable' };
|
|
1621
|
+
},
|
|
1622
|
+
(/** @type {any} */ r) => Success(r)
|
|
1623
|
+
),
|
|
1624
|
+
{},
|
|
1625
|
+
{ onStep: rec.onStep }
|
|
1626
|
+
);
|
|
1627
|
+
assert.equal(result.type, 'Success', 'an uncloneable result must not fail the run');
|
|
1628
|
+
assert.equal(/** @type {any} */ (rec.entries[0].result).ok, true, 'and the entry is still recorded');
|
|
1629
|
+
});
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
describe('Documented sharp edges', function () {
|
|
1633
|
+
beforeEach(() => configureEffect());
|
|
1634
|
+
|
|
1635
|
+
it('should re-run already-succeeded Commands on every Retry attempt', async function () {
|
|
1636
|
+
// Pinned deliberately: `Retry` repeats the whole wrapped tree, which is why the docs say to
|
|
1637
|
+
// wrap the one Command that fails transiently rather than a pipeline.
|
|
1638
|
+
/** @type {string[]} */
|
|
1639
|
+
const charges = [];
|
|
1640
|
+
let receiptFailures = 0;
|
|
1641
|
+
const effect = Retry(
|
|
1642
|
+
effectPipe(
|
|
1643
|
+
() =>
|
|
1644
|
+
Command(
|
|
1645
|
+
function cmdCharge() {
|
|
1646
|
+
charges.push('charged');
|
|
1647
|
+
return { id: charges.length };
|
|
1648
|
+
},
|
|
1649
|
+
(/** @type {any} */ r) => Success(r)
|
|
1650
|
+
),
|
|
1651
|
+
() =>
|
|
1652
|
+
Command(
|
|
1653
|
+
function cmdSendReceipt() {
|
|
1654
|
+
if (++receiptFailures < 3) throw new Error('smtp down');
|
|
1655
|
+
return 'sent';
|
|
1656
|
+
},
|
|
1657
|
+
(/** @type {any} */ v) => Success(v)
|
|
1658
|
+
)
|
|
1659
|
+
)({ order: 1 }),
|
|
1660
|
+
{ attempts: 3, delay: 0 }
|
|
1661
|
+
);
|
|
1662
|
+
|
|
1663
|
+
const result = await runEffect(effect);
|
|
240
1664
|
assert.equal(result.type, 'Success');
|
|
1665
|
+
assert.equal(charges.length, 3, 'one order, three charges: wrap the Command, not the pipeline');
|
|
1666
|
+
});
|
|
1667
|
+
|
|
1668
|
+
it('should run every Parallel branch to completion even after one fails', async function () {
|
|
1669
|
+
// Pinned deliberately: Promise.all has no cancellation, so a Failure does not stop sibling I/O.
|
|
1670
|
+
/** @type {string[]} */
|
|
1671
|
+
const written = [];
|
|
1672
|
+
const result = await runEffect(
|
|
1673
|
+
Parallel(
|
|
1674
|
+
[
|
|
1675
|
+
Command(
|
|
1676
|
+
function cmdValidate() {
|
|
1677
|
+
return null;
|
|
1678
|
+
},
|
|
1679
|
+
() => Failure('validation_failed')
|
|
1680
|
+
),
|
|
1681
|
+
Command(
|
|
1682
|
+
function cmdSlowWrite() {
|
|
1683
|
+
return new Promise((r) =>
|
|
1684
|
+
setTimeout(() => {
|
|
1685
|
+
written.push('wrote');
|
|
1686
|
+
r('ok');
|
|
1687
|
+
}, 20)
|
|
1688
|
+
);
|
|
1689
|
+
},
|
|
1690
|
+
(/** @type {any} */ v) => Success(v)
|
|
1691
|
+
)
|
|
1692
|
+
],
|
|
1693
|
+
(/** @type {any} */ vals) => Success(vals)
|
|
1694
|
+
)
|
|
1695
|
+
);
|
|
1696
|
+
assert.equal(result.type, 'Failure');
|
|
1697
|
+
assert.equal(errorOf(result), 'validation_failed');
|
|
1698
|
+
assert.deepEqual(written, ['wrote'], 'the sibling branch still performed its write');
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
it('should carry the initial input on every Failure', async function () {
|
|
1702
|
+
// Pinned deliberately: convenient for tests, and a PII surface for anything that logs the
|
|
1703
|
+
// whole Failure rather than its `error`.
|
|
1704
|
+
const input = { email: 'user@test.com', password: 'plaintext' };
|
|
1705
|
+
const failing = (/** @type {any} */ i) => effectPipe(() => Failure('invalid'))(i);
|
|
1706
|
+
const result = await runEffect(failing(input));
|
|
1707
|
+
assert.equal(result.type, 'Failure');
|
|
1708
|
+
assert.deepEqual(/** @type {any} */ (result).initialInput, input);
|
|
1709
|
+
});
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
describe('Malformed flows', function () {
|
|
1713
|
+
beforeEach(() => configureEffect());
|
|
1714
|
+
|
|
1715
|
+
/** @param {() => any} fn */
|
|
1716
|
+
const errorFrom = async (fn) => {
|
|
1717
|
+
try {
|
|
1718
|
+
await fn();
|
|
1719
|
+
return null;
|
|
1720
|
+
} catch (e) {
|
|
1721
|
+
return /** @type {any} */ (e);
|
|
1722
|
+
}
|
|
1723
|
+
};
|
|
1724
|
+
|
|
1725
|
+
it('should name the step that returned something other than an Effect', async function () {
|
|
1726
|
+
function validateRegistration(/** @type {any} */ input) {
|
|
1727
|
+
return { ...input, ok: true }; // forgot Success()
|
|
1728
|
+
}
|
|
1729
|
+
const e = await errorFrom(() =>
|
|
1730
|
+
runEffect(effectPipe(validateRegistration, (/** @type {any} */ i) => Success(i))({ id: 1 }))
|
|
1731
|
+
);
|
|
1732
|
+
assert.equal(e?.name, 'EffectTypeError');
|
|
1733
|
+
assert.match(e.message, /Step 'validateRegistration' returned a plain object/);
|
|
1734
|
+
assert.match(e.message, /Success\(value\)/, 'the message says what to do about it');
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1737
|
+
it('should call a missing return what it usually is', async function () {
|
|
1738
|
+
function ensureEmailAvailable() {}
|
|
1739
|
+
const e = await errorFrom(() => runEffect(effectPipe(ensureEmailAvailable)({ id: 1 })));
|
|
1740
|
+
assert.equal(e?.name, 'EffectTypeError');
|
|
1741
|
+
assert.match(e.message, /returned undefined, which usually means a missing return/);
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
it('should reject a Command continuation that returns a plain value', async function () {
|
|
1745
|
+
// This used to resolve to 6, so `result.type === 'Success'` quietly took the else branch.
|
|
1746
|
+
const e = await errorFrom(() =>
|
|
1747
|
+
runEffect(
|
|
1748
|
+
Command(
|
|
1749
|
+
function cmdRead() {
|
|
1750
|
+
return 5;
|
|
1751
|
+
},
|
|
1752
|
+
(/** @type {any} */ r) => r + 1
|
|
1753
|
+
)
|
|
1754
|
+
)
|
|
1755
|
+
);
|
|
1756
|
+
assert.equal(e?.name, 'EffectTypeError');
|
|
1757
|
+
assert.match(e.message, /returned the number 6/);
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1760
|
+
it('should recognise a flow that was never called with its input', async function () {
|
|
1761
|
+
// runEffect(effectPipe(...)) rather than runEffect(effectPipe(...)(input)).
|
|
1762
|
+
const e = await errorFrom(() =>
|
|
1763
|
+
runEffect(/** @type {any} */ (effectPipe((/** @type {any} */ i) => Success(i))))
|
|
1764
|
+
);
|
|
1765
|
+
assert.equal(e?.name, 'EffectTypeError');
|
|
1766
|
+
assert.match(e.message, /a function, which usually means a flow was passed without being called/);
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
it('should not disguise a malformed flow as a domain Failure', async function () {
|
|
1770
|
+
// The interpreter turns a thrown Command into a Failure; a bug in the flow must still throw.
|
|
1771
|
+
const flow = (/** @type {any} */ input) =>
|
|
1772
|
+
effectPipe(
|
|
1773
|
+
() =>
|
|
1774
|
+
Command(
|
|
1775
|
+
function cmdRead() {
|
|
1776
|
+
return { id: 1 };
|
|
1777
|
+
},
|
|
1778
|
+
(/** @type {any} */ r) => Success(r)
|
|
1779
|
+
),
|
|
1780
|
+
function afterRead(/** @type {any} */ r) {
|
|
1781
|
+
return r; // forgot Success()
|
|
1782
|
+
}
|
|
1783
|
+
)(input);
|
|
1784
|
+
const e = await errorFrom(() => runEffect(flow({ id: 1 })));
|
|
1785
|
+
assert.equal(e?.name, 'EffectTypeError', 'thrown rather than folded into a Failure');
|
|
1786
|
+
assert.match(e.message, /Step 'afterRead'/);
|
|
1787
|
+
});
|
|
1788
|
+
|
|
1789
|
+
it('should still turn a thrown Command into a Failure', async function () {
|
|
1790
|
+
const result = await runEffect(
|
|
1791
|
+
Command(
|
|
1792
|
+
function cmdBoom() {
|
|
1793
|
+
throw new Error('boom');
|
|
1794
|
+
},
|
|
1795
|
+
(/** @type {any} */ v) => Success(v)
|
|
1796
|
+
)
|
|
1797
|
+
);
|
|
1798
|
+
assert.equal(result.type, 'Failure', 'domain failures are unaffected by the new guard');
|
|
1799
|
+
assert.equal(/** @type {Error} */ (errorOf(result)).message, 'boom');
|
|
241
1800
|
});
|
|
242
1801
|
});
|