@oh-my-pi/omptype 17.2.7 → 17.2.8

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.
@@ -3,17 +3,36 @@ import type { IR, PropIR } from "./ir";
3
3
  export interface JsonSchemaOptions {
4
4
  description?: string;
5
5
  target?: string;
6
- dialect?: string;
6
+ dialect?: string | null;
7
+ /**
8
+ * Which side of morphs and defaults to describe:
9
+ * - `'input'` — accepted payloads: morphs emit their input shape, defaulted
10
+ * properties are optional (with `default` annotations).
11
+ * - `'output'` — produced values: morphs emit their output shape, defaulted
12
+ * properties are required (always present after validation).
13
+ * Unset keeps the hybrid legacy behavior (morph output, defaults optional).
14
+ */
15
+ io?: "input" | "output";
7
16
  fallback?: (context: { base: Record<string, unknown> }) => unknown;
8
17
  }
9
18
 
10
19
  type JsonSchema = Record<string, unknown>;
11
20
 
21
+ interface EmitCtx {
22
+ options?: JsonSchemaOptions;
23
+ /** Emitted alias definitions, attached as `$defs` on the root schema. */
24
+ defs: Map<string, JsonSchema>;
25
+ /** Alias resolver identity → assigned `$defs` name (cycle-safe). */
26
+ refs: Map<() => IR, string>;
27
+ }
28
+
12
29
  /** Emit the requested JSON Schema dialect represented by an IR tree. */
13
30
  export function irToJsonSchema(ir: IR, options?: JsonSchemaOptions): JsonSchema {
14
- let schema = emit(ir, options);
31
+ const ctx: EmitCtx = { options, defs: new Map(), refs: new Map() };
32
+ let schema = emit(ir, ctx);
33
+ if (ctx.defs.size > 0) schema.$defs = Object.fromEntries(ctx.defs);
15
34
  if (options?.target === "draft-07") schema = toDraft7(schema);
16
- const dialect = options?.dialect ?? dialectFor(options?.target);
35
+ const dialect = options?.dialect === null ? undefined : (options?.dialect ?? dialectFor(options?.target));
17
36
  if (dialect !== undefined) schema.$schema = dialect;
18
37
  if (options?.description !== undefined) schema.description = options.description;
19
38
  return schema;
@@ -22,11 +41,11 @@ export function irToJsonSchema(ir: IR, options?: JsonSchemaOptions): JsonSchema
22
41
  function dialectFor(target: string | undefined): string | undefined {
23
42
  if (target === "draft-2020-12") return "https://json-schema.org/draft/2020-12/schema";
24
43
  if (target === "draft-07") return "http://json-schema.org/draft-07/schema#";
25
- return target?.startsWith("http://") || target?.startsWith("https://") ? target : undefined;
44
+ return target !== undefined && (target.startsWith("http://") || target.startsWith("https://")) ? target : undefined;
26
45
  }
27
46
 
28
- function fallback(schema: JsonSchema, options?: JsonSchemaOptions): JsonSchema {
29
- const replacement = options?.fallback?.({ base: schema });
47
+ function fallback(schema: JsonSchema, ctx: EmitCtx): JsonSchema {
48
+ const replacement = ctx.options?.fallback?.({ base: schema });
30
49
  if (replacement === true) return {};
31
50
  if (replacement === false) return { not: {} };
32
51
  return typeof replacement === "object" && replacement !== null ? (replacement as JsonSchema) : schema;
@@ -36,7 +55,11 @@ function toDraft7(schema: JsonSchema): JsonSchema {
36
55
  const converted: JsonSchema = {};
37
56
  for (const key in schema) {
38
57
  const value = schema[key];
39
- if (key === "prefixItems" && Array.isArray(value)) {
58
+ if (key === "$ref" && typeof value === "string") {
59
+ converted.$ref = value.replace("#/$defs/", "#/definitions/");
60
+ } else if (key === "$defs") {
61
+ converted.definitions = toDraft7(value as JsonSchema);
62
+ } else if (key === "prefixItems" && Array.isArray(value)) {
40
63
  converted.items = value.map(item =>
41
64
  typeof item === "object" && item !== null ? toDraft7(item as JsonSchema) : item,
42
65
  );
@@ -56,14 +79,14 @@ function toDraft7(schema: JsonSchema): JsonSchema {
56
79
  return converted;
57
80
  }
58
81
 
59
- function emit(ir: IR, options?: JsonSchemaOptions): JsonSchema {
82
+ function emit(ir: IR, ctx: EmitCtx): JsonSchema {
60
83
  let schema: JsonSchema;
61
84
  switch (ir.k) {
62
85
  case "unknown":
63
86
  schema = {};
64
87
  break;
65
88
  case "undefined":
66
- schema = fallback({}, options);
89
+ schema = fallback({}, ctx);
67
90
  break;
68
91
  case "null":
69
92
  schema = { type: "null" };
@@ -75,7 +98,7 @@ function emit(ir: IR, options?: JsonSchemaOptions): JsonSchema {
75
98
  schema = { type: "integer" };
76
99
  break;
77
100
  case "symbol":
78
- schema = fallback({}, options);
101
+ schema = fallback({}, ctx);
79
102
  break;
80
103
  case "never":
81
104
  schema = { not: {} };
@@ -93,19 +116,19 @@ function emit(ir: IR, options?: JsonSchemaOptions): JsonSchema {
93
116
  schema = emitLiteral(ir.v);
94
117
  break;
95
118
  case "union":
96
- schema = emitUnion(ir.members, options);
119
+ schema = emitUnion(ir.members, ctx);
97
120
  break;
98
121
  case "intersection":
99
- schema = { allOf: ir.members.map(member => emit(member, options)) };
122
+ schema = { allOf: ir.members.map(member => emit(member, ctx)) };
100
123
  break;
101
124
  case "array":
102
- schema = { type: "array", items: emit(ir.el, options) };
125
+ schema = { type: "array", items: emit(ir.el, ctx) };
103
126
  if (ir.min !== undefined) schema.minItems = ir.min;
104
127
  if (ir.max !== undefined) schema.maxItems = ir.max;
105
128
  break;
106
129
  case "tuple": {
107
130
  const prefixItems = ir.prefix.map(item => {
108
- const itemSchema = emit(item.val, options);
131
+ const itemSchema = emit(item.val, ctx);
109
132
  if (item.hasDefault) {
110
133
  itemSchema.default = item.defFactory && typeof item.def === "function" ? item.def() : item.def;
111
134
  }
@@ -117,35 +140,57 @@ function emit(ir: IR, options?: JsonSchemaOptions): JsonSchema {
117
140
  );
118
141
  schema = { type: "array", prefixItems, minItems: required };
119
142
  if (ir.variadic === undefined) {
120
- schema.maxItems = ir.prefix.length + ir.postfix.length;
121
143
  schema.items = false;
122
144
  } else {
123
- schema.items = emit(ir.variadic, options);
145
+ schema.items = emit(ir.variadic, ctx);
124
146
  }
125
147
  break;
126
148
  }
127
149
  case "object":
128
- schema = emitObject(ir.props, ir.index, ir.extras, options);
150
+ schema = emitObject(ir.props, ir.index, ir.extras, ctx);
129
151
  break;
130
152
  case "instance":
131
- schema = ir.ctor === Date ? { type: "string", format: "date-time" } : fallback({ type: "object" }, options);
153
+ schema = ir.ctor === Date ? { type: "string", format: "date-time" } : fallback({ type: "object" }, ctx);
132
154
  break;
133
155
  case "refine":
134
- schema = emit(ir.base, options);
156
+ schema = emit(ir.base, ctx);
135
157
  if (ir.json !== undefined) Object.assign(schema, ir.json);
136
158
  break;
137
159
  case "morph":
138
- schema = fallback(emit(ir.out ?? ir.input, options), options);
160
+ schema = ctx.options?.io === "input" ? emit(ir.input, ctx) : fallback(emit(ir.out ?? ir.input, ctx), ctx);
139
161
  break;
140
- case "alias":
141
- schema = emit(ir.resolve(), options);
162
+ case "alias": {
163
+ const known = ctx.refs.get(ir.resolve);
164
+ if (known !== undefined) {
165
+ schema = { $ref: `#/$defs/${known}` };
166
+ break;
167
+ }
168
+ // Register before lowering so cyclic aliases resolve to the same $ref
169
+ // instead of recursing forever.
170
+ let name = ir.name.replace(/[^\w.-]/g, "_");
171
+ while ([...ctx.refs.values()].includes(name)) name = `${name}_`;
172
+ ctx.refs.set(ir.resolve, name);
173
+ ctx.defs.set(name, emit(ir.resolve(), ctx));
174
+ schema = { $ref: `#/$defs/${name}` };
142
175
  break;
176
+ }
143
177
  case "sub":
144
- schema = ir.schema.hasSteps ? fallback(emit(ir.schema.ir, options), options) : emit(ir.schema.ir, options);
145
- if (ir.schema.description !== undefined) schema.description = ir.schema.description;
178
+ if (ctx.options?.io === "output" && ir.schema.opaqueOutput) {
179
+ schema = {};
180
+ } else if (ctx.options?.io === "output" && ir.schema.stepOut !== undefined) {
181
+ schema = emit(ir.schema.stepOut, ctx);
182
+ } else if (ctx.options?.io === "input") {
183
+ schema = emit(ir.schema.ir, ctx);
184
+ } else {
185
+ schema = ir.schema.hasSteps ? fallback(emit(ir.schema.ir, ctx), ctx) : emit(ir.schema.ir, ctx);
186
+ }
187
+ if (ir.schema.ir.desc !== undefined) schema.description = ir.schema.ir.desc;
188
+ else if (ir.schema.description !== undefined && ir.descAuto !== true) {
189
+ schema.description = ir.schema.description;
190
+ }
146
191
  break;
147
192
  }
148
- if (ir.desc !== undefined) schema.description = ir.desc;
193
+ if (ir.desc !== undefined && ir.descAuto !== true) schema.description = ir.desc;
149
194
  return schema;
150
195
  }
151
196
 
@@ -184,10 +229,10 @@ function emitLiteral(value: unknown): JsonSchema {
184
229
  }
185
230
  }
186
231
 
187
- function emitUnion(members: IR[], options?: JsonSchemaOptions): JsonSchema {
232
+ function emitUnion(members: IR[], ctx: EmitCtx): JsonSchema {
188
233
  const defined = members.filter(member => member.k !== "undefined");
189
234
  if (defined.length === 0) return {};
190
- if (defined.length === 1) return emit(defined[0], options);
235
+ if (defined.length === 1) return emit(defined[0], ctx);
191
236
  if (defined.every(member => member.k === "lit" && isJsonValue(member.v))) {
192
237
  const values = defined.map(member => (member as Extract<IR, { k: "lit" }>).v);
193
238
  const schema: JsonSchema = { enum: values };
@@ -195,7 +240,7 @@ function emitUnion(members: IR[], options?: JsonSchemaOptions): JsonSchema {
195
240
  if (scalarType !== undefined) schema.type = scalarType;
196
241
  return schema;
197
242
  }
198
- return { anyOf: defined.map(member => emit(member, options)) };
243
+ return { anyOf: defined.map(member => emit(member, ctx)) };
199
244
  }
200
245
 
201
246
  function homogeneousScalarType(values: unknown[]): string | undefined {
@@ -223,24 +268,27 @@ function emitObject(
223
268
  props: PropIR[],
224
269
  index: IR | undefined,
225
270
  extras: "keep" | "reject" | "delete",
226
- options?: JsonSchemaOptions,
271
+ ctx: EmitCtx,
227
272
  ): JsonSchema {
228
273
  const properties: Record<string, unknown> = {};
229
274
  const required: string[] = [];
275
+ const filled = (prop: PropIR): boolean => !prop.opt && (ctx.options?.io === "output" || !prop.hasDefault);
230
276
  // ArkType emits required properties first (each group in declaration
231
277
  // order); downstream wire consumers rely on that stable ordering.
232
- const ordered = [...props.filter(p => !p.opt && !p.hasDefault), ...props.filter(p => p.opt || p.hasDefault)];
278
+ const ordered = [...props.filter(filled), ...props.filter(prop => !filled(prop))];
233
279
  for (const prop of ordered) {
234
- const propertySchema = emit(prop.val, options);
280
+ if (typeof prop.key === "symbol") throw new TypeError("Cannot convert a symbol to a string");
281
+ const key = String(prop.key);
282
+ const propertySchema = emit(prop.val, ctx);
235
283
  if (prop.hasDefault) {
236
284
  propertySchema.default = prop.defFactory ? (prop.def as () => unknown)() : prop.def;
237
285
  }
238
- properties[prop.key] = propertySchema;
239
- if (!prop.opt && !prop.hasDefault) required.push(prop.key);
286
+ properties[key] = propertySchema;
287
+ if (filled(prop)) required.push(key);
240
288
  }
241
289
  const schema: JsonSchema = { type: "object", properties };
242
290
  if (required.length > 0) schema.required = required;
243
- if (index !== undefined) schema.additionalProperties = emit(index, options);
291
+ if (index !== undefined) schema.additionalProperties = emit(index, ctx);
244
292
  else if (extras === "reject") schema.additionalProperties = false;
245
293
  return schema;
246
294
  }
package/src/keywords.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { IR, MorphContext } from "./ir";
2
2
 
3
- const NUMERIC = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
3
+ const NUMERIC = /^(?:(?!^-0\.?0*$)(?:-?(?:(?:0|[1-9]\d*)(?:\.\d+)?)|\.\d+?))$/;
4
4
  const INTEGER = /^[+-]?(?:0|[1-9]\d*)$/;
5
5
  const EMAIL = /^[\w%+.-]+@[\d.A-Za-z-]+\.[A-Za-z]{2,}$/;
6
6
  const SEMVER =
@@ -106,8 +106,118 @@ function isLuhnValid(input: string): boolean {
106
106
  return value.length > 0 && sum % 10 === 0;
107
107
  }
108
108
 
109
+ function finiteNumber(expected = "a number"): IR {
110
+ return {
111
+ k: "refine",
112
+ base: { k: "unknown" },
113
+ pred: value => typeof value === "number" && !Number.isNaN(value),
114
+ expected,
115
+ };
116
+ }
117
+
118
+ function jsonObject(): IR {
119
+ let value: IR;
120
+ const resolveValue = (): IR => value;
121
+ const object: IR = {
122
+ k: "object",
123
+ props: [],
124
+ index: { k: "alias", name: "$jsonValue", resolve: resolveValue },
125
+ extras: "keep",
126
+ };
127
+ const array: IR = {
128
+ k: "array",
129
+ el: { k: "alias", name: "$jsonValue", resolve: resolveValue },
130
+ desc: "an object",
131
+ };
132
+ value = {
133
+ k: "union",
134
+ members: [
135
+ object,
136
+ array,
137
+ { k: "number" },
138
+ { k: "string" },
139
+ { k: "lit", v: false },
140
+ { k: "null" },
141
+ { k: "lit", v: true },
142
+ ],
143
+ };
144
+ return object;
145
+ }
146
+
147
+ function instance(ctor: new (...args: never[]) => object, name: string): IR {
148
+ return { k: "instance", ctor, expected: `${/^[AEIOU]/.test(name) ? "an" : "a"} ${name} instance` };
149
+ }
150
+
109
151
  const keywordFactories: Record<string, () => IR> = {
110
152
  string: () => ({ k: "string" }),
153
+ Key: () => ({ k: "union", members: [{ k: "string" }, { k: "symbol" }] }),
154
+ "unknown.any": () => ({ k: "unknown" }),
155
+ Array: () => ({ k: "array", el: { k: "unknown" } }),
156
+ Function: () => instance(Function as unknown as new (...args: never[]) => object, "Function"),
157
+ RegExp: () => instance(RegExp, "RegExp"),
158
+ File: () => instance(File, "File"),
159
+ Error: () => instance(Error, "Error"),
160
+ Set: () => instance(Set, "Set"),
161
+ Map: () => instance(Map, "Map"),
162
+ WeakSet: () => instance(WeakSet, "WeakSet"),
163
+ WeakMap: () => instance(WeakMap, "WeakMap"),
164
+ Promise: () => instance(Promise, "Promise"),
165
+ FormData: () => instance(FormData, "FormData"),
166
+ "FormData.parse": () => ({
167
+ k: "morph",
168
+ input: instance(FormData, "FormData"),
169
+ fn: value => {
170
+ const out: Record<string, Bun.FormDataEntryValue | Bun.FormDataEntryValue[]> = {};
171
+ for (const [key, entry] of (value as FormData).entries()) {
172
+ const current = out[key];
173
+ out[key] = current === undefined ? entry : Array.isArray(current) ? [...current, entry] : [current, entry];
174
+ }
175
+ return out;
176
+ },
177
+ out: { k: "object", props: [], index: { k: "unknown" }, extras: "keep" },
178
+ }),
179
+ "object.json": jsonObject,
180
+ "object.json.stringify": () => ({
181
+ k: "morph",
182
+ input: jsonObject(),
183
+ fn: value => JSON.stringify(value),
184
+ out: { k: "string" },
185
+ }),
186
+ "number.epoch": () => ({
187
+ k: "refine",
188
+ base: {
189
+ k: "refine",
190
+ base: {
191
+ k: "refine",
192
+ base: finiteNumber("a number representing a Unix timestamp"),
193
+ pred: value => Number.isInteger(value),
194
+ expected: "an integer representing a Unix timestamp",
195
+ },
196
+ pred: value => (value as number) >= -8_640_000_000_000_000,
197
+ expected: "a Unix timestamp after -8640000000000000",
198
+ },
199
+ pred: value => (value as number) <= 8_640_000_000_000_000,
200
+ expected: "a Unix timestamp before 8640000000000000",
201
+ }),
202
+ "number.safe": () => ({
203
+ k: "refine",
204
+ base: {
205
+ k: "refine",
206
+ base: finiteNumber(),
207
+ pred: value => (value as number) >= Number.MIN_SAFE_INTEGER,
208
+ expected: `at least ${Number.MIN_SAFE_INTEGER}`,
209
+ },
210
+ pred: value => (value as number) <= Number.MAX_SAFE_INTEGER,
211
+ expected: `at most ${Number.MAX_SAFE_INTEGER}`,
212
+ }),
213
+ "number.NaN": () => ({
214
+ k: "refine",
215
+ base: { k: "unknown" },
216
+ pred: Number.isNaN,
217
+ expected: "NaN",
218
+ }),
219
+ "number.Infinity": () => ({ k: "lit", v: Number.POSITIVE_INFINITY }),
220
+ "number.NegativeInfinity": () => ({ k: "lit", v: Number.NEGATIVE_INFINITY }),
111
221
  "string.alpha": () => pattern(/^[A-Za-z]*$/, "only letters"),
112
222
  "string.alphanumeric": () => pattern(/^[\dA-Za-z]*$/, "only letters and digits 0-9"),
113
223
  "string.hex": () => pattern(/^[\dA-Fa-f]+$/, "hex characters only"),