@ontrails/core 1.0.0-beta.1 → 1.0.0-beta.10
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 +101 -0
- package/README.md +53 -11
- 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 +30 -0
- package/dist/execute.d.ts.map +1 -0
- package/dist/execute.js +64 -0
- package/dist/execute.js.map +1 -0
- package/dist/index.d.ts +10 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -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/topo.d.ts +4 -4
- package/dist/topo.d.ts.map +1 -1
- package/dist/topo.js +12 -16
- package/dist/topo.js.map +1 -1
- package/dist/trail.d.ts +16 -10
- package/dist/trail.d.ts.map +1 -1
- package/dist/trail.js +4 -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 +1 -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 +59 -9
- package/dist/validate-topo.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/context.test.ts +4 -5
- package/src/__tests__/derive.test.ts +44 -0
- package/src/__tests__/dispatch.test.ts +154 -0
- package/src/__tests__/event.test.ts +5 -5
- package/src/__tests__/execute.test.ts +208 -0
- package/src/__tests__/layer.test.ts +11 -111
- package/src/__tests__/serialization.test.ts +166 -1
- package/src/__tests__/topo.test.ts +101 -79
- package/src/__tests__/trail.test.ts +73 -35
- package/src/__tests__/type-utils.test.ts +90 -0
- package/src/__tests__/validate-topo.test.ts +97 -20
- package/src/derive.ts +12 -2
- package/src/dispatch.ts +54 -0
- package/src/event.ts +3 -3
- package/src/execute.ts +96 -0
- package/src/index.ts +22 -18
- package/src/result.ts +18 -4
- package/src/serialization.ts +56 -11
- package/src/topo.ts +18 -23
- package/src/trail.ts +24 -13
- package/src/type-utils.ts +45 -0
- package/src/types.ts +1 -3
- package/src/validate-topo.ts +70 -10
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ontrails/core",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts",
|
|
@@ -16,6 +16,6 @@
|
|
|
16
16
|
"clean": "rm -rf dist *.tsbuildinfo"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
|
-
"zod": "
|
|
19
|
+
"zod": "^4.3.5"
|
|
20
20
|
}
|
|
21
21
|
}
|
|
@@ -48,14 +48,13 @@ describe('createTrailContext', () => {
|
|
|
48
48
|
expect(ctx.workspaceRoot).toBe('/tmp');
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
test('TrailContext
|
|
51
|
+
test('TrailContext supports extensions for custom fields', () => {
|
|
52
52
|
const ctx: TrailContext = createTrailContext({
|
|
53
|
-
customField: 42,
|
|
54
|
-
nested: { key: 'value' },
|
|
53
|
+
extensions: { customField: 42, nested: { key: 'value' } },
|
|
55
54
|
});
|
|
56
55
|
|
|
57
|
-
expect(ctx['customField']).toBe(42);
|
|
58
|
-
expect(ctx['nested']).toEqual({ key: 'value' });
|
|
56
|
+
expect(ctx.extensions?.['customField']).toBe(42);
|
|
57
|
+
expect(ctx.extensions?.['nested']).toEqual({ key: 'value' });
|
|
59
58
|
});
|
|
60
59
|
|
|
61
60
|
test('each call generates a unique requestId', () => {
|
|
@@ -77,6 +77,50 @@ describe('derive', () => {
|
|
|
77
77
|
});
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
+
describe('array types', () => {
|
|
81
|
+
test('z.array(z.string()) derives string[] field', () => {
|
|
82
|
+
const schema = z.object({ names: z.array(z.string()) });
|
|
83
|
+
const fields = deriveFields(schema);
|
|
84
|
+
expect(fields[0]).toMatchObject({
|
|
85
|
+
name: 'names',
|
|
86
|
+
options: undefined,
|
|
87
|
+
required: true,
|
|
88
|
+
type: 'string[]',
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('z.array(z.number()) derives number[] field', () => {
|
|
93
|
+
const schema = z.object({ scores: z.array(z.number()) });
|
|
94
|
+
const fields = deriveFields(schema);
|
|
95
|
+
expect(fields[0]).toMatchObject({
|
|
96
|
+
name: 'scores',
|
|
97
|
+
options: undefined,
|
|
98
|
+
required: true,
|
|
99
|
+
type: 'number[]',
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('optional z.array(z.string()) marks not required', () => {
|
|
104
|
+
const schema = z.object({ tags: z.array(z.string()).optional() });
|
|
105
|
+
const fields = deriveFields(schema);
|
|
106
|
+
expect(fields[0]).toMatchObject({
|
|
107
|
+
name: 'tags',
|
|
108
|
+
required: false,
|
|
109
|
+
type: 'string[]',
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('z.array(z.string()) with default sets default value', () => {
|
|
114
|
+
const schema = z.object({
|
|
115
|
+
items: z.array(z.string()).default(['a', 'b']),
|
|
116
|
+
});
|
|
117
|
+
const fields = deriveFields(schema);
|
|
118
|
+
expect(fields[0]?.required).toBe(false);
|
|
119
|
+
expect(fields[0]?.type).toBe('string[]');
|
|
120
|
+
expect(fields[0]?.default).toEqual(['a', 'b']);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
80
124
|
describe('modifiers', () => {
|
|
81
125
|
test('.describe() sets label', () => {
|
|
82
126
|
const schema = z.object({
|
|
@@ -0,0 +1,154 @@
|
|
|
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 { dispatch } from '../dispatch';
|
|
7
|
+
import { InternalError, NotFoundError, ValidationError } from '../errors';
|
|
8
|
+
import type { Layer } from '../layer';
|
|
9
|
+
import { Result } from '../result';
|
|
10
|
+
import { topo } from '../topo';
|
|
11
|
+
import { trail } from '../trail';
|
|
12
|
+
import type { TrailContext } from '../types';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Fixtures
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const echoTrail = trail('echo', {
|
|
19
|
+
input: z.object({ value: z.string() }),
|
|
20
|
+
output: z.object({ value: z.string() }),
|
|
21
|
+
run: (input) => Result.ok({ value: input.value }),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const throwingTrail = trail('throws', {
|
|
25
|
+
input: z.object({}),
|
|
26
|
+
run: () => {
|
|
27
|
+
throw new Error('kaboom');
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const testTopo = topo('test', { echoTrail, throwingTrail });
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Tests
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
describe('dispatch', () => {
|
|
38
|
+
describe('happy path', () => {
|
|
39
|
+
test('dispatches by ID and returns Result.ok with expected value', async () => {
|
|
40
|
+
const result = await dispatch(testTopo, 'echo', { value: 'hello' });
|
|
41
|
+
|
|
42
|
+
expect(result.isOk()).toBe(true);
|
|
43
|
+
expect(result.unwrap()).toEqual({ value: 'hello' });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('not found', () => {
|
|
48
|
+
test('returns NotFoundError for unknown trail ID', async () => {
|
|
49
|
+
const result = await dispatch(testTopo, 'nonexistent', {});
|
|
50
|
+
|
|
51
|
+
expect(result.isErr()).toBe(true);
|
|
52
|
+
expect(result.error).toBeInstanceOf(NotFoundError);
|
|
53
|
+
expect(result.error.message).toContain('nonexistent');
|
|
54
|
+
expect(result.error.message).toContain('test');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('validation', () => {
|
|
59
|
+
test('returns ValidationError for invalid input', async () => {
|
|
60
|
+
const result = await dispatch(testTopo, 'echo', { value: 42 });
|
|
61
|
+
|
|
62
|
+
expect(result.isErr()).toBe(true);
|
|
63
|
+
expect(result.error).toBeInstanceOf(ValidationError);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('layers', () => {
|
|
68
|
+
test('layer composition works through dispatch', async () => {
|
|
69
|
+
const log: string[] = [];
|
|
70
|
+
const layer: Layer = {
|
|
71
|
+
name: 'test-layer',
|
|
72
|
+
wrap(_trail, impl) {
|
|
73
|
+
return async (input, ctx) => {
|
|
74
|
+
log.push('before');
|
|
75
|
+
const r = await impl(input, ctx);
|
|
76
|
+
log.push('after');
|
|
77
|
+
return r;
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const result = await dispatch(
|
|
83
|
+
testTopo,
|
|
84
|
+
'echo',
|
|
85
|
+
{ value: 'x' },
|
|
86
|
+
{ layers: [layer] }
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(result.isOk()).toBe(true);
|
|
90
|
+
expect(log).toEqual(['before', 'after']);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('context', () => {
|
|
95
|
+
test('context overrides work through dispatch', async () => {
|
|
96
|
+
let capturedCtx: TrailContext | undefined;
|
|
97
|
+
const ctxTrail = trail('ctx-dispatch-test', {
|
|
98
|
+
input: z.object({}),
|
|
99
|
+
run: (_input, ctx) => {
|
|
100
|
+
capturedCtx = ctx;
|
|
101
|
+
return Result.ok(null);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const ctxTopo = topo('ctx-test', { ctxTrail });
|
|
106
|
+
await dispatch(
|
|
107
|
+
ctxTopo,
|
|
108
|
+
'ctx-dispatch-test',
|
|
109
|
+
{},
|
|
110
|
+
{ ctx: { requestId: 'override-id' } }
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(capturedCtx?.requestId).toBe('override-id');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('createContext factory works through dispatch', async () => {
|
|
117
|
+
let capturedCtx: TrailContext | undefined;
|
|
118
|
+
const ctxTrail = trail('factory-dispatch-test', {
|
|
119
|
+
input: z.object({}),
|
|
120
|
+
run: (_input, ctx) => {
|
|
121
|
+
capturedCtx = ctx;
|
|
122
|
+
return Result.ok(null);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const ctxTopo = topo('factory-test', { ctxTrail });
|
|
127
|
+
const customCtx: TrailContext = {
|
|
128
|
+
cwd: '/custom',
|
|
129
|
+
requestId: 'factory-id',
|
|
130
|
+
signal: new AbortController().signal,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
await dispatch(
|
|
134
|
+
ctxTopo,
|
|
135
|
+
'factory-dispatch-test',
|
|
136
|
+
{},
|
|
137
|
+
{ createContext: () => customCtx }
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(capturedCtx?.requestId).toBe('factory-id');
|
|
141
|
+
expect(capturedCtx?.cwd).toBe('/custom');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('error handling', () => {
|
|
146
|
+
test('never throws — exceptions become InternalError', async () => {
|
|
147
|
+
const result = await dispatch(testTopo, 'throws', {});
|
|
148
|
+
|
|
149
|
+
expect(result.isErr()).toBe(true);
|
|
150
|
+
expect(result.error).toBeInstanceOf(InternalError);
|
|
151
|
+
expect(result.error.message).toBe('kaboom');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -12,7 +12,7 @@ const payloadSchema = z.object({
|
|
|
12
12
|
const userAction = event('user.action', {
|
|
13
13
|
description: 'A user performed an action',
|
|
14
14
|
from: ['auth.login', 'auth.signup'],
|
|
15
|
-
|
|
15
|
+
metadata: { domain: 'auth', priority: 1 },
|
|
16
16
|
payload: payloadSchema,
|
|
17
17
|
});
|
|
18
18
|
|
|
@@ -45,7 +45,7 @@ describe('event() basics', () => {
|
|
|
45
45
|
});
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
describe('event() from and
|
|
48
|
+
describe('event() from and metadata', () => {
|
|
49
49
|
test('preserves from', () => {
|
|
50
50
|
expect(userAction.from).toEqual(['auth.login', 'auth.signup']);
|
|
51
51
|
});
|
|
@@ -54,8 +54,8 @@ describe('event() from and markers', () => {
|
|
|
54
54
|
expect(Object.isFrozen(userAction.from)).toBe(true);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
test('preserves
|
|
58
|
-
expect(userAction.
|
|
57
|
+
test('preserves metadata', () => {
|
|
58
|
+
expect(userAction.metadata).toEqual({ domain: 'auth', priority: 1 });
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
test('optional fields default to undefined', () => {
|
|
@@ -64,7 +64,7 @@ describe('event() from and markers', () => {
|
|
|
64
64
|
});
|
|
65
65
|
expect(minimal.description).toBeUndefined();
|
|
66
66
|
expect(minimal.from).toBeUndefined();
|
|
67
|
-
expect(minimal.
|
|
67
|
+
expect(minimal.metadata).toBeUndefined();
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
70
|
|
|
@@ -0,0 +1,208 @@
|
|
|
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 { trail } from '../trail';
|
|
12
|
+
import type { TrailContext } from '../types';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Fixtures
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const echoTrail = trail('echo', {
|
|
19
|
+
input: z.object({ value: z.string() }),
|
|
20
|
+
output: z.object({ value: z.string() }),
|
|
21
|
+
run: (input) => Result.ok({ value: input.value }),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const failingTrail = trail('fails', {
|
|
25
|
+
input: z.object({}),
|
|
26
|
+
run: () => Result.err(new ValidationError('bad input')),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const throwingTrail = trail('throws', {
|
|
30
|
+
input: z.object({}),
|
|
31
|
+
run: () => {
|
|
32
|
+
throw new Error('kaboom');
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Tests
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
describe('executeTrail', () => {
|
|
41
|
+
describe('happy path', () => {
|
|
42
|
+
test('validates input and executes trail', async () => {
|
|
43
|
+
const result = await executeTrail(echoTrail, { value: 'hello' });
|
|
44
|
+
|
|
45
|
+
expect(result.isOk()).toBe(true);
|
|
46
|
+
expect(result.unwrap()).toEqual({ value: 'hello' });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('validation', () => {
|
|
51
|
+
test('returns validation error for invalid input', async () => {
|
|
52
|
+
const result = await executeTrail(echoTrail, { value: 42 });
|
|
53
|
+
|
|
54
|
+
expect(result.isErr()).toBe(true);
|
|
55
|
+
expect(result.error).toBeInstanceOf(ValidationError);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('layers', () => {
|
|
60
|
+
test('composes layers around execution', async () => {
|
|
61
|
+
const log: string[] = [];
|
|
62
|
+
const layer: Layer = {
|
|
63
|
+
name: 'test-layer',
|
|
64
|
+
wrap(_trail, impl) {
|
|
65
|
+
return async (input, ctx) => {
|
|
66
|
+
log.push('before');
|
|
67
|
+
const r = await impl(input, ctx);
|
|
68
|
+
log.push('after');
|
|
69
|
+
return r;
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const result = await executeTrail(
|
|
75
|
+
echoTrail,
|
|
76
|
+
{ value: 'x' },
|
|
77
|
+
{ layers: [layer] }
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(result.isOk()).toBe(true);
|
|
81
|
+
expect(log).toEqual(['before', 'after']);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('context', () => {
|
|
86
|
+
test('accepts context overrides', async () => {
|
|
87
|
+
let capturedCtx: TrailContext | undefined;
|
|
88
|
+
const ctxTrail = trail('ctx-test', {
|
|
89
|
+
input: z.object({}),
|
|
90
|
+
run: (_input, ctx) => {
|
|
91
|
+
capturedCtx = ctx;
|
|
92
|
+
return Result.ok(null);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await executeTrail(ctxTrail, {}, { ctx: { requestId: 'override-id' } });
|
|
97
|
+
|
|
98
|
+
expect(capturedCtx?.requestId).toBe('override-id');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('accepts signal override', async () => {
|
|
102
|
+
let capturedSignal: AbortSignal | undefined;
|
|
103
|
+
const sigTrail = trail('sig-test', {
|
|
104
|
+
input: z.object({}),
|
|
105
|
+
run: (_input, ctx) => {
|
|
106
|
+
capturedSignal = ctx.signal;
|
|
107
|
+
return Result.ok(null);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const signal = AbortSignal.timeout(9999);
|
|
112
|
+
await executeTrail(sigTrail, {}, { signal });
|
|
113
|
+
|
|
114
|
+
expect(capturedSignal).toBe(signal);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('accepts context factory', async () => {
|
|
118
|
+
let capturedCtx: TrailContext | undefined;
|
|
119
|
+
const ctxTrail = trail('factory-test', {
|
|
120
|
+
input: z.object({}),
|
|
121
|
+
run: (_input, ctx) => {
|
|
122
|
+
capturedCtx = ctx;
|
|
123
|
+
return Result.ok(null);
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const customCtx: TrailContext = {
|
|
128
|
+
cwd: '/custom',
|
|
129
|
+
requestId: 'factory-id',
|
|
130
|
+
signal: new AbortController().signal,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
await executeTrail(ctxTrail, {}, { createContext: () => customCtx });
|
|
134
|
+
|
|
135
|
+
expect(capturedCtx?.requestId).toBe('factory-id');
|
|
136
|
+
expect(capturedCtx?.cwd).toBe('/custom');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('context factory + ctx overrides merge correctly', async () => {
|
|
140
|
+
let capturedCtx: TrailContext | undefined;
|
|
141
|
+
const ctxTrail = trail('merge-test', {
|
|
142
|
+
input: z.object({}),
|
|
143
|
+
run: (_input, ctx) => {
|
|
144
|
+
capturedCtx = ctx;
|
|
145
|
+
return Result.ok(null);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const baseCtx: TrailContext = {
|
|
150
|
+
cwd: '/factory',
|
|
151
|
+
requestId: 'factory-id',
|
|
152
|
+
signal: new AbortController().signal,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
await executeTrail(
|
|
156
|
+
ctxTrail,
|
|
157
|
+
{},
|
|
158
|
+
{
|
|
159
|
+
createContext: () => baseCtx,
|
|
160
|
+
ctx: { requestId: 'overridden-id' },
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(capturedCtx?.requestId).toBe('overridden-id');
|
|
165
|
+
expect(capturedCtx?.cwd).toBe('/factory');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('deep-merges extensions from factory and overrides', async () => {
|
|
169
|
+
let captured: TrailContext | undefined;
|
|
170
|
+
const t = trail('ext.test', {
|
|
171
|
+
input: z.object({}),
|
|
172
|
+
output: z.object({}),
|
|
173
|
+
run: (_input, ctx) => {
|
|
174
|
+
captured = ctx;
|
|
175
|
+
return Result.ok({});
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
await executeTrail(
|
|
179
|
+
t,
|
|
180
|
+
{},
|
|
181
|
+
{
|
|
182
|
+
createContext: () =>
|
|
183
|
+
createTrailContext({ extensions: { store: 'db' } }),
|
|
184
|
+
ctx: { extensions: { userId: '123' } },
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
expect(captured?.extensions).toEqual({ store: 'db', userId: '123' });
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('error handling', () => {
|
|
192
|
+
test('propagates Result.err from run function', async () => {
|
|
193
|
+
const result = await executeTrail(failingTrail, {});
|
|
194
|
+
|
|
195
|
+
expect(result.isErr()).toBe(true);
|
|
196
|
+
expect(result.error).toBeInstanceOf(ValidationError);
|
|
197
|
+
expect(result.error.message).toBe('bad input');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('catches thrown exceptions and returns InternalError', async () => {
|
|
201
|
+
const result = await executeTrail(throwingTrail, {});
|
|
202
|
+
|
|
203
|
+
expect(result.isErr()).toBe(true);
|
|
204
|
+
expect(result.error).toBeInstanceOf(InternalError);
|
|
205
|
+
expect(result.error.message).toBe('kaboom');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -1,17 +1,8 @@
|
|
|
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 type {
|
|
7
|
-
IndexAdapter,
|
|
8
|
-
StorageAdapter,
|
|
9
|
-
CacheAdapter,
|
|
10
|
-
SearchOptions,
|
|
11
|
-
SearchResult,
|
|
12
|
-
StorageOptions,
|
|
13
|
-
} from '../adapters';
|
|
14
|
-
import type { HealthStatus, HealthResult, HealthCheck } from '../health';
|
|
15
6
|
import { composeLayers } from '../layer';
|
|
16
7
|
import type { Layer } from '../layer';
|
|
17
8
|
import { Result } from '../result';
|
|
@@ -24,10 +15,10 @@ const stubCtx: TrailContext = {
|
|
|
24
15
|
};
|
|
25
16
|
|
|
26
17
|
const echoTrail = trail('echo', {
|
|
27
|
-
implementation: (input) => Result.ok({ value: input.value }),
|
|
28
18
|
input: z.object({ value: z.string() }),
|
|
29
|
-
|
|
19
|
+
metadata: { domain: 'test' },
|
|
30
20
|
output: z.object({ value: z.string() }),
|
|
21
|
+
run: (input) => Result.ok({ value: input.value }),
|
|
31
22
|
});
|
|
32
23
|
|
|
33
24
|
// ---------------------------------------------------------------------------
|
|
@@ -49,11 +40,7 @@ describe('Layer', () => {
|
|
|
49
40
|
},
|
|
50
41
|
};
|
|
51
42
|
|
|
52
|
-
const wrapped = composeLayers(
|
|
53
|
-
[prefixLayer],
|
|
54
|
-
echoTrail,
|
|
55
|
-
echoTrail.implementation
|
|
56
|
-
);
|
|
43
|
+
const wrapped = composeLayers([prefixLayer], echoTrail, echoTrail.run);
|
|
57
44
|
const result = await wrapped({ value: 'hello' }, stubCtx);
|
|
58
45
|
|
|
59
46
|
expect(result.isOk()).toBe(true);
|
|
@@ -87,11 +74,7 @@ describe('Layer', () => {
|
|
|
87
74
|
},
|
|
88
75
|
};
|
|
89
76
|
|
|
90
|
-
const wrapped = composeLayers(
|
|
91
|
-
[outer, inner],
|
|
92
|
-
echoTrail,
|
|
93
|
-
echoTrail.implementation
|
|
94
|
-
);
|
|
77
|
+
const wrapped = composeLayers([outer, inner], echoTrail, echoTrail.run);
|
|
95
78
|
await wrapped({ value: 'x' }, stubCtx);
|
|
96
79
|
|
|
97
80
|
expect(log).toEqual([
|
|
@@ -110,11 +93,7 @@ describe('Layer', () => {
|
|
|
110
93
|
},
|
|
111
94
|
};
|
|
112
95
|
|
|
113
|
-
const wrapped = composeLayers(
|
|
114
|
-
[shortCircuit],
|
|
115
|
-
echoTrail,
|
|
116
|
-
echoTrail.implementation
|
|
117
|
-
);
|
|
96
|
+
const wrapped = composeLayers([shortCircuit], echoTrail, echoTrail.run);
|
|
118
97
|
const result = await wrapped({ value: 'hello' }, stubCtx);
|
|
119
98
|
|
|
120
99
|
expect(result.isErr()).toBe(true);
|
|
@@ -122,103 +101,24 @@ describe('Layer', () => {
|
|
|
122
101
|
expect(err.error.message).toBe('blocked');
|
|
123
102
|
});
|
|
124
103
|
|
|
125
|
-
test('layer can inspect trail
|
|
104
|
+
test('layer can inspect trail metadata', () => {
|
|
126
105
|
let capturedDomain: unknown;
|
|
127
106
|
|
|
128
107
|
const inspectLayer: Layer = {
|
|
129
108
|
name: 'inspect',
|
|
130
109
|
wrap(t, impl) {
|
|
131
|
-
capturedDomain = t.
|
|
110
|
+
capturedDomain = t.metadata?.['domain'];
|
|
132
111
|
return impl;
|
|
133
112
|
},
|
|
134
113
|
};
|
|
135
114
|
|
|
136
|
-
composeLayers([inspectLayer], echoTrail, echoTrail.
|
|
115
|
+
composeLayers([inspectLayer], echoTrail, echoTrail.run);
|
|
137
116
|
|
|
138
117
|
expect(capturedDomain).toBe('test');
|
|
139
118
|
});
|
|
140
119
|
|
|
141
120
|
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);
|
|
121
|
+
const wrapped = composeLayers([], echoTrail, echoTrail.run);
|
|
122
|
+
expect(wrapped).toBe(echoTrail.run);
|
|
223
123
|
});
|
|
224
124
|
});
|