@ontrails/core 1.0.0-beta.18 → 1.0.0-beta.19

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/fire.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  * `ctx.fire()` resolves after dispatch is initiated and without a value.
24
24
  * Unknown signals, invalid payloads, guard suppression, and consumer errors
25
25
  * are logged/diagnosed but do NOT propagate back to the producer. Consumers
26
- * that need transactional coupling should use `crosses:`.
26
+ * that need transactional coupling should use `composes:`.
27
27
  */
28
28
 
29
29
  import type { z } from 'zod';
@@ -617,7 +617,7 @@ const shouldInvokeConsumer = async (
617
617
  *
618
618
  * @remarks
619
619
  * Signal delivery is fire-and-forget notification, not ordered orchestration;
620
- * if one consumer depends on another, the dependency belongs in `crosses:`
620
+ * if one consumer depends on another, the dependency belongs in `composes:`
621
621
  * instead of sibling signal sequencing.
622
622
  *
623
623
  * `Promise.allSettled` preserves failure isolation for the background
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export {
9
9
  AmbiguousError,
10
10
  AssertionError,
11
11
  NotFoundError,
12
+ VersionNotSupportedError,
12
13
  AlreadyExistsError,
13
14
  ConflictError,
14
15
  PermissionError,
@@ -74,8 +75,9 @@ export type {
74
75
  Implementation,
75
76
  TrailContext,
76
77
  TrailContextInit,
77
- CrossBatchOptions,
78
- CrossFn,
78
+ ComposeBatchOptions,
79
+ ComposeOptions,
80
+ ComposeFn,
79
81
  FireFn,
80
82
  LogLevel,
81
83
  LogFormatter,
@@ -157,23 +159,77 @@ export {
157
159
  projectActivationSourceDeclaration,
158
160
  } from './activation-source-projection.js';
159
161
  export type { ActivationSourceProjection } from './activation-source-projection.js';
160
- export { intentValues, trail } from './trail.js';
162
+ export {
163
+ deriveSupportedTrailVersions,
164
+ getTrailVersionEntryKind,
165
+ hasDeprecatedTrailVersionGuidance,
166
+ intentValues,
167
+ isArchivedTrailVersionEntry,
168
+ isDeprecatedTrailVersionEntry,
169
+ isLiveTrailVersionEntry,
170
+ trail,
171
+ } from './trail.js';
172
+ export {
173
+ TRAIL_VERSION_MARKER_LENGTH,
174
+ TRAIL_VERSION_MARKER_MIN_PREFIX_LENGTH,
175
+ assertTrailVersionMarker,
176
+ assertUniqueTrailVersionMarkers,
177
+ canonicalizeTrailVersionMarkerContent,
178
+ deriveCurrentTrailVersionMarker,
179
+ deriveCurrentTrailVersionMarkerContent,
180
+ deriveShortestUnambiguousTrailVersionMarkerPrefix,
181
+ deriveTrailVersionEntryMarker,
182
+ deriveTrailVersionEntryMarkerContent,
183
+ deriveTrailVersionMarker,
184
+ deriveTrailVersionMarkers,
185
+ normalizeTrailVersionMarkerPrefix,
186
+ resolveTrailVersionMarkerPrefix,
187
+ } from './version-marker.js';
188
+ export {
189
+ parseTrailIdVersionReference,
190
+ resolveTrailVersion,
191
+ } from './version-resolution.js';
161
192
  export type {
162
193
  AnyTrail,
163
194
  BlazeInput,
164
195
  Intent,
165
196
  Trail,
197
+ TrailVersionEntry,
198
+ TrailVersionEntryKind,
199
+ TrailVersionArchivedStatus,
200
+ TrailVersionDeprecatedStatus,
201
+ TrailVersionForkEntry,
202
+ TrailVersionRevisionEntry,
203
+ TrailVersions,
204
+ TrailVersionStatus,
205
+ TrailVersionTranspose,
206
+ TrailVersionTransposeInput,
207
+ TrailVersionTransposeOutput,
166
208
  TrailSpec,
167
209
  TrailExample,
168
210
  TrailExampleSignalAssertion,
169
211
  TrailVisibility,
212
+ VersionContract,
213
+ VersionEntry,
170
214
  } from './trail.js';
215
+ export type {
216
+ TrailVersionMarkerBinding,
217
+ TrailVersionMarkerRecord,
218
+ TrailVersionMarkerResolution,
219
+ } from './version-marker.js';
220
+ export type {
221
+ ParsedTrailVersionReference,
222
+ ResolvedTrailVersion,
223
+ TrailVersionReference,
224
+ } from './version-resolution.js';
171
225
  export {
172
226
  filterSurfaceTrails,
173
227
  matchesTrailPattern,
174
228
  shouldIncludeTrailForSurface,
175
229
  } from './surface-filter.js';
176
230
  export type { SurfaceFilterOptions } from './surface-filter.js';
231
+ export { deriveSurfaceTrailVersionProjections } from './surface-versioning.js';
232
+ export type { SurfaceTrailVersionProjection } from './surface-versioning.js';
177
233
  export {
178
234
  shouldValidateSurfaceTopo,
179
235
  validateSurfaceTopo,
@@ -202,7 +258,7 @@ export type {
202
258
 
203
259
  // Type utilities
204
260
  export type {
205
- CrossInput,
261
+ ComposeInput,
206
262
  TrailInput,
207
263
  TrailOutput,
208
264
  TrailResult,
@@ -388,13 +444,13 @@ export type {
388
444
  export { deriveCliPath, deriveFields } from './derive.js';
389
445
  export type { Field, FieldOverride } from './derive.js';
390
446
 
391
- // Cross schema
392
- export { buildCrossValidationSchema } from './cross-schema.js';
447
+ // Compose schema
448
+ export { buildComposeValidationSchema } from './compose-schema.js';
393
449
  export {
394
- claimNextCrossBatchIndex,
395
- createCrossBatchValidationResults,
396
- normalizeCrossBatchConcurrency,
397
- } from './cross-batch.js';
450
+ claimNextComposeBatchIndex,
451
+ createComposeBatchValidationResults,
452
+ normalizeComposeBatchConcurrency,
453
+ } from './compose-batch.js';
398
454
 
399
455
  // Execute
400
456
  export { DETOUR_MAX_ATTEMPTS_CAP } from './detours.js';
@@ -2,7 +2,7 @@
2
2
  * Internal helper for "forking" a trail context.
3
3
  *
4
4
  * Several execution sites need to derive a child context from a parent
5
- * context while **resetting** a well-known set of bound closures (`cross`,
5
+ * context while **resetting** a well-known set of bound closures (`compose`,
6
6
  * `fire`, `resource`). Those closures capture the parent scope, so reusing
7
7
  * them on the child would re-enter execution with the wrong attribution,
8
8
  * the wrong resource scope, or the wrong fan-out identity. The reset list
@@ -24,7 +24,7 @@
24
24
  import type { TrailContext, TrailContextInit } from '../types.js';
25
25
 
26
26
  /** Keys cleared by default when forking a context. */
27
- export type ForkCtxResetKey = 'cross' | 'fire' | 'resource';
27
+ export type ForkCtxResetKey = 'compose' | 'fire' | 'resource';
28
28
 
29
29
  /** Override fields callers are allowed to apply when forking a context. */
30
30
  export type ForkCtxOverrides = Readonly<
@@ -32,7 +32,7 @@ export type ForkCtxOverrides = Readonly<
32
32
  >;
33
33
 
34
34
  const DEFAULT_RESET_KEYS: readonly ForkCtxResetKey[] = [
35
- 'cross',
35
+ 'compose',
36
36
  'fire',
37
37
  'resource',
38
38
  ];
@@ -53,7 +53,7 @@ export const forkCtx = <
53
53
  TCtx extends Partial<
54
54
  Pick<
55
55
  TrailContextInit,
56
- 'cross' | 'env' | 'extensions' | 'fire' | 'logger' | 'resource'
56
+ 'compose' | 'env' | 'extensions' | 'fire' | 'logger' | 'resource'
57
57
  >
58
58
  >,
59
59
  >(
@@ -38,6 +38,7 @@ export const LAYER_FIELD_RESERVED_NAMES: ReadonlySet<string> = new Set([
38
38
  'permit',
39
39
  'quiet',
40
40
  'token',
41
+ 'trailVersion',
41
42
  'trace',
42
43
  'watch',
43
44
  ]);
package/src/layer.ts CHANGED
@@ -8,7 +8,7 @@ import type { Implementation } from './types.js';
8
8
  // ---------------------------------------------------------------------------
9
9
 
10
10
  /**
11
- * A composable, named layer that wraps trail implementations.
11
+ * A composable, named layer that wraps blazes.
12
12
  *
13
13
  * Layers attach at trail, surface, or topo scope and may declare an object
14
14
  * `input` schema describing the configuration they need from the surrounding
package/src/observe.ts CHANGED
@@ -128,7 +128,7 @@ export const isObserveInput = (
128
128
  }
129
129
  if (isTraceSink(value) && !isLogSink(value)) {
130
130
  // A bare TraceSink (no `name`) is unambiguous — `normalizeObserve`
131
- // routes it via the `isTraceSink` fallthrough. A LogSink shape is
131
+ // accepts it via the `isTraceSink` fallthrough. A LogSink shape is
132
132
  // ambiguous (matches both guards) and would be rejected by
133
133
  // `normalizeObserve`, so the guard rejects it here too.
134
134
  return true;
@@ -148,7 +148,7 @@ export const hasObserveCapabilities = (value: unknown): boolean =>
148
148
  /**
149
149
  * Returns true when `value` is shaped like the explicit `{ log?, trace? }`
150
150
  * `ObserveConfig` payload. Exposed for the topo classifier so a config-style
151
- * trailing argument is unambiguously routed to options.
151
+ * trailing argument is unambiguously classified as options.
152
152
  */
153
153
  export const isObserveConfig = (value: unknown): value is ObserveConfig =>
154
154
  isObserveConfigShape(value);
@@ -212,15 +212,15 @@ const stringifyDefaultConsoleRecord = (record: LogRecord): string => {
212
212
  /**
213
213
  * In-core mirror of `@ontrails/observe`'s `createConsoleSink` shape, kept
214
214
  * minimal and private to avoid a reverse dependency from `@ontrails/core`
215
- * onto `@ontrails/observe`. It mirrors the console level routing in
215
+ * onto `@ontrails/observe`. It mirrors the console level mapping in
216
216
  * `packages/observe/src/sinks.ts:50` and emits each record as a single-line
217
- * JSON object routed to the matching `console.{debug|info|warn|error}` method.
217
+ * JSON object written to the matching `console.{debug|info|warn|error}` method.
218
218
  *
219
219
  * @remarks
220
220
  * Used as the default `observe.log` target when `topo()` is called without an
221
221
  * explicit `observe` option, so every app gets a non-null `ctx.logger` with
222
222
  * structured stdout output and zero configuration. Apps that want richer
223
- * formatting, file destinations, or custom routing should pass an explicit
223
+ * formatting, file destinations, or custom sink behavior should pass an explicit
224
224
  * `observe` option, which fully replaces this default.
225
225
  */
226
226
  const createDefaultConsoleSink = (): LogSink => ({
package/src/resource.ts CHANGED
@@ -54,10 +54,19 @@ export interface ResourceSpec<T, C = unknown> {
54
54
  readonly meta?: Readonly<Record<string, unknown>> | undefined;
55
55
  /** Signals projected or owned by this resource. */
56
56
  readonly signals?: readonly AnySignal[] | undefined;
57
+ /** Reserved for future resource-specific design; trail versioning is trail-only. */
58
+ readonly version?: never;
57
59
  }
58
60
 
61
+ type ResourceSpecWithConfig<T, S extends z.ZodTypeAny> = Omit<
62
+ ResourceSpec<T, z.infer<S>>,
63
+ 'config'
64
+ > & {
65
+ readonly config: S;
66
+ };
67
+
59
68
  /** A typed resource definition. */
60
- export interface Resource<T> extends ResourceSpec<T> {
69
+ export interface Resource<T, C = unknown> extends ResourceSpec<T, C> {
61
70
  readonly kind: 'resource';
62
71
  readonly id: string;
63
72
  /** Read the resolved resource instance from a trail context. */
@@ -72,7 +81,7 @@ export interface Resource<T> extends ResourceSpec<T> {
72
81
  * existential here.
73
82
  */
74
83
  // oxlint-disable-next-line no-explicit-any -- existential type for heterogeneous resource collections
75
- export type AnyResource = Resource<any>;
84
+ export type AnyResource = Resource<any, any>;
76
85
 
77
86
  /** Explicit runtime overrides keyed by resource ID. */
78
87
  export type ResourceOverrideMap = Readonly<Record<string, unknown>>;
@@ -114,37 +123,47 @@ export const createResourceLookup = (
114
123
  * The resource object is inert until a later execution branch resolves concrete
115
124
  * instances into TrailContext extensions.
116
125
  */
117
- export const resource = <T>(id: string, spec: ResourceSpec<T>): Resource<T> =>
118
- (() => {
119
- if (id.includes(':')) {
120
- throw new InternalError(
121
- `Resource "${id}" is invalid because resource ids may not contain ":"`
122
- );
123
- }
124
- if (spec.mock !== undefined && spec.unmockable !== undefined) {
125
- throw new InternalError(
126
- `Resource "${id}" cannot define both mock and unmockable`
127
- );
128
- }
129
- if (
130
- spec.unmockable !== undefined &&
131
- spec.unmockable.reason.trim().length === 0
132
- ) {
133
- throw new InternalError(
134
- `Resource "${id}" is invalid because unmockable.reason must not be empty`
135
- );
136
- }
126
+ export function resource<T, S extends z.ZodTypeAny>(
127
+ id: string,
128
+ spec: ResourceSpecWithConfig<T, S>
129
+ ): Resource<T, z.infer<S>>;
130
+ export function resource<T, C = unknown>(
131
+ id: string,
132
+ spec: ResourceSpec<T, C>
133
+ ): Resource<T, C>;
134
+ export function resource<T, C = unknown>(
135
+ id: string,
136
+ spec: ResourceSpec<T, C>
137
+ ): Resource<T, C> {
138
+ if (id.includes(':')) {
139
+ throw new InternalError(
140
+ `Resource "${id}" is invalid because resource ids may not contain ":"`
141
+ );
142
+ }
143
+ if (spec.mock !== undefined && spec.unmockable !== undefined) {
144
+ throw new InternalError(
145
+ `Resource "${id}" cannot define both mock and unmockable`
146
+ );
147
+ }
148
+ if (
149
+ spec.unmockable !== undefined &&
150
+ spec.unmockable.reason.trim().length === 0
151
+ ) {
152
+ throw new InternalError(
153
+ `Resource "${id}" is invalid because unmockable.reason must not be empty`
154
+ );
155
+ }
137
156
 
138
- return Object.freeze({
139
- ...spec,
140
- from(ctx: TrailContext): T {
141
- const lookup = ctx.resource ?? createResourceLookup(() => ctx);
142
- return lookup(this);
143
- },
144
- id,
145
- kind: 'resource' as const,
146
- });
147
- })();
157
+ return Object.freeze({
158
+ ...spec,
159
+ from(ctx: TrailContext): T {
160
+ const lookup = ctx.resource ?? createResourceLookup(() => ctx);
161
+ return lookup(this);
162
+ },
163
+ id,
164
+ kind: 'resource' as const,
165
+ });
166
+ }
148
167
 
149
168
  /** Narrow unknown values to resource definitions during topo discovery. */
150
169
  export const isResource = (value: unknown): value is AnyResource => {
package/src/run.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  import type { Topo } from './topo.js';
9
9
  import { executeTrail } from './execute.js';
10
10
  import type { ExecuteTrailOptions } from './execute.js';
11
- import { NotFoundError } from './errors.js';
11
+ import { parseTrailIdVersionReference } from './version-resolution.js';
12
+ import { NotFoundError, ValidationError } from './errors.js';
12
13
  import { Result } from './result.js';
13
14
 
14
15
  // ---------------------------------------------------------------------------
@@ -43,7 +44,21 @@ export const run = (
43
44
  input: unknown,
44
45
  options?: RunOptions
45
46
  ): Promise<Result<unknown, Error>> => {
46
- const trail = topo.get(id);
47
+ const parsed = parseTrailIdVersionReference(id);
48
+ if (parsed.isErr()) {
49
+ return Promise.resolve(Result.err(parsed.error));
50
+ }
51
+ if (parsed.value.version !== undefined && options?.version !== undefined) {
52
+ return Promise.resolve(
53
+ Result.err(
54
+ new ValidationError(
55
+ `Trail "${parsed.value.id}" cannot combine an @version suffix with options.version`
56
+ )
57
+ )
58
+ );
59
+ }
60
+
61
+ const trail = topo.get(parsed.value.id);
47
62
  if (trail === undefined) {
48
63
  return Promise.resolve(
49
64
  Result.err(
@@ -51,5 +66,11 @@ export const run = (
51
66
  )
52
67
  );
53
68
  }
54
- return executeTrail(trail, input, { ...options, topo });
69
+ return executeTrail(trail, input, {
70
+ ...options,
71
+ ...(parsed.value.version === undefined
72
+ ? {}
73
+ : { version: parsed.value.version }),
74
+ topo,
75
+ });
55
76
  };
package/src/schedule.ts CHANGED
@@ -11,6 +11,8 @@ export interface ScheduleSpec<TInput = unknown> {
11
11
  readonly input?: TInput | undefined;
12
12
  readonly meta?: ActivationSourceMeta | undefined;
13
13
  readonly timezone?: string | undefined;
14
+ /** Reserved for future schedule-specific design; trail versioning is trail-only. */
15
+ readonly version?: never;
14
16
  }
15
17
 
16
18
  export interface ScheduleSource<TInput = unknown> extends ActivationSource {
package/src/signal.ts CHANGED
@@ -39,6 +39,8 @@ export interface SignalSpec<T> {
39
39
  readonly meta?: Readonly<Record<string, unknown>> | undefined;
40
40
  /** Trail IDs that produce this signal (e.g. the trails it originates from). */
41
41
  readonly from?: readonly string[] | undefined;
42
+ /** Reserved for future signal-specific design; trail versioning is trail-only. */
43
+ readonly version?: never;
42
44
  }
43
45
 
44
46
  // ---------------------------------------------------------------------------
@@ -1,7 +1,7 @@
1
1
  import type { TrailExample, TrailExampleSignalAssertion } from './trail.js';
2
2
 
3
3
  export interface StructuredTrailExampleProvenance {
4
- readonly source: 'trail.examples';
4
+ readonly source: 'trail.examples' | 'trail.versions.examples';
5
5
  }
6
6
 
7
7
  export interface StructuredSignalExampleProvenance {
@@ -152,7 +152,8 @@ const projectSignalAssertions = (
152
152
  };
153
153
 
154
154
  const projectExample = (
155
- example: TrailExample<unknown, unknown>
155
+ example: TrailExample<unknown, unknown>,
156
+ provenance: StructuredTrailExampleProvenance
156
157
  ): StructuredTrailExample | undefined => {
157
158
  const input = toJsonSerializable(example.input);
158
159
  if (input === undefined) {
@@ -163,7 +164,7 @@ const projectExample = (
163
164
  input,
164
165
  kind: example.error === undefined ? 'success' : 'error',
165
166
  name: example.name,
166
- provenance: { source: 'trail.examples' },
167
+ provenance,
167
168
  };
168
169
 
169
170
  if (example.description !== undefined) {
@@ -213,14 +214,16 @@ const projectSignalExample = (
213
214
  };
214
215
 
215
216
  export const deriveStructuredTrailExamples = (
216
- examples: readonly TrailExample<unknown, unknown>[] | undefined
217
+ examples: readonly TrailExample<unknown, unknown>[] | undefined,
218
+ options?: { readonly provenance?: StructuredTrailExampleProvenance }
217
219
  ): readonly StructuredTrailExample[] | undefined => {
218
220
  if (examples === undefined || examples.length === 0) {
219
221
  return undefined;
220
222
  }
221
223
 
224
+ const provenance = options?.provenance ?? { source: 'trail.examples' };
222
225
  const projected = examples
223
- .map(projectExample)
226
+ .map((example) => projectExample(example, provenance))
224
227
  .filter(
225
228
  (example): example is StructuredTrailExample => example !== undefined
226
229
  );
@@ -0,0 +1,42 @@
1
+ import type { AnyTrail, TrailVersionStatus } from './trail.js';
2
+ import {
3
+ deriveSupportedTrailVersions,
4
+ isDeprecatedTrailVersionEntry,
5
+ } from './trail.js';
6
+ import { deriveTrailVersionMarkers } from './version-marker.js';
7
+
8
+ export interface SurfaceTrailVersionProjection {
9
+ readonly current: boolean;
10
+ readonly deprecated: boolean;
11
+ readonly marker?: string | undefined;
12
+ readonly status?: TrailVersionStatus | undefined;
13
+ readonly version: number;
14
+ }
15
+
16
+ export const deriveSurfaceTrailVersionProjections = (
17
+ trail: AnyTrail
18
+ ): readonly SurfaceTrailVersionProjection[] | undefined => {
19
+ if (trail.version === undefined) {
20
+ return undefined;
21
+ }
22
+
23
+ const markers = new Map(
24
+ deriveTrailVersionMarkers(trail).map((record) => [
25
+ record.version,
26
+ record.marker,
27
+ ])
28
+ );
29
+
30
+ return deriveSupportedTrailVersions(trail).map((version) => {
31
+ const entry = trail.versions?.[version];
32
+ const marker = markers.get(version);
33
+ return {
34
+ current: version === trail.version,
35
+ deprecated:
36
+ entry === undefined ? false : isDeprecatedTrailVersionEntry(entry),
37
+ ...(marker === undefined ? {} : { marker }),
38
+ ...(entry?.status === undefined ? {} : { status: entry.status }),
39
+ version,
40
+ };
41
+ });
42
+ };
package/src/topo.ts CHANGED
@@ -578,7 +578,7 @@ const disambiguateBrandedObserve = (options: TopoOptions): TopoOptions => {
578
578
  * 1. Branded via `topo.options()` → always options. Unknown option
579
579
  * keys throw, and downstream `normalizeObserve` rejects malformed
580
580
  * values. A bare `LogSink` (`{ name, write }`) in the `observe`
581
- * slot is auto-routed to `{ log: sink }` so the brand is the
581
+ * slot is auto-wrapped as `{ log: sink }` so the brand is the
582
582
  * complete escape hatch the docs promise.
583
583
  * 2. The shape does not look like `TopoOptions` (mixed keys or no
584
584
  * keys) → module.
@@ -621,11 +621,11 @@ const classifyTrailingArgument = (
621
621
  };
622
622
  }
623
623
  // Branding via `topo.options()` is the documented escape hatch for
624
- // disambiguating bare-sink shorthand. Route a bare `LogSink` into the
624
+ // disambiguating bare-sink shorthand. Wrap a bare `LogSink` in the
625
625
  // explicit `{ log: sink }` slot before handing off to `normalizeObserve`,
626
626
  // which would otherwise reject it as ambiguous (a LogSink shape matches
627
627
  // both `isLogSink` and `isTraceSink`). A bare TraceSink (no `name`) does
628
- // not need rewriting because `normalizeObserve` already routes it via
628
+ // not need rewriting because `normalizeObserve` already accepts it via
629
629
  // the `isTraceSink` fallthrough.
630
630
  return {
631
631
  kind: 'options',