@ontrails/core 1.0.0-beta.10 → 1.0.0-beta.12
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 +33 -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 +8 -3
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +25 -6
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/service-config.d.ts +22 -0
- package/dist/service-config.d.ts.map +1 -0
- package/dist/service-config.js +208 -0
- package/dist/service-config.js.map +1 -0
- package/dist/service.d.ts +75 -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 +9 -2
- 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 +27 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -1
- package/dist/types.js.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-config.test.ts +224 -0
- package/src/__tests__/service.test.ts +197 -0
- package/src/__tests__/topo.test.ts +71 -0
- package/src/__tests__/trail-permit.test.ts +60 -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 +63 -9
- package/src/index.ts +20 -0
- package/src/service-config.ts +354 -0
- package/src/service.ts +145 -0
- package/src/topo.ts +53 -9
- package/src/trail.ts +21 -3
- package/src/types.ts +32 -1
- package/src/validate-topo.ts +22 -0
- package/src/validation.ts +35 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
Result,
|
|
6
|
+
createTrailContext,
|
|
7
|
+
findDuplicateServiceId,
|
|
8
|
+
isService,
|
|
9
|
+
service as defineService,
|
|
10
|
+
} from '../index.js';
|
|
11
|
+
import type {
|
|
12
|
+
Service,
|
|
13
|
+
ServiceContext,
|
|
14
|
+
ServiceSpec,
|
|
15
|
+
TrailContext,
|
|
16
|
+
} from '../index.js';
|
|
17
|
+
|
|
18
|
+
const serviceCtx: ServiceContext = {
|
|
19
|
+
cwd: '/tmp/trails',
|
|
20
|
+
env: { DATABASE_URL: 'file::memory:' },
|
|
21
|
+
workspaceRoot: '/tmp',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let disposedValue: number | undefined;
|
|
25
|
+
|
|
26
|
+
const counterServiceSpec: ServiceSpec<number> = {
|
|
27
|
+
create: () => Result.ok(3),
|
|
28
|
+
description: 'Counter service',
|
|
29
|
+
dispose: (service) => {
|
|
30
|
+
disposedValue = service;
|
|
31
|
+
},
|
|
32
|
+
health: (service) => Result.ok({ healthy: service > 0 }),
|
|
33
|
+
metadata: { domain: 'data' },
|
|
34
|
+
mock: () => 1,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const resolvedServiceCtx = (id: string, instance: unknown): TrailContext =>
|
|
38
|
+
createTrailContext({
|
|
39
|
+
extensions: { [id]: instance },
|
|
40
|
+
requestId: `${id}-request`,
|
|
41
|
+
signal: new AbortController().signal,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('service types', () => {
|
|
45
|
+
test('ServiceContext exposes the stable process-scoped fields', () => {
|
|
46
|
+
expect(serviceCtx.cwd).toBe('/tmp/trails');
|
|
47
|
+
expect(serviceCtx.env?.DATABASE_URL).toBe('file::memory:');
|
|
48
|
+
expect(serviceCtx.workspaceRoot).toBe('/tmp');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('ServiceSpec stores description and metadata', () => {
|
|
52
|
+
expect(counterServiceSpec.description).toBe('Counter service');
|
|
53
|
+
expect(counterServiceSpec.metadata).toEqual({ domain: 'data' });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('ServiceSpec can reserve a config schema for future composition', () => {
|
|
57
|
+
const config = z.object({ url: z.string().url() });
|
|
58
|
+
const spec: ServiceSpec<number> = {
|
|
59
|
+
config,
|
|
60
|
+
create: () => Result.ok(1),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
expect(spec.config).toBe(config);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('ServiceSpec create and health callbacks are callable', async () => {
|
|
67
|
+
const result = await counterServiceSpec.create(serviceCtx);
|
|
68
|
+
expect(result.isOk()).toBe(true);
|
|
69
|
+
expect(result.unwrap()).toBe(3);
|
|
70
|
+
|
|
71
|
+
const health = await counterServiceSpec.health?.(result.unwrap());
|
|
72
|
+
expect(health?.isOk()).toBe(true);
|
|
73
|
+
expect(health?.unwrap()).toEqual({ healthy: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('ServiceSpec mock and dispose callbacks are callable', async () => {
|
|
77
|
+
disposedValue = undefined;
|
|
78
|
+
const result = await counterServiceSpec.create(serviceCtx);
|
|
79
|
+
expect(result.isOk()).toBe(true);
|
|
80
|
+
|
|
81
|
+
const service = result.unwrap();
|
|
82
|
+
expect(await counterServiceSpec.mock?.()).toBe(1);
|
|
83
|
+
|
|
84
|
+
await counterServiceSpec.dispose?.(service);
|
|
85
|
+
expect(disposedValue).toBe(3);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('ServiceSpec create can be async', async () => {
|
|
89
|
+
const spec: ServiceSpec<number> = {
|
|
90
|
+
create: async () => {
|
|
91
|
+
await Bun.sleep(0);
|
|
92
|
+
return Result.ok(7);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const result = await spec.create(serviceCtx);
|
|
97
|
+
expect(result.isOk()).toBe(true);
|
|
98
|
+
expect(result.unwrap()).toBe(7);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('Service carries identity alongside the shared spec fields', async () => {
|
|
102
|
+
const service: Service<number> = {
|
|
103
|
+
from(ctx) {
|
|
104
|
+
return ctx.service(this);
|
|
105
|
+
},
|
|
106
|
+
id: 'counter.main',
|
|
107
|
+
kind: 'service',
|
|
108
|
+
...counterServiceSpec,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
expect(service.kind).toBe('service');
|
|
112
|
+
expect(service.id).toBe('counter.main');
|
|
113
|
+
|
|
114
|
+
const created = await service.create(serviceCtx);
|
|
115
|
+
expect(created.isOk()).toBe(true);
|
|
116
|
+
expect(created.unwrap()).toBe(3);
|
|
117
|
+
expect(await service.mock?.()).toBe(1);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('service()', () => {
|
|
122
|
+
test('returns a frozen service object with kind and id', () => {
|
|
123
|
+
const counter = defineService('counter.main', counterServiceSpec);
|
|
124
|
+
|
|
125
|
+
expect(counter.kind).toBe('service');
|
|
126
|
+
expect(counter.id).toBe('counter.main');
|
|
127
|
+
expect(Object.isFrozen(counter)).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('infers the service type through from(ctx)', () => {
|
|
131
|
+
const db = defineService('db.main', {
|
|
132
|
+
create: () =>
|
|
133
|
+
Result.ok({
|
|
134
|
+
query(sql: string) {
|
|
135
|
+
return sql.length;
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
description: 'Typed database service',
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const store = {
|
|
142
|
+
query(sql: string) {
|
|
143
|
+
return sql.length;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
const ctx = resolvedServiceCtx('db.main', store);
|
|
147
|
+
|
|
148
|
+
const resolved = db.from(ctx);
|
|
149
|
+
expect(resolved.query('select 1')).toBe(8);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('from(ctx) throws when the service is missing', () => {
|
|
153
|
+
const counter = defineService('counter.main', counterServiceSpec);
|
|
154
|
+
const ctx = createTrailContext({
|
|
155
|
+
requestId: 'missing-service',
|
|
156
|
+
signal: new AbortController().signal,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(() => counter.from(ctx)).toThrow(
|
|
160
|
+
'Service "counter.main" not found in trail context'
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('from(ctx) returns undefined when the service key exists with an undefined value', () => {
|
|
165
|
+
const optional = defineService<undefined>('optional.main', {
|
|
166
|
+
create: () => Result.ok<undefined>(),
|
|
167
|
+
});
|
|
168
|
+
const ctx = createTrailContext({
|
|
169
|
+
extensions: { [optional.id]: undefined },
|
|
170
|
+
requestId: 'undefined-service',
|
|
171
|
+
signal: new AbortController().signal,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(optional.from(ctx)).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('service helpers', () => {
|
|
179
|
+
test('isService identifies service definitions', () => {
|
|
180
|
+
const counter = defineService('counter.main', counterServiceSpec);
|
|
181
|
+
|
|
182
|
+
expect(isService(counter)).toBe(true);
|
|
183
|
+
expect(isService({ id: 'counter.main', kind: 'trail' })).toBe(false);
|
|
184
|
+
expect(isService(null)).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('findDuplicateServiceId returns the first repeated ID', () => {
|
|
188
|
+
const first = defineService('counter.main', counterServiceSpec);
|
|
189
|
+
const duplicate = defineService('counter.main', counterServiceSpec);
|
|
190
|
+
const other = defineService('counter.secondary', counterServiceSpec);
|
|
191
|
+
|
|
192
|
+
expect(findDuplicateServiceId([first, other])).toBeUndefined();
|
|
193
|
+
expect(findDuplicateServiceId([first, other, duplicate])).toBe(
|
|
194
|
+
'counter.main'
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,60 @@
|
|
|
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 { PermitRequirement } from '../types';
|
|
8
|
+
|
|
9
|
+
describe('PermitRequirement type', () => {
|
|
10
|
+
test('accepts a scopes object', () => {
|
|
11
|
+
const req: PermitRequirement = { scopes: ['user:write', 'user:read'] };
|
|
12
|
+
expect(req).toEqual({ scopes: ['user:write', 'user:read'] });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('accepts public literal', () => {
|
|
16
|
+
const req: PermitRequirement = 'public';
|
|
17
|
+
expect(req).toBe('public');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('trail() with permit field', () => {
|
|
22
|
+
test('accepts permit with scopes', () => {
|
|
23
|
+
const t = trail('user.delete', {
|
|
24
|
+
input: z.object({ id: z.string() }),
|
|
25
|
+
intent: 'destroy',
|
|
26
|
+
permit: { scopes: ['user:write'] },
|
|
27
|
+
run: (input) => Result.ok({ deleted: input.id }),
|
|
28
|
+
});
|
|
29
|
+
expect(t.permit).toEqual({ scopes: ['user:write'] });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('accepts permit: public', () => {
|
|
33
|
+
const t = trail('health.check', {
|
|
34
|
+
input: z.object({}),
|
|
35
|
+
intent: 'read',
|
|
36
|
+
permit: 'public',
|
|
37
|
+
run: () => Result.ok({ status: 'ok' }),
|
|
38
|
+
});
|
|
39
|
+
expect(t.permit).toBe('public');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('permit is undefined when omitted (backward compatible)', () => {
|
|
43
|
+
const t = trail('legacy.trail', {
|
|
44
|
+
input: z.object({}),
|
|
45
|
+
run: () => Result.ok(),
|
|
46
|
+
});
|
|
47
|
+
expect(t.permit).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('preserves permit on the frozen Trail object', () => {
|
|
51
|
+
const t = trail('user.list', {
|
|
52
|
+
input: z.object({}),
|
|
53
|
+
intent: 'read',
|
|
54
|
+
permit: { scopes: ['user:read'] },
|
|
55
|
+
run: () => Result.ok([]),
|
|
56
|
+
});
|
|
57
|
+
expect(Object.isFrozen(t)).toBe(true);
|
|
58
|
+
expect(t.permit).toEqual({ scopes: ['user:read'] });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -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
|
+
};
|