@@ -229,6 +229,12 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12';
229229 * cannot evaluate the fn, and deferring the preprocess spelling to the
230230 * validate(undefined) probe would mis-require async-refined preprocess fields
231231 * (the probe goes async and conservatively claims nothing).
232+ * - The converse trade for async-staged VALIDATING pipe OUT sides: a genuinely
233+ * tolerant `.default(5).pipe(z.number().min(1).refine(async () => true))` stays
234+ * advertised as required — the probe goes async, and claiming IN-side tolerance
235+ * structurally would wrongly drop `.default(0).pipe(z.number().min(1).refine(
236+ * async …))`. Neither structural direction is sound there, so the conservative
237+ * stay-required posture (byte-parity with the pre-#2464 emission) wins.
232238 * - Output schemas containing `.transform()`/`.pipe()`/`z.coerce` still advertise the
233239 * post-transform shape (`io: 'output'`) even though the server validates and ships
234240 * the raw pre-transform value — rewriting pipe nodes to their input side per-node
@@ -530,21 +536,25 @@ function rewriteOneOfToAnyOf(node: unknown, seen: Set<unknown> = new Set()): voi
530536const ZOD_REGISTRY_REF_PATTERN = / ^ # \/ \$ d e f s \/ [ ^ / ] + $ / ;
531537
532538/**
533- * Whether any reference keyword in the document carries a hand-authored value —
534- * anything but zod's own registry shapes (`#`, `#/$defs/<name>`). Zod's registry
535- * refs are root-base JSON Pointers that never resolve through a draft-04 `id`
536- * base, so stripping `id` around them is safe; every OTHER ref may depend on an
537- * `id` base on the cfworker engine (URI-form refs resolve through
538- * `schema.$id || schema.id` registration, and even a fragment pointer INSIDE an
539- * `id` resource resolves relative to that base). Position-aware like the guard
540- * walk.
539+ * Whether any reference keyword in the document carries a hand-authored value.
540+ * Hand-authored-ness is decided by value SHAPE and lexical CONTEXT together:
541+ * outside `id` resources, anything but zod's own registry shapes (`#`,
542+ * `#/$defs/<name>` — root-base JSON Pointers that never resolve through a
543+ * draft-04 `id` base) is hand-authored; INSIDE a draft-04 `id`-carrying
544+ * resource, EVERY ref counts — resolution there is base-relative on the
545+ * cfworker engine (`schema.$id || schema.id` registration), so even a
546+ * registry-shaped `$ref: '#'` addresses the resource, a spelling zod's emitter
547+ * never produces at that position. `$recursiveRef` counts regardless of value
548+ * or position — zod never emits the keyword at all.
541549 */
542550function hasHandAuthoredRefValues ( document : Record < string , unknown > ) : boolean {
543- return someSchemaNode ( document , record =>
551+ return someSchemaNode ( document , ( record , insideIdResource ) =>
544552 [ '$ref' , '$dynamicRef' , '$recursiveRef' ] . some ( refKey => {
545553 const value = record [ refKey ] ;
546554 if ( value === undefined ) return false ;
547- return typeof value !== 'string' || ( value !== '#' && ! ZOD_REGISTRY_REF_PATTERN . test ( value ) ) ;
555+ if ( refKey === '$recursiveRef' ) return true ;
556+ if ( typeof value !== 'string' || insideIdResource ) return true ;
557+ return value !== '#' && ! ZOD_REGISTRY_REF_PATTERN . test ( value ) ;
548558 } )
549559 ) ;
550560}
@@ -571,30 +581,37 @@ function stripLegacyIdKeywords(document: Record<string, unknown>): void {
571581 * keywords are descended into (data-valued `const`/`enum`/`default`/`examples`
572582 * and annotation values stay opaque), and schema-map VALUES are schemas while
573583 * their keys stay names — a property literally named `id` or `$ref` is user
574- * data, not a keyword. Stops at the first node where `predicate` returns true.
584+ * data, not a keyword. The predicate also receives whether the node sits
585+ * lexically inside (or at) a draft-04 `id`-carrying resource, where ref
586+ * resolution is base-relative. Stops at the first node where `predicate`
587+ * returns true.
575588 */
576- function someSchemaNode ( document : Record < string , unknown > , predicate : ( record : Record < string , unknown > ) => boolean ) : boolean {
577- const walk = ( node : unknown , seen : Set < unknown > ) : boolean => {
589+ function someSchemaNode (
590+ document : Record < string , unknown > ,
591+ predicate : ( record : Record < string , unknown > , insideIdResource : boolean ) => boolean
592+ ) : boolean {
593+ const walk = ( node : unknown , seen : Set < unknown > , insideIdResource : boolean ) : boolean => {
578594 if ( typeof node !== 'object' || node === null || seen . has ( node ) ) return false ;
579595 seen . add ( node ) ;
580- if ( Array . isArray ( node ) ) return node . some ( item => walk ( item , seen ) ) ;
596+ if ( Array . isArray ( node ) ) return node . some ( item => walk ( item , seen , insideIdResource ) ) ;
581597 const record = node as Record < string , unknown > ;
582- if ( predicate ( record ) ) return true ;
598+ const inIdResource = insideIdResource || typeof record . id === 'string' ;
599+ if ( predicate ( record , inIdResource ) ) return true ;
583600 for ( const [ key , value ] of Object . entries ( record ) ) {
584601 if ( ! SCHEMA_CARRYING_JSON_SCHEMA_KEYWORDS . has ( key ) ) continue ;
585602 if ( SCHEMA_MAP_JSON_SCHEMA_KEYWORDS . has ( key ) && typeof value === 'object' && value !== null && ! Array . isArray ( value ) ) {
586603 if ( seen . has ( value ) ) continue ;
587604 seen . add ( value ) ;
588605 for ( const subschema of Object . values ( value as Record < string , unknown > ) ) {
589- if ( walk ( subschema , seen ) ) return true ;
606+ if ( walk ( subschema , seen , inIdResource ) ) return true ;
590607 }
591608 continue ;
592609 }
593- if ( walk ( value , seen ) ) return true ;
610+ if ( walk ( value , seen , inIdResource ) ) return true ;
594611 }
595612 return false ;
596613 } ;
597- return walk ( document , new Set ( ) ) ;
614+ return walk ( document , new Set ( ) , false ) ;
598615}
599616
600617/**
@@ -630,11 +647,18 @@ function someSchemaNode(document: Record<string, unknown>, predicate: (record: R
630647 * break all the same.
631648 */
632649function hasHandAuthoredReferenceConstructs ( document : Record < string , unknown > ) : boolean {
633- const walk = ( node : unknown , isRoot : boolean , underPolarityBoundary : boolean , seen : Set < unknown > ) : boolean => {
650+ const walk = (
651+ node : unknown ,
652+ isRoot : boolean ,
653+ underPolarityBoundary : boolean ,
654+ insideIdResource : boolean ,
655+ seen : Set < unknown >
656+ ) : boolean => {
634657 if ( typeof node !== 'object' || node === null || seen . has ( node ) ) return false ;
635658 seen . add ( node ) ;
636- if ( Array . isArray ( node ) ) return node . some ( item => walk ( item , false , underPolarityBoundary , seen ) ) ;
659+ if ( Array . isArray ( node ) ) return node . some ( item => walk ( item , false , underPolarityBoundary , insideIdResource , seen ) ) ;
637660 const record = node as Record < string , unknown > ;
661+ const inIdResource = insideIdResource || typeof record . id === 'string' ;
638662 for ( const refKey of [ '$ref' , '$dynamicRef' ] as const ) {
639663 const value = record [ refKey ] ;
640664 if ( value === undefined ) continue ;
@@ -645,6 +669,11 @@ function hasHandAuthoredReferenceConstructs(document: Record<string, unknown>):
645669 // loosening as a tightening the rename walk's lexical polarity skip
646670 // cannot see through the ref indirection.
647671 if ( underPolarityBoundary ) return true ;
672+ // And they are loosen-safe only OUTSIDE draft-04 `id` resources: the
673+ // cfworker engine resolves refs base-relatively there, and zod never
674+ // emits a bare `#` inside an id-carrying entry — such refs are
675+ // hand-authored and observe the strip/loosening.
676+ if ( inIdResource ) return true ;
648677 if ( value !== '#' && ! ZOD_REGISTRY_REF_PATTERN . test ( value ) ) return true ;
649678 }
650679 if ( record . $anchor !== undefined || record . $dynamicAnchor !== undefined || record . $id !== undefined ) return true ;
@@ -670,15 +699,15 @@ function hasHandAuthoredReferenceConstructs(document: Record<string, unknown>):
670699 if ( seen . has ( value ) ) continue ;
671700 seen . add ( value ) ;
672701 for ( const subschema of Object . values ( value as Record < string , unknown > ) ) {
673- if ( walk ( subschema , false , childUnderBoundary , seen ) ) return true ;
702+ if ( walk ( subschema , false , childUnderBoundary , inIdResource , seen ) ) return true ;
674703 }
675704 continue ;
676705 }
677- if ( walk ( value , false , childUnderBoundary , seen ) ) return true ;
706+ if ( walk ( value , false , childUnderBoundary , inIdResource , seen ) ) return true ;
678707 }
679708 return false ;
680709 } ;
681- return walk ( document , true , false , new Set ( ) ) ;
710+ return walk ( document , true , false , false , new Set ( ) ) ;
682711}
683712
684713/**
@@ -893,7 +922,15 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet
893922 // would step past the very node granting tolerance (bare `.optional()` fields
894923 // are already excluded from `required` by zod's emitter, but one inside a pipe
895924 // — `z.string().optional().transform(async ...)` — is not).
896- if ( def . type === 'default' || def . type === 'prefault' || def . type === 'catch' || def . type === 'optional' ) return true ;
925+ if ( def . type === 'default' || def . type === 'catch' || def . type === 'optional' ) return true ;
926+ if ( def . type === 'prefault' ) {
927+ // UNLIKE `.default()`, `.prefault(v)` feeds v THROUGH the inner schema —
928+ // `z.number().min(1).prefault(0)` rejects a missing key. Filling-then-
929+ // revalidating is not filling: claim nothing and let the probe decide
930+ // (sync verdicts are correct both ways; an async-refined valid-prefault
931+ // field conservatively stays required, matching the documented posture).
932+ return false ;
933+ }
897934 if ( def . type === 'any' || def . type === 'unknown' || def . type === 'undefined' || def . type === 'void' ) return true ;
898935 if ( def . type === 'symbol' || def . type === 'function' ) return true ;
899936 if ( def . type === 'literal' && Array . isArray ( def . values ) && def . values . includes ( undefined ) ) return true ;
@@ -931,28 +968,84 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet
931968 return def . options . some ( option => hasStructuralMissingKeyTolerance ( option , path ) ) ;
932969 }
933970 if ( def . type === 'intersection' && def . left !== undefined && def . right !== undefined ) {
934- // EVERY-side semantics: `undefined` must parse through BOTH sides (each
935- // filling its default) for zod to merge the results.
936- return hasStructuralMissingKeyTolerance ( def . left , path ) && hasStructuralMissingKeyTolerance ( def . right , path ) ;
971+ // `undefined` must parse through BOTH sides AND the two filled results
972+ // must MERGE — zod throws 'Unmergable intersection' otherwise (two scalar
973+ // defaults with different values reject every payload omitting the key).
974+ // Merging is provable structurally only for the distinct-key
975+ // plain-object-defaults shape (the pinned async-refined spelling, where
976+ // the probe cannot be used); everything else defers to the probe.
977+ return intersectionSidesFillDisjointObjects ( def . left , def . right ) ;
978+ }
979+ if ( def . type === 'promise' ) {
980+ // zod 4's promise parse rejects `undefined` outright regardless of the
981+ // inner type — there is no undefined-tolerant z.promise spelling. Claim
982+ // nothing (the generic unwind below would wrongly grant the inner's
983+ // tolerance); `promise` stays in WRAPPER_ZOD_DEF_TYPES for the
984+ // root-TYPE-verdict walks, where transparency is correct.
985+ return false ;
937986 }
938987 if ( def . type === 'nonoptional' ) {
939988 // z.nonoptional() RE-FORBIDS undefined, so tolerance by ACCEPTANCE inside
940989 // it (an inner optional, any/unknown, undefined-valued literals) does NOT
941- // survive the wrapper — only tolerance by FILLING does (default/prefault/
942- // static catch replace undefined before nonoptional's check runs). The
990+ // survive the wrapper — only tolerance by FILLING does (default/static
991+ // catch replace undefined before nonoptional's check runs). The
943992 // structural walk cannot tell the two apart, so it claims nothing and the
944993 // validate(undefined) probe in fieldAcceptsMissingKey decides: it returns
945994 // issues for `.optional().nonoptional()` (stays required) and success for
946995 // `.default(1).nonoptional()` (stays droppable). The generic unwind below
947996 // would wrongly propagate acceptance-tolerance through the re-forbid.
948- return false ;
997+ // SERIALIZATION-drop tolerance is the exception: a symbol/function leaf
998+ // can never appear on the wire (JSON.stringify drops the key) no matter
999+ // what validation demands, and the probe cannot see that — it survives
1000+ // the re-forbid.
1001+ return hasSerializationDroppedLeaf ( def . innerType ) ;
9491002 }
9501003 if ( WRAPPER_ZOD_DEF_TYPES . has ( def . type ) && def . innerType !== undefined ) {
9511004 return hasStructuralMissingKeyTolerance ( def . innerType , path ) ;
9521005 }
9531006 return false ;
9541007}
9551008
1009+ /**
1010+ * Whether the field unwinds (through transparent wrappers) to a symbol- or
1011+ * function-typed leaf — values `JSON.stringify` drops from the payload entirely,
1012+ * so the key can never appear on the wire regardless of what validation demands.
1013+ * Used where VALIDATION-based tolerance must not propagate but
1014+ * SERIALIZATION-based tolerance still applies (the `nonoptional` re-forbid).
1015+ */
1016+ function hasSerializationDroppedLeaf ( field : unknown ) : boolean {
1017+ if ( typeof field !== 'object' || field === null ) return false ;
1018+ const def = ( field as { _zod ?: { def ?: { type ?: string ; innerType ?: unknown } } } ) . _zod ?. def ;
1019+ if ( def === undefined || typeof def . type !== 'string' ) return false ;
1020+ if ( def . type === 'symbol' || def . type === 'function' ) return true ;
1021+ if ( WRAPPER_ZOD_DEF_TYPES . has ( def . type ) && def . innerType !== undefined ) return hasSerializationDroppedLeaf ( def . innerType ) ;
1022+ return false ;
1023+ }
1024+
1025+ /**
1026+ * The one structurally-provable mergeable-intersection shape: BOTH sides are
1027+ * `.default()`s whose fill values are plain objects with disjoint key sets, so
1028+ * zod's merge of the two fills cannot throw. Function-form defaults
1029+ * (`.default(() => …)`) and every other spelling defer to the probe.
1030+ */
1031+ function intersectionSidesFillDisjointObjects ( left : unknown , right : unknown ) : boolean {
1032+ const leftFill = plainObjectDefaultFill ( left ) ;
1033+ if ( leftFill === undefined ) return false ;
1034+ const rightFill = plainObjectDefaultFill ( right ) ;
1035+ if ( rightFill === undefined ) return false ;
1036+ return Object . keys ( leftFill ) . every ( key => ! Object . hasOwn ( rightFill , key ) ) ;
1037+ }
1038+
1039+ /** The side's `.default()` fill value, when it is a plain (non-array) object. */
1040+ function plainObjectDefaultFill ( side : unknown ) : Record < string , unknown > | undefined {
1041+ if ( typeof side !== 'object' || side === null ) return undefined ;
1042+ const def = ( side as { _zod ?: { def ?: { type ?: string ; defaultValue ?: unknown } } } ) . _zod ?. def ;
1043+ if ( def ?. type !== 'default' ) return undefined ;
1044+ const fill = def . defaultValue ;
1045+ if ( typeof fill !== 'object' || fill === null || Array . isArray ( fill ) ) return undefined ;
1046+ return fill as Record < string , unknown > ;
1047+ }
1048+
9561049/** Options for {@linkcode standardSchemaToJsonSchema}. */
9571050export interface StandardSchemaToJsonSchemaOptions {
9581051 /**
0 commit comments