@metaobjectsdev/runtime-ts 1.0.13 → 1.1.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/decimal-wire.d.ts +54 -0
- package/dist/decimal-wire.d.ts.map +1 -0
- package/dist/decimal-wire.js +158 -0
- package/dist/decimal-wire.js.map +1 -0
- package/dist/drizzle-fastify/filter-allowlist.d.ts +4 -2
- package/dist/drizzle-fastify/filter-allowlist.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-allowlist.js +2 -1
- package/dist/drizzle-fastify/filter-allowlist.js.map +1 -1
- package/dist/drizzle-fastify/filter-parser.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-parser.js +23 -9
- package/dist/drizzle-fastify/filter-parser.js.map +1 -1
- package/dist/drizzle-fastify/filter-value-format.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-value-format.js +4 -1
- package/dist/drizzle-fastify/filter-value-format.js.map +1 -1
- package/dist/drizzle-fastify/index.d.ts +8 -0
- package/dist/drizzle-fastify/index.d.ts.map +1 -1
- package/dist/drizzle-fastify/index.js +4 -2
- package/dist/drizzle-fastify/index.js.map +1 -1
- package/dist/drizzle-fastify/mount-m2m.d.ts +2 -0
- package/dist/drizzle-fastify/mount-m2m.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-m2m.js +2 -1
- package/dist/drizzle-fastify/mount-m2m.js.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.d.ts +25 -1
- package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.js +43 -28
- package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
- package/dist/extract-object.js +5 -7
- package/dist/extract-object.js.map +1 -1
- package/dist/hono/index.d.ts +8 -0
- package/dist/hono/index.d.ts.map +1 -1
- package/dist/hono/index.js +4 -2
- package/dist/hono/index.js.map +1 -1
- package/dist/hono/mount-read-only.d.ts +25 -1
- package/dist/hono/mount-read-only.d.ts.map +1 -1
- package/dist/hono/mount-read-only.js +51 -36
- package/dist/hono/mount-read-only.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/n2m-resolver.d.ts.map +1 -1
- package/dist/n2m-resolver.js +4 -3
- package/dist/n2m-resolver.js.map +1 -1
- package/dist/net-format.d.ts +8 -0
- package/dist/net-format.d.ts.map +1 -0
- package/dist/net-format.js +34 -0
- package/dist/net-format.js.map +1 -0
- package/dist/object-manager.d.ts +20 -7
- package/dist/object-manager.d.ts.map +1 -1
- package/dist/object-manager.js +68 -13
- package/dist/object-manager.js.map +1 -1
- package/dist/query-builder.d.ts.map +1 -1
- package/dist/query-builder.js +11 -2
- package/dist/query-builder.js.map +1 -1
- package/dist/relation-resolver.d.ts.map +1 -1
- package/dist/relation-resolver.js +11 -11
- package/dist/relation-resolver.js.map +1 -1
- package/dist/type-coercer.d.ts +1 -1
- package/dist/type-coercer.d.ts.map +1 -1
- package/dist/type-coercer.js +114 -5
- package/dist/type-coercer.js.map +1 -1
- package/dist/validator-runner.d.ts.map +1 -1
- package/dist/validator-runner.js +192 -35
- package/dist/validator-runner.js.map +1 -1
- package/package.json +3 -3
- package/src/decimal-wire.ts +154 -0
- package/src/drizzle-fastify/filter-allowlist.ts +5 -1
- package/src/drizzle-fastify/filter-parser.ts +22 -9
- package/src/drizzle-fastify/filter-value-format.ts +4 -0
- package/src/drizzle-fastify/index.ts +11 -2
- package/src/drizzle-fastify/mount-m2m.ts +4 -1
- package/src/drizzle-fastify/mount-read-only.ts +68 -31
- package/src/extract-object.ts +5 -7
- package/src/hono/index.ts +11 -2
- package/src/hono/mount-read-only.ts +76 -37
- package/src/index.ts +2 -1
- package/src/n2m-resolver.ts +4 -2
- package/src/net-format.ts +37 -0
- package/src/object-manager.ts +80 -18
- package/src/query-builder.ts +14 -1
- package/src/relation-resolver.ts +15 -14
- package/src/type-coercer.ts +116 -5
- package/src/validator-runner.ts +195 -32
package/src/validator-runner.ts
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
// Pure function: NEVER throws. ObjectManager wraps a non-ok result in a ValidationError on writes;
|
|
2
2
|
// om.validate() returns the result directly.
|
|
3
3
|
|
|
4
|
-
import { isMetaRoot, type MetaData } from "@metaobjectsdev/metadata";
|
|
4
|
+
import { isMetaObject, isMetaRoot, refTarget, type MetaData } from "@metaobjectsdev/metadata";
|
|
5
5
|
import {
|
|
6
6
|
TYPE_FIELD, TYPE_VALIDATOR,
|
|
7
7
|
VALIDATOR_SUBTYPE_REQUIRED, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX,
|
|
8
8
|
FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG,
|
|
9
9
|
FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_CURRENCY,
|
|
10
|
-
FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_OBJECT,
|
|
10
|
+
FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_MAP, FIELD_SUBTYPE_DECIMAL,
|
|
11
|
+
FIELD_ATTR_PRECISION, FIELD_ATTR_SCALE,
|
|
11
12
|
FIELD_ATTR_REQUIRED, FIELD_ATTR_MAX_LENGTH, FIELD_ATTR_DEFAULT,
|
|
12
13
|
FIELD_ATTR_DB_COLUMN_TYPE, DB_COLUMN_TYPE_JSONB, FIELD_ATTR_OBJECT_REF,
|
|
13
|
-
|
|
14
|
+
OBJECT_SUBTYPE_VALUE,
|
|
14
15
|
VALIDATOR_ATTR_MIN, VALIDATOR_ATTR_MAX, VALIDATOR_ATTR_PATTERN,
|
|
16
|
+
VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_ARRAY,
|
|
17
|
+
FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, FIELD_ATTR_LENIENT,
|
|
18
|
+
IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, GENERATION_INCREMENT, GENERATION_UUID,
|
|
15
19
|
} from "@metaobjectsdev/metadata";
|
|
16
20
|
import type { ValidationFailure } from "./errors.js";
|
|
21
|
+
import { decimalFits, parseDecimalInput } from "./decimal-wire.js";
|
|
22
|
+
import { isAbsoluteUri, isInetLiteral } from "./net-format.js";
|
|
17
23
|
|
|
18
24
|
export type ValidationResult =
|
|
19
25
|
| { ok: true }
|
|
@@ -56,6 +62,7 @@ export function runValidators(
|
|
|
56
62
|
opts: RunValidatorsOpts = {},
|
|
57
63
|
): ValidationResult {
|
|
58
64
|
const errors: ValidationFailure[] = [];
|
|
65
|
+
const assignedPk = assignedPkFieldNames(entity);
|
|
59
66
|
|
|
60
67
|
// Effective children so a TPH subtype validates inherited base fields too.
|
|
61
68
|
for (const field of entity.children()) {
|
|
@@ -70,7 +77,11 @@ export function runValidators(
|
|
|
70
77
|
const required = isRequired(field);
|
|
71
78
|
// ADR-0039: effective attr — @default may be inherited via extends.
|
|
72
79
|
const hasDefault = field.attr(FIELD_ATTR_DEFAULT) !== undefined;
|
|
73
|
-
|
|
80
|
+
// An ASSIGNED primary key must be supplied whatever @required says: nothing else can
|
|
81
|
+
// produce the value. That is presence only — the non-empty-string floor below stays
|
|
82
|
+
// tied to a DECLARED required, as in the generated InsertSchema.
|
|
83
|
+
const mustBePresent = required || assignedPk.has(field.name);
|
|
84
|
+
if (mustBePresent && (value === undefined || value === null)) {
|
|
74
85
|
if (opts.partial && !present) continue;
|
|
75
86
|
// A @default exempts a required field only when it is ABSENT (the DB fills
|
|
76
87
|
// it on insert / an omitted patch key is untouched). It does NOT rescue an
|
|
@@ -117,6 +128,7 @@ export function runValidators(
|
|
|
117
128
|
continue;
|
|
118
129
|
}
|
|
119
130
|
const elements: unknown[] = field.resolvedIsArray() ? (value as unknown[]) : [value];
|
|
131
|
+
if (field.resolvedIsArray()) errors.push(...arraySizeErrors(field, elements.length));
|
|
120
132
|
elements.forEach((el, i) => {
|
|
121
133
|
if (typeof el !== "object" || el === null || Array.isArray(el)) {
|
|
122
134
|
errors.push({
|
|
@@ -140,6 +152,38 @@ export function runValidators(
|
|
|
140
152
|
continue;
|
|
141
153
|
}
|
|
142
154
|
|
|
155
|
+
// A map of value objects (`field.map @objectRef`): every map value is a VO validated in full,
|
|
156
|
+
// as an array element is. The keys are arbitrary text and are never compared with the VO's
|
|
157
|
+
// member names. A map whose value is a scalar (@valueType) is unchanged.
|
|
158
|
+
if (field.subType === FIELD_SUBTYPE_MAP) {
|
|
159
|
+
const vo = resolveVoRef(field);
|
|
160
|
+
if (vo !== undefined) {
|
|
161
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
162
|
+
errors.push({
|
|
163
|
+
field: field.name, rule: "type",
|
|
164
|
+
message: `'${field.name}' must be a map of ${vo.name}`,
|
|
165
|
+
expected: "object", received: Array.isArray(value) ? "array" : typeof value,
|
|
166
|
+
});
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
for (const [key, el] of Object.entries(value as Record<string, unknown>)) {
|
|
170
|
+
if (typeof el !== "object" || el === null || Array.isArray(el)) {
|
|
171
|
+
errors.push({
|
|
172
|
+
field: `${field.name}.${key}`, rule: "type",
|
|
173
|
+
message: `'${field.name}.${key}' must be a ${vo.name} object`,
|
|
174
|
+
expected: vo.name, received: el === null ? "null" : Array.isArray(el) ? "array" : typeof el,
|
|
175
|
+
});
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const sub = runValidators(vo, el as Record<string, unknown>);
|
|
179
|
+
if (!sub.ok) {
|
|
180
|
+
for (const e of sub.errors) errors.push({ ...e, field: `${field.name}.${key}.${e.field}` });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
143
187
|
// A scalar array (`field.string isArray`, …) — stored as a native array or a JSON array.
|
|
144
188
|
// Each element is checked against the element rules; an element error names its index,
|
|
145
189
|
// as the value-object branch above does. ADR-0039: resolving `isArray`.
|
|
@@ -152,6 +196,7 @@ export function runValidators(
|
|
|
152
196
|
});
|
|
153
197
|
continue;
|
|
154
198
|
}
|
|
199
|
+
errors.push(...arraySizeErrors(field, value.length));
|
|
155
200
|
value.forEach((el, i) => {
|
|
156
201
|
if (el === null || el === undefined) return;
|
|
157
202
|
errors.push(...scalarErrors(field, el, false, `${field.name}[${i}]`));
|
|
@@ -166,8 +211,7 @@ export function runValidators(
|
|
|
166
211
|
}
|
|
167
212
|
|
|
168
213
|
/** Resolve a `field.object`'s `@objectRef` to its value-object MetaData by walking
|
|
169
|
-
* to the tree root. The ref may be a bare name or a `pkg::Name` FQN.
|
|
170
|
-
* extract-object resolver. Returns undefined when unresolvable OR when the target
|
|
214
|
+
* to the tree root. The ref may be a bare name or a `pkg::Name` FQN. Returns undefined when unresolvable OR when the target
|
|
171
215
|
* is not an `object.value` (→ no VO recursion). Cross-port parity: C#/Java/Kotlin
|
|
172
216
|
* gate the recursion on the ref being a value object, so a `field.object @objectRef`
|
|
173
217
|
* pointing at a non-value object validates identically (skipped) on every port.
|
|
@@ -180,11 +224,9 @@ function resolveVoRef(field: MetaData): MetaData | undefined {
|
|
|
180
224
|
// class check fails for a real root and every VO reference silently stops
|
|
181
225
|
// resolving, skipping nested value-object validation with no error.
|
|
182
226
|
if (!isMetaRoot(root)) return undefined;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (sep >= 0) target = root.findObject(ref.slice(sep + PACKAGE_SEPARATOR.length));
|
|
187
|
-
}
|
|
227
|
+
// ADR-0059 R7 — the object the loader bound `@objectRef` to, resolved in the package
|
|
228
|
+
// of the node that declared it — never a same-named object in some other package.
|
|
229
|
+
const target = refTarget(field, FIELD_ATTR_OBJECT_REF);
|
|
188
230
|
return target?.subType === OBJECT_SUBTYPE_VALUE ? target : undefined;
|
|
189
231
|
}
|
|
190
232
|
|
|
@@ -197,32 +239,87 @@ function isRequired(field: MetaData): boolean {
|
|
|
197
239
|
return false;
|
|
198
240
|
}
|
|
199
241
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
242
|
+
/** Primary-identity field names the CALLER must supply: the identity carries no
|
|
243
|
+
* store-side `@generation` (increment / uuid). A `@default` on the field still lets the
|
|
244
|
+
* caller omit it — the `hasDefault` exemption at the call site covers that. */
|
|
245
|
+
function assignedPkFieldNames(entity: MetaData): Set<string> {
|
|
246
|
+
// isMetaObject, not `instanceof` — see resolveVoRef.
|
|
247
|
+
const primary = isMetaObject(entity) ? entity.primaryIdentity() : undefined;
|
|
248
|
+
if (primary === undefined) return new Set();
|
|
249
|
+
// ADR-0039: effective — an identity may be inherited via extends.
|
|
250
|
+
const generation = primary.attr(IDENTITY_ATTR_GENERATION);
|
|
251
|
+
if (generation === GENERATION_INCREMENT || generation === GENERATION_UUID) return new Set();
|
|
252
|
+
const fields = primary.attr(IDENTITY_ATTR_FIELDS);
|
|
253
|
+
if (Array.isArray(fields)) return new Set(fields.map(String));
|
|
254
|
+
return typeof fields === "string" ? new Set([fields]) : new Set();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
interface Bounds { min?: number; max?: number }
|
|
258
|
+
|
|
259
|
+
/** The `@min` / `@max` of the field's validators of one subtype (last authored wins). */
|
|
260
|
+
function validatorBounds(field: MetaData, validatorSubType: string): Bounds {
|
|
261
|
+
const bounds: Bounds = {};
|
|
262
|
+
// ADR-0039: effective — a validator and its bounds may be inherited via extends.
|
|
204
263
|
for (const child of field.children()) {
|
|
205
|
-
if (child.type !== TYPE_VALIDATOR) continue;
|
|
206
|
-
|
|
264
|
+
if (child.type !== TYPE_VALIDATOR || child.subType !== validatorSubType) continue;
|
|
265
|
+
const min = child.attr(VALIDATOR_ATTR_MIN);
|
|
207
266
|
const max = child.attr(VALIDATOR_ATTR_MAX);
|
|
208
|
-
if (typeof
|
|
267
|
+
if (typeof min === "number") bounds.min = min;
|
|
268
|
+
if (typeof max === "number") bounds.max = max;
|
|
209
269
|
}
|
|
210
|
-
return
|
|
270
|
+
return bounds;
|
|
211
271
|
}
|
|
212
272
|
|
|
213
|
-
|
|
214
|
-
|
|
273
|
+
/** String length bounds. Max is strictest-wins across `@maxLength` and every
|
|
274
|
+
* `validator.length @max` (FR-036 A3). Min is the authored `validator.length @min`,
|
|
275
|
+
* undefined when none was authored. */
|
|
276
|
+
function lengthBounds(field: MetaData): Bounds {
|
|
277
|
+
const bounds: Bounds = {};
|
|
278
|
+
// ADR-0039: effective — @maxLength and a length-validator may be inherited via extends.
|
|
279
|
+
const attr = field.attr(FIELD_ATTR_MAX_LENGTH);
|
|
280
|
+
if (typeof attr === "number") bounds.max = attr;
|
|
215
281
|
for (const child of field.children()) {
|
|
216
|
-
if (child.type !== TYPE_VALIDATOR) continue;
|
|
217
|
-
if (child.subType !== VALIDATOR_SUBTYPE_LENGTH) continue;
|
|
282
|
+
if (child.type !== TYPE_VALIDATOR || child.subType !== VALIDATOR_SUBTYPE_LENGTH) continue;
|
|
218
283
|
const min = child.attr(VALIDATOR_ATTR_MIN);
|
|
219
|
-
|
|
284
|
+
const max = child.attr(VALIDATOR_ATTR_MAX);
|
|
285
|
+
if (typeof min === "number") bounds.min = min;
|
|
286
|
+
if (typeof max === "number") bounds.max = bounds.max === undefined ? max : Math.min(bounds.max, max);
|
|
287
|
+
}
|
|
288
|
+
return bounds;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** `validator.array @min/@max` — element-count bounds on an array field of any element type. */
|
|
292
|
+
function arraySizeErrors(field: MetaData, size: number): ValidationFailure[] {
|
|
293
|
+
const { min, max } = validatorBounds(field, VALIDATOR_SUBTYPE_ARRAY);
|
|
294
|
+
const errors: ValidationFailure[] = [];
|
|
295
|
+
if (min !== undefined && size < min) {
|
|
296
|
+
errors.push({
|
|
297
|
+
field: field.name, rule: "array",
|
|
298
|
+
message: `'${field.name}' must have at least ${min} items (got ${size})`,
|
|
299
|
+
expected: { min }, received: size,
|
|
300
|
+
});
|
|
220
301
|
}
|
|
302
|
+
if (max !== undefined && size > max) {
|
|
303
|
+
errors.push({
|
|
304
|
+
field: field.name, rule: "array",
|
|
305
|
+
message: `'${field.name}' must have at most ${max} items (got ${size})`,
|
|
306
|
+
expected: { max }, received: size,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
return errors;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The comparable numeric value of a type-checked numeric field value. An int64 arrives
|
|
313
|
+
* as a number, a bigint or a base-10 integer string; the string compares as an integer. */
|
|
314
|
+
function comparable(value: unknown): number | bigint | undefined {
|
|
315
|
+
if (typeof value === "number" || typeof value === "bigint") return value;
|
|
316
|
+
if (typeof value === "string" && INT64_STRING_RE.test(value)) return BigInt(value);
|
|
221
317
|
return undefined;
|
|
222
318
|
}
|
|
223
319
|
|
|
224
320
|
function checkType(subType: string, value: unknown): string | null {
|
|
225
|
-
if (subType === FIELD_SUBTYPE_STRING || subType === FIELD_SUBTYPE_UUID
|
|
321
|
+
if (subType === FIELD_SUBTYPE_STRING || subType === FIELD_SUBTYPE_UUID
|
|
322
|
+
|| subType === FIELD_SUBTYPE_URI || subType === FIELD_SUBTYPE_INET) {
|
|
226
323
|
if (typeof value !== "string") return `expected string`;
|
|
227
324
|
} else if (NUMERIC_FIELD_SUBTYPES.has(subType)) {
|
|
228
325
|
if (typeof value !== "number") return `expected number`;
|
|
@@ -237,6 +334,33 @@ function checkType(subType: string, value: unknown): string | null {
|
|
|
237
334
|
return null;
|
|
238
335
|
}
|
|
239
336
|
|
|
337
|
+
/**
|
|
338
|
+
* ADR-0019 Amendment 1 (W2, W3) on a write: a decimal is a string in the JSON-number grammar or a
|
|
339
|
+
* JSON number a double holds exactly, and must fit the declared `@precision` / `@scale`. Refused
|
|
340
|
+
* here, as a validation failure, so the write answers 400 rather than failing later in the coercer.
|
|
341
|
+
*/
|
|
342
|
+
function decimalErrors(field: MetaData, value: unknown, label: string): ValidationFailure[] {
|
|
343
|
+
const canonical = parseDecimalInput(value);
|
|
344
|
+
if (canonical === undefined) {
|
|
345
|
+
return [{
|
|
346
|
+
field: label, rule: "type",
|
|
347
|
+
message: `'${label}' must be a decimal number (a string in the JSON-number grammar, or a number a double holds exactly: send a string for more than 15 significant digits)`,
|
|
348
|
+
expected: FIELD_SUBTYPE_DECIMAL, received: typeof value,
|
|
349
|
+
}];
|
|
350
|
+
}
|
|
351
|
+
// ADR-0039: effective attrs — @precision / @scale may be inherited via extends.
|
|
352
|
+
const precision = field.attr(FIELD_ATTR_PRECISION);
|
|
353
|
+
const scale = field.attr(FIELD_ATTR_SCALE);
|
|
354
|
+
if (!decimalFits(canonical, typeof precision === "number" ? precision : undefined, typeof scale === "number" ? scale : undefined)) {
|
|
355
|
+
return [{
|
|
356
|
+
field: label, rule: "range",
|
|
357
|
+
message: `'${label}' (${canonical}) does not fit @precision ${String(precision)} / @scale ${String(scale ?? 0)}`,
|
|
358
|
+
expected: { precision, scale }, received: canonical,
|
|
359
|
+
}];
|
|
360
|
+
}
|
|
361
|
+
return [];
|
|
362
|
+
}
|
|
363
|
+
|
|
240
364
|
/**
|
|
241
365
|
* The type, length and regex errors for one scalar value of `field` — the whole field
|
|
242
366
|
* value, or one element of a scalar array (then `required` is false: element presence is not
|
|
@@ -244,6 +368,7 @@ function checkType(subType: string, value: unknown): string | null {
|
|
|
244
368
|
*/
|
|
245
369
|
function scalarErrors(field: MetaData, value: unknown, required: boolean, label: string): ValidationFailure[] {
|
|
246
370
|
const errors: ValidationFailure[] = [];
|
|
371
|
+
if (field.subType === FIELD_SUBTYPE_DECIMAL) return decimalErrors(field, value, label);
|
|
247
372
|
const typeError = checkType(field.subType, value);
|
|
248
373
|
if (typeError !== null) {
|
|
249
374
|
errors.push({
|
|
@@ -256,8 +381,7 @@ function scalarErrors(field: MetaData, value: unknown, required: boolean, label:
|
|
|
256
381
|
return errors;
|
|
257
382
|
}
|
|
258
383
|
|
|
259
|
-
const maxLen =
|
|
260
|
-
const minLen = resolveMinLength(field);
|
|
384
|
+
const { min: minLen, max: maxLen } = lengthBounds(field);
|
|
261
385
|
if (typeof value === "string") {
|
|
262
386
|
if (maxLen !== undefined && value.length > maxLen) {
|
|
263
387
|
errors.push({
|
|
@@ -268,11 +392,11 @@ function scalarErrors(field: MetaData, value: unknown, required: boolean, label:
|
|
|
268
392
|
received: value.length,
|
|
269
393
|
});
|
|
270
394
|
}
|
|
271
|
-
// FR-036 Pin 1: a @required string is non-empty
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
const effectiveMin =
|
|
395
|
+
// FR-036 Pin 1: a @required string is non-empty by default (an implicit floor of 1),
|
|
396
|
+
// but an explicitly authored `validator.length @min` is ALWAYS authoritative over that
|
|
397
|
+
// floor (#224 / ADR-0044): `@min: 0` opts back to presence-only. Same rule as the
|
|
398
|
+
// generated Zod InsertSchema, so the two enforcement surfaces stay in lockstep.
|
|
399
|
+
const effectiveMin = minLen !== undefined ? minLen : required ? 1 : 0;
|
|
276
400
|
if (effectiveMin > 0 && value.length < effectiveMin) {
|
|
277
401
|
errors.push({
|
|
278
402
|
field: label,
|
|
@@ -316,5 +440,44 @@ function scalarErrors(field: MetaData, value: unknown, required: boolean, label:
|
|
|
316
440
|
});
|
|
317
441
|
}
|
|
318
442
|
}
|
|
443
|
+
|
|
444
|
+
// validator.numeric @min/@max — inclusive value bounds on a numeric field.
|
|
445
|
+
if (NUMERIC_FIELD_SUBTYPES.has(field.subType) || INT64_FIELD_SUBTYPES.has(field.subType)) {
|
|
446
|
+
const num = comparable(value);
|
|
447
|
+
const { min, max } = validatorBounds(field, VALIDATOR_SUBTYPE_NUMERIC);
|
|
448
|
+
if (num !== undefined && min !== undefined && num < min) {
|
|
449
|
+
errors.push({
|
|
450
|
+
field: label, rule: "numeric",
|
|
451
|
+
message: `'${label}' must be at least ${min} (got ${value})`,
|
|
452
|
+
expected: { min }, received: value,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
if (num !== undefined && max !== undefined && num > max) {
|
|
456
|
+
errors.push({
|
|
457
|
+
field: label, rule: "numeric",
|
|
458
|
+
message: `'${label}' must be at most ${max} (got ${value})`,
|
|
459
|
+
expected: { max }, received: value,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// field.uri / field.inet — the strict format contract, unless @lenient opts out.
|
|
465
|
+
// ADR-0039: effective — @lenient may be inherited via extends.
|
|
466
|
+
if (typeof value === "string" && field.attr(FIELD_ATTR_LENIENT) !== true) {
|
|
467
|
+
if (field.subType === FIELD_SUBTYPE_URI && !isAbsoluteUri(value)) {
|
|
468
|
+
errors.push({
|
|
469
|
+
field: label, rule: "format",
|
|
470
|
+
message: `'${label}' must be an absolute URI`,
|
|
471
|
+
expected: "uri", received: value,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
if (field.subType === FIELD_SUBTYPE_INET && !isInetLiteral(value)) {
|
|
475
|
+
errors.push({
|
|
476
|
+
field: label, rule: "format",
|
|
477
|
+
message: `'${label}' must be an IPv4 or IPv6 address`,
|
|
478
|
+
expected: "inet", received: value,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
319
482
|
return errors;
|
|
320
483
|
}
|