@ontrails/core 1.0.0-beta.10 → 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 (55) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +20 -0
  3. package/README.md +1 -0
  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/execute.d.ts +6 -3
  9. package/dist/execute.d.ts.map +1 -1
  10. package/dist/execute.js +149 -6
  11. package/dist/execute.js.map +1 -1
  12. package/dist/index.d.ts +3 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/service.d.ts +69 -0
  17. package/dist/service.d.ts.map +1 -0
  18. package/dist/service.js +56 -0
  19. package/dist/service.js.map +1 -0
  20. package/dist/topo.d.ts +7 -0
  21. package/dist/topo.d.ts.map +1 -1
  22. package/dist/topo.js +37 -8
  23. package/dist/topo.js.map +1 -1
  24. package/dist/trail.d.ts +6 -1
  25. package/dist/trail.d.ts.map +1 -1
  26. package/dist/trail.js +2 -1
  27. package/dist/trail.js.map +1 -1
  28. package/dist/types.d.ts +9 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/validate-topo.d.ts.map +1 -1
  31. package/dist/validate-topo.js +16 -0
  32. package/dist/validate-topo.js.map +1 -1
  33. package/dist/validation.d.ts.map +1 -1
  34. package/dist/validation.js +34 -3
  35. package/dist/validation.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/__tests__/context.test.ts +12 -0
  38. package/src/__tests__/dispatch.test.ts +29 -2
  39. package/src/__tests__/execute.test.ts +318 -3
  40. package/src/__tests__/layer.test.ts +3 -2
  41. package/src/__tests__/service.test.ts +197 -0
  42. package/src/__tests__/topo.test.ts +71 -0
  43. package/src/__tests__/trail.test.ts +46 -2
  44. package/src/__tests__/validate-topo.test.ts +45 -1
  45. package/src/__tests__/validation.test.ts +53 -0
  46. package/src/context.ts +18 -9
  47. package/src/execute.ts +258 -9
  48. package/src/index.ts +17 -0
  49. package/src/service.ts +139 -0
  50. package/src/topo.ts +53 -9
  51. package/src/trail.ts +14 -2
  52. package/src/types.ts +11 -0
  53. package/src/validate-topo.ts +22 -0
  54. package/src/validation.ts +35 -3
  55. package/tsconfig.tsbuildinfo +1 -1
@@ -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
  // ---------------------------------------------------------------------------
@@ -28,6 +29,12 @@ const mockEvent = (id: string) => ({
28
29
  payload: z.object({ payload: z.string() }),
29
30
  });
30
31
 
32
+ const mockService = (id: string) =>
33
+ service(id, {
34
+ create: () => Result.ok({ id }),
35
+ description: `${id} service`,
36
+ });
37
+
31
38
  // ---------------------------------------------------------------------------
32
39
  // topo()
33
40
  // ---------------------------------------------------------------------------
@@ -50,6 +57,7 @@ describe('topo', () => {
50
57
  test('auto-scans exports by kind discriminant', () => {
51
58
  const mod = {
52
59
  event1: mockEvent('e1'),
60
+ service1: mockService('s1'),
53
61
  trail1: mockTrail('t1'),
54
62
  trail2: mockTrail('t2', ['t1']),
55
63
  };
@@ -57,6 +65,7 @@ describe('topo', () => {
57
65
 
58
66
  expect(t.trails.size).toBe(2);
59
67
  expect(t.events.size).toBe(1);
68
+ expect(t.services.size).toBe(1);
60
69
  });
61
70
 
62
71
  test('collects from multiple modules', () => {
@@ -81,6 +90,7 @@ describe('topo', () => {
81
90
 
82
91
  expect(t.trails.size).toBe(1);
83
92
  expect(t.events.size).toBe(0);
93
+ expect(t.services.size).toBe(0);
84
94
  });
85
95
 
86
96
  test('trail with follow registers correctly', () => {
@@ -91,6 +101,14 @@ describe('topo', () => {
91
101
  const registered = t.trails.get('trail-1');
92
102
  expect(registered?.follow).toEqual(['trail-2']);
93
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
+ });
94
112
  });
95
113
 
96
114
  describe('duplicate rejection', () => {
@@ -113,6 +131,16 @@ describe('topo', () => {
113
131
  'Duplicate event ID: "dup"'
114
132
  );
115
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
+ });
116
144
  });
117
145
  });
118
146
 
@@ -134,10 +162,26 @@ describe('topo accessors', () => {
134
162
  expect(app.count).toBe(1);
135
163
  });
136
164
 
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);
170
+ });
171
+
137
172
  test('empty topo has zero count and empty ids', () => {
138
173
  const app = topo('empty');
139
174
  expect(app.count).toBe(0);
140
175
  expect(app.ids()).toEqual([]);
176
+ expect(app.serviceCount).toBe(0);
177
+ expect(app.serviceIds()).toEqual([]);
178
+ });
179
+
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']);
141
185
  });
142
186
  });
143
187
 
@@ -148,6 +192,7 @@ describe('topo accessors', () => {
148
192
  describe('Topo', () => {
149
193
  const mod = {
150
194
  e1: mockEvent('event-1'),
195
+ s1: mockService('service-1'),
151
196
  t1: mockTrail('trail-1'),
152
197
  t2: mockTrail('trail-2'),
153
198
  t3: mockTrail('trail-3', ['trail-1']),
@@ -188,6 +233,26 @@ describe('Topo', () => {
188
233
  });
189
234
  });
190
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
+
191
256
  describe('listing', () => {
192
257
  test('list() returns all trails (with and without follow)', () => {
193
258
  const items = app.list();
@@ -202,5 +267,11 @@ describe('Topo', () => {
202
267
  expect(items).toHaveLength(1);
203
268
  expect(items).toContain(mod.e1);
204
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
+ });
205
276
  });
206
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() });
@@ -125,6 +137,36 @@ describe('trail()', () => {
125
137
  });
126
138
  });
127
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
+
128
170
  describe('intent and idempotent', () => {
129
171
  test('intent defaults to write', () => {
130
172
  const minimal = trail('bare', {
@@ -183,10 +225,12 @@ describe('trail()', () => {
183
225
  output: outputSchema,
184
226
  run: (input: { name: string }, _ctx: TrailContext) =>
185
227
  Result.ok({ greeting: `Hi, ${input.name}` }),
228
+ services: [dbService],
186
229
  });
187
230
  expect(t.description).toBe('A full trail');
188
231
  expect(t.intent).toBe('read');
189
232
  expect(t.examples).toHaveLength(1);
233
+ expect(t.services).toEqual([dbService]);
190
234
  });
191
235
 
192
236
  test('implementation is callable', async () => {
@@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test';
3
3
  import { z } from 'zod';
4
4
 
5
5
  import { Result } from '../result.js';
6
+ import { service } from '../service.js';
6
7
  import { topo } from '../topo.js';
7
8
  import type { TopoIssue } from '../validate-topo.js';
8
9
  import { validateTopo } from '../validate-topo.js';
@@ -25,6 +26,7 @@ const mockTrail = (
25
26
  error?: string;
26
27
  }[];
27
28
  output?: z.ZodType;
29
+ services?: readonly ReturnType<typeof service>[];
28
30
  }
29
31
  ) => ({
30
32
  follow: Object.freeze([...(overrides?.follow ?? [])]),
@@ -32,9 +34,15 @@ const mockTrail = (
32
34
  input: z.object({ name: z.string() }),
33
35
  kind: 'trail' as const,
34
36
  run: noop,
37
+ services: Object.freeze([...(overrides?.services ?? [])]),
35
38
  ...overrides,
36
39
  });
37
40
 
41
+ const mockService = (id: string) =>
42
+ service(id, {
43
+ create: () => Result.ok({ id }),
44
+ });
45
+
38
46
  const mockEvent = (id: string, from?: readonly string[]) => ({
39
47
  from,
40
48
  id,
@@ -141,6 +149,38 @@ describe('validateTopo', () => {
141
149
  });
142
150
  });
143
151
 
152
+ describe('service declarations', () => {
153
+ test('trail declaring a registered service passes', () => {
154
+ const db = mockService('db.main');
155
+ const app = topo('app', {
156
+ db,
157
+ show: mockTrail('entity.show', {
158
+ services: [db],
159
+ }),
160
+ });
161
+
162
+ const result = validateTopo(app);
163
+ expect(result.isOk()).toBe(true);
164
+ });
165
+
166
+ test('trail declaring a missing service fails', () => {
167
+ const db = mockService('db.main');
168
+ const app = topo('app', {
169
+ show: mockTrail('entity.show', {
170
+ services: [db],
171
+ }),
172
+ });
173
+
174
+ const result = validateTopo(app);
175
+ expect(result.isErr()).toBe(true);
176
+
177
+ const issues = extractIssues(result);
178
+ expect(issues).toHaveLength(1);
179
+ expect(issues[0]?.rule).toBe('service-exists');
180
+ expect(issues[0]?.message).toContain('db.main');
181
+ });
182
+ });
183
+
144
184
  describe('example validation', () => {
145
185
  test('example with invalid input fails', () => {
146
186
  const app = topo('app', {
@@ -261,8 +301,12 @@ describe('validateTopo', () => {
261
301
  });
262
302
 
263
303
  test('collects multiple issues', () => {
304
+ const db = mockService('db.main');
264
305
  const app = topo('app', {
265
306
  broken: mockTrail('entity.broken', { follow: ['entity.missing'] }),
307
+ missingService: mockTrail('entity.missing-service', {
308
+ services: [db],
309
+ }),
266
310
  show: mockTrail('entity.show', {
267
311
  examples: [{ input: { name: 123 }, name: 'Bad' }],
268
312
  }),
@@ -273,6 +317,6 @@ describe('validateTopo', () => {
273
317
  expect(result.isErr()).toBe(true);
274
318
 
275
319
  const issues = extractIssues(result);
276
- expect(issues).toHaveLength(3);
320
+ expect(issues).toHaveLength(4);
277
321
  });
278
322
  });
@@ -280,4 +280,57 @@ describe('zodToJsonSchema', () => {
280
280
  expect(zodToJsonSchema(z.any())).toEqual({});
281
281
  });
282
282
  });
283
+
284
+ describe('default values', () => {
285
+ test('preserves static defaults as-is', () => {
286
+ const schema = z.string().default('hello');
287
+ expect(zodToJsonSchema(schema)).toEqual({
288
+ default: 'hello',
289
+ type: 'string',
290
+ });
291
+ });
292
+
293
+ test('omits dynamic defaults that produce different values', () => {
294
+ let counter = 0;
295
+ const schema = z.string().default(() => {
296
+ counter += 1;
297
+ return `id-${counter}`;
298
+ });
299
+ const result = zodToJsonSchema(schema);
300
+ expect(result).toEqual({ type: 'string' });
301
+ expect(result['default']).toBeUndefined();
302
+ });
303
+
304
+ test('preserves stable functional defaults that return constant values', () => {
305
+ const schema = z.string().default(() => 'constant');
306
+ const result = zodToJsonSchema(schema);
307
+ expect(result).toEqual({ default: 'constant', type: 'string' });
308
+ });
309
+
310
+ test('preserves stable functional defaults that return equivalent objects', () => {
311
+ const schema = z
312
+ .object({ key: z.string() })
313
+ .default(() => ({ key: 'val' }));
314
+ const result = zodToJsonSchema(schema);
315
+ expect(result['default']).toEqual({ key: 'val' });
316
+ });
317
+
318
+ test('preserves stable functional defaults that return equivalent arrays', () => {
319
+ const schema = z.array(z.string()).default(() => ['a', 'b']);
320
+ const result = zodToJsonSchema(schema);
321
+ expect(result['default']).toEqual(['a', 'b']);
322
+ });
323
+
324
+ test('produces identical output across repeated calls', () => {
325
+ let counter = 0;
326
+ const schema = z.string().default(() => {
327
+ counter += 1;
328
+ return `id-${counter}`;
329
+ });
330
+ const first = zodToJsonSchema(schema);
331
+ const second = zodToJsonSchema(schema);
332
+ // Key invariant: repeated calls always produce the same schema
333
+ expect(first).toEqual(second);
334
+ });
335
+ });
283
336
  });
package/src/context.ts CHANGED
@@ -1,4 +1,9 @@
1
- import type { TrailContext } from './types.js';
1
+ import { createServiceLookup } from './service.js';
2
+ import type { TrailContext, TrailContextInit } from './types.js';
3
+
4
+ type MutableTrailContext = {
5
+ -readonly [K in keyof TrailContext]: TrailContext[K];
6
+ };
2
7
 
3
8
  /**
4
9
  * Create a TrailContext with sensible defaults.
@@ -8,11 +13,15 @@ import type { TrailContext } from './types.js';
8
13
  * - All other fields come from `overrides`
9
14
  */
10
15
  export const createTrailContext = (
11
- overrides?: Partial<TrailContext>
12
- ): TrailContext => ({
13
- cwd: process.cwd(),
14
- env: process.env as Record<string, string | undefined>,
15
- requestId: Bun.randomUUIDv7(),
16
- signal: new AbortController().signal,
17
- ...overrides,
18
- });
16
+ overrides?: Partial<TrailContextInit>
17
+ ): TrailContext => {
18
+ const ctx = {
19
+ cwd: process.cwd(),
20
+ env: process.env as Record<string, string | undefined>,
21
+ requestId: Bun.randomUUIDv7(),
22
+ signal: new AbortController().signal,
23
+ ...overrides,
24
+ } as MutableTrailContext;
25
+ ctx.service = overrides?.service ?? createServiceLookup(() => ctx);
26
+ return ctx;
27
+ };