@zudojs/validation 1.0.0 → 1.0.2

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/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Schema validation with Zod integration, constraints, parsers, composers, circular detection, and depth/size checks.
4
4
 
5
+ <!-- zudo-docs:start -->
6
+
7
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-validation](https://zudojs.oyinlola.site/docs/packages-validation) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-validation.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
8
+
9
+ <!-- zudo-docs:end -->
10
+
5
11
  ## Installation
6
12
 
7
13
  ```bash
@@ -51,9 +57,21 @@ assertNoCircularReference(body);
51
57
  `test()` stateful and flip the answer on alternate calls.
52
58
  - Constraints carry an optional type guard, so wrong-typed input at a trust
53
59
  boundary reports as a validation failure rather than a `TypeError`.
54
- - The depth and size guards count a shared subtree once per occurrence, the way
55
- a serializer expands it, and walk iteratively so deeply nested input cannot
56
- exhaust the stack inside the check.
60
+ - The size guard counts a shared subtree once per occurrence, the way a
61
+ serializer expands it, and measures what `toJSON()` returns when a value has
62
+ one. The cycle and depth guards walk a shared subtree once, so a small graph
63
+ of shared nodes cannot cost exponential time. All guards walk iteratively, so
64
+ deeply nested input cannot exhaust the stack inside the check, and handle
65
+ sparse arrays (a hole counts as `undefined`).
66
+ - `ValidationError` and `ValidationResultError` extend `@zudojs/errors`'
67
+ `ValidationError`, so `instanceof` and `isValidationError()` from either
68
+ package catch them. Their `toJSON()` keeps the base class's redaction:
69
+ submitted issue values and sensitive `context` keys never reach the JSON.
70
+ - `not(constraint)` fails closed: a wrong-typed value, or one that makes the
71
+ inner constraint throw, fails. `everyItem`/`someItem` read every index,
72
+ holes included.
73
+ - A registry rule that declares both `schema` and `constraints` runs the schema,
74
+ then the constraints on the parsed value.
57
75
 
58
76
  ## Features
59
77
 
@@ -34,16 +34,10 @@ export function any(...validators) {
34
34
  return result;
35
35
  issues.push(...result.issues);
36
36
  }
37
- return failure(issues.length > 0
38
- ? issues
39
- : [
40
- {
41
- path: [],
42
- code: "no_validator_succeeded",
43
- message: "No validation rule accepted the value.",
44
- received: value,
45
- },
46
- ]);
37
+ // Never echo the rejected value: issues flow into a 400 response and
38
+ // into logs, so `received` would hand back the password or token that
39
+ // was just refused.
40
+ return failure(issues.length > 0 ? issues : [noValidatorSucceeded()]);
47
41
  };
48
42
  }
49
43
  /**
@@ -14,6 +14,18 @@ function itemHolds(constraint, value) {
14
14
  return false;
15
15
  }
16
16
  }
17
+ /**
18
+ * Whether any item's constraint result equals `want`, reading every index.
19
+ * `Array.prototype.every`/`some` skip holes, so `new Array(3)` passed any
20
+ * `everyItem` constraint.
21
+ */
22
+ function scanItems(values, constraint, want) {
23
+ for (let i = 0; i < values.length; i++) {
24
+ if (itemHolds(constraint, values[i]) === want)
25
+ return true;
26
+ }
27
+ return false;
28
+ }
17
29
  /**
18
30
  * Requires an array to contain at least a given number of items.
19
31
  */
@@ -54,7 +66,7 @@ export function exactItems(length) {
54
66
  * Requires every array item to satisfy a constraint.
55
67
  */
56
68
  export function everyItem(constraint) {
57
- return createConstraint((values) => values.every((value) => itemHolds(constraint, value)), {
69
+ return createConstraint((values) => !scanItems(values, constraint, false), {
58
70
  name: `every_${constraint.name}`,
59
71
  code: "item_constraint_failed",
60
72
  message: constraint.message,
@@ -65,7 +77,7 @@ export function everyItem(constraint) {
65
77
  * Requires at least one array item to satisfy a constraint.
66
78
  */
67
79
  export function someItem(constraint) {
68
- return createConstraint((values) => values.some((value) => itemHolds(constraint, value)), {
80
+ return createConstraint((values) => scanItems(values, constraint, true), {
69
81
  name: `some_${constraint.name}`,
70
82
  code: "some_item_constraint_failed",
71
83
  message: `At least one item must satisfy ${constraint.name}.`,
@@ -30,6 +30,9 @@ export declare function matches(pattern: RegExp, message?: string): ValidationCo
30
30
  *
31
31
  * Deliberately structural, not a full RFC 5322 parser: it rejects the shapes
32
32
  * that are certainly wrong and leaves deliverability to a verification step.
33
+ * Uses `ValidationPattern.EMAIL` from `@zudojs/constants`, the monorepo's one
34
+ * acceptance set (shared with `isEmail` in `@zudojs/types`). The 254-character
35
+ * bound is checked first so an oversized value is rejected without a scan.
33
36
  */
34
37
  export declare const email: ValidationConstraint<string>;
35
38
  /**
@@ -1,3 +1,4 @@
1
+ import { ValidationLength, ValidationPattern } from "@zudojs/constants";
1
2
  import { createConstraint, assertNonNegativeInteger, } from "../validationConstraints.base.js";
2
3
  /** Counts Unicode code points rather than UTF-16 code units. */
3
4
  function characterLength(value) {
@@ -77,8 +78,12 @@ export function matches(pattern, message = "Value has an invalid format.") {
77
78
  *
78
79
  * Deliberately structural, not a full RFC 5322 parser: it rejects the shapes
79
80
  * that are certainly wrong and leaves deliverability to a verification step.
81
+ * Uses `ValidationPattern.EMAIL` from `@zudojs/constants`, the monorepo's one
82
+ * acceptance set (shared with `isEmail` in `@zudojs/types`). The 254-character
83
+ * bound is checked first so an oversized value is rejected without a scan.
80
84
  */
81
- export const email = createConstraint((value) => /^[^\s@,;<>"[\]\\]+@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/iu.test(value) && !value.includes(".."), {
85
+ export const email = createConstraint((value) => value.length <= ValidationLength.EMAIL &&
86
+ ValidationPattern.EMAIL.test(value), {
82
87
  name: "email",
83
88
  code: "invalid_email",
84
89
  message: "Value must be a valid email address.",
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @zudojs/validation — Child enumeration for the structural guards.
3
+ */
4
+ /** Whether a value has children worth descending into. */
5
+ export declare function isContainer(value: unknown): value is object;
6
+ /**
7
+ * The child values of a container, as [pathSegment, value] pairs.
8
+ *
9
+ * Arrays are read by index. `Array.prototype.map` skips holes and returns a
10
+ * sparse result, so `[1, , 3]` used to yield an `undefined` pair that crashed
11
+ * every guard with a raw TypeError; a hole is now an `undefined` child, which
12
+ * is also how `JSON.stringify` writes it (as `null`).
13
+ */
14
+ export declare function childrenOf(value: object): Array<[string, unknown]>;
15
+ //# sourceMappingURL=validationConstraints.children.d.ts.map
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @zudojs/validation — Child enumeration for the structural guards.
3
+ */
4
+ /** Whether a value has children worth descending into. */
5
+ export function isContainer(value) {
6
+ return (typeof value === "object" && value !== null && !ArrayBuffer.isView(value));
7
+ }
8
+ /**
9
+ * The child values of a container, as [pathSegment, value] pairs.
10
+ *
11
+ * Arrays are read by index. `Array.prototype.map` skips holes and returns a
12
+ * sparse result, so `[1, , 3]` used to yield an `undefined` pair that crashed
13
+ * every guard with a raw TypeError; a hole is now an `undefined` child, which
14
+ * is also how `JSON.stringify` writes it (as `null`).
15
+ */
16
+ export function childrenOf(value) {
17
+ if (Array.isArray(value)) {
18
+ const children = [];
19
+ for (let index = 0; index < value.length; index++) {
20
+ children.push([`[${index}]`, value[index]]);
21
+ }
22
+ return children;
23
+ }
24
+ if (value instanceof Map) {
25
+ const children = [];
26
+ let index = 0;
27
+ for (const [key, entry] of value) {
28
+ children.push([`.key(${index})`, key], [`[${String(key)}]`, entry]);
29
+ index++;
30
+ }
31
+ return children;
32
+ }
33
+ if (value instanceof Set) {
34
+ return [...value].map((entry, index) => [`.item(${index})`, entry]);
35
+ }
36
+ if (value instanceof Date || value instanceof RegExp)
37
+ return [];
38
+ const record = value;
39
+ return Object.keys(record).map((key) => [`.${key}`, record[key]]);
40
+ }
41
+ //# sourceMappingURL=validationConstraints.children.js.map
@@ -43,6 +43,24 @@ function chargeFor(value) {
43
43
  keys.reduce((total, key) => total + key.length + 4, 0) +
44
44
  Math.max(0, keys.length - 1));
45
45
  }
46
+ /**
47
+ * What `JSON.stringify` will actually write for a node: the result of its
48
+ * `toJSON()` when it has one. Measuring the object's own keys instead let a
49
+ * class whose `toJSON` returns megabytes estimate at a dozen bytes. Dates and
50
+ * binary views keep their existing flat and byte-length charges.
51
+ */
52
+ function resolveToJson(value) {
53
+ if (typeof value !== "object" ||
54
+ value === null ||
55
+ value instanceof Date ||
56
+ ArrayBuffer.isView(value)) {
57
+ return value;
58
+ }
59
+ const toJSON = value.toJSON;
60
+ return typeof toJSON === "function"
61
+ ? toJSON.call(value, "")
62
+ : value;
63
+ }
46
64
  /**
47
65
  * Estimate the byte size of a value as JSON without allocating a string.
48
66
  *
@@ -61,6 +79,7 @@ export function estimateSerializedSize(value, maxBytes = Number.POSITIVE_INFINIT
61
79
  maxDepth: MAX_MEASURABLE_DEPTH,
62
80
  maxCost: maxBytes,
63
81
  charge: chargeFor,
82
+ resolve: resolveToJson,
64
83
  }).cost;
65
84
  }
66
85
  catch (error) {
@@ -86,6 +105,7 @@ export function assertSizeWithinLimit(value, maxSize) {
86
105
  maxDepth: MAX_MEASURABLE_DEPTH,
87
106
  maxCost: maxSize,
88
107
  charge: chargeFor,
108
+ resolve: resolveToJson,
89
109
  });
90
110
  }
91
111
  catch (error) {
@@ -17,15 +17,12 @@
17
17
  * exactly the deeply nested input the depth guard exists to reject, so the
18
18
  * check would fail inside itself before it could report anything.
19
19
  */
20
- /** Why a traversal stopped early. */
21
- export type TraversalHalt = "depth" | "budget" | "cycle";
22
- /** Signals that a traversal hit one of its bounds. */
23
- export declare class TraversalLimitError extends Error {
24
- readonly halt: TraversalHalt;
25
- readonly path: string;
26
- readonly observed: number;
27
- constructor(halt: TraversalHalt, path: string, observed: number);
28
- }
20
+ /**
21
+ * The halt signal is owned by `@zudojs/errors` (round 10 VAL-05/CV-02) and
22
+ * re-exported here, so the depth, size and circular guards keep importing
23
+ * it from this module.
24
+ */
25
+ export { TraversalLimitError, type TraversalHalt } from "@zudojs/errors";
29
26
  /** What the caller wants from each node. */
30
27
  export interface TraversalVisitor {
31
28
  /** Maximum nesting depth to descend before halting. */
@@ -36,6 +33,11 @@ export interface TraversalVisitor {
36
33
  readonly failOnCycle?: boolean;
37
34
  /** Cost contributed by a single node, excluding its children. */
38
35
  charge?(value: unknown): number;
36
+ /**
37
+ * Maps a node to the value actually walked in its place, e.g. the result
38
+ * of `toJSON()` when measuring what `JSON.stringify` will write.
39
+ */
40
+ resolve?(value: unknown): unknown;
39
41
  }
40
42
  /** What a completed traversal observed. */
41
43
  export interface TraversalReport {
@@ -17,45 +17,14 @@
17
17
  * exactly the deeply nested input the depth guard exists to reject, so the
18
18
  * check would fail inside itself before it could report anything.
19
19
  */
20
- /** Signals that a traversal hit one of its bounds. */
21
- export class TraversalLimitError extends Error {
22
- halt;
23
- path;
24
- observed;
25
- constructor(halt, path, observed) {
26
- super(`Traversal halted (${halt}) at ${path}`);
27
- this.halt = halt;
28
- this.path = path;
29
- this.observed = observed;
30
- this.name = "TraversalLimitError";
31
- }
32
- }
33
- /** Whether a value has children worth descending into. */
34
- function isContainer(value) {
35
- return (typeof value === "object" && value !== null && !ArrayBuffer.isView(value));
36
- }
37
- /** The child values of a container, as [pathSegment, value] pairs. */
38
- function childrenOf(value) {
39
- if (Array.isArray(value)) {
40
- return value.map((child, index) => [`[${index}]`, child]);
41
- }
42
- if (value instanceof Map) {
43
- const children = [];
44
- let index = 0;
45
- for (const [key, entry] of value) {
46
- children.push([`.key(${index})`, key], [`[${String(key)}]`, entry]);
47
- index++;
48
- }
49
- return children;
50
- }
51
- if (value instanceof Set) {
52
- return [...value].map((entry, index) => [`.item(${index})`, entry]);
53
- }
54
- if (value instanceof Date || value instanceof RegExp)
55
- return [];
56
- const record = value;
57
- return Object.keys(record).map((key) => [`.${key}`, record[key]]);
58
- }
20
+ import { TraversalLimitError } from "@zudojs/errors";
21
+ import { childrenOf, isContainer } from "./validationConstraints.children.js";
22
+ /**
23
+ * The halt signal is owned by `@zudojs/errors` (round 10 VAL-05/CV-02) and
24
+ * re-exported here, so the depth, size and circular guards keep importing
25
+ * it from this module.
26
+ */
27
+ export { TraversalLimitError } from "@zudojs/errors";
59
28
  /**
60
29
  * Walk a value graph within explicit depth and cost bounds.
61
30
  *
@@ -68,6 +37,12 @@ function childrenOf(value) {
68
37
  export function traverse(root, visitor, rootPath = "root") {
69
38
  const maxCost = visitor.maxCost ?? Number.POSITIVE_INFINITY;
70
39
  const onPath = new Set();
40
+ // Without a per-node charge, a subtree already walked from some depth need
41
+ // not be walked again from the same depth or a shallower one: it holds no
42
+ // cycle (the walk would have halted) and cannot reach deeper than before.
43
+ // Re-walking it made a DAG of n shared `[node, node]` pairs cost 2^n.
44
+ // Only cost accounting must expand every occurrence, as a serializer does.
45
+ const walkedAt = visitor.charge ? undefined : new Map();
71
46
  const stack = [{ value: root, depth: 0, path: rootPath }];
72
47
  let cost = 0;
73
48
  let deepest = 0;
@@ -77,7 +52,8 @@ export function traverse(root, visitor, rootPath = "root") {
77
52
  onPath.delete(frame.leave);
78
53
  continue;
79
54
  }
80
- const { value, depth, path } = frame;
55
+ const { depth, path } = frame;
56
+ const value = visitor.resolve ? visitor.resolve(frame.value) : frame.value;
81
57
  cost += visitor.charge?.(value) ?? 0;
82
58
  if (cost > maxCost)
83
59
  throw new TraversalLimitError("budget", path, cost);
@@ -85,15 +61,26 @@ export function traverse(root, visitor, rootPath = "root") {
85
61
  deepest = depth;
86
62
  if (!isContainer(value))
87
63
  continue;
64
+ // A container occupies the level below the one it sits at, so an empty
65
+ // `{}` at depth d reaches d + 1 — the same level at which the depth
66
+ // guard below refuses to descend into it. Reporting only leaf depths
67
+ // made `getSerializationDepth({})` 0 while `assertDepthWithinLimit({},
68
+ // 0)` threw.
69
+ if (depth + 1 > deepest)
70
+ deepest = depth + 1;
88
71
  if (onPath.has(value)) {
89
72
  if (visitor.failOnCycle) {
90
73
  throw new TraversalLimitError("cycle", path, depth);
91
74
  }
92
75
  continue;
93
76
  }
77
+ const previous = walkedAt?.get(value);
78
+ if (previous !== undefined && depth <= previous)
79
+ continue;
94
80
  if (depth >= visitor.maxDepth) {
95
81
  throw new TraversalLimitError("depth", path, depth + 1);
96
82
  }
83
+ walkedAt?.set(value, depth);
97
84
  onPath.add(value);
98
85
  stack.push({ value: undefined, depth, path, leave: value });
99
86
  const children = childrenOf(value);
@@ -88,10 +88,23 @@ export function combineConstraints(...constraints) {
88
88
  * Creates a negated constraint.
89
89
  */
90
90
  export function not(constraint, options = {}) {
91
- return createConstraint((value) => !runConstraint(constraint, value), {
91
+ // Negation must not fail open. A value of the wrong type, or one that makes
92
+ // the inner check throw, used to count as "does not satisfy" and pass, so
93
+ // `not(matches(/<script/))` accepted `["<script>"]`. The inner guard is
94
+ // carried over and a throw is a failure.
95
+ const guard = options.guard ?? constraint.guard;
96
+ return createConstraint((value) => {
97
+ try {
98
+ return !constraint.validate(value);
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }, {
92
104
  name: options.name ?? `not_${constraint.name}`,
93
105
  code: options.code ?? "negated_constraint_failed",
94
106
  message: options.message ?? `Value must not satisfy ${constraint.name}.`,
107
+ ...(guard ? { guard } : {}),
95
108
  });
96
109
  }
97
110
  /**
@@ -2,7 +2,7 @@
2
2
  * Base validation error and error codes.
3
3
  */
4
4
  import type { ValidationIssue } from "../validationResult/validationResult.type.js";
5
- import { BaseError, ErrorCategory, ErrorSeverity, type ErrorMetadata } from "@zudojs/errors";
5
+ import { ValidationError as SharedValidationError, ErrorCategory, ErrorSeverity, type ErrorMetadata } from "@zudojs/errors";
6
6
  /** Error codes used by the validation package. */
7
7
  export declare enum ValidationErrorCode {
8
8
  INVALID_INPUT = "VALIDATION_INVALID_INPUT",
@@ -20,8 +20,15 @@ export interface ValidationErrorOptions {
20
20
  readonly cause?: unknown;
21
21
  readonly context?: Readonly<Record<string, unknown>>;
22
22
  }
23
- /** Base error for validation failures. */
24
- export declare class ValidationError extends BaseError {
23
+ /**
24
+ * Base error for validation failures.
25
+ *
26
+ * A thin subclass of `@zudojs/errors`' `ValidationError`, so a consumer that
27
+ * catches the shared class (or calls its `isValidationError`) also catches
28
+ * errors thrown by this package. It used to extend `BaseError` directly and
29
+ * was an unrelated class with the same name.
30
+ */
31
+ export declare class ValidationError extends SharedValidationError {
25
32
  readonly validationCode: ValidationErrorCode;
26
33
  readonly issues: readonly ValidationIssue[];
27
34
  readonly context?: Readonly<Record<string, unknown>>;
@@ -31,6 +38,11 @@ export declare class ValidationError extends BaseError {
31
38
  get fieldErrors(): Readonly<Record<string, string>>;
32
39
  /** Returns a formatted representation of all issues. */
33
40
  get formattedIssues(): string;
41
+ /**
42
+ * Serializes the error. `issues` and `context` come from the base
43
+ * `toJSON()`, which redacts submitted issue values and sensitive metadata
44
+ * keys; re-adding the raw fields here used to undo that redaction.
45
+ */
34
46
  toJSON(): {
35
47
  code: string;
36
48
  category: ErrorCategory;
@@ -38,14 +50,14 @@ export declare class ValidationError extends BaseError {
38
50
  statusCode: number;
39
51
  expose: boolean;
40
52
  isOperational: boolean;
41
- metadata: Readonly<ErrorMetadata>;
53
+ metadata: Readonly<import("@zudojs/errors").ErrorMetadata>;
42
54
  stack?: string;
43
55
  cause?: import("@zudojs/errors").SerializedBaseError | unknown;
56
+ issues: readonly import("@zudojs/errors").ValidationIssue[];
44
57
  name: string;
45
58
  validationCode: ValidationErrorCode;
46
59
  message: string;
47
- issues: readonly ValidationIssue[];
48
- context?: Readonly<Record<string, unknown>> | undefined;
60
+ context?: Readonly<ErrorMetadata> | undefined;
49
61
  timestamp: number;
50
62
  };
51
63
  }
@@ -2,7 +2,7 @@
2
2
  * Base validation error and error codes.
3
3
  */
4
4
  import { formatIssues, toFieldErrors, } from "../validationResult/validationResult.type.js";
5
- import { BaseError, ErrorCode, ErrorCategory, ErrorSeverity, } from "@zudojs/errors";
5
+ import { ValidationError as SharedValidationError, ErrorCode, ErrorCategory, ErrorSeverity, } from "@zudojs/errors";
6
6
  /**
7
7
  * Maps a package-specific validation code to the shared error registry code.
8
8
  *
@@ -42,10 +42,16 @@ export var ValidationErrorCode;
42
42
  ValidationErrorCode["SCHEMA_FAILED"] = "VALIDATION_SCHEMA_FAILED";
43
43
  ValidationErrorCode["UNKNOWN"] = "VALIDATION_UNKNOWN";
44
44
  })(ValidationErrorCode || (ValidationErrorCode = {}));
45
- /** Base error for validation failures. */
46
- export class ValidationError extends BaseError {
45
+ /**
46
+ * Base error for validation failures.
47
+ *
48
+ * A thin subclass of `@zudojs/errors`' `ValidationError`, so a consumer that
49
+ * catches the shared class (or calls its `isValidationError`) also catches
50
+ * errors thrown by this package. It used to extend `BaseError` directly and
51
+ * was an unrelated class with the same name.
52
+ */
53
+ export class ValidationError extends SharedValidationError {
47
54
  validationCode;
48
- issues;
49
55
  context;
50
56
  timestamp;
51
57
  constructor(message, issues = [], options = {}) {
@@ -57,10 +63,10 @@ export class ValidationError extends BaseError {
57
63
  expose: true,
58
64
  cause: options.cause,
59
65
  metadata: { ...options.context },
66
+ issues,
60
67
  });
61
68
  this.name = "ValidationError";
62
69
  this.validationCode = options.code ?? ValidationErrorCode.UNKNOWN;
63
- this.issues = Object.freeze([...issues]);
64
70
  this.context = options.context;
65
71
  this.timestamp = Date.now();
66
72
  }
@@ -72,14 +78,19 @@ export class ValidationError extends BaseError {
72
78
  get formattedIssues() {
73
79
  return formatIssues(this.issues);
74
80
  }
81
+ /**
82
+ * Serializes the error. `issues` and `context` come from the base
83
+ * `toJSON()`, which redacts submitted issue values and sensitive metadata
84
+ * keys; re-adding the raw fields here used to undo that redaction.
85
+ */
75
86
  toJSON() {
87
+ const base = super.toJSON();
76
88
  return {
77
- ...super.toJSON(),
89
+ ...base,
78
90
  name: this.name,
79
91
  validationCode: this.validationCode,
80
92
  message: this.message,
81
- issues: this.issues,
82
- ...(this.context ? { context: this.context } : {}),
93
+ ...(this.context ? { context: base.metadata } : {}),
83
94
  timestamp: this.timestamp,
84
95
  };
85
96
  }
@@ -39,7 +39,9 @@ export declare class ValidationRegistry {
39
39
  * Constraints receive whatever the caller passed, which at a trust boundary
40
40
  * is arbitrary JSON. `checkConstraints` guards and catches internally, so a
41
41
  * wrong-typed value reports as a validation failure rather than escaping as
42
- * a `TypeError` and turning a 400 into a 500.
42
+ * a `TypeError` and turning a 400 into a 500. A rule with both `schema`
43
+ * and `constraints` runs the schema first, then the constraints on its
44
+ * parsed output.
43
45
  */
44
46
  validate<T>(name: string, value: unknown): ValidationResult<T>;
45
47
  clone(): ValidationRegistry;
@@ -66,14 +66,23 @@ export class ValidationRegistry {
66
66
  * Constraints receive whatever the caller passed, which at a trust boundary
67
67
  * is arbitrary JSON. `checkConstraints` guards and catches internally, so a
68
68
  * wrong-typed value reports as a validation failure rather than escaping as
69
- * a `TypeError` and turning a 400 into a 500.
69
+ * a `TypeError` and turning a 400 into a 500. A rule with both `schema`
70
+ * and `constraints` runs the schema first, then the constraints on its
71
+ * parsed output.
70
72
  */
71
73
  validate(name, value) {
72
74
  const rule = this.require(name);
73
- if (rule.schema)
74
- return validate(rule.schema, value);
75
- if (rule.constraints && rule.constraints.length > 0)
76
- return checkConstraints(rule.constraints, value);
75
+ const constraints = rule.constraints ?? [];
76
+ // A rule may declare both. The constraints used to be skipped whenever a
77
+ // schema was present; now they run on the schema's parsed output.
78
+ if (rule.schema) {
79
+ const parsed = validate(rule.schema, value);
80
+ if (!parsed.success || constraints.length === 0)
81
+ return parsed;
82
+ return checkConstraints(constraints, parsed.data);
83
+ }
84
+ if (constraints.length > 0)
85
+ return checkConstraints(constraints, value);
77
86
  throw new TypeError(`Validation rule "${name}" has no validation implementation.`);
78
87
  }
79
88
  clone() {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Validation result types and helpers.
3
3
  */
4
- import { BaseError, ErrorCategory, ErrorSeverity } from "@zudojs/errors";
4
+ import { ValidationError as SharedValidationError, ErrorCategory, ErrorSeverity } from "@zudojs/errors";
5
5
  /** A single validation issue. */
6
6
  export interface ValidationIssue {
7
7
  readonly path: readonly (string | number)[];
@@ -58,10 +58,14 @@ export declare function map<T, U>(result: ValidationResult<T>, fn: (data: T) =>
58
58
  export declare function combine<T extends readonly unknown[]>(results: {
59
59
  [K in keyof T]: ValidationResult<T[K]>;
60
60
  }): ValidationResult<T>;
61
- /** Error thrown when attempting to unwrap a failed validation result. */
62
- export declare class ValidationResultError extends BaseError {
61
+ /**
62
+ * Error thrown when attempting to unwrap a failed validation result.
63
+ * Extends `@zudojs/errors`' `ValidationError`.
64
+ */
65
+ export declare class ValidationResultError extends SharedValidationError {
63
66
  readonly issues: readonly ValidationIssue[];
64
67
  constructor(issues: readonly ValidationIssue[]);
68
+ /** Serializes the error; `issues` keep the base class's value redaction. */
65
69
  toJSON(): {
66
70
  code: string;
67
71
  category: ErrorCategory;
@@ -72,9 +76,9 @@ export declare class ValidationResultError extends BaseError {
72
76
  metadata: Readonly<import("@zudojs/errors").ErrorMetadata>;
73
77
  stack?: string;
74
78
  cause?: import("@zudojs/errors").SerializedBaseError | unknown;
79
+ issues: readonly import("@zudojs/errors").ValidationIssue[];
75
80
  name: string;
76
81
  message: string;
77
- issues: readonly ValidationIssue[];
78
82
  };
79
83
  }
80
84
  //# sourceMappingURL=validationResult.type.d.ts.map
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Validation result types and helpers.
3
3
  */
4
- import { BaseError, ErrorCode, ErrorCategory, ErrorSeverity, } from "@zudojs/errors";
4
+ import { ValidationError as SharedValidationError, ErrorCode, ErrorCategory, ErrorSeverity, } from "@zudojs/errors";
5
5
  /** Formats validation issues into a human-readable string. */
6
6
  export function formatIssues(issues) {
7
7
  if (issues.length === 0)
@@ -96,9 +96,11 @@ export function combine(results) {
96
96
  ? failure(failures)
97
97
  : success(data);
98
98
  }
99
- /** Error thrown when attempting to unwrap a failed validation result. */
100
- export class ValidationResultError extends BaseError {
101
- issues;
99
+ /**
100
+ * Error thrown when attempting to unwrap a failed validation result.
101
+ * Extends `@zudojs/errors`' `ValidationError`.
102
+ */
103
+ export class ValidationResultError extends SharedValidationError {
102
104
  constructor(issues) {
103
105
  super(formatIssues(issues) || "Validation failed.", {
104
106
  code: ErrorCode.VALIDATION_FAILED,
@@ -107,16 +109,16 @@ export class ValidationResultError extends BaseError {
107
109
  statusCode: 400,
108
110
  expose: true,
109
111
  metadata: { issueCount: issues.length },
112
+ issues,
110
113
  });
111
114
  this.name = "ValidationResultError";
112
- this.issues = Object.freeze([...issues]);
113
115
  }
116
+ /** Serializes the error; `issues` keep the base class's value redaction. */
114
117
  toJSON() {
115
118
  return {
116
119
  ...super.toJSON(),
117
120
  name: this.name,
118
121
  message: this.message,
119
- issues: this.issues,
120
122
  };
121
123
  }
122
124
  }
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@zudojs/validation",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Schema validation with Zod integration, constraints, parsers, composers, circular detection, and depth/size checks.",
5
5
  "license": "MIT",
6
+ "author": {
7
+ "name": "Oluwayemi Oyinlola",
8
+ "url": "https://github.com/oyinlola-tech"
9
+ },
6
10
  "type": "module",
7
11
  "main": "./dist/index.js",
8
12
  "module": "./dist/index.js",
@@ -21,8 +25,9 @@
21
25
  "!dist/.tsbuildinfo"
22
26
  ],
23
27
  "dependencies": {
24
- "zod": "^4.4.3",
25
- "@zudojs/errors": "1.0.0"
28
+ "@zudojs/constants": "1.1.0",
29
+ "@zudojs/errors": "1.1.0",
30
+ "zod": "^4.4.3"
26
31
  },
27
32
  "devDependencies": {
28
33
  "typescript": "7.0.2",