@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.
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Service resolution pipeline.
3
+ *
4
+ * Extracted from execute.ts to keep both modules under the 400 LOC ceiling.
5
+ * Handles config validation, singleton caching, concurrent-creation dedup,
6
+ * and the full resolve-or-create flow for declared services.
7
+ */
8
+
9
+ import type {
10
+ AnyService,
11
+ ServiceContext,
12
+ ServiceOverrideMap,
13
+ } from './service.js';
14
+ import type { AnyTrail } from './trail.js';
15
+ import type { TrailContext } from './types.js';
16
+
17
+ import { InternalError, ValidationError } from './errors.js';
18
+ import { Result } from './result.js';
19
+ import { createServiceLookup } from './service.js';
20
+
21
+ type MutableTrailContext = {
22
+ -readonly [K in keyof TrailContext]: TrailContext[K];
23
+ };
24
+
25
+ type ConfigValues = Readonly<Record<string, Record<string, unknown>>>;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Singleton caches
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const singletonServices = new WeakMap<AnyService, Map<string, unknown>>();
32
+
33
+ /** In-flight service creation promises, keyed by service x context. */
34
+ const pendingCreations = new WeakMap<
35
+ AnyService,
36
+ Map<string, Promise<Result<unknown, Error>>>
37
+ >();
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Context helpers
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const toServiceContext = (
44
+ ctx: TrailContext,
45
+ config?: unknown
46
+ ): ServiceContext => ({
47
+ config,
48
+ cwd: ctx.cwd,
49
+ env: ctx.env,
50
+ workspaceRoot: ctx.workspaceRoot,
51
+ });
52
+
53
+ const toServiceContextKey = (ctx: ServiceContext): string =>
54
+ JSON.stringify({
55
+ config: ctx.config,
56
+ cwd: ctx.cwd,
57
+ env: Object.entries(ctx.env ?? {}).toSorted(([left], [right]) =>
58
+ left.localeCompare(right)
59
+ ),
60
+ workspaceRoot: ctx.workspaceRoot,
61
+ });
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Config validation
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /** Validate and resolve a service's config from the provided configValues map. */
68
+ const resolveServiceConfig = (
69
+ declaredService: AnyService,
70
+ configValues?: ConfigValues
71
+ ): Result<unknown, Error> => {
72
+ if (declaredService.config === undefined) {
73
+ return Result.ok();
74
+ }
75
+ const raw = configValues?.[declaredService.id];
76
+ if (raw === undefined) {
77
+ return Result.err(
78
+ new ValidationError(
79
+ `Service "${declaredService.id}" declares a config schema but no config was provided`
80
+ )
81
+ );
82
+ }
83
+ const parsed = declaredService.config.safeParse(raw);
84
+ if (!parsed.success) {
85
+ return Result.err(
86
+ new ValidationError(
87
+ `Service "${declaredService.id}" config validation failed: ${parsed.error.message}`
88
+ )
89
+ );
90
+ }
91
+ return Result.ok(parsed.data);
92
+ };
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Override / cache lookups
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 getCachedSingletonService = (
105
+ declaredService: AnyService,
106
+ serviceContext: ServiceContext
107
+ ): { readonly found: boolean; readonly value: unknown } => {
108
+ const scopedCache = singletonServices.get(declaredService);
109
+ if (scopedCache === undefined) {
110
+ return { found: false, value: undefined };
111
+ }
112
+
113
+ const key = toServiceContextKey(serviceContext);
114
+ if (!scopedCache.has(key)) {
115
+ return { found: false, value: undefined };
116
+ }
117
+
118
+ return {
119
+ found: true,
120
+ value: scopedCache.get(key),
121
+ };
122
+ };
123
+
124
+ const getProvidedService = (
125
+ ctx: TrailContext,
126
+ overrides: ServiceOverrideMap | undefined,
127
+ declaredService: AnyService,
128
+ serviceContext: ServiceContext
129
+ ): Result<unknown, Error> | undefined => {
130
+ const { id } = declaredService;
131
+ if (hasOwnServiceOverride(overrides, id)) {
132
+ return Result.ok(overrides[id]);
133
+ }
134
+
135
+ if (Object.hasOwn(ctx.extensions ?? {}, id)) {
136
+ return Result.ok(ctx.extensions?.[id]);
137
+ }
138
+
139
+ const cached = getCachedSingletonService(declaredService, serviceContext);
140
+ if (cached.found) {
141
+ return Result.ok(cached.value);
142
+ }
143
+
144
+ return undefined;
145
+ };
146
+
147
+ const getOverrideOrExtension = (
148
+ ctx: TrailContext,
149
+ overrides: ServiceOverrideMap | undefined,
150
+ declaredService: AnyService
151
+ ): Result<unknown, Error> | undefined =>
152
+ getProvidedService(ctx, overrides, declaredService, toServiceContext(ctx));
153
+
154
+ type ConfigAwareResolution =
155
+ | Result<{ readonly kind: 'provided'; readonly value: unknown }, Error>
156
+ | Result<
157
+ { readonly kind: 'context'; readonly serviceContext: ServiceContext },
158
+ Error
159
+ >;
160
+
161
+ const resolveConfigAwareProvidedService = (
162
+ ctx: TrailContext,
163
+ declaredService: AnyService,
164
+ configValues: ConfigValues | undefined
165
+ ): ConfigAwareResolution => {
166
+ const configResult = resolveServiceConfig(declaredService, configValues);
167
+ if (configResult.isErr()) {
168
+ return configResult;
169
+ }
170
+
171
+ const serviceContext = toServiceContext(ctx, configResult.value);
172
+ const provided = getProvidedService(
173
+ ctx,
174
+ undefined,
175
+ declaredService,
176
+ serviceContext
177
+ );
178
+
179
+ return provided
180
+ ? Result.ok({ kind: 'provided', value: provided.unwrap() })
181
+ : Result.ok({ kind: 'context', serviceContext });
182
+ };
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Instance creation
186
+ // ---------------------------------------------------------------------------
187
+
188
+ const toInternalServiceError = (id: string, error: unknown): InternalError => {
189
+ const cause = error instanceof Error ? error : undefined;
190
+ const message = cause?.message ?? String(error);
191
+ return new InternalError(`Service "${id}" failed to resolve: ${message}`, {
192
+ ...(cause ? { cause } : {}),
193
+ context: { serviceId: id },
194
+ });
195
+ };
196
+
197
+ const getSingletonServiceCache = (
198
+ declaredService: AnyService
199
+ ): Map<string, unknown> => {
200
+ const existing = singletonServices.get(declaredService);
201
+ if (existing !== undefined) {
202
+ return existing;
203
+ }
204
+
205
+ const created = new Map<string, unknown>();
206
+ singletonServices.set(declaredService, created);
207
+ return created;
208
+ };
209
+
210
+ const doCreateServiceInstance = async (
211
+ declaredService: AnyService,
212
+ serviceContext: ServiceContext
213
+ ): Promise<Result<unknown, Error>> => {
214
+ try {
215
+ const created = await declaredService.create(serviceContext);
216
+ if (created.isErr()) {
217
+ return Result.err(created.error);
218
+ }
219
+
220
+ const instance = created.unwrap();
221
+ getSingletonServiceCache(declaredService).set(
222
+ toServiceContextKey(serviceContext),
223
+ instance
224
+ );
225
+ return Result.ok(instance);
226
+ } catch (error: unknown) {
227
+ return Result.err(toInternalServiceError(declaredService.id, error));
228
+ }
229
+ };
230
+
231
+ const trackPendingCreation = (
232
+ declaredService: AnyService,
233
+ key: string,
234
+ promise: Promise<Result<unknown, Error>>
235
+ ): void => {
236
+ const pending = pendingCreations.get(declaredService);
237
+ if (pending) {
238
+ pending.set(key, promise);
239
+ } else {
240
+ pendingCreations.set(declaredService, new Map([[key, promise]]));
241
+ }
242
+ };
243
+
244
+ /**
245
+ * Deduplicates concurrent creation of the same service singleton.
246
+ * If a creation is already in flight for this service x context key,
247
+ * returns the existing promise instead of spawning a second factory call.
248
+ */
249
+ const createServiceInstance = async (
250
+ declaredService: AnyService,
251
+ serviceContext: ServiceContext
252
+ ): Promise<Result<unknown, Error>> => {
253
+ const key = toServiceContextKey(serviceContext);
254
+ const inflight = pendingCreations.get(declaredService)?.get(key);
255
+ if (inflight) {
256
+ return inflight;
257
+ }
258
+
259
+ const promise = doCreateServiceInstance(declaredService, serviceContext);
260
+ trackPendingCreation(declaredService, key, promise);
261
+
262
+ try {
263
+ return await promise;
264
+ } finally {
265
+ pendingCreations.get(declaredService)?.delete(key);
266
+ }
267
+ };
268
+
269
+ /** Validate config and resolve a single declared service. */
270
+ const resolveDeclaredService = async (
271
+ declaredService: AnyService,
272
+ ctx: TrailContext,
273
+ overrides: ServiceOverrideMap | undefined,
274
+ configValues: ConfigValues | undefined
275
+ ): Promise<Result<unknown, Error>> => {
276
+ // Check overrides/extensions first — skip config validation entirely when
277
+ // a service instance is already provided.
278
+ const overrideOrExtension = getOverrideOrExtension(
279
+ ctx,
280
+ overrides,
281
+ declaredService
282
+ );
283
+ if (overrideOrExtension !== undefined) {
284
+ return overrideOrExtension;
285
+ }
286
+
287
+ // Resolve config before consulting the singleton cache so config-aware
288
+ // services use the same canonical context for cache reads and writes.
289
+ const configAwareService = resolveConfigAwareProvidedService(
290
+ ctx,
291
+ declaredService,
292
+ configValues
293
+ );
294
+ if (configAwareService.isErr()) {
295
+ return configAwareService;
296
+ }
297
+
298
+ // No provided instance — create via factory.
299
+ const resolved = configAwareService.unwrap();
300
+ if (resolved.kind === 'provided') {
301
+ return Result.ok(resolved.value);
302
+ }
303
+
304
+ return await createServiceInstance(declaredService, resolved.serviceContext);
305
+ };
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Full trail service resolution
309
+ // ---------------------------------------------------------------------------
310
+
311
+ const withResolvedServices = (
312
+ ctx: TrailContext,
313
+ resolvedServices: Record<string, unknown>
314
+ ): TrailContext => {
315
+ const extensions = { ...ctx.extensions, ...resolvedServices };
316
+ const resolvedCtx = { ...ctx, extensions } as MutableTrailContext;
317
+ resolvedCtx.service = createServiceLookup(() => resolvedCtx);
318
+ return resolvedCtx;
319
+ };
320
+
321
+ /**
322
+ * Resolve all declared services for a trail.
323
+ *
324
+ * Validates per-service config, checks overrides and caches, and creates
325
+ * new instances as needed. Returns an enriched context with all service
326
+ * instances injected into extensions.
327
+ */
328
+ export const resolveServices = async (
329
+ trail: AnyTrail,
330
+ ctx: TrailContext,
331
+ overrides?: ServiceOverrideMap,
332
+ configValues?: ConfigValues
333
+ ): Promise<Result<TrailContext, Error>> => {
334
+ if (trail.services.length === 0) {
335
+ return Result.ok(ctx);
336
+ }
337
+
338
+ const resolvedServices: Record<string, unknown> = {};
339
+
340
+ for (const declaredService of trail.services) {
341
+ const resolved = await resolveDeclaredService(
342
+ declaredService,
343
+ ctx,
344
+ overrides,
345
+ configValues
346
+ );
347
+ if (resolved.isErr()) {
348
+ return resolved;
349
+ }
350
+ resolvedServices[declaredService.id] = resolved.unwrap();
351
+ }
352
+
353
+ return Result.ok(withResolvedServices(ctx, resolvedServices));
354
+ };
package/src/service.ts CHANGED
@@ -7,23 +7,29 @@ import type { z } from 'zod';
7
7
  * Stable process-scoped fields available when constructing a service.
8
8
  *
9
9
  * Services are app-level singletons, so they intentionally do not receive the
10
- * full per-request TrailContext.
10
+ * full per-request TrailContext. When a service declares a `config` schema,
11
+ * the validated config is passed as `svc.config`.
11
12
  */
12
- export type ServiceContext = Pick<
13
+ export type ServiceContext<C = unknown> = Pick<
13
14
  TrailContext,
14
15
  'cwd' | 'env' | 'workspaceRoot'
15
- >;
16
+ > & {
17
+ readonly config: C;
18
+ };
16
19
 
17
20
  /**
18
21
  * Everything needed to describe a service before a factory is introduced.
22
+ *
23
+ * When `config` is a Zod schema, the `create` callback receives
24
+ * `ServiceContext<C>` with the validated config value.
19
25
  */
20
- export interface ServiceSpec<T> {
26
+ export interface ServiceSpec<T, C = unknown> {
21
27
  /** Create the service instance from stable process-scoped context. */
22
28
  readonly create: (
23
- svc: ServiceContext
29
+ svc: ServiceContext<C>
24
30
  ) => Result<T, Error> | Promise<Result<T, Error>>;
25
- /** Reserved config schema for follow-up config composition work. */
26
- readonly config?: z.ZodType | undefined;
31
+ /** Config schema when present, config is validated and passed to `create`. */
32
+ readonly config?: z.ZodType<C> | undefined;
27
33
  /** Optional cleanup performed when the hosting surface shuts down. */
28
34
  readonly dispose?: ((service: T) => void | Promise<void>) | undefined;
29
35
  /** Optional operational readiness probe for introspection tooling. */
package/src/trail.ts CHANGED
@@ -3,7 +3,11 @@ import type { z } from 'zod';
3
3
  import type { FieldOverride } from './derive.js';
4
4
  import type { Result } from './result.js';
5
5
  import type { AnyService } from './service.js';
6
- import type { Implementation, TrailContext } from './types.js';
6
+ import type {
7
+ Implementation,
8
+ PermitRequirement,
9
+ TrailContext,
10
+ } from './types.js';
7
11
 
8
12
  // ---------------------------------------------------------------------------
9
13
  // Trail example
@@ -59,6 +63,8 @@ export interface TrailSpec<I, O> {
59
63
  readonly follow?: readonly string[] | undefined;
60
64
  /** Services this trail may access via service.from(ctx) */
61
65
  readonly services?: readonly AnyService[] | undefined;
66
+ /** Auth requirement: scopes object, 'public', or omitted (undeclared) */
67
+ readonly permit?: PermitRequirement | undefined;
62
68
  }
63
69
 
64
70
  // ---------------------------------------------------------------------------
package/src/types.ts CHANGED
@@ -46,12 +46,21 @@ export interface Logger {
46
46
  child(context: Record<string, unknown>): Logger;
47
47
  }
48
48
 
49
+ /** Context extension key for the invoking surface name. */
50
+ export const SURFACE_KEY = '__trails_surface' as const;
51
+
52
+ /** Minimal permit shape available on TrailContext. Permits extends this. */
53
+ export interface BasePermit {
54
+ readonly id: string;
55
+ readonly scopes: readonly string[];
56
+ }
57
+
49
58
  /** Runtime context threaded through every trail execution */
50
59
  export interface TrailContext {
51
60
  readonly requestId: string;
52
61
  readonly signal: AbortSignal;
53
62
  readonly follow?: FollowFn | undefined;
54
- readonly permit?: unknown | undefined;
63
+ readonly permit?: BasePermit;
55
64
  readonly workspaceRoot?: string | undefined;
56
65
  readonly logger?: Logger | undefined;
57
66
  readonly progress?: ProgressCallback | undefined;
@@ -61,6 +70,17 @@ export interface TrailContext {
61
70
  readonly service?: ServiceLookup | undefined;
62
71
  }
63
72
 
73
+ /**
74
+ * Permit requirement declared on a trail spec.
75
+ *
76
+ * A scopes object means the trail requires a permit with those scopes.
77
+ * `'public'` means the trail has explicitly opted out of auth.
78
+ * Omitting the field entirely means the trail hasn't declared an auth posture.
79
+ */
80
+ export type PermitRequirement =
81
+ | { readonly scopes: readonly string[] }
82
+ | 'public';
83
+
64
84
  /** Input shape used to seed a runtime TrailContext before resolution. */
65
85
  export type TrailContextInit = Omit<TrailContext, 'service'> & {
66
86
  readonly service?: ServiceLookup | undefined;
@@ -1 +1 @@
1
- {"root":["./src/blob-ref.ts","./src/branded.ts","./src/collections.ts","./src/context.ts","./src/derive.ts","./src/dispatch.ts","./src/errors.ts","./src/event.ts","./src/execute.ts","./src/fetch.ts","./src/guards.ts","./src/index.ts","./src/layer.ts","./src/path-security.ts","./src/resilience.ts","./src/result.ts","./src/serialization.ts","./src/service.ts","./src/topo.ts","./src/trail.ts","./src/type-utils.ts","./src/types.ts","./src/validate-topo.ts","./src/validation.ts","./src/workspace.ts","./src/patterns/bulk.ts","./src/patterns/change.ts","./src/patterns/date-range.ts","./src/patterns/index.ts","./src/patterns/pagination.ts","./src/patterns/progress.ts","./src/patterns/sorting.ts","./src/patterns/status.ts","./src/patterns/timestamps.ts","./src/redaction/index.ts","./src/redaction/patterns.ts","./src/redaction/redactor.ts"],"version":"5.9.3"}
1
+ {"root":["./src/blob-ref.ts","./src/branded.ts","./src/collections.ts","./src/context.ts","./src/derive.ts","./src/dispatch.ts","./src/errors.ts","./src/event.ts","./src/execute.ts","./src/fetch.ts","./src/guards.ts","./src/index.ts","./src/layer.ts","./src/path-security.ts","./src/resilience.ts","./src/result.ts","./src/serialization.ts","./src/service-config.ts","./src/service.ts","./src/topo.ts","./src/trail.ts","./src/type-utils.ts","./src/types.ts","./src/validate-topo.ts","./src/validation.ts","./src/workspace.ts","./src/patterns/bulk.ts","./src/patterns/change.ts","./src/patterns/date-range.ts","./src/patterns/index.ts","./src/patterns/pagination.ts","./src/patterns/progress.ts","./src/patterns/sorting.ts","./src/patterns/status.ts","./src/patterns/timestamps.ts","./src/redaction/index.ts","./src/redaction/patterns.ts","./src/redaction/redactor.ts"],"version":"5.9.3"}