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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +101 -0
  3. package/README.md +53 -11
  4. package/dist/derive.d.ts +1 -1
  5. package/dist/derive.d.ts.map +1 -1
  6. package/dist/derive.js +4 -1
  7. package/dist/derive.js.map +1 -1
  8. package/dist/dispatch.d.ts +27 -0
  9. package/dist/dispatch.d.ts.map +1 -0
  10. package/dist/dispatch.js +34 -0
  11. package/dist/dispatch.js.map +1 -0
  12. package/dist/event.d.ts +2 -2
  13. package/dist/event.d.ts.map +1 -1
  14. package/dist/event.js +1 -1
  15. package/dist/event.js.map +1 -1
  16. package/dist/execute.d.ts +30 -0
  17. package/dist/execute.d.ts.map +1 -0
  18. package/dist/execute.js +64 -0
  19. package/dist/execute.js.map +1 -0
  20. package/dist/index.d.ts +10 -6
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +7 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/patterns/status.d.ts +1 -1
  25. package/dist/result.d.ts.map +1 -1
  26. package/dist/result.js +15 -4
  27. package/dist/result.js.map +1 -1
  28. package/dist/serialization.d.ts.map +1 -1
  29. package/dist/serialization.js +45 -7
  30. package/dist/serialization.js.map +1 -1
  31. package/dist/topo.d.ts +4 -4
  32. package/dist/topo.d.ts.map +1 -1
  33. package/dist/topo.js +12 -16
  34. package/dist/topo.js.map +1 -1
  35. package/dist/trail.d.ts +16 -10
  36. package/dist/trail.d.ts.map +1 -1
  37. package/dist/trail.js +4 -2
  38. package/dist/trail.js.map +1 -1
  39. package/dist/type-utils.d.ts +24 -0
  40. package/dist/type-utils.d.ts.map +1 -0
  41. package/dist/type-utils.js +12 -0
  42. package/dist/type-utils.js.map +1 -0
  43. package/dist/types.d.ts +1 -2
  44. package/dist/types.d.ts.map +1 -1
  45. package/dist/validate-topo.d.ts +2 -2
  46. package/dist/validate-topo.d.ts.map +1 -1
  47. package/dist/validate-topo.js +59 -9
  48. package/dist/validate-topo.js.map +1 -1
  49. package/package.json +2 -2
  50. package/src/__tests__/context.test.ts +4 -5
  51. package/src/__tests__/derive.test.ts +44 -0
  52. package/src/__tests__/dispatch.test.ts +154 -0
  53. package/src/__tests__/event.test.ts +5 -5
  54. package/src/__tests__/execute.test.ts +208 -0
  55. package/src/__tests__/layer.test.ts +11 -111
  56. package/src/__tests__/serialization.test.ts +166 -1
  57. package/src/__tests__/topo.test.ts +101 -79
  58. package/src/__tests__/trail.test.ts +73 -35
  59. package/src/__tests__/type-utils.test.ts +90 -0
  60. package/src/__tests__/validate-topo.test.ts +97 -20
  61. package/src/derive.ts +12 -2
  62. package/src/dispatch.ts +54 -0
  63. package/src/event.ts +3 -3
  64. package/src/execute.ts +96 -0
  65. package/src/index.ts +22 -18
  66. package/src/result.ts +18 -4
  67. package/src/serialization.ts +56 -11
  68. package/src/topo.ts +18 -23
  69. package/src/trail.ts +24 -13
  70. package/src/type-utils.ts +45 -0
  71. package/src/types.ts +1 -3
  72. package/src/validate-topo.ts +70 -10
  73. package/tsconfig.tsbuildinfo +1 -1
  74. package/dist/hike.d.ts +0 -36
  75. package/dist/hike.d.ts.map +0 -1
  76. package/dist/hike.js +0 -20
  77. package/dist/hike.js.map +0 -1
  78. package/src/__tests__/hike.test.ts +0 -117
  79. package/src/__tests__/job.test.ts +0 -98
  80. package/src/adapters.ts +0 -68
  81. package/src/health.ts +0 -23
  82. package/src/hike.ts +0 -77
  83. package/src/job.ts +0 -20
@@ -2,13 +2,24 @@ import { describe, test, expect } from 'bun:test';
2
2
 
3
3
  import {
4
4
  ValidationError,
5
+ AmbiguousError,
6
+ AssertionError,
5
7
  NetworkError,
6
8
  RateLimitError,
7
9
  InternalError,
8
10
  TimeoutError,
9
11
  NotFoundError,
12
+ AlreadyExistsError,
13
+ ConflictError,
14
+ PermissionError,
15
+ AuthError,
16
+ CancelledError,
10
17
  } from '../errors.js';
11
- import { serializeError, deserializeError } from '../serialization.js';
18
+ import {
19
+ serializeError,
20
+ deserializeError,
21
+ safeStringify,
22
+ } from '../serialization.js';
12
23
  import { Result } from '../result.js';
13
24
  import type { SerializedError } from '../serialization.js';
14
25
 
@@ -156,6 +167,66 @@ describe('deserializeError', () => {
156
167
  expect(err.category).toBe(category);
157
168
  }
158
169
  });
170
+
171
+ describe('round-trips all subclasses by name', () => {
172
+ const subclasses = [
173
+ { Ctor: ValidationError, category: 'validation' },
174
+ { Ctor: AmbiguousError, category: 'validation' },
175
+ { Ctor: AssertionError, category: 'internal' },
176
+ { Ctor: NotFoundError, category: 'not_found' },
177
+ { Ctor: AlreadyExistsError, category: 'conflict' },
178
+ { Ctor: ConflictError, category: 'conflict' },
179
+ { Ctor: PermissionError, category: 'permission' },
180
+ { Ctor: TimeoutError, category: 'timeout' },
181
+ { Ctor: NetworkError, category: 'network' },
182
+ { Ctor: InternalError, category: 'internal' },
183
+ { Ctor: AuthError, category: 'auth' },
184
+ { Ctor: CancelledError, category: 'cancelled' },
185
+ ] as const;
186
+
187
+ test.each(subclasses)(
188
+ '$Ctor.name round-trips with correct identity',
189
+ ({ Ctor, category }) => {
190
+ const original = new Ctor(`test ${Ctor.name}`, {
191
+ context: { key: 'value' },
192
+ });
193
+ const serialized = serializeError(original);
194
+ const restored = deserializeError(serialized);
195
+
196
+ expect(restored).toBeInstanceOf(Ctor);
197
+ expect(restored.constructor.name).toBe(Ctor.name);
198
+ expect(restored.name).toBe(Ctor.name);
199
+ expect(restored.category).toBe(category);
200
+ expect(restored.message).toBe(`test ${Ctor.name}`);
201
+ expect(restored.context).toEqual({ key: 'value' });
202
+ }
203
+ );
204
+
205
+ test('RateLimitError round-trips with retryAfter', () => {
206
+ const original = new RateLimitError('slow down', {
207
+ context: { endpoint: '/api' },
208
+ retryAfter: 42,
209
+ });
210
+ const serialized = serializeError(original);
211
+ const restored = deserializeError(serialized);
212
+
213
+ expect(restored).toBeInstanceOf(RateLimitError);
214
+ expect(restored.constructor.name).toBe('RateLimitError');
215
+ expect((restored as RateLimitError).retryAfter).toBe(42);
216
+ expect(restored.context).toEqual({ endpoint: '/api' });
217
+ });
218
+
219
+ test('falls back to category when name is unknown', () => {
220
+ const data: SerializedError = {
221
+ category: 'conflict',
222
+ message: 'custom error',
223
+ name: 'CustomConflictError',
224
+ };
225
+ const err = deserializeError(data);
226
+ expect(err).toBeInstanceOf(ConflictError);
227
+ expect(err.category).toBe('conflict');
228
+ });
229
+ });
159
230
  });
160
231
 
161
232
  // ---------------------------------------------------------------------------
@@ -233,4 +304,98 @@ describe('Result.toJson (safeStringify)', () => {
233
304
  expect(parsed['a']).toBe(1);
234
305
  expect(parsed['self']).toBe('[Circular]');
235
306
  });
307
+
308
+ test('serializes shared references in a DAG without marking as circular', () => {
309
+ const shared = { x: 1 };
310
+ const obj = { a: shared, b: shared };
311
+ const result = Result.toJson(obj);
312
+ expect(result.isOk()).toBe(true);
313
+ const parsed = JSON.parse(result.unwrap()) as Record<string, unknown>;
314
+ expect(parsed['a']).toEqual({ x: 1 });
315
+ expect(parsed['b']).toEqual({ x: 1 });
316
+ });
317
+
318
+ test('detects deep circular references', () => {
319
+ const inner: Record<string, unknown> = { value: 'deep' };
320
+ const obj: Record<string, unknown> = { child: { nested: inner } };
321
+ inner['back'] = obj;
322
+ const result = Result.toJson(obj);
323
+ expect(result.isOk()).toBe(true);
324
+ const parsed = JSON.parse(result.unwrap()) as Record<string, unknown>;
325
+ const child = parsed['child'] as Record<string, unknown>;
326
+ const nested = child['nested'] as Record<string, unknown>;
327
+ expect(nested['value']).toBe('deep');
328
+ expect(nested['back']).toBe('[Circular]');
329
+ });
330
+
331
+ test('handles shared ref used in sibling subtrees of a DAG', () => {
332
+ const shared = { id: 42 };
333
+ const obj = {
334
+ left: { extra: 'l', ref: shared },
335
+ right: { extra: 'r', ref: shared },
336
+ };
337
+ const result = Result.toJson(obj);
338
+ expect(result.isOk()).toBe(true);
339
+ const parsed = JSON.parse(result.unwrap()) as Record<
340
+ string,
341
+ Record<string, unknown>
342
+ >;
343
+ expect(parsed['left']?.['ref']).toEqual({ id: 42 });
344
+ expect(parsed['right']?.['ref']).toEqual({ id: 42 });
345
+ });
346
+ });
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // safeStringify (shared DAG / circular detection)
350
+ // ---------------------------------------------------------------------------
351
+
352
+ describe('safeStringify', () => {
353
+ test('serializes shared references in a DAG without marking as circular', () => {
354
+ const shared = { x: 1 };
355
+ const obj = { a: shared, b: shared };
356
+ const result = safeStringify(obj);
357
+ expect(result.isOk()).toBe(true);
358
+ const parsed = JSON.parse(result.unwrap()) as Record<string, unknown>;
359
+ expect(parsed['a']).toEqual({ x: 1 });
360
+ expect(parsed['b']).toEqual({ x: 1 });
361
+ });
362
+
363
+ test('detects true circular references', () => {
364
+ const obj: Record<string, unknown> = { a: 1 };
365
+ obj['self'] = obj;
366
+ const result = safeStringify(obj);
367
+ expect(result.isOk()).toBe(true);
368
+ const parsed = JSON.parse(result.unwrap()) as Record<string, unknown>;
369
+ expect(parsed['a']).toBe(1);
370
+ expect(parsed['self']).toBe('[Circular]');
371
+ });
372
+
373
+ test('detects deep circular references', () => {
374
+ const inner: Record<string, unknown> = { value: 'deep' };
375
+ const obj: Record<string, unknown> = { child: { nested: inner } };
376
+ inner['back'] = obj;
377
+ const result = safeStringify(obj);
378
+ expect(result.isOk()).toBe(true);
379
+ const parsed = JSON.parse(result.unwrap()) as Record<string, unknown>;
380
+ const child = parsed['child'] as Record<string, unknown>;
381
+ const nested = child['nested'] as Record<string, unknown>;
382
+ expect(nested['value']).toBe('deep');
383
+ expect(nested['back']).toBe('[Circular]');
384
+ });
385
+
386
+ test('handles shared ref used in sibling subtrees of a DAG', () => {
387
+ const shared = { id: 42 };
388
+ const obj = {
389
+ left: { extra: 'l', ref: shared },
390
+ right: { extra: 'r', ref: shared },
391
+ };
392
+ const result = safeStringify(obj);
393
+ expect(result.isOk()).toBe(true);
394
+ const parsed = JSON.parse(result.unwrap()) as Record<
395
+ string,
396
+ Record<string, unknown>
397
+ >;
398
+ expect(parsed['left']?.['ref']).toEqual({ id: 42 });
399
+ expect(parsed['right']?.['ref']).toEqual({ id: 42 });
400
+ });
236
401
  });
@@ -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
- test('returns Topo with name', () => {
46
- const t = topo('my-app');
47
- expect(t.name).toBe('my-app');
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
- test('collects trails from modules', () => {
51
- const mod = { myTrail: mockTrail('create-user') };
52
- const t = topo('app', mod);
42
+ test('collects trails from modules', () => {
43
+ const mod = { myTrail: mockTrail('create-user') };
44
+ const t = topo('app', mod);
53
45
 
54
- expect(t.trails.size).toBe(1);
55
- expect(t.trails.get('create-user')).toBe(mod.myTrail);
56
- });
46
+ expect(t.trails.size).toBe(1);
47
+ expect(t.trails.get('create-user')).toBe(mod.myTrail);
48
+ });
57
49
 
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
- });
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
- 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);
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
- expect(t.trails.size).toBe(2);
77
- expect(t.hikes.size).toBe(1);
78
- });
67
+ expect(t.trails.size).toBe(3);
68
+ });
79
69
 
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);
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
- test('rejects duplicate trail IDs', () => {
98
- const mod1 = { a: mockTrail('dup') };
99
- const mod2 = { b: mockTrail('dup') };
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
- expect(() => topo('app', mod1, mod2)).toThrow(ValidationError);
102
- expect(() => topo('app', mod1, mod2)).toThrow('Duplicate trail ID: "dup"');
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
- test('rejects duplicate hike IDs', () => {
106
- const mod1 = { a: mockHike('dup') };
107
- const mod2 = { b: mockHike('dup') };
119
+ // ---------------------------------------------------------------------------
120
+ // topo accessors
121
+ // ---------------------------------------------------------------------------
108
122
 
109
- expect(() => topo('app', mod1, mod2)).toThrow(ValidationError);
110
- expect(() => topo('app', mod1, mod2)).toThrow('Duplicate hike ID: "dup"');
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('rejects duplicate event IDs', () => {
114
- const mod1 = { a: mockEvent('dup') };
115
- const mod2 = { b: mockEvent('dup') };
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
- expect(() => topo('app', mod1, mod2)).toThrow(ValidationError);
118
- expect(() => topo('app', mod1, mod2)).toThrow('Duplicate event ID: "dup"');
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 hike by ID', () => {
143
- expect(app.get('hike-1')).toBe(mod.h1);
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 known hike', () => {
157
- expect(app.has('hike-1')).toBe(true);
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/hikes)', () => {
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 hikes', () => {
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.h1);
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.implementation({ name: 'World' }, stubCtx);
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('markers are stored', () => {
75
- const withMarkers = trail('tagged', {
76
- implementation: () => Result.ok(),
74
+ test('metadata is stored', () => {
75
+ const withMetadata = trail('tagged', {
77
76
  input: z.object({}),
78
- markers: { domain: 'billing', tier: 1 },
77
+ metadata: { domain: 'billing', tier: 1 },
78
+ run: () => Result.ok(),
79
79
  });
80
- expect(withMarkers.markers).toEqual({ domain: 'billing', tier: 1 });
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('boolean flags', () => {
100
- test('boolean flags default to undefined', () => {
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.readOnly).toBeUndefined();
106
- expect(minimal.destructive).toBeUndefined();
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('boolean flags are preserved when set', () => {
111
- const withFlags = trail('flagged', {
112
- destructive: false,
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
- readOnly: true,
158
+ run: () => Result.ok(),
117
159
  });
118
- expect(withFlags.readOnly).toBe(true);
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
- readOnly: true,
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.readOnly).toBe(true);
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.implementation({ x: 5 }, stubCtx);
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.implementation({ value: 2 }, stubCtx);
209
+ const promise = t.run({ value: 2 }, stubCtx);
172
210
  expect(promise).toBeInstanceOf(Promise);
173
211
 
174
212
  const result = await promise;