@ontrails/core 1.0.0-beta.1 → 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 +121 -0
- package/README.md +54 -11
- 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/derive.d.ts +1 -1
- package/dist/derive.d.ts.map +1 -1
- package/dist/derive.js +4 -1
- package/dist/derive.js.map +1 -1
- package/dist/dispatch.d.ts +27 -0
- package/dist/dispatch.d.ts.map +1 -0
- package/dist/dispatch.js +34 -0
- package/dist/dispatch.js.map +1 -0
- package/dist/event.d.ts +2 -2
- package/dist/event.d.ts.map +1 -1
- package/dist/event.js +1 -1
- package/dist/event.js.map +1 -1
- package/dist/execute.d.ts +33 -0
- package/dist/execute.d.ts.map +1 -0
- package/dist/execute.js +207 -0
- package/dist/execute.js.map +1 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/patterns/status.d.ts +1 -1
- package/dist/result.d.ts.map +1 -1
- package/dist/result.js +15 -4
- package/dist/result.js.map +1 -1
- package/dist/serialization.d.ts.map +1 -1
- package/dist/serialization.js +45 -7
- package/dist/serialization.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 +11 -4
- package/dist/topo.d.ts.map +1 -1
- package/dist/topo.js +43 -18
- package/dist/topo.js.map +1 -1
- package/dist/trail.d.ts +21 -10
- package/dist/trail.d.ts.map +1 -1
- package/dist/trail.js +5 -2
- package/dist/trail.js.map +1 -1
- package/dist/type-utils.d.ts +24 -0
- package/dist/type-utils.d.ts.map +1 -0
- package/dist/type-utils.js +12 -0
- package/dist/type-utils.js.map +1 -0
- package/dist/types.d.ts +10 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-topo.d.ts +2 -2
- package/dist/validate-topo.d.ts.map +1 -1
- package/dist/validate-topo.js +75 -9
- 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 +2 -2
- package/src/__tests__/context.test.ts +16 -5
- package/src/__tests__/derive.test.ts +44 -0
- package/src/__tests__/dispatch.test.ts +181 -0
- package/src/__tests__/event.test.ts +5 -5
- package/src/__tests__/execute.test.ts +523 -0
- package/src/__tests__/layer.test.ts +14 -113
- package/src/__tests__/serialization.test.ts +166 -1
- package/src/__tests__/service.test.ts +197 -0
- package/src/__tests__/topo.test.ts +171 -78
- package/src/__tests__/trail.test.ts +119 -37
- package/src/__tests__/type-utils.test.ts +90 -0
- package/src/__tests__/validate-topo.test.ts +140 -19
- package/src/__tests__/validation.test.ts +53 -0
- package/src/context.ts +18 -9
- package/src/derive.ts +12 -2
- package/src/dispatch.ts +54 -0
- package/src/event.ts +3 -3
- package/src/execute.ts +345 -0
- package/src/index.ts +39 -18
- package/src/result.ts +18 -4
- package/src/serialization.ts +56 -11
- package/src/service.ts +139 -0
- package/src/topo.ts +66 -27
- package/src/trail.ts +36 -13
- package/src/type-utils.ts +45 -0
- package/src/types.ts +11 -2
- package/src/validate-topo.ts +92 -10
- package/src/validation.ts +35 -3
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/hike.d.ts +0 -36
- package/dist/hike.d.ts.map +0 -1
- package/dist/hike.js +0 -20
- package/dist/hike.js.map +0 -1
- package/src/__tests__/hike.test.ts +0 -117
- package/src/__tests__/job.test.ts +0 -98
- package/src/adapters.ts +0 -68
- package/src/health.ts +0 -23
- package/src/hike.ts +0 -77
- package/src/job.ts +0 -20
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
/* oxlint-disable require-await -- trail implementations satisfy async interface without awaiting */
|
|
2
|
+
import { describe, test, expect } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
import { InternalError, ValidationError } from '../errors';
|
|
7
|
+
import { executeTrail } from '../execute';
|
|
8
|
+
import { createTrailContext } from '../context';
|
|
9
|
+
import type { Layer } from '../layer';
|
|
10
|
+
import { Result } from '../result';
|
|
11
|
+
import { service } from '../service';
|
|
12
|
+
import { trail } from '../trail';
|
|
13
|
+
import type { TrailContext, TrailContextInit } from '../types';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Fixtures
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const echoTrail = trail('echo', {
|
|
20
|
+
input: z.object({ value: z.string() }),
|
|
21
|
+
output: z.object({ value: z.string() }),
|
|
22
|
+
run: (input) => Result.ok({ value: input.value }),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const failingTrail = trail('fails', {
|
|
26
|
+
input: z.object({}),
|
|
27
|
+
run: () => Result.err(new ValidationError('bad input')),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const throwingTrail = trail('throws', {
|
|
31
|
+
input: z.object({}),
|
|
32
|
+
run: () => {
|
|
33
|
+
throw new Error('kaboom');
|
|
34
|
+
},
|
|
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
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Tests
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
describe('executeTrail', () => {
|
|
112
|
+
describe('happy path', () => {
|
|
113
|
+
test('validates input and executes trail', async () => {
|
|
114
|
+
const result = await executeTrail(echoTrail, { value: 'hello' });
|
|
115
|
+
|
|
116
|
+
expect(result.isOk()).toBe(true);
|
|
117
|
+
expect(result.unwrap()).toEqual({ value: 'hello' });
|
|
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
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('validation', () => {
|
|
228
|
+
test('returns validation error for invalid input', async () => {
|
|
229
|
+
const result = await executeTrail(echoTrail, { value: 42 });
|
|
230
|
+
|
|
231
|
+
expect(result.isErr()).toBe(true);
|
|
232
|
+
expect(result.error).toBeInstanceOf(ValidationError);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('layers', () => {
|
|
237
|
+
test('composes layers around execution', async () => {
|
|
238
|
+
const log: string[] = [];
|
|
239
|
+
const layer: Layer = {
|
|
240
|
+
name: 'test-layer',
|
|
241
|
+
wrap(_trail, impl) {
|
|
242
|
+
return async (input, ctx) => {
|
|
243
|
+
log.push('before');
|
|
244
|
+
const r = await impl(input, ctx);
|
|
245
|
+
log.push('after');
|
|
246
|
+
return r;
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const result = await executeTrail(
|
|
252
|
+
echoTrail,
|
|
253
|
+
{ value: 'x' },
|
|
254
|
+
{ layers: [layer] }
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(result.isOk()).toBe(true);
|
|
258
|
+
expect(log).toEqual(['before', 'after']);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('context', () => {
|
|
263
|
+
test('accepts context overrides', async () => {
|
|
264
|
+
let capturedCtx: TrailContext | undefined;
|
|
265
|
+
const ctxTrail = trail('ctx-test', {
|
|
266
|
+
input: z.object({}),
|
|
267
|
+
run: (_input, ctx) => {
|
|
268
|
+
capturedCtx = ctx;
|
|
269
|
+
return Result.ok(null);
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await executeTrail(ctxTrail, {}, { ctx: { requestId: 'override-id' } });
|
|
274
|
+
|
|
275
|
+
expect(capturedCtx?.requestId).toBe('override-id');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('accepts signal override', async () => {
|
|
279
|
+
let capturedSignal: AbortSignal | undefined;
|
|
280
|
+
const sigTrail = trail('sig-test', {
|
|
281
|
+
input: z.object({}),
|
|
282
|
+
run: (_input, ctx) => {
|
|
283
|
+
capturedSignal = ctx.signal;
|
|
284
|
+
return Result.ok(null);
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const signal = AbortSignal.timeout(9999);
|
|
289
|
+
await executeTrail(sigTrail, {}, { signal });
|
|
290
|
+
|
|
291
|
+
expect(capturedSignal).toBe(signal);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('accepts context factory', async () => {
|
|
295
|
+
let capturedCtx: TrailContext | undefined;
|
|
296
|
+
const ctxTrail = trail('factory-test', {
|
|
297
|
+
input: z.object({}),
|
|
298
|
+
run: (_input, ctx) => {
|
|
299
|
+
capturedCtx = ctx;
|
|
300
|
+
return Result.ok(null);
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const customCtx: TrailContextInit = {
|
|
305
|
+
cwd: '/custom',
|
|
306
|
+
requestId: 'factory-id',
|
|
307
|
+
signal: new AbortController().signal,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
await executeTrail(ctxTrail, {}, { createContext: () => customCtx });
|
|
311
|
+
|
|
312
|
+
expect(capturedCtx?.requestId).toBe('factory-id');
|
|
313
|
+
expect(capturedCtx?.cwd).toBe('/custom');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('context factory + ctx overrides merge correctly', async () => {
|
|
317
|
+
let capturedCtx: TrailContext | undefined;
|
|
318
|
+
const ctxTrail = trail('merge-test', {
|
|
319
|
+
input: z.object({}),
|
|
320
|
+
run: (_input, ctx) => {
|
|
321
|
+
capturedCtx = ctx;
|
|
322
|
+
return Result.ok(null);
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const baseCtx: TrailContextInit = {
|
|
327
|
+
cwd: '/factory',
|
|
328
|
+
requestId: 'factory-id',
|
|
329
|
+
signal: new AbortController().signal,
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
await executeTrail(
|
|
333
|
+
ctxTrail,
|
|
334
|
+
{},
|
|
335
|
+
{
|
|
336
|
+
createContext: () => baseCtx,
|
|
337
|
+
ctx: { requestId: 'overridden-id' },
|
|
338
|
+
}
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
expect(capturedCtx?.requestId).toBe('overridden-id');
|
|
342
|
+
expect(capturedCtx?.cwd).toBe('/factory');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('deep-merges extensions from factory and overrides', async () => {
|
|
346
|
+
let captured: TrailContext | undefined;
|
|
347
|
+
const t = trail('ext.test', {
|
|
348
|
+
input: z.object({}),
|
|
349
|
+
output: z.object({}),
|
|
350
|
+
run: (_input, ctx) => {
|
|
351
|
+
captured = ctx;
|
|
352
|
+
return Result.ok({});
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
await executeTrail(
|
|
356
|
+
t,
|
|
357
|
+
{},
|
|
358
|
+
{
|
|
359
|
+
createContext: () =>
|
|
360
|
+
createTrailContext({ extensions: { store: 'db' } }),
|
|
361
|
+
ctx: { extensions: { userId: '123' } },
|
|
362
|
+
}
|
|
363
|
+
);
|
|
364
|
+
expect(captured?.extensions).toEqual({ store: 'db', userId: '123' });
|
|
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
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
describe('error handling', () => {
|
|
434
|
+
test('propagates Result.err from run function', async () => {
|
|
435
|
+
const result = await executeTrail(failingTrail, {});
|
|
436
|
+
|
|
437
|
+
expect(result.isErr()).toBe(true);
|
|
438
|
+
expect(result.error).toBeInstanceOf(ValidationError);
|
|
439
|
+
expect(result.error.message).toBe('bad input');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test('catches thrown exceptions and returns InternalError', async () => {
|
|
443
|
+
const result = await executeTrail(throwingTrail, {});
|
|
444
|
+
|
|
445
|
+
expect(result.isErr()).toBe(true);
|
|
446
|
+
expect(result.error).toBeInstanceOf(InternalError);
|
|
447
|
+
expect(result.error.message).toBe('kaboom');
|
|
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
|
+
});
|
|
522
|
+
});
|
|
523
|
+
});
|
|
@@ -1,33 +1,25 @@
|
|
|
1
|
-
/* oxlint-disable require-await --
|
|
1
|
+
/* oxlint-disable require-await -- layer wrappers satisfy async interfaces without awaiting */
|
|
2
2
|
import { describe, test, expect } from 'bun:test';
|
|
3
3
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
|
|
6
|
-
import
|
|
7
|
-
IndexAdapter,
|
|
8
|
-
StorageAdapter,
|
|
9
|
-
CacheAdapter,
|
|
10
|
-
SearchOptions,
|
|
11
|
-
SearchResult,
|
|
12
|
-
StorageOptions,
|
|
13
|
-
} from '../adapters';
|
|
14
|
-
import type { HealthStatus, HealthResult, HealthCheck } from '../health';
|
|
6
|
+
import { createTrailContext } from '../context';
|
|
15
7
|
import { composeLayers } from '../layer';
|
|
16
8
|
import type { Layer } from '../layer';
|
|
17
9
|
import { Result } from '../result';
|
|
18
10
|
import { trail } from '../trail';
|
|
19
11
|
import type { TrailContext } from '../types';
|
|
20
12
|
|
|
21
|
-
const stubCtx: TrailContext = {
|
|
13
|
+
const stubCtx: TrailContext = createTrailContext({
|
|
22
14
|
requestId: 'test-layer',
|
|
23
15
|
signal: AbortSignal.timeout(5000),
|
|
24
|
-
};
|
|
16
|
+
});
|
|
25
17
|
|
|
26
18
|
const echoTrail = trail('echo', {
|
|
27
|
-
implementation: (input) => Result.ok({ value: input.value }),
|
|
28
19
|
input: z.object({ value: z.string() }),
|
|
29
|
-
|
|
20
|
+
metadata: { domain: 'test' },
|
|
30
21
|
output: z.object({ value: z.string() }),
|
|
22
|
+
run: (input) => Result.ok({ value: input.value }),
|
|
31
23
|
});
|
|
32
24
|
|
|
33
25
|
// ---------------------------------------------------------------------------
|
|
@@ -49,11 +41,7 @@ describe('Layer', () => {
|
|
|
49
41
|
},
|
|
50
42
|
};
|
|
51
43
|
|
|
52
|
-
const wrapped = composeLayers(
|
|
53
|
-
[prefixLayer],
|
|
54
|
-
echoTrail,
|
|
55
|
-
echoTrail.implementation
|
|
56
|
-
);
|
|
44
|
+
const wrapped = composeLayers([prefixLayer], echoTrail, echoTrail.run);
|
|
57
45
|
const result = await wrapped({ value: 'hello' }, stubCtx);
|
|
58
46
|
|
|
59
47
|
expect(result.isOk()).toBe(true);
|
|
@@ -87,11 +75,7 @@ describe('Layer', () => {
|
|
|
87
75
|
},
|
|
88
76
|
};
|
|
89
77
|
|
|
90
|
-
const wrapped = composeLayers(
|
|
91
|
-
[outer, inner],
|
|
92
|
-
echoTrail,
|
|
93
|
-
echoTrail.implementation
|
|
94
|
-
);
|
|
78
|
+
const wrapped = composeLayers([outer, inner], echoTrail, echoTrail.run);
|
|
95
79
|
await wrapped({ value: 'x' }, stubCtx);
|
|
96
80
|
|
|
97
81
|
expect(log).toEqual([
|
|
@@ -110,11 +94,7 @@ describe('Layer', () => {
|
|
|
110
94
|
},
|
|
111
95
|
};
|
|
112
96
|
|
|
113
|
-
const wrapped = composeLayers(
|
|
114
|
-
[shortCircuit],
|
|
115
|
-
echoTrail,
|
|
116
|
-
echoTrail.implementation
|
|
117
|
-
);
|
|
97
|
+
const wrapped = composeLayers([shortCircuit], echoTrail, echoTrail.run);
|
|
118
98
|
const result = await wrapped({ value: 'hello' }, stubCtx);
|
|
119
99
|
|
|
120
100
|
expect(result.isErr()).toBe(true);
|
|
@@ -122,103 +102,24 @@ describe('Layer', () => {
|
|
|
122
102
|
expect(err.error.message).toBe('blocked');
|
|
123
103
|
});
|
|
124
104
|
|
|
125
|
-
test('layer can inspect trail
|
|
105
|
+
test('layer can inspect trail metadata', () => {
|
|
126
106
|
let capturedDomain: unknown;
|
|
127
107
|
|
|
128
108
|
const inspectLayer: Layer = {
|
|
129
109
|
name: 'inspect',
|
|
130
110
|
wrap(t, impl) {
|
|
131
|
-
capturedDomain = t.
|
|
111
|
+
capturedDomain = t.metadata?.['domain'];
|
|
132
112
|
return impl;
|
|
133
113
|
},
|
|
134
114
|
};
|
|
135
115
|
|
|
136
|
-
composeLayers([inspectLayer], echoTrail, echoTrail.
|
|
116
|
+
composeLayers([inspectLayer], echoTrail, echoTrail.run);
|
|
137
117
|
|
|
138
118
|
expect(capturedDomain).toBe('test');
|
|
139
119
|
});
|
|
140
120
|
|
|
141
121
|
test('empty layers array returns implementation unchanged', () => {
|
|
142
|
-
const wrapped = composeLayers([], echoTrail, echoTrail.
|
|
143
|
-
expect(wrapped).toBe(echoTrail.
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
// ---------------------------------------------------------------------------
|
|
148
|
-
// Health types (compile-time verification)
|
|
149
|
-
// ---------------------------------------------------------------------------
|
|
150
|
-
|
|
151
|
-
describe('health types', () => {
|
|
152
|
-
test('HealthResult satisfies the interface', () => {
|
|
153
|
-
const check: HealthCheck = {
|
|
154
|
-
latency: 12,
|
|
155
|
-
message: 'ok',
|
|
156
|
-
status: 'healthy',
|
|
157
|
-
};
|
|
158
|
-
const result: HealthResult = {
|
|
159
|
-
checks: { db: check },
|
|
160
|
-
status: 'healthy',
|
|
161
|
-
uptime: 3600,
|
|
162
|
-
version: '1.0.0',
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
expect(result.status).toBe('healthy');
|
|
166
|
-
expect(result.checks['db']?.status).toBe('healthy');
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test('HealthStatus union is exhaustive', () => {
|
|
170
|
-
const statuses: HealthStatus[] = ['healthy', 'degraded', 'unhealthy'];
|
|
171
|
-
expect(statuses).toHaveLength(3);
|
|
172
|
-
});
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
// ---------------------------------------------------------------------------
|
|
176
|
-
// Adapter types (compile-time verification)
|
|
177
|
-
// ---------------------------------------------------------------------------
|
|
178
|
-
|
|
179
|
-
describe('adapter types', () => {
|
|
180
|
-
test('IndexAdapter mock satisfies the interface', () => {
|
|
181
|
-
const mock: IndexAdapter = {
|
|
182
|
-
index: async () => Result.ok(),
|
|
183
|
-
remove: async () => Result.ok(),
|
|
184
|
-
search: async () => Result.ok([]),
|
|
185
|
-
};
|
|
186
|
-
expect(mock.index).toBeDefined();
|
|
187
|
-
expect(mock.search).toBeDefined();
|
|
188
|
-
expect(mock.remove).toBeDefined();
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
test('StorageAdapter mock satisfies the interface', () => {
|
|
192
|
-
const mock: StorageAdapter = {
|
|
193
|
-
delete: async () => Result.ok(),
|
|
194
|
-
get: async () => Result.ok('value'),
|
|
195
|
-
has: async () => Result.ok(true),
|
|
196
|
-
set: async () => Result.ok(),
|
|
197
|
-
};
|
|
198
|
-
expect(mock.has).toBeDefined();
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
test('CacheAdapter mock satisfies the interface', () => {
|
|
202
|
-
const mock: CacheAdapter = {
|
|
203
|
-
clear: async () => Result.ok(),
|
|
204
|
-
delete: async () => Result.ok(),
|
|
205
|
-
get: async () => Result.ok(),
|
|
206
|
-
set: async () => Result.ok(),
|
|
207
|
-
};
|
|
208
|
-
expect(mock.clear).toBeDefined();
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
test('SearchOptions and SearchResult satisfy their shapes', () => {
|
|
212
|
-
const opts: SearchOptions = { filters: { tag: 'a' }, limit: 10, offset: 0 };
|
|
213
|
-
const hit: SearchResult = {
|
|
214
|
-
document: { title: 'x' },
|
|
215
|
-
id: '1',
|
|
216
|
-
score: 0.95,
|
|
217
|
-
};
|
|
218
|
-
const sopts: StorageOptions = { ttl: 5000 };
|
|
219
|
-
|
|
220
|
-
expect(opts.limit).toBe(10);
|
|
221
|
-
expect(hit.score).toBe(0.95);
|
|
222
|
-
expect(sopts.ttl).toBe(5000);
|
|
122
|
+
const wrapped = composeLayers([], echoTrail, echoTrail.run);
|
|
123
|
+
expect(wrapped).toBe(echoTrail.run);
|
|
223
124
|
});
|
|
224
125
|
});
|