@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.
Files changed (84) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +109 -0
  3. package/README.md +101 -131
  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 +11 -11
  26. package/dist/result.d.ts.map +1 -1
  27. package/dist/result.js +15 -4
  28. package/dist/result.js.map +1 -1
  29. package/dist/serialization.d.ts.map +1 -1
  30. package/dist/serialization.js +45 -7
  31. package/dist/serialization.js.map +1 -1
  32. package/dist/topo.d.ts +4 -4
  33. package/dist/topo.d.ts.map +1 -1
  34. package/dist/topo.js +12 -16
  35. package/dist/topo.js.map +1 -1
  36. package/dist/trail.d.ts +16 -10
  37. package/dist/trail.d.ts.map +1 -1
  38. package/dist/trail.js +4 -2
  39. package/dist/trail.js.map +1 -1
  40. package/dist/type-utils.d.ts +24 -0
  41. package/dist/type-utils.d.ts.map +1 -0
  42. package/dist/type-utils.js +12 -0
  43. package/dist/type-utils.js.map +1 -0
  44. package/dist/types.d.ts +1 -2
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/validate-topo.d.ts +2 -2
  47. package/dist/validate-topo.d.ts.map +1 -1
  48. package/dist/validate-topo.js +59 -9
  49. package/dist/validate-topo.js.map +1 -1
  50. package/package.json +2 -2
  51. package/src/__tests__/context.test.ts +4 -5
  52. package/src/__tests__/derive.test.ts +44 -0
  53. package/src/__tests__/dispatch.test.ts +154 -0
  54. package/src/__tests__/event.test.ts +5 -5
  55. package/src/__tests__/execute.test.ts +208 -0
  56. package/src/__tests__/layer.test.ts +11 -111
  57. package/src/__tests__/serialization.test.ts +166 -1
  58. package/src/__tests__/topo.test.ts +101 -79
  59. package/src/__tests__/trail.test.ts +73 -35
  60. package/src/__tests__/type-utils.test.ts +90 -0
  61. package/src/__tests__/validate-topo.test.ts +97 -20
  62. package/src/derive.ts +12 -2
  63. package/src/dispatch.ts +54 -0
  64. package/src/event.ts +3 -3
  65. package/src/execute.ts +96 -0
  66. package/src/index.ts +22 -18
  67. package/src/result.ts +29 -15
  68. package/src/serialization.ts +56 -11
  69. package/src/topo.ts +18 -23
  70. package/src/trail.ts +24 -13
  71. package/src/type-utils.ts +45 -0
  72. package/src/types.ts +1 -3
  73. package/src/validate-topo.ts +70 -10
  74. package/tsconfig.tsbuildinfo +1 -1
  75. package/dist/hike.d.ts +0 -36
  76. package/dist/hike.d.ts.map +0 -1
  77. package/dist/hike.js +0 -20
  78. package/dist/hike.js.map +0 -1
  79. package/src/__tests__/hike.test.ts +0 -117
  80. package/src/__tests__/job.test.ts +0 -98
  81. package/src/adapters.ts +0 -68
  82. package/src/health.ts +0 -23
  83. package/src/hike.ts +0 -77
  84. 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
- 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;
@@ -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
+ });