@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.
Files changed (99) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +121 -0
  3. package/README.md +54 -11
  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/derive.d.ts +1 -1
  9. package/dist/derive.d.ts.map +1 -1
  10. package/dist/derive.js +4 -1
  11. package/dist/derive.js.map +1 -1
  12. package/dist/dispatch.d.ts +27 -0
  13. package/dist/dispatch.d.ts.map +1 -0
  14. package/dist/dispatch.js +34 -0
  15. package/dist/dispatch.js.map +1 -0
  16. package/dist/event.d.ts +2 -2
  17. package/dist/event.d.ts.map +1 -1
  18. package/dist/event.js +1 -1
  19. package/dist/event.js.map +1 -1
  20. package/dist/execute.d.ts +33 -0
  21. package/dist/execute.d.ts.map +1 -0
  22. package/dist/execute.js +207 -0
  23. package/dist/execute.js.map +1 -0
  24. package/dist/index.d.ts +12 -6
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +9 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/patterns/status.d.ts +1 -1
  29. package/dist/result.d.ts.map +1 -1
  30. package/dist/result.js +15 -4
  31. package/dist/result.js.map +1 -1
  32. package/dist/serialization.d.ts.map +1 -1
  33. package/dist/serialization.js +45 -7
  34. package/dist/serialization.js.map +1 -1
  35. package/dist/service.d.ts +69 -0
  36. package/dist/service.d.ts.map +1 -0
  37. package/dist/service.js +56 -0
  38. package/dist/service.js.map +1 -0
  39. package/dist/topo.d.ts +11 -4
  40. package/dist/topo.d.ts.map +1 -1
  41. package/dist/topo.js +43 -18
  42. package/dist/topo.js.map +1 -1
  43. package/dist/trail.d.ts +21 -10
  44. package/dist/trail.d.ts.map +1 -1
  45. package/dist/trail.js +5 -2
  46. package/dist/trail.js.map +1 -1
  47. package/dist/type-utils.d.ts +24 -0
  48. package/dist/type-utils.d.ts.map +1 -0
  49. package/dist/type-utils.js +12 -0
  50. package/dist/type-utils.js.map +1 -0
  51. package/dist/types.d.ts +10 -2
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/validate-topo.d.ts +2 -2
  54. package/dist/validate-topo.d.ts.map +1 -1
  55. package/dist/validate-topo.js +75 -9
  56. package/dist/validate-topo.js.map +1 -1
  57. package/dist/validation.d.ts.map +1 -1
  58. package/dist/validation.js +34 -3
  59. package/dist/validation.js.map +1 -1
  60. package/package.json +2 -2
  61. package/src/__tests__/context.test.ts +16 -5
  62. package/src/__tests__/derive.test.ts +44 -0
  63. package/src/__tests__/dispatch.test.ts +181 -0
  64. package/src/__tests__/event.test.ts +5 -5
  65. package/src/__tests__/execute.test.ts +523 -0
  66. package/src/__tests__/layer.test.ts +14 -113
  67. package/src/__tests__/serialization.test.ts +166 -1
  68. package/src/__tests__/service.test.ts +197 -0
  69. package/src/__tests__/topo.test.ts +171 -78
  70. package/src/__tests__/trail.test.ts +119 -37
  71. package/src/__tests__/type-utils.test.ts +90 -0
  72. package/src/__tests__/validate-topo.test.ts +140 -19
  73. package/src/__tests__/validation.test.ts +53 -0
  74. package/src/context.ts +18 -9
  75. package/src/derive.ts +12 -2
  76. package/src/dispatch.ts +54 -0
  77. package/src/event.ts +3 -3
  78. package/src/execute.ts +345 -0
  79. package/src/index.ts +39 -18
  80. package/src/result.ts +18 -4
  81. package/src/serialization.ts +56 -11
  82. package/src/service.ts +139 -0
  83. package/src/topo.ts +66 -27
  84. package/src/trail.ts +36 -13
  85. package/src/type-utils.ts +45 -0
  86. package/src/types.ts +11 -2
  87. package/src/validate-topo.ts +92 -10
  88. package/src/validation.ts +35 -3
  89. package/tsconfig.tsbuildinfo +1 -1
  90. package/dist/hike.d.ts +0 -36
  91. package/dist/hike.d.ts.map +0 -1
  92. package/dist/hike.js +0 -20
  93. package/dist/hike.js.map +0 -1
  94. package/src/__tests__/hike.test.ts +0 -117
  95. package/src/__tests__/job.test.ts +0 -98
  96. package/src/adapters.ts +0 -68
  97. package/src/health.ts +0 -23
  98. package/src/hike.ts +0 -77
  99. 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 -- adapter mocks and layer wrappers satisfy async interfaces without awaiting */
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 type {
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
- markers: { domain: 'test' },
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 markers', () => {
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.markers?.['domain'];
111
+ capturedDomain = t.metadata?.['domain'];
132
112
  return impl;
133
113
  },
134
114
  };
135
115
 
136
- composeLayers([inspectLayer], echoTrail, echoTrail.implementation);
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.implementation);
143
- expect(wrapped).toBe(echoTrail.implementation);
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
  });