@ontrails/core 1.0.0-beta.1 → 1.0.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +121 -0
  3. package/README.md +54 -11
  4. package/dist/context.d.ts +2 -2
  5. package/dist/context.d.ts.map +1 -1
  6. package/dist/context.js +12 -7
  7. package/dist/context.js.map +1 -1
  8. package/dist/derive.d.ts +1 -1
  9. package/dist/derive.d.ts.map +1 -1
  10. package/dist/derive.js +4 -1
  11. package/dist/derive.js.map +1 -1
  12. package/dist/dispatch.d.ts +27 -0
  13. package/dist/dispatch.d.ts.map +1 -0
  14. package/dist/dispatch.js +34 -0
  15. package/dist/dispatch.js.map +1 -0
  16. package/dist/event.d.ts +2 -2
  17. package/dist/event.d.ts.map +1 -1
  18. package/dist/event.js +1 -1
  19. package/dist/event.js.map +1 -1
  20. package/dist/execute.d.ts +33 -0
  21. package/dist/execute.d.ts.map +1 -0
  22. package/dist/execute.js +207 -0
  23. package/dist/execute.js.map +1 -0
  24. package/dist/index.d.ts +12 -6
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +9 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/patterns/status.d.ts +1 -1
  29. package/dist/result.d.ts.map +1 -1
  30. package/dist/result.js +15 -4
  31. package/dist/result.js.map +1 -1
  32. package/dist/serialization.d.ts.map +1 -1
  33. package/dist/serialization.js +45 -7
  34. package/dist/serialization.js.map +1 -1
  35. package/dist/service.d.ts +69 -0
  36. package/dist/service.d.ts.map +1 -0
  37. package/dist/service.js +56 -0
  38. package/dist/service.js.map +1 -0
  39. package/dist/topo.d.ts +11 -4
  40. package/dist/topo.d.ts.map +1 -1
  41. package/dist/topo.js +43 -18
  42. package/dist/topo.js.map +1 -1
  43. package/dist/trail.d.ts +21 -10
  44. package/dist/trail.d.ts.map +1 -1
  45. package/dist/trail.js +5 -2
  46. package/dist/trail.js.map +1 -1
  47. package/dist/type-utils.d.ts +24 -0
  48. package/dist/type-utils.d.ts.map +1 -0
  49. package/dist/type-utils.js +12 -0
  50. package/dist/type-utils.js.map +1 -0
  51. package/dist/types.d.ts +10 -2
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/validate-topo.d.ts +2 -2
  54. package/dist/validate-topo.d.ts.map +1 -1
  55. package/dist/validate-topo.js +75 -9
  56. package/dist/validate-topo.js.map +1 -1
  57. package/dist/validation.d.ts.map +1 -1
  58. package/dist/validation.js +34 -3
  59. package/dist/validation.js.map +1 -1
  60. package/package.json +2 -2
  61. package/src/__tests__/context.test.ts +16 -5
  62. package/src/__tests__/derive.test.ts +44 -0
  63. package/src/__tests__/dispatch.test.ts +181 -0
  64. package/src/__tests__/event.test.ts +5 -5
  65. package/src/__tests__/execute.test.ts +523 -0
  66. package/src/__tests__/layer.test.ts +14 -113
  67. package/src/__tests__/serialization.test.ts +166 -1
  68. package/src/__tests__/service.test.ts +197 -0
  69. package/src/__tests__/topo.test.ts +171 -78
  70. package/src/__tests__/trail.test.ts +119 -37
  71. package/src/__tests__/type-utils.test.ts +90 -0
  72. package/src/__tests__/validate-topo.test.ts +140 -19
  73. package/src/__tests__/validation.test.ts +53 -0
  74. package/src/context.ts +18 -9
  75. package/src/derive.ts +12 -2
  76. package/src/dispatch.ts +54 -0
  77. package/src/event.ts +3 -3
  78. package/src/execute.ts +345 -0
  79. package/src/index.ts +39 -18
  80. package/src/result.ts +18 -4
  81. package/src/serialization.ts +56 -11
  82. package/src/service.ts +139 -0
  83. package/src/topo.ts +66 -27
  84. package/src/trail.ts +36 -13
  85. package/src/type-utils.ts +45 -0
  86. package/src/types.ts +11 -2
  87. package/src/validate-topo.ts +92 -10
  88. package/src/validation.ts +35 -3
  89. package/tsconfig.tsbuildinfo +1 -1
  90. package/dist/hike.d.ts +0 -36
  91. package/dist/hike.d.ts.map +0 -1
  92. package/dist/hike.js +0 -20
  93. package/dist/hike.js.map +0 -1
  94. package/src/__tests__/hike.test.ts +0 -117
  95. package/src/__tests__/job.test.ts +0 -98
  96. package/src/adapters.ts +0 -68
  97. package/src/health.ts +0 -23
  98. package/src/hike.ts +0 -77
  99. package/src/job.ts +0 -20
@@ -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
- test('returns Topo with name', () => {
46
- const t = topo('my-app');
47
- expect(t.name).toBe('my-app');
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
- test('collects trails from modules', () => {
51
- const mod = { myTrail: mockTrail('create-user') };
52
- const t = topo('app', mod);
49
+ test('collects trails from modules', () => {
50
+ const mod = { myTrail: mockTrail('create-user') };
51
+ const t = topo('app', mod);
53
52
 
54
- expect(t.trails.size).toBe(1);
55
- expect(t.trails.get('create-user')).toBe(mod.myTrail);
56
- });
53
+ expect(t.trails.size).toBe(1);
54
+ expect(t.trails.get('create-user')).toBe(mod.myTrail);
55
+ });
57
56
 
58
- test('auto-scans exports by kind discriminant', () => {
59
- const mod = {
60
- event1: mockEvent('e1'),
61
- hike1: mockHike('r1'),
62
- trail1: mockTrail('t1'),
63
- };
64
- const t = topo('app', mod);
65
-
66
- expect(t.trails.size).toBe(1);
67
- expect(t.hikes.size).toBe(1);
68
- expect(t.events.size).toBe(1);
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
- test('collects from multiple modules', () => {
72
- const mod1 = { a: mockTrail('t1') };
73
- const mod2 = { b: mockTrail('t2'), c: mockHike('r1') };
74
- const t = topo('app', mod1, mod2);
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
- expect(t.trails.size).toBe(2);
77
- expect(t.hikes.size).toBe(1);
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
- test('non-trail exports are silently ignored', () => {
81
- const mod = {
82
- config: { port: 3000 },
83
- helper: () => 'not a trail',
84
- name: 'some-string',
85
- nothing: null,
86
- num: 42,
87
- trail1: mockTrail('t1'),
88
- undef: undefined,
89
- };
90
- const t = topo('app', mod);
91
-
92
- expect(t.trails.size).toBe(1);
93
- expect(t.hikes.size).toBe(0);
94
- expect(t.events.size).toBe(0);
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
- test('rejects duplicate trail IDs', () => {
98
- const mod1 = { a: mockTrail('dup') };
99
- const mod2 = { b: mockTrail('dup') };
147
+ // ---------------------------------------------------------------------------
148
+ // topo accessors
149
+ // ---------------------------------------------------------------------------
100
150
 
101
- expect(() => topo('app', mod1, mod2)).toThrow(ValidationError);
102
- expect(() => topo('app', mod1, mod2)).toThrow('Duplicate trail ID: "dup"');
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('rejects duplicate hike IDs', () => {
106
- const mod1 = { a: mockHike('dup') };
107
- const mod2 = { b: mockHike('dup') };
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
- expect(() => topo('app', mod1, mod2)).toThrow(ValidationError);
110
- expect(() => topo('app', mod1, mod2)).toThrow('Duplicate hike ID: "dup"');
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('rejects duplicate event IDs', () => {
114
- const mod1 = { a: mockEvent('dup') };
115
- const mod2 = { b: mockEvent('dup') };
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
- expect(() => topo('app', mod1, mod2)).toThrow(ValidationError);
118
- expect(() => topo('app', mod1, mod2)).toThrow('Duplicate event ID: "dup"');
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
- h1: mockHike('hike-1'),
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 hike by ID', () => {
143
- expect(app.get('hike-1')).toBe(mod.h1);
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 known hike', () => {
157
- expect(app.has('hike-1')).toBe(true);
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/hikes)', () => {
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 hikes', () => {
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.h1);
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.implementation({ name: 'World' }, stubCtx);
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('markers are stored', () => {
75
- const withMarkers = trail('tagged', {
76
- implementation: () => Result.ok(),
86
+ test('metadata is stored', () => {
87
+ const withMetadata = trail('tagged', {
77
88
  input: z.object({}),
78
- markers: { domain: 'billing', tier: 1 },
89
+ metadata: { domain: 'billing', tier: 1 },
90
+ run: () => Result.ok(),
79
91
  });
80
- expect(withMarkers.markers).toEqual({ domain: 'billing', tier: 1 });
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('boolean flags', () => {
100
- test('boolean flags default to undefined', () => {
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.readOnly).toBeUndefined();
106
- expect(minimal.destructive).toBeUndefined();
176
+ expect(minimal.intent).toBe('write');
107
177
  expect(minimal.idempotent).toBeUndefined();
108
178
  });
109
179
 
110
- test('boolean flags are preserved when set', () => {
111
- const withFlags = trail('flagged', {
112
- destructive: false,
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
- readOnly: true,
200
+ run: () => Result.ok(),
117
201
  });
118
- expect(withFlags.readOnly).toBe(true);
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
- readOnly: true,
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.readOnly).toBe(true);
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.implementation({ x: 5 }, stubCtx);
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.implementation({ value: 2 }, stubCtx);
253
+ const promise = t.run({ value: 2 }, stubCtx);
172
254
  expect(promise).toBeInstanceOf(Promise);
173
255
 
174
256
  const result = await promise;