@ontrails/core 1.0.0-beta.11 → 1.0.0-beta.13

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 (99) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +67 -30
  3. package/README.md +19 -19
  4. package/dist/context.d.ts +1 -1
  5. package/dist/context.d.ts.map +1 -1
  6. package/dist/context.js +5 -4
  7. package/dist/context.js.map +1 -1
  8. package/dist/derive.d.ts +8 -3
  9. package/dist/derive.d.ts.map +1 -1
  10. package/dist/derive.js +7 -7
  11. package/dist/derive.js.map +1 -1
  12. package/dist/event.d.ts +4 -41
  13. package/dist/event.d.ts.map +1 -1
  14. package/dist/event.js +4 -14
  15. package/dist/event.js.map +1 -1
  16. package/dist/execute.d.ts +11 -9
  17. package/dist/execute.d.ts.map +1 -1
  18. package/dist/execute.js +34 -151
  19. package/dist/execute.js.map +1 -1
  20. package/dist/gate.d.ts +17 -0
  21. package/dist/gate.d.ts.map +1 -0
  22. package/dist/gate.js +21 -0
  23. package/dist/gate.js.map +1 -0
  24. package/dist/index.d.ts +10 -7
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +9 -7
  27. package/dist/index.js.map +1 -1
  28. package/dist/layer.d.ts +5 -16
  29. package/dist/layer.d.ts.map +1 -1
  30. package/dist/layer.js +3 -20
  31. package/dist/layer.js.map +1 -1
  32. package/dist/provision-config.d.ts +22 -0
  33. package/dist/provision-config.d.ts.map +1 -0
  34. package/dist/provision-config.js +210 -0
  35. package/dist/provision-config.js.map +1 -0
  36. package/dist/provision.d.ts +71 -0
  37. package/dist/provision.d.ts.map +1 -0
  38. package/dist/provision.js +56 -0
  39. package/dist/provision.js.map +1 -0
  40. package/dist/run.d.ts +27 -0
  41. package/dist/run.d.ts.map +1 -0
  42. package/dist/run.js +34 -0
  43. package/dist/run.js.map +1 -0
  44. package/dist/service-config.d.ts +22 -0
  45. package/dist/service-config.d.ts.map +1 -0
  46. package/dist/service-config.js +210 -0
  47. package/dist/service-config.js.map +1 -0
  48. package/dist/service.d.ts +39 -37
  49. package/dist/service.d.ts.map +1 -1
  50. package/dist/service.js +21 -21
  51. package/dist/service.js.map +1 -1
  52. package/dist/signal.d.ts +33 -0
  53. package/dist/signal.d.ts.map +1 -0
  54. package/dist/signal.js +17 -0
  55. package/dist/signal.js.map +1 -0
  56. package/dist/topo.d.ts +10 -10
  57. package/dist/topo.d.ts.map +1 -1
  58. package/dist/topo.js +48 -35
  59. package/dist/topo.js.map +1 -1
  60. package/dist/trail.d.ts +19 -17
  61. package/dist/trail.d.ts.map +1 -1
  62. package/dist/trail.js +5 -4
  63. package/dist/trail.js.map +1 -1
  64. package/dist/types.d.ts +26 -9
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js +2 -1
  67. package/dist/types.js.map +1 -1
  68. package/dist/validate-topo.d.ts +2 -2
  69. package/dist/validate-topo.js +31 -31
  70. package/dist/validate-topo.js.map +1 -1
  71. package/package.json +1 -1
  72. package/src/__tests__/context.test.ts +8 -8
  73. package/src/__tests__/execute.test.ts +124 -122
  74. package/src/__tests__/{layer.test.ts → gate.test.ts} +26 -26
  75. package/src/__tests__/{dispatch.test.ts → run.test.ts} +39 -39
  76. package/src/__tests__/service-config.test.ts +228 -0
  77. package/src/__tests__/service.test.ts +71 -71
  78. package/src/__tests__/{event.test.ts → signal.test.ts} +15 -15
  79. package/src/__tests__/topo.test.ts +54 -54
  80. package/src/__tests__/trail-permit.test.ts +60 -0
  81. package/src/__tests__/trail.test.ts +58 -58
  82. package/src/__tests__/type-utils.test.ts +3 -3
  83. package/src/__tests__/validate-topo.test.ts +38 -38
  84. package/src/context.ts +5 -4
  85. package/src/derive.ts +8 -8
  86. package/src/event.ts +11 -73
  87. package/src/execute.ts +59 -238
  88. package/src/{layer.ts → gate.ts} +13 -13
  89. package/src/index.ts +26 -21
  90. package/src/provision-config.ts +373 -0
  91. package/src/provision.ts +148 -0
  92. package/src/{dispatch.ts → run.ts} +8 -8
  93. package/src/signal.ts +65 -0
  94. package/src/topo.ts +74 -52
  95. package/src/trail.ts +30 -23
  96. package/src/types.ts +30 -10
  97. package/src/validate-topo.ts +33 -33
  98. package/tsconfig.tsbuildinfo +1 -1
  99. package/src/service.ts +0 -139
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Provision 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 provisions.
7
+ */
8
+
9
+ import type {
10
+ AnyProvision,
11
+ ProvisionContext,
12
+ ProvisionOverrideMap,
13
+ } from './provision.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 { createProvisionLookup } from './provision.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 singletonProvisions = new WeakMap<AnyProvision, Map<string, unknown>>();
32
+
33
+ /** In-flight provision creation promises, keyed by provision x context. */
34
+ const pendingCreations = new WeakMap<
35
+ AnyProvision,
36
+ Map<string, Promise<Result<unknown, Error>>>
37
+ >();
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Context helpers
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const toProvisionContext = (
44
+ ctx: TrailContext,
45
+ config?: unknown
46
+ ): ProvisionContext => ({
47
+ config,
48
+ cwd: ctx.cwd,
49
+ env: ctx.env,
50
+ workspaceRoot: ctx.workspaceRoot,
51
+ });
52
+
53
+ const toProvisionContextKey = (ctx: ProvisionContext): 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 provision's config from the provided configValues map. */
68
+ const resolveProvisionConfig = (
69
+ declaredProvision: AnyProvision,
70
+ configValues?: ConfigValues
71
+ ): Result<unknown, Error> => {
72
+ if (declaredProvision.config === undefined) {
73
+ return Result.ok();
74
+ }
75
+ const raw = configValues?.[declaredProvision.id];
76
+ if (raw === undefined) {
77
+ return Result.err(
78
+ new ValidationError(
79
+ `Provision "${declaredProvision.id}" declares a config schema but no config was provided`
80
+ )
81
+ );
82
+ }
83
+ const parsed = declaredProvision.config.safeParse(raw);
84
+ if (!parsed.success) {
85
+ return Result.err(
86
+ new ValidationError(
87
+ `Provision "${declaredProvision.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 hasOwnProvisionOverride = (
99
+ overrides: ProvisionOverrideMap | undefined,
100
+ id: string
101
+ ): overrides is ProvisionOverrideMap =>
102
+ overrides !== undefined && Object.hasOwn(overrides, id);
103
+
104
+ const getCachedSingletonProvision = (
105
+ declaredProvision: AnyProvision,
106
+ provisionContext: ProvisionContext
107
+ ): { readonly found: boolean; readonly value: unknown } => {
108
+ const scopedCache = singletonProvisions.get(declaredProvision);
109
+ if (scopedCache === undefined) {
110
+ return { found: false, value: undefined };
111
+ }
112
+
113
+ const key = toProvisionContextKey(provisionContext);
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 getProvidedProvision = (
125
+ ctx: TrailContext,
126
+ overrides: ProvisionOverrideMap | undefined,
127
+ declaredProvision: AnyProvision,
128
+ provisionContext: ProvisionContext
129
+ ): Result<unknown, Error> | undefined => {
130
+ const { id } = declaredProvision;
131
+ if (hasOwnProvisionOverride(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 = getCachedSingletonProvision(
140
+ declaredProvision,
141
+ provisionContext
142
+ );
143
+ if (cached.found) {
144
+ return Result.ok(cached.value);
145
+ }
146
+
147
+ return undefined;
148
+ };
149
+
150
+ const getOverrideOrExtension = (
151
+ ctx: TrailContext,
152
+ overrides: ProvisionOverrideMap | undefined,
153
+ declaredProvision: AnyProvision
154
+ ): Result<unknown, Error> | undefined =>
155
+ getProvidedProvision(
156
+ ctx,
157
+ overrides,
158
+ declaredProvision,
159
+ toProvisionContext(ctx)
160
+ );
161
+
162
+ type ConfigAwareResolution =
163
+ | Result<{ readonly kind: 'provided'; readonly value: unknown }, Error>
164
+ | Result<
165
+ { readonly kind: 'context'; readonly provisionContext: ProvisionContext },
166
+ Error
167
+ >;
168
+
169
+ const resolveConfigAwareProvidedProvision = (
170
+ ctx: TrailContext,
171
+ declaredProvision: AnyProvision,
172
+ configValues: ConfigValues | undefined
173
+ ): ConfigAwareResolution => {
174
+ const configResult = resolveProvisionConfig(declaredProvision, configValues);
175
+ if (configResult.isErr()) {
176
+ return configResult;
177
+ }
178
+
179
+ const provisionContext = toProvisionContext(ctx, configResult.value);
180
+ const provided = getProvidedProvision(
181
+ ctx,
182
+ undefined,
183
+ declaredProvision,
184
+ provisionContext
185
+ );
186
+
187
+ return provided
188
+ ? Result.ok({ kind: 'provided', value: provided.unwrap() })
189
+ : Result.ok({ kind: 'context', provisionContext });
190
+ };
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Instance creation
194
+ // ---------------------------------------------------------------------------
195
+
196
+ const toInternalProvisionError = (
197
+ id: string,
198
+ error: unknown
199
+ ): InternalError => {
200
+ const cause = error instanceof Error ? error : undefined;
201
+ const message = cause?.message ?? String(error);
202
+ return new InternalError(`Provision "${id}" failed to resolve: ${message}`, {
203
+ ...(cause ? { cause } : {}),
204
+ context: { provisionId: id },
205
+ });
206
+ };
207
+
208
+ const getSingletonProvisionCache = (
209
+ declaredProvision: AnyProvision
210
+ ): Map<string, unknown> => {
211
+ const existing = singletonProvisions.get(declaredProvision);
212
+ if (existing !== undefined) {
213
+ return existing;
214
+ }
215
+
216
+ const created = new Map<string, unknown>();
217
+ singletonProvisions.set(declaredProvision, created);
218
+ return created;
219
+ };
220
+
221
+ const doCreateProvisionInstance = async (
222
+ declaredProvision: AnyProvision,
223
+ provisionContext: ProvisionContext
224
+ ): Promise<Result<unknown, Error>> => {
225
+ try {
226
+ const created = await declaredProvision.create(provisionContext);
227
+ if (created.isErr()) {
228
+ return Result.err(created.error);
229
+ }
230
+
231
+ const instance = created.unwrap();
232
+ getSingletonProvisionCache(declaredProvision).set(
233
+ toProvisionContextKey(provisionContext),
234
+ instance
235
+ );
236
+ return Result.ok(instance);
237
+ } catch (error: unknown) {
238
+ return Result.err(toInternalProvisionError(declaredProvision.id, error));
239
+ }
240
+ };
241
+
242
+ const trackPendingCreation = (
243
+ declaredProvision: AnyProvision,
244
+ key: string,
245
+ promise: Promise<Result<unknown, Error>>
246
+ ): void => {
247
+ const pending = pendingCreations.get(declaredProvision);
248
+ if (pending) {
249
+ pending.set(key, promise);
250
+ } else {
251
+ pendingCreations.set(declaredProvision, new Map([[key, promise]]));
252
+ }
253
+ };
254
+
255
+ /**
256
+ * Deduplicates concurrent creation of the same provision singleton.
257
+ * If a creation is already in flight for this provision x context key,
258
+ * returns the existing promise instead of spawning a second factory call.
259
+ */
260
+ const createProvisionInstance = async (
261
+ declaredProvision: AnyProvision,
262
+ provisionContext: ProvisionContext
263
+ ): Promise<Result<unknown, Error>> => {
264
+ const key = toProvisionContextKey(provisionContext);
265
+ const inflight = pendingCreations.get(declaredProvision)?.get(key);
266
+ if (inflight) {
267
+ return inflight;
268
+ }
269
+
270
+ const promise = doCreateProvisionInstance(
271
+ declaredProvision,
272
+ provisionContext
273
+ );
274
+ trackPendingCreation(declaredProvision, key, promise);
275
+
276
+ try {
277
+ return await promise;
278
+ } finally {
279
+ pendingCreations.get(declaredProvision)?.delete(key);
280
+ }
281
+ };
282
+
283
+ /** Validate config and resolve a single declared provision. */
284
+ const resolveDeclaredProvision = async (
285
+ declaredProvision: AnyProvision,
286
+ ctx: TrailContext,
287
+ overrides: ProvisionOverrideMap | undefined,
288
+ configValues: ConfigValues | undefined
289
+ ): Promise<Result<unknown, Error>> => {
290
+ // Check overrides/extensions first — skip config validation entirely when
291
+ // a provision instance is already provided.
292
+ const overrideOrExtension = getOverrideOrExtension(
293
+ ctx,
294
+ overrides,
295
+ declaredProvision
296
+ );
297
+ if (overrideOrExtension !== undefined) {
298
+ return overrideOrExtension;
299
+ }
300
+
301
+ // Resolve config before consulting the singleton cache so config-aware
302
+ // provisions use the same canonical context for cache reads and writes.
303
+ const configAwareProvision = resolveConfigAwareProvidedProvision(
304
+ ctx,
305
+ declaredProvision,
306
+ configValues
307
+ );
308
+ if (configAwareProvision.isErr()) {
309
+ return configAwareProvision;
310
+ }
311
+
312
+ // No provided instance — create via factory.
313
+ const resolved = configAwareProvision.unwrap();
314
+ if (resolved.kind === 'provided') {
315
+ return Result.ok(resolved.value);
316
+ }
317
+
318
+ return await createProvisionInstance(
319
+ declaredProvision,
320
+ resolved.provisionContext
321
+ );
322
+ };
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Full trail provision resolution
326
+ // ---------------------------------------------------------------------------
327
+
328
+ const withResolvedProvisions = (
329
+ ctx: TrailContext,
330
+ resolvedProvisions: Record<string, unknown>
331
+ ): TrailContext => {
332
+ const extensions = { ...ctx.extensions, ...resolvedProvisions };
333
+ const resolvedCtx = { ...ctx, extensions } as MutableTrailContext;
334
+ const lookup = createProvisionLookup(() => resolvedCtx);
335
+ resolvedCtx.provision = lookup;
336
+ return resolvedCtx;
337
+ };
338
+
339
+ /**
340
+ * Resolve all declared provisions for a trail.
341
+ *
342
+ * Validates per-provision config, checks overrides and caches, and creates
343
+ * new instances as needed. Returns an enriched context with all provision
344
+ * instances injected into extensions.
345
+ */
346
+ export const resolveProvisions = async (
347
+ trail: AnyTrail,
348
+ ctx: TrailContext,
349
+ overrides?: ProvisionOverrideMap,
350
+ configValues?: ConfigValues
351
+ ): Promise<Result<TrailContext, Error>> => {
352
+ const { provisions } = trail;
353
+ if (provisions.length === 0) {
354
+ return Result.ok(ctx);
355
+ }
356
+
357
+ const resolvedProvisions: Record<string, unknown> = {};
358
+
359
+ for (const declaredProvision of provisions) {
360
+ const resolved = await resolveDeclaredProvision(
361
+ declaredProvision,
362
+ ctx,
363
+ overrides,
364
+ configValues
365
+ );
366
+ if (resolved.isErr()) {
367
+ return resolved;
368
+ }
369
+ resolvedProvisions[declaredProvision.id] = resolved.unwrap();
370
+ }
371
+
372
+ return Result.ok(withResolvedProvisions(ctx, resolvedProvisions));
373
+ };
@@ -0,0 +1,148 @@
1
+ import { NotFoundError } from './errors.js';
2
+ import type { Result } from './result.js';
3
+ import type { ProvisionLookup, TrailContext } from './types.js';
4
+ import type { z } from 'zod';
5
+
6
+ /**
7
+ * Stable process-scoped fields available when constructing a provision.
8
+ *
9
+ * Provisions are app-level singletons, so they intentionally do not receive
10
+ * the full per-request TrailContext. When a provision declares a `config` schema,
11
+ * the validated config is passed as `svc.config`.
12
+ */
13
+ export type ProvisionContext<C = unknown> = Pick<
14
+ TrailContext,
15
+ 'cwd' | 'env' | 'workspaceRoot'
16
+ > & {
17
+ readonly config: C;
18
+ };
19
+
20
+ /**
21
+ * Everything needed to describe a provision before a factory is introduced.
22
+ *
23
+ * When `config` is a Zod schema, the `create` callback receives
24
+ * `ProvisionContext<C>` with the validated config value.
25
+ */
26
+ export interface ProvisionSpec<T, C = unknown> {
27
+ /** Create the provision instance from stable process-scoped context. */
28
+ readonly create: (
29
+ svc: ProvisionContext<C>
30
+ ) => Result<T, Error> | Promise<Result<T, Error>>;
31
+ /** Config schema — when present, config is validated and passed to `create`. */
32
+ readonly config?: z.ZodType<C> | undefined;
33
+ /** Optional cleanup performed when the hosting trailhead shuts down. */
34
+ readonly dispose?: ((provision: T) => void | Promise<void>) | undefined;
35
+ /** Optional operational readiness probe for introspection tooling. */
36
+ readonly health?:
37
+ | ((
38
+ provision: T
39
+ ) => Result<unknown, Error> | Promise<Result<unknown, Error>>)
40
+ | undefined;
41
+ /** Optional test factory used by higher-level helpers. */
42
+ readonly mock?: (() => T | Promise<T>) | undefined;
43
+ /** Human-readable description. */
44
+ readonly description?: string | undefined;
45
+ /** Arbitrary meta for tooling and filtering. */
46
+ readonly meta?: Readonly<Record<string, unknown>> | undefined;
47
+ }
48
+
49
+ /** A typed provision definition. */
50
+ export interface Provision<T> extends ProvisionSpec<T> {
51
+ readonly kind: 'provision';
52
+ readonly id: string;
53
+ /** Read the resolved provision instance from a trail context. */
54
+ from(ctx: TrailContext): T;
55
+ }
56
+
57
+ /**
58
+ * Existential type for heterogeneous provision collections.
59
+ *
60
+ * `Provision<T>` includes function parameters in `dispose`/`health`, so
61
+ * `unknown` is too narrow for mixed provision arrays. `any` is the correct
62
+ * existential here.
63
+ */
64
+ // oxlint-disable-next-line no-explicit-any -- existential type for heterogeneous provision collections
65
+ export type AnyProvision = Provision<any>;
66
+
67
+ /** Explicit runtime overrides keyed by provision ID. */
68
+ export type ProvisionOverrideMap = Readonly<Record<string, unknown>>;
69
+
70
+ const getProvisionId = <T>(
71
+ provisionOrId: string | Pick<Provision<T>, 'id'>
72
+ ): string =>
73
+ typeof provisionOrId === 'string' ? provisionOrId : provisionOrId.id;
74
+
75
+ const getProvisionInstance = <T>(
76
+ ctx: Pick<TrailContext, 'extensions'>,
77
+ provisionOrId: string | Pick<Provision<T>, 'id'>
78
+ ): T => {
79
+ const id = getProvisionId(provisionOrId);
80
+ return ctx.extensions?.[id] as T;
81
+ };
82
+
83
+ const hasProvisionInstance = (
84
+ ctx: Pick<TrailContext, 'extensions'>,
85
+ provisionOrId: string | Pick<AnyProvision, 'id'>
86
+ ): boolean =>
87
+ Object.hasOwn(ctx.extensions ?? {}, getProvisionId(provisionOrId));
88
+
89
+ /** Create a `ctx.provision(...)` accessor bound to a concrete context snapshot. */
90
+ export const createProvisionLookup = (
91
+ getContext: () => Pick<TrailContext, 'extensions'>
92
+ ): ProvisionLookup =>
93
+ ((provisionOrId: string | Pick<AnyProvision, 'id'>) => {
94
+ const id = getProvisionId(provisionOrId);
95
+ const ctx = getContext();
96
+ if (!hasProvisionInstance(ctx, id)) {
97
+ throw new NotFoundError(`Provision "${id}" not found in trail context`);
98
+ }
99
+ return getProvisionInstance(ctx, id);
100
+ }) as ProvisionLookup;
101
+
102
+ /**
103
+ * Create a typed provision definition.
104
+ *
105
+ * The provision object is inert until a later execution branch resolves concrete
106
+ * instances into TrailContext extensions.
107
+ */
108
+ export const provision = <T>(
109
+ id: string,
110
+ spec: ProvisionSpec<T>
111
+ ): Provision<T> =>
112
+ Object.freeze({
113
+ ...spec,
114
+ from(ctx: TrailContext): T {
115
+ const lookup = ctx.provision ?? createProvisionLookup(() => ctx);
116
+ return lookup(this);
117
+ },
118
+ id,
119
+ kind: 'provision' as const,
120
+ });
121
+
122
+ /** Narrow unknown values to provision definitions during topo discovery. */
123
+ export const isProvision = (value: unknown): value is AnyProvision => {
124
+ if (typeof value !== 'object' || value === null) {
125
+ return false;
126
+ }
127
+ const v = value as { kind?: unknown; id?: unknown };
128
+ return v.kind === 'provision' && typeof v.id === 'string';
129
+ };
130
+
131
+ /**
132
+ * Return the first duplicate provision ID in a collection, if any.
133
+ *
134
+ * This supports later topo registration without each caller duplicating the
135
+ * same scan logic.
136
+ */
137
+ export const findDuplicateProvisionId = (
138
+ provisions: readonly Pick<AnyProvision, 'id'>[]
139
+ ): string | undefined => {
140
+ const seen = new Set<string>();
141
+ for (const candidate of provisions) {
142
+ if (seen.has(candidate.id)) {
143
+ return candidate.id;
144
+ }
145
+ seen.add(candidate.id);
146
+ }
147
+ return undefined;
148
+ };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Headless trail execution the "no-surface" surface.
2
+ * Headless trail execution without mounting a trailhead.
3
3
  *
4
4
  * Looks up a trail by ID in a topo, then delegates to `executeTrail`.
5
5
  * Returns a `Result` and never throws.
@@ -15,15 +15,15 @@ import { Result } from './result.js';
15
15
  // Options
16
16
  // ---------------------------------------------------------------------------
17
17
 
18
- /** Options forwarded to `executeTrail` from `dispatch`. */
19
- export type DispatchOptions = ExecuteTrailOptions;
18
+ /** Options forwarded to `executeTrail` from `run`. */
19
+ export type RunOptions = ExecuteTrailOptions;
20
20
 
21
21
  // ---------------------------------------------------------------------------
22
- // dispatch()
22
+ // run()
23
23
  // ---------------------------------------------------------------------------
24
24
 
25
25
  /**
26
- * Execute a trail by ID from a topo without mounting a surface.
26
+ * Execute a trail by ID from a topo without mounting a trailhead.
27
27
  *
28
28
  * Resolves the trail from the topo, then runs it through the standard
29
29
  * `executeTrail` pipeline. Returns `Result.err(NotFoundError)` if the
@@ -32,15 +32,15 @@ export type DispatchOptions = ExecuteTrailOptions;
32
32
  *
33
33
  * @example
34
34
  * ```typescript
35
- * const result = await dispatch(myTopo, 'greet', { name: 'Alice' });
35
+ * const result = await run(myTopo, 'greet', { name: 'Alice' });
36
36
  * if (result.isOk()) console.log(result.value);
37
37
  * ```
38
38
  */
39
- export const dispatch = (
39
+ export const run = (
40
40
  topo: Topo,
41
41
  id: string,
42
42
  input: unknown,
43
- options?: DispatchOptions
43
+ options?: RunOptions
44
44
  ): Promise<Result<unknown, Error>> => {
45
45
  const trail = topo.get(id);
46
46
  if (trail === undefined) {
package/src/signal.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Signal — a named payload schema with optional provenance meta.
3
+ */
4
+
5
+ import type { z } from 'zod';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Spec (input to the factory)
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export interface SignalSpec<T> {
12
+ readonly payload: z.ZodType<T>;
13
+ readonly description?: string | undefined;
14
+ readonly meta?: Readonly<Record<string, unknown>> | undefined;
15
+ /** Trail IDs that produce this signal (e.g. the trails it originates from). */
16
+ readonly from?: readonly string[] | undefined;
17
+ }
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Shape (output of the factory)
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export interface Signal<T> {
24
+ readonly id: string;
25
+ readonly kind: 'signal';
26
+ readonly payload: z.ZodType<T>;
27
+ readonly description?: string | undefined;
28
+ readonly meta?: Readonly<Record<string, unknown>> | undefined;
29
+ /** Trail IDs that produce this signal (e.g. the trails it originates from). */
30
+ readonly from?: readonly string[] | undefined;
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Factory
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Create a signal definition.
39
+ *
40
+ * A signal is a named payload schema describing something that happened.
41
+ * Returns a frozen object with `kind: "signal"` and all spec fields.
42
+ */
43
+ export function signal<T>(id: string, spec: SignalSpec<T>): Signal<T>;
44
+ export function signal<T>(
45
+ spec: SignalSpec<T> & { readonly id: string }
46
+ ): Signal<T>;
47
+ export function signal<T>(
48
+ idOrSpec: string | (SignalSpec<T> & { readonly id: string }),
49
+ maybeSpec?: SignalSpec<T>
50
+ ): Signal<T> {
51
+ const resolvedId = typeof idOrSpec === 'string' ? idOrSpec : idOrSpec.id;
52
+ // oxlint-disable-next-line no-non-null-assertion -- overload guarantees maybeSpec when idOrSpec is string
53
+ const resolvedSpec = typeof idOrSpec === 'string' ? maybeSpec! : idOrSpec;
54
+ return Object.freeze({
55
+ description: resolvedSpec.description,
56
+ from: resolvedSpec.from ? Object.freeze([...resolvedSpec.from]) : undefined,
57
+ id: resolvedId,
58
+ kind: 'signal' as const,
59
+ meta: resolvedSpec.meta,
60
+ payload: resolvedSpec.payload,
61
+ });
62
+ }
63
+
64
+ /** Existential type for heterogeneous signal collections */
65
+ export type AnySignal = Signal<unknown>;