@@ -32,6 +32,8 @@ export interface JsonSchemaFragment {
3232 const ?: unknown ;
3333 /** Binary encoding of a string-typed value. Emitted on the MCP surface only — not a 3.0.3 keyword. */
3434 contentEncoding ?: string ;
35+ /** Emitted by the OpenAPI 3.0 projection for a genuine multi-type union; not authored directly. */
36+ oneOf ?: JsonSchemaFragment [ ] ;
3537}
3638
3739/**
@@ -75,6 +77,12 @@ export interface AttributeLike {
7577 assignCreatedTime ?: boolean ;
7678 assignUpdatedTime ?: boolean ;
7779 nullable ?: boolean ;
80+ /**
81+ * The source JSON-Schema type union, verbatim, when `static properties` declared one. `type` holds
82+ * the first non-null member so single-type consumers keep working; surfaces that can express a
83+ * union (MCP passes it through, OpenAPI 3.0 translates it to `oneOf`) read this instead.
84+ */
85+ types ?: readonly string [ ] ;
7886 elements ?: AttributeLike ;
7987 /** Sub-attributes of a nested object field (the same array form `Table.validate` iterates). */
8088 properties ?: AttributeLike [ ] ;
@@ -111,6 +119,10 @@ export function attributeToFragment(attr: AttributeLike): JsonSchemaFragment {
111119 } else if ( attr . type === 'array' && attr . elements ) {
112120 fragment . type = 'array' ;
113121 fragment . items = attributeToFragment ( attr . elements ) ;
122+ } else if ( attr . types ) {
123+ // A declared union round-trips verbatim; collapsing it to `attr.type` here would make the
124+ // canonical `Table.properties` disagree with what the author wrote.
125+ fragment . type = [ ...attr . types ] as JsonSchemaType [ ] ;
114126 } else {
115127 const jsonType = attr . type ? DATA_TYPES [ attr . type ] : undefined ;
116128 if ( jsonType ) fragment . type = jsonType ;
@@ -161,9 +173,11 @@ function fragmentToAttribute(name: string, fragment: JsonSchemaFragment): Attrib
161173 // than misleadingly reusing the array field's own name.
162174 attr . elements = fragmentToAttribute ( '' , fragment . items ) ;
163175 } else if ( Array . isArray ( fragment . type ) ) {
164- // JSON-Schema union type. Fold a `'null'` member into `nullable` (the OpenAPI-expressible form)
165- // and keep the remaining member. A single non-null member is the common `['T','null']` case; a
166- // genuine multi-type union isn't expressible on an attribute, so the first member is kept.
176+ // JSON-Schema union type. Keep the source union on `types` so surfaces that can express one
177+ // (MCP natively, OpenAPI 3.0 via `oneOf`) don't have to reconstruct it, and fold a `'null'`
178+ // member into `nullable` as well since that is the form OpenAPI needs. `type` carries the first
179+ // non-null member for the single-type consumers (validation, query coercion) that read it.
180+ attr . types = fragment . type ;
167181 const members = fragment . type . filter ( ( t ) => t !== 'null' ) ;
168182 if ( members . length !== fragment . type . length ) attr . nullable = true ;
169183 if ( members . length > 0 ) attr . type = members [ 0 ] ;
@@ -251,6 +265,17 @@ export function resolveDeclaredType(type: string | undefined, context?: string):
251265 return undefined ;
252266}
253267
268+ /**
269+ * The non-null members of an attribute's declared type union, but only when there is more than one —
270+ * `['string','null']` is nullability, not a union, and `type` already carries its single member.
271+ * Returns undefined when the attribute has no union to translate.
272+ */
273+ export function unionMembers ( attr : { types ?: readonly string [ ] } ) : string [ ] | undefined {
274+ if ( ! attr . types ) return undefined ;
275+ const members = attr . types . filter ( ( member ) => member !== 'null' ) ;
276+ return members . length > 1 ? members : undefined ;
277+ }
278+
254279/** Test hook: the unknown-type warning is once-per-process, which would leak across test cases. */
255280export function _resetUnknownTypeWarningsForTest ( ) : void {
256281 warnedUnknownTypes . clear ( ) ;
@@ -303,6 +328,14 @@ export function attributeToSchema(attr: AttributeLike, options: SchemaEmitOption
303328 const items = attributeToSchema ( attr . elements , childOptions ) ;
304329 if ( items ) fragment . items = items ;
305330 }
331+ } else if ( attr . types && options . dialect === 'json-schema' ) {
332+ // JSON Schema has type unions — emit the author's declaration as written.
333+ fragment . type = [ ...attr . types ] as JsonSchemaType [ ] ;
334+ } else if ( unionMembers ( attr ) ) {
335+ // 3.0.3 has neither type arrays nor a `null` type, so a genuine union becomes `oneOf`. Each
336+ // member still goes through the surface's own primitive mapping, so it is described exactly as
337+ // the same type would be on its own.
338+ fragment . oneOf = unionMembers ( attr ) . map ( ( member ) => options . mapPrimitive ( member , attr ) ) ;
306339 } else {
307340 // Copy the mapper's result field by field rather than merging it wholesale: the set of keys a
308341 // surface may contribute to a leaf schema is fixed, and spreading an arbitrary object here would
@@ -312,6 +345,8 @@ export function attributeToSchema(attr: AttributeLike, options: SchemaEmitOption
312345 if ( primitive . description !== undefined ) fragment . description = primitive . description ;
313346 if ( primitive . format !== undefined ) fragment . format = primitive . format ;
314347 if ( primitive . contentEncoding !== undefined ) fragment . contentEncoding = primitive . contentEncoding ;
348+ if ( primitive . nullable !== undefined ) fragment . nullable = primitive . nullable ;
349+ if ( primitive . enum !== undefined ) fragment . enum = primitive . enum ;
315350 }
316351
317352 if ( attr . nullable ) applyNullability ( fragment , options . dialect ) ;
@@ -347,9 +382,15 @@ export function attributeToSchema(attr: AttributeLike, options: SchemaEmitOption
347382}
348383
349384function applyNullability ( fragment : JsonSchemaFragment , dialect : SchemaDialect ) : void {
350- // Both dialects express nullability as a modification of `type`, so neither has anything to say
351- // about a fragment that never resolved one.
352- if ( ! ( 'type' in fragment ) || fragment . type === undefined ) return ;
385+ // Nullability qualifies a schema that says something; neither dialect has anything to add to a
386+ // fragment that resolved neither a `type` nor a `oneOf`.
387+ if ( ( ! ( 'type' in fragment ) || fragment . type === undefined ) && fragment . oneOf === undefined ) return ;
388+ if ( fragment . type === undefined ) {
389+ // A `oneOf` union. 3.0 takes `nullable` alongside it; JSON Schema takes a `null` branch.
390+ if ( dialect === 'openapi-3.0.3' ) fragment . nullable = true ;
391+ else fragment . oneOf = [ ...fragment . oneOf , { type : 'null' } ] ;
392+ return ;
393+ }
353394 if ( dialect === 'openapi-3.0.3' ) {
354395 // OpenAPI 3.0.3 has no union types; `nullable` is the spec-provided expression.
355396 fragment . nullable = true ;
0 commit comments