@ontrails/core 1.0.0-beta.10 → 1.0.0-beta.12

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.
Files changed (64) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +33 -0
  3. package/README.md +1 -0
  4. package/dist/context.d.ts +2 -2
  5. package/dist/context.d.ts.map +1 -1
  6. package/dist/context.js +12 -7
  7. package/dist/context.js.map +1 -1
  8. package/dist/execute.d.ts +8 -3
  9. package/dist/execute.d.ts.map +1 -1
  10. package/dist/execute.js +25 -6
  11. package/dist/execute.js.map +1 -1
  12. package/dist/index.d.ts +4 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +3 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/service-config.d.ts +22 -0
  17. package/dist/service-config.d.ts.map +1 -0
  18. package/dist/service-config.js +208 -0
  19. package/dist/service-config.js.map +1 -0
  20. package/dist/service.d.ts +75 -0
  21. package/dist/service.d.ts.map +1 -0
  22. package/dist/service.js +56 -0
  23. package/dist/service.js.map +1 -0
  24. package/dist/topo.d.ts +7 -0
  25. package/dist/topo.d.ts.map +1 -1
  26. package/dist/topo.js +37 -8
  27. package/dist/topo.js.map +1 -1
  28. package/dist/trail.d.ts +9 -2
  29. package/dist/trail.d.ts.map +1 -1
  30. package/dist/trail.js +2 -1
  31. package/dist/trail.js.map +1 -1
  32. package/dist/types.d.ts +27 -1
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/types.js +2 -1
  35. package/dist/types.js.map +1 -1
  36. package/dist/validate-topo.d.ts.map +1 -1
  37. package/dist/validate-topo.js +16 -0
  38. package/dist/validate-topo.js.map +1 -1
  39. package/dist/validation.d.ts.map +1 -1
  40. package/dist/validation.js +34 -3
  41. package/dist/validation.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/__tests__/context.test.ts +12 -0
  44. package/src/__tests__/dispatch.test.ts +29 -2
  45. package/src/__tests__/execute.test.ts +318 -3
  46. package/src/__tests__/layer.test.ts +3 -2
  47. package/src/__tests__/service-config.test.ts +224 -0
  48. package/src/__tests__/service.test.ts +197 -0
  49. package/src/__tests__/topo.test.ts +71 -0
  50. package/src/__tests__/trail-permit.test.ts +60 -0
  51. package/src/__tests__/trail.test.ts +46 -2
  52. package/src/__tests__/validate-topo.test.ts +45 -1
  53. package/src/__tests__/validation.test.ts +53 -0
  54. package/src/context.ts +18 -9
  55. package/src/execute.ts +63 -9
  56. package/src/index.ts +20 -0
  57. package/src/service-config.ts +354 -0
  58. package/src/service.ts +145 -0
  59. package/src/topo.ts +53 -9
  60. package/src/trail.ts +21 -3
  61. package/src/types.ts +32 -1
  62. package/src/validate-topo.ts +22 -0
  63. package/src/validation.ts +35 -3
  64. package/tsconfig.tsbuildinfo +1 -1
@@ -8,8 +8,9 @@ import { executeTrail } from '../execute';
8
8
  import { createTrailContext } from '../context';
9
9
  import type { Layer } from '../layer';
10
10
  import { Result } from '../result';
11
+ import { service } from '../service';
11
12
  import { trail } from '../trail';
12
- import type { TrailContext } from '../types';
13
+ import type { TrailContext, TrailContextInit } from '../types';
13
14
 
14
15
  // ---------------------------------------------------------------------------
15
16
  // Fixtures
@@ -33,6 +34,76 @@ const throwingTrail = trail('throws', {
33
34
  },
34
35
  });
35
36
 
37
+ const nextServiceId = (name: string): string =>
38
+ `test.service.${name}.${Bun.randomUUIDv7()}`;
39
+
40
+ const createResolvedValueService = (
41
+ id: string,
42
+ onCreate: () => void,
43
+ value: number
44
+ ) =>
45
+ service(id, {
46
+ create: () => {
47
+ onCreate();
48
+ return Result.ok({ value });
49
+ },
50
+ });
51
+
52
+ const createEagerServiceTrail = (
53
+ id: string,
54
+ counter: ReturnType<typeof createResolvedValueService>,
55
+ onRun: (value: number) => void
56
+ ) =>
57
+ trail('service.eager', {
58
+ input: z.object({}),
59
+ output: z.object({ total: z.number() }),
60
+ run: (_input, ctx) => {
61
+ const fromAccessor = ctx.service<{ value: number }>(id);
62
+ const fromDefinition = counter.from(ctx);
63
+ onRun(fromDefinition.value);
64
+ return Result.ok({ total: fromAccessor.value + 1 });
65
+ },
66
+ services: [counter],
67
+ });
68
+
69
+ const createServiceProbeLayer = (
70
+ counter: ReturnType<typeof createResolvedValueService>,
71
+ onResolve: (value: number) => void
72
+ ): Layer => ({
73
+ name: 'uses-service',
74
+ wrap(_trail, impl) {
75
+ return async (input, ctx) => {
76
+ onResolve(ctx.service<{ value: number }>(counter).value);
77
+ return await impl(input, ctx);
78
+ };
79
+ },
80
+ });
81
+
82
+ const createSingletonService = (id: string, onCreate: () => number) =>
83
+ service(id, {
84
+ create: () => Result.ok({ createdAtCall: onCreate() }),
85
+ });
86
+
87
+ const createSingletonTrail = (
88
+ singleton: ReturnType<typeof createSingletonService>
89
+ ) =>
90
+ trail('service.singleton', {
91
+ input: z.object({}),
92
+ output: z.object({ createdAtCall: z.number() }),
93
+ run: (_input, ctx) =>
94
+ Result.ok({
95
+ createdAtCall: singleton.from(ctx).createdAtCall,
96
+ }),
97
+ services: [singleton],
98
+ });
99
+
100
+ const unwrapExecution = async (
101
+ target: ReturnType<typeof createSingletonTrail>
102
+ ) => {
103
+ const result = await executeTrail(target, {});
104
+ return result.unwrap();
105
+ };
106
+
36
107
  // ---------------------------------------------------------------------------
37
108
  // Tests
38
109
  // ---------------------------------------------------------------------------
@@ -45,6 +116,112 @@ describe('executeTrail', () => {
45
116
  expect(result.isOk()).toBe(true);
46
117
  expect(result.unwrap()).toEqual({ value: 'hello' });
47
118
  });
119
+
120
+ test('eagerly resolves declared services before layers and implementation run', async () => {
121
+ const id = nextServiceId('eager');
122
+ const captures = {
123
+ createCalls: 0,
124
+ layerResolved: undefined as number | undefined,
125
+ runResolved: undefined as number | undefined,
126
+ };
127
+ const counter = createResolvedValueService(
128
+ id,
129
+ () => {
130
+ captures.createCalls += 1;
131
+ },
132
+ 41
133
+ );
134
+ const layeredTrail = createEagerServiceTrail(id, counter, (value) => {
135
+ captures.runResolved = value;
136
+ });
137
+ const layer = createServiceProbeLayer(counter, (value) => {
138
+ captures.layerResolved = value;
139
+ });
140
+
141
+ const result = await executeTrail(layeredTrail, {}, { layers: [layer] });
142
+
143
+ expect(result.unwrap()).toEqual({ total: 42 });
144
+ expect(captures.createCalls).toBe(1);
145
+ expect(captures.layerResolved).toBe(41);
146
+ expect(captures.runResolved).toBe(41);
147
+ });
148
+
149
+ test('awaits async service factories before running the trail', async () => {
150
+ const db = service(nextServiceId('async-factory'), {
151
+ create: async () => {
152
+ await Bun.sleep(0);
153
+ return Result.ok({ source: 'async-factory' });
154
+ },
155
+ });
156
+ const serviceTrail = trail('service.async-factory', {
157
+ input: z.object({}),
158
+ output: z.object({ source: z.string() }),
159
+ run: (_input, ctx) =>
160
+ Result.ok({ source: db.from(ctx).source as string }),
161
+ services: [db],
162
+ });
163
+
164
+ const result = await executeTrail(serviceTrail, {});
165
+
166
+ expect(result.isOk()).toBe(true);
167
+ expect(result.unwrap()).toEqual({ source: 'async-factory' });
168
+ });
169
+
170
+ test('reuses cached singleton services across executions', async () => {
171
+ const id = nextServiceId('singleton');
172
+ const captures = { createCalls: 0 };
173
+ const singleton = createSingletonService(id, () => {
174
+ captures.createCalls += 1;
175
+ return captures.createCalls;
176
+ });
177
+ const singletonTrail = createSingletonTrail(singleton);
178
+
179
+ const outputs = [
180
+ await unwrapExecution(singletonTrail),
181
+ await unwrapExecution(singletonTrail),
182
+ ];
183
+
184
+ expect(outputs).toEqual([{ createdAtCall: 1 }, { createdAtCall: 1 }]);
185
+ expect(captures.createCalls).toBe(1);
186
+ });
187
+
188
+ test('scopes cached singleton services to compatible service contexts', async () => {
189
+ const id = nextServiceId('singleton-context');
190
+ const envAwareService = service(id, {
191
+ create: (ctx) => Result.ok({ value: String(ctx.env?.VAL) }),
192
+ });
193
+ const envAwareTrail = trail('service.singleton-context', {
194
+ input: z.object({}),
195
+ output: z.object({ value: z.string() }),
196
+ run: (_input, ctx) =>
197
+ Result.ok({ value: envAwareService.from(ctx).value }),
198
+ services: [envAwareService],
199
+ });
200
+
201
+ const first = await executeTrail(
202
+ envAwareTrail,
203
+ {},
204
+ {
205
+ createContext: () =>
206
+ createTrailContext({
207
+ env: { VAL: 'first' },
208
+ }),
209
+ }
210
+ );
211
+ const second = await executeTrail(
212
+ envAwareTrail,
213
+ {},
214
+ {
215
+ createContext: () =>
216
+ createTrailContext({
217
+ env: { VAL: 'second' },
218
+ }),
219
+ }
220
+ );
221
+
222
+ expect(first.unwrap()).toEqual({ value: 'first' });
223
+ expect(second.unwrap()).toEqual({ value: 'second' });
224
+ });
48
225
  });
49
226
 
50
227
  describe('validation', () => {
@@ -124,7 +301,7 @@ describe('executeTrail', () => {
124
301
  },
125
302
  });
126
303
 
127
- const customCtx: TrailContext = {
304
+ const customCtx: TrailContextInit = {
128
305
  cwd: '/custom',
129
306
  requestId: 'factory-id',
130
307
  signal: new AbortController().signal,
@@ -146,7 +323,7 @@ describe('executeTrail', () => {
146
323
  },
147
324
  });
148
325
 
149
- const baseCtx: TrailContext = {
326
+ const baseCtx: TrailContextInit = {
150
327
  cwd: '/factory',
151
328
  requestId: 'factory-id',
152
329
  signal: new AbortController().signal,
@@ -186,6 +363,71 @@ describe('executeTrail', () => {
186
363
  );
187
364
  expect(captured?.extensions).toEqual({ store: 'db', userId: '123' });
188
365
  });
366
+
367
+ test('rebinds ctx.service after merging extension overrides from createContext', async () => {
368
+ let resolvedSource: string | undefined;
369
+ const db = service(nextServiceId('context-override'), {
370
+ create: () => Result.ok({ source: 'factory' }),
371
+ });
372
+ const serviceTrail = trail('ctx.service.override', {
373
+ input: z.object({}),
374
+ output: z.object({ source: z.string() }),
375
+ run: (_input, ctx) => {
376
+ resolvedSource = ctx.service<{ source: string }>(db).source;
377
+ return Result.ok({ source: resolvedSource });
378
+ },
379
+ services: [db],
380
+ });
381
+
382
+ const result = await executeTrail(
383
+ serviceTrail,
384
+ {},
385
+ {
386
+ createContext: () =>
387
+ createTrailContext({
388
+ extensions: {
389
+ [db.id]: { source: 'factory-context' },
390
+ },
391
+ }),
392
+ ctx: {
393
+ extensions: {
394
+ [db.id]: { source: 'override-context' },
395
+ },
396
+ },
397
+ }
398
+ );
399
+
400
+ expect(result.unwrap()).toEqual({ source: 'override-context' });
401
+ expect(resolvedSource).toBe('override-context');
402
+ });
403
+
404
+ test('context factory seeds the service accessor when omitted', async () => {
405
+ let capturedCtx: TrailContext | undefined;
406
+ const id = nextServiceId('factory-seed');
407
+ const widget = { id: 'widget-1' };
408
+ const widgetTrail = trail('service.factory-seed', {
409
+ input: z.object({}),
410
+ run: (_input, ctx) => {
411
+ capturedCtx = ctx;
412
+ return Result.ok(null);
413
+ },
414
+ services: [],
415
+ });
416
+
417
+ await executeTrail(
418
+ widgetTrail,
419
+ {},
420
+ {
421
+ createContext: () => ({
422
+ extensions: { [id]: widget },
423
+ requestId: 'seeded-service',
424
+ signal: new AbortController().signal,
425
+ }),
426
+ }
427
+ );
428
+
429
+ expect(capturedCtx?.service(id)).toBe(widget);
430
+ });
189
431
  });
190
432
 
191
433
  describe('error handling', () => {
@@ -204,5 +446,78 @@ describe('executeTrail', () => {
204
446
  expect(result.error).toBeInstanceOf(InternalError);
205
447
  expect(result.error.message).toBe('kaboom');
206
448
  });
449
+
450
+ test('short-circuits when a service factory returns Result.err', async () => {
451
+ const failingService = service(nextServiceId('factory-error'), {
452
+ create: () =>
453
+ Result.err(new ValidationError('DATABASE_URL is required')),
454
+ });
455
+ let ran = false;
456
+ const serviceTrail = trail('service.factory-error', {
457
+ input: z.object({}),
458
+ run: () => {
459
+ ran = true;
460
+ return Result.ok(null);
461
+ },
462
+ services: [failingService],
463
+ });
464
+
465
+ const result = await executeTrail(serviceTrail, {});
466
+
467
+ expect(result.isErr()).toBe(true);
468
+ expect(result.error).toBeInstanceOf(ValidationError);
469
+ expect(result.error.message).toBe('DATABASE_URL is required');
470
+ expect(ran).toBe(false);
471
+ });
472
+
473
+ test('wraps thrown service factory exceptions with the service ID in context', async () => {
474
+ const explodingService = service(nextServiceId('factory-throw'), {
475
+ create: () => {
476
+ throw new Error('boom');
477
+ },
478
+ });
479
+ const serviceTrail = trail('service.factory-throw', {
480
+ input: z.object({}),
481
+ run: () => Result.ok(null),
482
+ services: [explodingService],
483
+ });
484
+
485
+ const result = await executeTrail(serviceTrail, {});
486
+
487
+ expect(result.isErr()).toBe(true);
488
+ expect(result.error).toBeInstanceOf(InternalError);
489
+ expect(result.error.message).toContain(explodingService.id);
490
+ expect(result.error.context).toEqual({ serviceId: explodingService.id });
491
+ });
492
+
493
+ test('prefers explicit service overrides over cached or created instances', async () => {
494
+ const id = nextServiceId('override');
495
+ let createCalls = 0;
496
+ const db = service(id, {
497
+ create: () => {
498
+ createCalls += 1;
499
+ return Result.ok({ source: 'factory' });
500
+ },
501
+ });
502
+ const serviceTrail = trail('service.override', {
503
+ input: z.object({}),
504
+ output: z.object({ source: z.string() }),
505
+ run: (_input, ctx) =>
506
+ Result.ok({ source: db.from(ctx).source as string }),
507
+ services: [db],
508
+ });
509
+
510
+ const result = await executeTrail(
511
+ serviceTrail,
512
+ {},
513
+ {
514
+ services: { [id]: { source: 'override' } },
515
+ }
516
+ );
517
+
518
+ expect(result.isOk()).toBe(true);
519
+ expect(result.unwrap()).toEqual({ source: 'override' });
520
+ expect(createCalls).toBe(0);
521
+ });
207
522
  });
208
523
  });
@@ -3,16 +3,17 @@ import { describe, test, expect } from 'bun:test';
3
3
 
4
4
  import { z } from 'zod';
5
5
 
6
+ import { createTrailContext } from '../context';
6
7
  import { composeLayers } from '../layer';
7
8
  import type { Layer } from '../layer';
8
9
  import { Result } from '../result';
9
10
  import { trail } from '../trail';
10
11
  import type { TrailContext } from '../types';
11
12
 
12
- const stubCtx: TrailContext = {
13
+ const stubCtx: TrailContext = createTrailContext({
13
14
  requestId: 'test-layer',
14
15
  signal: AbortSignal.timeout(5000),
15
- };
16
+ });
16
17
 
17
18
  const echoTrail = trail('echo', {
18
19
  input: z.object({ value: z.string() }),
@@ -0,0 +1,224 @@
1
+ /* oxlint-disable require-await -- trail implementations satisfy async interface without awaiting */
2
+ import { describe, expect, test } from 'bun:test';
3
+
4
+ import { z } from 'zod';
5
+
6
+ import { executeTrail } from '../execute.js';
7
+ import { Result } from '../result.js';
8
+ import { service } from '../service.js';
9
+ import { trail } from '../trail.js';
10
+ import type { ServiceContext } from '../service.js';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const nextId = (name: string): string =>
17
+ `test.svc-config.${name}.${Bun.randomUUIDv7()}`;
18
+
19
+ const createSingletonConfigTrail = (id: string) => {
20
+ const captures = { createCalls: 0 };
21
+ const svc = service(id, {
22
+ config: z.object({ key: z.string() }),
23
+ create: (ctx: ServiceContext<{ key: string }>) => {
24
+ captures.createCalls += 1;
25
+ return Result.ok({
26
+ createCall: captures.createCalls,
27
+ key: ctx.config.key,
28
+ });
29
+ },
30
+ });
31
+
32
+ return {
33
+ captures,
34
+ trail: trail('svc-config.singleton', {
35
+ input: z.object({}),
36
+ output: z.object({ createCall: z.number(), key: z.string() }),
37
+ run: (_input, ctx) =>
38
+ Result.ok({
39
+ createCall: svc.from(ctx).createCall,
40
+ key: svc.from(ctx).key,
41
+ }),
42
+ services: [svc],
43
+ }),
44
+ };
45
+ };
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Tests
49
+ // ---------------------------------------------------------------------------
50
+
51
+ describe('ServiceContext.config', () => {
52
+ test('service with config schema receives validated config in svc.config', async () => {
53
+ const id = nextId('typed-config');
54
+ let capturedConfig: unknown;
55
+
56
+ const db = service(id, {
57
+ config: z.object({ poolSize: z.number(), url: z.string().url() }),
58
+ create: (svc: ServiceContext<{ url: string; poolSize: number }>) => {
59
+ capturedConfig = svc.config;
60
+ return Result.ok({ connected: true });
61
+ },
62
+ });
63
+
64
+ const dbTrail = trail('svc-config.typed', {
65
+ input: z.object({}),
66
+ output: z.object({ connected: z.boolean() }),
67
+ run: (_input, ctx) => Result.ok({ connected: db.from(ctx).connected }),
68
+ services: [db],
69
+ });
70
+
71
+ const result = await executeTrail(
72
+ dbTrail,
73
+ {},
74
+ {
75
+ configValues: {
76
+ [id]: { poolSize: 5, url: 'https://example.com' },
77
+ },
78
+ }
79
+ );
80
+
81
+ expect(result.isOk()).toBe(true);
82
+ expect(capturedConfig).toEqual({ poolSize: 5, url: 'https://example.com' });
83
+ });
84
+
85
+ test('service without config still works — svc.config is undefined', async () => {
86
+ const id = nextId('no-config');
87
+ let capturedConfig: unknown = 'sentinel';
88
+
89
+ const counter = service(id, {
90
+ create: (svc) => {
91
+ capturedConfig = svc.config;
92
+ return Result.ok(42);
93
+ },
94
+ });
95
+
96
+ const counterTrail = trail('svc-config.no-config', {
97
+ input: z.object({}),
98
+ run: (_input, ctx) => Result.ok({ value: counter.from(ctx) }),
99
+ services: [counter],
100
+ });
101
+
102
+ const result = await executeTrail(counterTrail, {});
103
+
104
+ expect(result.isOk()).toBe(true);
105
+ expect(capturedConfig).toBeUndefined();
106
+ });
107
+
108
+ test('config validation failure returns Result.err at service creation time', async () => {
109
+ const id = nextId('invalid-config');
110
+
111
+ const db = service(id, {
112
+ config: z.object({ url: z.string().url() }),
113
+ create: () => Result.ok({ connected: true }),
114
+ });
115
+
116
+ const dbTrail = trail('svc-config.invalid', {
117
+ input: z.object({}),
118
+ run: () => Result.ok(null),
119
+ services: [db],
120
+ });
121
+
122
+ const result = await executeTrail(
123
+ dbTrail,
124
+ {},
125
+ {
126
+ configValues: {
127
+ [id]: { url: 'not-a-url' },
128
+ },
129
+ }
130
+ );
131
+
132
+ expect(result.isErr()).toBe(true);
133
+ expect(result.error.message).toContain(id);
134
+ });
135
+
136
+ test('missing configValues for a service with config schema returns Result.err', async () => {
137
+ const id = nextId('missing-config');
138
+
139
+ const db = service(id, {
140
+ config: z.object({ url: z.string().url() }),
141
+ create: () => Result.ok({ connected: true }),
142
+ });
143
+
144
+ const dbTrail = trail('svc-config.missing', {
145
+ input: z.object({}),
146
+ run: () => Result.ok(null),
147
+ services: [db],
148
+ });
149
+
150
+ const result = await executeTrail(dbTrail, {});
151
+
152
+ expect(result.isErr()).toBe(true);
153
+ expect(result.error.message).toContain(id);
154
+ });
155
+
156
+ test('service override bypasses config validation', async () => {
157
+ const id = nextId('override-bypass');
158
+
159
+ const db = service(id, {
160
+ config: z.object({ url: z.string().url() }),
161
+ create: () => Result.ok({ connected: true }),
162
+ });
163
+
164
+ const dbTrail = trail('svc-config.override', {
165
+ input: z.object({}),
166
+ output: z.object({ value: z.number() }),
167
+ run: (_input, ctx) => Result.ok({ value: db.from(ctx) as number }),
168
+ services: [db],
169
+ });
170
+
171
+ // Provide the service via overrides without any configValues — should NOT fail
172
+ const result = await executeTrail(dbTrail, {}, { services: { [id]: 42 } });
173
+
174
+ expect(result.isOk()).toBe(true);
175
+ expect(result.unwrap()).toEqual({ value: 42 });
176
+ });
177
+ test('config values passed through ExecuteTrailOptions.configValues', async () => {
178
+ const id = nextId('options-config');
179
+ const captures: unknown[] = [];
180
+
181
+ const svc = service(id, {
182
+ config: z.object({ key: z.string() }),
183
+ create: (ctx: ServiceContext<{ key: string }>) => {
184
+ captures.push(ctx.config);
185
+ return Result.ok({ key: ctx.config.key });
186
+ },
187
+ });
188
+
189
+ const svcTrail = trail('svc-config.options', {
190
+ input: z.object({}),
191
+ output: z.object({ key: z.string() }),
192
+ run: (_input, ctx) => Result.ok({ key: svc.from(ctx).key }),
193
+ services: [svc],
194
+ });
195
+
196
+ const result = await executeTrail(
197
+ svcTrail,
198
+ {},
199
+ {
200
+ configValues: { [id]: { key: 'hello' } },
201
+ }
202
+ );
203
+
204
+ expect(result.isOk()).toBe(true);
205
+ expect(result.unwrap()).toEqual({ key: 'hello' });
206
+ expect(captures).toEqual([{ key: 'hello' }]);
207
+ });
208
+
209
+ test('config-aware singleton services reuse the cached instance', async () => {
210
+ const id = nextId('singleton-config');
211
+ const { captures, trail: singletonTrail } = createSingletonConfigTrail(id);
212
+ const options = {
213
+ configValues: { [id]: { key: 'hello' } },
214
+ };
215
+ const first = await executeTrail(singletonTrail, {}, options);
216
+ const second = await executeTrail(singletonTrail, {}, options);
217
+
218
+ expect(first.isOk()).toBe(true);
219
+ expect(second.isOk()).toBe(true);
220
+ expect(first.unwrap()).toEqual({ createCall: 1, key: 'hello' });
221
+ expect(second.unwrap()).toEqual({ createCall: 1, key: 'hello' });
222
+ expect(captures.createCalls).toBe(1);
223
+ });
224
+ });