@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
|
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
|
|
4
4
|
|
|
5
5
|
import { ValidationError } from '../errors.js';
|
|
6
6
|
import { Result } from '../result.js';
|
|
7
|
+
import { service } from '../service.js';
|
|
7
8
|
import { topo } from '../topo.js';
|
|
8
9
|
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
@@ -13,22 +14,13 @@ import { topo } from '../topo.js';
|
|
|
13
14
|
// oxlint-disable-next-line require-await -- satisfies async interface without needing await
|
|
14
15
|
const noop = async () => Result.ok();
|
|
15
16
|
|
|
16
|
-
const mockTrail = (id: string) => ({
|
|
17
|
+
const mockTrail = (id: string, follow?: readonly string[]) => ({
|
|
18
|
+
follow: Object.freeze([...(follow ?? [])]),
|
|
17
19
|
id,
|
|
18
|
-
implementation: noop,
|
|
19
20
|
input: z.object({ x: z.number() }),
|
|
20
21
|
kind: 'trail' as const,
|
|
21
22
|
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}`,
|
|
23
|
+
run: noop,
|
|
32
24
|
});
|
|
33
25
|
|
|
34
26
|
const mockEvent = (id: string) => ({
|
|
@@ -37,85 +29,159 @@ const mockEvent = (id: string) => ({
|
|
|
37
29
|
payload: z.object({ payload: z.string() }),
|
|
38
30
|
});
|
|
39
31
|
|
|
32
|
+
const mockService = (id: string) =>
|
|
33
|
+
service(id, {
|
|
34
|
+
create: () => Result.ok({ id }),
|
|
35
|
+
description: `${id} service`,
|
|
36
|
+
});
|
|
37
|
+
|
|
40
38
|
// ---------------------------------------------------------------------------
|
|
41
39
|
// topo()
|
|
42
40
|
// ---------------------------------------------------------------------------
|
|
43
41
|
|
|
44
42
|
describe('topo', () => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
describe('collection', () => {
|
|
44
|
+
test('returns Topo with name', () => {
|
|
45
|
+
const t = topo('my-app');
|
|
46
|
+
expect(t.name).toBe('my-app');
|
|
47
|
+
});
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
test('collects trails from modules', () => {
|
|
50
|
+
const mod = { myTrail: mockTrail('create-user') };
|
|
51
|
+
const t = topo('app', mod);
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
expect(t.trails.size).toBe(1);
|
|
54
|
+
expect(t.trails.get('create-user')).toBe(mod.myTrail);
|
|
55
|
+
});
|
|
57
56
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
57
|
+
test('auto-scans exports by kind discriminant', () => {
|
|
58
|
+
const mod = {
|
|
59
|
+
event1: mockEvent('e1'),
|
|
60
|
+
service1: mockService('s1'),
|
|
61
|
+
trail1: mockTrail('t1'),
|
|
62
|
+
trail2: mockTrail('t2', ['t1']),
|
|
63
|
+
};
|
|
64
|
+
const t = topo('app', mod);
|
|
65
|
+
|
|
66
|
+
expect(t.trails.size).toBe(2);
|
|
67
|
+
expect(t.events.size).toBe(1);
|
|
68
|
+
expect(t.services.size).toBe(1);
|
|
69
|
+
});
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
test('collects from multiple modules', () => {
|
|
72
|
+
const mod1 = { a: mockTrail('t1') };
|
|
73
|
+
const mod2 = { b: mockTrail('t2'), c: mockTrail('t3', ['t1']) };
|
|
74
|
+
const t = topo('app', mod1, mod2);
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
expect(t.trails.size).toBe(3);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('non-trail exports are silently ignored', () => {
|
|
80
|
+
const mod = {
|
|
81
|
+
config: { port: 3000 },
|
|
82
|
+
helper: () => 'not a trail',
|
|
83
|
+
name: 'some-string',
|
|
84
|
+
nothing: null,
|
|
85
|
+
num: 42,
|
|
86
|
+
trail1: mockTrail('t1'),
|
|
87
|
+
undef: undefined,
|
|
88
|
+
};
|
|
89
|
+
const t = topo('app', mod);
|
|
90
|
+
|
|
91
|
+
expect(t.trails.size).toBe(1);
|
|
92
|
+
expect(t.events.size).toBe(0);
|
|
93
|
+
expect(t.services.size).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('trail with follow registers correctly', () => {
|
|
97
|
+
const mod = { t: mockTrail('trail-1', ['trail-2']) };
|
|
98
|
+
const t = topo('app', mod);
|
|
99
|
+
|
|
100
|
+
expect(t.trails.size).toBe(1);
|
|
101
|
+
const registered = t.trails.get('trail-1');
|
|
102
|
+
expect(registered?.follow).toEqual(['trail-2']);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('collects services from modules', () => {
|
|
106
|
+
const mod = { db: mockService('db.main') };
|
|
107
|
+
const t = topo('app', mod);
|
|
108
|
+
|
|
109
|
+
expect(t.services.size).toBe(1);
|
|
110
|
+
expect(t.services.get('db.main')).toBe(mod.db);
|
|
111
|
+
});
|
|
78
112
|
});
|
|
79
113
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
114
|
+
describe('duplicate rejection', () => {
|
|
115
|
+
test('rejects duplicate trail IDs', () => {
|
|
116
|
+
const mod1 = { a: mockTrail('dup') };
|
|
117
|
+
const mod2 = { b: mockTrail('dup') };
|
|
118
|
+
|
|
119
|
+
expect(() => topo('app', mod1, mod2)).toThrow(ValidationError);
|
|
120
|
+
expect(() => topo('app', mod1, mod2)).toThrow(
|
|
121
|
+
'Duplicate trail ID: "dup"'
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('rejects duplicate event IDs', () => {
|
|
126
|
+
const mod1 = { a: mockEvent('dup') };
|
|
127
|
+
const mod2 = { b: mockEvent('dup') };
|
|
128
|
+
|
|
129
|
+
expect(() => topo('app', mod1, mod2)).toThrow(ValidationError);
|
|
130
|
+
expect(() => topo('app', mod1, mod2)).toThrow(
|
|
131
|
+
'Duplicate event ID: "dup"'
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('rejects duplicate service IDs', () => {
|
|
136
|
+
const mod1 = { a: mockService('dup') };
|
|
137
|
+
const mod2 = { b: mockService('dup') };
|
|
138
|
+
|
|
139
|
+
expect(() => topo('app', mod1, mod2)).toThrow(ValidationError);
|
|
140
|
+
expect(() => topo('app', mod1, mod2)).toThrow(
|
|
141
|
+
'Duplicate service ID: "dup"'
|
|
142
|
+
);
|
|
143
|
+
});
|
|
95
144
|
});
|
|
145
|
+
});
|
|
96
146
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// topo accessors
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
100
150
|
|
|
101
|
-
|
|
102
|
-
|
|
151
|
+
describe('topo accessors', () => {
|
|
152
|
+
test('ids() returns all trail IDs', () => {
|
|
153
|
+
const a = mockTrail('alpha');
|
|
154
|
+
const b = mockTrail('beta');
|
|
155
|
+
const app = topo('test', { a, b });
|
|
156
|
+
expect(app.ids().toSorted()).toEqual(['alpha', 'beta']);
|
|
103
157
|
});
|
|
104
158
|
|
|
105
|
-
test('
|
|
106
|
-
const
|
|
107
|
-
const
|
|
159
|
+
test('count returns number of trails', () => {
|
|
160
|
+
const a = mockTrail('alpha');
|
|
161
|
+
const app = topo('test', { a });
|
|
162
|
+
expect(app.count).toBe(1);
|
|
163
|
+
});
|
|
108
164
|
|
|
109
|
-
|
|
110
|
-
|
|
165
|
+
test('serviceCount returns number of services', () => {
|
|
166
|
+
const db = mockService('db.main');
|
|
167
|
+
const cache = mockService('cache.main');
|
|
168
|
+
const app = topo('test', { cache, db });
|
|
169
|
+
expect(app.serviceCount).toBe(2);
|
|
111
170
|
});
|
|
112
171
|
|
|
113
|
-
test('
|
|
114
|
-
const
|
|
115
|
-
|
|
172
|
+
test('empty topo has zero count and empty ids', () => {
|
|
173
|
+
const app = topo('empty');
|
|
174
|
+
expect(app.count).toBe(0);
|
|
175
|
+
expect(app.ids()).toEqual([]);
|
|
176
|
+
expect(app.serviceCount).toBe(0);
|
|
177
|
+
expect(app.serviceIds()).toEqual([]);
|
|
178
|
+
});
|
|
116
179
|
|
|
117
|
-
|
|
118
|
-
|
|
180
|
+
test('serviceIds() returns all service IDs', () => {
|
|
181
|
+
const db = mockService('db.main');
|
|
182
|
+
const cache = mockService('cache.main');
|
|
183
|
+
const app = topo('test', { cache, db });
|
|
184
|
+
expect(app.serviceIds().toSorted()).toEqual(['cache.main', 'db.main']);
|
|
119
185
|
});
|
|
120
186
|
});
|
|
121
187
|
|
|
@@ -126,9 +192,10 @@ describe('topo', () => {
|
|
|
126
192
|
describe('Topo', () => {
|
|
127
193
|
const mod = {
|
|
128
194
|
e1: mockEvent('event-1'),
|
|
129
|
-
|
|
195
|
+
s1: mockService('service-1'),
|
|
130
196
|
t1: mockTrail('trail-1'),
|
|
131
197
|
t2: mockTrail('trail-2'),
|
|
198
|
+
t3: mockTrail('trail-3', ['trail-1']),
|
|
132
199
|
};
|
|
133
200
|
|
|
134
201
|
// Build once for the describe block
|
|
@@ -139,8 +206,8 @@ describe('Topo', () => {
|
|
|
139
206
|
expect(app.get('trail-1')).toBe(mod.t1);
|
|
140
207
|
});
|
|
141
208
|
|
|
142
|
-
test('retrieves
|
|
143
|
-
expect(app.get('
|
|
209
|
+
test('retrieves trail with follow by ID', () => {
|
|
210
|
+
expect(app.get('trail-3')).toBe(mod.t3);
|
|
144
211
|
});
|
|
145
212
|
|
|
146
213
|
test('returns undefined for unknown ID', () => {
|
|
@@ -153,26 +220,46 @@ describe('Topo', () => {
|
|
|
153
220
|
expect(app.has('trail-1')).toBe(true);
|
|
154
221
|
});
|
|
155
222
|
|
|
156
|
-
test('returns true for
|
|
157
|
-
expect(app.has('
|
|
223
|
+
test('returns true for trail with follow', () => {
|
|
224
|
+
expect(app.has('trail-3')).toBe(true);
|
|
158
225
|
});
|
|
159
226
|
|
|
160
227
|
test('returns false for unknown ID', () => {
|
|
161
228
|
expect(app.has('nope')).toBe(false);
|
|
162
229
|
});
|
|
163
230
|
|
|
164
|
-
test('returns false for event ID (events are not trails
|
|
231
|
+
test('returns false for event ID (events are not trails)', () => {
|
|
165
232
|
expect(app.has('event-1')).toBe(false);
|
|
166
233
|
});
|
|
167
234
|
});
|
|
168
235
|
|
|
236
|
+
describe('getService()', () => {
|
|
237
|
+
test('retrieves service by ID', () => {
|
|
238
|
+
expect(app.getService('service-1')).toBe(mod.s1);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('returns undefined for unknown service ID', () => {
|
|
242
|
+
expect(app.getService('missing-service')).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('hasService()', () => {
|
|
247
|
+
test('returns true for known service', () => {
|
|
248
|
+
expect(app.hasService('service-1')).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('returns false for unknown service', () => {
|
|
252
|
+
expect(app.hasService('missing-service')).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
169
256
|
describe('listing', () => {
|
|
170
|
-
test('list() returns all trails and
|
|
257
|
+
test('list() returns all trails (with and without follow)', () => {
|
|
171
258
|
const items = app.list();
|
|
172
259
|
expect(items).toHaveLength(3);
|
|
173
260
|
expect(items).toContain(mod.t1);
|
|
174
261
|
expect(items).toContain(mod.t2);
|
|
175
|
-
expect(items).toContain(mod.
|
|
262
|
+
expect(items).toContain(mod.t3);
|
|
176
263
|
});
|
|
177
264
|
|
|
178
265
|
test('listEvents() returns all events', () => {
|
|
@@ -180,5 +267,11 @@ describe('Topo', () => {
|
|
|
180
267
|
expect(items).toHaveLength(1);
|
|
181
268
|
expect(items).toContain(mod.e1);
|
|
182
269
|
});
|
|
270
|
+
|
|
271
|
+
test('listServices() returns all services', () => {
|
|
272
|
+
const items = app.listServices();
|
|
273
|
+
expect(items).toHaveLength(1);
|
|
274
|
+
expect(items).toContain(mod.s1);
|
|
275
|
+
});
|
|
183
276
|
});
|
|
184
277
|
});
|
|
@@ -2,14 +2,26 @@ import { describe, test, expect } from 'bun:test';
|
|
|
2
2
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
5
|
+
import { createTrailContext } from '../context';
|
|
5
6
|
import { Result } from '../result';
|
|
7
|
+
import { service } from '../service';
|
|
6
8
|
import { trail } from '../trail';
|
|
7
9
|
import type { TrailContext } from '../types';
|
|
8
10
|
|
|
9
|
-
const stubCtx: TrailContext = {
|
|
11
|
+
const stubCtx: TrailContext = createTrailContext({
|
|
10
12
|
requestId: 'test-123',
|
|
11
13
|
signal: AbortSignal.timeout(5000),
|
|
12
|
-
};
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const dbService = service('db.main', {
|
|
17
|
+
create: () =>
|
|
18
|
+
Result.ok({
|
|
19
|
+
query(sql: string) {
|
|
20
|
+
return sql.length;
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
description: 'Primary database service',
|
|
24
|
+
});
|
|
13
25
|
|
|
14
26
|
describe('trail()', () => {
|
|
15
27
|
const inputSchema = z.object({ name: z.string() });
|
|
@@ -17,9 +29,9 @@ describe('trail()', () => {
|
|
|
17
29
|
|
|
18
30
|
const greet = trail('greet', {
|
|
19
31
|
description: 'Greet someone',
|
|
20
|
-
implementation: (input) => Result.ok({ greeting: `Hello, ${input.name}!` }),
|
|
21
32
|
input: inputSchema,
|
|
22
33
|
output: outputSchema,
|
|
34
|
+
run: (input) => Result.ok({ greeting: `Hello, ${input.name}!` }),
|
|
23
35
|
});
|
|
24
36
|
|
|
25
37
|
describe('basics', () => {
|
|
@@ -41,14 +53,14 @@ describe('trail()', () => {
|
|
|
41
53
|
|
|
42
54
|
test('output schema is optional', () => {
|
|
43
55
|
const minimal = trail('noop', {
|
|
44
|
-
implementation: () => Result.ok(),
|
|
45
56
|
input: z.object({}),
|
|
57
|
+
run: () => Result.ok(),
|
|
46
58
|
});
|
|
47
59
|
expect(minimal.output).toBeUndefined();
|
|
48
60
|
});
|
|
49
61
|
|
|
50
62
|
test('implementation is callable', async () => {
|
|
51
|
-
const result = await greet.
|
|
63
|
+
const result = await greet.run({ name: 'World' }, stubCtx);
|
|
52
64
|
expect(result.isOk()).toBe(true);
|
|
53
65
|
expect(result.unwrap()).toEqual({ greeting: 'Hello, World!' });
|
|
54
66
|
});
|
|
@@ -61,8 +73,8 @@ describe('trail()', () => {
|
|
|
61
73
|
{ error: 'ValidationError', input: { text: '' }, name: 'error-case' },
|
|
62
74
|
{ expected: { text: 'hi' }, input: { text: 'hi' }, name: 'basic' },
|
|
63
75
|
],
|
|
64
|
-
implementation: (input) => Result.ok({ text: input.text }),
|
|
65
76
|
input: z.object({ text: z.string() }),
|
|
77
|
+
run: (input) => Result.ok({ text: input.text }),
|
|
66
78
|
});
|
|
67
79
|
expect(withExamples.examples).toHaveLength(2);
|
|
68
80
|
const first = withExamples.examples?.[0];
|
|
@@ -71,13 +83,13 @@ describe('trail()', () => {
|
|
|
71
83
|
expect(second?.name).toBe('basic');
|
|
72
84
|
});
|
|
73
85
|
|
|
74
|
-
test('
|
|
75
|
-
const
|
|
76
|
-
implementation: () => Result.ok(),
|
|
86
|
+
test('metadata is stored', () => {
|
|
87
|
+
const withMetadata = trail('tagged', {
|
|
77
88
|
input: z.object({}),
|
|
78
|
-
|
|
89
|
+
metadata: { domain: 'billing', tier: 1 },
|
|
90
|
+
run: () => Result.ok(),
|
|
79
91
|
});
|
|
80
|
-
expect(
|
|
92
|
+
expect(withMetadata.metadata).toEqual({ domain: 'billing', tier: 1 });
|
|
81
93
|
});
|
|
82
94
|
|
|
83
95
|
test('detours are stored', () => {
|
|
@@ -86,8 +98,8 @@ describe('trail()', () => {
|
|
|
86
98
|
onFailure: ['alert'],
|
|
87
99
|
onSuccess: ['notify', 'audit'],
|
|
88
100
|
},
|
|
89
|
-
implementation: () => Result.ok(),
|
|
90
101
|
input: z.object({}),
|
|
102
|
+
run: () => Result.ok(),
|
|
91
103
|
});
|
|
92
104
|
expect(withDetours.detours).toEqual({
|
|
93
105
|
onFailure: ['alert'],
|
|
@@ -96,28 +108,98 @@ describe('trail()', () => {
|
|
|
96
108
|
});
|
|
97
109
|
});
|
|
98
110
|
|
|
99
|
-
describe('
|
|
100
|
-
test('
|
|
111
|
+
describe('follow', () => {
|
|
112
|
+
test('defaults to empty frozen array when omitted', () => {
|
|
113
|
+
const minimal = trail('bare', {
|
|
114
|
+
input: z.object({}),
|
|
115
|
+
run: () => Result.ok(),
|
|
116
|
+
});
|
|
117
|
+
expect(minimal.follow).toEqual([]);
|
|
118
|
+
expect(Object.isFrozen(minimal.follow)).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('preserves follow array', () => {
|
|
122
|
+
const withFollow = trail('composed', {
|
|
123
|
+
follow: ['authenticate', 'validate-session'],
|
|
124
|
+
input: z.object({}),
|
|
125
|
+
run: () => Result.ok(),
|
|
126
|
+
});
|
|
127
|
+
expect(withFollow.follow).toEqual(['authenticate', 'validate-session']);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('follow array is frozen', () => {
|
|
131
|
+
const withFollow = trail('composed', {
|
|
132
|
+
follow: ['authenticate'],
|
|
133
|
+
input: z.object({}),
|
|
134
|
+
run: () => Result.ok(),
|
|
135
|
+
});
|
|
136
|
+
expect(Object.isFrozen(withFollow.follow)).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('services', () => {
|
|
141
|
+
test('defaults to empty frozen array when omitted', () => {
|
|
142
|
+
const minimal = trail('bare', {
|
|
143
|
+
input: z.object({}),
|
|
144
|
+
run: () => Result.ok(),
|
|
145
|
+
});
|
|
146
|
+
expect(minimal.services).toEqual([]);
|
|
147
|
+
expect(Object.isFrozen(minimal.services)).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('preserves declared service objects', () => {
|
|
151
|
+
const withServices = trail('search', {
|
|
152
|
+
input: z.object({}),
|
|
153
|
+
run: () => Result.ok(),
|
|
154
|
+
services: [dbService],
|
|
155
|
+
});
|
|
156
|
+
expect(withServices.services).toEqual([dbService]);
|
|
157
|
+
expect(withServices.services[0]).toBe(dbService);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('services array is frozen', () => {
|
|
161
|
+
const withServices = trail('search', {
|
|
162
|
+
input: z.object({}),
|
|
163
|
+
run: () => Result.ok(),
|
|
164
|
+
services: [dbService],
|
|
165
|
+
});
|
|
166
|
+
expect(Object.isFrozen(withServices.services)).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('intent and idempotent', () => {
|
|
171
|
+
test('intent defaults to write', () => {
|
|
101
172
|
const minimal = trail('bare', {
|
|
102
|
-
implementation: () => Result.ok(),
|
|
103
173
|
input: z.object({}),
|
|
174
|
+
run: () => Result.ok(),
|
|
104
175
|
});
|
|
105
|
-
expect(minimal.
|
|
106
|
-
expect(minimal.destructive).toBeUndefined();
|
|
176
|
+
expect(minimal.intent).toBe('write');
|
|
107
177
|
expect(minimal.idempotent).toBeUndefined();
|
|
108
178
|
});
|
|
109
179
|
|
|
110
|
-
test('
|
|
111
|
-
const
|
|
112
|
-
|
|
180
|
+
test('intent is preserved when set', () => {
|
|
181
|
+
const readTrail = trail('reader', {
|
|
182
|
+
input: z.object({}),
|
|
183
|
+
intent: 'read',
|
|
184
|
+
run: () => Result.ok(),
|
|
185
|
+
});
|
|
186
|
+
expect(readTrail.intent).toBe('read');
|
|
187
|
+
|
|
188
|
+
const destroyTrail = trail('destroyer', {
|
|
189
|
+
input: z.object({}),
|
|
190
|
+
intent: 'destroy',
|
|
191
|
+
run: () => Result.ok(),
|
|
192
|
+
});
|
|
193
|
+
expect(destroyTrail.intent).toBe('destroy');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('idempotent is preserved when set', () => {
|
|
197
|
+
const t = trail('idempotent', {
|
|
113
198
|
idempotent: true,
|
|
114
|
-
implementation: () => Result.ok(),
|
|
115
199
|
input: z.object({}),
|
|
116
|
-
|
|
200
|
+
run: () => Result.ok(),
|
|
117
201
|
});
|
|
118
|
-
expect(
|
|
119
|
-
expect(withFlags.destructive).toBe(false);
|
|
120
|
-
expect(withFlags.idempotent).toBe(true);
|
|
202
|
+
expect(t.idempotent).toBe(true);
|
|
121
203
|
});
|
|
122
204
|
});
|
|
123
205
|
|
|
@@ -125,9 +207,9 @@ describe('trail()', () => {
|
|
|
125
207
|
test('accepts spec with id property', () => {
|
|
126
208
|
const t = trail({
|
|
127
209
|
id: 'entity.show',
|
|
128
|
-
implementation: (input: { name: string }, _ctx: TrailContext) =>
|
|
129
|
-
Result.ok({ greeting: `Hi, ${input.name}` }),
|
|
130
210
|
input: inputSchema,
|
|
211
|
+
run: (input: { name: string }, _ctx: TrailContext) =>
|
|
212
|
+
Result.ok({ greeting: `Hi, ${input.name}` }),
|
|
131
213
|
});
|
|
132
214
|
expect(t.id).toBe('entity.show');
|
|
133
215
|
expect(t.kind).toBe('trail');
|
|
@@ -136,39 +218,39 @@ describe('trail()', () => {
|
|
|
136
218
|
test('preserves all spec fields', () => {
|
|
137
219
|
const t = trail({
|
|
138
220
|
description: 'A full trail',
|
|
139
|
-
destructive: false,
|
|
140
221
|
examples: [{ input: { name: 'World' }, name: 'test' }],
|
|
141
222
|
id: 'full',
|
|
142
|
-
implementation: (input: { name: string }, _ctx: TrailContext) =>
|
|
143
|
-
Result.ok({ greeting: `Hi, ${input.name}` }),
|
|
144
223
|
input: inputSchema,
|
|
224
|
+
intent: 'read',
|
|
145
225
|
output: outputSchema,
|
|
146
|
-
|
|
226
|
+
run: (input: { name: string }, _ctx: TrailContext) =>
|
|
227
|
+
Result.ok({ greeting: `Hi, ${input.name}` }),
|
|
228
|
+
services: [dbService],
|
|
147
229
|
});
|
|
148
230
|
expect(t.description).toBe('A full trail');
|
|
149
|
-
expect(t.
|
|
231
|
+
expect(t.intent).toBe('read');
|
|
150
232
|
expect(t.examples).toHaveLength(1);
|
|
233
|
+
expect(t.services).toEqual([dbService]);
|
|
151
234
|
});
|
|
152
235
|
|
|
153
236
|
test('implementation is callable', async () => {
|
|
154
237
|
const t = trail({
|
|
155
238
|
id: 'callable',
|
|
156
|
-
implementation: (input: { x: number }) => Result.ok(input.x * 2),
|
|
157
239
|
input: z.object({ x: z.number() }),
|
|
240
|
+
run: (input: { x: number }) => Result.ok(input.x * 2),
|
|
158
241
|
});
|
|
159
|
-
const result = await t.
|
|
242
|
+
const result = await t.run({ x: 5 }, stubCtx);
|
|
160
243
|
expect(result.isOk()).toBe(true);
|
|
161
244
|
expect(result.unwrap()).toBe(10);
|
|
162
245
|
});
|
|
163
246
|
|
|
164
247
|
test('sync implementations are normalized to an awaitable runtime function', async () => {
|
|
165
248
|
const t = trail('normalized', {
|
|
166
|
-
implementation: (input: { value: number }) =>
|
|
167
|
-
Result.ok(input.value + 1),
|
|
168
249
|
input: z.object({ value: z.number() }),
|
|
250
|
+
run: (input: { value: number }) => Result.ok(input.value + 1),
|
|
169
251
|
});
|
|
170
252
|
|
|
171
|
-
const promise = t.
|
|
253
|
+
const promise = t.run({ value: 2 }, stubCtx);
|
|
172
254
|
expect(promise).toBeInstanceOf(Promise);
|
|
173
255
|
|
|
174
256
|
const result = await promise;
|