@ontrails/core 1.0.0-beta.11 → 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 +13 -0
- package/dist/execute.d.ts +2 -0
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +2 -126
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -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 +12 -6
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js.map +1 -1
- package/dist/trail.d.ts +3 -1
- package/dist/trail.d.ts.map +1 -1
- package/dist/trail.js.map +1 -1
- package/dist/types.d.ts +18 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/service-config.test.ts +224 -0
- package/src/__tests__/trail-permit.test.ts +60 -0
- package/src/execute.ts +12 -207
- package/src/index.ts +3 -0
- package/src/service-config.ts +354 -0
- package/src/service.ts +13 -7
- package/src/trail.ts +7 -1
- package/src/types.ts +21 -1
- package/tsconfig.tsbuildinfo +1 -1
package/dist/types.d.ts
CHANGED
|
@@ -33,12 +33,19 @@ export interface Logger {
|
|
|
33
33
|
fatal(message: string, data?: Record<string, unknown>): void;
|
|
34
34
|
child(context: Record<string, unknown>): Logger;
|
|
35
35
|
}
|
|
36
|
+
/** Context extension key for the invoking surface name. */
|
|
37
|
+
export declare const SURFACE_KEY: "__trails_surface";
|
|
38
|
+
/** Minimal permit shape available on TrailContext. Permits extends this. */
|
|
39
|
+
export interface BasePermit {
|
|
40
|
+
readonly id: string;
|
|
41
|
+
readonly scopes: readonly string[];
|
|
42
|
+
}
|
|
36
43
|
/** Runtime context threaded through every trail execution */
|
|
37
44
|
export interface TrailContext {
|
|
38
45
|
readonly requestId: string;
|
|
39
46
|
readonly signal: AbortSignal;
|
|
40
47
|
readonly follow?: FollowFn | undefined;
|
|
41
|
-
readonly permit?:
|
|
48
|
+
readonly permit?: BasePermit;
|
|
42
49
|
readonly workspaceRoot?: string | undefined;
|
|
43
50
|
readonly logger?: Logger | undefined;
|
|
44
51
|
readonly progress?: ProgressCallback | undefined;
|
|
@@ -47,6 +54,16 @@ export interface TrailContext {
|
|
|
47
54
|
readonly extensions?: Readonly<Record<string, unknown>> | undefined;
|
|
48
55
|
readonly service?: ServiceLookup | undefined;
|
|
49
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Permit requirement declared on a trail spec.
|
|
59
|
+
*
|
|
60
|
+
* A scopes object means the trail requires a permit with those scopes.
|
|
61
|
+
* `'public'` means the trail has explicitly opted out of auth.
|
|
62
|
+
* Omitting the field entirely means the trail hasn't declared an auth posture.
|
|
63
|
+
*/
|
|
64
|
+
export type PermitRequirement = {
|
|
65
|
+
readonly scopes: readonly string[];
|
|
66
|
+
} | 'public';
|
|
50
67
|
/** Input shape used to seed a runtime TrailContext before resolution. */
|
|
51
68
|
export type TrailContextInit = Omit<TrailContext, 'service'> & {
|
|
52
69
|
readonly service?: ServiceLookup | undefined;
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C;;;;;GAKG;AACH,MAAM,MAAM,cAAc,CAAC,CAAC,EAAE,CAAC,IAAI,CACjC,KAAK,EAAE,CAAC,EACR,GAAG,EAAE,YAAY,KACd,MAAM,CAAC,CAAC,EAAE,KAAK,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC;AAElD,8DAA8D;AAC9D,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,EACvB,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,OAAO,KACX,OAAO,CAAC,MAAM,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC;AAE/B,iEAAiE;AACjE,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,GAAG,OAAO,EACtC,WAAW,EAAE;IAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,KAC1C,CAAC,CAAC;AAEP,+DAA+D;AAC/D,MAAM,MAAM,gBAAgB,GAAG,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;AAE9D,+DAA+D;AAC/D,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,UAAU,GAAG,UAAU,GAAG,OAAO,CAAC;IAC3D,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;CACrB;AAED,mFAAmF;AACnF,MAAM,WAAW,MAAM;IACrB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC7D,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC7D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC5D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC5D,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC7D,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC7D,KAAK,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC;CACjD;AAED,6DAA6D;AAC7D,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAC;IACvC,QAAQ,CAAC,MAAM,CAAC,EAAE,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
CHANGED
package/dist/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAgDA,2DAA2D;AAC3D,MAAM,CAAC,MAAM,WAAW,GAAG,kBAA2B,CAAC"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/* oxlint-disable require-await -- trail implementations satisfy async interface without awaiting */
|
|
2
|
+
import { describe, expect, test } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
import { executeTrail } from '../execute.js';
|
|
7
|
+
import { Result } from '../result.js';
|
|
8
|
+
import { service } from '../service.js';
|
|
9
|
+
import { trail } from '../trail.js';
|
|
10
|
+
import type { ServiceContext } from '../service.js';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const nextId = (name: string): string =>
|
|
17
|
+
`test.svc-config.${name}.${Bun.randomUUIDv7()}`;
|
|
18
|
+
|
|
19
|
+
const createSingletonConfigTrail = (id: string) => {
|
|
20
|
+
const captures = { createCalls: 0 };
|
|
21
|
+
const svc = service(id, {
|
|
22
|
+
config: z.object({ key: z.string() }),
|
|
23
|
+
create: (ctx: ServiceContext<{ key: string }>) => {
|
|
24
|
+
captures.createCalls += 1;
|
|
25
|
+
return Result.ok({
|
|
26
|
+
createCall: captures.createCalls,
|
|
27
|
+
key: ctx.config.key,
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
captures,
|
|
34
|
+
trail: trail('svc-config.singleton', {
|
|
35
|
+
input: z.object({}),
|
|
36
|
+
output: z.object({ createCall: z.number(), key: z.string() }),
|
|
37
|
+
run: (_input, ctx) =>
|
|
38
|
+
Result.ok({
|
|
39
|
+
createCall: svc.from(ctx).createCall,
|
|
40
|
+
key: svc.from(ctx).key,
|
|
41
|
+
}),
|
|
42
|
+
services: [svc],
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Tests
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
describe('ServiceContext.config', () => {
|
|
52
|
+
test('service with config schema receives validated config in svc.config', async () => {
|
|
53
|
+
const id = nextId('typed-config');
|
|
54
|
+
let capturedConfig: unknown;
|
|
55
|
+
|
|
56
|
+
const db = service(id, {
|
|
57
|
+
config: z.object({ poolSize: z.number(), url: z.string().url() }),
|
|
58
|
+
create: (svc: ServiceContext<{ url: string; poolSize: number }>) => {
|
|
59
|
+
capturedConfig = svc.config;
|
|
60
|
+
return Result.ok({ connected: true });
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const dbTrail = trail('svc-config.typed', {
|
|
65
|
+
input: z.object({}),
|
|
66
|
+
output: z.object({ connected: z.boolean() }),
|
|
67
|
+
run: (_input, ctx) => Result.ok({ connected: db.from(ctx).connected }),
|
|
68
|
+
services: [db],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const result = await executeTrail(
|
|
72
|
+
dbTrail,
|
|
73
|
+
{},
|
|
74
|
+
{
|
|
75
|
+
configValues: {
|
|
76
|
+
[id]: { poolSize: 5, url: 'https://example.com' },
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(result.isOk()).toBe(true);
|
|
82
|
+
expect(capturedConfig).toEqual({ poolSize: 5, url: 'https://example.com' });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('service without config still works — svc.config is undefined', async () => {
|
|
86
|
+
const id = nextId('no-config');
|
|
87
|
+
let capturedConfig: unknown = 'sentinel';
|
|
88
|
+
|
|
89
|
+
const counter = service(id, {
|
|
90
|
+
create: (svc) => {
|
|
91
|
+
capturedConfig = svc.config;
|
|
92
|
+
return Result.ok(42);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const counterTrail = trail('svc-config.no-config', {
|
|
97
|
+
input: z.object({}),
|
|
98
|
+
run: (_input, ctx) => Result.ok({ value: counter.from(ctx) }),
|
|
99
|
+
services: [counter],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const result = await executeTrail(counterTrail, {});
|
|
103
|
+
|
|
104
|
+
expect(result.isOk()).toBe(true);
|
|
105
|
+
expect(capturedConfig).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('config validation failure returns Result.err at service creation time', async () => {
|
|
109
|
+
const id = nextId('invalid-config');
|
|
110
|
+
|
|
111
|
+
const db = service(id, {
|
|
112
|
+
config: z.object({ url: z.string().url() }),
|
|
113
|
+
create: () => Result.ok({ connected: true }),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const dbTrail = trail('svc-config.invalid', {
|
|
117
|
+
input: z.object({}),
|
|
118
|
+
run: () => Result.ok(null),
|
|
119
|
+
services: [db],
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const result = await executeTrail(
|
|
123
|
+
dbTrail,
|
|
124
|
+
{},
|
|
125
|
+
{
|
|
126
|
+
configValues: {
|
|
127
|
+
[id]: { url: 'not-a-url' },
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(result.isErr()).toBe(true);
|
|
133
|
+
expect(result.error.message).toContain(id);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('missing configValues for a service with config schema returns Result.err', async () => {
|
|
137
|
+
const id = nextId('missing-config');
|
|
138
|
+
|
|
139
|
+
const db = service(id, {
|
|
140
|
+
config: z.object({ url: z.string().url() }),
|
|
141
|
+
create: () => Result.ok({ connected: true }),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const dbTrail = trail('svc-config.missing', {
|
|
145
|
+
input: z.object({}),
|
|
146
|
+
run: () => Result.ok(null),
|
|
147
|
+
services: [db],
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const result = await executeTrail(dbTrail, {});
|
|
151
|
+
|
|
152
|
+
expect(result.isErr()).toBe(true);
|
|
153
|
+
expect(result.error.message).toContain(id);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('service override bypasses config validation', async () => {
|
|
157
|
+
const id = nextId('override-bypass');
|
|
158
|
+
|
|
159
|
+
const db = service(id, {
|
|
160
|
+
config: z.object({ url: z.string().url() }),
|
|
161
|
+
create: () => Result.ok({ connected: true }),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const dbTrail = trail('svc-config.override', {
|
|
165
|
+
input: z.object({}),
|
|
166
|
+
output: z.object({ value: z.number() }),
|
|
167
|
+
run: (_input, ctx) => Result.ok({ value: db.from(ctx) as number }),
|
|
168
|
+
services: [db],
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Provide the service via overrides without any configValues — should NOT fail
|
|
172
|
+
const result = await executeTrail(dbTrail, {}, { services: { [id]: 42 } });
|
|
173
|
+
|
|
174
|
+
expect(result.isOk()).toBe(true);
|
|
175
|
+
expect(result.unwrap()).toEqual({ value: 42 });
|
|
176
|
+
});
|
|
177
|
+
test('config values passed through ExecuteTrailOptions.configValues', async () => {
|
|
178
|
+
const id = nextId('options-config');
|
|
179
|
+
const captures: unknown[] = [];
|
|
180
|
+
|
|
181
|
+
const svc = service(id, {
|
|
182
|
+
config: z.object({ key: z.string() }),
|
|
183
|
+
create: (ctx: ServiceContext<{ key: string }>) => {
|
|
184
|
+
captures.push(ctx.config);
|
|
185
|
+
return Result.ok({ key: ctx.config.key });
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const svcTrail = trail('svc-config.options', {
|
|
190
|
+
input: z.object({}),
|
|
191
|
+
output: z.object({ key: z.string() }),
|
|
192
|
+
run: (_input, ctx) => Result.ok({ key: svc.from(ctx).key }),
|
|
193
|
+
services: [svc],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const result = await executeTrail(
|
|
197
|
+
svcTrail,
|
|
198
|
+
{},
|
|
199
|
+
{
|
|
200
|
+
configValues: { [id]: { key: 'hello' } },
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(result.isOk()).toBe(true);
|
|
205
|
+
expect(result.unwrap()).toEqual({ key: 'hello' });
|
|
206
|
+
expect(captures).toEqual([{ key: 'hello' }]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('config-aware singleton services reuse the cached instance', async () => {
|
|
210
|
+
const id = nextId('singleton-config');
|
|
211
|
+
const { captures, trail: singletonTrail } = createSingletonConfigTrail(id);
|
|
212
|
+
const options = {
|
|
213
|
+
configValues: { [id]: { key: 'hello' } },
|
|
214
|
+
};
|
|
215
|
+
const first = await executeTrail(singletonTrail, {}, options);
|
|
216
|
+
const second = await executeTrail(singletonTrail, {}, options);
|
|
217
|
+
|
|
218
|
+
expect(first.isOk()).toBe(true);
|
|
219
|
+
expect(second.isOk()).toBe(true);
|
|
220
|
+
expect(first.unwrap()).toEqual({ createCall: 1, key: 'hello' });
|
|
221
|
+
expect(second.unwrap()).toEqual({ createCall: 1, key: 'hello' });
|
|
222
|
+
expect(captures.createCalls).toBe(1);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -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
|
+
});
|
package/src/execute.ts
CHANGED
|
@@ -8,11 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { AnyTrail } from './trail.js';
|
|
10
10
|
import type { Layer } from './layer.js';
|
|
11
|
-
import type {
|
|
12
|
-
AnyService,
|
|
13
|
-
ServiceContext,
|
|
14
|
-
ServiceOverrideMap,
|
|
15
|
-
} from './service.js';
|
|
11
|
+
import type { ServiceOverrideMap } from './service.js';
|
|
16
12
|
import type { TrailContext, TrailContextInit } from './types.js';
|
|
17
13
|
|
|
18
14
|
import { composeLayers } from './layer.js';
|
|
@@ -20,6 +16,7 @@ import { createTrailContext } from './context.js';
|
|
|
20
16
|
import { InternalError } from './errors.js';
|
|
21
17
|
import { Result } from './result.js';
|
|
22
18
|
import { createServiceLookup } from './service.js';
|
|
19
|
+
import { resolveServices } from './service-config.js';
|
|
23
20
|
import { validateInput } from './validation.js';
|
|
24
21
|
|
|
25
22
|
type MutableTrailContext = {
|
|
@@ -44,6 +41,10 @@ export interface ExecuteTrailOptions {
|
|
|
44
41
|
| undefined;
|
|
45
42
|
/** Explicit service instance overrides keyed by service ID. */
|
|
46
43
|
readonly services?: ServiceOverrideMap | undefined;
|
|
44
|
+
/** Config values for services that declare a `config` schema, keyed by service ID. */
|
|
45
|
+
readonly configValues?:
|
|
46
|
+
| Readonly<Record<string, Record<string, unknown>>>
|
|
47
|
+
| undefined;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
// ---------------------------------------------------------------------------
|
|
@@ -87,213 +88,17 @@ const resolveContext = async (
|
|
|
87
88
|
return resolved as TrailContext;
|
|
88
89
|
};
|
|
89
90
|
|
|
90
|
-
const singletonServices = new WeakMap<AnyService, Map<string, unknown>>();
|
|
91
|
-
|
|
92
|
-
/** In-flight service creation promises, keyed by service × context. */
|
|
93
|
-
const pendingCreations = new WeakMap<
|
|
94
|
-
AnyService,
|
|
95
|
-
Map<string, Promise<Result<unknown, Error>>>
|
|
96
|
-
>();
|
|
97
|
-
|
|
98
|
-
const hasOwnServiceOverride = (
|
|
99
|
-
overrides: ServiceOverrideMap | undefined,
|
|
100
|
-
id: string
|
|
101
|
-
): overrides is ServiceOverrideMap =>
|
|
102
|
-
overrides !== undefined && Object.hasOwn(overrides, id);
|
|
103
|
-
|
|
104
|
-
const toServiceContext = (ctx: TrailContext): ServiceContext => ({
|
|
105
|
-
cwd: ctx.cwd,
|
|
106
|
-
env: ctx.env,
|
|
107
|
-
workspaceRoot: ctx.workspaceRoot,
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
const toServiceContextKey = (ctx: ServiceContext): string =>
|
|
111
|
-
JSON.stringify({
|
|
112
|
-
cwd: ctx.cwd,
|
|
113
|
-
env: Object.entries(ctx.env ?? {}).toSorted(([left], [right]) =>
|
|
114
|
-
left.localeCompare(right)
|
|
115
|
-
),
|
|
116
|
-
workspaceRoot: ctx.workspaceRoot,
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
const toInternalServiceError = (id: string, error: unknown): InternalError => {
|
|
120
|
-
const cause = error instanceof Error ? error : undefined;
|
|
121
|
-
const message = cause?.message ?? String(error);
|
|
122
|
-
return new InternalError(`Service "${id}" failed to resolve: ${message}`, {
|
|
123
|
-
...(cause ? { cause } : {}),
|
|
124
|
-
context: { serviceId: id },
|
|
125
|
-
});
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
const getCachedSingletonService = (
|
|
129
|
-
declaredService: AnyService,
|
|
130
|
-
serviceContext: ServiceContext
|
|
131
|
-
): { readonly found: boolean; readonly value: unknown } => {
|
|
132
|
-
const scopedCache = singletonServices.get(declaredService);
|
|
133
|
-
if (scopedCache === undefined) {
|
|
134
|
-
return { found: false, value: undefined };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const key = toServiceContextKey(serviceContext);
|
|
138
|
-
if (!scopedCache.has(key)) {
|
|
139
|
-
return { found: false, value: undefined };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
return {
|
|
143
|
-
found: true,
|
|
144
|
-
value: scopedCache.get(key),
|
|
145
|
-
};
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
const getProvidedService = (
|
|
149
|
-
ctx: TrailContext,
|
|
150
|
-
overrides: ServiceOverrideMap | undefined,
|
|
151
|
-
declaredService: AnyService,
|
|
152
|
-
serviceContext: ServiceContext
|
|
153
|
-
): Result<unknown, Error> | undefined => {
|
|
154
|
-
const { id } = declaredService;
|
|
155
|
-
if (hasOwnServiceOverride(overrides, id)) {
|
|
156
|
-
return Result.ok(overrides[id]);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (Object.hasOwn(ctx.extensions ?? {}, id)) {
|
|
160
|
-
return Result.ok(ctx.extensions?.[id]);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const cached = getCachedSingletonService(declaredService, serviceContext);
|
|
164
|
-
if (cached.found) {
|
|
165
|
-
return Result.ok(cached.value);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return undefined;
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
const getSingletonServiceCache = (
|
|
172
|
-
declaredService: AnyService
|
|
173
|
-
): Map<string, unknown> => {
|
|
174
|
-
const existing = singletonServices.get(declaredService);
|
|
175
|
-
if (existing !== undefined) {
|
|
176
|
-
return existing;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const created = new Map<string, unknown>();
|
|
180
|
-
singletonServices.set(declaredService, created);
|
|
181
|
-
return created;
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
const doCreateServiceInstance = async (
|
|
185
|
-
declaredService: AnyService,
|
|
186
|
-
serviceContext: ServiceContext
|
|
187
|
-
): Promise<Result<unknown, Error>> => {
|
|
188
|
-
try {
|
|
189
|
-
const created = await declaredService.create(serviceContext);
|
|
190
|
-
if (created.isErr()) {
|
|
191
|
-
return Result.err(created.error);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const instance = created.unwrap();
|
|
195
|
-
getSingletonServiceCache(declaredService).set(
|
|
196
|
-
toServiceContextKey(serviceContext),
|
|
197
|
-
instance
|
|
198
|
-
);
|
|
199
|
-
return Result.ok(instance);
|
|
200
|
-
} catch (error: unknown) {
|
|
201
|
-
return Result.err(toInternalServiceError(declaredService.id, error));
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
const trackPendingCreation = (
|
|
206
|
-
declaredService: AnyService,
|
|
207
|
-
key: string,
|
|
208
|
-
promise: Promise<Result<unknown, Error>>
|
|
209
|
-
): void => {
|
|
210
|
-
const pending = pendingCreations.get(declaredService);
|
|
211
|
-
if (pending) {
|
|
212
|
-
pending.set(key, promise);
|
|
213
|
-
} else {
|
|
214
|
-
pendingCreations.set(declaredService, new Map([[key, promise]]));
|
|
215
|
-
}
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Deduplicates concurrent creation of the same service singleton.
|
|
220
|
-
* If a creation is already in flight for this service × context key,
|
|
221
|
-
* returns the existing promise instead of spawning a second factory call.
|
|
222
|
-
*/
|
|
223
|
-
const createServiceInstance = async (
|
|
224
|
-
declaredService: AnyService,
|
|
225
|
-
serviceContext: ServiceContext
|
|
226
|
-
): Promise<Result<unknown, Error>> => {
|
|
227
|
-
const key = toServiceContextKey(serviceContext);
|
|
228
|
-
const inflight = pendingCreations.get(declaredService)?.get(key);
|
|
229
|
-
if (inflight) {
|
|
230
|
-
return inflight;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const promise = doCreateServiceInstance(declaredService, serviceContext);
|
|
234
|
-
trackPendingCreation(declaredService, key, promise);
|
|
235
|
-
|
|
236
|
-
try {
|
|
237
|
-
return await promise;
|
|
238
|
-
} finally {
|
|
239
|
-
pendingCreations.get(declaredService)?.delete(key);
|
|
240
|
-
}
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
const resolveServiceInstance = async (
|
|
244
|
-
declaredService: AnyService,
|
|
245
|
-
ctx: TrailContext,
|
|
246
|
-
serviceContext: ServiceContext,
|
|
247
|
-
overrides?: ServiceOverrideMap
|
|
248
|
-
): Promise<Result<unknown, Error>> =>
|
|
249
|
-
getProvidedService(ctx, overrides, declaredService, serviceContext) ??
|
|
250
|
-
(await createServiceInstance(declaredService, serviceContext));
|
|
251
|
-
|
|
252
|
-
const withResolvedServices = (
|
|
253
|
-
ctx: TrailContext,
|
|
254
|
-
resolvedServices: Record<string, unknown>
|
|
255
|
-
): TrailContext => {
|
|
256
|
-
const extensions = { ...ctx.extensions, ...resolvedServices };
|
|
257
|
-
const resolvedCtx = { ...ctx, extensions } as MutableTrailContext;
|
|
258
|
-
resolvedCtx.service = createServiceLookup(() => resolvedCtx);
|
|
259
|
-
return resolvedCtx;
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
const resolveServices = async (
|
|
263
|
-
trail: AnyTrail,
|
|
264
|
-
ctx: TrailContext,
|
|
265
|
-
overrides?: ServiceOverrideMap
|
|
266
|
-
): Promise<Result<TrailContext, Error>> => {
|
|
267
|
-
if (trail.services.length === 0) {
|
|
268
|
-
return Result.ok(ctx);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const resolvedServices: Record<string, unknown> = {};
|
|
272
|
-
const serviceContext = toServiceContext(ctx);
|
|
273
|
-
|
|
274
|
-
for (const declaredService of trail.services) {
|
|
275
|
-
const resolved = await resolveServiceInstance(
|
|
276
|
-
declaredService,
|
|
277
|
-
ctx,
|
|
278
|
-
serviceContext,
|
|
279
|
-
overrides
|
|
280
|
-
);
|
|
281
|
-
if (resolved.isErr()) {
|
|
282
|
-
return resolved;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
resolvedServices[declaredService.id] = resolved.unwrap();
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
return Result.ok(withResolvedServices(ctx, resolvedServices));
|
|
289
|
-
};
|
|
290
|
-
|
|
291
91
|
const prepareContext = async (
|
|
292
92
|
trail: AnyTrail,
|
|
293
93
|
options?: ExecuteTrailOptions
|
|
294
94
|
): Promise<Result<TrailContext, Error>> => {
|
|
295
95
|
const baseCtx = await resolveContext(options);
|
|
296
|
-
return await resolveServices(
|
|
96
|
+
return await resolveServices(
|
|
97
|
+
trail,
|
|
98
|
+
baseCtx,
|
|
99
|
+
options?.services,
|
|
100
|
+
options?.configValues
|
|
101
|
+
);
|
|
297
102
|
};
|
|
298
103
|
|
|
299
104
|
const runTrail = async (
|
package/src/index.ts
CHANGED
|
@@ -32,11 +32,14 @@ export type {
|
|
|
32
32
|
TrailContext,
|
|
33
33
|
TrailContextInit,
|
|
34
34
|
FollowFn,
|
|
35
|
+
BasePermit,
|
|
36
|
+
PermitRequirement,
|
|
35
37
|
ProgressCallback,
|
|
36
38
|
ProgressEvent,
|
|
37
39
|
Logger,
|
|
38
40
|
ServiceLookup,
|
|
39
41
|
} from './types.js';
|
|
42
|
+
export { SURFACE_KEY } from './types.js';
|
|
40
43
|
|
|
41
44
|
// Context factory
|
|
42
45
|
export { createTrailContext } from './context.js';
|