@nifrajs/schema 2.8.1 → 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 +1 -1
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +123 -2
- package/dist/adapter.js.map +1 -1
- package/package.json +2 -2
- package/src/adapter.ts +145 -3
package/dist/adapter.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { StandardSchemaV1 } from "@nifrajs/core/schema";
|
|
2
|
-
import type
|
|
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` -
|
package/dist/adapter.d.ts.map
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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);
|
package/dist/adapter.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"adapter.js","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|