@c9up/rune 0.1.5 → 0.1.7
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/MessagesProvider.d.ts +40 -0
- package/dist/MessagesProvider.d.ts.map +1 -0
- package/dist/MessagesProvider.js +77 -0
- package/dist/MessagesProvider.js.map +1 -0
- package/dist/Schema.d.ts +226 -27
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +688 -134
- package/dist/Schema.js.map +1 -1
- package/dist/errors.d.ts +32 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +27 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +3 -1
- package/src/MessagesProvider.ts +115 -0
- package/src/Schema.ts +965 -154
- package/src/errors.ts +48 -0
- package/src/index.ts +9 -1
package/src/Schema.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* @implements FR38, FR39, FR40, FR41
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import type { RuneErrorNode } from "./errors.js";
|
|
8
|
+
import { RuneError, RuneValidationError } from "./errors.js";
|
|
9
|
+
import type { MessagesProviderContract } from "./MessagesProvider.js";
|
|
8
10
|
import {
|
|
9
11
|
isNativeAvailable,
|
|
10
12
|
validateNative,
|
|
@@ -21,6 +23,81 @@ export interface ValidationError {
|
|
|
21
23
|
field: string;
|
|
22
24
|
rule: string;
|
|
23
25
|
message: string;
|
|
26
|
+
/** Array index when the field is an array item (VineJS parity). */
|
|
27
|
+
index?: number;
|
|
28
|
+
/** Rule metadata carried for reporters/i18n (e.g. `{ min: 3 }`). */
|
|
29
|
+
meta?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Field context handed to `.use()` rules — mirrors VineJS's field context. It
|
|
34
|
+
* exposes the value plus the surrounding data so a rule can validate across
|
|
35
|
+
* fields (e.g. `password === passwordConfirmation`), and a `report()` sink to
|
|
36
|
+
* raise errors (VineJS reports instead of returning a boolean).
|
|
37
|
+
*/
|
|
38
|
+
export interface FieldContext {
|
|
39
|
+
/** The current field value (post-transform). */
|
|
40
|
+
value: unknown;
|
|
41
|
+
/** The root object being validated. Shared across fields — do NOT mutate. */
|
|
42
|
+
data: Record<string, unknown>;
|
|
43
|
+
/** The immediate parent container of this field (object or array). */
|
|
44
|
+
parent: Record<string, unknown> | unknown[];
|
|
45
|
+
/** Dotted path to the field, e.g. `address.city` or `tags.0`. */
|
|
46
|
+
field: string;
|
|
47
|
+
/** Runtime metadata passed to `validate(data, { meta })`. */
|
|
48
|
+
meta: Record<string, unknown>;
|
|
49
|
+
/** `true` while no error has been reported for this field yet. */
|
|
50
|
+
isValid: boolean;
|
|
51
|
+
/** Report a validation failure for this field. */
|
|
52
|
+
report(message: string, rule: string): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A `.use()` rule validator — VineJS shape `(value, options, field)`. Report
|
|
57
|
+
* failures via `field.report(...)`; the return value is ignored.
|
|
58
|
+
*/
|
|
59
|
+
export type RuleValidator<Options = undefined> = (
|
|
60
|
+
value: unknown,
|
|
61
|
+
options: Options,
|
|
62
|
+
field: FieldContext,
|
|
63
|
+
) => void;
|
|
64
|
+
|
|
65
|
+
/** A compiled `.use()` rule produced by {@link createRule}. */
|
|
66
|
+
export interface CompiledRule {
|
|
67
|
+
readonly __rune: "rule";
|
|
68
|
+
run(value: unknown, field: FieldContext): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Turn a validator function into a reusable `.use()` rule — VineJS's
|
|
73
|
+
* `createRule`. Returns a factory: call it with the rule's options to get a
|
|
74
|
+
* `CompiledRule`, then attach it with `chain.use(rule(options))`.
|
|
75
|
+
*
|
|
76
|
+
* const sameAs = createRule<string>((value, other, field) => {
|
|
77
|
+
* if (value !== field.data[other]) {
|
|
78
|
+
* field.report(`Must match ${other}`, 'sameAs')
|
|
79
|
+
* }
|
|
80
|
+
* })
|
|
81
|
+
* schema({
|
|
82
|
+
* password: rules.string().min(8),
|
|
83
|
+
* passwordConfirmation: rules.string().use(sameAs('password')),
|
|
84
|
+
* })
|
|
85
|
+
*/
|
|
86
|
+
export function createRule(
|
|
87
|
+
validator: RuleValidator<undefined>,
|
|
88
|
+
): () => CompiledRule;
|
|
89
|
+
export function createRule<Options>(
|
|
90
|
+
validator: RuleValidator<Options>,
|
|
91
|
+
): (options: Options) => CompiledRule;
|
|
92
|
+
export function createRule<Options>(
|
|
93
|
+
validator: RuleValidator<Options>,
|
|
94
|
+
): (options: Options) => CompiledRule {
|
|
95
|
+
return (options: Options): CompiledRule => ({
|
|
96
|
+
__rune: "rule",
|
|
97
|
+
run(value: unknown, field: FieldContext): void {
|
|
98
|
+
validator(value, options, field);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
24
101
|
}
|
|
25
102
|
|
|
26
103
|
/**
|
|
@@ -32,17 +109,57 @@ export type ValidationResult<T = Record<string, unknown>> =
|
|
|
32
109
|
| { valid: true; errors: ValidationError[]; data: T }
|
|
33
110
|
| { valid: false; errors: ValidationError[]; data?: undefined };
|
|
34
111
|
|
|
112
|
+
/** Options for {@link ValidationSchema.validate}. */
|
|
113
|
+
export interface ValidateOptions {
|
|
114
|
+
/** Runtime metadata exposed to `.use()` rules via `field.meta` (VineJS parity). */
|
|
115
|
+
meta?: Record<string, unknown>;
|
|
116
|
+
/**
|
|
117
|
+
* VineJS-style messages provider. When supplied, default rule messages are
|
|
118
|
+
* resolved through it (custom `.message()` overrides still win, and the
|
|
119
|
+
* provider takes precedence over a globally bound translator).
|
|
120
|
+
*/
|
|
121
|
+
messagesProvider?: MessagesProviderContract;
|
|
122
|
+
}
|
|
123
|
+
|
|
35
124
|
export interface ValidationSchema<T = Record<string, unknown>> {
|
|
36
125
|
fields: Record<string, RuleChain>;
|
|
37
|
-
|
|
126
|
+
/** Result-based validation (superset) — never throws. */
|
|
127
|
+
validate(data: unknown, options?: ValidateOptions): ValidationResult<T>;
|
|
128
|
+
/**
|
|
129
|
+
* Throwing validation (VineJS/Adonis parity). Returns the validated data on
|
|
130
|
+
* success; throws {@link RuneValidationError} (`E_VALIDATION_ERROR`, HTTP 422)
|
|
131
|
+
* with a structured `.messages` array on failure.
|
|
132
|
+
*/
|
|
133
|
+
validateOrThrow(data: unknown, options?: ValidateOptions): T;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Context threaded through validation so field rules can reach root/parent/meta. */
|
|
137
|
+
interface RunContext {
|
|
138
|
+
data: Record<string, unknown>;
|
|
139
|
+
parent: Record<string, unknown> | unknown[];
|
|
140
|
+
meta: Record<string, unknown>;
|
|
141
|
+
messagesProvider?: MessagesProviderContract;
|
|
38
142
|
}
|
|
39
143
|
|
|
144
|
+
/** Default context for internal callers that don't supply one (no root available). */
|
|
145
|
+
const EMPTY_RUN_CONTEXT: RunContext = { data: {}, parent: {}, meta: {} };
|
|
146
|
+
|
|
40
147
|
/** Type guard: narrows `unknown` to a plain object (non-null, non-array, typeof 'object'). */
|
|
41
148
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
42
149
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
150
|
}
|
|
44
151
|
|
|
45
|
-
/**
|
|
152
|
+
/**
|
|
153
|
+
* Rules the Rust validation engine (`crates/rune-engine/src/engine.rs`)
|
|
154
|
+
* ACTUALLY implements. A schema built from only these rules can be validated
|
|
155
|
+
* natively; anything else routes to the TS path (`rule.validate`).
|
|
156
|
+
*
|
|
157
|
+
* CRITICAL: a rule name here that the Rust engine does not implement is a
|
|
158
|
+
* SILENT VALIDATION BYPASS — the engine's `_ => {}` arm skips unknown rules, so
|
|
159
|
+
* the constraint never runs. Every entry MUST have a matching arm in engine.rs.
|
|
160
|
+
* The TS chain rules (minLength/uuid/alpha/in/enum/range/…) live only in the TS
|
|
161
|
+
* validator, so they are deliberately absent here.
|
|
162
|
+
*/
|
|
46
163
|
const STANDARD_RULES: ReadonlySet<string> = new Set([
|
|
47
164
|
"string",
|
|
48
165
|
"number",
|
|
@@ -51,9 +168,23 @@ const STANDARD_RULES: ReadonlySet<string> = new Set([
|
|
|
51
168
|
"max",
|
|
52
169
|
"email",
|
|
53
170
|
"positive",
|
|
171
|
+
"minLength",
|
|
172
|
+
"maxLength",
|
|
173
|
+
"fixedLength",
|
|
174
|
+
"uuid",
|
|
175
|
+
"alpha",
|
|
176
|
+
"alphaNumeric",
|
|
177
|
+
"startsWith",
|
|
178
|
+
"endsWith",
|
|
179
|
+
"in",
|
|
180
|
+
"notIn",
|
|
181
|
+
"enum",
|
|
182
|
+
"negative",
|
|
183
|
+
"nonNegative",
|
|
184
|
+
"range",
|
|
54
185
|
]);
|
|
55
186
|
|
|
56
|
-
/** Default messages for standard rules — used
|
|
187
|
+
/** Default messages for standard rules — used only for translator-key fallback. */
|
|
57
188
|
const STANDARD_MSGS: Readonly<Record<string, string>> = {
|
|
58
189
|
string: "Must be a string",
|
|
59
190
|
number: "Must be a number",
|
|
@@ -62,6 +193,20 @@ const STANDARD_MSGS: Readonly<Record<string, string>> = {
|
|
|
62
193
|
max: "Maximum",
|
|
63
194
|
email: "Must be a valid email",
|
|
64
195
|
positive: "Must be positive",
|
|
196
|
+
minLength: "Too short",
|
|
197
|
+
maxLength: "Too long",
|
|
198
|
+
fixedLength: "Wrong length",
|
|
199
|
+
alpha: "Must contain only letters",
|
|
200
|
+
alphaNumeric: "Must contain only letters and numbers",
|
|
201
|
+
startsWith: "Invalid prefix",
|
|
202
|
+
endsWith: "Invalid suffix",
|
|
203
|
+
uuid: "Must be a valid UUID",
|
|
204
|
+
in: "Invalid value",
|
|
205
|
+
notIn: "Invalid value",
|
|
206
|
+
enum: "Invalid value",
|
|
207
|
+
range: "Out of range",
|
|
208
|
+
negative: "Must be negative",
|
|
209
|
+
nonNegative: "Must be positive or zero",
|
|
65
210
|
};
|
|
66
211
|
|
|
67
212
|
const TYPE_RULE_NAMES: ReadonlySet<string> = new Set([
|
|
@@ -73,15 +218,8 @@ const TYPE_RULE_NAMES: ReadonlySet<string> = new Set([
|
|
|
73
218
|
]);
|
|
74
219
|
let validationTranslator: ValidationTranslator | undefined;
|
|
75
220
|
|
|
76
|
-
function
|
|
77
|
-
|
|
78
|
-
if (typeof rule.param !== "number") return false;
|
|
79
|
-
const expected = `${rule.name === "min" ? "Minimum" : "Maximum"} ${rule.param}`;
|
|
80
|
-
return rule.message === expected;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const defaultMsg = STANDARD_MSGS[rule.name];
|
|
84
|
-
return defaultMsg !== undefined && rule.message === defaultMsg;
|
|
221
|
+
function hasCustomMessage(rule: RuleDef): boolean {
|
|
222
|
+
return rule.hasCustomMessage === true;
|
|
85
223
|
}
|
|
86
224
|
|
|
87
225
|
function resolveValidationMessage(
|
|
@@ -96,104 +234,213 @@ function resolveValidationMessage(
|
|
|
96
234
|
return fallback;
|
|
97
235
|
}
|
|
98
236
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
237
|
+
/** Args carried on a rule, exposed to i18n/providers via message interpolation. */
|
|
238
|
+
function ruleArgs(rule: RuleDef): Record<string, unknown> | undefined {
|
|
239
|
+
return rule.args;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Resolve the final message for a failing rule. Precedence:
|
|
244
|
+
* 1. explicit `.message()` override (always wins),
|
|
245
|
+
* 2. a per-call {@link MessagesProviderContract} (VineJS parity),
|
|
246
|
+
* 3. a globally bound translator (rune's rosetta superset),
|
|
247
|
+
* 4. the rule's raw default message.
|
|
248
|
+
*/
|
|
249
|
+
function resolveRuleMessage(
|
|
250
|
+
field: string,
|
|
251
|
+
rule: RuleDef,
|
|
252
|
+
ctx: RunContext,
|
|
253
|
+
): string {
|
|
254
|
+
if (hasCustomMessage(rule)) {
|
|
255
|
+
return rule.message;
|
|
103
256
|
}
|
|
104
|
-
|
|
105
|
-
|
|
257
|
+
|
|
258
|
+
const args = ruleArgs(rule);
|
|
259
|
+
if (ctx.messagesProvider) {
|
|
260
|
+
return ctx.messagesProvider.getMessage(
|
|
261
|
+
rule.message,
|
|
262
|
+
rule.name,
|
|
263
|
+
field,
|
|
264
|
+
args,
|
|
265
|
+
);
|
|
106
266
|
}
|
|
107
267
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
params.min = rule.param;
|
|
268
|
+
if (!STANDARD_RULES.has(rule.name)) {
|
|
269
|
+
return rule.message;
|
|
111
270
|
}
|
|
112
|
-
|
|
113
|
-
|
|
271
|
+
|
|
272
|
+
const params: ValidationMessageParams = { field };
|
|
273
|
+
if (typeof rule.param === "number") {
|
|
274
|
+
if (rule.name === "min" || rule.name === "minLength")
|
|
275
|
+
params.min = rule.param;
|
|
276
|
+
if (rule.name === "max" || rule.name === "maxLength")
|
|
277
|
+
params.max = rule.param;
|
|
114
278
|
}
|
|
279
|
+
return resolveValidationMessage(
|
|
280
|
+
`validation.${rule.name}`,
|
|
281
|
+
rule.message,
|
|
282
|
+
params,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
115
285
|
|
|
116
|
-
|
|
286
|
+
/** Resolve the "required" message through provider → translator → fallback. */
|
|
287
|
+
function resolveRequiredMessage(field: string, ctx: RunContext): string {
|
|
288
|
+
if (ctx.messagesProvider) {
|
|
289
|
+
return ctx.messagesProvider.getMessage(
|
|
290
|
+
`${field} is required`,
|
|
291
|
+
"required",
|
|
292
|
+
field,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
return resolveValidationMessage(
|
|
296
|
+
"validation.required",
|
|
297
|
+
`${field} is required`,
|
|
298
|
+
{ field },
|
|
299
|
+
);
|
|
117
300
|
}
|
|
118
301
|
|
|
119
302
|
/** Compute once: does any field rule prevent dispatching to Rust? */
|
|
120
303
|
function detectHasCustomRules(fields: Record<string, RuleChain>): boolean {
|
|
121
304
|
return Object.values(fields).some((chain) => {
|
|
305
|
+
if (chain.useRules.length > 0) return true; // .use() rule — TS-only (Rust can't run JS)
|
|
306
|
+
if (chain.hasConditionalRequired) return true; // requiredWhen — TS-only
|
|
307
|
+
if (chain.preTransforms.length > 0) return true; // .parse() — TS-only
|
|
308
|
+
if (chain.transforms.length > 0) return true; // .transform() — Rust gets only the NAME, can't run a JS fn
|
|
309
|
+
if (chain.isNullable) return true; // .nullable() — the flag is not sent to the Rust engine
|
|
122
310
|
return chain.rules.some((r) => {
|
|
123
311
|
if (!STANDARD_RULES.has(r.name)) return true; // custom rule
|
|
124
|
-
if (
|
|
312
|
+
if (hasCustomMessage(r)) return true; // custom message
|
|
125
313
|
return false;
|
|
126
314
|
});
|
|
127
315
|
});
|
|
128
316
|
}
|
|
129
317
|
|
|
318
|
+
/** Extract the phantom output type of a chain. */
|
|
319
|
+
type OutputOf<C> = C extends RuleChain<infer O> ? O : never;
|
|
320
|
+
/** Keys whose output includes `undefined` become optional in the inferred shape. */
|
|
321
|
+
type OptionalKeys<S> = {
|
|
322
|
+
[K in keyof S]: undefined extends OutputOf<S[K]> ? K : never;
|
|
323
|
+
}[keyof S];
|
|
324
|
+
/** Flatten an intersection into a single readable object type. */
|
|
325
|
+
type Prettify<T> = { [K in keyof T]: T[K] } & unknown;
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Infer the validated data shape from a schema's field map — the type
|
|
329
|
+
* `result.data` carries once `result.valid === true`. `rules.string()` →
|
|
330
|
+
* `string`, `.optional()` → an optional key, `.nullable()` → `T | null`,
|
|
331
|
+
* `.object(shape)`/`.array(item)` recurse.
|
|
332
|
+
*/
|
|
333
|
+
export type Infer<S> = Prettify<
|
|
334
|
+
{
|
|
335
|
+
[K in Exclude<keyof S, OptionalKeys<S>>]: OutputOf<S[K]>;
|
|
336
|
+
} & {
|
|
337
|
+
[K in OptionalKeys<S>]?: Exclude<OutputOf<S[K]>, undefined>;
|
|
338
|
+
}
|
|
339
|
+
>;
|
|
340
|
+
|
|
130
341
|
/**
|
|
131
342
|
* Create a validation schema.
|
|
132
343
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
344
|
+
* The field map's rule chains are phantom-typed, so `result.data` is inferred
|
|
345
|
+
* automatically — `schema({ email: rules.string(), age: rules.number() })`
|
|
346
|
+
* types `data` as `{ email: string; age: number }` with no manual generic.
|
|
136
347
|
*
|
|
137
|
-
* const RegisterValidator = schema
|
|
348
|
+
* const RegisterValidator = schema({
|
|
138
349
|
* email: rules.string().email(),
|
|
139
|
-
*
|
|
350
|
+
* age: rules.number().optional(),
|
|
140
351
|
* });
|
|
352
|
+
* // Infer<typeof RegisterValidator> not needed — result.data is typed.
|
|
141
353
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* own narrowing continue to compile.
|
|
354
|
+
* An explicit generic is still accepted for back-compat
|
|
355
|
+
* (`schema<MyType>({ ... })`), overriding inference.
|
|
145
356
|
*/
|
|
357
|
+
export function schema<S extends Record<string, RuleChain>>(
|
|
358
|
+
fields: S,
|
|
359
|
+
): ValidationSchema<Infer<S>>;
|
|
146
360
|
export function schema<T = Record<string, unknown>>(
|
|
147
361
|
fields: Record<string, RuleChain>,
|
|
148
|
-
): ValidationSchema<T
|
|
362
|
+
): ValidationSchema<T>;
|
|
363
|
+
export function schema(
|
|
364
|
+
fields: Record<string, RuleChain>,
|
|
365
|
+
): ValidationSchema<Record<string, unknown>> {
|
|
149
366
|
// Computed once at construction time, not per validate() call.
|
|
150
367
|
const hasCustomRules = detectHasCustomRules(fields);
|
|
151
368
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
],
|
|
165
|
-
};
|
|
166
|
-
}
|
|
369
|
+
function validate(
|
|
370
|
+
data: unknown,
|
|
371
|
+
options?: ValidateOptions,
|
|
372
|
+
): ValidationResult<Record<string, unknown>> {
|
|
373
|
+
if (!isPlainObject(data)) {
|
|
374
|
+
return {
|
|
375
|
+
valid: false,
|
|
376
|
+
errors: [
|
|
377
|
+
{ field: "_root", rule: "type", message: "Input must be an object" },
|
|
378
|
+
],
|
|
379
|
+
};
|
|
380
|
+
}
|
|
167
381
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
// This schema would have used the native engine, but it isn't
|
|
173
|
-
// loaded — surface the platform-dependent TS fallback once instead
|
|
174
|
-
// of diverging silently. (Schemas with custom rules / a translator
|
|
175
|
-
// always run on TS by design and don't warn.)
|
|
176
|
-
warnNativeUnavailableOnce();
|
|
382
|
+
const provider = options?.messagesProvider;
|
|
383
|
+
if (!hasCustomRules && !validationTranslator && !provider) {
|
|
384
|
+
if (isNativeAvailable()) {
|
|
385
|
+
return validateWithRust(fields, data);
|
|
177
386
|
}
|
|
387
|
+
// This schema would have used the native engine, but it isn't loaded —
|
|
388
|
+
// surface the platform-dependent TS fallback once instead of diverging
|
|
389
|
+
// silently.
|
|
390
|
+
warnNativeUnavailableOnce();
|
|
391
|
+
}
|
|
178
392
|
|
|
179
|
-
|
|
180
|
-
|
|
393
|
+
const errors: ValidationError[] = [];
|
|
394
|
+
const validated: Record<string, unknown> = {};
|
|
395
|
+
const rootCtx: RunContext = {
|
|
396
|
+
data,
|
|
397
|
+
parent: data,
|
|
398
|
+
meta: options?.meta ?? {},
|
|
399
|
+
messagesProvider: provider,
|
|
400
|
+
};
|
|
181
401
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
402
|
+
for (const [field, chain] of Object.entries(fields)) {
|
|
403
|
+
const value = data[field];
|
|
404
|
+
const result = chain._validateWithTransform(field, value, rootCtx);
|
|
405
|
+
errors.push(...result.errors);
|
|
406
|
+
// Gate on the TRANSFORMED result, not the raw input: a pre-transform
|
|
407
|
+
// (`parse(() => 42)`) can produce a value for an absent field, and that
|
|
408
|
+
// value must land in `data` — testing the raw `value` dropped it.
|
|
409
|
+
if (result.errors.length === 0 && result.transformed !== undefined) {
|
|
410
|
+
validated[field] = result.transformed;
|
|
189
411
|
}
|
|
412
|
+
}
|
|
190
413
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
414
|
+
if (errors.length === 0) {
|
|
415
|
+
return { valid: true, errors, data: validated };
|
|
416
|
+
}
|
|
417
|
+
return { valid: false, errors };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function validateOrThrow(
|
|
421
|
+
data: unknown,
|
|
422
|
+
options?: ValidateOptions,
|
|
423
|
+
): Record<string, unknown> {
|
|
424
|
+
const result = validate(data, options);
|
|
425
|
+
if (result.valid) {
|
|
426
|
+
return result.data;
|
|
427
|
+
}
|
|
428
|
+
throw new RuneValidationError(result.errors.map(toErrorNode));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return { fields, validate, validateOrThrow };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Map an internal {@link ValidationError} to a {@link RuneErrorNode}. */
|
|
435
|
+
function toErrorNode(error: ValidationError): RuneErrorNode {
|
|
436
|
+
const node: RuneErrorNode = {
|
|
437
|
+
message: error.message,
|
|
438
|
+
rule: error.rule,
|
|
439
|
+
field: error.field,
|
|
196
440
|
};
|
|
441
|
+
if (error.index !== undefined) node.index = error.index;
|
|
442
|
+
if (error.meta !== undefined) node.meta = error.meta;
|
|
443
|
+
return node;
|
|
197
444
|
}
|
|
198
445
|
|
|
199
446
|
export function setValidationTranslator(
|
|
@@ -212,17 +459,47 @@ export function bindRosetta(rosetta: {
|
|
|
212
459
|
export interface RuleDef {
|
|
213
460
|
name: string;
|
|
214
461
|
param?: number;
|
|
462
|
+
/** Interpolation args exposed to i18n/messages providers (e.g. `{ min: 3 }`). */
|
|
463
|
+
args?: Record<string, unknown>;
|
|
215
464
|
validate: (value: unknown) => boolean;
|
|
216
465
|
message: string;
|
|
466
|
+
/** Set when `.message()` overrode this rule's default text. */
|
|
467
|
+
hasCustomMessage?: boolean;
|
|
217
468
|
}
|
|
218
469
|
|
|
219
|
-
/**
|
|
220
|
-
|
|
470
|
+
/** A conditional-required condition (VineJS `requiredWhen` family). */
|
|
471
|
+
interface RequiredCondition {
|
|
472
|
+
kind: "exists" | "missing" | "when";
|
|
473
|
+
otherField: string;
|
|
474
|
+
operator?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn";
|
|
475
|
+
value?: unknown;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Phantom brand carrying the inferred output type (never assigned at runtime). */
|
|
479
|
+
declare const OUTPUT: unique symbol;
|
|
480
|
+
|
|
481
|
+
/** UUID (any version/variant) — identical to the Rust engine's pattern. */
|
|
482
|
+
const UUID_RE =
|
|
483
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
484
|
+
|
|
485
|
+
/** Rule chain — fluent, phantom-typed validation builder. */
|
|
486
|
+
export class RuleChain<Output = unknown> {
|
|
487
|
+
/** Phantom output type — drives {@link Infer}; never read at runtime. */
|
|
488
|
+
declare readonly [OUTPUT]: Output;
|
|
489
|
+
|
|
221
490
|
#rules: RuleDef[] = [];
|
|
222
491
|
#isOptional = false;
|
|
223
|
-
#
|
|
492
|
+
#isNullable = false;
|
|
493
|
+
#bail = false;
|
|
494
|
+
#transforms: Array<{
|
|
495
|
+
name: string;
|
|
496
|
+
fn: (value: unknown, field: FieldContext) => unknown;
|
|
497
|
+
}> = [];
|
|
498
|
+
#preTransforms: Array<(value: unknown) => unknown> = [];
|
|
224
499
|
#nestedSchema: Record<string, RuleChain> | null = null;
|
|
225
500
|
#arrayItemChain: RuleChain | null = null;
|
|
501
|
+
#useRules: CompiledRule[] = [];
|
|
502
|
+
#requiredConditions: RequiredCondition[] = [];
|
|
226
503
|
|
|
227
504
|
/** Public read access to rules (for OpenAPI generation, Rust bridge). */
|
|
228
505
|
get rules(): readonly RuleDef[] {
|
|
@@ -231,77 +508,161 @@ export class RuleChain {
|
|
|
231
508
|
get isOptionalField(): boolean {
|
|
232
509
|
return this.#isOptional;
|
|
233
510
|
}
|
|
511
|
+
/** Public read access to the `.nullable()` flag (keeps such schemas off the native path). */
|
|
512
|
+
get isNullable(): boolean {
|
|
513
|
+
return this.#isNullable;
|
|
514
|
+
}
|
|
234
515
|
get transforms(): ReadonlyArray<{
|
|
235
516
|
name: string;
|
|
236
|
-
fn: (value: unknown) => unknown;
|
|
517
|
+
fn: (value: unknown, field: FieldContext) => unknown;
|
|
237
518
|
}> {
|
|
238
519
|
return this.#transforms;
|
|
239
520
|
}
|
|
521
|
+
/** Public read access to `.use()` rules (used to keep such schemas off the native path). */
|
|
522
|
+
get useRules(): readonly CompiledRule[] {
|
|
523
|
+
return this.#useRules;
|
|
524
|
+
}
|
|
525
|
+
/** Public read access to `.parse()` pre-transforms (kept off the native path). */
|
|
526
|
+
get preTransforms(): ReadonlyArray<(value: unknown) => unknown> {
|
|
527
|
+
return this.#preTransforms;
|
|
528
|
+
}
|
|
529
|
+
/** Whether this chain carries a `requiredWhen`-family condition. */
|
|
530
|
+
get hasConditionalRequired(): boolean {
|
|
531
|
+
return this.#requiredConditions.length > 0;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Re-type this chain to a new phantom output while carrying its runtime state
|
|
536
|
+
* forward. Cast-free: `new RuleChain<U>()` is genuinely `RuleChain<U>` because
|
|
537
|
+
* the brand is `declare`-only. State arrays are copied so the abandoned source
|
|
538
|
+
* chain can't be mutated through the new one.
|
|
539
|
+
*/
|
|
540
|
+
#retype<U>(): RuleChain<U> {
|
|
541
|
+
const next = new RuleChain<U>();
|
|
542
|
+
next.#rules = [...this.#rules];
|
|
543
|
+
next.#isOptional = this.#isOptional;
|
|
544
|
+
next.#isNullable = this.#isNullable;
|
|
545
|
+
next.#bail = this.#bail;
|
|
546
|
+
next.#transforms = [...this.#transforms];
|
|
547
|
+
next.#preTransforms = [...this.#preTransforms];
|
|
548
|
+
next.#nestedSchema = this.#nestedSchema;
|
|
549
|
+
next.#arrayItemChain = this.#arrayItemChain;
|
|
550
|
+
next.#useRules = [...this.#useRules];
|
|
551
|
+
next.#requiredConditions = [...this.#requiredConditions];
|
|
552
|
+
return next;
|
|
553
|
+
}
|
|
240
554
|
|
|
241
|
-
/** Mark field as optional. */
|
|
242
|
-
optional():
|
|
555
|
+
/** Mark field as optional (absent / `undefined` allowed). */
|
|
556
|
+
optional(): RuleChain<Output | undefined> {
|
|
243
557
|
this.#isOptional = true;
|
|
244
558
|
return this;
|
|
245
559
|
}
|
|
246
560
|
|
|
561
|
+
/** Mark field as nullable (`null` allowed, kept in the output). */
|
|
562
|
+
nullable(): RuleChain<Output | null> {
|
|
563
|
+
this.#isNullable = true;
|
|
564
|
+
return this;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Mark field as both optional and nullable. */
|
|
568
|
+
nullish(): RuleChain<Output | null | undefined> {
|
|
569
|
+
this.#isOptional = true;
|
|
570
|
+
this.#isNullable = true;
|
|
571
|
+
return this;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Stop at the first failing rule for this field (VineJS bail). */
|
|
575
|
+
bail(enabled = true): this {
|
|
576
|
+
this.#bail = enabled;
|
|
577
|
+
return this;
|
|
578
|
+
}
|
|
579
|
+
|
|
247
580
|
/** Must be an object matching a nested schema. */
|
|
248
|
-
object
|
|
581
|
+
object<Sh extends Record<string, RuleChain>>(
|
|
582
|
+
shape: Sh,
|
|
583
|
+
): RuleChain<Infer<Sh>> {
|
|
249
584
|
this.#rules.push({
|
|
250
585
|
name: "object",
|
|
251
|
-
validate: (v) =>
|
|
586
|
+
validate: (v) => isPlainObject(v),
|
|
252
587
|
message: "Must be an object",
|
|
253
588
|
});
|
|
254
589
|
this.#nestedSchema = shape;
|
|
255
|
-
return this;
|
|
590
|
+
return this.#retype<Infer<Sh>>();
|
|
256
591
|
}
|
|
257
592
|
|
|
258
593
|
/** Must be an array. Items validated by the provided chain. */
|
|
259
|
-
array(itemChain?:
|
|
594
|
+
array<Item extends RuleChain>(itemChain?: Item): RuleChain<OutputOf<Item>[]> {
|
|
260
595
|
this.#rules.push({
|
|
261
596
|
name: "array",
|
|
262
597
|
validate: (v) => Array.isArray(v),
|
|
263
598
|
message: "Must be an array",
|
|
264
599
|
});
|
|
265
600
|
this.#arrayItemChain = itemChain ?? null;
|
|
266
|
-
return this;
|
|
601
|
+
return this.#retype<OutputOf<Item>[]>();
|
|
267
602
|
}
|
|
268
603
|
|
|
269
604
|
/** Must be a string. */
|
|
270
|
-
string():
|
|
605
|
+
string(): RuleChain<string> {
|
|
271
606
|
this.#rules.push({
|
|
272
607
|
name: "string",
|
|
273
608
|
validate: (v) => typeof v === "string",
|
|
274
609
|
message: "Must be a string",
|
|
275
610
|
});
|
|
276
|
-
return this;
|
|
611
|
+
return this.#retype<string>();
|
|
277
612
|
}
|
|
278
613
|
|
|
279
614
|
/** Must be a number. */
|
|
280
|
-
number():
|
|
615
|
+
number(): RuleChain<number> {
|
|
281
616
|
this.#rules.push({
|
|
282
617
|
name: "number",
|
|
283
618
|
validate: (v) =>
|
|
284
619
|
typeof v === "number" && !Number.isNaN(v) && Number.isFinite(v),
|
|
285
620
|
message: "Must be a number",
|
|
286
621
|
});
|
|
287
|
-
return this;
|
|
622
|
+
return this.#retype<number>();
|
|
288
623
|
}
|
|
289
624
|
|
|
290
625
|
/** Must be a boolean. */
|
|
291
|
-
boolean():
|
|
626
|
+
boolean(): RuleChain<boolean> {
|
|
292
627
|
this.#rules.push({
|
|
293
628
|
name: "boolean",
|
|
294
629
|
validate: (v) => typeof v === "boolean",
|
|
295
630
|
message: "Must be a boolean",
|
|
296
631
|
});
|
|
297
|
-
return this;
|
|
632
|
+
return this.#retype<boolean>();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Must equal one of `values` (enum). Narrows the output to the union. */
|
|
636
|
+
enum<const V extends readonly (string | number | boolean)[]>(
|
|
637
|
+
values: V,
|
|
638
|
+
): RuleChain<V[number]> {
|
|
639
|
+
const allowed = [...values];
|
|
640
|
+
this.#rules.push({
|
|
641
|
+
name: "enum",
|
|
642
|
+
args: { values: allowed },
|
|
643
|
+
validate: (v) => allowed.includes(asPrimitive(v)),
|
|
644
|
+
message: "Invalid value",
|
|
645
|
+
});
|
|
646
|
+
return this.#retype<V[number]>();
|
|
298
647
|
}
|
|
299
648
|
|
|
300
|
-
/**
|
|
649
|
+
/** Must equal a literal value. */
|
|
650
|
+
literal<V extends string | number | boolean>(value: V): RuleChain<V> {
|
|
651
|
+
this.#rules.push({
|
|
652
|
+
name: "literal",
|
|
653
|
+
args: { expectedValue: value },
|
|
654
|
+
validate: (v) => v === value,
|
|
655
|
+
message: `Must be ${String(value)}`,
|
|
656
|
+
});
|
|
657
|
+
return this.#retype<V>();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Minimum length (string) or minimum value (number). Alias of min/minLength. */
|
|
301
661
|
min(n: number): this {
|
|
302
662
|
this.#rules.push({
|
|
303
663
|
name: "min",
|
|
304
664
|
param: n,
|
|
665
|
+
args: { min: n },
|
|
305
666
|
validate: (v) =>
|
|
306
667
|
typeof v === "string"
|
|
307
668
|
? [...v].length >= n
|
|
@@ -313,11 +674,12 @@ export class RuleChain {
|
|
|
313
674
|
return this;
|
|
314
675
|
}
|
|
315
676
|
|
|
316
|
-
/** Maximum length (string) or maximum value (number). */
|
|
677
|
+
/** Maximum length (string) or maximum value (number). Alias of max/maxLength. */
|
|
317
678
|
max(n: number): this {
|
|
318
679
|
this.#rules.push({
|
|
319
680
|
name: "max",
|
|
320
681
|
param: n,
|
|
682
|
+
args: { max: n },
|
|
321
683
|
validate: (v) =>
|
|
322
684
|
typeof v === "string"
|
|
323
685
|
? [...v].length <= n
|
|
@@ -329,14 +691,51 @@ export class RuleChain {
|
|
|
329
691
|
return this;
|
|
330
692
|
}
|
|
331
693
|
|
|
694
|
+
/** Minimum length for a string or array (VineJS `minLength`). */
|
|
695
|
+
minLength(n: number): this {
|
|
696
|
+
this.#rules.push({
|
|
697
|
+
name: "minLength",
|
|
698
|
+
param: n,
|
|
699
|
+
args: { min: n },
|
|
700
|
+
validate: (v) => sizedLength(v) >= n,
|
|
701
|
+
message: `Must have at least ${n} characters`,
|
|
702
|
+
});
|
|
703
|
+
return this;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Maximum length for a string or array (VineJS `maxLength`). */
|
|
707
|
+
maxLength(n: number): this {
|
|
708
|
+
this.#rules.push({
|
|
709
|
+
name: "maxLength",
|
|
710
|
+
param: n,
|
|
711
|
+
args: { max: n },
|
|
712
|
+
validate: (v) => {
|
|
713
|
+
const len = sizedLength(v);
|
|
714
|
+
return len >= 0 && len <= n;
|
|
715
|
+
},
|
|
716
|
+
message: `Must not exceed ${n} characters`,
|
|
717
|
+
});
|
|
718
|
+
return this;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Exact length for a string or array (VineJS `fixedLength`). */
|
|
722
|
+
fixedLength(n: number): this {
|
|
723
|
+
this.#rules.push({
|
|
724
|
+
name: "fixedLength",
|
|
725
|
+
param: n,
|
|
726
|
+
args: { size: n },
|
|
727
|
+
validate: (v) => sizedLength(v) === n,
|
|
728
|
+
message: `Must be exactly ${n} characters`,
|
|
729
|
+
});
|
|
730
|
+
return this;
|
|
731
|
+
}
|
|
732
|
+
|
|
332
733
|
/** Must be a valid email. */
|
|
333
734
|
email(): this {
|
|
334
735
|
this.#rules.push({
|
|
335
736
|
name: "email",
|
|
336
|
-
// Mirror the Rust engine's regex exactly
|
|
337
|
-
//
|
|
338
|
-
// binary loaded: no whitespace anywhere (the old TS rule rejected only
|
|
339
|
-
// \r\n, silently accepting interior spaces), a single @, dotted domain.
|
|
737
|
+
// Mirror the Rust engine's regex exactly so the SAME schema validates
|
|
738
|
+
// identically whether or not the native binary loaded.
|
|
340
739
|
validate: (v) =>
|
|
341
740
|
typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
|
|
342
741
|
message: "Must be a valid email",
|
|
@@ -344,7 +743,105 @@ export class RuleChain {
|
|
|
344
743
|
return this;
|
|
345
744
|
}
|
|
346
745
|
|
|
347
|
-
/** Must
|
|
746
|
+
/** Must match a regular expression (TS-only — never dispatched to Rust). */
|
|
747
|
+
regex(pattern: RegExp): this {
|
|
748
|
+
this.#rules.push({
|
|
749
|
+
name: "regex",
|
|
750
|
+
validate: (v) => typeof v === "string" && pattern.test(v),
|
|
751
|
+
message: "Invalid format",
|
|
752
|
+
});
|
|
753
|
+
return this;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** Must be a valid URL (TS-only — uses the WHATWG URL parser). */
|
|
757
|
+
url(): this {
|
|
758
|
+
this.#rules.push({
|
|
759
|
+
name: "url",
|
|
760
|
+
validate: (v) => typeof v === "string" && isValidUrl(v),
|
|
761
|
+
message: "Must be a valid URL",
|
|
762
|
+
});
|
|
763
|
+
return this;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** Must be a valid UUID. */
|
|
767
|
+
uuid(): this {
|
|
768
|
+
this.#rules.push({
|
|
769
|
+
name: "uuid",
|
|
770
|
+
validate: (v) => typeof v === "string" && UUID_RE.test(v),
|
|
771
|
+
message: "Must be a valid UUID",
|
|
772
|
+
});
|
|
773
|
+
return this;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Must contain only ASCII letters. */
|
|
777
|
+
alpha(): this {
|
|
778
|
+
this.#rules.push({
|
|
779
|
+
name: "alpha",
|
|
780
|
+
validate: (v) =>
|
|
781
|
+
typeof v === "string" && v.length > 0 && /^[a-zA-Z]+$/.test(v),
|
|
782
|
+
message: "Must contain only letters",
|
|
783
|
+
});
|
|
784
|
+
return this;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** Must contain only ASCII letters and digits. */
|
|
788
|
+
alphaNumeric(): this {
|
|
789
|
+
this.#rules.push({
|
|
790
|
+
name: "alphaNumeric",
|
|
791
|
+
validate: (v) =>
|
|
792
|
+
typeof v === "string" && v.length > 0 && /^[a-zA-Z0-9]+$/.test(v),
|
|
793
|
+
message: "Must contain only letters and numbers",
|
|
794
|
+
});
|
|
795
|
+
return this;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** String must start with `substring`. */
|
|
799
|
+
startsWith(substring: string): this {
|
|
800
|
+
this.#rules.push({
|
|
801
|
+
name: "startsWith",
|
|
802
|
+
args: { substring },
|
|
803
|
+
validate: (v) => typeof v === "string" && v.startsWith(substring),
|
|
804
|
+
message: `Must start with ${substring}`,
|
|
805
|
+
});
|
|
806
|
+
return this;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** String must end with `substring`. */
|
|
810
|
+
endsWith(substring: string): this {
|
|
811
|
+
this.#rules.push({
|
|
812
|
+
name: "endsWith",
|
|
813
|
+
args: { substring },
|
|
814
|
+
validate: (v) => typeof v === "string" && v.endsWith(substring),
|
|
815
|
+
message: `Must end with ${substring}`,
|
|
816
|
+
});
|
|
817
|
+
return this;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Value must be one of `values`. */
|
|
821
|
+
in(values: ReadonlyArray<string | number | boolean>): this {
|
|
822
|
+
const allowed = [...values];
|
|
823
|
+
this.#rules.push({
|
|
824
|
+
name: "in",
|
|
825
|
+
args: { values: allowed },
|
|
826
|
+
validate: (v) => allowed.includes(asPrimitive(v)),
|
|
827
|
+
message: "Invalid value",
|
|
828
|
+
});
|
|
829
|
+
return this;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** Value must NOT be one of `values`. */
|
|
833
|
+
notIn(values: ReadonlyArray<string | number | boolean>): this {
|
|
834
|
+
const denied = [...values];
|
|
835
|
+
this.#rules.push({
|
|
836
|
+
name: "notIn",
|
|
837
|
+
args: { values: denied },
|
|
838
|
+
validate: (v) => !denied.includes(asPrimitive(v)),
|
|
839
|
+
message: "Invalid value",
|
|
840
|
+
});
|
|
841
|
+
return this;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** Number must be positive (> 0) and finite. */
|
|
348
845
|
positive(): this {
|
|
349
846
|
this.#rules.push({
|
|
350
847
|
name: "positive",
|
|
@@ -354,6 +851,109 @@ export class RuleChain {
|
|
|
354
851
|
return this;
|
|
355
852
|
}
|
|
356
853
|
|
|
854
|
+
/** Number must be negative (< 0) and finite. */
|
|
855
|
+
negative(): this {
|
|
856
|
+
this.#rules.push({
|
|
857
|
+
name: "negative",
|
|
858
|
+
validate: (v) => typeof v === "number" && Number.isFinite(v) && v < 0,
|
|
859
|
+
message: "Must be negative",
|
|
860
|
+
});
|
|
861
|
+
return this;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** Number must be >= 0 and finite. */
|
|
865
|
+
nonNegative(): this {
|
|
866
|
+
this.#rules.push({
|
|
867
|
+
name: "nonNegative",
|
|
868
|
+
validate: (v) => typeof v === "number" && Number.isFinite(v) && v >= 0,
|
|
869
|
+
message: "Must be positive or zero",
|
|
870
|
+
});
|
|
871
|
+
return this;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** Number must fall within `[min, max]` (inclusive). */
|
|
875
|
+
range(min: number, max: number): this {
|
|
876
|
+
this.#rules.push({
|
|
877
|
+
name: "range",
|
|
878
|
+
args: { min, max },
|
|
879
|
+
validate: (v) =>
|
|
880
|
+
typeof v === "number" && Number.isFinite(v) && v >= min && v <= max,
|
|
881
|
+
message: `Must be between ${min} and ${max}`,
|
|
882
|
+
});
|
|
883
|
+
return this;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** Number must have at most `digits` decimal places (TS-only). */
|
|
887
|
+
decimal(digits: number): this {
|
|
888
|
+
this.#rules.push({
|
|
889
|
+
name: "decimal",
|
|
890
|
+
args: { digits },
|
|
891
|
+
validate: (v) => {
|
|
892
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return false;
|
|
893
|
+
const parts = String(v).split(".");
|
|
894
|
+
return (parts[1]?.length ?? 0) <= digits;
|
|
895
|
+
},
|
|
896
|
+
message: `Must have at most ${digits} decimal places`,
|
|
897
|
+
});
|
|
898
|
+
return this;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** Must equal a sibling field (VineJS `sameAs`). Cross-field → TS-only. */
|
|
902
|
+
sameAs(otherField: string): this {
|
|
903
|
+
this.#useRules.push({
|
|
904
|
+
__rune: "rule",
|
|
905
|
+
run: (value, field) => {
|
|
906
|
+
const other = readSibling(field, otherField);
|
|
907
|
+
if (value !== other) {
|
|
908
|
+
field.report(`Must match ${otherField}`, "sameAs");
|
|
909
|
+
}
|
|
910
|
+
},
|
|
911
|
+
});
|
|
912
|
+
return this;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** Must equal its `<field>_confirmation` sibling (VineJS `confirmed`). */
|
|
916
|
+
confirmed(options?: { confirmationField?: string }): this {
|
|
917
|
+
this.#useRules.push({
|
|
918
|
+
__rune: "rule",
|
|
919
|
+
run: (value, field) => {
|
|
920
|
+
const leaf = field.field.split(".").pop() ?? field.field;
|
|
921
|
+
const other = options?.confirmationField ?? `${leaf}_confirmation`;
|
|
922
|
+
if (value !== readSibling(field, other)) {
|
|
923
|
+
field.report("Confirmation does not match", "confirmed");
|
|
924
|
+
}
|
|
925
|
+
},
|
|
926
|
+
});
|
|
927
|
+
return this;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** Required only when `otherField` is present (non-null) — else optional. */
|
|
931
|
+
requiredIfExists(otherField: string): this {
|
|
932
|
+
this.#requiredConditions.push({ kind: "exists", otherField });
|
|
933
|
+
return this;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** Required only when `otherField` is absent/null — else optional. */
|
|
937
|
+
requiredIfMissing(otherField: string): this {
|
|
938
|
+
this.#requiredConditions.push({ kind: "missing", otherField });
|
|
939
|
+
return this;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** Required only when `otherField <op> value` holds — else optional. */
|
|
943
|
+
requiredWhen(
|
|
944
|
+
otherField: string,
|
|
945
|
+
operator: RequiredCondition["operator"],
|
|
946
|
+
value: unknown,
|
|
947
|
+
): this {
|
|
948
|
+
this.#requiredConditions.push({
|
|
949
|
+
kind: "when",
|
|
950
|
+
otherField,
|
|
951
|
+
operator,
|
|
952
|
+
value,
|
|
953
|
+
});
|
|
954
|
+
return this;
|
|
955
|
+
}
|
|
956
|
+
|
|
357
957
|
/** Trim whitespace (transform). */
|
|
358
958
|
trim(): this {
|
|
359
959
|
this.#transforms.push({
|
|
@@ -363,6 +963,23 @@ export class RuleChain {
|
|
|
363
963
|
return this;
|
|
364
964
|
}
|
|
365
965
|
|
|
966
|
+
/**
|
|
967
|
+
* Post-validation transform changing the output type (VineJS `transform`).
|
|
968
|
+
* `value` is `unknown` — narrow it in the callback (the no-cast rule forbids
|
|
969
|
+
* lying about a dynamically-produced value's static type).
|
|
970
|
+
*/
|
|
971
|
+
transform<U>(fn: (value: unknown, field: FieldContext) => U): RuleChain<U> {
|
|
972
|
+
const next = this.#retype<U>();
|
|
973
|
+
next.#transforms.push({ name: "transform", fn: (v, f) => fn(v, f) });
|
|
974
|
+
return next;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** Pre-validation transform of the raw input (VineJS `parse`). */
|
|
978
|
+
parse(fn: (value: unknown) => unknown): this {
|
|
979
|
+
this.#preTransforms.push(fn);
|
|
980
|
+
return this;
|
|
981
|
+
}
|
|
982
|
+
|
|
366
983
|
/** Custom validation rule. */
|
|
367
984
|
custom(
|
|
368
985
|
name: string,
|
|
@@ -377,68 +994,119 @@ export class RuleChain {
|
|
|
377
994
|
return this;
|
|
378
995
|
}
|
|
379
996
|
|
|
997
|
+
/**
|
|
998
|
+
* Attach a `.use()` rule (from {@link createRule}). Unlike `custom`, the rule
|
|
999
|
+
* receives a {@link FieldContext} with the root `data` and `parent`, so it can
|
|
1000
|
+
* validate across fields. Runs after this field's type/value rules.
|
|
1001
|
+
*/
|
|
1002
|
+
use(rule: CompiledRule): this {
|
|
1003
|
+
if (rule?.__rune !== "rule" || typeof rule.run !== "function") {
|
|
1004
|
+
throw new RuneError(
|
|
1005
|
+
"INVALID_RULE",
|
|
1006
|
+
"use() expects a compiled rule — call the factory first",
|
|
1007
|
+
{ hint: "use(myRule()) or use(myRule(options)), not use(myRule)" },
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
this.#useRules.push(rule);
|
|
1011
|
+
return this;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
380
1014
|
/** Set custom error message for the last rule. */
|
|
381
1015
|
message(msg: string): this {
|
|
382
1016
|
if (this.#rules.length === 0) {
|
|
383
1017
|
throw new RuneError("NO_RULE", "message() must be called after a rule");
|
|
384
1018
|
}
|
|
385
|
-
this.#rules[this.#rules.length - 1]
|
|
1019
|
+
const last = this.#rules[this.#rules.length - 1];
|
|
1020
|
+
last.message = msg;
|
|
1021
|
+
last.hasCustomMessage = true;
|
|
386
1022
|
return this;
|
|
387
1023
|
}
|
|
388
1024
|
|
|
1025
|
+
/** Whether the field is required given the surrounding data (conditionals). */
|
|
1026
|
+
#isRequired(ctx: RunContext): boolean {
|
|
1027
|
+
if (this.#requiredConditions.length === 0) return true;
|
|
1028
|
+
return this.#requiredConditions.some((cond) =>
|
|
1029
|
+
evalRequiredCondition(cond, ctx),
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
389
1033
|
/** Internal: validate a field value and return errors + transformed value. */
|
|
390
1034
|
_validateWithTransform(
|
|
391
1035
|
field: string,
|
|
392
|
-
|
|
1036
|
+
rawValue: unknown,
|
|
1037
|
+
ctx: RunContext = EMPTY_RUN_CONTEXT,
|
|
393
1038
|
): { errors: ValidationError[]; transformed: unknown } {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
1039
|
+
// 0. Pre-validation parse() transforms run on the raw value first.
|
|
1040
|
+
let value = rawValue;
|
|
1041
|
+
for (const pre of this.#preTransforms) {
|
|
1042
|
+
value = pre(value);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
if (value === undefined) {
|
|
1046
|
+
if (this.#isOptional || !this.#isRequired(ctx)) {
|
|
1047
|
+
return { errors: [], transformed: value };
|
|
1048
|
+
}
|
|
1049
|
+
return { errors: [this.#requiredError(field, ctx)], transformed: value };
|
|
1050
|
+
}
|
|
1051
|
+
if (value === null) {
|
|
1052
|
+
// rune treats a `null` value as "absent" for optional/nullable/
|
|
1053
|
+
// non-required fields (a deliberate, cross-engine-conformance-tested
|
|
1054
|
+
// choice — see the Rust↔TS parity suite). `.nullable()` additionally
|
|
1055
|
+
// keeps it in the output. This unifies null/undefined for optional
|
|
1056
|
+
// fields, a documented deviation from VineJS's stricter split.
|
|
1057
|
+
if (this.#isNullable || this.#isOptional || !this.#isRequired(ctx)) {
|
|
1058
|
+
return { errors: [], transformed: value };
|
|
1059
|
+
}
|
|
1060
|
+
return { errors: [this.#requiredError(field, ctx)], transformed: value };
|
|
397
1061
|
}
|
|
398
1062
|
|
|
399
1063
|
// 1. Type rules first on the raw value — bail on type mismatch.
|
|
400
|
-
const typeError = this.#runTypeRules(field, value);
|
|
1064
|
+
const typeError = this.#runTypeRules(field, value, ctx);
|
|
401
1065
|
if (typeError) return { errors: [typeError], transformed: value };
|
|
402
1066
|
|
|
403
1067
|
// 2. Apply transforms (trim, etc.), then run value rules on the result.
|
|
404
|
-
let transformed = this.#applyTransformsTo(value);
|
|
405
|
-
const errors = this.#runValueRules(field, transformed);
|
|
1068
|
+
let transformed = this.#applyTransformsTo(value, field, ctx);
|
|
1069
|
+
const errors = this.#runValueRules(field, transformed, ctx);
|
|
1070
|
+
|
|
1071
|
+
// 3. Vine-style .use() rules — run with a FieldContext exposing the root
|
|
1072
|
+
// `data` and `parent`, so a rule can validate across fields.
|
|
1073
|
+
if (this.#useRules.length > 0 && !(this.#bail && errors.length > 0)) {
|
|
1074
|
+
this.#runUseRules(field, transformed, ctx, errors);
|
|
1075
|
+
}
|
|
406
1076
|
|
|
407
1077
|
// 4. Nested object validation (only if type check passed — not arrays)
|
|
408
|
-
if (
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
transformed !== null &&
|
|
412
|
-
!Array.isArray(transformed)
|
|
413
|
-
) {
|
|
414
|
-
transformed = { ...(transformed as Record<string, unknown>) };
|
|
1078
|
+
if (this.#nestedSchema && isPlainObject(transformed)) {
|
|
1079
|
+
const obj: Record<string, unknown> = { ...transformed };
|
|
1080
|
+
transformed = obj;
|
|
415
1081
|
for (const [nestedField, chain] of Object.entries(this.#nestedSchema)) {
|
|
416
|
-
const nestedValue = (transformed as Record<string, unknown>)[
|
|
417
|
-
nestedField
|
|
418
|
-
];
|
|
419
1082
|
const nestedResult = chain._validateWithTransform(
|
|
420
1083
|
`${field}.${nestedField}`,
|
|
421
|
-
|
|
1084
|
+
obj[nestedField],
|
|
1085
|
+
{ ...ctx, parent: obj },
|
|
422
1086
|
);
|
|
423
1087
|
errors.push(...nestedResult.errors);
|
|
424
1088
|
if (nestedResult.transformed !== undefined) {
|
|
425
|
-
|
|
426
|
-
nestedResult.transformed;
|
|
1089
|
+
obj[nestedField] = nestedResult.transformed;
|
|
427
1090
|
}
|
|
428
1091
|
}
|
|
429
1092
|
}
|
|
430
1093
|
|
|
431
1094
|
// 5. Array item validation
|
|
432
1095
|
if (this.#arrayItemChain && Array.isArray(transformed)) {
|
|
433
|
-
|
|
434
|
-
|
|
1096
|
+
const arr: unknown[] = [...transformed];
|
|
1097
|
+
transformed = arr;
|
|
1098
|
+
for (let i = 0; i < arr.length; i++) {
|
|
435
1099
|
const itemResult = this.#arrayItemChain._validateWithTransform(
|
|
436
1100
|
`${field}.${i}`,
|
|
437
|
-
|
|
1101
|
+
arr[i],
|
|
1102
|
+
{ ...ctx, parent: arr },
|
|
438
1103
|
);
|
|
1104
|
+
for (const e of itemResult.errors) {
|
|
1105
|
+
if (e.index === undefined) e.index = i;
|
|
1106
|
+
}
|
|
439
1107
|
errors.push(...itemResult.errors);
|
|
440
1108
|
if (itemResult.transformed !== undefined) {
|
|
441
|
-
|
|
1109
|
+
arr[i] = itemResult.transformed;
|
|
442
1110
|
}
|
|
443
1111
|
}
|
|
444
1112
|
}
|
|
@@ -446,26 +1114,51 @@ export class RuleChain {
|
|
|
446
1114
|
return { errors, transformed };
|
|
447
1115
|
}
|
|
448
1116
|
|
|
449
|
-
|
|
1117
|
+
/** Run `.use()` rules on the transformed value with a fresh FieldContext. */
|
|
1118
|
+
#runUseRules(
|
|
1119
|
+
field: string,
|
|
1120
|
+
transformed: unknown,
|
|
1121
|
+
ctx: RunContext,
|
|
1122
|
+
errors: ValidationError[],
|
|
1123
|
+
): void {
|
|
1124
|
+
const fieldCtx: FieldContext = {
|
|
1125
|
+
value: transformed,
|
|
1126
|
+
data: ctx.data,
|
|
1127
|
+
parent: ctx.parent,
|
|
1128
|
+
field,
|
|
1129
|
+
meta: ctx.meta,
|
|
1130
|
+
isValid: errors.length === 0,
|
|
1131
|
+
report(message: string, rule: string): void {
|
|
1132
|
+
errors.push({ field, rule, message });
|
|
1133
|
+
},
|
|
1134
|
+
};
|
|
1135
|
+
for (const rule of this.#useRules) {
|
|
1136
|
+
fieldCtx.isValid = errors.length === 0;
|
|
1137
|
+
rule.run(transformed, fieldCtx);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
#requiredError(field: string, ctx: RunContext): ValidationError {
|
|
450
1142
|
return {
|
|
451
1143
|
field,
|
|
452
1144
|
rule: "required",
|
|
453
|
-
message:
|
|
454
|
-
"validation.required",
|
|
455
|
-
`${field} is required`,
|
|
456
|
-
{ field },
|
|
457
|
-
),
|
|
1145
|
+
message: resolveRequiredMessage(field, ctx),
|
|
458
1146
|
};
|
|
459
1147
|
}
|
|
460
1148
|
|
|
461
1149
|
/** Run the type rules (string/number/…) on the raw value; first failure bails. */
|
|
462
|
-
#runTypeRules(
|
|
1150
|
+
#runTypeRules(
|
|
1151
|
+
field: string,
|
|
1152
|
+
value: unknown,
|
|
1153
|
+
ctx: RunContext,
|
|
1154
|
+
): ValidationError | null {
|
|
463
1155
|
for (const rule of this.#rules) {
|
|
464
1156
|
if (TYPE_RULE_NAMES.has(rule.name) && !rule.validate(value)) {
|
|
465
1157
|
return {
|
|
466
1158
|
field,
|
|
467
1159
|
rule: rule.name,
|
|
468
|
-
message: resolveRuleMessage(field, rule),
|
|
1160
|
+
message: resolveRuleMessage(field, rule, ctx),
|
|
1161
|
+
...(ruleArgs(rule) ? { meta: ruleArgs(rule) } : {}),
|
|
469
1162
|
};
|
|
470
1163
|
}
|
|
471
1164
|
}
|
|
@@ -473,14 +1166,21 @@ export class RuleChain {
|
|
|
473
1166
|
}
|
|
474
1167
|
|
|
475
1168
|
/** Run the non-type rules (min/max/email/…) on the transformed value. */
|
|
476
|
-
#runValueRules(
|
|
1169
|
+
#runValueRules(
|
|
1170
|
+
field: string,
|
|
1171
|
+
transformed: unknown,
|
|
1172
|
+
ctx: RunContext,
|
|
1173
|
+
): ValidationError[] {
|
|
477
1174
|
const errors: ValidationError[] = [];
|
|
478
1175
|
for (const rule of this.#rules) {
|
|
479
|
-
if (
|
|
1176
|
+
if (TYPE_RULE_NAMES.has(rule.name)) continue;
|
|
1177
|
+
if (this.#bail && errors.length > 0) break;
|
|
1178
|
+
if (!rule.validate(transformed)) {
|
|
480
1179
|
errors.push({
|
|
481
1180
|
field,
|
|
482
1181
|
rule: rule.name,
|
|
483
|
-
message: resolveRuleMessage(field, rule),
|
|
1182
|
+
message: resolveRuleMessage(field, rule, ctx),
|
|
1183
|
+
...(ruleArgs(rule) ? { meta: ruleArgs(rule) } : {}),
|
|
484
1184
|
});
|
|
485
1185
|
}
|
|
486
1186
|
}
|
|
@@ -494,31 +1194,148 @@ export class RuleChain {
|
|
|
494
1194
|
|
|
495
1195
|
/** Internal: apply transforms. */
|
|
496
1196
|
_transform(value: unknown): unknown {
|
|
497
|
-
return this.#applyTransformsTo(value);
|
|
1197
|
+
return this.#applyTransformsTo(value, "", EMPTY_RUN_CONTEXT);
|
|
498
1198
|
}
|
|
499
1199
|
|
|
500
|
-
#applyTransformsTo(value: unknown): unknown {
|
|
1200
|
+
#applyTransformsTo(value: unknown, field: string, ctx: RunContext): unknown {
|
|
501
1201
|
let result = value;
|
|
502
1202
|
for (const transform of this.#transforms) {
|
|
503
|
-
|
|
1203
|
+
LAST_FIELD.value = result;
|
|
1204
|
+
LAST_FIELD.data = ctx.data;
|
|
1205
|
+
LAST_FIELD.parent = ctx.parent;
|
|
1206
|
+
LAST_FIELD.field = field;
|
|
1207
|
+
LAST_FIELD.meta = ctx.meta;
|
|
1208
|
+
result = transform.fn(result, LAST_FIELD);
|
|
504
1209
|
}
|
|
505
1210
|
return result;
|
|
506
1211
|
}
|
|
507
1212
|
}
|
|
508
1213
|
|
|
1214
|
+
/**
|
|
1215
|
+
* Scratch FieldContext reused for `.transform()` callbacks — transforms run
|
|
1216
|
+
* inline in {@link RuleChain.#applyTransformsTo}, which repopulates it before
|
|
1217
|
+
* each call. A shared object avoids per-transform allocation; it is never
|
|
1218
|
+
* retained across calls.
|
|
1219
|
+
*/
|
|
1220
|
+
const LAST_FIELD: FieldContext = {
|
|
1221
|
+
value: undefined,
|
|
1222
|
+
data: {},
|
|
1223
|
+
parent: {},
|
|
1224
|
+
field: "",
|
|
1225
|
+
meta: {},
|
|
1226
|
+
isValid: true,
|
|
1227
|
+
report(): void {},
|
|
1228
|
+
};
|
|
1229
|
+
|
|
1230
|
+
/** Coerce a value to a comparable primitive for `in`/`enum` membership. */
|
|
1231
|
+
function asPrimitive(value: unknown): string | number | boolean {
|
|
1232
|
+
if (
|
|
1233
|
+
typeof value === "string" ||
|
|
1234
|
+
typeof value === "number" ||
|
|
1235
|
+
typeof value === "boolean"
|
|
1236
|
+
) {
|
|
1237
|
+
return value;
|
|
1238
|
+
}
|
|
1239
|
+
// Non-primitive values can never be a member of a primitive set; return a
|
|
1240
|
+
// sentinel that no allowed entry equals.
|
|
1241
|
+
return Symbol.iterator.toString();
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/** Code-point length for a string, element count for an array, else -1. */
|
|
1245
|
+
function sizedLength(value: unknown): number {
|
|
1246
|
+
if (typeof value === "string") return [...value].length;
|
|
1247
|
+
if (Array.isArray(value)) return value.length;
|
|
1248
|
+
return -1;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/** WHATWG URL validity — accepts only http/https to avoid `mailto:` etc. */
|
|
1252
|
+
function isValidUrl(value: string): boolean {
|
|
1253
|
+
try {
|
|
1254
|
+
const url = new URL(value);
|
|
1255
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
1256
|
+
} catch {
|
|
1257
|
+
return false;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/** Read a sibling field's value from the immediate parent (object only). */
|
|
1262
|
+
function readSibling(field: FieldContext, name: string): unknown {
|
|
1263
|
+
const parent = field.parent;
|
|
1264
|
+
if (Array.isArray(parent)) return undefined;
|
|
1265
|
+
return parent[name];
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/** Evaluate whether a `requiredWhen`-family condition makes the field required. */
|
|
1269
|
+
function evalRequiredCondition(
|
|
1270
|
+
cond: RequiredCondition,
|
|
1271
|
+
ctx: RunContext,
|
|
1272
|
+
): boolean {
|
|
1273
|
+
const other = isPlainObject(ctx.parent)
|
|
1274
|
+
? ctx.parent[cond.otherField]
|
|
1275
|
+
: ctx.data[cond.otherField];
|
|
1276
|
+
const present = other !== undefined && other !== null;
|
|
1277
|
+
|
|
1278
|
+
if (cond.kind === "exists") return present;
|
|
1279
|
+
if (cond.kind === "missing") return !present;
|
|
1280
|
+
|
|
1281
|
+
switch (cond.operator) {
|
|
1282
|
+
case "=":
|
|
1283
|
+
return other === cond.value;
|
|
1284
|
+
case "!=":
|
|
1285
|
+
return other !== cond.value;
|
|
1286
|
+
case ">":
|
|
1287
|
+
return typeof other === "number" && typeof cond.value === "number"
|
|
1288
|
+
? other > cond.value
|
|
1289
|
+
: false;
|
|
1290
|
+
case "<":
|
|
1291
|
+
return typeof other === "number" && typeof cond.value === "number"
|
|
1292
|
+
? other < cond.value
|
|
1293
|
+
: false;
|
|
1294
|
+
case ">=":
|
|
1295
|
+
return typeof other === "number" && typeof cond.value === "number"
|
|
1296
|
+
? other >= cond.value
|
|
1297
|
+
: false;
|
|
1298
|
+
case "<=":
|
|
1299
|
+
return typeof other === "number" && typeof cond.value === "number"
|
|
1300
|
+
? other <= cond.value
|
|
1301
|
+
: false;
|
|
1302
|
+
case "in":
|
|
1303
|
+
return Array.isArray(cond.value) && cond.value.includes(other);
|
|
1304
|
+
case "notIn":
|
|
1305
|
+
return Array.isArray(cond.value) && !cond.value.includes(other);
|
|
1306
|
+
default:
|
|
1307
|
+
return false;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/** No-op alias of {@link schema} — VineJS `vine.compile()` API parity. */
|
|
1312
|
+
export function compile<T extends ValidationSchema>(s: T): T {
|
|
1313
|
+
return s;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
509
1316
|
/** Entry point for building rules. */
|
|
510
1317
|
export const rules = {
|
|
511
|
-
string: () => new RuleChain().string(),
|
|
512
|
-
number: () => new RuleChain().number(),
|
|
513
|
-
boolean: () => new RuleChain().boolean(),
|
|
514
|
-
any: () => new RuleChain(),
|
|
1318
|
+
string: (): RuleChain<string> => new RuleChain().string(),
|
|
1319
|
+
number: (): RuleChain<number> => new RuleChain().number(),
|
|
1320
|
+
boolean: (): RuleChain<boolean> => new RuleChain().boolean(),
|
|
1321
|
+
any: (): RuleChain<unknown> => new RuleChain(),
|
|
1322
|
+
object: <Sh extends Record<string, RuleChain>>(
|
|
1323
|
+
shape: Sh,
|
|
1324
|
+
): RuleChain<Infer<Sh>> => new RuleChain().object(shape),
|
|
1325
|
+
array: <Item extends RuleChain>(item?: Item): RuleChain<OutputOf<Item>[]> =>
|
|
1326
|
+
new RuleChain().array(item),
|
|
1327
|
+
enum: <const V extends readonly (string | number | boolean)[]>(
|
|
1328
|
+
values: V,
|
|
1329
|
+
): RuleChain<V[number]> => new RuleChain().enum(values),
|
|
1330
|
+
literal: <V extends string | number | boolean>(value: V): RuleChain<V> =>
|
|
1331
|
+
new RuleChain().literal(value),
|
|
515
1332
|
};
|
|
516
1333
|
|
|
517
1334
|
/** Serialize schema + data and validate via Rust NAPI. */
|
|
518
|
-
function validateWithRust
|
|
1335
|
+
function validateWithRust(
|
|
519
1336
|
fields: Record<string, RuleChain>,
|
|
520
1337
|
data: Record<string, unknown>,
|
|
521
|
-
): ValidationResult<
|
|
1338
|
+
): ValidationResult<Record<string, unknown>> {
|
|
522
1339
|
const schemaDesc: Record<
|
|
523
1340
|
string,
|
|
524
1341
|
{
|
|
@@ -529,20 +1346,14 @@ function validateWithRust<T>(
|
|
|
529
1346
|
> = {};
|
|
530
1347
|
|
|
531
1348
|
for (const [field, chain] of Object.entries(fields)) {
|
|
532
|
-
const
|
|
1349
|
+
const ruleDescs = chain.rules.map((r) => ({
|
|
533
1350
|
name: r.name,
|
|
534
|
-
// Serialize THIS rule's own
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
params:
|
|
538
|
-
r.name === "min"
|
|
539
|
-
? { min: r.param }
|
|
540
|
-
: r.name === "max"
|
|
541
|
-
? { max: r.param }
|
|
542
|
-
: null,
|
|
1351
|
+
// Serialize THIS rule's own args (per-rule, so min(3).min(5) keeps both
|
|
1352
|
+
// bounds — a find-first lookup previously collapsed them).
|
|
1353
|
+
params: r.args ?? null,
|
|
543
1354
|
}));
|
|
544
1355
|
schemaDesc[field] = {
|
|
545
|
-
rules,
|
|
1356
|
+
rules: ruleDescs,
|
|
546
1357
|
optional: chain.isOptionalField,
|
|
547
1358
|
transforms: chain.transforms.map((t) => t.name),
|
|
548
1359
|
};
|
|
@@ -551,7 +1362,7 @@ function validateWithRust<T>(
|
|
|
551
1362
|
const request = JSON.stringify({ schema: schemaDesc, data });
|
|
552
1363
|
const native = validateNative(request);
|
|
553
1364
|
if (native.valid && native.data !== undefined) {
|
|
554
|
-
return { valid: true, errors: native.errors, data: native.data
|
|
1365
|
+
return { valid: true, errors: native.errors, data: native.data };
|
|
555
1366
|
}
|
|
556
1367
|
return { valid: false, errors: native.errors };
|
|
557
1368
|
}
|