@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/type-utils.ts CHANGED
@@ -3,7 +3,9 @@
3
3
  */
4
4
 
5
5
  import type { Result } from './result.js';
6
- import type { AnyTrail, Trail } from './trail.js';
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
- T extends Trail<infer I, any, any> ? I : never;
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
- T extends Trail<any, infer O, any> ? O : never;
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 cross-callable input type from a trail.
31
+ * Extract the compose-callable input type from a trail.
24
32
  *
25
- * When a trail declares `crossInput`, callers via `ctx.cross()` must pass
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 `crossInput` exists.
36
+ * site. Falls back to plain `TrailInput<T>` when no `composeInput` exists.
29
37
  */
30
- export type CrossInput<T extends AnyTrail> =
31
- T extends Trail<infer I, any, infer CI>
38
+ export type ComposeInput<T extends AnyTrail> =
39
+ NonNullable<T['composeInput']> extends z.ZodType<infer CI>
32
40
  ? [CI] extends [never]
33
- ? I
34
- : I & CI
35
- : never;
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 { CrossInput, TrailOutput } from './type-utils.js';
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 CrossBatchCall<TTarget extends AnyTrail | string = AnyTrail | string> =
35
+ type ComposeBatchCall<TTarget extends AnyTrail | string = AnyTrail | string> =
35
36
  TTarget extends AnyTrail
36
- ? readonly [trail: TTarget, input: CrossInput<TTarget>]
37
+ ? readonly [trail: TTarget, input: ComposeInput<TTarget>]
37
38
  : readonly [id: string, input: unknown];
38
39
 
39
- type CrossBatchResult<TTarget extends AnyTrail | string> =
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 CrossBatchResults<TCalls extends readonly CrossBatchCall[]> = {
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
- ? CrossBatchResult<TTarget>
51
+ ? ComposeBatchResult<TTarget>
51
52
  : never
52
53
  : never;
53
54
  };
54
55
 
55
- /** Runtime options for batch `ctx.cross([...])` calls. */
56
- export interface CrossBatchOptions {
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: TrailContext
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.cross(showGist, { id })` — the compiler
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.cross('gist.show', { id })`
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.cross([[showGist, { id }], ['audit.log', payload]])`
86
- * — executes every crossing concurrently and resolves once all results are
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 CrossFn {
92
- <const TCalls extends readonly CrossBatchCall[]>(
103
+ export interface ComposeFn {
104
+ <const TCalls extends readonly ComposeBatchCall[]>(
93
105
  calls: TCalls,
94
- options?: CrossBatchOptions
95
- ): Promise<CrossBatchResults<TCalls>>;
106
+ options?: ComposeBatchOptions
107
+ ): Promise<ComposeBatchResults<TCalls>>;
96
108
  <T extends AnyTrail>(
97
109
  trail: T,
98
- input: CrossInput<T>
110
+ input: ComposeInput<T>,
111
+ options?: ComposeOptions
99
112
  ): Promise<Result<TrailOutput<T>, Error>>;
100
- <O = unknown>(id: string, input: unknown): Promise<Result<O, Error>>;
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 cross?: CrossFn | undefined;
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
- 'cross-cycle',
10
- 'cross-exists',
11
- 'no-self-cross',
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',
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Structural validation for a Topo graph.
3
3
  *
4
- * Checks trail crossing references, example input validity, signal origin
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 crossings. */
54
- const buildCrossGraph = (
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, t] of trails) {
62
- if (t.crosses.length > 0) {
63
- graph.set(id, t.crosses);
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 crossing graph via DFS. */
74
- const detectCrossCycles = (
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 } = buildCrossGraph(trails);
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: 'cross-cycle',
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 checkCrosses = (
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 crossedId of trail.crosses) {
116
- if (crossedId === id) {
134
+ for (const composedId of trail.composes) {
135
+ if (composedId === id) {
117
136
  issues.push({
118
- message: `Trail crosses itself`,
119
- rule: 'no-self-cross',
137
+ message: `Trail composes itself`,
138
+ rule: 'no-self-compose',
120
139
  trailId: id,
121
140
  });
122
- } else if (!topo.has(crossedId) && !isDraftId(crossedId)) {
141
+ } else if (!topo.has(composedId) && !isDraftId(composedId)) {
123
142
  issues.push({
124
- message: `Crosses "${crossedId}" which is not in the topo`,
125
- rule: 'cross-exists',
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(...detectCrossCycles(trails));
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: `Example "${example.name}" input does not parse against schema`,
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: `Example "${example.name}" has expected output but trail has no output schema`,
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 (!trail.examples) {
193
- continue;
194
- }
195
- for (const example of trail.examples) {
196
- issues.push(...checkOneExample(id, example, trail.input, !!trail.output));
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 crossing references, example inputs, signal origins, activation
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
- ...checkCrosses(topo.trails, topo),
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
- /** Read a Zod v4 default getter twice and decide if it's stable.
156
- * Uses Object.is for primitives and JSON.stringify for objects/arrays. */
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 (Object.is(a, b)) {
162
- return a;
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
- // Object/array defaults produce new references each call but may
165
- // still be structurally identical (e.g. `() => ({ key: 'val' })`).
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
- if (!defaultValueCache.has(value._zod.def)) {
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;