@ontrails/core 1.0.0-beta.0 → 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 +109 -0
- package/README.md +101 -131
- 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 +11 -11
- 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 +29 -15
- 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
|
@@ -13,22 +13,13 @@ import { topo } from '../topo.js';
|
|
|
13
13
|
// oxlint-disable-next-line require-await -- satisfies async interface without needing await
|
|
14
14
|
const noop = async () => Result.ok();
|
|
15
15
|
|
|
16
|
-
const mockTrail = (id: string) => ({
|
|
16
|
+
const mockTrail = (id: string, follow?: readonly string[]) => ({
|
|
17
|
+
follow: Object.freeze([...(follow ?? [])]),
|
|
17
18
|
id,
|
|
18
|
-
implementation: noop,
|
|
19
19
|
input: z.object({ x: z.number() }),
|
|
20
20
|
kind: 'trail' as const,
|
|
21
21
|
output: z.object({ y: z.number() }),
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const mockHike = (id: string) => ({
|
|
25
|
-
follows: [] as readonly string[],
|
|
26
|
-
id,
|
|
27
|
-
implementation: noop,
|
|
28
|
-
input: z.object({ q: z.string() }),
|
|
29
|
-
kind: 'hike' as const,
|
|
30
|
-
output: z.object({ r: z.string() }),
|
|
31
|
-
path: `/${id}`,
|
|
22
|
+
run: noop,
|
|
32
23
|
});
|
|
33
24
|
|
|
34
25
|
const mockEvent = (id: string) => ({
|
|
@@ -42,80 +33,111 @@ const mockEvent = (id: string) => ({
|
|
|
42
33
|
// ---------------------------------------------------------------------------
|
|
43
34
|
|
|
44
35
|
describe('topo', () => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
36
|
+
describe('collection', () => {
|
|
37
|
+
test('returns Topo with name', () => {
|
|
38
|
+
const t = topo('my-app');
|
|
39
|
+
expect(t.name).toBe('my-app');
|
|
40
|
+
});
|
|
49
41
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
42
|
+
test('collects trails from modules', () => {
|
|
43
|
+
const mod = { myTrail: mockTrail('create-user') };
|
|
44
|
+
const t = topo('app', mod);
|
|
53
45
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
46
|
+
expect(t.trails.size).toBe(1);
|
|
47
|
+
expect(t.trails.get('create-user')).toBe(mod.myTrail);
|
|
48
|
+
});
|
|
57
49
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
});
|
|
50
|
+
test('auto-scans exports by kind discriminant', () => {
|
|
51
|
+
const mod = {
|
|
52
|
+
event1: mockEvent('e1'),
|
|
53
|
+
trail1: mockTrail('t1'),
|
|
54
|
+
trail2: mockTrail('t2', ['t1']),
|
|
55
|
+
};
|
|
56
|
+
const t = topo('app', mod);
|
|
57
|
+
|
|
58
|
+
expect(t.trails.size).toBe(2);
|
|
59
|
+
expect(t.events.size).toBe(1);
|
|
60
|
+
});
|
|
70
61
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
62
|
+
test('collects from multiple modules', () => {
|
|
63
|
+
const mod1 = { a: mockTrail('t1') };
|
|
64
|
+
const mod2 = { b: mockTrail('t2'), c: mockTrail('t3', ['t1']) };
|
|
65
|
+
const t = topo('app', mod1, mod2);
|
|
75
66
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
});
|
|
67
|
+
expect(t.trails.size).toBe(3);
|
|
68
|
+
});
|
|
79
69
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
70
|
+
test('non-trail exports are silently ignored', () => {
|
|
71
|
+
const mod = {
|
|
72
|
+
config: { port: 3000 },
|
|
73
|
+
helper: () => 'not a trail',
|
|
74
|
+
name: 'some-string',
|
|
75
|
+
nothing: null,
|
|
76
|
+
num: 42,
|
|
77
|
+
trail1: mockTrail('t1'),
|
|
78
|
+
undef: undefined,
|
|
79
|
+
};
|
|
80
|
+
const t = topo('app', mod);
|
|
81
|
+
|
|
82
|
+
expect(t.trails.size).toBe(1);
|
|
83
|
+
expect(t.events.size).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('trail with follow registers correctly', () => {
|
|
87
|
+
const mod = { t: mockTrail('trail-1', ['trail-2']) };
|
|
88
|
+
const t = topo('app', mod);
|
|
89
|
+
|
|
90
|
+
expect(t.trails.size).toBe(1);
|
|
91
|
+
const registered = t.trails.get('trail-1');
|
|
92
|
+
expect(registered?.follow).toEqual(['trail-2']);
|
|
93
|
+
});
|
|
95
94
|
});
|
|
96
95
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
describe('duplicate rejection', () => {
|
|
97
|
+
test('rejects duplicate trail IDs', () => {
|
|
98
|
+
const mod1 = { a: mockTrail('dup') };
|
|
99
|
+
const mod2 = { b: mockTrail('dup') };
|
|
100
|
+
|
|
101
|
+
expect(() => topo('app', mod1, mod2)).toThrow(ValidationError);
|
|
102
|
+
expect(() => topo('app', mod1, mod2)).toThrow(
|
|
103
|
+
'Duplicate trail ID: "dup"'
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('rejects duplicate event IDs', () => {
|
|
108
|
+
const mod1 = { a: mockEvent('dup') };
|
|
109
|
+
const mod2 = { b: mockEvent('dup') };
|
|
100
110
|
|
|
101
|
-
|
|
102
|
-
|
|
111
|
+
expect(() => topo('app', mod1, mod2)).toThrow(ValidationError);
|
|
112
|
+
expect(() => topo('app', mod1, mod2)).toThrow(
|
|
113
|
+
'Duplicate event ID: "dup"'
|
|
114
|
+
);
|
|
115
|
+
});
|
|
103
116
|
});
|
|
117
|
+
});
|
|
104
118
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// topo accessors
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
108
122
|
|
|
109
|
-
|
|
110
|
-
|
|
123
|
+
describe('topo accessors', () => {
|
|
124
|
+
test('ids() returns all trail IDs', () => {
|
|
125
|
+
const a = mockTrail('alpha');
|
|
126
|
+
const b = mockTrail('beta');
|
|
127
|
+
const app = topo('test', { a, b });
|
|
128
|
+
expect(app.ids().toSorted()).toEqual(['alpha', 'beta']);
|
|
111
129
|
});
|
|
112
130
|
|
|
113
|
-
test('
|
|
114
|
-
const
|
|
115
|
-
const
|
|
131
|
+
test('count returns number of trails', () => {
|
|
132
|
+
const a = mockTrail('alpha');
|
|
133
|
+
const app = topo('test', { a });
|
|
134
|
+
expect(app.count).toBe(1);
|
|
135
|
+
});
|
|
116
136
|
|
|
117
|
-
|
|
118
|
-
|
|
137
|
+
test('empty topo has zero count and empty ids', () => {
|
|
138
|
+
const app = topo('empty');
|
|
139
|
+
expect(app.count).toBe(0);
|
|
140
|
+
expect(app.ids()).toEqual([]);
|
|
119
141
|
});
|
|
120
142
|
});
|
|
121
143
|
|
|
@@ -126,9 +148,9 @@ describe('topo', () => {
|
|
|
126
148
|
describe('Topo', () => {
|
|
127
149
|
const mod = {
|
|
128
150
|
e1: mockEvent('event-1'),
|
|
129
|
-
h1: mockHike('hike-1'),
|
|
130
151
|
t1: mockTrail('trail-1'),
|
|
131
152
|
t2: mockTrail('trail-2'),
|
|
153
|
+
t3: mockTrail('trail-3', ['trail-1']),
|
|
132
154
|
};
|
|
133
155
|
|
|
134
156
|
// Build once for the describe block
|
|
@@ -139,8 +161,8 @@ describe('Topo', () => {
|
|
|
139
161
|
expect(app.get('trail-1')).toBe(mod.t1);
|
|
140
162
|
});
|
|
141
163
|
|
|
142
|
-
test('retrieves
|
|
143
|
-
expect(app.get('
|
|
164
|
+
test('retrieves trail with follow by ID', () => {
|
|
165
|
+
expect(app.get('trail-3')).toBe(mod.t3);
|
|
144
166
|
});
|
|
145
167
|
|
|
146
168
|
test('returns undefined for unknown ID', () => {
|
|
@@ -153,26 +175,26 @@ describe('Topo', () => {
|
|
|
153
175
|
expect(app.has('trail-1')).toBe(true);
|
|
154
176
|
});
|
|
155
177
|
|
|
156
|
-
test('returns true for
|
|
157
|
-
expect(app.has('
|
|
178
|
+
test('returns true for trail with follow', () => {
|
|
179
|
+
expect(app.has('trail-3')).toBe(true);
|
|
158
180
|
});
|
|
159
181
|
|
|
160
182
|
test('returns false for unknown ID', () => {
|
|
161
183
|
expect(app.has('nope')).toBe(false);
|
|
162
184
|
});
|
|
163
185
|
|
|
164
|
-
test('returns false for event ID (events are not trails
|
|
186
|
+
test('returns false for event ID (events are not trails)', () => {
|
|
165
187
|
expect(app.has('event-1')).toBe(false);
|
|
166
188
|
});
|
|
167
189
|
});
|
|
168
190
|
|
|
169
191
|
describe('listing', () => {
|
|
170
|
-
test('list() returns all trails and
|
|
192
|
+
test('list() returns all trails (with and without follow)', () => {
|
|
171
193
|
const items = app.list();
|
|
172
194
|
expect(items).toHaveLength(3);
|
|
173
195
|
expect(items).toContain(mod.t1);
|
|
174
196
|
expect(items).toContain(mod.t2);
|
|
175
|
-
expect(items).toContain(mod.
|
|
197
|
+
expect(items).toContain(mod.t3);
|
|
176
198
|
});
|
|
177
199
|
|
|
178
200
|
test('listEvents() returns all events', () => {
|
|
@@ -17,9 +17,9 @@ describe('trail()', () => {
|
|
|
17
17
|
|
|
18
18
|
const greet = trail('greet', {
|
|
19
19
|
description: 'Greet someone',
|
|
20
|
-
implementation: (input) => Result.ok({ greeting: `Hello, ${input.name}!` }),
|
|
21
20
|
input: inputSchema,
|
|
22
21
|
output: outputSchema,
|
|
22
|
+
run: (input) => Result.ok({ greeting: `Hello, ${input.name}!` }),
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
describe('basics', () => {
|
|
@@ -41,14 +41,14 @@ describe('trail()', () => {
|
|
|
41
41
|
|
|
42
42
|
test('output schema is optional', () => {
|
|
43
43
|
const minimal = trail('noop', {
|
|
44
|
-
implementation: () => Result.ok(),
|
|
45
44
|
input: z.object({}),
|
|
45
|
+
run: () => Result.ok(),
|
|
46
46
|
});
|
|
47
47
|
expect(minimal.output).toBeUndefined();
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
test('implementation is callable', async () => {
|
|
51
|
-
const result = await greet.
|
|
51
|
+
const result = await greet.run({ name: 'World' }, stubCtx);
|
|
52
52
|
expect(result.isOk()).toBe(true);
|
|
53
53
|
expect(result.unwrap()).toEqual({ greeting: 'Hello, World!' });
|
|
54
54
|
});
|
|
@@ -61,8 +61,8 @@ describe('trail()', () => {
|
|
|
61
61
|
{ error: 'ValidationError', input: { text: '' }, name: 'error-case' },
|
|
62
62
|
{ expected: { text: 'hi' }, input: { text: 'hi' }, name: 'basic' },
|
|
63
63
|
],
|
|
64
|
-
implementation: (input) => Result.ok({ text: input.text }),
|
|
65
64
|
input: z.object({ text: z.string() }),
|
|
65
|
+
run: (input) => Result.ok({ text: input.text }),
|
|
66
66
|
});
|
|
67
67
|
expect(withExamples.examples).toHaveLength(2);
|
|
68
68
|
const first = withExamples.examples?.[0];
|
|
@@ -71,13 +71,13 @@ describe('trail()', () => {
|
|
|
71
71
|
expect(second?.name).toBe('basic');
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
test('
|
|
75
|
-
const
|
|
76
|
-
implementation: () => Result.ok(),
|
|
74
|
+
test('metadata is stored', () => {
|
|
75
|
+
const withMetadata = trail('tagged', {
|
|
77
76
|
input: z.object({}),
|
|
78
|
-
|
|
77
|
+
metadata: { domain: 'billing', tier: 1 },
|
|
78
|
+
run: () => Result.ok(),
|
|
79
79
|
});
|
|
80
|
-
expect(
|
|
80
|
+
expect(withMetadata.metadata).toEqual({ domain: 'billing', tier: 1 });
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
test('detours are stored', () => {
|
|
@@ -86,8 +86,8 @@ describe('trail()', () => {
|
|
|
86
86
|
onFailure: ['alert'],
|
|
87
87
|
onSuccess: ['notify', 'audit'],
|
|
88
88
|
},
|
|
89
|
-
implementation: () => Result.ok(),
|
|
90
89
|
input: z.object({}),
|
|
90
|
+
run: () => Result.ok(),
|
|
91
91
|
});
|
|
92
92
|
expect(withDetours.detours).toEqual({
|
|
93
93
|
onFailure: ['alert'],
|
|
@@ -96,28 +96,68 @@ describe('trail()', () => {
|
|
|
96
96
|
});
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
describe('
|
|
100
|
-
test('
|
|
99
|
+
describe('follow', () => {
|
|
100
|
+
test('defaults to empty frozen array when omitted', () => {
|
|
101
101
|
const minimal = trail('bare', {
|
|
102
|
-
implementation: () => Result.ok(),
|
|
103
102
|
input: z.object({}),
|
|
103
|
+
run: () => Result.ok(),
|
|
104
104
|
});
|
|
105
|
-
expect(minimal.
|
|
106
|
-
expect(minimal.
|
|
105
|
+
expect(minimal.follow).toEqual([]);
|
|
106
|
+
expect(Object.isFrozen(minimal.follow)).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('preserves follow array', () => {
|
|
110
|
+
const withFollow = trail('composed', {
|
|
111
|
+
follow: ['authenticate', 'validate-session'],
|
|
112
|
+
input: z.object({}),
|
|
113
|
+
run: () => Result.ok(),
|
|
114
|
+
});
|
|
115
|
+
expect(withFollow.follow).toEqual(['authenticate', 'validate-session']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('follow array is frozen', () => {
|
|
119
|
+
const withFollow = trail('composed', {
|
|
120
|
+
follow: ['authenticate'],
|
|
121
|
+
input: z.object({}),
|
|
122
|
+
run: () => Result.ok(),
|
|
123
|
+
});
|
|
124
|
+
expect(Object.isFrozen(withFollow.follow)).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('intent and idempotent', () => {
|
|
129
|
+
test('intent defaults to write', () => {
|
|
130
|
+
const minimal = trail('bare', {
|
|
131
|
+
input: z.object({}),
|
|
132
|
+
run: () => Result.ok(),
|
|
133
|
+
});
|
|
134
|
+
expect(minimal.intent).toBe('write');
|
|
107
135
|
expect(minimal.idempotent).toBeUndefined();
|
|
108
136
|
});
|
|
109
137
|
|
|
110
|
-
test('
|
|
111
|
-
const
|
|
112
|
-
|
|
138
|
+
test('intent is preserved when set', () => {
|
|
139
|
+
const readTrail = trail('reader', {
|
|
140
|
+
input: z.object({}),
|
|
141
|
+
intent: 'read',
|
|
142
|
+
run: () => Result.ok(),
|
|
143
|
+
});
|
|
144
|
+
expect(readTrail.intent).toBe('read');
|
|
145
|
+
|
|
146
|
+
const destroyTrail = trail('destroyer', {
|
|
147
|
+
input: z.object({}),
|
|
148
|
+
intent: 'destroy',
|
|
149
|
+
run: () => Result.ok(),
|
|
150
|
+
});
|
|
151
|
+
expect(destroyTrail.intent).toBe('destroy');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('idempotent is preserved when set', () => {
|
|
155
|
+
const t = trail('idempotent', {
|
|
113
156
|
idempotent: true,
|
|
114
|
-
implementation: () => Result.ok(),
|
|
115
157
|
input: z.object({}),
|
|
116
|
-
|
|
158
|
+
run: () => Result.ok(),
|
|
117
159
|
});
|
|
118
|
-
expect(
|
|
119
|
-
expect(withFlags.destructive).toBe(false);
|
|
120
|
-
expect(withFlags.idempotent).toBe(true);
|
|
160
|
+
expect(t.idempotent).toBe(true);
|
|
121
161
|
});
|
|
122
162
|
});
|
|
123
163
|
|
|
@@ -125,9 +165,9 @@ describe('trail()', () => {
|
|
|
125
165
|
test('accepts spec with id property', () => {
|
|
126
166
|
const t = trail({
|
|
127
167
|
id: 'entity.show',
|
|
128
|
-
implementation: (input: { name: string }, _ctx: TrailContext) =>
|
|
129
|
-
Result.ok({ greeting: `Hi, ${input.name}` }),
|
|
130
168
|
input: inputSchema,
|
|
169
|
+
run: (input: { name: string }, _ctx: TrailContext) =>
|
|
170
|
+
Result.ok({ greeting: `Hi, ${input.name}` }),
|
|
131
171
|
});
|
|
132
172
|
expect(t.id).toBe('entity.show');
|
|
133
173
|
expect(t.kind).toBe('trail');
|
|
@@ -136,39 +176,37 @@ describe('trail()', () => {
|
|
|
136
176
|
test('preserves all spec fields', () => {
|
|
137
177
|
const t = trail({
|
|
138
178
|
description: 'A full trail',
|
|
139
|
-
destructive: false,
|
|
140
179
|
examples: [{ input: { name: 'World' }, name: 'test' }],
|
|
141
180
|
id: 'full',
|
|
142
|
-
implementation: (input: { name: string }, _ctx: TrailContext) =>
|
|
143
|
-
Result.ok({ greeting: `Hi, ${input.name}` }),
|
|
144
181
|
input: inputSchema,
|
|
182
|
+
intent: 'read',
|
|
145
183
|
output: outputSchema,
|
|
146
|
-
|
|
184
|
+
run: (input: { name: string }, _ctx: TrailContext) =>
|
|
185
|
+
Result.ok({ greeting: `Hi, ${input.name}` }),
|
|
147
186
|
});
|
|
148
187
|
expect(t.description).toBe('A full trail');
|
|
149
|
-
expect(t.
|
|
188
|
+
expect(t.intent).toBe('read');
|
|
150
189
|
expect(t.examples).toHaveLength(1);
|
|
151
190
|
});
|
|
152
191
|
|
|
153
192
|
test('implementation is callable', async () => {
|
|
154
193
|
const t = trail({
|
|
155
194
|
id: 'callable',
|
|
156
|
-
implementation: (input: { x: number }) => Result.ok(input.x * 2),
|
|
157
195
|
input: z.object({ x: z.number() }),
|
|
196
|
+
run: (input: { x: number }) => Result.ok(input.x * 2),
|
|
158
197
|
});
|
|
159
|
-
const result = await t.
|
|
198
|
+
const result = await t.run({ x: 5 }, stubCtx);
|
|
160
199
|
expect(result.isOk()).toBe(true);
|
|
161
200
|
expect(result.unwrap()).toBe(10);
|
|
162
201
|
});
|
|
163
202
|
|
|
164
203
|
test('sync implementations are normalized to an awaitable runtime function', async () => {
|
|
165
204
|
const t = trail('normalized', {
|
|
166
|
-
implementation: (input: { value: number }) =>
|
|
167
|
-
Result.ok(input.value + 1),
|
|
168
205
|
input: z.object({ value: z.number() }),
|
|
206
|
+
run: (input: { value: number }) => Result.ok(input.value + 1),
|
|
169
207
|
});
|
|
170
208
|
|
|
171
|
-
const promise = t.
|
|
209
|
+
const promise = t.run({ value: 2 }, stubCtx);
|
|
172
210
|
expect(promise).toBeInstanceOf(Promise);
|
|
173
211
|
|
|
174
212
|
const result = await promise;
|
|
@@ -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
|
+
});
|