@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
package/src/execute.ts CHANGED
@@ -8,14 +8,24 @@
8
8
 
9
9
  import type { AnyTrail } from './trail.js';
10
10
  import type { Layer } from './layer.js';
11
- import type { TrailContext } from './types.js';
11
+ import type {
12
+ AnyService,
13
+ ServiceContext,
14
+ ServiceOverrideMap,
15
+ } from './service.js';
16
+ import type { TrailContext, TrailContextInit } from './types.js';
12
17
 
13
18
  import { composeLayers } from './layer.js';
14
19
  import { createTrailContext } from './context.js';
15
20
  import { InternalError } from './errors.js';
16
21
  import { Result } from './result.js';
22
+ import { createServiceLookup } from './service.js';
17
23
  import { validateInput } from './validation.js';
18
24
 
25
+ type MutableTrailContext = {
26
+ -readonly [K in keyof TrailContext]: TrailContext[K];
27
+ };
28
+
19
29
  // ---------------------------------------------------------------------------
20
30
  // Options
21
31
  // ---------------------------------------------------------------------------
@@ -23,15 +33,17 @@ import { validateInput } from './validation.js';
23
33
  /** Options for executeTrail. */
24
34
  export interface ExecuteTrailOptions {
25
35
  /** Partial context overrides merged on top of the base context. */
26
- readonly ctx?: Partial<TrailContext> | undefined;
36
+ readonly ctx?: Partial<TrailContextInit> | undefined;
27
37
  /** AbortSignal override (takes final precedence over ctx and factory). */
28
38
  readonly signal?: AbortSignal | undefined;
29
39
  /** Layers to compose around the implementation. */
30
40
  readonly layers?: readonly Layer[] | undefined;
31
41
  /** Factory that produces a base TrailContext (takes precedence over defaults). */
32
42
  readonly createContext?:
33
- | (() => TrailContext | Promise<TrailContext>)
43
+ | (() => TrailContextInit | Promise<TrailContextInit>)
34
44
  | undefined;
45
+ /** Explicit service instance overrides keyed by service ID. */
46
+ readonly services?: ServiceOverrideMap | undefined;
35
47
  }
36
48
 
37
49
  // ---------------------------------------------------------------------------
@@ -49,9 +61,10 @@ export interface ExecuteTrailOptions {
49
61
  const resolveContext = async (
50
62
  options?: ExecuteTrailOptions
51
63
  ): Promise<TrailContext> => {
52
- const base = options?.createContext
64
+ const seed = options?.createContext
53
65
  ? await options.createContext()
54
66
  : createTrailContext();
67
+ const base = seed.service ? seed : createTrailContext(seed);
55
68
  const withOverrides = options?.ctx
56
69
  ? {
57
70
  ...base,
@@ -59,9 +72,238 @@ const resolveContext = async (
59
72
  extensions: { ...base.extensions, ...options.ctx.extensions },
60
73
  }
61
74
  : base;
62
- return options?.signal
75
+ const resolved = options?.signal
63
76
  ? { ...withOverrides, signal: options.signal }
64
77
  : withOverrides;
78
+ if (
79
+ options?.ctx?.extensions !== undefined ||
80
+ resolved.service === undefined
81
+ ) {
82
+ const bound = { ...resolved } as MutableTrailContext;
83
+ bound.service = createServiceLookup(() => bound);
84
+ return bound;
85
+ }
86
+
87
+ return resolved as TrailContext;
88
+ };
89
+
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
+ const prepareContext = async (
292
+ trail: AnyTrail,
293
+ options?: ExecuteTrailOptions
294
+ ): Promise<Result<TrailContext, Error>> => {
295
+ const baseCtx = await resolveContext(options);
296
+ return await resolveServices(trail, baseCtx, options?.services);
297
+ };
298
+
299
+ const runTrail = async (
300
+ trail: AnyTrail,
301
+ input: unknown,
302
+ ctx: TrailContext,
303
+ layers: readonly Layer[]
304
+ ): Promise<Result<unknown, Error>> => {
305
+ const impl = composeLayers([...layers], trail, trail.run);
306
+ return await impl(input, ctx);
65
307
  };
66
308
 
67
309
  // ---------------------------------------------------------------------------
@@ -85,10 +327,17 @@ export const executeTrail = async (
85
327
  return validated;
86
328
  }
87
329
 
88
- const ctx = await resolveContext(options);
89
- const layers = options?.layers ?? [];
90
- const impl = composeLayers([...layers], trail, trail.run);
91
- return await impl(validated.value, ctx);
330
+ const resolvedCtx = await prepareContext(trail, options);
331
+ if (resolvedCtx.isErr()) {
332
+ return resolvedCtx;
333
+ }
334
+
335
+ return await runTrail(
336
+ trail,
337
+ validated.value,
338
+ resolvedCtx.value,
339
+ options?.layers ?? []
340
+ );
92
341
  } catch (error: unknown) {
93
342
  const message = error instanceof Error ? error.message : String(error);
94
343
  return Result.err(new InternalError(message));
package/src/index.ts CHANGED
@@ -30,15 +30,32 @@ export type { ErrorCategory } from './errors.js';
30
30
  export type {
31
31
  Implementation,
32
32
  TrailContext,
33
+ TrailContextInit,
33
34
  FollowFn,
34
35
  ProgressCallback,
35
36
  ProgressEvent,
36
37
  Logger,
38
+ ServiceLookup,
37
39
  } from './types.js';
38
40
 
39
41
  // Context factory
40
42
  export { createTrailContext } from './context.js';
41
43
 
44
+ // Service
45
+ export {
46
+ createServiceLookup,
47
+ findDuplicateServiceId,
48
+ isService,
49
+ service,
50
+ } from './service.js';
51
+ export type {
52
+ AnyService,
53
+ Service,
54
+ ServiceContext,
55
+ ServiceOverrideMap,
56
+ ServiceSpec,
57
+ } from './service.js';
58
+
42
59
  // Trail
43
60
  export { trail } from './trail.js';
44
61
  export type {
package/src/service.ts ADDED
@@ -0,0 +1,139 @@
1
+ import { NotFoundError } from './errors.js';
2
+ import type { Result } from './result.js';
3
+ import type { ServiceLookup, TrailContext } from './types.js';
4
+ import type { z } from 'zod';
5
+
6
+ /**
7
+ * Stable process-scoped fields available when constructing a service.
8
+ *
9
+ * Services are app-level singletons, so they intentionally do not receive the
10
+ * full per-request TrailContext.
11
+ */
12
+ export type ServiceContext = Pick<
13
+ TrailContext,
14
+ 'cwd' | 'env' | 'workspaceRoot'
15
+ >;
16
+
17
+ /**
18
+ * Everything needed to describe a service before a factory is introduced.
19
+ */
20
+ export interface ServiceSpec<T> {
21
+ /** Create the service instance from stable process-scoped context. */
22
+ readonly create: (
23
+ svc: ServiceContext
24
+ ) => Result<T, Error> | Promise<Result<T, Error>>;
25
+ /** Reserved config schema for follow-up config composition work. */
26
+ readonly config?: z.ZodType | undefined;
27
+ /** Optional cleanup performed when the hosting surface shuts down. */
28
+ readonly dispose?: ((service: T) => void | Promise<void>) | undefined;
29
+ /** Optional operational readiness probe for introspection tooling. */
30
+ readonly health?:
31
+ | ((service: T) => Result<unknown, Error> | Promise<Result<unknown, Error>>)
32
+ | undefined;
33
+ /** Optional test factory used by higher-level helpers. */
34
+ readonly mock?: (() => T | Promise<T>) | undefined;
35
+ /** Human-readable description. */
36
+ readonly description?: string | undefined;
37
+ /** Arbitrary metadata for tooling and filtering. */
38
+ readonly metadata?: Readonly<Record<string, unknown>> | undefined;
39
+ }
40
+
41
+ /**
42
+ * A typed service definition.
43
+ *
44
+ * TRL-73 introduces the structural contract only. The `service()` factory and
45
+ * runtime helpers land in follow-up branches.
46
+ */
47
+ export interface Service<T> extends ServiceSpec<T> {
48
+ readonly kind: 'service';
49
+ readonly id: string;
50
+ /** Read the resolved service instance from a trail context. */
51
+ from(ctx: TrailContext): T;
52
+ }
53
+
54
+ /**
55
+ * Existential type for heterogeneous service collections.
56
+ *
57
+ * `Service<T>` includes function parameters in `dispose`/`health`, so `unknown`
58
+ * is too narrow for mixed service arrays. `any` is the correct existential here.
59
+ */
60
+ // oxlint-disable-next-line no-explicit-any -- existential type for heterogeneous service collections
61
+ export type AnyService = Service<any>;
62
+
63
+ /** Explicit runtime overrides keyed by service ID. */
64
+ export type ServiceOverrideMap = Readonly<Record<string, unknown>>;
65
+
66
+ const getServiceId = <T>(
67
+ serviceOrId: string | Pick<Service<T>, 'id'>
68
+ ): string => (typeof serviceOrId === 'string' ? serviceOrId : serviceOrId.id);
69
+
70
+ const getServiceInstance = <T>(
71
+ ctx: Pick<TrailContext, 'extensions'>,
72
+ serviceOrId: string | Pick<Service<T>, 'id'>
73
+ ): T => {
74
+ const id = getServiceId(serviceOrId);
75
+ return ctx.extensions?.[id] as T;
76
+ };
77
+
78
+ const hasServiceInstance = (
79
+ ctx: Pick<TrailContext, 'extensions'>,
80
+ serviceOrId: string | Pick<AnyService, 'id'>
81
+ ): boolean => Object.hasOwn(ctx.extensions ?? {}, getServiceId(serviceOrId));
82
+
83
+ /** Create a `ctx.service(...)` accessor bound to a concrete context snapshot. */
84
+ export const createServiceLookup = (
85
+ getContext: () => Pick<TrailContext, 'extensions'>
86
+ ): ServiceLookup =>
87
+ ((serviceOrId: string | Pick<AnyService, 'id'>) => {
88
+ const id = getServiceId(serviceOrId);
89
+ const ctx = getContext();
90
+ if (!hasServiceInstance(ctx, id)) {
91
+ throw new NotFoundError(`Service "${id}" not found in trail context`);
92
+ }
93
+ return getServiceInstance(ctx, id);
94
+ }) as ServiceLookup;
95
+
96
+ /**
97
+ * Create a typed service definition.
98
+ *
99
+ * The service object is inert until a later execution branch resolves concrete
100
+ * instances into TrailContext extensions.
101
+ */
102
+ export const service = <T>(id: string, spec: ServiceSpec<T>): Service<T> =>
103
+ Object.freeze({
104
+ ...spec,
105
+ from(ctx: TrailContext): T {
106
+ const lookup = ctx.service ?? createServiceLookup(() => ctx);
107
+ return lookup(this);
108
+ },
109
+ id,
110
+ kind: 'service' as const,
111
+ });
112
+
113
+ /** Narrow unknown values to service definitions during topo discovery. */
114
+ export const isService = (value: unknown): value is AnyService => {
115
+ if (typeof value !== 'object' || value === null) {
116
+ return false;
117
+ }
118
+ const v = value as { kind?: unknown; id?: unknown };
119
+ return v.kind === 'service' && typeof v.id === 'string';
120
+ };
121
+
122
+ /**
123
+ * Return the first duplicate service ID in a collection, if any.
124
+ *
125
+ * This supports later topo registration without each caller duplicating the
126
+ * same scan logic.
127
+ */
128
+ export const findDuplicateServiceId = (
129
+ services: readonly Pick<AnyService, 'id'>[]
130
+ ): string | undefined => {
131
+ const seen = new Set<string>();
132
+ for (const candidate of services) {
133
+ if (seen.has(candidate.id)) {
134
+ return candidate.id;
135
+ }
136
+ seen.add(candidate.id);
137
+ }
138
+ return undefined;
139
+ };
package/src/topo.ts CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  import { ValidationError } from './errors.js';
6
6
  import type { AnyEvent } from './event.js';
7
+ import type { AnyService } from './service.js';
8
+ import { isService } from './service.js';
7
9
  import type { AnyTrail } from './trail.js';
8
10
 
9
11
  // ---------------------------------------------------------------------------
@@ -14,19 +16,25 @@ export interface Topo {
14
16
  readonly name: string;
15
17
  readonly trails: ReadonlyMap<string, AnyTrail>;
16
18
  readonly events: ReadonlyMap<string, AnyEvent>;
19
+ readonly services: ReadonlyMap<string, AnyService>;
17
20
  readonly count: number;
21
+ readonly serviceCount: number;
18
22
  get(id: string): AnyTrail | undefined;
23
+ getService(id: string): AnyService | undefined;
19
24
  has(id: string): boolean;
25
+ hasService(id: string): boolean;
20
26
  ids(): string[];
27
+ serviceIds(): string[];
21
28
  list(): AnyTrail[];
22
29
  listEvents(): AnyEvent[];
30
+ listServices(): AnyService[];
23
31
  }
24
32
 
25
33
  // ---------------------------------------------------------------------------
26
34
  // Kind discriminant check
27
35
  // ---------------------------------------------------------------------------
28
36
 
29
- type Registrable = AnyTrail | AnyEvent;
37
+ type Registrable = AnyTrail | AnyEvent | AnyService;
30
38
 
31
39
  const isRegistrable = (value: unknown): value is Registrable => {
32
40
  if (typeof value !== 'object' || value === null) {
@@ -43,16 +51,23 @@ const isRegistrable = (value: unknown): value is Registrable => {
43
51
  const createTopo = (
44
52
  name: string,
45
53
  trails: ReadonlyMap<string, AnyTrail>,
46
- events: ReadonlyMap<string, AnyEvent>
54
+ events: ReadonlyMap<string, AnyEvent>,
55
+ services: ReadonlyMap<string, AnyService>
47
56
  ): Topo => ({
48
57
  count: trails.size,
49
58
  events,
50
59
  get(id: string): AnyTrail | undefined {
51
60
  return trails.get(id);
52
61
  },
62
+ getService(id: string): AnyService | undefined {
63
+ return services.get(id);
64
+ },
53
65
  has(id: string): boolean {
54
66
  return trails.has(id);
55
67
  },
68
+ hasService(id: string): boolean {
69
+ return services.has(id);
70
+ },
56
71
 
57
72
  ids(): string[] {
58
73
  return [...trails.keys()];
@@ -65,9 +80,17 @@ const createTopo = (
65
80
  listEvents(): AnyEvent[] {
66
81
  return [...events.values()];
67
82
  },
83
+ listServices(): AnyService[] {
84
+ return [...services.values()];
85
+ },
68
86
 
69
87
  name,
88
+ serviceCount: services.size,
89
+ serviceIds(): string[] {
90
+ return [...services.keys()];
91
+ },
70
92
 
93
+ services,
71
94
  trails,
72
95
  });
73
96
 
@@ -79,7 +102,8 @@ const createTopo = (
79
102
  const register = (
80
103
  value: Registrable,
81
104
  trails: Map<string, AnyTrail>,
82
- events: Map<string, AnyEvent>
105
+ events: Map<string, AnyEvent>,
106
+ services: Map<string, AnyService>
83
107
  ): void => {
84
108
  const { id } = value as { id: string };
85
109
  const registrars: Record<string, () => void> = {
@@ -89,6 +113,12 @@ const register = (
89
113
  }
90
114
  events.set(id, value as AnyEvent);
91
115
  },
116
+ service: () => {
117
+ if (services.has(id)) {
118
+ throw new ValidationError(`Duplicate service ID: "${id}"`);
119
+ }
120
+ services.set(id, value as AnyService);
121
+ },
92
122
  trail: () => {
93
123
  if (trails.has(id)) {
94
124
  throw new ValidationError(`Duplicate trail ID: "${id}"`);
@@ -99,20 +129,34 @@ const register = (
99
129
  registrars[value.kind]?.();
100
130
  };
101
131
 
132
+ const registerModuleValues = (
133
+ mod: Record<string, unknown>,
134
+ trails: Map<string, AnyTrail>,
135
+ events: Map<string, AnyEvent>,
136
+ services: Map<string, AnyService>
137
+ ): void => {
138
+ for (const value of Object.values(mod)) {
139
+ if (isService(value)) {
140
+ register(value, trails, events, services);
141
+ continue;
142
+ }
143
+ if (isRegistrable(value)) {
144
+ register(value, trails, events, services);
145
+ }
146
+ }
147
+ };
148
+
102
149
  export const topo = (
103
150
  name: string,
104
151
  ...modules: Record<string, unknown>[]
105
152
  ): Topo => {
106
153
  const trails = new Map<string, AnyTrail>();
107
154
  const events = new Map<string, AnyEvent>();
155
+ const services = new Map<string, AnyService>();
108
156
 
109
157
  for (const mod of modules) {
110
- for (const value of Object.values(mod)) {
111
- if (isRegistrable(value)) {
112
- register(value, trails, events);
113
- }
114
- }
158
+ registerModuleValues(mod, trails, events, services);
115
159
  }
116
160
 
117
- return createTopo(name, trails, events);
161
+ return createTopo(name, trails, events, services);
118
162
  };
package/src/trail.ts CHANGED
@@ -2,6 +2,7 @@ import type { z } from 'zod';
2
2
 
3
3
  import type { FieldOverride } from './derive.js';
4
4
  import type { Result } from './result.js';
5
+ import type { AnyService } from './service.js';
5
6
  import type { Implementation, TrailContext } from './types.js';
6
7
 
7
8
  // ---------------------------------------------------------------------------
@@ -56,6 +57,8 @@ export interface TrailSpec<I, O> {
56
57
  readonly fields?: Readonly<Record<string, FieldOverride>> | undefined;
57
58
  /** IDs of downstream trails this trail may invoke via ctx.follow() */
58
59
  readonly follow?: readonly string[] | undefined;
60
+ /** Services this trail may access via service.from(ctx) */
61
+ readonly services?: readonly AnyService[] | undefined;
59
62
  }
60
63
 
61
64
  // ---------------------------------------------------------------------------
@@ -68,13 +71,15 @@ export type Intent = 'read' | 'write' | 'destroy';
68
71
  /** A fully-defined trail — the unit of work in the Trails system */
69
72
  export interface Trail<I, O> extends Omit<
70
73
  TrailSpec<I, O>,
71
- 'run' | 'follow' | 'intent'
74
+ 'run' | 'follow' | 'intent' | 'services'
72
75
  > {
73
76
  readonly kind: 'trail';
74
77
  readonly id: string;
75
78
  readonly run: Implementation<I, O>;
76
79
  /** IDs of downstream trails this trail may invoke via ctx.follow() (always present, default []) */
77
80
  readonly follow: readonly string[];
81
+ /** Services this trail may access via service.from(ctx) (always present, default []) */
82
+ readonly services: readonly AnyService[];
78
83
  /** What this trail does to the world (always present, default 'write') */
79
84
  readonly intent: Intent;
80
85
  }
@@ -122,7 +127,13 @@ export function trail<I, O>(
122
127
  throw new TypeError('trail() requires a spec when an id is provided');
123
128
  }
124
129
 
125
- const { run, follow: rawFollow, intent: rawIntent, ...spec } = resolved.spec;
130
+ const {
131
+ run,
132
+ follow: rawFollow,
133
+ intent: rawIntent,
134
+ services: rawServices,
135
+ ...spec
136
+ } = resolved.spec;
126
137
 
127
138
  return Object.freeze({
128
139
  ...spec,
@@ -131,6 +142,7 @@ export function trail<I, O>(
131
142
  intent: rawIntent ?? 'write',
132
143
  kind: 'trail' as const,
133
144
  run: async (input: I, ctx: TrailContext) => await run(input, ctx),
145
+ services: Object.freeze([...(rawServices ?? [])]),
134
146
  });
135
147
  }
136
148