@ontrails/core 1.0.0-beta.39 → 1.0.0-beta.41

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/src/queue.ts ADDED
@@ -0,0 +1,163 @@
1
+ import type {
2
+ ActivationSource,
3
+ ActivationSourceMeta,
4
+ ActivationSourceParse,
5
+ } from './activation-source.js';
6
+ import { ValidationError } from './errors.js';
7
+
8
+ export interface QueueSpec<TOutput = unknown> {
9
+ readonly meta?: ActivationSourceMeta | undefined;
10
+ readonly parse: ActivationSourceParse<TOutput>;
11
+ readonly payload?: ActivationSource['payload'] | undefined;
12
+ /**
13
+ * Runtime queue name. Defaults to the source id, so authored queue
14
+ * contracts can stay stable while host bindings choose their own names.
15
+ */
16
+ readonly queue?: string | undefined;
17
+ /** Reserved for future queue-specific design; trail versioning is trail-only. */
18
+ readonly version?: never;
19
+ }
20
+
21
+ export interface QueueSource<TOutput = unknown> extends ActivationSource {
22
+ readonly kind: 'queue';
23
+ readonly meta?: ActivationSourceMeta | undefined;
24
+ readonly parse: ActivationSourceParse<TOutput>;
25
+ readonly payload?: ActivationSource['payload'] | undefined;
26
+ readonly queue: string;
27
+ }
28
+
29
+ export interface QueueValidationIssue {
30
+ readonly field: 'parse' | 'queue';
31
+ readonly message: string;
32
+ }
33
+
34
+ const normalizeQueueName = (queueName: string): string => queueName.trim();
35
+
36
+ const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
37
+ typeof value === 'object' && value !== null && !Array.isArray(value);
38
+
39
+ const isZodSchema = (value: unknown): boolean =>
40
+ isObjectRecord(value) && typeof value['safeParse'] === 'function';
41
+
42
+ const validateQueueName = (queueName: unknown): QueueValidationIssue[] =>
43
+ typeof queueName === 'string' && queueName.trim().length > 0
44
+ ? []
45
+ : [
46
+ {
47
+ field: 'queue',
48
+ message: 'Queue source must define a non-empty queue name',
49
+ },
50
+ ];
51
+
52
+ const validateRequiredParse = (parse: unknown): QueueValidationIssue[] =>
53
+ parse === undefined
54
+ ? [
55
+ {
56
+ field: 'parse',
57
+ message: 'Queue sources must define parse',
58
+ },
59
+ ]
60
+ : [];
61
+
62
+ const validateParseShape = (parse: unknown): QueueValidationIssue[] => {
63
+ if (parse === undefined || isZodSchema(parse)) {
64
+ return [];
65
+ }
66
+ if (isObjectRecord(parse) && isZodSchema(parse['output'])) {
67
+ return [];
68
+ }
69
+ return [
70
+ {
71
+ field: 'parse',
72
+ message: 'Queue parse must be a Zod schema or define parse.output',
73
+ },
74
+ ];
75
+ };
76
+
77
+ const queueIssuesMessage = (
78
+ id: string,
79
+ issues: readonly QueueValidationIssue[]
80
+ ): string =>
81
+ `queue("${id}") is invalid: ${issues.map((issue) => `${issue.field}: ${issue.message}`).join('; ')}`;
82
+
83
+ const assertQueueSpec = <TOutput>(
84
+ id: string,
85
+ spec: QueueSpec<TOutput>
86
+ ): {
87
+ readonly queue: string;
88
+ } => {
89
+ const queueName = spec.queue === undefined ? id : spec.queue;
90
+ const issues = [
91
+ ...validateQueueName(queueName),
92
+ ...validateRequiredParse(spec.parse),
93
+ ...validateParseShape(spec.parse),
94
+ ];
95
+
96
+ if (issues.length > 0) {
97
+ throw new ValidationError(queueIssuesMessage(id, issues), {
98
+ context: { issues },
99
+ });
100
+ }
101
+
102
+ return { queue: normalizeQueueName(queueName) };
103
+ };
104
+
105
+ export const validateQueueSource = (
106
+ source: ActivationSource
107
+ ): readonly QueueValidationIssue[] => {
108
+ if (source.kind !== 'queue') {
109
+ return [];
110
+ }
111
+
112
+ return [
113
+ ...validateQueueName(source.queue),
114
+ ...validateRequiredParse(source.parse),
115
+ ...validateParseShape(source.parse),
116
+ ];
117
+ };
118
+
119
+ /**
120
+ * Define a queue activation source.
121
+ *
122
+ * Queue sources are inert contract data: they describe which runtime queue
123
+ * wakes a trail and how the message body becomes trail input. A host adapter
124
+ * such as `@ontrails/cloudflare/workers` owns delivery.
125
+ *
126
+ * @example
127
+ * ```ts
128
+ * import { queue } from '@ontrails/core';
129
+ * import { z } from 'zod';
130
+ *
131
+ * const source = queue('queue.email.outbox', {
132
+ * queue: 'email-outbox',
133
+ * parse: z.object({ messageId: z.string() }),
134
+ * });
135
+ * ```
136
+ */
137
+ export function queue<TOutput>(
138
+ id: string,
139
+ spec: QueueSpec<TOutput>
140
+ ): QueueSource<TOutput>;
141
+ export function queue<TOutput>(
142
+ spec: QueueSpec<TOutput> & { readonly id: string }
143
+ ): QueueSource<TOutput>;
144
+ export function queue<TOutput>(
145
+ idOrSpec: string | (QueueSpec<TOutput> & { readonly id: string }),
146
+ maybeSpec?: QueueSpec<TOutput>
147
+ ): QueueSource<TOutput> {
148
+ const id = typeof idOrSpec === 'string' ? idOrSpec : idOrSpec.id;
149
+ // oxlint-disable-next-line no-non-null-assertion -- overload guarantees maybeSpec when idOrSpec is string
150
+ const spec = typeof idOrSpec === 'string' ? maybeSpec! : idOrSpec;
151
+ const normalized = assertQueueSpec(id, spec);
152
+
153
+ return Object.freeze({
154
+ id,
155
+ kind: 'queue' as const,
156
+ parse: spec.parse,
157
+ queue: normalized.queue,
158
+ ...(spec.meta === undefined
159
+ ? {}
160
+ : { meta: Object.freeze({ ...spec.meta }) }),
161
+ ...(spec.payload === undefined ? {} : { payload: spec.payload }),
162
+ });
163
+ }
package/src/signal-ref.ts CHANGED
@@ -11,6 +11,7 @@ export interface LateBoundSignalMarker {
11
11
  }
12
12
 
13
13
  const LATE_BOUND_SIGNAL_REF = Symbol('trails.late-bound-signal-ref');
14
+ const LATE_BOUND_SIGNAL_BOUND = Symbol('trails.late-bound-signal-bound');
14
15
  const LATE_BOUND_SIGNAL_MARKER_PREFIX = '@@trails:late-bound-signal-ref:';
15
16
 
16
17
  const defineLateBoundSignalRef = <T extends object>(
@@ -26,6 +27,22 @@ const defineLateBoundSignalRef = <T extends object>(
26
27
  return value;
27
28
  };
28
29
 
30
+ const defineLateBoundSignalBound = <T extends object>(value: T): T => {
31
+ Object.defineProperty(value, LATE_BOUND_SIGNAL_BOUND, {
32
+ configurable: false,
33
+ enumerable: false,
34
+ value: true,
35
+ writable: false,
36
+ });
37
+ return value;
38
+ };
39
+
40
+ export const isBoundLateBoundSignal = (
41
+ signal: Pick<AnySignal, 'id'> | undefined
42
+ ): boolean =>
43
+ signal !== undefined &&
44
+ (signal as Record<PropertyKey, unknown>)[LATE_BOUND_SIGNAL_BOUND] === true;
45
+
29
46
  export const getLateBoundSignalRef = (
30
47
  signal: Pick<AnySignal, 'id'> | undefined
31
48
  ): LateBoundSignalRef | undefined =>
@@ -58,14 +75,17 @@ export const cloneSignalWithId = <T>(
58
75
  };
59
76
  const ref = getLateBoundSignalRef(signal);
60
77
  return Object.freeze(
61
- ref ? defineLateBoundSignalRef(clone, ref) : clone
78
+ ref
79
+ ? defineLateBoundSignalBound(defineLateBoundSignalRef(clone, ref))
80
+ : clone
62
81
  ) as Signal<T>;
63
82
  };
64
83
 
65
84
  export const createLateBoundSignalMarker = (
66
85
  ref: LateBoundSignalRef,
67
86
  displayId: string
68
- ): string => `${LATE_BOUND_SIGNAL_MARKER_PREFIX}${ref.token}:${displayId}`;
87
+ ): string =>
88
+ `${LATE_BOUND_SIGNAL_MARKER_PREFIX}${encodeURIComponent(ref.token)}:${displayId}`;
69
89
 
70
90
  export const parseLateBoundSignalMarker = (
71
91
  value: string
@@ -80,8 +100,12 @@ export const parseLateBoundSignalMarker = (
80
100
  return null;
81
101
  }
82
102
 
83
- return {
84
- displayId: remainder.slice(separator + 1),
85
- token: remainder.slice(0, separator),
86
- };
103
+ try {
104
+ const token = decodeURIComponent(remainder.slice(0, separator));
105
+ return token.length === 0
106
+ ? null
107
+ : { displayId: remainder.slice(separator + 1), token };
108
+ } catch {
109
+ return null;
110
+ }
87
111
  };
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Structural accessor protocol used by `deriveTrail()` to synthesize default
3
- * blazes for standard CRUD operations without depending on `@ontrails/store`.
3
+ * implementations for standard CRUD operations without depending on `@ontrails/store`.
4
4
  *
5
5
  * @remarks
6
6
  * This type is intentionally minimal and structural. `@ontrails/store`'s
@@ -35,21 +35,21 @@ export interface StoreAccessorProtocol<
35
35
  remove(id: TId): Promise<{ readonly deleted: boolean }>;
36
36
  /**
37
37
  * Optional insert — available on tabular adapters that distinguish
38
- * create from update. When absent, synthesized blazes fall back to
38
+ * create from update. When absent, synthesized implementations fall back to
39
39
  * `upsert`.
40
40
  */
41
41
  insert?(input: TInput): Promise<TEntity>;
42
42
  /**
43
43
  * Optional patch-by-identity — available on tabular adapters. Returns
44
44
  * `null` when no row with the given identity exists. When absent,
45
- * synthesized update blazes fall back to `get` + merge + `upsert`.
45
+ * synthesized update implementations fall back to `get` + merge + `upsert`.
46
46
  */
47
47
  update?(id: TId, patch: Partial<TInput>): Promise<TEntity | null>;
48
48
  }
49
49
 
50
50
  /**
51
51
  * Record shape returned by a resource's `from(ctx)` call, keyed by accessor
52
- * name. Used by `deriveTrail()` to resolve an accessor by contour name.
52
+ * name. Used by `deriveTrail()` to resolve an accessor by entity name.
53
53
  */
54
54
  export type StoreAccessorRecord = Readonly<
55
55
  Record<string, StoreAccessorProtocol<unknown, unknown, unknown, unknown>>
@@ -577,7 +577,7 @@ const assertMcpSynonymBindingName = (name: string): void => {
577
577
  * Derive the deterministic default description for an MCP grouped entry
578
578
  * projected from the `surfaces` overlay.
579
579
  *
580
- * Both the MCP surface (the runtime tool description) and Topographer (the
580
+ * Both the MCP surface (the runtime tool description) and Topography (the
581
581
  * lock's trailhead entry description) read this helper, so the authored
582
582
  * binding projects one description everywhere.
583
583
  *
package/src/topo.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Application entry point — scans module exports to build a topology graph.
3
3
  */
4
4
 
5
- import type { AnyContour } from './contour.js';
5
+ import type { AnyEntity } from './entity.js';
6
6
  import { ValidationError } from './errors.js';
7
7
  import type { ActivationEntry } from './activation-source.js';
8
8
  import {
@@ -39,7 +39,7 @@ export interface Topo {
39
39
  readonly name: string;
40
40
  readonly version?: string;
41
41
  readonly description?: string;
42
- readonly contours: ReadonlyMap<string, AnyContour>;
42
+ readonly entities: ReadonlyMap<string, AnyEntity>;
43
43
  readonly trails: ReadonlyMap<string, AnyTrail>;
44
44
  readonly signals: ReadonlyMap<string, AnySignal>;
45
45
  readonly resources: ReadonlyMap<string, AnyResource>;
@@ -49,23 +49,23 @@ export interface Topo {
49
49
  *
50
50
  * The CLI/MCP/HTTP surfaces forward these into `executeTrail` as
51
51
  * `topoLayers`, where they are composed outermost in the layer chain.
52
- * The final composition order is `topo → surface → trail → blaze`
52
+ * The final composition order is `topo → surface → trail → implementation`
53
53
  * (outermost-first).
54
54
  */
55
55
  readonly layers: readonly Layer[];
56
56
  readonly count: number;
57
- readonly contourCount: number;
57
+ readonly entityCount: number;
58
58
  readonly resourceCount: number;
59
- getContour(name: string): AnyContour | undefined;
59
+ getEntity(name: string): AnyEntity | undefined;
60
60
  get(id: string): AnyTrail | undefined;
61
61
  getResource(id: string): AnyResource | undefined;
62
- hasContour(name: string): boolean;
62
+ hasEntity(name: string): boolean;
63
63
  has(id: string): boolean;
64
64
  hasResource(id: string): boolean;
65
- contourIds(): string[];
65
+ entityIds(): string[];
66
66
  ids(): string[];
67
67
  resourceIds(): string[];
68
- listContours(): AnyContour[];
68
+ listEntities(): AnyEntity[];
69
69
  list(): AnyTrail[];
70
70
  listSignals(): AnySignal[];
71
71
  listResources(): AnyResource[];
@@ -75,14 +75,14 @@ export interface Topo {
75
75
  // Kind discriminant check
76
76
  // ---------------------------------------------------------------------------
77
77
 
78
- type Registrable = AnyContour | AnyTrail | AnySignal | AnyResource;
78
+ type Registrable = AnyEntity | AnyTrail | AnySignal | AnyResource;
79
79
 
80
80
  const isRegistrable = (value: unknown): value is Registrable => {
81
81
  if (typeof value !== 'object' || value === null) {
82
82
  return false;
83
83
  }
84
84
  const { kind } = value as Record<string, unknown>;
85
- return kind === 'contour' || kind === 'trail' || kind === 'signal';
85
+ return kind === 'entity' || kind === 'trail' || kind === 'signal';
86
86
  };
87
87
 
88
88
  // ---------------------------------------------------------------------------
@@ -91,24 +91,24 @@ const isRegistrable = (value: unknown): value is Registrable => {
91
91
 
92
92
  const createTopo = (
93
93
  identity: TopoIdentity,
94
- contours: ReadonlyMap<string, AnyContour>,
94
+ entities: ReadonlyMap<string, AnyEntity>,
95
95
  trails: ReadonlyMap<string, AnyTrail>,
96
96
  signals: ReadonlyMap<string, AnySignal>,
97
97
  resources: ReadonlyMap<string, AnyResource>,
98
98
  observe: ObserveConfig | undefined,
99
99
  layers: readonly Layer[]
100
100
  ): Topo => ({
101
- contourCount: contours.size,
102
- contourIds(): string[] {
103
- return [...contours.keys()];
104
- },
105
- contours,
106
101
  count: trails.size,
102
+ entities,
103
+ entityCount: entities.size,
104
+ entityIds(): string[] {
105
+ return [...entities.keys()];
106
+ },
107
107
  get(id: string): AnyTrail | undefined {
108
108
  return trails.get(id);
109
109
  },
110
- getContour(contourName: string): AnyContour | undefined {
111
- return contours.get(contourName);
110
+ getEntity(entityName: string): AnyEntity | undefined {
111
+ return entities.get(entityName);
112
112
  },
113
113
  getResource(id: string): AnyResource | undefined {
114
114
  return resources.get(id);
@@ -116,8 +116,8 @@ const createTopo = (
116
116
  has(id: string): boolean {
117
117
  return trails.has(id);
118
118
  },
119
- hasContour(contourName: string): boolean {
120
- return contours.has(contourName);
119
+ hasEntity(entityName: string): boolean {
120
+ return entities.has(entityName);
121
121
  },
122
122
  hasResource(id: string): boolean {
123
123
  return resources.has(id);
@@ -129,8 +129,8 @@ const createTopo = (
129
129
  list(): AnyTrail[] {
130
130
  return [...trails.values()];
131
131
  },
132
- listContours(): AnyContour[] {
133
- return [...contours.values()];
132
+ listEntities(): AnyEntity[] {
133
+ return [...entities.values()];
134
134
  },
135
135
  listResources(): AnyResource[] {
136
136
  return [...resources.values()];
@@ -176,15 +176,15 @@ const registerUnique = <T>(
176
176
  collection.set(id, value);
177
177
  };
178
178
 
179
- const registerContour = (
180
- contour: AnyContour,
181
- contours: Map<string, AnyContour>
179
+ const registerEntity = (
180
+ entity: AnyEntity,
181
+ entities: Map<string, AnyEntity>
182
182
  ): void => {
183
183
  registerUnique(
184
- contours,
185
- contour.name,
186
- contour,
187
- `Duplicate contour name: "${contour.name}"`
184
+ entities,
185
+ entity.name,
186
+ entity,
187
+ `Duplicate entity name: "${entity.name}"`
188
188
  );
189
189
  };
190
190
 
@@ -395,14 +395,14 @@ const finalizeTrailSignals = (
395
395
  /** Register a single registrable value into the appropriate map. */
396
396
  const register = (
397
397
  value: Registrable,
398
- contours: Map<string, AnyContour>,
398
+ entities: Map<string, AnyEntity>,
399
399
  trails: Map<string, AnyTrail>,
400
400
  signals: Map<string, AnySignal>,
401
401
  resources: Map<string, AnyResource>
402
402
  ): void => {
403
403
  switch (value.kind) {
404
- case 'contour': {
405
- registerContour(value as AnyContour, contours);
404
+ case 'entity': {
405
+ registerEntity(value as AnyEntity, entities);
406
406
  break;
407
407
  }
408
408
  case 'resource': {
@@ -423,15 +423,15 @@ const register = (
423
423
  }
424
424
  };
425
425
 
426
- const registerTrailContours = (
426
+ const registerTrailEntities = (
427
427
  trail: AnyTrail,
428
- contours: Map<string, AnyContour>,
428
+ entities: Map<string, AnyEntity>,
429
429
  trails: Map<string, AnyTrail>,
430
430
  signals: Map<string, AnySignal>,
431
431
  resources: Map<string, AnyResource>
432
432
  ): void => {
433
- for (const contour of trail.contours ?? []) {
434
- register(contour, contours, trails, signals, resources);
433
+ for (const entity of trail.entities ?? []) {
434
+ register(entity, entities, trails, signals, resources);
435
435
  }
436
436
  };
437
437
 
@@ -451,13 +451,13 @@ const markUniqueObject = (
451
451
 
452
452
  const registerModuleValue = (
453
453
  value: unknown,
454
- contours: Map<string, AnyContour>,
454
+ entities: Map<string, AnyEntity>,
455
455
  trails: Map<string, AnyTrail>,
456
456
  signals: Map<string, AnySignal>,
457
457
  resources: Map<string, AnyResource>
458
458
  ): void => {
459
459
  if (isResource(value) || isRegistrable(value)) {
460
- register(value, contours, trails, signals, resources);
460
+ register(value, entities, trails, signals, resources);
461
461
  }
462
462
 
463
463
  if (isResource(value)) {
@@ -469,9 +469,9 @@ const registerModuleValue = (
469
469
  value !== null &&
470
470
  (value as { kind?: unknown }).kind === 'trail'
471
471
  ) {
472
- registerTrailContours(
472
+ registerTrailEntities(
473
473
  value as AnyTrail,
474
- contours,
474
+ entities,
475
475
  trails,
476
476
  signals,
477
477
  resources
@@ -481,7 +481,7 @@ const registerModuleValue = (
481
481
 
482
482
  const registerModuleValues = (
483
483
  mod: Record<string, unknown>,
484
- contours: Map<string, AnyContour>,
484
+ entities: Map<string, AnyEntity>,
485
485
  trails: Map<string, AnyTrail>,
486
486
  signals: Map<string, AnySignal>,
487
487
  resources: Map<string, AnyResource>
@@ -491,7 +491,7 @@ const registerModuleValues = (
491
491
  if (!markUniqueObject(value, seenValues)) {
492
492
  continue;
493
493
  }
494
- registerModuleValue(value, contours, trails, signals, resources);
494
+ registerModuleValue(value, entities, trails, signals, resources);
495
495
  }
496
496
  };
497
497
 
@@ -535,7 +535,7 @@ const hasRegistrableKind = (value: unknown): boolean => {
535
535
  }
536
536
  const { kind } = value as { kind?: unknown };
537
537
  return (
538
- kind === 'contour' ||
538
+ kind === 'entity' ||
539
539
  kind === 'trail' ||
540
540
  kind === 'signal' ||
541
541
  kind === 'resource'
@@ -583,7 +583,7 @@ const disambiguateBrandedObserve = (options: TopoOptions): TopoOptions => {
583
583
  * 2. The shape does not look like `TopoOptions` (mixed keys or no
584
584
  * keys) → module.
585
585
  * 3. The trailing arg is a registrable module export
586
- * (`kind: 'trail' | 'contour' | …`) under a known option key →
586
+ * (`kind: 'trail' | 'entity' | …`) under a known option key →
587
587
  * module. Preserves the "module exporting a single trail named
588
588
  * `observe`" case that the warden and existing apps rely on.
589
589
  * 4. The `observe` value is a bare `LogSink` or `TraceSink` (a sink
@@ -810,18 +810,18 @@ const topoImpl = (
810
810
  const observe = normalizeObserve(options?.observe);
811
811
  const layers = Object.freeze([...(options?.layers ?? [])]);
812
812
 
813
- const contours = new Map<string, AnyContour>();
813
+ const entities = new Map<string, AnyEntity>();
814
814
  const trails = new Map<string, AnyTrail>();
815
815
  const signals = new Map<string, AnySignal>();
816
816
  const resources = new Map<string, AnyResource>();
817
817
 
818
818
  for (const mod of modules) {
819
- registerModuleValues(mod, contours, trails, signals, resources);
819
+ registerModuleValues(mod, entities, trails, signals, resources);
820
820
  }
821
821
 
822
822
  return createTopo(
823
823
  identity,
824
- contours,
824
+ entities,
825
825
  finalizeTrailSignals(trails, resources),
826
826
  signals,
827
827
  resources,
package/src/tracing.ts CHANGED
@@ -28,6 +28,7 @@ export type SignalTraceRecordName =
28
28
  /** Activation boundary records emitted by runtime materializers. */
29
29
  export type ActivationTraceRecordName =
30
30
  | 'activation.cycle_detected'
31
+ | 'activation.queue'
31
32
  | 'activation.scheduled'
32
33
  | 'activation.webhook'
33
34
  | 'activation.webhook.invalid';