@ontrails/core 1.0.0-beta.17 → 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/CHANGELOG.md +28 -0
- package/README.md +14 -27
- package/package.json +1 -1
- package/src/{cross-batch.ts → compose-batch.ts} +10 -10
- package/src/compose-schema.ts +36 -0
- package/src/contour.ts +2 -0
- package/src/draft.ts +2 -2
- package/src/errors.ts +65 -1
- package/src/execute.ts +304 -103
- package/src/fire.ts +2 -2
- package/src/index.ts +66 -10
- package/src/internal/fork-ctx.ts +4 -4
- package/src/layer-projection.ts +1 -0
- package/src/layer.ts +1 -1
- package/src/observe.ts +5 -5
- package/src/resource.ts +51 -32
- package/src/run.ts +24 -3
- package/src/schedule.ts +2 -0
- package/src/signal.ts +2 -0
- package/src/structured-examples.ts +8 -5
- package/src/surface-versioning.ts +42 -0
- package/src/topo.ts +3 -3
- package/src/trail.ts +673 -38
- package/src/type-utils.ts +21 -13
- package/src/types.ts +43 -21
- package/src/validate-established-topo.ts +3 -3
- package/src/validate-topo.ts +120 -30
- package/src/validation.ts +69 -10
- package/src/version-marker.ts +716 -0
- package/src/version-resolution.ts +308 -0
- package/src/version-runtime.ts +120 -0
- package/src/webhook.ts +2 -0
- package/src/cross-schema.ts +0 -36
package/src/trail.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { z } from 'zod';
|
|
2
2
|
|
|
3
|
+
import { ValidationError } from './errors.js';
|
|
3
4
|
import type {
|
|
4
5
|
ActivationEntry,
|
|
5
6
|
ActivationEntrySpec,
|
|
@@ -22,11 +23,13 @@ import {
|
|
|
22
23
|
} from './signal-ref.js';
|
|
23
24
|
import type { TrailsError } from './errors.js';
|
|
24
25
|
import type {
|
|
26
|
+
ComposeTrailContext,
|
|
25
27
|
Detour,
|
|
26
28
|
Implementation,
|
|
27
29
|
PermitRequirement,
|
|
28
30
|
TrailContext,
|
|
29
31
|
} from './types.js';
|
|
32
|
+
import { zodToJsonSchema } from './validation.js';
|
|
30
33
|
|
|
31
34
|
// ---------------------------------------------------------------------------
|
|
32
35
|
// Trail example
|
|
@@ -68,31 +71,213 @@ export interface TrailExample<I, O> {
|
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
// ---------------------------------------------------------------------------
|
|
71
|
-
// Blaze input — merges
|
|
74
|
+
// Blaze input — merges composeInput when declared
|
|
72
75
|
// ---------------------------------------------------------------------------
|
|
73
76
|
|
|
74
77
|
/**
|
|
75
78
|
* The input type received by a trail's blaze function.
|
|
76
79
|
*
|
|
77
|
-
* When a trail declares `
|
|
80
|
+
* When a trail declares `composeInput`, the runtime merges those fields into
|
|
78
81
|
* the input object before calling blaze. This type makes the compiler aware
|
|
79
|
-
* of the merged shape so developers can access
|
|
82
|
+
* of the merged shape so developers can access composeInput fields without a
|
|
80
83
|
* cast. Falls back to plain `I` when `CI` is `never` (the default).
|
|
81
84
|
*/
|
|
82
85
|
export type BlazeInput<I, CI> = [CI] extends [never] ? I : I & CI;
|
|
83
86
|
|
|
87
|
+
type TrailRef = string | { readonly id: string };
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Trail versioning
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/** Contract pair represented by a version entry. */
|
|
94
|
+
export interface VersionContract<I = unknown, O = unknown> {
|
|
95
|
+
readonly input: I;
|
|
96
|
+
readonly output: O;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface TrailVersionDeprecatedStatus {
|
|
100
|
+
readonly migration?: readonly string[] | undefined;
|
|
101
|
+
readonly note?: string | undefined;
|
|
102
|
+
readonly state: 'deprecated';
|
|
103
|
+
readonly successor?: number | undefined;
|
|
104
|
+
readonly [key: string]: unknown;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface TrailVersionArchivedStatus {
|
|
108
|
+
readonly reason?: string | undefined;
|
|
109
|
+
readonly state: 'archived';
|
|
110
|
+
readonly [key: string]: unknown;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Shared lifecycle metadata for historical version entries. */
|
|
114
|
+
export type TrailVersionStatus =
|
|
115
|
+
| TrailVersionArchivedStatus
|
|
116
|
+
| TrailVersionDeprecatedStatus;
|
|
117
|
+
|
|
118
|
+
/** Shared base for version entries. Historical entries never inherit schemas. */
|
|
119
|
+
export interface VersionEntry<
|
|
120
|
+
TContract extends VersionContract = VersionContract,
|
|
121
|
+
> {
|
|
122
|
+
readonly examples?:
|
|
123
|
+
| readonly TrailExample<TContract['input'], TContract['output']>[]
|
|
124
|
+
| undefined;
|
|
125
|
+
readonly input: z.ZodType<TContract['input']>;
|
|
126
|
+
readonly marker?: never;
|
|
127
|
+
readonly output: z.ZodType<TContract['output']>;
|
|
128
|
+
readonly status?: TrailVersionStatus | undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type TrailVersionTransposeInput<VersionInput, CurrentInput> = (value: {
|
|
132
|
+
readonly input: VersionInput;
|
|
133
|
+
}) => CurrentInput | Promise<CurrentInput>;
|
|
134
|
+
|
|
135
|
+
export type TrailVersionTransposeOutput<CurrentOutput, VersionOutput> =
|
|
136
|
+
(value: {
|
|
137
|
+
readonly output: CurrentOutput;
|
|
138
|
+
}) => VersionOutput | Promise<VersionOutput>;
|
|
139
|
+
|
|
140
|
+
export interface TrailVersionTranspose<
|
|
141
|
+
VersionInput,
|
|
142
|
+
VersionOutput,
|
|
143
|
+
CurrentInput,
|
|
144
|
+
CurrentOutput,
|
|
145
|
+
> {
|
|
146
|
+
readonly input: TrailVersionTransposeInput<VersionInput, CurrentInput>;
|
|
147
|
+
readonly output: TrailVersionTransposeOutput<CurrentOutput, VersionOutput>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface TrailVersionRevisionEntry<
|
|
151
|
+
VersionInput = unknown,
|
|
152
|
+
VersionOutput = unknown,
|
|
153
|
+
CurrentInput = unknown,
|
|
154
|
+
CurrentOutput = unknown,
|
|
155
|
+
> extends VersionEntry<VersionContract<VersionInput, VersionOutput>> {
|
|
156
|
+
readonly blaze?: never;
|
|
157
|
+
readonly composeInput?: never;
|
|
158
|
+
readonly composes?: never;
|
|
159
|
+
readonly detours?: never;
|
|
160
|
+
readonly kind?: never;
|
|
161
|
+
readonly resources?: never;
|
|
162
|
+
readonly transpose?:
|
|
163
|
+
| TrailVersionTranspose<
|
|
164
|
+
VersionInput,
|
|
165
|
+
VersionOutput,
|
|
166
|
+
CurrentInput,
|
|
167
|
+
CurrentOutput
|
|
168
|
+
>
|
|
169
|
+
| undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface TrailVersionForkEntry<
|
|
173
|
+
VersionInput = unknown,
|
|
174
|
+
VersionOutput = unknown,
|
|
175
|
+
ComposeInput = never,
|
|
176
|
+
> extends VersionEntry<VersionContract<VersionInput, VersionOutput>> {
|
|
177
|
+
readonly blaze: Implementation<
|
|
178
|
+
BlazeInput<VersionInput, ComposeInput>,
|
|
179
|
+
VersionOutput
|
|
180
|
+
>;
|
|
181
|
+
readonly composes?: readonly (string | AnyTrail)[] | undefined;
|
|
182
|
+
readonly composeInput?: z.ZodType<ComposeInput> | undefined;
|
|
183
|
+
readonly detours?:
|
|
184
|
+
| readonly Detour<VersionInput, VersionOutput, TrailsError>[]
|
|
185
|
+
| undefined;
|
|
186
|
+
readonly kind?: never;
|
|
187
|
+
readonly resources?: readonly AnyResource[] | undefined;
|
|
188
|
+
readonly transpose?: never;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export type TrailVersionEntry<
|
|
192
|
+
VersionInput = unknown,
|
|
193
|
+
VersionOutput = unknown,
|
|
194
|
+
CurrentInput = unknown,
|
|
195
|
+
CurrentOutput = unknown,
|
|
196
|
+
ComposeInput = never,
|
|
197
|
+
> =
|
|
198
|
+
| TrailVersionRevisionEntry<
|
|
199
|
+
VersionInput,
|
|
200
|
+
VersionOutput,
|
|
201
|
+
CurrentInput,
|
|
202
|
+
CurrentOutput
|
|
203
|
+
>
|
|
204
|
+
| TrailVersionForkEntry<VersionInput, VersionOutput, ComposeInput>;
|
|
205
|
+
|
|
206
|
+
export type TrailVersionEntryKind = 'revision' | 'fork';
|
|
207
|
+
|
|
208
|
+
export type TrailVersions<
|
|
209
|
+
CurrentInput = unknown,
|
|
210
|
+
CurrentOutput = unknown,
|
|
211
|
+
> = Readonly<
|
|
212
|
+
Record<
|
|
213
|
+
number,
|
|
214
|
+
TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput>
|
|
215
|
+
>
|
|
216
|
+
>;
|
|
217
|
+
|
|
218
|
+
export const getTrailVersionEntryKind = (
|
|
219
|
+
entry: TrailVersionEntry
|
|
220
|
+
): TrailVersionEntryKind => {
|
|
221
|
+
const raw = entry as unknown as Record<string, unknown>;
|
|
222
|
+
return typeof raw['blaze'] === 'function' ? 'fork' : 'revision';
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export const isArchivedTrailVersionEntry = (
|
|
226
|
+
entry: Pick<TrailVersionEntry, 'status'>
|
|
227
|
+
): boolean => entry.status?.state === 'archived';
|
|
228
|
+
|
|
229
|
+
export const isDeprecatedTrailVersionEntry = (
|
|
230
|
+
entry: Pick<TrailVersionEntry, 'status'>
|
|
231
|
+
): boolean => entry.status?.state === 'deprecated';
|
|
232
|
+
|
|
233
|
+
export const isLiveTrailVersionEntry = (
|
|
234
|
+
entry: Pick<TrailVersionEntry, 'status'>
|
|
235
|
+
): boolean => entry.status === undefined || entry.status.state === 'deprecated';
|
|
236
|
+
|
|
237
|
+
export const hasDeprecatedTrailVersionGuidance = (
|
|
238
|
+
status: Pick<TrailVersionDeprecatedStatus, 'migration' | 'note' | 'successor'>
|
|
239
|
+
): boolean =>
|
|
240
|
+
status.successor !== undefined ||
|
|
241
|
+
(Array.isArray(status.migration) && status.migration.length > 0) ||
|
|
242
|
+
(typeof status.note === 'string' && status.note.trim().length > 0);
|
|
243
|
+
|
|
244
|
+
export const deriveSupportedTrailVersions = (
|
|
245
|
+
trail: Pick<AnyTrail, 'version' | 'versions'>
|
|
246
|
+
): readonly number[] => {
|
|
247
|
+
if (trail.version === undefined) {
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const supported = new Set<number>([trail.version]);
|
|
252
|
+
for (const [rawVersion, entry] of Object.entries(trail.versions ?? {})) {
|
|
253
|
+
if (isLiveTrailVersionEntry(entry)) {
|
|
254
|
+
supported.add(Number(rawVersion));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return Object.freeze([...supported].toSorted((a, b) => a - b));
|
|
259
|
+
};
|
|
260
|
+
|
|
84
261
|
// ---------------------------------------------------------------------------
|
|
85
262
|
// Trail spec
|
|
86
263
|
// ---------------------------------------------------------------------------
|
|
87
264
|
|
|
88
265
|
/** Everything needed to define a trail (minus the id) */
|
|
89
|
-
|
|
266
|
+
type ComposeContextFor<C extends readonly TrailRef[] | undefined> =
|
|
267
|
+
C extends undefined ? TrailContext : ComposeTrailContext;
|
|
268
|
+
|
|
269
|
+
export interface TrailSpec<
|
|
270
|
+
I,
|
|
271
|
+
O,
|
|
272
|
+
CI = never,
|
|
273
|
+
C extends readonly TrailRef[] | undefined = undefined,
|
|
274
|
+
> {
|
|
90
275
|
/** Zod schema for validating input */
|
|
91
276
|
readonly input: z.ZodType<I>;
|
|
92
277
|
/** Zod schema for validating output (optional — some trails are fire-and-forget) */
|
|
93
278
|
readonly output?: z.ZodType<O> | undefined;
|
|
94
279
|
/** The pure function that does the work (sync or async authoring) */
|
|
95
|
-
readonly blaze: Implementation<BlazeInput<I, CI>, O
|
|
280
|
+
readonly blaze: Implementation<BlazeInput<I, CI>, O, ComposeContextFor<C>>;
|
|
96
281
|
/** Human-readable description */
|
|
97
282
|
readonly description?: string | undefined;
|
|
98
283
|
/** Declared operational shape for governance, derivation, and agent guidance. */
|
|
@@ -134,17 +319,17 @@ export interface TrailSpec<I, O, CI = never> {
|
|
|
134
319
|
readonly fields?: Readonly<Record<string, FieldOverride>> | undefined;
|
|
135
320
|
/** Contours this trail operates on. */
|
|
136
321
|
readonly contours?: readonly AnyContour[] | undefined;
|
|
137
|
-
/** IDs or trail objects of downstream trails this trail may invoke via ctx.
|
|
138
|
-
readonly
|
|
322
|
+
/** IDs or trail objects of downstream trails this trail may invoke via ctx.compose() */
|
|
323
|
+
readonly composes?: C;
|
|
139
324
|
/**
|
|
140
|
-
* Composition-only input schema — merged with `input` for `ctx.
|
|
325
|
+
* Composition-only input schema — merged with `input` for `ctx.compose()` calls,
|
|
141
326
|
* invisible to public surfaces (CLI, MCP, HTTP).
|
|
142
327
|
*
|
|
143
328
|
* Fields here are available in the blaze but are not derived into CLI flags,
|
|
144
329
|
* MCP tool parameters, or HTTP request bodies. Use for data that only makes
|
|
145
|
-
* sense when one trail
|
|
330
|
+
* sense when one trail composes another (e.g. `forkedFrom`).
|
|
146
331
|
*/
|
|
147
|
-
readonly
|
|
332
|
+
readonly composeInput?: z.ZodType<CI> | undefined;
|
|
148
333
|
/** Resources this trail may access via resource.from(ctx) */
|
|
149
334
|
readonly resources?: readonly AnyResource[] | undefined;
|
|
150
335
|
/**
|
|
@@ -154,7 +339,7 @@ export interface TrailSpec<I, O, CI = never> {
|
|
|
154
339
|
* normalized to the signal's id at trail definition time, so
|
|
155
340
|
* `trail.fires` is always `readonly string[]`.
|
|
156
341
|
*
|
|
157
|
-
* Note: `
|
|
342
|
+
* Note: `composes` also accepts trail objects (normalized to IDs),
|
|
158
343
|
* following the same pattern as signal references here.
|
|
159
344
|
*/
|
|
160
345
|
readonly fires?: readonly (string | AnySignal)[] | undefined;
|
|
@@ -171,6 +356,12 @@ export interface TrailSpec<I, O, CI = never> {
|
|
|
171
356
|
readonly permit?: PermitRequirement | undefined;
|
|
172
357
|
/** Primary input fields and their order. CLI projects as positional args. */
|
|
173
358
|
readonly args?: readonly string[] | false | undefined;
|
|
359
|
+
/** Current trail version number. Omit for current-only unversioned trails. */
|
|
360
|
+
readonly version?: number | undefined;
|
|
361
|
+
/** Explicit historical trail versions. Current stays top-level. */
|
|
362
|
+
readonly versions?: TrailVersions<I, O> | undefined;
|
|
363
|
+
/** Version markers are projected into the resolved graph, not authored. */
|
|
364
|
+
readonly marker?: never;
|
|
174
365
|
}
|
|
175
366
|
|
|
176
367
|
// ---------------------------------------------------------------------------
|
|
@@ -191,12 +382,12 @@ export type TrailVisibility = 'public' | 'internal';
|
|
|
191
382
|
|
|
192
383
|
/** A fully-defined trail — the unit of work in the Trails system */
|
|
193
384
|
export interface Trail<I, O, CI = never> extends Omit<
|
|
194
|
-
TrailSpec<I, O, CI>,
|
|
385
|
+
TrailSpec<I, O, CI, readonly TrailRef[] | undefined>,
|
|
195
386
|
| 'args'
|
|
196
387
|
| 'blaze'
|
|
197
388
|
| 'contours'
|
|
198
|
-
| '
|
|
199
|
-
| '
|
|
389
|
+
| 'composes'
|
|
390
|
+
| 'composeInput'
|
|
200
391
|
| 'detours'
|
|
201
392
|
| 'fires'
|
|
202
393
|
| 'intent'
|
|
@@ -209,10 +400,10 @@ export interface Trail<I, O, CI = never> extends Omit<
|
|
|
209
400
|
readonly blaze: Implementation<BlazeInput<I, CI>, O>;
|
|
210
401
|
/** Contours this trail operates on (always present, default []). */
|
|
211
402
|
readonly contours: readonly AnyContour[];
|
|
212
|
-
/** IDs of downstream trails this trail may invoke via ctx.
|
|
213
|
-
readonly
|
|
214
|
-
/** Composition-only input schema, merged with `input` for ctx.
|
|
215
|
-
readonly
|
|
403
|
+
/** IDs of downstream trails this trail may invoke via ctx.compose() (always present, default []) */
|
|
404
|
+
readonly composes: readonly string[];
|
|
405
|
+
/** Composition-only input schema, merged with `input` for ctx.compose() calls (optional) */
|
|
406
|
+
readonly composeInput?: z.ZodType<CI> | undefined;
|
|
216
407
|
/** Recovery paths activated when blaze fails with a matching error (always present, default []). */
|
|
217
408
|
readonly detours: readonly Detour<I, O, TrailsError>[];
|
|
218
409
|
/**
|
|
@@ -358,13 +549,427 @@ const extractSignalActivationIds = (
|
|
|
358
549
|
.map((entry) => entry.source.id)
|
|
359
550
|
);
|
|
360
551
|
|
|
361
|
-
/** Normalize a
|
|
362
|
-
const
|
|
552
|
+
/** Normalize a composes entry — trail objects are reduced to their id. */
|
|
553
|
+
const normalizeComposeRef = (entry: TrailRef): string =>
|
|
363
554
|
typeof entry === 'string' ? entry : entry.id;
|
|
364
555
|
|
|
556
|
+
const assertVersionNumber = (
|
|
557
|
+
trailId: string,
|
|
558
|
+
label: string,
|
|
559
|
+
version: number
|
|
560
|
+
): void => {
|
|
561
|
+
if (!Number.isSafeInteger(version) || version <= 0) {
|
|
562
|
+
throw new ValidationError(
|
|
563
|
+
`Trail "${trailId}" ${label} must be a positive integer`
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const hasOwn = (value: Record<string, unknown>, key: string): boolean =>
|
|
569
|
+
Object.hasOwn(value, key);
|
|
570
|
+
|
|
571
|
+
const ORDER_INSENSITIVE_SCHEMA_ARRAY_KEYS = new Set([
|
|
572
|
+
'allOf',
|
|
573
|
+
'anyOf',
|
|
574
|
+
'enum',
|
|
575
|
+
'oneOf',
|
|
576
|
+
'required',
|
|
577
|
+
'type',
|
|
578
|
+
]);
|
|
579
|
+
|
|
580
|
+
const canonicalizeVersionSchema = (
|
|
581
|
+
value: unknown,
|
|
582
|
+
parentKey?: string
|
|
583
|
+
): unknown => {
|
|
584
|
+
if (Array.isArray(value)) {
|
|
585
|
+
const items = value.map((item) => canonicalizeVersionSchema(item));
|
|
586
|
+
return parentKey !== undefined &&
|
|
587
|
+
ORDER_INSENSITIVE_SCHEMA_ARRAY_KEYS.has(parentKey)
|
|
588
|
+
? items.toSorted((left, right) =>
|
|
589
|
+
JSON.stringify(left).localeCompare(JSON.stringify(right))
|
|
590
|
+
)
|
|
591
|
+
: items;
|
|
592
|
+
}
|
|
593
|
+
if (value !== null && typeof value === 'object') {
|
|
594
|
+
const sorted: Record<string, unknown> = {};
|
|
595
|
+
for (const key of Object.keys(value).toSorted()) {
|
|
596
|
+
sorted[key] = canonicalizeVersionSchema(
|
|
597
|
+
(value as Record<string, unknown>)[key],
|
|
598
|
+
key
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
return sorted;
|
|
602
|
+
}
|
|
603
|
+
return value;
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const schemasMatch = (left: z.ZodType, right: z.ZodType): boolean =>
|
|
607
|
+
JSON.stringify(canonicalizeVersionSchema(zodToJsonSchema(left))) ===
|
|
608
|
+
JSON.stringify(canonicalizeVersionSchema(zodToJsonSchema(right)));
|
|
609
|
+
|
|
610
|
+
const assertZodSchema = (
|
|
611
|
+
trailId: string,
|
|
612
|
+
version: number,
|
|
613
|
+
entry: Record<string, unknown>,
|
|
614
|
+
field: 'input' | 'output'
|
|
615
|
+
): void => {
|
|
616
|
+
if (!hasOwn(entry, field) || entry[field] === undefined) {
|
|
617
|
+
throw new ValidationError(
|
|
618
|
+
`Trail "${trailId}" version ${version} must declare explicit ${field}`
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
const normalizeVersionStatusMigration = (
|
|
624
|
+
trailId: string,
|
|
625
|
+
version: number,
|
|
626
|
+
migration: unknown
|
|
627
|
+
): readonly string[] | undefined => {
|
|
628
|
+
if (migration === undefined) {
|
|
629
|
+
return undefined;
|
|
630
|
+
}
|
|
631
|
+
if (!Array.isArray(migration)) {
|
|
632
|
+
throw new ValidationError(
|
|
633
|
+
`Trail "${trailId}" version ${version} status.migration must be an array`
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return Object.freeze(
|
|
638
|
+
migration.map((step, index) => {
|
|
639
|
+
if (typeof step !== 'string' || step.trim().length === 0) {
|
|
640
|
+
throw new ValidationError(
|
|
641
|
+
`Trail "${trailId}" version ${version} status.migration[${index}] must be a non-empty string`
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
return step;
|
|
645
|
+
})
|
|
646
|
+
);
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
const normalizeVersionStatus = (
|
|
650
|
+
trailId: string,
|
|
651
|
+
version: number,
|
|
652
|
+
status: unknown
|
|
653
|
+
): TrailVersionStatus | undefined => {
|
|
654
|
+
if (status === undefined) {
|
|
655
|
+
return undefined;
|
|
656
|
+
}
|
|
657
|
+
if (typeof status !== 'object' || status === null || Array.isArray(status)) {
|
|
658
|
+
throw new ValidationError(
|
|
659
|
+
`Trail "${trailId}" version ${version} status must be an object`
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const raw = status as Record<string, unknown>;
|
|
664
|
+
if (raw['state'] !== 'deprecated' && raw['state'] !== 'archived') {
|
|
665
|
+
throw new ValidationError(
|
|
666
|
+
`Trail "${trailId}" version ${version} status.state must be "deprecated" or "archived"`
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
if (raw['state'] === 'deprecated') {
|
|
670
|
+
if (raw['successor'] !== undefined) {
|
|
671
|
+
assertVersionNumber(
|
|
672
|
+
trailId,
|
|
673
|
+
`version ${version} status.successor`,
|
|
674
|
+
raw['successor'] as number
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
if (raw['note'] !== undefined) {
|
|
678
|
+
if (typeof raw['note'] !== 'string') {
|
|
679
|
+
throw new ValidationError(
|
|
680
|
+
`Trail "${trailId}" version ${version} status.note must be a string`
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
if (raw['note'].trim().length === 0) {
|
|
684
|
+
throw new ValidationError(
|
|
685
|
+
`Trail "${trailId}" version ${version} status.note must be a non-empty string`
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
const migration = normalizeVersionStatusMigration(
|
|
690
|
+
trailId,
|
|
691
|
+
version,
|
|
692
|
+
raw['migration']
|
|
693
|
+
);
|
|
694
|
+
const normalized = Object.freeze({
|
|
695
|
+
...raw,
|
|
696
|
+
...(migration === undefined ? {} : { migration }),
|
|
697
|
+
state: 'deprecated',
|
|
698
|
+
}) as TrailVersionDeprecatedStatus;
|
|
699
|
+
if (!hasDeprecatedTrailVersionGuidance(normalized)) {
|
|
700
|
+
throw new ValidationError(
|
|
701
|
+
`Trail "${trailId}" version ${version} deprecated status must declare successor, migration, or note guidance`
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
return normalized;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (
|
|
708
|
+
raw['reason'] !== undefined &&
|
|
709
|
+
(typeof raw['reason'] !== 'string' || raw['reason'].trim().length === 0)
|
|
710
|
+
) {
|
|
711
|
+
throw new ValidationError(
|
|
712
|
+
`Trail "${trailId}" version ${version} status.reason must be a non-empty string`
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return Object.freeze({ ...raw, state: 'archived' }) as TrailVersionStatus;
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
const normalizeVersionExamples = (
|
|
720
|
+
trailId: string,
|
|
721
|
+
version: number,
|
|
722
|
+
examples: unknown
|
|
723
|
+
): readonly TrailExample<unknown, unknown>[] | undefined => {
|
|
724
|
+
if (examples === undefined) {
|
|
725
|
+
return undefined;
|
|
726
|
+
}
|
|
727
|
+
if (!Array.isArray(examples)) {
|
|
728
|
+
throw new ValidationError(
|
|
729
|
+
`Trail "${trailId}" version ${version} examples must be an array`
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return Object.freeze([...examples]) as readonly TrailExample<
|
|
734
|
+
unknown,
|
|
735
|
+
unknown
|
|
736
|
+
>[];
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
const normalizeTranspose = (
|
|
740
|
+
trailId: string,
|
|
741
|
+
version: number,
|
|
742
|
+
transpose: unknown
|
|
743
|
+
): TrailVersionTranspose<unknown, unknown, unknown, unknown> | undefined => {
|
|
744
|
+
if (transpose === undefined) {
|
|
745
|
+
return undefined;
|
|
746
|
+
}
|
|
747
|
+
if (
|
|
748
|
+
typeof transpose !== 'object' ||
|
|
749
|
+
transpose === null ||
|
|
750
|
+
Array.isArray(transpose)
|
|
751
|
+
) {
|
|
752
|
+
throw new ValidationError(
|
|
753
|
+
`Trail "${trailId}" version ${version} transpose must be an object`
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const raw = transpose as Record<string, unknown>;
|
|
758
|
+
if (
|
|
759
|
+
typeof raw['input'] !== 'function' ||
|
|
760
|
+
typeof raw['output'] !== 'function'
|
|
761
|
+
) {
|
|
762
|
+
throw new ValidationError(
|
|
763
|
+
`Trail "${trailId}" version ${version} transpose must define input and output functions`
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
return Object.freeze({
|
|
768
|
+
input: raw['input'],
|
|
769
|
+
output: raw['output'],
|
|
770
|
+
}) as TrailVersionTranspose<unknown, unknown, unknown, unknown>;
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
const assertRevisionOwnsNoRuntimeFields = (
|
|
774
|
+
trailId: string,
|
|
775
|
+
version: number,
|
|
776
|
+
entry: Record<string, unknown>
|
|
777
|
+
): void => {
|
|
778
|
+
const forbidden = ['composeInput', 'composes', 'resources', 'detours'];
|
|
779
|
+
const declared = forbidden.filter((field) => hasOwn(entry, field));
|
|
780
|
+
if (declared.length > 0) {
|
|
781
|
+
throw new ValidationError(
|
|
782
|
+
`Trail "${trailId}" version ${version} is a revision and cannot declare ${declared.join(', ')}`
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
const normalizeVersionEntry = <CurrentInput, CurrentOutput>(
|
|
788
|
+
trailId: string,
|
|
789
|
+
version: number,
|
|
790
|
+
currentInput: z.ZodType<CurrentInput>,
|
|
791
|
+
currentOutput: z.ZodType<CurrentOutput> | undefined,
|
|
792
|
+
entry: TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput>
|
|
793
|
+
): TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput> => {
|
|
794
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
795
|
+
throw new ValidationError(
|
|
796
|
+
`Trail "${trailId}" version ${version} must be an object`
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const raw = entry as unknown as Record<string, unknown>;
|
|
801
|
+
assertZodSchema(trailId, version, raw, 'input');
|
|
802
|
+
assertZodSchema(trailId, version, raw, 'output');
|
|
803
|
+
|
|
804
|
+
if (hasOwn(raw, 'kind')) {
|
|
805
|
+
throw new ValidationError(
|
|
806
|
+
`Trail "${trailId}" version ${version} must not author kind; it is projected`
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
if (hasOwn(raw, 'marker')) {
|
|
810
|
+
throw new ValidationError(
|
|
811
|
+
`Trail "${trailId}" version ${version} must not author marker; it is projected`
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const hasBlaze = typeof raw['blaze'] === 'function';
|
|
816
|
+
const hasTranspose = raw['transpose'] !== undefined;
|
|
817
|
+
if (hasBlaze && hasTranspose) {
|
|
818
|
+
throw new ValidationError(
|
|
819
|
+
`Trail "${trailId}" version ${version} cannot declare both blaze and transpose`
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const base = {
|
|
824
|
+
...(raw['examples'] === undefined
|
|
825
|
+
? {}
|
|
826
|
+
: {
|
|
827
|
+
examples: normalizeVersionExamples(trailId, version, raw['examples']),
|
|
828
|
+
}),
|
|
829
|
+
input: raw['input'],
|
|
830
|
+
output: raw['output'],
|
|
831
|
+
...(raw['status'] === undefined
|
|
832
|
+
? {}
|
|
833
|
+
: { status: normalizeVersionStatus(trailId, version, raw['status']) }),
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
if (hasBlaze) {
|
|
837
|
+
return Object.freeze({
|
|
838
|
+
...base,
|
|
839
|
+
blaze: async (input: unknown, ctx: TrailContext) =>
|
|
840
|
+
await (raw['blaze'] as Implementation<unknown, unknown>)(input, ctx),
|
|
841
|
+
...(raw['composeInput'] === undefined
|
|
842
|
+
? {}
|
|
843
|
+
: { composeInput: raw['composeInput'] }),
|
|
844
|
+
composes: Object.freeze(
|
|
845
|
+
(
|
|
846
|
+
(raw['composes'] as readonly (string | AnyTrail)[] | undefined) ?? []
|
|
847
|
+
).map(normalizeComposeRef)
|
|
848
|
+
),
|
|
849
|
+
detours: Object.freeze([
|
|
850
|
+
...(((raw['detours'] as readonly Detour<
|
|
851
|
+
unknown,
|
|
852
|
+
unknown,
|
|
853
|
+
TrailsError
|
|
854
|
+
>[]) ?? []) as readonly Detour<unknown, unknown, TrailsError>[]),
|
|
855
|
+
]),
|
|
856
|
+
resources: Object.freeze([
|
|
857
|
+
...(((raw['resources'] as readonly AnyResource[]) ??
|
|
858
|
+
[]) as readonly AnyResource[]),
|
|
859
|
+
]),
|
|
860
|
+
}) as TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput>;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
assertRevisionOwnsNoRuntimeFields(trailId, version, raw);
|
|
864
|
+
const inputMatchesCurrent = schemasMatch(
|
|
865
|
+
raw['input'] as z.ZodType,
|
|
866
|
+
currentInput
|
|
867
|
+
);
|
|
868
|
+
const outputMatchesCurrent =
|
|
869
|
+
currentOutput === undefined ||
|
|
870
|
+
schemasMatch(raw['output'] as z.ZodType, currentOutput);
|
|
871
|
+
if (!hasTranspose && (!inputMatchesCurrent || !outputMatchesCurrent)) {
|
|
872
|
+
throw new ValidationError(
|
|
873
|
+
`Trail "${trailId}" version ${version} changes schema and must declare transpose`
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
return Object.freeze({
|
|
878
|
+
...base,
|
|
879
|
+
...(hasTranspose
|
|
880
|
+
? { transpose: normalizeTranspose(trailId, version, raw['transpose']) }
|
|
881
|
+
: {}),
|
|
882
|
+
}) as TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput>;
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
const normalizeTrailVersions = <CurrentInput, CurrentOutput>(
|
|
886
|
+
trailId: string,
|
|
887
|
+
currentInput: z.ZodType<CurrentInput>,
|
|
888
|
+
currentOutput: z.ZodType<CurrentOutput> | undefined,
|
|
889
|
+
currentVersion: number | undefined,
|
|
890
|
+
versions: TrailVersions<CurrentInput, CurrentOutput> | undefined
|
|
891
|
+
): TrailVersions<CurrentInput, CurrentOutput> | undefined => {
|
|
892
|
+
if (currentVersion === undefined) {
|
|
893
|
+
if (versions !== undefined) {
|
|
894
|
+
throw new ValidationError(
|
|
895
|
+
`Trail "${trailId}" declares versions without a current version`
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
return undefined;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
assertVersionNumber(trailId, 'version', currentVersion);
|
|
902
|
+
|
|
903
|
+
if (versions === undefined) {
|
|
904
|
+
return undefined;
|
|
905
|
+
}
|
|
906
|
+
if (
|
|
907
|
+
typeof versions !== 'object' ||
|
|
908
|
+
versions === null ||
|
|
909
|
+
Array.isArray(versions)
|
|
910
|
+
) {
|
|
911
|
+
throw new ValidationError(`Trail "${trailId}" versions must be an object`);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const normalized: Record<
|
|
915
|
+
number,
|
|
916
|
+
TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput>
|
|
917
|
+
> = {};
|
|
918
|
+
for (const [rawVersion, entry] of Object.entries(versions)) {
|
|
919
|
+
const historicalVersion = Number(rawVersion);
|
|
920
|
+
if (`${historicalVersion}` !== rawVersion) {
|
|
921
|
+
throw new ValidationError(
|
|
922
|
+
`Trail "${trailId}" versions key "${rawVersion}" must be a positive integer`
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
assertVersionNumber(trailId, `versions.${rawVersion}`, historicalVersion);
|
|
926
|
+
if (historicalVersion === currentVersion) {
|
|
927
|
+
throw new ValidationError(
|
|
928
|
+
`Trail "${trailId}" version ${historicalVersion} is current and must stay top-level`
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
if (historicalVersion > currentVersion) {
|
|
932
|
+
throw new ValidationError(
|
|
933
|
+
`Trail "${trailId}" version ${historicalVersion} must be less than the current version (${currentVersion})`
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
normalized[historicalVersion] = normalizeVersionEntry(
|
|
937
|
+
trailId,
|
|
938
|
+
historicalVersion,
|
|
939
|
+
currentInput,
|
|
940
|
+
currentOutput,
|
|
941
|
+
entry
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const knownVersions = new Set([
|
|
946
|
+
currentVersion,
|
|
947
|
+
...Object.keys(normalized).map(Number),
|
|
948
|
+
]);
|
|
949
|
+
for (const [rawVersion, entry] of Object.entries(normalized)) {
|
|
950
|
+
if (
|
|
951
|
+
entry.status?.state === 'deprecated' &&
|
|
952
|
+
entry.status.successor !== undefined &&
|
|
953
|
+
(!knownVersions.has(entry.status.successor) ||
|
|
954
|
+
entry.status.successor === Number(rawVersion))
|
|
955
|
+
) {
|
|
956
|
+
throw new ValidationError(
|
|
957
|
+
`Trail "${trailId}" version ${rawVersion} status.successor must reference the current version or another known historical version`
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return Object.freeze(normalized);
|
|
963
|
+
};
|
|
964
|
+
|
|
365
965
|
/** Freeze and normalize all collection fields from a trail spec. */
|
|
366
|
-
const normalizeCollections = <
|
|
367
|
-
|
|
966
|
+
const normalizeCollections = <
|
|
967
|
+
I,
|
|
968
|
+
O,
|
|
969
|
+
CI,
|
|
970
|
+
C extends readonly TrailRef[] | undefined,
|
|
971
|
+
>(
|
|
972
|
+
spec: TrailSpec<I, O, CI, C>
|
|
368
973
|
): {
|
|
369
974
|
readonly args: readonly string[] | false | undefined;
|
|
370
975
|
readonly activationSources: readonly ActivationEntry[];
|
|
@@ -410,16 +1015,26 @@ const normalizeCollections = <I, O, CI>(
|
|
|
410
1015
|
* });
|
|
411
1016
|
* ```
|
|
412
1017
|
*/
|
|
413
|
-
export function trail<
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
1018
|
+
export function trail<
|
|
1019
|
+
I,
|
|
1020
|
+
O,
|
|
1021
|
+
CI = never,
|
|
1022
|
+
const C extends readonly TrailRef[] | undefined = undefined,
|
|
1023
|
+
>(id: string, spec: TrailSpec<I, O, CI, C>): Trail<I, O, CI>;
|
|
1024
|
+
export function trail<
|
|
1025
|
+
I,
|
|
1026
|
+
O,
|
|
1027
|
+
CI = never,
|
|
1028
|
+
const C extends readonly TrailRef[] | undefined = undefined,
|
|
1029
|
+
>(spec: TrailSpec<I, O, CI, C> & { readonly id: string }): Trail<I, O, CI>;
|
|
1030
|
+
export function trail<
|
|
1031
|
+
I,
|
|
1032
|
+
O,
|
|
1033
|
+
CI = never,
|
|
1034
|
+
const C extends readonly TrailRef[] | undefined = undefined,
|
|
1035
|
+
>(
|
|
1036
|
+
idOrSpec: string | (TrailSpec<I, O, CI, C> & { readonly id: string }),
|
|
1037
|
+
maybeSpec?: TrailSpec<I, O, CI, C>
|
|
423
1038
|
): Trail<I, O, CI> {
|
|
424
1039
|
const resolved =
|
|
425
1040
|
typeof idOrSpec === 'string'
|
|
@@ -429,11 +1044,16 @@ export function trail<I, O, CI = never>(
|
|
|
429
1044
|
if (!resolved.spec) {
|
|
430
1045
|
throw new TypeError('trail() requires a spec when an id is provided');
|
|
431
1046
|
}
|
|
1047
|
+
if (hasOwn(resolved.spec as unknown as Record<string, unknown>, 'marker')) {
|
|
1048
|
+
throw new ValidationError(
|
|
1049
|
+
`Trail "${resolved.id}" must not author marker; it is projected`
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
432
1052
|
|
|
433
1053
|
const {
|
|
434
1054
|
blaze,
|
|
435
|
-
|
|
436
|
-
|
|
1055
|
+
composeInput,
|
|
1056
|
+
composes: rawComposes,
|
|
437
1057
|
intent: rawIntent,
|
|
438
1058
|
visibility: rawVisibility,
|
|
439
1059
|
// Destructure away fields handled by normalizeCollections
|
|
@@ -444,20 +1064,31 @@ export function trail<I, O, CI = never>(
|
|
|
444
1064
|
layers: _l,
|
|
445
1065
|
on: _o,
|
|
446
1066
|
resources: _r,
|
|
1067
|
+
version: rawVersion,
|
|
1068
|
+
versions: rawVersions,
|
|
447
1069
|
...spec
|
|
448
1070
|
} = resolved.spec;
|
|
449
1071
|
const collections = normalizeCollections(resolved.spec);
|
|
1072
|
+
const versions = normalizeTrailVersions<I, O>(
|
|
1073
|
+
resolved.id,
|
|
1074
|
+
resolved.spec.input,
|
|
1075
|
+
resolved.spec.output,
|
|
1076
|
+
rawVersion,
|
|
1077
|
+
rawVersions
|
|
1078
|
+
);
|
|
450
1079
|
|
|
451
1080
|
return Object.freeze({
|
|
452
1081
|
...spec,
|
|
453
1082
|
...collections,
|
|
454
1083
|
blaze: async (input: BlazeInput<I, CI>, ctx: TrailContext) =>
|
|
455
|
-
await blaze(input, ctx),
|
|
456
|
-
|
|
457
|
-
|
|
1084
|
+
await blaze(input, ctx as ComposeContextFor<C>),
|
|
1085
|
+
composeInput,
|
|
1086
|
+
composes: Object.freeze((rawComposes ?? []).map(normalizeComposeRef)),
|
|
458
1087
|
id: resolved.id,
|
|
459
1088
|
intent: rawIntent ?? 'write',
|
|
460
1089
|
kind: 'trail' as const,
|
|
1090
|
+
...(rawVersion === undefined ? {} : { version: rawVersion }),
|
|
1091
|
+
...(versions === undefined ? {} : { versions }),
|
|
461
1092
|
visibility: rawVisibility ?? 'public',
|
|
462
1093
|
});
|
|
463
1094
|
}
|
|
@@ -466,8 +1097,12 @@ export function trail<I, O, CI = never>(
|
|
|
466
1097
|
// The Omit+override avoids a TypeScript limitation where BlazeInput's conditional type
|
|
467
1098
|
// makes Trail<any, any, any> structurally incompatible with Trail<I, O, never>.
|
|
468
1099
|
/* oxlint-disable no-explicit-any -- existential type for heterogeneous collections */
|
|
469
|
-
export type AnyTrail = Omit<
|
|
1100
|
+
export type AnyTrail = Omit<
|
|
1101
|
+
Trail<any, any, never>,
|
|
1102
|
+
'blaze' | 'composeInput'
|
|
1103
|
+
> & {
|
|
470
1104
|
readonly blaze: Implementation<any, any>;
|
|
1105
|
+
readonly composeInput?: z.ZodType<any> | undefined;
|
|
471
1106
|
};
|
|
472
1107
|
/* oxlint-enable no-explicit-any */
|
|
473
1108
|
|