@ontrails/core 1.0.0-beta.10 → 1.0.0-beta.11
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/.turbo/turbo-lint.log +1 -1
- package/CHANGELOG.md +20 -0
- package/README.md +1 -0
- package/dist/context.d.ts +2 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +12 -7
- package/dist/context.js.map +1 -1
- package/dist/execute.d.ts +6 -3
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +149 -6
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/service.d.ts +69 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +56 -0
- package/dist/service.js.map +1 -0
- package/dist/topo.d.ts +7 -0
- package/dist/topo.d.ts.map +1 -1
- package/dist/topo.js +37 -8
- package/dist/topo.js.map +1 -1
- package/dist/trail.d.ts +6 -1
- package/dist/trail.d.ts.map +1 -1
- package/dist/trail.js +2 -1
- package/dist/trail.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-topo.d.ts.map +1 -1
- package/dist/validate-topo.js +16 -0
- package/dist/validate-topo.js.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +34 -3
- package/dist/validation.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/context.test.ts +12 -0
- package/src/__tests__/dispatch.test.ts +29 -2
- package/src/__tests__/execute.test.ts +318 -3
- package/src/__tests__/layer.test.ts +3 -2
- package/src/__tests__/service.test.ts +197 -0
- package/src/__tests__/topo.test.ts +71 -0
- package/src/__tests__/trail.test.ts +46 -2
- package/src/__tests__/validate-topo.test.ts +45 -1
- package/src/__tests__/validation.test.ts +53 -0
- package/src/context.ts +18 -9
- package/src/execute.ts +258 -9
- package/src/index.ts +17 -0
- package/src/service.ts +139 -0
- package/src/topo.ts +53 -9
- package/src/trail.ts +14 -2
- package/src/types.ts +11 -0
- package/src/validate-topo.ts +22 -0
- package/src/validation.ts +35 -3
- 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:
|
|
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:
|
|
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,197 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
Result,
|
|
6
|
+
createTrailContext,
|
|
7
|
+
findDuplicateServiceId,
|
|
8
|
+
isService,
|
|
9
|
+
service as defineService,
|
|
10
|
+
} from '../index.js';
|
|
11
|
+
import type {
|
|
12
|
+
Service,
|
|
13
|
+
ServiceContext,
|
|
14
|
+
ServiceSpec,
|
|
15
|
+
TrailContext,
|
|
16
|
+
} from '../index.js';
|
|
17
|
+
|
|
18
|
+
const serviceCtx: ServiceContext = {
|
|
19
|
+
cwd: '/tmp/trails',
|
|
20
|
+
env: { DATABASE_URL: 'file::memory:' },
|
|
21
|
+
workspaceRoot: '/tmp',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let disposedValue: number | undefined;
|
|
25
|
+
|
|
26
|
+
const counterServiceSpec: ServiceSpec<number> = {
|
|
27
|
+
create: () => Result.ok(3),
|
|
28
|
+
description: 'Counter service',
|
|
29
|
+
dispose: (service) => {
|
|
30
|
+
disposedValue = service;
|
|
31
|
+
},
|
|
32
|
+
health: (service) => Result.ok({ healthy: service > 0 }),
|
|
33
|
+
metadata: { domain: 'data' },
|
|
34
|
+
mock: () => 1,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const resolvedServiceCtx = (id: string, instance: unknown): TrailContext =>
|
|
38
|
+
createTrailContext({
|
|
39
|
+
extensions: { [id]: instance },
|
|
40
|
+
requestId: `${id}-request`,
|
|
41
|
+
signal: new AbortController().signal,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('service types', () => {
|
|
45
|
+
test('ServiceContext exposes the stable process-scoped fields', () => {
|
|
46
|
+
expect(serviceCtx.cwd).toBe('/tmp/trails');
|
|
47
|
+
expect(serviceCtx.env?.DATABASE_URL).toBe('file::memory:');
|
|
48
|
+
expect(serviceCtx.workspaceRoot).toBe('/tmp');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('ServiceSpec stores description and metadata', () => {
|
|
52
|
+
expect(counterServiceSpec.description).toBe('Counter service');
|
|
53
|
+
expect(counterServiceSpec.metadata).toEqual({ domain: 'data' });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('ServiceSpec can reserve a config schema for future composition', () => {
|
|
57
|
+
const config = z.object({ url: z.string().url() });
|
|
58
|
+
const spec: ServiceSpec<number> = {
|
|
59
|
+
config,
|
|
60
|
+
create: () => Result.ok(1),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
expect(spec.config).toBe(config);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('ServiceSpec create and health callbacks are callable', async () => {
|
|
67
|
+
const result = await counterServiceSpec.create(serviceCtx);
|
|
68
|
+
expect(result.isOk()).toBe(true);
|
|
69
|
+
expect(result.unwrap()).toBe(3);
|
|
70
|
+
|
|
71
|
+
const health = await counterServiceSpec.health?.(result.unwrap());
|
|
72
|
+
expect(health?.isOk()).toBe(true);
|
|
73
|
+
expect(health?.unwrap()).toEqual({ healthy: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('ServiceSpec mock and dispose callbacks are callable', async () => {
|
|
77
|
+
disposedValue = undefined;
|
|
78
|
+
const result = await counterServiceSpec.create(serviceCtx);
|
|
79
|
+
expect(result.isOk()).toBe(true);
|
|
80
|
+
|
|
81
|
+
const service = result.unwrap();
|
|
82
|
+
expect(await counterServiceSpec.mock?.()).toBe(1);
|
|
83
|
+
|
|
84
|
+
await counterServiceSpec.dispose?.(service);
|
|
85
|
+
expect(disposedValue).toBe(3);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('ServiceSpec create can be async', async () => {
|
|
89
|
+
const spec: ServiceSpec<number> = {
|
|
90
|
+
create: async () => {
|
|
91
|
+
await Bun.sleep(0);
|
|
92
|
+
return Result.ok(7);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const result = await spec.create(serviceCtx);
|
|
97
|
+
expect(result.isOk()).toBe(true);
|
|
98
|
+
expect(result.unwrap()).toBe(7);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('Service carries identity alongside the shared spec fields', async () => {
|
|
102
|
+
const service: Service<number> = {
|
|
103
|
+
from(ctx) {
|
|
104
|
+
return ctx.service(this);
|
|
105
|
+
},
|
|
106
|
+
id: 'counter.main',
|
|
107
|
+
kind: 'service',
|
|
108
|
+
...counterServiceSpec,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
expect(service.kind).toBe('service');
|
|
112
|
+
expect(service.id).toBe('counter.main');
|
|
113
|
+
|
|
114
|
+
const created = await service.create(serviceCtx);
|
|
115
|
+
expect(created.isOk()).toBe(true);
|
|
116
|
+
expect(created.unwrap()).toBe(3);
|
|
117
|
+
expect(await service.mock?.()).toBe(1);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('service()', () => {
|
|
122
|
+
test('returns a frozen service object with kind and id', () => {
|
|
123
|
+
const counter = defineService('counter.main', counterServiceSpec);
|
|
124
|
+
|
|
125
|
+
expect(counter.kind).toBe('service');
|
|
126
|
+
expect(counter.id).toBe('counter.main');
|
|
127
|
+
expect(Object.isFrozen(counter)).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('infers the service type through from(ctx)', () => {
|
|
131
|
+
const db = defineService('db.main', {
|
|
132
|
+
create: () =>
|
|
133
|
+
Result.ok({
|
|
134
|
+
query(sql: string) {
|
|
135
|
+
return sql.length;
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
description: 'Typed database service',
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const store = {
|
|
142
|
+
query(sql: string) {
|
|
143
|
+
return sql.length;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
const ctx = resolvedServiceCtx('db.main', store);
|
|
147
|
+
|
|
148
|
+
const resolved = db.from(ctx);
|
|
149
|
+
expect(resolved.query('select 1')).toBe(8);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('from(ctx) throws when the service is missing', () => {
|
|
153
|
+
const counter = defineService('counter.main', counterServiceSpec);
|
|
154
|
+
const ctx = createTrailContext({
|
|
155
|
+
requestId: 'missing-service',
|
|
156
|
+
signal: new AbortController().signal,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(() => counter.from(ctx)).toThrow(
|
|
160
|
+
'Service "counter.main" not found in trail context'
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('from(ctx) returns undefined when the service key exists with an undefined value', () => {
|
|
165
|
+
const optional = defineService<undefined>('optional.main', {
|
|
166
|
+
create: () => Result.ok<undefined>(),
|
|
167
|
+
});
|
|
168
|
+
const ctx = createTrailContext({
|
|
169
|
+
extensions: { [optional.id]: undefined },
|
|
170
|
+
requestId: 'undefined-service',
|
|
171
|
+
signal: new AbortController().signal,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(optional.from(ctx)).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('service helpers', () => {
|
|
179
|
+
test('isService identifies service definitions', () => {
|
|
180
|
+
const counter = defineService('counter.main', counterServiceSpec);
|
|
181
|
+
|
|
182
|
+
expect(isService(counter)).toBe(true);
|
|
183
|
+
expect(isService({ id: 'counter.main', kind: 'trail' })).toBe(false);
|
|
184
|
+
expect(isService(null)).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('findDuplicateServiceId returns the first repeated ID', () => {
|
|
188
|
+
const first = defineService('counter.main', counterServiceSpec);
|
|
189
|
+
const duplicate = defineService('counter.main', counterServiceSpec);
|
|
190
|
+
const other = defineService('counter.secondary', counterServiceSpec);
|
|
191
|
+
|
|
192
|
+
expect(findDuplicateServiceId([first, other])).toBeUndefined();
|
|
193
|
+
expect(findDuplicateServiceId([first, other, duplicate])).toBe(
|
|
194
|
+
'counter.main'
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
});
|