@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.
- package/.turbo/turbo-lint.log +1 -1
- package/CHANGELOG.md +20 -0
- package/README.md +1 -0
- package/dist/context.d.ts +2 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +12 -7
- package/dist/context.js.map +1 -1
- package/dist/execute.d.ts +6 -3
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +149 -6
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/service.d.ts +69 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +56 -0
- package/dist/service.js.map +1 -0
- package/dist/topo.d.ts +7 -0
- package/dist/topo.d.ts.map +1 -1
- package/dist/topo.js +37 -8
- package/dist/topo.js.map +1 -1
- package/dist/trail.d.ts +6 -1
- package/dist/trail.d.ts.map +1 -1
- package/dist/trail.js +2 -1
- package/dist/trail.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-topo.d.ts.map +1 -1
- package/dist/validate-topo.js +16 -0
- package/dist/validate-topo.js.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +34 -3
- package/dist/validation.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/context.test.ts +12 -0
- package/src/__tests__/dispatch.test.ts +29 -2
- package/src/__tests__/execute.test.ts +318 -3
- package/src/__tests__/layer.test.ts +3 -2
- package/src/__tests__/service.test.ts +197 -0
- package/src/__tests__/topo.test.ts +71 -0
- package/src/__tests__/trail.test.ts +46 -2
- package/src/__tests__/validate-topo.test.ts +45 -1
- package/src/__tests__/validation.test.ts +53 -0
- package/src/context.ts +18 -9
- package/src/execute.ts +258 -9
- package/src/index.ts +17 -0
- package/src/service.ts +139 -0
- package/src/topo.ts +53 -9
- package/src/trail.ts +14 -2
- package/src/types.ts +11 -0
- package/src/validate-topo.ts +22 -0
- package/src/validation.ts +35 -3
- 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(
|
|
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
|
|
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<
|
|
12
|
-
): TrailContext =>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
+
};
|