@c9up/rune 0.1.6 → 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 +156 -26
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +628 -131
- 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 +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- 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 +814 -147
- package/src/errors.ts +48 -0
- package/src/index.ts +4 -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,10 @@ 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>;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
/**
|
|
@@ -107,11 +113,24 @@ export type ValidationResult<T = Record<string, unknown>> =
|
|
|
107
113
|
export interface ValidateOptions {
|
|
108
114
|
/** Runtime metadata exposed to `.use()` rules via `field.meta` (VineJS parity). */
|
|
109
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;
|
|
110
122
|
}
|
|
111
123
|
|
|
112
124
|
export interface ValidationSchema<T = Record<string, unknown>> {
|
|
113
125
|
fields: Record<string, RuleChain>;
|
|
126
|
+
/** Result-based validation (superset) — never throws. */
|
|
114
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;
|
|
115
134
|
}
|
|
116
135
|
|
|
117
136
|
/** Context threaded through validation so field rules can reach root/parent/meta. */
|
|
@@ -119,6 +138,7 @@ interface RunContext {
|
|
|
119
138
|
data: Record<string, unknown>;
|
|
120
139
|
parent: Record<string, unknown> | unknown[];
|
|
121
140
|
meta: Record<string, unknown>;
|
|
141
|
+
messagesProvider?: MessagesProviderContract;
|
|
122
142
|
}
|
|
123
143
|
|
|
124
144
|
/** Default context for internal callers that don't supply one (no root available). */
|
|
@@ -129,7 +149,17 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
129
149
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
130
150
|
}
|
|
131
151
|
|
|
132
|
-
/**
|
|
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
|
+
*/
|
|
133
163
|
const STANDARD_RULES: ReadonlySet<string> = new Set([
|
|
134
164
|
"string",
|
|
135
165
|
"number",
|
|
@@ -138,9 +168,23 @@ const STANDARD_RULES: ReadonlySet<string> = new Set([
|
|
|
138
168
|
"max",
|
|
139
169
|
"email",
|
|
140
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",
|
|
141
185
|
]);
|
|
142
186
|
|
|
143
|
-
/** Default messages for standard rules — used
|
|
187
|
+
/** Default messages for standard rules — used only for translator-key fallback. */
|
|
144
188
|
const STANDARD_MSGS: Readonly<Record<string, string>> = {
|
|
145
189
|
string: "Must be a string",
|
|
146
190
|
number: "Must be a number",
|
|
@@ -149,6 +193,20 @@ const STANDARD_MSGS: Readonly<Record<string, string>> = {
|
|
|
149
193
|
max: "Maximum",
|
|
150
194
|
email: "Must be a valid email",
|
|
151
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",
|
|
152
210
|
};
|
|
153
211
|
|
|
154
212
|
const TYPE_RULE_NAMES: ReadonlySet<string> = new Set([
|
|
@@ -160,15 +218,8 @@ const TYPE_RULE_NAMES: ReadonlySet<string> = new Set([
|
|
|
160
218
|
]);
|
|
161
219
|
let validationTranslator: ValidationTranslator | undefined;
|
|
162
220
|
|
|
163
|
-
function
|
|
164
|
-
|
|
165
|
-
if (typeof rule.param !== "number") return false;
|
|
166
|
-
const expected = `${rule.name === "min" ? "Minimum" : "Maximum"} ${rule.param}`;
|
|
167
|
-
return rule.message === expected;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const defaultMsg = STANDARD_MSGS[rule.name];
|
|
171
|
-
return defaultMsg !== undefined && rule.message === defaultMsg;
|
|
221
|
+
function hasCustomMessage(rule: RuleDef): boolean {
|
|
222
|
+
return rule.hasCustomMessage === true;
|
|
172
223
|
}
|
|
173
224
|
|
|
174
225
|
function resolveValidationMessage(
|
|
@@ -183,112 +234,213 @@ function resolveValidationMessage(
|
|
|
183
234
|
return fallback;
|
|
184
235
|
}
|
|
185
236
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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;
|
|
190
256
|
}
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
);
|
|
193
266
|
}
|
|
194
267
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
params.min = rule.param;
|
|
268
|
+
if (!STANDARD_RULES.has(rule.name)) {
|
|
269
|
+
return rule.message;
|
|
198
270
|
}
|
|
199
|
-
|
|
200
|
-
|
|
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;
|
|
201
278
|
}
|
|
279
|
+
return resolveValidationMessage(
|
|
280
|
+
`validation.${rule.name}`,
|
|
281
|
+
rule.message,
|
|
282
|
+
params,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
202
285
|
|
|
203
|
-
|
|
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
|
+
);
|
|
204
300
|
}
|
|
205
301
|
|
|
206
302
|
/** Compute once: does any field rule prevent dispatching to Rust? */
|
|
207
303
|
function detectHasCustomRules(fields: Record<string, RuleChain>): boolean {
|
|
208
304
|
return Object.values(fields).some((chain) => {
|
|
209
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
|
|
210
310
|
return chain.rules.some((r) => {
|
|
211
311
|
if (!STANDARD_RULES.has(r.name)) return true; // custom rule
|
|
212
|
-
if (
|
|
312
|
+
if (hasCustomMessage(r)) return true; // custom message
|
|
213
313
|
return false;
|
|
214
314
|
});
|
|
215
315
|
});
|
|
216
316
|
}
|
|
217
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
|
+
|
|
218
341
|
/**
|
|
219
342
|
* Create a validation schema.
|
|
220
343
|
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
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.
|
|
224
347
|
*
|
|
225
|
-
* const RegisterValidator = schema
|
|
348
|
+
* const RegisterValidator = schema({
|
|
226
349
|
* email: rules.string().email(),
|
|
227
|
-
*
|
|
350
|
+
* age: rules.number().optional(),
|
|
228
351
|
* });
|
|
352
|
+
* // Infer<typeof RegisterValidator> not needed — result.data is typed.
|
|
229
353
|
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
* own narrowing continue to compile.
|
|
354
|
+
* An explicit generic is still accepted for back-compat
|
|
355
|
+
* (`schema<MyType>({ ... })`), overriding inference.
|
|
233
356
|
*/
|
|
357
|
+
export function schema<S extends Record<string, RuleChain>>(
|
|
358
|
+
fields: S,
|
|
359
|
+
): ValidationSchema<Infer<S>>;
|
|
234
360
|
export function schema<T = Record<string, unknown>>(
|
|
235
361
|
fields: Record<string, RuleChain>,
|
|
236
|
-
): ValidationSchema<T
|
|
362
|
+
): ValidationSchema<T>;
|
|
363
|
+
export function schema(
|
|
364
|
+
fields: Record<string, RuleChain>,
|
|
365
|
+
): ValidationSchema<Record<string, unknown>> {
|
|
237
366
|
// Computed once at construction time, not per validate() call.
|
|
238
367
|
const hasCustomRules = detectHasCustomRules(fields);
|
|
239
368
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
],
|
|
253
|
-
};
|
|
254
|
-
}
|
|
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
|
+
}
|
|
255
381
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
// This schema would have used the native engine, but it isn't
|
|
261
|
-
// loaded — surface the platform-dependent TS fallback once instead
|
|
262
|
-
// of diverging silently. (Schemas with custom rules / a translator
|
|
263
|
-
// always run on TS by design and don't warn.)
|
|
264
|
-
warnNativeUnavailableOnce();
|
|
382
|
+
const provider = options?.messagesProvider;
|
|
383
|
+
if (!hasCustomRules && !validationTranslator && !provider) {
|
|
384
|
+
if (isNativeAvailable()) {
|
|
385
|
+
return validateWithRust(fields, data);
|
|
265
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
|
+
}
|
|
266
392
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
};
|
|
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
|
+
};
|
|
276
401
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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;
|
|
284
411
|
}
|
|
412
|
+
}
|
|
285
413
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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,
|
|
291
440
|
};
|
|
441
|
+
if (error.index !== undefined) node.index = error.index;
|
|
442
|
+
if (error.meta !== undefined) node.meta = error.meta;
|
|
443
|
+
return node;
|
|
292
444
|
}
|
|
293
445
|
|
|
294
446
|
export function setValidationTranslator(
|
|
@@ -307,18 +459,47 @@ export function bindRosetta(rosetta: {
|
|
|
307
459
|
export interface RuleDef {
|
|
308
460
|
name: string;
|
|
309
461
|
param?: number;
|
|
462
|
+
/** Interpolation args exposed to i18n/messages providers (e.g. `{ min: 3 }`). */
|
|
463
|
+
args?: Record<string, unknown>;
|
|
310
464
|
validate: (value: unknown) => boolean;
|
|
311
465
|
message: string;
|
|
466
|
+
/** Set when `.message()` overrode this rule's default text. */
|
|
467
|
+
hasCustomMessage?: boolean;
|
|
312
468
|
}
|
|
313
469
|
|
|
314
|
-
/**
|
|
315
|
-
|
|
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
|
+
|
|
316
490
|
#rules: RuleDef[] = [];
|
|
317
491
|
#isOptional = false;
|
|
318
|
-
#
|
|
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> = [];
|
|
319
499
|
#nestedSchema: Record<string, RuleChain> | null = null;
|
|
320
500
|
#arrayItemChain: RuleChain | null = null;
|
|
321
501
|
#useRules: CompiledRule[] = [];
|
|
502
|
+
#requiredConditions: RequiredCondition[] = [];
|
|
322
503
|
|
|
323
504
|
/** Public read access to rules (for OpenAPI generation, Rust bridge). */
|
|
324
505
|
get rules(): readonly RuleDef[] {
|
|
@@ -327,9 +508,13 @@ export class RuleChain {
|
|
|
327
508
|
get isOptionalField(): boolean {
|
|
328
509
|
return this.#isOptional;
|
|
329
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
|
+
}
|
|
330
515
|
get transforms(): ReadonlyArray<{
|
|
331
516
|
name: string;
|
|
332
|
-
fn: (value: unknown) => unknown;
|
|
517
|
+
fn: (value: unknown, field: FieldContext) => unknown;
|
|
333
518
|
}> {
|
|
334
519
|
return this.#transforms;
|
|
335
520
|
}
|
|
@@ -337,71 +522,147 @@ export class RuleChain {
|
|
|
337
522
|
get useRules(): readonly CompiledRule[] {
|
|
338
523
|
return this.#useRules;
|
|
339
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
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Mark field as optional (absent / `undefined` allowed). */
|
|
556
|
+
optional(): RuleChain<Output | undefined> {
|
|
557
|
+
this.#isOptional = true;
|
|
558
|
+
return this;
|
|
559
|
+
}
|
|
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
|
+
}
|
|
340
566
|
|
|
341
|
-
/** Mark field as optional. */
|
|
342
|
-
|
|
567
|
+
/** Mark field as both optional and nullable. */
|
|
568
|
+
nullish(): RuleChain<Output | null | undefined> {
|
|
343
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;
|
|
344
577
|
return this;
|
|
345
578
|
}
|
|
346
579
|
|
|
347
580
|
/** Must be an object matching a nested schema. */
|
|
348
|
-
object
|
|
581
|
+
object<Sh extends Record<string, RuleChain>>(
|
|
582
|
+
shape: Sh,
|
|
583
|
+
): RuleChain<Infer<Sh>> {
|
|
349
584
|
this.#rules.push({
|
|
350
585
|
name: "object",
|
|
351
|
-
validate: (v) =>
|
|
586
|
+
validate: (v) => isPlainObject(v),
|
|
352
587
|
message: "Must be an object",
|
|
353
588
|
});
|
|
354
589
|
this.#nestedSchema = shape;
|
|
355
|
-
return this;
|
|
590
|
+
return this.#retype<Infer<Sh>>();
|
|
356
591
|
}
|
|
357
592
|
|
|
358
593
|
/** Must be an array. Items validated by the provided chain. */
|
|
359
|
-
array(itemChain?:
|
|
594
|
+
array<Item extends RuleChain>(itemChain?: Item): RuleChain<OutputOf<Item>[]> {
|
|
360
595
|
this.#rules.push({
|
|
361
596
|
name: "array",
|
|
362
597
|
validate: (v) => Array.isArray(v),
|
|
363
598
|
message: "Must be an array",
|
|
364
599
|
});
|
|
365
600
|
this.#arrayItemChain = itemChain ?? null;
|
|
366
|
-
return this;
|
|
601
|
+
return this.#retype<OutputOf<Item>[]>();
|
|
367
602
|
}
|
|
368
603
|
|
|
369
604
|
/** Must be a string. */
|
|
370
|
-
string():
|
|
605
|
+
string(): RuleChain<string> {
|
|
371
606
|
this.#rules.push({
|
|
372
607
|
name: "string",
|
|
373
608
|
validate: (v) => typeof v === "string",
|
|
374
609
|
message: "Must be a string",
|
|
375
610
|
});
|
|
376
|
-
return this;
|
|
611
|
+
return this.#retype<string>();
|
|
377
612
|
}
|
|
378
613
|
|
|
379
614
|
/** Must be a number. */
|
|
380
|
-
number():
|
|
615
|
+
number(): RuleChain<number> {
|
|
381
616
|
this.#rules.push({
|
|
382
617
|
name: "number",
|
|
383
618
|
validate: (v) =>
|
|
384
619
|
typeof v === "number" && !Number.isNaN(v) && Number.isFinite(v),
|
|
385
620
|
message: "Must be a number",
|
|
386
621
|
});
|
|
387
|
-
return this;
|
|
622
|
+
return this.#retype<number>();
|
|
388
623
|
}
|
|
389
624
|
|
|
390
625
|
/** Must be a boolean. */
|
|
391
|
-
boolean():
|
|
626
|
+
boolean(): RuleChain<boolean> {
|
|
392
627
|
this.#rules.push({
|
|
393
628
|
name: "boolean",
|
|
394
629
|
validate: (v) => typeof v === "boolean",
|
|
395
630
|
message: "Must be a boolean",
|
|
396
631
|
});
|
|
397
|
-
return this;
|
|
632
|
+
return this.#retype<boolean>();
|
|
398
633
|
}
|
|
399
634
|
|
|
400
|
-
/**
|
|
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]>();
|
|
647
|
+
}
|
|
648
|
+
|
|
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. */
|
|
401
661
|
min(n: number): this {
|
|
402
662
|
this.#rules.push({
|
|
403
663
|
name: "min",
|
|
404
664
|
param: n,
|
|
665
|
+
args: { min: n },
|
|
405
666
|
validate: (v) =>
|
|
406
667
|
typeof v === "string"
|
|
407
668
|
? [...v].length >= n
|
|
@@ -413,11 +674,12 @@ export class RuleChain {
|
|
|
413
674
|
return this;
|
|
414
675
|
}
|
|
415
676
|
|
|
416
|
-
/** Maximum length (string) or maximum value (number). */
|
|
677
|
+
/** Maximum length (string) or maximum value (number). Alias of max/maxLength. */
|
|
417
678
|
max(n: number): this {
|
|
418
679
|
this.#rules.push({
|
|
419
680
|
name: "max",
|
|
420
681
|
param: n,
|
|
682
|
+
args: { max: n },
|
|
421
683
|
validate: (v) =>
|
|
422
684
|
typeof v === "string"
|
|
423
685
|
? [...v].length <= n
|
|
@@ -429,14 +691,51 @@ export class RuleChain {
|
|
|
429
691
|
return this;
|
|
430
692
|
}
|
|
431
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
|
+
|
|
432
733
|
/** Must be a valid email. */
|
|
433
734
|
email(): this {
|
|
434
735
|
this.#rules.push({
|
|
435
736
|
name: "email",
|
|
436
|
-
// Mirror the Rust engine's regex exactly
|
|
437
|
-
//
|
|
438
|
-
// binary loaded: no whitespace anywhere (the old TS rule rejected only
|
|
439
|
-
// \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.
|
|
440
739
|
validate: (v) =>
|
|
441
740
|
typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
|
|
442
741
|
message: "Must be a valid email",
|
|
@@ -444,7 +743,105 @@ export class RuleChain {
|
|
|
444
743
|
return this;
|
|
445
744
|
}
|
|
446
745
|
|
|
447
|
-
/** 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. */
|
|
448
845
|
positive(): this {
|
|
449
846
|
this.#rules.push({
|
|
450
847
|
name: "positive",
|
|
@@ -454,6 +851,109 @@ export class RuleChain {
|
|
|
454
851
|
return this;
|
|
455
852
|
}
|
|
456
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
|
+
|
|
457
957
|
/** Trim whitespace (transform). */
|
|
458
958
|
trim(): this {
|
|
459
959
|
this.#transforms.push({
|
|
@@ -463,6 +963,23 @@ export class RuleChain {
|
|
|
463
963
|
return this;
|
|
464
964
|
}
|
|
465
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
|
+
|
|
466
983
|
/** Custom validation rule. */
|
|
467
984
|
custom(
|
|
468
985
|
name: string,
|
|
@@ -499,32 +1016,61 @@ export class RuleChain {
|
|
|
499
1016
|
if (this.#rules.length === 0) {
|
|
500
1017
|
throw new RuneError("NO_RULE", "message() must be called after a rule");
|
|
501
1018
|
}
|
|
502
|
-
this.#rules[this.#rules.length - 1]
|
|
1019
|
+
const last = this.#rules[this.#rules.length - 1];
|
|
1020
|
+
last.message = msg;
|
|
1021
|
+
last.hasCustomMessage = true;
|
|
503
1022
|
return this;
|
|
504
1023
|
}
|
|
505
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
|
+
|
|
506
1033
|
/** Internal: validate a field value and return errors + transformed value. */
|
|
507
1034
|
_validateWithTransform(
|
|
508
1035
|
field: string,
|
|
509
|
-
|
|
1036
|
+
rawValue: unknown,
|
|
510
1037
|
ctx: RunContext = EMPTY_RUN_CONTEXT,
|
|
511
1038
|
): { errors: ValidationError[]; transformed: unknown } {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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 };
|
|
515
1061
|
}
|
|
516
1062
|
|
|
517
1063
|
// 1. Type rules first on the raw value — bail on type mismatch.
|
|
518
|
-
const typeError = this.#runTypeRules(field, value);
|
|
1064
|
+
const typeError = this.#runTypeRules(field, value, ctx);
|
|
519
1065
|
if (typeError) return { errors: [typeError], transformed: value };
|
|
520
1066
|
|
|
521
1067
|
// 2. Apply transforms (trim, etc.), then run value rules on the result.
|
|
522
|
-
let transformed = this.#applyTransformsTo(value);
|
|
523
|
-
const errors = this.#runValueRules(field, transformed);
|
|
1068
|
+
let transformed = this.#applyTransformsTo(value, field, ctx);
|
|
1069
|
+
const errors = this.#runValueRules(field, transformed, ctx);
|
|
524
1070
|
|
|
525
1071
|
// 3. Vine-style .use() rules — run with a FieldContext exposing the root
|
|
526
1072
|
// `data` and `parent`, so a rule can validate across fields.
|
|
527
|
-
if (this.#useRules.length > 0) {
|
|
1073
|
+
if (this.#useRules.length > 0 && !(this.#bail && errors.length > 0)) {
|
|
528
1074
|
this.#runUseRules(field, transformed, ctx, errors);
|
|
529
1075
|
}
|
|
530
1076
|
|
|
@@ -536,7 +1082,7 @@ export class RuleChain {
|
|
|
536
1082
|
const nestedResult = chain._validateWithTransform(
|
|
537
1083
|
`${field}.${nestedField}`,
|
|
538
1084
|
obj[nestedField],
|
|
539
|
-
{
|
|
1085
|
+
{ ...ctx, parent: obj },
|
|
540
1086
|
);
|
|
541
1087
|
errors.push(...nestedResult.errors);
|
|
542
1088
|
if (nestedResult.transformed !== undefined) {
|
|
@@ -553,8 +1099,11 @@ export class RuleChain {
|
|
|
553
1099
|
const itemResult = this.#arrayItemChain._validateWithTransform(
|
|
554
1100
|
`${field}.${i}`,
|
|
555
1101
|
arr[i],
|
|
556
|
-
{
|
|
1102
|
+
{ ...ctx, parent: arr },
|
|
557
1103
|
);
|
|
1104
|
+
for (const e of itemResult.errors) {
|
|
1105
|
+
if (e.index === undefined) e.index = i;
|
|
1106
|
+
}
|
|
558
1107
|
errors.push(...itemResult.errors);
|
|
559
1108
|
if (itemResult.transformed !== undefined) {
|
|
560
1109
|
arr[i] = itemResult.transformed;
|
|
@@ -584,32 +1133,32 @@ export class RuleChain {
|
|
|
584
1133
|
},
|
|
585
1134
|
};
|
|
586
1135
|
for (const rule of this.#useRules) {
|
|
587
|
-
// Refresh isValid so a rule can early-return once the field has failed.
|
|
588
1136
|
fieldCtx.isValid = errors.length === 0;
|
|
589
1137
|
rule.run(transformed, fieldCtx);
|
|
590
1138
|
}
|
|
591
1139
|
}
|
|
592
1140
|
|
|
593
|
-
#requiredError(field: string): ValidationError {
|
|
1141
|
+
#requiredError(field: string, ctx: RunContext): ValidationError {
|
|
594
1142
|
return {
|
|
595
1143
|
field,
|
|
596
1144
|
rule: "required",
|
|
597
|
-
message:
|
|
598
|
-
"validation.required",
|
|
599
|
-
`${field} is required`,
|
|
600
|
-
{ field },
|
|
601
|
-
),
|
|
1145
|
+
message: resolveRequiredMessage(field, ctx),
|
|
602
1146
|
};
|
|
603
1147
|
}
|
|
604
1148
|
|
|
605
1149
|
/** Run the type rules (string/number/…) on the raw value; first failure bails. */
|
|
606
|
-
#runTypeRules(
|
|
1150
|
+
#runTypeRules(
|
|
1151
|
+
field: string,
|
|
1152
|
+
value: unknown,
|
|
1153
|
+
ctx: RunContext,
|
|
1154
|
+
): ValidationError | null {
|
|
607
1155
|
for (const rule of this.#rules) {
|
|
608
1156
|
if (TYPE_RULE_NAMES.has(rule.name) && !rule.validate(value)) {
|
|
609
1157
|
return {
|
|
610
1158
|
field,
|
|
611
1159
|
rule: rule.name,
|
|
612
|
-
message: resolveRuleMessage(field, rule),
|
|
1160
|
+
message: resolveRuleMessage(field, rule, ctx),
|
|
1161
|
+
...(ruleArgs(rule) ? { meta: ruleArgs(rule) } : {}),
|
|
613
1162
|
};
|
|
614
1163
|
}
|
|
615
1164
|
}
|
|
@@ -617,14 +1166,21 @@ export class RuleChain {
|
|
|
617
1166
|
}
|
|
618
1167
|
|
|
619
1168
|
/** Run the non-type rules (min/max/email/…) on the transformed value. */
|
|
620
|
-
#runValueRules(
|
|
1169
|
+
#runValueRules(
|
|
1170
|
+
field: string,
|
|
1171
|
+
transformed: unknown,
|
|
1172
|
+
ctx: RunContext,
|
|
1173
|
+
): ValidationError[] {
|
|
621
1174
|
const errors: ValidationError[] = [];
|
|
622
1175
|
for (const rule of this.#rules) {
|
|
623
|
-
if (
|
|
1176
|
+
if (TYPE_RULE_NAMES.has(rule.name)) continue;
|
|
1177
|
+
if (this.#bail && errors.length > 0) break;
|
|
1178
|
+
if (!rule.validate(transformed)) {
|
|
624
1179
|
errors.push({
|
|
625
1180
|
field,
|
|
626
1181
|
rule: rule.name,
|
|
627
|
-
message: resolveRuleMessage(field, rule),
|
|
1182
|
+
message: resolveRuleMessage(field, rule, ctx),
|
|
1183
|
+
...(ruleArgs(rule) ? { meta: ruleArgs(rule) } : {}),
|
|
628
1184
|
});
|
|
629
1185
|
}
|
|
630
1186
|
}
|
|
@@ -638,31 +1194,148 @@ export class RuleChain {
|
|
|
638
1194
|
|
|
639
1195
|
/** Internal: apply transforms. */
|
|
640
1196
|
_transform(value: unknown): unknown {
|
|
641
|
-
return this.#applyTransformsTo(value);
|
|
1197
|
+
return this.#applyTransformsTo(value, "", EMPTY_RUN_CONTEXT);
|
|
642
1198
|
}
|
|
643
1199
|
|
|
644
|
-
#applyTransformsTo(value: unknown): unknown {
|
|
1200
|
+
#applyTransformsTo(value: unknown, field: string, ctx: RunContext): unknown {
|
|
645
1201
|
let result = value;
|
|
646
1202
|
for (const transform of this.#transforms) {
|
|
647
|
-
|
|
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);
|
|
648
1209
|
}
|
|
649
1210
|
return result;
|
|
650
1211
|
}
|
|
651
1212
|
}
|
|
652
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
|
+
|
|
653
1316
|
/** Entry point for building rules. */
|
|
654
1317
|
export const rules = {
|
|
655
|
-
string: () => new RuleChain().string(),
|
|
656
|
-
number: () => new RuleChain().number(),
|
|
657
|
-
boolean: () => new RuleChain().boolean(),
|
|
658
|
-
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),
|
|
659
1332
|
};
|
|
660
1333
|
|
|
661
1334
|
/** Serialize schema + data and validate via Rust NAPI. */
|
|
662
|
-
function validateWithRust
|
|
1335
|
+
function validateWithRust(
|
|
663
1336
|
fields: Record<string, RuleChain>,
|
|
664
1337
|
data: Record<string, unknown>,
|
|
665
|
-
): ValidationResult<
|
|
1338
|
+
): ValidationResult<Record<string, unknown>> {
|
|
666
1339
|
const schemaDesc: Record<
|
|
667
1340
|
string,
|
|
668
1341
|
{
|
|
@@ -673,20 +1346,14 @@ function validateWithRust<T>(
|
|
|
673
1346
|
> = {};
|
|
674
1347
|
|
|
675
1348
|
for (const [field, chain] of Object.entries(fields)) {
|
|
676
|
-
const
|
|
1349
|
+
const ruleDescs = chain.rules.map((r) => ({
|
|
677
1350
|
name: r.name,
|
|
678
|
-
// Serialize THIS rule's own
|
|
679
|
-
//
|
|
680
|
-
|
|
681
|
-
params:
|
|
682
|
-
r.name === "min"
|
|
683
|
-
? { min: r.param }
|
|
684
|
-
: r.name === "max"
|
|
685
|
-
? { max: r.param }
|
|
686
|
-
: 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,
|
|
687
1354
|
}));
|
|
688
1355
|
schemaDesc[field] = {
|
|
689
|
-
rules,
|
|
1356
|
+
rules: ruleDescs,
|
|
690
1357
|
optional: chain.isOptionalField,
|
|
691
1358
|
transforms: chain.transforms.map((t) => t.name),
|
|
692
1359
|
};
|
|
@@ -695,7 +1362,7 @@ function validateWithRust<T>(
|
|
|
695
1362
|
const request = JSON.stringify({ schema: schemaDesc, data });
|
|
696
1363
|
const native = validateNative(request);
|
|
697
1364
|
if (native.valid && native.data !== undefined) {
|
|
698
|
-
return { valid: true, errors: native.errors, data: native.data
|
|
1365
|
+
return { valid: true, errors: native.errors, data: native.data };
|
|
699
1366
|
}
|
|
700
1367
|
return { valid: false, errors: native.errors };
|
|
701
1368
|
}
|