@amritk/adapters
Advanced tools
@@ -9,4 +9,5 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12'; | ||
| * pass that representation straight through — it accurately reflects what Effect | ||
| * expects on the wire — only stripping the dialect marker. | ||
| * expects on the wire — only stripping the dialect marker. A top-level | ||
| * `BigIntFromSelf` / `DateFromSelf` is rescued into an `x-mjst` hint. | ||
| */ | ||
| export declare const effectToJsonSchema: (source: unknown) => Promise<JSONSchema>; |
@@ -0,1 +1,2 @@ | ||
| import { MJST_EXTENSION_KEY } from '@amritk/helpers/mjst-extension'; | ||
| /** | ||
@@ -20,3 +21,27 @@ * Resolves `effect`'s `JSONSchema.make` from a runtime import, throwing a clear | ||
| }; | ||
| // The annotation key Effect stamps an identifier under (e.g. `"DateFromSelf"`). | ||
| const IDENTIFIER_ANNOTATION = Symbol.for('effect/annotation/Identifier'); | ||
| /** Reads `node.ast`, the AST that backs every Effect `Schema`. */ | ||
| const astOf = (source) => { | ||
| const ast = source?.ast; | ||
| return ast && typeof ast === 'object' ? ast : undefined; | ||
| }; | ||
| /** | ||
| * Effect models `bigint` and a runtime `Date` as types with no JSON Schema | ||
| * representation, so `JSONSchema.make` throws on `Schema.BigIntFromSelf` / | ||
| * `Schema.DateFromSelf`. To stay consistent with the Zod, Valibot, and TypeBox | ||
| * adapters — which map those to the shared `x-mjst` hint — we rescue them here: | ||
| * `bigint` (`BigIntKeyword`) → `primitive: 'bigint'`, and the `DateFromSelf` | ||
| * declaration → `instanceOf: 'Date'`. (`Schema.Date` / `Schema.BigInt` decode | ||
| * from a string and already convert to a `string` schema, so they are untouched.) | ||
| */ | ||
| const rescueXMjst = (ast) => { | ||
| if (ast._tag === 'BigIntKeyword') | ||
| return { [MJST_EXTENSION_KEY]: { primitive: 'bigint' } }; | ||
| if (ast._tag === 'Declaration' && ast.annotations?.[IDENTIFIER_ANNOTATION] === 'DateFromSelf') { | ||
| return { [MJST_EXTENSION_KEY]: { instanceOf: 'Date' } }; | ||
| } | ||
| return undefined; | ||
| }; | ||
| /** | ||
| * Converts an Effect `Schema` into a JSON Schema via `JSONSchema.make`. | ||
@@ -28,3 +53,4 @@ * | ||
| * pass that representation straight through — it accurately reflects what Effect | ||
| * expects on the wire — only stripping the dialect marker. | ||
| * expects on the wire — only stripping the dialect marker. A top-level | ||
| * `BigIntFromSelf` / `DateFromSelf` is rescued into an `x-mjst` hint. | ||
| */ | ||
@@ -37,2 +63,8 @@ export const effectToJsonSchema = async (source) => { | ||
| } | ||
| const ast = astOf(source); | ||
| if (ast) { | ||
| const rescued = rescueXMjst(ast); | ||
| if (rescued) | ||
| return rescued; | ||
| } | ||
| const make = await loadMake(); | ||
@@ -44,3 +76,8 @@ let json; | ||
| catch (error) { | ||
| throw new Error(`Effect adapter failed to convert the schema. Is it a valid Effect Schema?\n${String(error)}`); | ||
| // `make` throws on an unrepresentable type with no `jsonSchema` annotation — | ||
| // most often a nested `BigIntFromSelf` / `DateFromSelf`. Point the user at the | ||
| // fix rather than surfacing Effect's opaque "Missing annotation" message. | ||
| throw new Error(`Effect adapter failed to convert the schema. A nested bigint or runtime Date (BigIntFromSelf / DateFromSelf) ` + | ||
| `has no JSON Schema representation — use the string-encoded Schema.BigInt / Schema.Date, or add a ` + | ||
| `\`jsonSchema\` annotation to that field.\n${String(error)}`); | ||
| } | ||
@@ -47,0 +84,0 @@ delete json['$schema']; |
| import type { JSONSchema } from 'json-schema-typed/draft-2020-12'; | ||
| /** | ||
| * Converts a Zod schema into a Draft 2020-12 JSON Schema using Zod 4's native | ||
| * `toJSONSchema`. | ||
| * | ||
| * Zod's `z.date()` has no JSON Schema representation and would otherwise throw. | ||
| * We pass `unrepresentable: 'any'` so conversion never fails on it, then use the | ||
| * `override` hook to rewrite date schemas into an `x-mjst` instanceOf hint — the | ||
| * same extension TypeBox dates use — so generated types and runtime checks treat | ||
| * them as `Date`. | ||
| */ | ||
| export declare const zodToJsonSchema: (source: unknown) => Promise<JSONSchema>; |
@@ -35,2 +35,93 @@ import { MJST_EXTENSION_KEY } from '@amritk/helpers/mjst-extension'; | ||
| */ | ||
| /** | ||
| * Zod 4's `toJSONSchema` emits a fixed tuple as a bare `prefixItems` array with | ||
| * no length bound, so the result accepts a too-short array (positions past the | ||
| * end are simply unconstrained) and a too-long one (nothing forbids extra | ||
| * items). A Zod tuple requires exactly its fixed elements, so we restore that: | ||
| * `minItems` forces the fixed elements to be present, and — when the tuple has | ||
| * no `.rest(...)` (no `items`) — `items: false` forbids extras. Applied to every | ||
| * `prefixItems` node in the tree. Existing tighter bounds are never loosened. | ||
| */ | ||
| const enforceTupleLength = (node) => { | ||
| if (node === null || typeof node !== 'object') | ||
| return; | ||
| if (Array.isArray(node)) { | ||
| for (const item of node) | ||
| enforceTupleLength(item); | ||
| return; | ||
| } | ||
| const obj = node; | ||
| if (Array.isArray(obj['prefixItems'])) { | ||
| const fixed = obj['prefixItems'].length; | ||
| const min = typeof obj['minItems'] === 'number' ? obj['minItems'] : 0; | ||
| if (min < fixed) | ||
| obj['minItems'] = fixed; | ||
| // No `items` keyword means no rest element: the array may not exceed the | ||
| // fixed tuple, so forbid additional items. | ||
| if (!('items' in obj)) | ||
| obj['items'] = false; | ||
| } | ||
| for (const value of Object.values(obj)) | ||
| enforceTupleLength(value); | ||
| }; | ||
| // Keys a branch may carry and still be treated as a plain "closed object" that | ||
| // is safe to merge. Anything else (patternProperties, a nested allOf, $ref, a | ||
| // conditional, …) makes the merge unsafe, so we leave such an allOf alone. | ||
| const CLOSED_OBJECT_KEYS = new Set(['type', 'properties', 'required', 'additionalProperties']); | ||
| const isClosedObject = (node) => { | ||
| if (node === null || typeof node !== 'object' || Array.isArray(node)) | ||
| return false; | ||
| const obj = node; | ||
| if (obj['type'] !== 'object' || obj['additionalProperties'] !== false) | ||
| return false; | ||
| return Object.keys(obj).every((key) => CLOSED_OBJECT_KEYS.has(key)); | ||
| }; | ||
| /** | ||
| * Zod emits an intersection of objects as an `allOf` where every branch carries | ||
| * `additionalProperties: false`. That is unsatisfiable — each branch rejects the | ||
| * keys the others contribute — even though the Zod intersection accepts the | ||
| * combined object. When every `allOf` branch is a closed object we merge them | ||
| * into one: properties are unioned (a key in several branches becomes an `allOf` | ||
| * of its schemas), `required` is unioned, and `additionalProperties: false` is | ||
| * kept over the combined key set. `allOf`s with a non-object branch (e.g. two | ||
| * refined strings) are left untouched — those already combine correctly. | ||
| */ | ||
| const mergeClosedObjectAllOf = (node) => { | ||
| if (node === null || typeof node !== 'object') | ||
| return node; | ||
| if (Array.isArray(node)) | ||
| return node.map(mergeClosedObjectAllOf); | ||
| const obj = node; | ||
| const out = {}; | ||
| for (const [key, value] of Object.entries(obj)) | ||
| out[key] = mergeClosedObjectAllOf(value); | ||
| const allOf = out['allOf']; | ||
| if (Array.isArray(allOf) && allOf.length > 1 && allOf.every(isClosedObject)) { | ||
| const properties = {}; | ||
| const required = new Set(); | ||
| for (const branch of allOf) { | ||
| const props = (branch['properties'] ?? {}); | ||
| for (const [prop, schema] of Object.entries(props)) { | ||
| const bucket = properties[prop]; | ||
| if (bucket) | ||
| bucket.push(schema); | ||
| else | ||
| properties[prop] = [schema]; | ||
| } | ||
| for (const r of (branch['required'] ?? [])) | ||
| required.add(r); | ||
| } | ||
| const mergedProps = {}; | ||
| for (const [prop, schemas] of Object.entries(properties)) { | ||
| mergedProps[prop] = schemas.length === 1 ? schemas[0] : { allOf: schemas }; | ||
| } | ||
| delete out['allOf']; | ||
| out['type'] = 'object'; | ||
| out['properties'] = mergedProps; | ||
| if (required.size > 0) | ||
| out['required'] = [...required]; | ||
| out['additionalProperties'] = false; | ||
| } | ||
| return out; | ||
| }; | ||
| export const zodToJsonSchema = async (source) => { | ||
@@ -77,3 +168,6 @@ if (typeof source !== 'object' || source === null) { | ||
| delete json['$schema']; | ||
| return json; | ||
| // Zod under-constrains fixed tuples (bare `prefixItems`); restore their length. | ||
| enforceTupleLength(json); | ||
| // Collapse an unsatisfiable `allOf` of closed objects (object intersections). | ||
| return mergeClosedObjectAllOf(json); | ||
| }; |
+1
-1
| { | ||
| "name": "@amritk/adapters", | ||
| "version": "0.2.10", | ||
| "version": "0.2.11", | ||
| "description": "Convert schemas from external libraries (TypeBox, Zod, ...) into JSON Schema for mjst.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
25937
29.63%511
31.36%