@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,90 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import { Result } from '../result';
|
|
6
|
+
import { trail } from '../trail';
|
|
7
|
+
import type { TrailInput, TrailOutput, TrailResult } from '../type-utils';
|
|
8
|
+
import { inputOf, outputOf } from '../type-utils';
|
|
9
|
+
|
|
10
|
+
const greetTrail = trail('greet', {
|
|
11
|
+
input: z.object({ name: z.string() }),
|
|
12
|
+
output: z.object({ message: z.string() }),
|
|
13
|
+
run: (input) => Result.ok({ message: `Hello, ${input.name}!` }),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const noOutputTrail = trail('ping', {
|
|
17
|
+
input: z.object({}),
|
|
18
|
+
run: () => Result.ok(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('type-utils', () => {
|
|
22
|
+
describe('inputOf', () => {
|
|
23
|
+
test('returns the Zod input schema and can parse valid input', () => {
|
|
24
|
+
const schema = inputOf(greetTrail);
|
|
25
|
+
const result = schema.safeParse({ name: 'Alice' });
|
|
26
|
+
expect(result.success).toBe(true);
|
|
27
|
+
expect(
|
|
28
|
+
(result as { success: true; data: { name: string } }).data
|
|
29
|
+
).toEqual({
|
|
30
|
+
name: 'Alice',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('preserves specific schema type so .shape is accessible', () => {
|
|
35
|
+
const schema = inputOf(greetTrail);
|
|
36
|
+
// .shape is only available on z.ZodObject, not the broader z.ZodType
|
|
37
|
+
expect(schema.shape).toBeDefined();
|
|
38
|
+
expect(schema.shape.name).toBeDefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('outputOf', () => {
|
|
43
|
+
test('returns the Zod output schema when defined', () => {
|
|
44
|
+
const schema = outputOf(greetTrail);
|
|
45
|
+
expect(schema).toBeDefined();
|
|
46
|
+
// oxlint-disable-next-line no-non-null-assertion -- guarded by toBeDefined() above
|
|
47
|
+
const result = schema!.safeParse({ message: 'hello' });
|
|
48
|
+
expect(result.success).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('returns undefined when no output schema', () => {
|
|
52
|
+
const schema = outputOf(noOutputTrail);
|
|
53
|
+
expect(schema).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('type-level checks', () => {
|
|
58
|
+
test('TrailInput matches expected shape', () => {
|
|
59
|
+
// If this compiles, the type is correct
|
|
60
|
+
const _input: TrailInput<typeof greetTrail> = { name: 'test' };
|
|
61
|
+
expect(_input.name).toBe('test');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('TrailOutput matches expected shape', () => {
|
|
65
|
+
// If this compiles, the type is correct
|
|
66
|
+
const _output: TrailOutput<typeof greetTrail> = { message: 'hello' };
|
|
67
|
+
expect(_output.message).toBe('hello');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('TrailResult', () => {
|
|
72
|
+
test('extracts Result<Output, Error> from a trail', () => {
|
|
73
|
+
const t = trail('test.result', {
|
|
74
|
+
input: z.object({ q: z.string() }),
|
|
75
|
+
output: z.object({ answer: z.string() }),
|
|
76
|
+
run: (input) => Result.ok({ answer: input.q }),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
type Expected = Result<{ answer: string }, Error>;
|
|
80
|
+
type Actual = TrailResult<typeof t>;
|
|
81
|
+
|
|
82
|
+
// Compile-time check: assignment works in both directions
|
|
83
|
+
const _check1: Expected = {} as Actual;
|
|
84
|
+
const _check2: Actual = {} as Expected;
|
|
85
|
+
|
|
86
|
+
// Runtime: type exists and is usable
|
|
87
|
+
expect(true).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test';
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
5
5
|
import { Result } from '../result.js';
|
|
6
|
+
import { service } from '../service.js';
|
|
6
7
|
import { topo } from '../topo.js';
|
|
7
8
|
import type { TopoIssue } from '../validate-topo.js';
|
|
8
9
|
import { validateTopo } from '../validate-topo.js';
|
|
@@ -17,6 +18,7 @@ const noop = async () => Result.ok();
|
|
|
17
18
|
const mockTrail = (
|
|
18
19
|
id: string,
|
|
19
20
|
overrides?: {
|
|
21
|
+
follow?: readonly string[];
|
|
20
22
|
examples?: readonly {
|
|
21
23
|
name: string;
|
|
22
24
|
input: unknown;
|
|
@@ -24,22 +26,22 @@ const mockTrail = (
|
|
|
24
26
|
error?: string;
|
|
25
27
|
}[];
|
|
26
28
|
output?: z.ZodType;
|
|
29
|
+
services?: readonly ReturnType<typeof service>[];
|
|
27
30
|
}
|
|
28
31
|
) => ({
|
|
32
|
+
follow: Object.freeze([...(overrides?.follow ?? [])]),
|
|
29
33
|
id,
|
|
30
|
-
implementation: noop,
|
|
31
34
|
input: z.object({ name: z.string() }),
|
|
32
35
|
kind: 'trail' as const,
|
|
36
|
+
run: noop,
|
|
37
|
+
services: Object.freeze([...(overrides?.services ?? [])]),
|
|
33
38
|
...overrides,
|
|
34
39
|
});
|
|
35
40
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
input: z.object({ q: z.string() }),
|
|
41
|
-
kind: 'hike' as const,
|
|
42
|
-
});
|
|
41
|
+
const mockService = (id: string) =>
|
|
42
|
+
service(id, {
|
|
43
|
+
create: () => Result.ok({ id }),
|
|
44
|
+
});
|
|
43
45
|
|
|
44
46
|
const mockEvent = (id: string, from?: readonly string[]) => ({
|
|
45
47
|
from,
|
|
@@ -65,7 +67,9 @@ describe('validateTopo', () => {
|
|
|
65
67
|
test('valid topo passes', () => {
|
|
66
68
|
const app = topo('app', {
|
|
67
69
|
add: mockTrail('entity.add'),
|
|
68
|
-
onboard:
|
|
70
|
+
onboard: mockTrail('entity.onboard', {
|
|
71
|
+
follow: ['entity.add'],
|
|
72
|
+
}),
|
|
69
73
|
updated: mockEvent('entity.updated', ['entity.add']),
|
|
70
74
|
});
|
|
71
75
|
|
|
@@ -73,10 +77,12 @@ describe('validateTopo', () => {
|
|
|
73
77
|
expect(result.isOk()).toBe(true);
|
|
74
78
|
});
|
|
75
79
|
|
|
76
|
-
describe('
|
|
77
|
-
test('
|
|
80
|
+
describe('trail follow', () => {
|
|
81
|
+
test('trail following non-existent trail fails', () => {
|
|
78
82
|
const app = topo('app', {
|
|
79
|
-
onboard:
|
|
83
|
+
onboard: mockTrail('entity.onboard', {
|
|
84
|
+
follow: ['entity.missing'],
|
|
85
|
+
}),
|
|
80
86
|
});
|
|
81
87
|
|
|
82
88
|
const result = validateTopo(app);
|
|
@@ -84,13 +90,85 @@ describe('validateTopo', () => {
|
|
|
84
90
|
|
|
85
91
|
const issues = extractIssues(result);
|
|
86
92
|
expect(issues).toHaveLength(1);
|
|
87
|
-
expect(issues[0]?.rule).toBe('
|
|
93
|
+
expect(issues[0]?.rule).toBe('follow-exists');
|
|
88
94
|
expect(issues[0]?.message).toContain('entity.missing');
|
|
89
95
|
});
|
|
90
96
|
|
|
91
|
-
test('
|
|
97
|
+
test('trail following itself fails', () => {
|
|
98
|
+
const app = topo('app', {
|
|
99
|
+
loop: mockTrail('entity.loop', { follow: ['entity.loop'] }),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const result = validateTopo(app);
|
|
103
|
+
expect(result.isErr()).toBe(true);
|
|
104
|
+
|
|
105
|
+
const issues = extractIssues(result);
|
|
106
|
+
expect(issues.some((i) => i.rule === 'no-self-follow')).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('two-node cycle (a→b→a) is detected', () => {
|
|
110
|
+
const app = topo('app', {
|
|
111
|
+
a: mockTrail('a', { follow: ['b'] }),
|
|
112
|
+
b: mockTrail('b', { follow: ['a'] }),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const result = validateTopo(app);
|
|
116
|
+
expect(result.isErr()).toBe(true);
|
|
117
|
+
|
|
118
|
+
const issues = extractIssues(result);
|
|
119
|
+
const cycleIssues = issues.filter((i) => i.rule === 'follow-cycle');
|
|
120
|
+
expect(cycleIssues.length).toBeGreaterThanOrEqual(1);
|
|
121
|
+
expect(cycleIssues[0]?.message).toContain('Cycle detected');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('three-node cycle (a→b→c→a) is detected', () => {
|
|
125
|
+
const app = topo('app', {
|
|
126
|
+
a: mockTrail('a', { follow: ['b'] }),
|
|
127
|
+
b: mockTrail('b', { follow: ['c'] }),
|
|
128
|
+
c: mockTrail('c', { follow: ['a'] }),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const result = validateTopo(app);
|
|
132
|
+
expect(result.isErr()).toBe(true);
|
|
133
|
+
|
|
134
|
+
const issues = extractIssues(result);
|
|
135
|
+
const cycleIssues = issues.filter((i) => i.rule === 'follow-cycle');
|
|
136
|
+
expect(cycleIssues.length).toBeGreaterThanOrEqual(1);
|
|
137
|
+
expect(cycleIssues[0]?.message).toContain('Cycle detected');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('valid DAG with shared targets is not flagged', () => {
|
|
141
|
+
const app = topo('app', {
|
|
142
|
+
a: mockTrail('a', { follow: ['c'] }),
|
|
143
|
+
b: mockTrail('b', { follow: ['c'] }),
|
|
144
|
+
c: mockTrail('c'),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const result = validateTopo(app);
|
|
148
|
+
expect(result.isOk()).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('service declarations', () => {
|
|
153
|
+
test('trail declaring a registered service passes', () => {
|
|
154
|
+
const db = mockService('db.main');
|
|
155
|
+
const app = topo('app', {
|
|
156
|
+
db,
|
|
157
|
+
show: mockTrail('entity.show', {
|
|
158
|
+
services: [db],
|
|
159
|
+
}),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const result = validateTopo(app);
|
|
163
|
+
expect(result.isOk()).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('trail declaring a missing service fails', () => {
|
|
167
|
+
const db = mockService('db.main');
|
|
92
168
|
const app = topo('app', {
|
|
93
|
-
|
|
169
|
+
show: mockTrail('entity.show', {
|
|
170
|
+
services: [db],
|
|
171
|
+
}),
|
|
94
172
|
});
|
|
95
173
|
|
|
96
174
|
const result = validateTopo(app);
|
|
@@ -98,7 +176,8 @@ describe('validateTopo', () => {
|
|
|
98
176
|
|
|
99
177
|
const issues = extractIssues(result);
|
|
100
178
|
expect(issues).toHaveLength(1);
|
|
101
|
-
expect(issues[0]?.rule).toBe('
|
|
179
|
+
expect(issues[0]?.rule).toBe('service-exists');
|
|
180
|
+
expect(issues[0]?.message).toContain('db.main');
|
|
102
181
|
});
|
|
103
182
|
});
|
|
104
183
|
|
|
@@ -140,7 +219,7 @@ describe('validateTopo', () => {
|
|
|
140
219
|
expect(issues[0]?.rule).toBe('output-schema-present');
|
|
141
220
|
});
|
|
142
221
|
|
|
143
|
-
test('
|
|
222
|
+
test('ValidationError example with invalid input is allowed', () => {
|
|
144
223
|
const app = topo('app', {
|
|
145
224
|
show: mockTrail('entity.show', {
|
|
146
225
|
examples: [
|
|
@@ -156,6 +235,44 @@ describe('validateTopo', () => {
|
|
|
156
235
|
const result = validateTopo(app);
|
|
157
236
|
expect(result.isOk()).toBe(true);
|
|
158
237
|
});
|
|
238
|
+
|
|
239
|
+
test('NotFoundError example with invalid input fails', () => {
|
|
240
|
+
const app = topo('app', {
|
|
241
|
+
show: mockTrail('entity.show', {
|
|
242
|
+
examples: [
|
|
243
|
+
{
|
|
244
|
+
error: 'NotFoundError',
|
|
245
|
+
input: { name: 123 },
|
|
246
|
+
name: 'Not found case',
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
}),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const result = validateTopo(app);
|
|
253
|
+
expect(result.isErr()).toBe(true);
|
|
254
|
+
|
|
255
|
+
const issues = extractIssues(result);
|
|
256
|
+
expect(issues).toHaveLength(1);
|
|
257
|
+
expect(issues[0]?.rule).toBe('example-input-valid');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('NotFoundError example with valid input passes', () => {
|
|
261
|
+
const app = topo('app', {
|
|
262
|
+
show: mockTrail('entity.show', {
|
|
263
|
+
examples: [
|
|
264
|
+
{
|
|
265
|
+
error: 'NotFoundError',
|
|
266
|
+
input: { name: 'test' },
|
|
267
|
+
name: 'Not found case',
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
}),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const result = validateTopo(app);
|
|
274
|
+
expect(result.isOk()).toBe(true);
|
|
275
|
+
});
|
|
159
276
|
});
|
|
160
277
|
|
|
161
278
|
describe('event origins', () => {
|
|
@@ -184,8 +301,12 @@ describe('validateTopo', () => {
|
|
|
184
301
|
});
|
|
185
302
|
|
|
186
303
|
test('collects multiple issues', () => {
|
|
304
|
+
const db = mockService('db.main');
|
|
187
305
|
const app = topo('app', {
|
|
188
|
-
broken:
|
|
306
|
+
broken: mockTrail('entity.broken', { follow: ['entity.missing'] }),
|
|
307
|
+
missingService: mockTrail('entity.missing-service', {
|
|
308
|
+
services: [db],
|
|
309
|
+
}),
|
|
189
310
|
show: mockTrail('entity.show', {
|
|
190
311
|
examples: [{ input: { name: 123 }, name: 'Bad' }],
|
|
191
312
|
}),
|
|
@@ -196,6 +317,6 @@ describe('validateTopo', () => {
|
|
|
196
317
|
expect(result.isErr()).toBe(true);
|
|
197
318
|
|
|
198
319
|
const issues = extractIssues(result);
|
|
199
|
-
expect(issues).toHaveLength(
|
|
320
|
+
expect(issues).toHaveLength(4);
|
|
200
321
|
});
|
|
201
322
|
});
|
|
@@ -280,4 +280,57 @@ describe('zodToJsonSchema', () => {
|
|
|
280
280
|
expect(zodToJsonSchema(z.any())).toEqual({});
|
|
281
281
|
});
|
|
282
282
|
});
|
|
283
|
+
|
|
284
|
+
describe('default values', () => {
|
|
285
|
+
test('preserves static defaults as-is', () => {
|
|
286
|
+
const schema = z.string().default('hello');
|
|
287
|
+
expect(zodToJsonSchema(schema)).toEqual({
|
|
288
|
+
default: 'hello',
|
|
289
|
+
type: 'string',
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('omits dynamic defaults that produce different values', () => {
|
|
294
|
+
let counter = 0;
|
|
295
|
+
const schema = z.string().default(() => {
|
|
296
|
+
counter += 1;
|
|
297
|
+
return `id-${counter}`;
|
|
298
|
+
});
|
|
299
|
+
const result = zodToJsonSchema(schema);
|
|
300
|
+
expect(result).toEqual({ type: 'string' });
|
|
301
|
+
expect(result['default']).toBeUndefined();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('preserves stable functional defaults that return constant values', () => {
|
|
305
|
+
const schema = z.string().default(() => 'constant');
|
|
306
|
+
const result = zodToJsonSchema(schema);
|
|
307
|
+
expect(result).toEqual({ default: 'constant', type: 'string' });
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('preserves stable functional defaults that return equivalent objects', () => {
|
|
311
|
+
const schema = z
|
|
312
|
+
.object({ key: z.string() })
|
|
313
|
+
.default(() => ({ key: 'val' }));
|
|
314
|
+
const result = zodToJsonSchema(schema);
|
|
315
|
+
expect(result['default']).toEqual({ key: 'val' });
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('preserves stable functional defaults that return equivalent arrays', () => {
|
|
319
|
+
const schema = z.array(z.string()).default(() => ['a', 'b']);
|
|
320
|
+
const result = zodToJsonSchema(schema);
|
|
321
|
+
expect(result['default']).toEqual(['a', 'b']);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('produces identical output across repeated calls', () => {
|
|
325
|
+
let counter = 0;
|
|
326
|
+
const schema = z.string().default(() => {
|
|
327
|
+
counter += 1;
|
|
328
|
+
return `id-${counter}`;
|
|
329
|
+
});
|
|
330
|
+
const first = zodToJsonSchema(schema);
|
|
331
|
+
const second = zodToJsonSchema(schema);
|
|
332
|
+
// Key invariant: repeated calls always produce the same schema
|
|
333
|
+
expect(first).toEqual(second);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
283
336
|
});
|
package/src/context.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createServiceLookup } from './service.js';
|
|
2
|
+
import type { TrailContext, TrailContextInit } from './types.js';
|
|
3
|
+
|
|
4
|
+
type MutableTrailContext = {
|
|
5
|
+
-readonly [K in keyof TrailContext]: TrailContext[K];
|
|
6
|
+
};
|
|
2
7
|
|
|
3
8
|
/**
|
|
4
9
|
* Create a TrailContext with sensible defaults.
|
|
@@ -8,11 +13,15 @@ import type { TrailContext } from './types.js';
|
|
|
8
13
|
* - All other fields come from `overrides`
|
|
9
14
|
*/
|
|
10
15
|
export const createTrailContext = (
|
|
11
|
-
overrides?: Partial<
|
|
12
|
-
): TrailContext =>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
overrides?: Partial<TrailContextInit>
|
|
17
|
+
): TrailContext => {
|
|
18
|
+
const ctx = {
|
|
19
|
+
cwd: process.cwd(),
|
|
20
|
+
env: process.env as Record<string, string | undefined>,
|
|
21
|
+
requestId: Bun.randomUUIDv7(),
|
|
22
|
+
signal: new AbortController().signal,
|
|
23
|
+
...overrides,
|
|
24
|
+
} as MutableTrailContext;
|
|
25
|
+
ctx.service = overrides?.service ?? createServiceLookup(() => ctx);
|
|
26
|
+
return ctx;
|
|
27
|
+
};
|
package/src/derive.ts
CHANGED
|
@@ -14,7 +14,14 @@ import type { z } from 'zod';
|
|
|
14
14
|
/** A surface-agnostic field descriptor derived from a Zod schema. */
|
|
15
15
|
export interface Field {
|
|
16
16
|
readonly name: string;
|
|
17
|
-
readonly type:
|
|
17
|
+
readonly type:
|
|
18
|
+
| 'string'
|
|
19
|
+
| 'number'
|
|
20
|
+
| 'boolean'
|
|
21
|
+
| 'enum'
|
|
22
|
+
| 'multiselect'
|
|
23
|
+
| 'string[]'
|
|
24
|
+
| 'number[]';
|
|
18
25
|
readonly label: string;
|
|
19
26
|
readonly required: boolean;
|
|
20
27
|
readonly default?: unknown | undefined;
|
|
@@ -141,7 +148,10 @@ const fieldTypeByDef: Record<string, (s: ZodInternals) => DerivedFieldType> = {
|
|
|
141
148
|
const entries = element._zod.def['entries'] as Record<string, string>;
|
|
142
149
|
return { options: Object.values(entries), type: 'multiselect' };
|
|
143
150
|
}
|
|
144
|
-
return {
|
|
151
|
+
return {
|
|
152
|
+
options: undefined,
|
|
153
|
+
type: elementType === 'number' ? 'number[]' : 'string[]',
|
|
154
|
+
};
|
|
145
155
|
},
|
|
146
156
|
boolean: () => ({ options: undefined, type: 'boolean' }),
|
|
147
157
|
enum: (s) => {
|
package/src/dispatch.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless trail execution — the "no-surface" surface.
|
|
3
|
+
*
|
|
4
|
+
* Looks up a trail by ID in a topo, then delegates to `executeTrail`.
|
|
5
|
+
* Returns a `Result` and never throws.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Topo } from './topo.js';
|
|
9
|
+
import { executeTrail } from './execute.js';
|
|
10
|
+
import type { ExecuteTrailOptions } from './execute.js';
|
|
11
|
+
import { NotFoundError } from './errors.js';
|
|
12
|
+
import { Result } from './result.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Options
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** Options forwarded to `executeTrail` from `dispatch`. */
|
|
19
|
+
export type DispatchOptions = ExecuteTrailOptions;
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// dispatch()
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Execute a trail by ID from a topo without mounting a surface.
|
|
27
|
+
*
|
|
28
|
+
* Resolves the trail from the topo, then runs it through the standard
|
|
29
|
+
* `executeTrail` pipeline. Returns `Result.err(NotFoundError)` if the
|
|
30
|
+
* trail ID is not registered. Never throws — unexpected exceptions are
|
|
31
|
+
* returned as `Result.err(InternalError)`.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```typescript
|
|
35
|
+
* const result = await dispatch(myTopo, 'greet', { name: 'Alice' });
|
|
36
|
+
* if (result.isOk()) console.log(result.value);
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export const dispatch = (
|
|
40
|
+
topo: Topo,
|
|
41
|
+
id: string,
|
|
42
|
+
input: unknown,
|
|
43
|
+
options?: DispatchOptions
|
|
44
|
+
): Promise<Result<unknown, Error>> => {
|
|
45
|
+
const trail = topo.get(id);
|
|
46
|
+
if (trail === undefined) {
|
|
47
|
+
return Promise.resolve(
|
|
48
|
+
Result.err(
|
|
49
|
+
new NotFoundError(`Trail "${id}" not found in topo "${topo.name}"`)
|
|
50
|
+
)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return executeTrail(trail, input, options);
|
|
54
|
+
};
|
package/src/event.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { z } from 'zod';
|
|
|
11
11
|
export interface EventSpec<T> {
|
|
12
12
|
readonly payload: z.ZodType<T>;
|
|
13
13
|
readonly description?: string | undefined;
|
|
14
|
-
readonly
|
|
14
|
+
readonly metadata?: Readonly<Record<string, unknown>> | undefined;
|
|
15
15
|
/** Trail IDs that produce this event (e.g. the trails it originates from). */
|
|
16
16
|
readonly from?: readonly string[] | undefined;
|
|
17
17
|
}
|
|
@@ -25,7 +25,7 @@ export interface Event<T> {
|
|
|
25
25
|
readonly kind: 'event';
|
|
26
26
|
readonly payload: z.ZodType<T>;
|
|
27
27
|
readonly description?: string | undefined;
|
|
28
|
-
readonly
|
|
28
|
+
readonly metadata?: Readonly<Record<string, unknown>> | undefined;
|
|
29
29
|
/** Trail IDs that produce this event (e.g. the trails it originates from). */
|
|
30
30
|
readonly from?: readonly string[] | undefined;
|
|
31
31
|
}
|
|
@@ -68,7 +68,7 @@ export function event<T>(
|
|
|
68
68
|
from: resolvedSpec.from ? Object.freeze([...resolvedSpec.from]) : undefined,
|
|
69
69
|
id: resolvedId,
|
|
70
70
|
kind: 'event' as const,
|
|
71
|
-
|
|
71
|
+
metadata: resolvedSpec.metadata,
|
|
72
72
|
payload: resolvedSpec.payload,
|
|
73
73
|
});
|
|
74
74
|
}
|