@nifrajs/schema 2.8.2 → 2.9.0

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/dist/adapter.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { StandardSchemaV1 } from "@nifrajs/core/schema";
2
- import type { Static, TSchema } from "@sinclair/typebox";
2
+ import { type Static, type TSchema } from "@sinclair/typebox";
3
3
  /**
4
4
  * A `t` schema. It is a Standard Schema (so any nifra route validates it with no
5
5
  * special-casing) whose raw TypeBox definition stays reachable as `jsonSchema` -
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,gBAAgB,EAEjB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAA;AAKxD;;;;;;;GAOG;AACH,MAAM,MAAM,WAAW,CAAC,CAAC,SAAS,OAAO,GAAG,OAAO,IAAI,gBAAgB,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG;IAC9F,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAA;CACvB,CAAA;AAeD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,OAAO,EAC3C,MAAM,EAAE,CAAC,EACT,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GACtC,WAAW,CAAC,CAAC,CAAC,CAsChB"}
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,gBAAgB,EAEjB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAQ,KAAK,MAAM,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAA;AAKnE;;;;;;;GAOG;AACH,MAAM,MAAM,WAAW,CAAC,CAAC,SAAS,OAAO,GAAG,OAAO,IAAI,gBAAgB,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG;IAC9F,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAA;CACvB,CAAA;AAoJD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,OAAO,EAC3C,MAAM,EAAE,CAAC,EACT,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GACtC,WAAW,CAAC,CAAC,CAAC,CA+ChB"}
package/dist/adapter.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Kind } from "@sinclair/typebox";
1
2
  import { TypeCompiler } from "@sinclair/typebox/compiler";
2
3
  import { Value } from "@sinclair/typebox/value";
3
4
  import { ensureDefaultFormats } from "./formats.js";
@@ -13,6 +14,117 @@ function toIssue(error) {
13
14
  path: error.path === "" ? undefined : error.path.slice(1).split("/"),
14
15
  };
15
16
  }
17
+ /** Extract a flat conversion plan, or `null` when this schema needs the full recursive Convert. */
18
+ function flatCoercionPlan(schema) {
19
+ if (schema[Kind] !== "Object")
20
+ return null;
21
+ const props = schema.properties;
22
+ if (props === null || typeof props !== "object")
23
+ return null;
24
+ const record = props;
25
+ const plan = [];
26
+ for (const key of Object.getOwnPropertyNames(record)) {
27
+ const kind = record[key]?.[Kind];
28
+ if (kind === "String" || kind === "Number" || kind === "Integer" || kind === "Boolean") {
29
+ plan.push([key, kind]);
30
+ }
31
+ else {
32
+ return null;
33
+ }
34
+ }
35
+ return plan;
36
+ }
37
+ // TypeBox's IsStringNumeric: global isNaN (string-coercing) + parseFloat guard, verbatim.
38
+ const isStringNumeric = (v) =>
39
+ // biome-ignore lint/suspicious/noGlobalIsNan: mirrors TypeBox's own coercing `isNaN` check exactly.
40
+ !isNaN(v) && !isNaN(parseFloat(v));
41
+ const isValueTrue = (v) => v === true ||
42
+ (typeof v === "number" && v === 1) ||
43
+ (typeof v === "bigint" && v === BigInt(1)) ||
44
+ (typeof v === "string" && (v.toLowerCase() === "true" || v === "1"));
45
+ const isValueFalse = (v) => v === false ||
46
+ (typeof v === "number" && (v === 0 || Object.is(v, -0))) ||
47
+ (typeof v === "bigint" && v === BigInt(0)) ||
48
+ (typeof v === "string" && (v.toLowerCase() === "false" || v === "0" || v === "-0"));
49
+ function convertScalar(kind, v) {
50
+ switch (kind) {
51
+ case "Number":
52
+ return typeof v === "string" && isStringNumeric(v)
53
+ ? parseFloat(v)
54
+ : isValueTrue(v)
55
+ ? 1
56
+ : isValueFalse(v)
57
+ ? 0
58
+ : v;
59
+ case "Integer":
60
+ return typeof v === "string" && isStringNumeric(v)
61
+ ? // Radix-less on purpose: TypeBox's Convert uses bare `parseInt(value)`, so `"0x10"`
62
+ // converts to 16 - adding a radix would fork semantics from the path this mirrors.
63
+ // biome-ignore lint/correctness/useParseIntRadix: parity with TypeBox Convert (see above)
64
+ parseInt(v)
65
+ : typeof v === "number"
66
+ ? Math.trunc(v)
67
+ : isValueTrue(v)
68
+ ? 1
69
+ : isValueFalse(v)
70
+ ? 0
71
+ : v;
72
+ case "Boolean":
73
+ return isValueTrue(v) ? true : isValueFalse(v) ? false : v;
74
+ case "String":
75
+ return typeof v === "symbol" && v.description !== undefined
76
+ ? v.description.toString()
77
+ : typeof v === "bigint" || typeof v === "boolean" || typeof v === "number"
78
+ ? v.toString()
79
+ : v;
80
+ }
81
+ }
82
+ /**
83
+ * TypeBox's `TypeCompiler` escapes only single quotes when embedding property keys and string
84
+ * literals into generated source (`LiteralString.Escape`), so a BACKSLASH in a `properties` key or
85
+ * a `Type.Literal` string survives raw into the `new Function` body and is reinterpreted as an
86
+ * escape sequence there - the compiled checker then reads a DIFFERENT property (or compares a
87
+ * different literal) than the schema declares, silently failing valid input (and a key like
88
+ * `"a\\'b"` even changes which quote ends the string). The interpretive `Value` checker has no
89
+ * codegen step and handles these keys correctly, so a schema carrying the hazard is pinned to the
90
+ * eval-free path up front. Detected once at construction over the raw JSON schema tree; the
91
+ * overwhelmingly common backslash-free schema pays one boolean.
92
+ */
93
+ function hasCompileHazard(node) {
94
+ if (node === null || typeof node !== "object")
95
+ return false;
96
+ if (Array.isArray(node))
97
+ return node.some(hasCompileHazard);
98
+ const record = node;
99
+ for (const key of Object.getOwnPropertyNames(record)) {
100
+ const value = record[key];
101
+ if ((key === "properties" || key === "patternProperties") &&
102
+ value !== null &&
103
+ typeof value === "object" &&
104
+ Object.getOwnPropertyNames(value).some((prop) => prop.includes("\\"))) {
105
+ return true;
106
+ }
107
+ if (key === "const" && typeof value === "string" && value.includes("\\"))
108
+ return true;
109
+ if (hasCompileHazard(value))
110
+ return true;
111
+ }
112
+ return false;
113
+ }
114
+ /** Replay a flat plan over `value` - the fast equivalent of `Value.Convert` for eligible schemas. */
115
+ function convertFlat(plan, value) {
116
+ // Mirrors FromObject's guards: only a non-array object is converted (in place), anything else
117
+ // passes through for the Check to reject.
118
+ if (value === null || typeof value !== "object" || Array.isArray(value))
119
+ return value;
120
+ const record = value;
121
+ for (const [key, kind] of plan) {
122
+ if (!(key in record))
123
+ continue; // `in` (not hasOwn) - TypeBox's HasPropertyKey is `key in value`
124
+ record[key] = convertScalar(kind, record[key]);
125
+ }
126
+ return value;
127
+ }
16
128
  /**
17
129
  * Wrap a TypeBox schema as a `NifraSchema`.
18
130
  *
@@ -39,8 +151,13 @@ function toIssue(error) {
39
151
  */
40
152
  export function fromTypeBox(schema, options) {
41
153
  const coerce = options?.coerce ?? false;
154
+ // Built once per schema: a flat scalar object replays a cheap per-key plan on each validate; any
155
+ // other shape keeps the full recursive `Value.Convert` (plan stays null). See flatCoercionPlan.
156
+ const coercionPlan = coerce ? flatCoercionPlan(schema) : null;
42
157
  let compiled;
43
- let evalFree = false;
158
+ // A backslash-carrying key/literal would MISCOMPILE (not throw) under TypeCompiler - see
159
+ // hasCompileHazard. Start such schemas on the eval-free path; everything else compiles as before.
160
+ let evalFree = hasCompileHazard(schema);
44
161
  return {
45
162
  "~standard": {
46
163
  version: 1,
@@ -53,7 +170,11 @@ export function fromTypeBox(schema, options) {
53
170
  // Coerce first when asked (query schemas): `Value.Convert` turns "20"→20 per the schema, so the
54
171
  // compiled/eval-free Check below sees the target type. A non-convertible value (e.g. "abc" for an
55
172
  // integer) is left as-is and fails Check → a proper 400.
56
- const input = coerce ? Value.Convert(schema, value) : value;
173
+ const input = coerce
174
+ ? coercionPlan !== null
175
+ ? convertFlat(coercionPlan, value)
176
+ : Value.Convert(schema, value)
177
+ : value;
57
178
  if (compiled === undefined && !evalFree) {
58
179
  try {
59
180
  compiled = TypeCompiler.Compile(schema);
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.js","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAOA,OAAO,EAAkB,YAAY,EAAE,MAAM,4BAA4B,CAAA;AACzE,OAAO,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAA;AAC/C,OAAO,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAA;AAcnD;;;;;GAKG;AACH,SAAS,OAAO,CAAC,KAA0D;IACzE,OAAO;QACL,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,IAAI,EAAE,KAAK,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC;KACrE,CAAA;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,UAAU,WAAW,CACzB,MAAS,EACT,OAAuC;IAEvC,MAAM,MAAM,GAAG,OAAO,EAAE,MAAM,IAAI,KAAK,CAAA;IACvC,IAAI,QAAkC,CAAA;IACtC,IAAI,QAAQ,GAAG,KAAK,CAAA;IACpB,OAAO;QACL,WAAW,EAAE;YACX,OAAO,EAAE,CAAC;YACV,MAAM,EAAE,OAAO;YACf,QAAQ,EAAE,CAAC,KAAc,EAA6B,EAAE;gBACtD,uFAAuF;gBACvF,mFAAmF;gBACnF,2FAA2F;gBAC3F,oBAAoB,EAAE,CAAA;gBACtB,gGAAgG;gBAChG,kGAAkG;gBAClG,yDAAyD;gBACzD,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAA;gBAC3D,IAAI,QAAQ,KAAK,SAAS,IAAI,CAAC,QAAQ,EAAE,CAAC;oBACxC,IAAI,CAAC;wBACH,QAAQ,GAAG,YAAY,CAAC,OAAO,CAAC,MAAM,CAAC,CAAA;oBACzC,CAAC;oBAAC,MAAM,CAAC;wBACP,8EAA8E;wBAC9E,QAAQ,GAAG,IAAI,CAAA;oBACjB,CAAC;gBACH,CAAC;gBACD,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;oBAC3B,IAAI,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC;wBAAE,OAAO,EAAE,KAAK,EAAE,KAAkB,EAAE,CAAA;oBAC/D,OAAO,EAAE,MAAM,EAAE,CAAC,GAAG,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAA;gBAC7D,CAAC;gBACD,IAAI,KAAK,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC;oBAAE,OAAO,EAAE,KAAK,EAAE,KAAkB,EAAE,CAAA;gBACpE,OAAO,EAAE,MAAM,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAA;YAClE,CAAC;YACD,oEAAoE;YACpE,8EAA8E;YAC9E,KAAK,EAAE,SAA2D;SACnE;QACD,UAAU,EAAE,MAAM;KACnB,CAAA;AACH,CAAC"}
1
+ {"version":3,"file":"adapter.js","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/schema",
3
- "version": "2.8.2",
3
+ "version": "2.9.0",
4
4
  "description": "Built-in schema builder `t` - TypeBox-backed (free JSON Schema + compiled validators), implements Standard Schema so nifra routes validate it natively.",
5
5
  "keywords": [
6
6
  "bun",
@@ -48,7 +48,7 @@
48
48
  "@sinclair/typebox": "^0.34.49"
49
49
  },
50
50
  "peerDependencies": {
51
- "@nifrajs/core": "^2.8.2"
51
+ "@nifrajs/core": "^2.9.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@nifrajs/core": "workspace:*",
package/src/adapter.ts CHANGED
@@ -4,7 +4,7 @@ import type {
4
4
  StandardSchemaV1,
5
5
  StandardTypes,
6
6
  } from "@nifrajs/core/schema"
7
- import type { Static, TSchema } from "@sinclair/typebox"
7
+ import { Kind, type Static, type TSchema } from "@sinclair/typebox"
8
8
  import { type TypeCheck, TypeCompiler } from "@sinclair/typebox/compiler"
9
9
  import { Value } from "@sinclair/typebox/value"
10
10
  import { ensureDefaultFormats } from "./formats.ts"
@@ -34,6 +34,139 @@ function toIssue(error: { readonly message: string; readonly path: string }): St
34
34
  }
35
35
  }
36
36
 
37
+ // ---------------------------------------------------------------------------------------------
38
+ // Precompiled coercion for flat scalar objects - the shape every real query schema has.
39
+ //
40
+ // `Value.Convert` walks the schema tree interpretively on EVERY validate call (measured ~115ns for
41
+ // a 3-field query schema vs ~5ns for the compiled Check it feeds - 23x the cost of validation
42
+ // itself). A `coerce` schema whose properties are all plain String/Number/Integer/Boolean can have
43
+ // its conversion plan extracted ONCE at construction and replayed as a flat loop per request.
44
+ //
45
+ // Every converter below mirrors `@sinclair/typebox/value` `convert.mjs` byte-for-byte - including
46
+ // its quirks (global-`isNaN` string coercion, radix-less `parseInt` so `"0x10"` → 16, `"1"`/
47
+ // `"true"` truthiness, `-0` handling) - and the object walk mirrors `FromObject` (`key in value`
48
+ // including the prototype chain, in-place mutation, arrays/non-objects passed through untouched).
49
+ // A schema with ANY other property kind (unions, literals, arrays, nested objects, dates, refs)
50
+ // gets `plan = null` and keeps taking the full `Value.Convert` path, so semantics never fork. The
51
+ // parity is pinned by a property-based test that diffs this plan against `Value.Convert` itself.
52
+ // ---------------------------------------------------------------------------------------------
53
+
54
+ type ScalarKind = "String" | "Number" | "Integer" | "Boolean"
55
+ type CoercionPlan = ReadonlyArray<readonly [key: string, kind: ScalarKind]>
56
+
57
+ /** Extract a flat conversion plan, or `null` when this schema needs the full recursive Convert. */
58
+ function flatCoercionPlan(schema: TSchema): CoercionPlan | null {
59
+ if ((schema as unknown as Record<symbol, unknown>)[Kind] !== "Object") return null
60
+ const props = (schema as { readonly properties?: unknown }).properties
61
+ if (props === null || typeof props !== "object") return null
62
+ const record = props as Record<string, TSchema>
63
+ const plan: Array<readonly [string, ScalarKind]> = []
64
+ for (const key of Object.getOwnPropertyNames(record)) {
65
+ const kind = (record[key] as unknown as Record<symbol, unknown> | undefined)?.[Kind]
66
+ if (kind === "String" || kind === "Number" || kind === "Integer" || kind === "Boolean") {
67
+ plan.push([key, kind])
68
+ } else {
69
+ return null
70
+ }
71
+ }
72
+ return plan
73
+ }
74
+
75
+ // TypeBox's IsStringNumeric: global isNaN (string-coercing) + parseFloat guard, verbatim.
76
+ const isStringNumeric = (v: string): boolean =>
77
+ // biome-ignore lint/suspicious/noGlobalIsNan: mirrors TypeBox's own coercing `isNaN` check exactly.
78
+ !isNaN(v as unknown as number) && !isNaN(parseFloat(v))
79
+
80
+ const isValueTrue = (v: unknown): boolean =>
81
+ v === true ||
82
+ (typeof v === "number" && v === 1) ||
83
+ (typeof v === "bigint" && v === BigInt(1)) ||
84
+ (typeof v === "string" && (v.toLowerCase() === "true" || v === "1"))
85
+
86
+ const isValueFalse = (v: unknown): boolean =>
87
+ v === false ||
88
+ (typeof v === "number" && (v === 0 || Object.is(v, -0))) ||
89
+ (typeof v === "bigint" && v === BigInt(0)) ||
90
+ (typeof v === "string" && (v.toLowerCase() === "false" || v === "0" || v === "-0"))
91
+
92
+ function convertScalar(kind: ScalarKind, v: unknown): unknown {
93
+ switch (kind) {
94
+ case "Number":
95
+ return typeof v === "string" && isStringNumeric(v)
96
+ ? parseFloat(v)
97
+ : isValueTrue(v)
98
+ ? 1
99
+ : isValueFalse(v)
100
+ ? 0
101
+ : v
102
+ case "Integer":
103
+ return typeof v === "string" && isStringNumeric(v)
104
+ ? // Radix-less on purpose: TypeBox's Convert uses bare `parseInt(value)`, so `"0x10"`
105
+ // converts to 16 - adding a radix would fork semantics from the path this mirrors.
106
+ // biome-ignore lint/correctness/useParseIntRadix: parity with TypeBox Convert (see above)
107
+ parseInt(v)
108
+ : typeof v === "number"
109
+ ? Math.trunc(v)
110
+ : isValueTrue(v)
111
+ ? 1
112
+ : isValueFalse(v)
113
+ ? 0
114
+ : v
115
+ case "Boolean":
116
+ return isValueTrue(v) ? true : isValueFalse(v) ? false : v
117
+ case "String":
118
+ return typeof v === "symbol" && v.description !== undefined
119
+ ? v.description.toString()
120
+ : typeof v === "bigint" || typeof v === "boolean" || typeof v === "number"
121
+ ? v.toString()
122
+ : v
123
+ }
124
+ }
125
+
126
+ /**
127
+ * TypeBox's `TypeCompiler` escapes only single quotes when embedding property keys and string
128
+ * literals into generated source (`LiteralString.Escape`), so a BACKSLASH in a `properties` key or
129
+ * a `Type.Literal` string survives raw into the `new Function` body and is reinterpreted as an
130
+ * escape sequence there - the compiled checker then reads a DIFFERENT property (or compares a
131
+ * different literal) than the schema declares, silently failing valid input (and a key like
132
+ * `"a\\'b"` even changes which quote ends the string). The interpretive `Value` checker has no
133
+ * codegen step and handles these keys correctly, so a schema carrying the hazard is pinned to the
134
+ * eval-free path up front. Detected once at construction over the raw JSON schema tree; the
135
+ * overwhelmingly common backslash-free schema pays one boolean.
136
+ */
137
+ function hasCompileHazard(node: unknown): boolean {
138
+ if (node === null || typeof node !== "object") return false
139
+ if (Array.isArray(node)) return node.some(hasCompileHazard)
140
+ const record = node as Record<string, unknown>
141
+ for (const key of Object.getOwnPropertyNames(record)) {
142
+ const value = record[key]
143
+ if (
144
+ (key === "properties" || key === "patternProperties") &&
145
+ value !== null &&
146
+ typeof value === "object" &&
147
+ Object.getOwnPropertyNames(value).some((prop) => prop.includes("\\"))
148
+ ) {
149
+ return true
150
+ }
151
+ if (key === "const" && typeof value === "string" && value.includes("\\")) return true
152
+ if (hasCompileHazard(value)) return true
153
+ }
154
+ return false
155
+ }
156
+
157
+ /** Replay a flat plan over `value` - the fast equivalent of `Value.Convert` for eligible schemas. */
158
+ function convertFlat(plan: CoercionPlan, value: unknown): unknown {
159
+ // Mirrors FromObject's guards: only a non-array object is converted (in place), anything else
160
+ // passes through for the Check to reject.
161
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return value
162
+ const record = value as Record<string, unknown>
163
+ for (const [key, kind] of plan) {
164
+ if (!(key in record)) continue // `in` (not hasOwn) - TypeBox's HasPropertyKey is `key in value`
165
+ record[key] = convertScalar(kind, record[key])
166
+ }
167
+ return value
168
+ }
169
+
37
170
  /**
38
171
  * Wrap a TypeBox schema as a `NifraSchema`.
39
172
  *
@@ -63,8 +196,13 @@ export function fromTypeBox<T extends TSchema>(
63
196
  options?: { readonly coerce?: boolean },
64
197
  ): NifraSchema<T> {
65
198
  const coerce = options?.coerce ?? false
199
+ // Built once per schema: a flat scalar object replays a cheap per-key plan on each validate; any
200
+ // other shape keeps the full recursive `Value.Convert` (plan stays null). See flatCoercionPlan.
201
+ const coercionPlan = coerce ? flatCoercionPlan(schema) : null
66
202
  let compiled: TypeCheck<T> | undefined
67
- let evalFree = false
203
+ // A backslash-carrying key/literal would MISCOMPILE (not throw) under TypeCompiler - see
204
+ // hasCompileHazard. Start such schemas on the eval-free path; everything else compiles as before.
205
+ let evalFree = hasCompileHazard(schema)
68
206
  return {
69
207
  "~standard": {
70
208
  version: 1,
@@ -77,7 +215,11 @@ export function fromTypeBox<T extends TSchema>(
77
215
  // Coerce first when asked (query schemas): `Value.Convert` turns "20"→20 per the schema, so the
78
216
  // compiled/eval-free Check below sees the target type. A non-convertible value (e.g. "abc" for an
79
217
  // integer) is left as-is and fails Check → a proper 400.
80
- const input = coerce ? Value.Convert(schema, value) : value
218
+ const input = coerce
219
+ ? coercionPlan !== null
220
+ ? convertFlat(coercionPlan, value)
221
+ : Value.Convert(schema, value)
222
+ : value
81
223
  if (compiled === undefined && !evalFree) {
82
224
  try {
83
225
  compiled = TypeCompiler.Compile(schema)