@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/CHANGELOG.md +26 -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/type-utils.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { Result } from './result.js';
|
|
6
|
-
import type { AnyTrail
|
|
6
|
+
import type { AnyTrail } from './trail.js';
|
|
7
|
+
import type { Implementation } from './types.js';
|
|
8
|
+
import type { z } from 'zod';
|
|
7
9
|
|
|
8
10
|
// ---------------------------------------------------------------------------
|
|
9
11
|
// Utility types
|
|
@@ -12,27 +14,33 @@ import type { AnyTrail, Trail } from './trail.js';
|
|
|
12
14
|
/* oxlint-disable no-explicit-any -- `any` required for conditional type inference; `unknown` breaks inference */
|
|
13
15
|
|
|
14
16
|
/** Extract the input type from a Trail. */
|
|
15
|
-
export type TrailInput<T extends AnyTrail> =
|
|
16
|
-
|
|
17
|
+
export type TrailInput<T extends AnyTrail> = T extends {
|
|
18
|
+
readonly input: z.ZodType<infer I>;
|
|
19
|
+
}
|
|
20
|
+
? I
|
|
21
|
+
: never;
|
|
17
22
|
|
|
18
23
|
/** Extract the output type from a Trail. */
|
|
19
|
-
export type TrailOutput<T extends AnyTrail> =
|
|
20
|
-
|
|
24
|
+
export type TrailOutput<T extends AnyTrail> = T extends {
|
|
25
|
+
readonly blaze: Implementation<any, infer O>;
|
|
26
|
+
}
|
|
27
|
+
? O
|
|
28
|
+
: never;
|
|
21
29
|
|
|
22
30
|
/**
|
|
23
|
-
* Extract the
|
|
31
|
+
* Extract the compose-callable input type from a trail.
|
|
24
32
|
*
|
|
25
|
-
* When a trail declares `
|
|
33
|
+
* When a trail declares `composeInput`, callers via `ctx.compose()` must pass
|
|
26
34
|
* both the public input fields and the composition-only fields. This type
|
|
27
35
|
* merges both schemas so the compiler enforces the full shape at the call
|
|
28
|
-
* site. Falls back to plain `TrailInput<T>` when no `
|
|
36
|
+
* site. Falls back to plain `TrailInput<T>` when no `composeInput` exists.
|
|
29
37
|
*/
|
|
30
|
-
export type
|
|
31
|
-
T extends
|
|
38
|
+
export type ComposeInput<T extends AnyTrail> =
|
|
39
|
+
NonNullable<T['composeInput']> extends z.ZodType<infer CI>
|
|
32
40
|
? [CI] extends [never]
|
|
33
|
-
?
|
|
34
|
-
:
|
|
35
|
-
:
|
|
41
|
+
? TrailInput<T>
|
|
42
|
+
: TrailInput<T> & CI
|
|
43
|
+
: TrailInput<T>;
|
|
36
44
|
|
|
37
45
|
/**
|
|
38
46
|
* Extracts the full `Result<Output, Error>` type from a trail definition.
|
package/src/types.ts
CHANGED
|
@@ -3,8 +3,9 @@ import type { BasePermit } from './permits.js';
|
|
|
3
3
|
import type { Result } from './result.js';
|
|
4
4
|
import type { Signal } from './signal.js';
|
|
5
5
|
import type { AnyTrail } from './trail.js';
|
|
6
|
-
import type {
|
|
6
|
+
import type { ComposeInput, TrailOutput } from './type-utils.js';
|
|
7
7
|
import type { ActivationProvenance } from './activation-provenance.js';
|
|
8
|
+
import type { TrailVersionReference } from './version-resolution.js';
|
|
8
9
|
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
10
11
|
// Detour
|
|
@@ -31,29 +32,29 @@ export interface DetourAttempt<Input, TErr extends TrailsError = TrailsError> {
|
|
|
31
32
|
readonly input: Input;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
type
|
|
35
|
+
type ComposeBatchCall<TTarget extends AnyTrail | string = AnyTrail | string> =
|
|
35
36
|
TTarget extends AnyTrail
|
|
36
|
-
? readonly [trail: TTarget, input:
|
|
37
|
+
? readonly [trail: TTarget, input: ComposeInput<TTarget>]
|
|
37
38
|
: readonly [id: string, input: unknown];
|
|
38
39
|
|
|
39
|
-
type
|
|
40
|
+
type ComposeBatchResult<TTarget extends AnyTrail | string> =
|
|
40
41
|
TTarget extends AnyTrail
|
|
41
42
|
? Result<TrailOutput<TTarget>, Error>
|
|
42
43
|
: Result<unknown, Error>;
|
|
43
44
|
|
|
44
|
-
type
|
|
45
|
+
type ComposeBatchResults<TCalls extends readonly ComposeBatchCall[]> = {
|
|
45
46
|
readonly [K in keyof TCalls]: TCalls[K] extends readonly [
|
|
46
47
|
infer TTarget,
|
|
47
48
|
unknown,
|
|
48
49
|
]
|
|
49
50
|
? TTarget extends AnyTrail | string
|
|
50
|
-
?
|
|
51
|
+
? ComposeBatchResult<TTarget>
|
|
51
52
|
: never
|
|
52
53
|
: never;
|
|
53
54
|
};
|
|
54
55
|
|
|
55
|
-
/** Runtime options for batch `ctx.
|
|
56
|
-
export interface
|
|
56
|
+
/** Runtime options for batch `ctx.compose([...])` calls. */
|
|
57
|
+
export interface ComposeBatchOptions {
|
|
57
58
|
/**
|
|
58
59
|
* Maximum number of branches to execute concurrently.
|
|
59
60
|
*
|
|
@@ -62,15 +63,26 @@ export interface CrossBatchOptions {
|
|
|
62
63
|
readonly concurrency?: number | undefined;
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
/** Runtime options for a single `ctx.compose(trail, input, options)` call. */
|
|
67
|
+
export interface ComposeOptions {
|
|
68
|
+
/**
|
|
69
|
+
* Execute a specific live version of the composed trail.
|
|
70
|
+
*
|
|
71
|
+
* Omit to keep composition current by default. Historical revision entries
|
|
72
|
+
* transpose through the current trail; fork entries run their own blaze.
|
|
73
|
+
*/
|
|
74
|
+
readonly version?: TrailVersionReference | undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
65
77
|
/**
|
|
66
78
|
* Trail implementation — sync or async.
|
|
67
79
|
*
|
|
68
80
|
* Authors can return `Result` directly or wrap it in a `Promise`. The framework
|
|
69
81
|
* normalizes with `await` at every call site, so both forms work transparently.
|
|
70
82
|
*/
|
|
71
|
-
export type Implementation<I, O> = (
|
|
83
|
+
export type Implementation<I, O, Ctx extends TrailContext = TrailContext> = (
|
|
72
84
|
input: I,
|
|
73
|
-
ctx:
|
|
85
|
+
ctx: Ctx
|
|
74
86
|
) => Result<O, Error> | Promise<Result<O, Error>>;
|
|
75
87
|
|
|
76
88
|
/**
|
|
@@ -78,26 +90,31 @@ export type Implementation<I, O> = (
|
|
|
78
90
|
*
|
|
79
91
|
* Two call shapes:
|
|
80
92
|
*
|
|
81
|
-
* - **By trail object** (typed): `ctx.
|
|
93
|
+
* - **By trail object** (typed): `ctx.compose(showGist, { id })` — the compiler
|
|
82
94
|
* infers `I` and `O` from the trail's schemas, so the result is fully typed.
|
|
83
|
-
* - **By string id** (untyped escape hatch): `ctx.
|
|
95
|
+
* - **By string id** (untyped escape hatch): `ctx.compose('gist.show', { id })`
|
|
84
96
|
* — returns `Result<O, Error>` where `O` defaults to `unknown`.
|
|
85
|
-
* - **By batch**: `ctx.
|
|
86
|
-
* — executes every
|
|
97
|
+
* - **By batch**: `ctx.compose([[showGist, { id }], ['audit.log', payload]])`
|
|
98
|
+
* — executes every composing concurrently and resolves once all results are
|
|
87
99
|
* available. Result ordering always matches the input tuple ordering. Pass
|
|
88
100
|
* `{ concurrency: N }` as the second argument to limit how many branches
|
|
89
101
|
* run at once.
|
|
90
102
|
*/
|
|
91
|
-
export interface
|
|
92
|
-
<const TCalls extends readonly
|
|
103
|
+
export interface ComposeFn {
|
|
104
|
+
<const TCalls extends readonly ComposeBatchCall[]>(
|
|
93
105
|
calls: TCalls,
|
|
94
|
-
options?:
|
|
95
|
-
): Promise<
|
|
106
|
+
options?: ComposeBatchOptions
|
|
107
|
+
): Promise<ComposeBatchResults<TCalls>>;
|
|
96
108
|
<T extends AnyTrail>(
|
|
97
109
|
trail: T,
|
|
98
|
-
input:
|
|
110
|
+
input: ComposeInput<T>,
|
|
111
|
+
options?: ComposeOptions
|
|
99
112
|
): Promise<Result<TrailOutput<T>, Error>>;
|
|
100
|
-
<O = unknown>(
|
|
113
|
+
<O = unknown>(
|
|
114
|
+
id: string,
|
|
115
|
+
input: unknown,
|
|
116
|
+
options?: ComposeOptions
|
|
117
|
+
): Promise<Result<O, Error>>;
|
|
101
118
|
}
|
|
102
119
|
|
|
103
120
|
/**
|
|
@@ -213,7 +230,7 @@ export interface TrailContext {
|
|
|
213
230
|
readonly activation?: ActivationProvenance | undefined;
|
|
214
231
|
readonly requestId: string;
|
|
215
232
|
readonly abortSignal: AbortSignal;
|
|
216
|
-
readonly
|
|
233
|
+
readonly compose?: ComposeFn | undefined;
|
|
217
234
|
/**
|
|
218
235
|
* Emit a typed signal. Fans out to every trail with the signal in its
|
|
219
236
|
* `on:` declaration. Bound by the runner that holds the topo (typically
|
|
@@ -260,6 +277,11 @@ export interface TrailContext {
|
|
|
260
277
|
readonly trace?: TraceFn | undefined;
|
|
261
278
|
}
|
|
262
279
|
|
|
280
|
+
/** Trail context for blazes that declare trail composition. */
|
|
281
|
+
export interface ComposeTrailContext extends TrailContext {
|
|
282
|
+
readonly compose: ComposeFn;
|
|
283
|
+
}
|
|
284
|
+
|
|
263
285
|
/**
|
|
264
286
|
* Permit requirement declared on a trail spec.
|
|
265
287
|
*
|
|
@@ -6,9 +6,9 @@ import type { TopoIssue } from './validate-topo.js';
|
|
|
6
6
|
import { validateTopo } from './validate-topo.js';
|
|
7
7
|
|
|
8
8
|
const PROJECTION_BLOCKING_RULES = new Set([
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
'no-self-
|
|
9
|
+
'compose-cycle',
|
|
10
|
+
'compose-exists',
|
|
11
|
+
'no-self-compose',
|
|
12
12
|
'activation-source-definition-unique',
|
|
13
13
|
'activation-source-edge-unique',
|
|
14
14
|
'activation-source-kind-known',
|
package/src/validate-topo.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Structural validation for a Topo graph.
|
|
3
3
|
*
|
|
4
|
-
* Checks trail
|
|
4
|
+
* Checks trail composing references, example input validity, signal origin
|
|
5
5
|
* references, activation source kinds, and output schema completeness. Returns
|
|
6
6
|
* a Result with all issues collected into a single ValidationError.
|
|
7
7
|
*/
|
|
@@ -22,7 +22,11 @@ import type { AnySignal } from './signal.js';
|
|
|
22
22
|
import { validateScheduleSource } from './schedule.js';
|
|
23
23
|
import { Result } from './result.js';
|
|
24
24
|
import type { Topo } from './topo.js';
|
|
25
|
-
import type { AnyTrail } from './trail.js';
|
|
25
|
+
import type { AnyTrail, TrailVersionForkEntry } from './trail.js';
|
|
26
|
+
import {
|
|
27
|
+
getTrailVersionEntryKind,
|
|
28
|
+
isArchivedTrailVersionEntry,
|
|
29
|
+
} from './trail.js';
|
|
26
30
|
import { validateInput } from './validation.js';
|
|
27
31
|
import { validateWebhookSource } from './webhook.js';
|
|
28
32
|
|
|
@@ -50,17 +54,32 @@ const WHITE = 0;
|
|
|
50
54
|
const GRAY = 1;
|
|
51
55
|
const BLACK = 2;
|
|
52
56
|
|
|
53
|
-
/** Build an adjacency list and initial color map from trails with
|
|
54
|
-
const
|
|
57
|
+
/** Build an adjacency list and initial color map from trails with compositions. */
|
|
58
|
+
const buildComposeGraph = (
|
|
55
59
|
trails: ReadonlyMap<string, AnyTrail>
|
|
56
60
|
): {
|
|
57
61
|
graph: Map<string, readonly string[]>;
|
|
58
62
|
color: Map<string, number>;
|
|
59
63
|
} => {
|
|
60
64
|
const graph = new Map<string, readonly string[]>();
|
|
61
|
-
for (const [id,
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
for (const [id, trail] of trails) {
|
|
66
|
+
const composedIds = new Set<string>(trail.composes);
|
|
67
|
+
for (const entry of Object.values(trail.versions ?? {})) {
|
|
68
|
+
if (
|
|
69
|
+
isArchivedTrailVersionEntry(entry) ||
|
|
70
|
+
getTrailVersionEntryKind(entry) !== 'fork'
|
|
71
|
+
) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const fork = entry as TrailVersionForkEntry;
|
|
76
|
+
for (const composed of fork.composes ?? []) {
|
|
77
|
+
composedIds.add(typeof composed === 'string' ? composed : composed.id);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (composedIds.size > 0) {
|
|
82
|
+
graph.set(id, [...composedIds]);
|
|
64
83
|
}
|
|
65
84
|
}
|
|
66
85
|
const color = new Map<string, number>();
|
|
@@ -70,12 +89,12 @@ const buildCrossGraph = (
|
|
|
70
89
|
return { color, graph };
|
|
71
90
|
};
|
|
72
91
|
|
|
73
|
-
/** Detect multi-node cycles in the trail
|
|
74
|
-
const
|
|
92
|
+
/** Detect multi-node cycles in the trail composing graph via DFS. */
|
|
93
|
+
const detectComposeCycles = (
|
|
75
94
|
trails: ReadonlyMap<string, AnyTrail>
|
|
76
95
|
): TopoIssue[] => {
|
|
77
96
|
const issues: TopoIssue[] = [];
|
|
78
|
-
const { color, graph } =
|
|
97
|
+
const { color, graph } = buildComposeGraph(trails);
|
|
79
98
|
|
|
80
99
|
const dfs = (node: string, path: string[]): void => {
|
|
81
100
|
color.set(node, GRAY);
|
|
@@ -88,7 +107,7 @@ const detectCrossCycles = (
|
|
|
88
107
|
const cycle = [...path.slice(path.indexOf(next)), next];
|
|
89
108
|
issues.push({
|
|
90
109
|
message: `Cycle detected: ${cycle.join(' → ')}`,
|
|
91
|
-
rule: '
|
|
110
|
+
rule: 'compose-cycle',
|
|
92
111
|
trailId: next,
|
|
93
112
|
});
|
|
94
113
|
} else if (c === WHITE) {
|
|
@@ -106,29 +125,57 @@ const detectCrossCycles = (
|
|
|
106
125
|
return issues;
|
|
107
126
|
};
|
|
108
127
|
|
|
109
|
-
const
|
|
128
|
+
const checkComposes = (
|
|
110
129
|
trails: ReadonlyMap<string, AnyTrail>,
|
|
111
130
|
topo: Topo
|
|
112
131
|
): TopoIssue[] => {
|
|
113
132
|
const issues: TopoIssue[] = [];
|
|
114
133
|
for (const [id, trail] of trails) {
|
|
115
|
-
for (const
|
|
116
|
-
if (
|
|
134
|
+
for (const composedId of trail.composes) {
|
|
135
|
+
if (composedId === id) {
|
|
117
136
|
issues.push({
|
|
118
|
-
message: `Trail
|
|
119
|
-
rule: 'no-self-
|
|
137
|
+
message: `Trail composes itself`,
|
|
138
|
+
rule: 'no-self-compose',
|
|
120
139
|
trailId: id,
|
|
121
140
|
});
|
|
122
|
-
} else if (!topo.has(
|
|
141
|
+
} else if (!topo.has(composedId) && !isDraftId(composedId)) {
|
|
123
142
|
issues.push({
|
|
124
|
-
message: `
|
|
125
|
-
rule: '
|
|
143
|
+
message: `Composes "${composedId}" which is not in the topo`,
|
|
144
|
+
rule: 'compose-exists',
|
|
126
145
|
trailId: id,
|
|
127
146
|
});
|
|
128
147
|
}
|
|
129
148
|
}
|
|
149
|
+
for (const [rawVersion, entry] of Object.entries(trail.versions ?? {})) {
|
|
150
|
+
if (
|
|
151
|
+
isArchivedTrailVersionEntry(entry) ||
|
|
152
|
+
getTrailVersionEntryKind(entry) !== 'fork'
|
|
153
|
+
) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const version = Number(rawVersion);
|
|
158
|
+
const fork = entry as TrailVersionForkEntry;
|
|
159
|
+
for (const composed of fork.composes ?? []) {
|
|
160
|
+
const composedId =
|
|
161
|
+
typeof composed === 'string' ? composed : composed.id;
|
|
162
|
+
if (composedId === id) {
|
|
163
|
+
issues.push({
|
|
164
|
+
message: `Trail version ${version} composes itself`,
|
|
165
|
+
rule: 'no-self-compose',
|
|
166
|
+
trailId: id,
|
|
167
|
+
});
|
|
168
|
+
} else if (!topo.has(composedId) && !isDraftId(composedId)) {
|
|
169
|
+
issues.push({
|
|
170
|
+
message: `Version ${version} composes "${composedId}" which is not in the topo`,
|
|
171
|
+
rule: 'compose-exists',
|
|
172
|
+
trailId: id,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
130
177
|
}
|
|
131
|
-
issues.push(...
|
|
178
|
+
issues.push(...detectComposeCycles(trails));
|
|
132
179
|
return issues;
|
|
133
180
|
};
|
|
134
181
|
|
|
@@ -151,6 +198,29 @@ const checkResources = (
|
|
|
151
198
|
});
|
|
152
199
|
}
|
|
153
200
|
}
|
|
201
|
+
for (const [rawVersion, entry] of Object.entries(trail.versions ?? {})) {
|
|
202
|
+
if (
|
|
203
|
+
isArchivedTrailVersionEntry(entry) ||
|
|
204
|
+
getTrailVersionEntryKind(entry) !== 'fork'
|
|
205
|
+
) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const version = Number(rawVersion);
|
|
210
|
+
const fork = entry as TrailVersionForkEntry;
|
|
211
|
+
for (const declaredResource of fork.resources ?? []) {
|
|
212
|
+
if (
|
|
213
|
+
!topo.hasResource(declaredResource.id) &&
|
|
214
|
+
!isDraftId(declaredResource.id)
|
|
215
|
+
) {
|
|
216
|
+
issues.push({
|
|
217
|
+
message: `Version ${version} resource "${declaredResource.id}" is not in the topo`,
|
|
218
|
+
rule: 'resource-exists',
|
|
219
|
+
trailId: id,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
154
224
|
}
|
|
155
225
|
|
|
156
226
|
return issues;
|
|
@@ -165,20 +235,21 @@ const checkOneExample = (
|
|
|
165
235
|
error?: string | undefined;
|
|
166
236
|
},
|
|
167
237
|
inputSchema: { safeParse: (data: unknown) => { success: boolean } },
|
|
168
|
-
hasOutput: boolean
|
|
238
|
+
hasOutput: boolean,
|
|
239
|
+
label = `Example "${example.name}"`
|
|
169
240
|
): TopoIssue[] => {
|
|
170
241
|
const issues: TopoIssue[] = [];
|
|
171
242
|
const result = validateInput(inputSchema as AnyTrail['input'], example.input);
|
|
172
243
|
if (result.isErr() && example.error !== 'ValidationError') {
|
|
173
244
|
issues.push({
|
|
174
|
-
message:
|
|
245
|
+
message: `${label} input does not parse against schema`,
|
|
175
246
|
rule: 'example-input-valid',
|
|
176
247
|
trailId: id,
|
|
177
248
|
});
|
|
178
249
|
}
|
|
179
250
|
if (example.expected !== undefined && !hasOutput) {
|
|
180
251
|
issues.push({
|
|
181
|
-
message:
|
|
252
|
+
message: `${label} has expected output but trail has no output schema`,
|
|
182
253
|
rule: 'output-schema-present',
|
|
183
254
|
trailId: id,
|
|
184
255
|
});
|
|
@@ -186,15 +257,34 @@ const checkOneExample = (
|
|
|
186
257
|
return issues;
|
|
187
258
|
};
|
|
188
259
|
|
|
260
|
+
const checkVersionExamples = (id: string, trail: AnyTrail): TopoIssue[] =>
|
|
261
|
+
Object.entries(trail.versions ?? {}).flatMap(([version, entry]) => {
|
|
262
|
+
if (isArchivedTrailVersionEntry(entry)) {
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return (entry.examples ?? []).flatMap((example) =>
|
|
267
|
+
checkOneExample(
|
|
268
|
+
id,
|
|
269
|
+
example,
|
|
270
|
+
entry.input,
|
|
271
|
+
true,
|
|
272
|
+
`Example "${example.name}" on version ${version}`
|
|
273
|
+
)
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
189
277
|
const checkExamples = (trails: ReadonlyMap<string, AnyTrail>): TopoIssue[] => {
|
|
190
278
|
const issues: TopoIssue[] = [];
|
|
191
279
|
for (const [id, trail] of trails) {
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
280
|
+
if (trail.examples) {
|
|
281
|
+
for (const example of trail.examples) {
|
|
282
|
+
issues.push(
|
|
283
|
+
...checkOneExample(id, example, trail.input, !!trail.output)
|
|
284
|
+
);
|
|
285
|
+
}
|
|
197
286
|
}
|
|
287
|
+
issues.push(...checkVersionExamples(id, trail));
|
|
198
288
|
}
|
|
199
289
|
return issues;
|
|
200
290
|
};
|
|
@@ -436,14 +526,14 @@ const checkContourReferences = (
|
|
|
436
526
|
/**
|
|
437
527
|
* Validate the structural integrity of a Topo graph.
|
|
438
528
|
*
|
|
439
|
-
* Checks
|
|
529
|
+
* Checks composing references, example inputs, signal origins, activation
|
|
440
530
|
* source kinds, and output schema presence. Returns `Result.ok()` when no
|
|
441
531
|
* issues are found, or
|
|
442
532
|
* `Result.err(ValidationError)` with all issues in the error context.
|
|
443
533
|
*/
|
|
444
534
|
export const validateTopo = (topo: Topo): Result<void, ValidationError> => {
|
|
445
535
|
const issues = [
|
|
446
|
-
...
|
|
536
|
+
...checkComposes(topo.trails, topo),
|
|
447
537
|
...checkResources(topo.trails, topo),
|
|
448
538
|
...checkContourReferences(topo.contours, topo),
|
|
449
539
|
...checkExamples(topo.trails),
|
package/src/validation.ts
CHANGED
|
@@ -83,6 +83,15 @@ const getSchemaJsonSchemaOverride = (
|
|
|
83
83
|
return undefined;
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Whether a schema has a deterministic JSON-schema override projection (for
|
|
88
|
+
* example `blobRefSchema`, a `z.custom(...)` carrying the descriptor metadata).
|
|
89
|
+
* Such schemas project to a canonical descriptor regardless of their underlying
|
|
90
|
+
* Zod internals, so marker derivation can treat them as supported.
|
|
91
|
+
*/
|
|
92
|
+
export const schemaHasJsonSchemaOverride = (schema: z.ZodType): boolean =>
|
|
93
|
+
getSchemaJsonSchemaOverride(schema) !== undefined;
|
|
94
|
+
|
|
86
95
|
// ---------------------------------------------------------------------------
|
|
87
96
|
// Issue formatting
|
|
88
97
|
// ---------------------------------------------------------------------------
|
|
@@ -152,24 +161,76 @@ export const validateOutput = <T>(
|
|
|
152
161
|
const DYNAMIC_DEFAULT = Symbol('DYNAMIC_DEFAULT');
|
|
153
162
|
const defaultValueCache = new WeakMap<object, unknown>();
|
|
154
163
|
|
|
155
|
-
|
|
156
|
-
|
|
164
|
+
const defaultsMatch = (left: unknown, right: unknown): boolean => {
|
|
165
|
+
if (Object.is(left, right)) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const waitForClockAdvance = (): void => {
|
|
176
|
+
const wallStart = Date.now();
|
|
177
|
+
const monotonicStart = performance.now();
|
|
178
|
+
while (Date.now() === wallStart && performance.now() - monotonicStart < 4) {
|
|
179
|
+
// Zod hides default factories behind a getter. A bounded sync wait lets
|
|
180
|
+
// Date.now()-style factories reveal themselves without making the API async.
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const readDefaultWithDateNowOffset = (
|
|
185
|
+
def: Record<string, unknown>
|
|
186
|
+
): unknown => {
|
|
187
|
+
const originalDateNow = Date.now;
|
|
188
|
+
try {
|
|
189
|
+
Date.now = () => originalDateNow() + 86_400_000;
|
|
190
|
+
return def['defaultValue'];
|
|
191
|
+
} finally {
|
|
192
|
+
Date.now = originalDateNow;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Read a Zod v4 default getter and decide if it is stable.
|
|
198
|
+
*
|
|
199
|
+
* Uses Object.is for primitives and JSON.stringify for objects/arrays. A delayed
|
|
200
|
+
* third read catches default factories such as `() => Date.now()` that can return
|
|
201
|
+
* equal values for immediate back-to-back reads. A Date.now() probe catches
|
|
202
|
+
* coarser clock factories without requiring marker derivation to wait for the
|
|
203
|
+
* next second/day boundary.
|
|
204
|
+
*/
|
|
157
205
|
const resolveDefault = (def: Record<string, unknown>): unknown => {
|
|
158
206
|
try {
|
|
159
207
|
const a = def['defaultValue'];
|
|
160
208
|
const b = def['defaultValue'];
|
|
161
|
-
if (
|
|
162
|
-
return
|
|
209
|
+
if (!defaultsMatch(a, b)) {
|
|
210
|
+
return DYNAMIC_DEFAULT;
|
|
211
|
+
}
|
|
212
|
+
waitForClockAdvance();
|
|
213
|
+
const c = def['defaultValue'];
|
|
214
|
+
if (!defaultsMatch(a, c)) {
|
|
215
|
+
return DYNAMIC_DEFAULT;
|
|
163
216
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return JSON.stringify(a) === JSON.stringify(b) ? a : DYNAMIC_DEFAULT;
|
|
217
|
+
const d = readDefaultWithDateNowOffset(def);
|
|
218
|
+
return defaultsMatch(a, d) ? a : DYNAMIC_DEFAULT;
|
|
167
219
|
} catch {
|
|
168
220
|
// BigInt, circular refs, or other non-serializable defaults
|
|
169
221
|
return DYNAMIC_DEFAULT;
|
|
170
222
|
}
|
|
171
223
|
};
|
|
172
224
|
|
|
225
|
+
export const zodDefaultValueIsDynamic = (
|
|
226
|
+
def: Record<string, unknown>
|
|
227
|
+
): boolean => {
|
|
228
|
+
if (!defaultValueCache.has(def)) {
|
|
229
|
+
defaultValueCache.set(def, resolveDefault(def));
|
|
230
|
+
}
|
|
231
|
+
return defaultValueCache.get(def) === DYNAMIC_DEFAULT;
|
|
232
|
+
};
|
|
233
|
+
|
|
173
234
|
/**
|
|
174
235
|
* Convert common Zod types to a JSON Schema object.
|
|
175
236
|
*
|
|
@@ -223,9 +284,7 @@ export const zodToJsonSchema: JsonSchemaConverter = (
|
|
|
223
284
|
default: (value) => {
|
|
224
285
|
const inner = value._zod.def['innerType'] as unknown as z.ZodType;
|
|
225
286
|
const innerSchema = zodToJsonSchema(inner);
|
|
226
|
-
|
|
227
|
-
defaultValueCache.set(value._zod.def, resolveDefault(value._zod.def));
|
|
228
|
-
}
|
|
287
|
+
zodDefaultValueIsDynamic(value._zod.def);
|
|
229
288
|
const cached = defaultValueCache.get(value._zod.def);
|
|
230
289
|
if (cached !== DYNAMIC_DEFAULT) {
|
|
231
290
|
innerSchema['default'] = cached;
|