@c9up/rune 0.1.7 → 0.1.8
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 +5 -0
- package/dist/MessagesProvider.d.ts.map +1 -1
- package/dist/MessagesProvider.js +1 -1
- package/dist/MessagesProvider.js.map +1 -1
- package/dist/Schema.d.ts +775 -31
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +2254 -129
- package/dist/Schema.js.map +1 -1
- package/dist/date.d.ts +36 -0
- package/dist/date.d.ts.map +1 -0
- package/dist/date.js +275 -0
- package/dist/date.js.map +1 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +10 -0
- package/dist/errors.js.map +1 -1
- package/dist/formats.d.ts +149 -0
- package/dist/formats.d.ts.map +1 -0
- package/dist/formats.js +612 -0
- package/dist/formats.js.map +1 -0
- package/dist/index.d.ts +149 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +179 -1
- package/dist/index.js.map +1 -1
- package/dist/magic.d.ts +30 -0
- package/dist/magic.d.ts.map +1 -0
- package/dist/magic.js +154 -0
- package/dist/magic.js.map +1 -0
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- 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 +9 -1
- package/src/MessagesProvider.ts +1 -1
- package/src/Schema.ts +3260 -172
- package/src/date.ts +320 -0
- package/src/errors.ts +11 -0
- package/src/formats.ts +721 -0
- package/src/index.ts +262 -0
- package/src/magic.ts +181 -0
- package/src/types.ts +55 -0
package/src/Schema.ts
CHANGED
|
@@ -4,9 +4,55 @@
|
|
|
4
4
|
* @implements FR38, FR39, FR40, FR41
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import {
|
|
8
|
+
type CompareUnit,
|
|
9
|
+
type DateFormat,
|
|
10
|
+
parseDateValue,
|
|
11
|
+
resolveOperand,
|
|
12
|
+
truncateTo,
|
|
13
|
+
} from "./date.js";
|
|
7
14
|
import type { RuneErrorNode } from "./errors.js";
|
|
8
15
|
import { RuneError, RuneValidationError } from "./errors.js";
|
|
16
|
+
import {
|
|
17
|
+
type AlphaOptions,
|
|
18
|
+
alphaPattern,
|
|
19
|
+
type EmailOptions,
|
|
20
|
+
escapeHtml,
|
|
21
|
+
isAscii,
|
|
22
|
+
isCoordinates,
|
|
23
|
+
isCreditCard,
|
|
24
|
+
isEmail,
|
|
25
|
+
isHexCode,
|
|
26
|
+
isIban,
|
|
27
|
+
isIpAddress,
|
|
28
|
+
isJwt,
|
|
29
|
+
isMobile,
|
|
30
|
+
isMobileForLocale,
|
|
31
|
+
isPassport,
|
|
32
|
+
isPostalCode,
|
|
33
|
+
isUlid,
|
|
34
|
+
isUrlWithOptions,
|
|
35
|
+
isVat,
|
|
36
|
+
type NormalizeEmailOptions,
|
|
37
|
+
type NormalizeUrlOptions,
|
|
38
|
+
normalizeEmail,
|
|
39
|
+
normalizeUrl,
|
|
40
|
+
SUPPORTED_MOBILE_LOCALES,
|
|
41
|
+
SUPPORTED_PASSPORTS,
|
|
42
|
+
SUPPORTED_POSTAL_CODES,
|
|
43
|
+
SUPPORTED_VAT_COUNTRIES,
|
|
44
|
+
toCamelCase,
|
|
45
|
+
type UrlOptions,
|
|
46
|
+
type VatOptions,
|
|
47
|
+
} from "./formats.js";
|
|
9
48
|
import type { MessagesProviderContract } from "./MessagesProvider.js";
|
|
49
|
+
import { toWildcardPath } from "./MessagesProvider.js";
|
|
50
|
+
import {
|
|
51
|
+
detectFileType,
|
|
52
|
+
extensionMatches,
|
|
53
|
+
MAGIC_HEAD_BYTES,
|
|
54
|
+
readHead,
|
|
55
|
+
} from "./magic.js";
|
|
10
56
|
import {
|
|
11
57
|
isNativeAvailable,
|
|
12
58
|
validateNative,
|
|
@@ -48,8 +94,31 @@ export interface FieldContext {
|
|
|
48
94
|
meta: Record<string, unknown>;
|
|
49
95
|
/** `true` while no error has been reported for this field yet. */
|
|
50
96
|
isValid: boolean;
|
|
51
|
-
/**
|
|
52
|
-
|
|
97
|
+
/** Last path segment — `city` for `address.city` (VineJS `name`). */
|
|
98
|
+
name: string;
|
|
99
|
+
/** Dotted path with numeric segments replaced by `*` (`tags.*.name`). */
|
|
100
|
+
wildCardPath: string;
|
|
101
|
+
/** `true` when this value sits inside an array. */
|
|
102
|
+
isArrayMember: boolean;
|
|
103
|
+
/** `true` when the value is neither `undefined` nor `null`. */
|
|
104
|
+
isDefined: boolean;
|
|
105
|
+
/** `true` when the value passed its type rule. */
|
|
106
|
+
isValidDataType: boolean;
|
|
107
|
+
/** The full dotted path — same value as {@link field}, VineJS spelling. */
|
|
108
|
+
getFieldPath(): string;
|
|
109
|
+
/** Replace the value under validation (VineJS `mutate`). */
|
|
110
|
+
mutate(newValue: unknown): void;
|
|
111
|
+
/**
|
|
112
|
+
* Report a validation failure. `field` and `args` are optional (VineJS
|
|
113
|
+
* passes four arguments); omitting them reports against this field with no
|
|
114
|
+
* interpolation data.
|
|
115
|
+
*/
|
|
116
|
+
report(
|
|
117
|
+
message: string,
|
|
118
|
+
rule: string,
|
|
119
|
+
field?: string | FieldContext,
|
|
120
|
+
args?: Record<string, unknown>,
|
|
121
|
+
): void;
|
|
53
122
|
}
|
|
54
123
|
|
|
55
124
|
/**
|
|
@@ -65,6 +134,13 @@ export type RuleValidator<Options = undefined> = (
|
|
|
65
134
|
/** A compiled `.use()` rule produced by {@link createRule}. */
|
|
66
135
|
export interface CompiledRule {
|
|
67
136
|
readonly __rune: "rule";
|
|
137
|
+
/** Run even on `undefined`/`null` (VineJS implicit rules). */
|
|
138
|
+
readonly implicit?: boolean;
|
|
139
|
+
readonly name?: string;
|
|
140
|
+
/** Modifier applied to this field's JSON Schema node. */
|
|
141
|
+
readonly toJSONSchema?: JsonSchemaModifier;
|
|
142
|
+
/** The options the rule was built with, handed to {@link toJSONSchema}. */
|
|
143
|
+
readonly ruleOptions?: unknown;
|
|
68
144
|
run(value: unknown, field: FieldContext): void;
|
|
69
145
|
}
|
|
70
146
|
|
|
@@ -83,23 +159,122 @@ export interface CompiledRule {
|
|
|
83
159
|
* passwordConfirmation: rules.string().use(sameAs('password')),
|
|
84
160
|
* })
|
|
85
161
|
*/
|
|
162
|
+
/**
|
|
163
|
+
* Options accepted by {@link createRule} / {@link createAsyncRule} — VineJS
|
|
164
|
+
* `vine.createRule(fn, { implicit, isAsync })`.
|
|
165
|
+
*/
|
|
166
|
+
export interface CreateRuleOptions {
|
|
167
|
+
/**
|
|
168
|
+
* Run the rule even when the value is `undefined` or `null`. Non-implicit
|
|
169
|
+
* rules are skipped on an absent value, which is why a `required`-style
|
|
170
|
+
* custom rule could not be written before.
|
|
171
|
+
*/
|
|
172
|
+
implicit?: boolean;
|
|
173
|
+
/** Rule name reported in errors when the validator does not pass one. */
|
|
174
|
+
name?: string;
|
|
175
|
+
/**
|
|
176
|
+
* VineJS `toJSONSchema?: JsonSchemaModifier` — a FUNCTION receiving the node
|
|
177
|
+
* built so far (plus the rule's options) and returning the modified node.
|
|
178
|
+
* A static fragment could only ever add keys; a modifier can also narrow or
|
|
179
|
+
* replace what the base rules produced.
|
|
180
|
+
*/
|
|
181
|
+
toJSONSchema?: JsonSchemaModifier;
|
|
182
|
+
/**
|
|
183
|
+
* Declare the rule asynchronous (VineJS `{ isAsync: true }`).
|
|
184
|
+
* {@link createAsyncRule} sets it; passing it to {@link createRule} routes
|
|
185
|
+
* the rule to the async builder instead of silently producing a sync rule
|
|
186
|
+
* whose Promise nobody awaits.
|
|
187
|
+
*/
|
|
188
|
+
isAsync?: boolean;
|
|
189
|
+
}
|
|
190
|
+
|
|
86
191
|
export function createRule(
|
|
87
192
|
validator: RuleValidator<undefined>,
|
|
193
|
+
options?: CreateRuleOptions,
|
|
88
194
|
): () => CompiledRule;
|
|
89
195
|
export function createRule<Options>(
|
|
90
196
|
validator: RuleValidator<Options>,
|
|
197
|
+
options?: CreateRuleOptions,
|
|
91
198
|
): (options: Options) => CompiledRule;
|
|
92
199
|
export function createRule<Options>(
|
|
93
200
|
validator: RuleValidator<Options>,
|
|
201
|
+
ruleOptions?: CreateRuleOptions,
|
|
94
202
|
): (options: Options) => CompiledRule {
|
|
203
|
+
if (ruleOptions?.isAsync) {
|
|
204
|
+
// VineJS expresses "async" as an option on createRule, so honour it by
|
|
205
|
+
// BUILDING the async rule rather than refusing: `.use()` routes an
|
|
206
|
+
// async-marked rule to the awaited register.
|
|
207
|
+
const asyncBuilder = createAsyncRule(
|
|
208
|
+
validator as unknown as AsyncRuleValidator<Options>,
|
|
209
|
+
{ ...ruleOptions, isAsync: undefined },
|
|
210
|
+
);
|
|
211
|
+
return asyncBuilder as unknown as (options: Options) => CompiledRule;
|
|
212
|
+
}
|
|
95
213
|
return (options: Options): CompiledRule => ({
|
|
96
214
|
__rune: "rule",
|
|
215
|
+
implicit: ruleOptions?.implicit ?? false,
|
|
216
|
+
name: ruleOptions?.name,
|
|
217
|
+
toJSONSchema: ruleOptions?.toJSONSchema,
|
|
218
|
+
ruleOptions: options,
|
|
97
219
|
run(value: unknown, field: FieldContext): void {
|
|
98
220
|
validator(value, options, field);
|
|
99
221
|
},
|
|
100
222
|
});
|
|
101
223
|
}
|
|
102
224
|
|
|
225
|
+
/**
|
|
226
|
+
* An async `.useAsync()` rule validator — same shape as {@link RuleValidator}
|
|
227
|
+
* but may return a Promise. Runs only under {@link ValidationSchema.validateResultAsync}.
|
|
228
|
+
*/
|
|
229
|
+
export type AsyncRuleValidator<Options = undefined> = (
|
|
230
|
+
value: unknown,
|
|
231
|
+
options: Options,
|
|
232
|
+
field: FieldContext,
|
|
233
|
+
) => void | Promise<void>;
|
|
234
|
+
|
|
235
|
+
/** A compiled async rule produced by {@link createAsyncRule}. */
|
|
236
|
+
export interface AsyncCompiledRule {
|
|
237
|
+
readonly __rune: "asyncRule";
|
|
238
|
+
/** Run even on `undefined`/`null` (VineJS implicit rules). */
|
|
239
|
+
readonly implicit?: boolean;
|
|
240
|
+
readonly name?: string;
|
|
241
|
+
/** Modifier applied to this field's JSON Schema node. */
|
|
242
|
+
readonly toJSONSchema?: JsonSchemaModifier;
|
|
243
|
+
/** The options the rule was built with, handed to {@link toJSONSchema}. */
|
|
244
|
+
readonly ruleOptions?: unknown;
|
|
245
|
+
run(value: unknown, field: FieldContext): Promise<void>;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Async counterpart of {@link createRule} — for rules that must await (DB lookups
|
|
250
|
+
* etc.). Attach with `chain.useAsync(rule(options))`; the schema must then be run
|
|
251
|
+
* with `validateResultAsync`. This is how DB-backed `unique`/`exists` rules are built
|
|
252
|
+
* (the validator does the query), keeping rune framework-agnostic.
|
|
253
|
+
*/
|
|
254
|
+
export function createAsyncRule(
|
|
255
|
+
validator: AsyncRuleValidator<undefined>,
|
|
256
|
+
options?: CreateRuleOptions,
|
|
257
|
+
): () => AsyncCompiledRule;
|
|
258
|
+
export function createAsyncRule<Options>(
|
|
259
|
+
validator: AsyncRuleValidator<Options>,
|
|
260
|
+
options?: CreateRuleOptions,
|
|
261
|
+
): (options: Options) => AsyncCompiledRule;
|
|
262
|
+
export function createAsyncRule<Options>(
|
|
263
|
+
validator: AsyncRuleValidator<Options>,
|
|
264
|
+
ruleOptions?: CreateRuleOptions,
|
|
265
|
+
): (options: Options) => AsyncCompiledRule {
|
|
266
|
+
return (options: Options): AsyncCompiledRule => ({
|
|
267
|
+
__rune: "asyncRule",
|
|
268
|
+
implicit: ruleOptions?.implicit ?? false,
|
|
269
|
+
name: ruleOptions?.name,
|
|
270
|
+
toJSONSchema: ruleOptions?.toJSONSchema,
|
|
271
|
+
ruleOptions: options,
|
|
272
|
+
async run(value: unknown, field: FieldContext): Promise<void> {
|
|
273
|
+
await validator(value, options, field);
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
103
278
|
/**
|
|
104
279
|
* Validation result — discriminated union that narrows `data` to the schema's
|
|
105
280
|
* `T` when `valid` is `true`, removing the need for callers to cast or guard
|
|
@@ -119,18 +294,196 @@ export interface ValidateOptions {
|
|
|
119
294
|
* provider takes precedence over a globally bound translator).
|
|
120
295
|
*/
|
|
121
296
|
messagesProvider?: MessagesProviderContract;
|
|
297
|
+
/**
|
|
298
|
+
* VineJS `errorReporter: () => ErrorReporterContract` — a FACTORY returning a
|
|
299
|
+
* reporter, so a transcribed Adonis reporter works as-is. A plain
|
|
300
|
+
* `(error) => void` observer is also accepted.
|
|
301
|
+
*
|
|
302
|
+
* The two are told apart by ARITY (a factory takes no argument), never by
|
|
303
|
+
* calling one speculatively to see what comes back.
|
|
304
|
+
*
|
|
305
|
+
* Either way the reporter OBSERVES: the validation result is never changed by
|
|
306
|
+
* it, so a reporter cannot mask a failure.
|
|
307
|
+
*/
|
|
308
|
+
errorReporter?: ErrorReporterFactory | ((error: ValidationError) => void);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* VineJS `JsonSchemaModifier`: receives the JSON Schema node assembled from the
|
|
313
|
+
* declarative rules and returns the node to use instead.
|
|
314
|
+
*/
|
|
315
|
+
export type JsonSchemaModifier = (
|
|
316
|
+
node: Record<string, unknown>,
|
|
317
|
+
options?: unknown,
|
|
318
|
+
) => Record<string, unknown>;
|
|
319
|
+
|
|
320
|
+
/** VineJS `ErrorReporterContract`. */
|
|
321
|
+
export interface ErrorReporterContract {
|
|
322
|
+
/** `true` once at least one error has been reported. */
|
|
323
|
+
hasErrors: boolean;
|
|
324
|
+
/** Build the exception a caller may throw. */
|
|
325
|
+
createError(): Error;
|
|
326
|
+
/** Report one failure. */
|
|
327
|
+
report(
|
|
328
|
+
message: string,
|
|
329
|
+
rule: string,
|
|
330
|
+
field: FieldContext | string,
|
|
331
|
+
args?: Record<string, unknown>,
|
|
332
|
+
): unknown;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** A zero-argument factory producing a fresh {@link ErrorReporterContract}. */
|
|
336
|
+
export type ErrorReporterFactory = () => ErrorReporterContract;
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Normalise either accepted spelling into one "report this error" callback.
|
|
340
|
+
* A factory is built ONCE per validation, so a stateful Vine reporter sees the
|
|
341
|
+
* whole run and can assemble its own error shape.
|
|
342
|
+
*/
|
|
343
|
+
function toReporter(
|
|
344
|
+
reporter:
|
|
345
|
+
| ErrorReporterFactory
|
|
346
|
+
| ((error: ValidationError) => void)
|
|
347
|
+
| undefined,
|
|
348
|
+
data: unknown,
|
|
349
|
+
meta: Record<string, unknown>,
|
|
350
|
+
):
|
|
351
|
+
| {
|
|
352
|
+
report(error: ValidationError): void;
|
|
353
|
+
createError?: () => Error;
|
|
354
|
+
}
|
|
355
|
+
| undefined {
|
|
356
|
+
if (!reporter) return undefined;
|
|
357
|
+
if (reporter.length > 0) {
|
|
358
|
+
// Plain observer: it consumes each error and never decides the outcome.
|
|
359
|
+
const observe = reporter as (error: ValidationError) => void;
|
|
360
|
+
return { report: observe };
|
|
361
|
+
}
|
|
362
|
+
const built = (reporter as ErrorReporterFactory)();
|
|
363
|
+
return {
|
|
364
|
+
report(error) {
|
|
365
|
+
// VineJS hands the reporter a FieldContext, not a path string: a real
|
|
366
|
+
// reporter reads `getFieldPath()` / `name` / `wildCardPath` off it.
|
|
367
|
+
built.report(
|
|
368
|
+
error.message,
|
|
369
|
+
error.rule,
|
|
370
|
+
reportedFieldContext(error, data, meta),
|
|
371
|
+
error.meta,
|
|
372
|
+
);
|
|
373
|
+
},
|
|
374
|
+
createError: () => built.createError(),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Rebuild the {@link FieldContext} a reporter expects from a collected error.
|
|
380
|
+
*
|
|
381
|
+
* The traversal reports post-hoc (it collects, then hands the batch over), so
|
|
382
|
+
* the original context is gone by then — but everything a reporter actually
|
|
383
|
+
* reads is derivable from the field path plus the root data.
|
|
384
|
+
*/
|
|
385
|
+
function reportedFieldContext(
|
|
386
|
+
error: ValidationError,
|
|
387
|
+
data: unknown,
|
|
388
|
+
meta: Record<string, unknown>,
|
|
389
|
+
): FieldContext {
|
|
390
|
+
const segments = error.field.split(".");
|
|
391
|
+
const root = isPlainObject(data) ? data : {};
|
|
392
|
+
return {
|
|
393
|
+
value: undefined,
|
|
394
|
+
data: root,
|
|
395
|
+
parent: root,
|
|
396
|
+
field: error.field,
|
|
397
|
+
meta,
|
|
398
|
+
isValid: false,
|
|
399
|
+
name: segments[segments.length - 1] ?? error.field,
|
|
400
|
+
wildCardPath: toWildcardPath(error.field),
|
|
401
|
+
isArrayMember: /\.\d+$/.test(error.field),
|
|
402
|
+
isDefined: false,
|
|
403
|
+
isValidDataType: false,
|
|
404
|
+
getFieldPath: () => error.field,
|
|
405
|
+
mutate: (): void => {},
|
|
406
|
+
report: (): void => {},
|
|
407
|
+
};
|
|
122
408
|
}
|
|
123
409
|
|
|
124
410
|
export interface ValidationSchema<T = Record<string, unknown>> {
|
|
125
411
|
fields: Record<string, RuleChain>;
|
|
126
|
-
/**
|
|
127
|
-
|
|
412
|
+
/**
|
|
413
|
+
* The object schema the validator was built from — always a chain, so
|
|
414
|
+
* `validator.schema.partial()` / `.pick()` / `.omit()` work as in VineJS
|
|
415
|
+
* whichever form `create()` received. The raw field map stays on
|
|
416
|
+
* {@link fields}.
|
|
417
|
+
*/
|
|
418
|
+
schema: RuleChain;
|
|
419
|
+
/**
|
|
420
|
+
* Standard Schema v1 contract, so a consumer can validate through the
|
|
421
|
+
* vendor-neutral protocol instead of rune's own API.
|
|
422
|
+
*/
|
|
423
|
+
"~standard": {
|
|
424
|
+
version: 1;
|
|
425
|
+
vendor: string;
|
|
426
|
+
/** Standard JSON Schema v1 props (VineJS 4.3+). */
|
|
427
|
+
jsonSchema: {
|
|
428
|
+
input(): Record<string, unknown>;
|
|
429
|
+
output(): Record<string, unknown>;
|
|
430
|
+
};
|
|
431
|
+
validate(
|
|
432
|
+
value: unknown,
|
|
433
|
+
): Promise<
|
|
434
|
+
| { value: T }
|
|
435
|
+
| { issues: ReadonlyArray<{ message: string; path: string[] }> }
|
|
436
|
+
>;
|
|
437
|
+
};
|
|
438
|
+
/**
|
|
439
|
+
* Error reporter for this validator (VineJS `validator.errorReporter`). A
|
|
440
|
+
* per-call option still wins; this wins over the process-wide one.
|
|
441
|
+
*/
|
|
442
|
+
errorReporter:
|
|
443
|
+
| ErrorReporterFactory
|
|
444
|
+
| ((error: ValidationError) => void)
|
|
445
|
+
| null;
|
|
446
|
+
/** Introspection of the compiled schema — VineJS `{ schema, refs }` shape. */
|
|
447
|
+
toJSON(): { schema: SchemaIntrospection; refs: string[] };
|
|
448
|
+
/** JSON Schema for the compiled validator (VineJS `toJSONSchema`). */
|
|
449
|
+
toJSONSchema(): Record<string, unknown>;
|
|
450
|
+
/**
|
|
451
|
+
* Validate and return the payload, throwing {@link RuneValidationError} on
|
|
452
|
+
* failure — the VineJS/Adonis contract (`validator.validate(data)`), async
|
|
453
|
+
* so a schema carrying `unique`/`exists` behaves like any other.
|
|
454
|
+
*
|
|
455
|
+
* The never-throwing, synchronous form rune also offers is
|
|
456
|
+
* {@link validateResult}.
|
|
457
|
+
*/
|
|
458
|
+
validate(data: unknown, options?: ValidateOptions): Promise<T>;
|
|
459
|
+
/** Result-based validation (rune superset) — synchronous, never throws. */
|
|
460
|
+
validateResult(data: unknown, options?: ValidateOptions): ValidationResult<T>;
|
|
461
|
+
/** Result-based validation awaiting async rules — never throws. */
|
|
462
|
+
validateResultAsync(
|
|
463
|
+
data: unknown,
|
|
464
|
+
options?: ValidateOptions,
|
|
465
|
+
): Promise<ValidationResult<T>>;
|
|
128
466
|
/**
|
|
129
467
|
* Throwing validation (VineJS/Adonis parity). Returns the validated data on
|
|
130
468
|
* success; throws {@link RuneValidationError} (`E_VALIDATION_ERROR`, HTTP 422)
|
|
131
469
|
* with a structured `.messages` array on failure.
|
|
132
470
|
*/
|
|
133
471
|
validateOrThrow(data: unknown, options?: ValidateOptions): T;
|
|
472
|
+
/**
|
|
473
|
+
* Non-throwing validation returning `[error, null] | [null, data]`
|
|
474
|
+
* (VineJS `tryValidate`).
|
|
475
|
+
*/
|
|
476
|
+
tryValidate(
|
|
477
|
+
data: unknown,
|
|
478
|
+
options?: ValidateOptions,
|
|
479
|
+
): Promise<[RuneValidationError, null] | [null, T]>;
|
|
480
|
+
/** Synchronous counterpart of {@link tryValidate} (rune superset). */
|
|
481
|
+
tryValidateSync(
|
|
482
|
+
data: unknown,
|
|
483
|
+
options?: ValidateOptions,
|
|
484
|
+
): [RuneValidationError, null] | [null, T];
|
|
485
|
+
/** Throwing async validation (see {@link validateResultAsync} + {@link validateOrThrow}). */
|
|
486
|
+
validateOrThrowAsync(data: unknown, options?: ValidateOptions): Promise<T>;
|
|
134
487
|
}
|
|
135
488
|
|
|
136
489
|
/** Context threaded through validation so field rules can reach root/parent/meta. */
|
|
@@ -139,6 +492,579 @@ interface RunContext {
|
|
|
139
492
|
parent: Record<string, unknown> | unknown[];
|
|
140
493
|
meta: Record<string, unknown>;
|
|
141
494
|
messagesProvider?: MessagesProviderContract;
|
|
495
|
+
errorReporter?: (error: ValidationError) => void;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* An async rule run deferred by the (synchronous) traversal and awaited by
|
|
500
|
+
* `validateResultAsync`. Collected at EVERY depth — top-level fields, nested object
|
|
501
|
+
* fields and array items alike.
|
|
502
|
+
*/
|
|
503
|
+
interface PendingAsync {
|
|
504
|
+
chain: RuleChain;
|
|
505
|
+
field: string;
|
|
506
|
+
value: unknown;
|
|
507
|
+
ctx: RunContext;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Global output mapper for `rules.date()` — VineJS's `VineDate.transform` seam.
|
|
512
|
+
*
|
|
513
|
+
* rune has zero runtime dependencies, so a validated date is a plain `Date`. A
|
|
514
|
+
* consumer that wants its own type (e.g. a `@c9up/chronos` `DateTime`, which is
|
|
515
|
+
* what atlas hands back on read) binds it here once at boot, exactly like
|
|
516
|
+
* `bindRosetta` does for translations. Applied AFTER the comparison rules, so
|
|
517
|
+
* `after`/`before` always compare real `Date`s.
|
|
518
|
+
*/
|
|
519
|
+
let dateOutputTransform: ((value: Date) => unknown) | null = null;
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Process-wide messages provider (VineJS `vine.messagesProvider`). A provider
|
|
523
|
+
* passed per call still wins — global is the fallback, not an override.
|
|
524
|
+
*/
|
|
525
|
+
let globalMessagesProvider: MessagesProviderContract | null = null;
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Process-wide error reporter (VineJS `vine.errorReporter = …`). A per-call
|
|
529
|
+
* option wins over a per-validator one, which wins over this.
|
|
530
|
+
*/
|
|
531
|
+
let globalErrorReporter:
|
|
532
|
+
| ErrorReporterFactory
|
|
533
|
+
| ((error: ValidationError) => void)
|
|
534
|
+
| null = null;
|
|
535
|
+
|
|
536
|
+
/** Bind (or clear) the process-wide error reporter. */
|
|
537
|
+
export function setGlobalErrorReporter(
|
|
538
|
+
reporter: ErrorReporterFactory | ((error: ValidationError) => void) | null,
|
|
539
|
+
): void {
|
|
540
|
+
globalErrorReporter = reporter;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Read the process-wide error reporter. */
|
|
544
|
+
export function getGlobalErrorReporter():
|
|
545
|
+
| ErrorReporterFactory
|
|
546
|
+
| ((error: ValidationError) => void)
|
|
547
|
+
| null {
|
|
548
|
+
return globalErrorReporter;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Host lookup seam backing `activeUrl()` — see that rule's note on why. */
|
|
552
|
+
export interface HostResolver {
|
|
553
|
+
/** Resolve `true` when the hostname resolves (DNS, or whatever you decide). */
|
|
554
|
+
resolves(hostname: string): Promise<boolean>;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
let hostResolver: HostResolver | null = null;
|
|
558
|
+
|
|
559
|
+
/** See `rune.convertEmptyStringsToNull`. */
|
|
560
|
+
let convertEmptyStringsToNull = false;
|
|
561
|
+
|
|
562
|
+
/** Toggle the global `"" -> null` conversion (VineJS `convertEmptyStringsToNull`). */
|
|
563
|
+
export function setConvertEmptyStringsToNull(enabled: boolean): void {
|
|
564
|
+
convertEmptyStringsToNull = enabled;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Read the global `"" -> null` conversion flag. */
|
|
568
|
+
export function getConvertEmptyStringsToNull(): boolean {
|
|
569
|
+
return convertEmptyStringsToNull;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Rewrite every `""` to `null`, deeply, before validation.
|
|
574
|
+
*
|
|
575
|
+
* Applied to the DATA rather than inside each chain: it has to happen before
|
|
576
|
+
* the optional/nullable decision, and before the native-engine routing — the
|
|
577
|
+
* Rust engine never sees this flag, so converting later would have made the
|
|
578
|
+
* behaviour depend on whether the binary was loadable.
|
|
579
|
+
*/
|
|
580
|
+
function convertEmptyStrings(value: unknown): unknown {
|
|
581
|
+
if (value === "") return null;
|
|
582
|
+
if (Array.isArray(value)) return value.map(convertEmptyStrings);
|
|
583
|
+
if (isPlainObject(value)) {
|
|
584
|
+
return Object.fromEntries(
|
|
585
|
+
Object.entries(value).map(([k, v]) => [k, convertEmptyStrings(v)]),
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
return value;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Bind (or clear, with `null`) the resolver backing `activeUrl()`. */
|
|
592
|
+
export function bindHostResolver(resolver: HostResolver | null): void {
|
|
593
|
+
hostResolver = resolver;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Bind (or clear) the process-wide messages provider. */
|
|
597
|
+
export function setGlobalMessagesProvider(
|
|
598
|
+
provider: MessagesProviderContract | null,
|
|
599
|
+
): void {
|
|
600
|
+
globalMessagesProvider = provider;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Read the process-wide messages provider. */
|
|
604
|
+
export function getGlobalMessagesProvider(): MessagesProviderContract | null {
|
|
605
|
+
return globalMessagesProvider;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Bind (or clear, with `null`) the global `rules.date()` output mapper. */
|
|
609
|
+
export function setDateTransform(fn: ((value: Date) => unknown) | null): void {
|
|
610
|
+
dateOutputTransform = fn;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* A database lookup seam for the Lucid-style `unique` / `exists` rules.
|
|
615
|
+
*
|
|
616
|
+
* rune stays framework-agnostic, so it never imports a driver: the host binds
|
|
617
|
+
* one resolver at boot (as `bindRosetta` does for translations) and the rules
|
|
618
|
+
* then take Lucid's `{ table, column, where }` options instead of a hand-written
|
|
619
|
+
* callback. The callback form is kept — it is what the resolver is built from.
|
|
620
|
+
*/
|
|
621
|
+
export interface DatabaseResolver {
|
|
622
|
+
/** Resolve `true` when at least one row matches. */
|
|
623
|
+
exists(query: DatabaseLookup): Promise<boolean>;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** The lookup handed to a {@link DatabaseResolver} (Lucid `unique`/`exists`). */
|
|
627
|
+
export interface DatabaseLookup {
|
|
628
|
+
table: string;
|
|
629
|
+
column: string;
|
|
630
|
+
value: unknown;
|
|
631
|
+
/** Extra equality filters, e.g. `{ tenant_id: 3 }` (Lucid `where`). */
|
|
632
|
+
where?: Record<string, unknown>;
|
|
633
|
+
/** Rows to ignore, e.g. `{ id: 7 }` when updating (Lucid `whereNot`). */
|
|
634
|
+
whereNot?: Record<string, unknown>;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
let databaseResolver: DatabaseResolver | null = null;
|
|
638
|
+
|
|
639
|
+
/** Bind (or clear, with `null`) the resolver backing `unique()` / `exists()`. */
|
|
640
|
+
export function bindDatabase(resolver: DatabaseResolver | null): void {
|
|
641
|
+
databaseResolver = resolver;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Options form of `unique()` / `exists()` — Lucid's shape. */
|
|
645
|
+
export interface DatabaseRuleOptions {
|
|
646
|
+
table: string;
|
|
647
|
+
column?: string;
|
|
648
|
+
where?: Record<string, unknown>;
|
|
649
|
+
whereNot?: Record<string, unknown>;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Turn the options form of `unique`/`exists` into the callback the rule runs.
|
|
654
|
+
* Fails loudly when no resolver is bound: a uniqueness check that cannot run
|
|
655
|
+
* must never look like one that passed.
|
|
656
|
+
*/
|
|
657
|
+
function toDatabaseCheck(
|
|
658
|
+
checkOrOptions:
|
|
659
|
+
| ((value: unknown, field: FieldContext) => boolean | Promise<boolean>)
|
|
660
|
+
| DatabaseRuleOptions,
|
|
661
|
+
kind: "unique" | "exists",
|
|
662
|
+
): (value: unknown, field: FieldContext) => boolean | Promise<boolean> {
|
|
663
|
+
if (typeof checkOrOptions === "function") return checkOrOptions;
|
|
664
|
+
const options = checkOrOptions;
|
|
665
|
+
return async (value, field) => {
|
|
666
|
+
if (!databaseResolver) {
|
|
667
|
+
throw new RuneError(
|
|
668
|
+
"NO_DATABASE_RESOLVER",
|
|
669
|
+
`rules.${kind}({ table }) needs a database resolver.`,
|
|
670
|
+
{
|
|
671
|
+
hint: "Call bindDatabase(resolver) once at boot, or pass a callback.",
|
|
672
|
+
},
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
const found = await databaseResolver.exists({
|
|
676
|
+
table: options.table,
|
|
677
|
+
column: options.column ?? field.name,
|
|
678
|
+
value,
|
|
679
|
+
where: options.where,
|
|
680
|
+
whereNot: options.whereNot,
|
|
681
|
+
});
|
|
682
|
+
return kind === "unique" ? !found : found;
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** VineJS number coercion: a numeric string becomes a number, the rest is untouched. */
|
|
687
|
+
function coerceNumber(value: unknown): unknown {
|
|
688
|
+
if (typeof value !== "string") return value;
|
|
689
|
+
const trimmed = value.trim();
|
|
690
|
+
if (trimmed === "") return value;
|
|
691
|
+
const n = Number(trimmed);
|
|
692
|
+
return Number.isFinite(n) ? n : value;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** VineJS boolean coercion over the usual form-encoded spellings. */
|
|
696
|
+
function coerceBoolean(value: unknown): unknown {
|
|
697
|
+
if (value === 1 || value === 0) return value === 1;
|
|
698
|
+
if (typeof value !== "string") return value;
|
|
699
|
+
const v = value.trim().toLowerCase();
|
|
700
|
+
if (["true", "on", "1"].includes(v)) return true;
|
|
701
|
+
if (["false", "off", "0"].includes(v)) return false;
|
|
702
|
+
return value;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Options accepted by every date comparison (VineJS `{ compare, format }`). */
|
|
706
|
+
export interface DateCompareOptions {
|
|
707
|
+
/** Granularity of the comparison. Defaults to `"day"`, like VineJS. */
|
|
708
|
+
compare?: CompareUnit;
|
|
709
|
+
/** Format used to parse the operand / sibling, when it is a string. */
|
|
710
|
+
format?: string;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* The structural shape `file()` accepts. An Adonis bodyparser `MultipartFile`
|
|
715
|
+
* satisfies it without rune having to know the type.
|
|
716
|
+
*/
|
|
717
|
+
export interface FileLike {
|
|
718
|
+
size: number;
|
|
719
|
+
/**
|
|
720
|
+
* MIME type as REPORTED by the upload. Trust it only with
|
|
721
|
+
* `verifyContent()`, which checks it against the real bytes.
|
|
722
|
+
*/
|
|
723
|
+
type?: string;
|
|
724
|
+
/** Adonis bodyparser's temp path — a byte source for `verifyContent()`. */
|
|
725
|
+
tmpPath?: string;
|
|
726
|
+
/** Alternative byte-source paths. */
|
|
727
|
+
filePath?: string;
|
|
728
|
+
path?: string;
|
|
729
|
+
/** In-memory bytes, when the upload was buffered. */
|
|
730
|
+
buffer?: Uint8Array;
|
|
731
|
+
extname?: string | null;
|
|
732
|
+
clientName?: string;
|
|
733
|
+
name?: string;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Byte multipliers for the size spellings Adonis accepts. */
|
|
737
|
+
const BYTE_UNITS: Record<string, number> = {
|
|
738
|
+
b: 1,
|
|
739
|
+
kb: 1024,
|
|
740
|
+
mb: 1024 ** 2,
|
|
741
|
+
gb: 1024 ** 3,
|
|
742
|
+
tb: 1024 ** 4,
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Parse a size limit — a byte count, or Adonis's `"2mb"` / `"512kb"` spelling.
|
|
747
|
+
* Throws on an unreadable unit rather than falling back to "unlimited": a cap
|
|
748
|
+
* that silently stops capping is worse than no cap at all.
|
|
749
|
+
*/
|
|
750
|
+
export function parseByteSize(size: number | string): number {
|
|
751
|
+
if (typeof size === "number") return size;
|
|
752
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)\s*$/i.exec(size);
|
|
753
|
+
if (!match) {
|
|
754
|
+
throw new RuneError("INVALID_SIZE", `file(): cannot read size '${size}'.`, {
|
|
755
|
+
hint: 'Use a byte count, or "2mb" / "512kb" / "1gb".',
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
return Math.round(Number(match[1]) * BYTE_UNITS[match[2].toLowerCase()]);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Get the leading bytes of an upload, from whichever source it exposes.
|
|
763
|
+
* Returns `null` when there is none — the caller must treat that as a FAILURE,
|
|
764
|
+
* not as "nothing to check".
|
|
765
|
+
*/
|
|
766
|
+
async function readFileHead(file: FileLike): Promise<Uint8Array | null> {
|
|
767
|
+
if (file.buffer instanceof Uint8Array) {
|
|
768
|
+
return file.buffer.subarray(0, MAGIC_HEAD_BYTES);
|
|
769
|
+
}
|
|
770
|
+
const path = file.tmpPath ?? file.filePath ?? file.path;
|
|
771
|
+
if (typeof path !== "string" || path.length === 0) return null;
|
|
772
|
+
try {
|
|
773
|
+
return await readHead(path);
|
|
774
|
+
} catch {
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Structural guard for {@link FileLike}. */
|
|
780
|
+
function isFileLike(value: unknown): value is FileLike {
|
|
781
|
+
return (
|
|
782
|
+
typeof value === "object" &&
|
|
783
|
+
value !== null &&
|
|
784
|
+
"size" in value &&
|
|
785
|
+
typeof (value as FileLike).size === "number"
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** Lowercase extension without the dot, from `extname` or a file name. */
|
|
790
|
+
function fileExtension(file: FileLike): string | null {
|
|
791
|
+
if (typeof file.extname === "string" && file.extname.length > 0) {
|
|
792
|
+
return file.extname.replace(/^\./, "").toLowerCase();
|
|
793
|
+
}
|
|
794
|
+
const name = file.clientName ?? file.name;
|
|
795
|
+
if (typeof name !== "string") return null;
|
|
796
|
+
const dot = name.lastIndexOf(".");
|
|
797
|
+
return dot > 0 ? name.slice(dot + 1).toLowerCase() : null;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* What a `parse()` callback receives besides the value — VineJS's
|
|
802
|
+
* `ParseFn = (value, ctx: Pick<FieldContext, 'data' | 'parent' | 'meta'>)`.
|
|
803
|
+
*/
|
|
804
|
+
export type ParseContext = Pick<FieldContext, "data" | "parent" | "meta">;
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* A conditional set of properties merged into an object (VineJS `vine.group`).
|
|
808
|
+
* The first branch whose predicate matches contributes its shape; `otherwise`
|
|
809
|
+
* is the unconditional fallback.
|
|
810
|
+
*/
|
|
811
|
+
export interface ConditionalGroup {
|
|
812
|
+
readonly __rune: "group";
|
|
813
|
+
branches: ReadonlyArray<{
|
|
814
|
+
predicate: ((data: Record<string, unknown>) => boolean) | null;
|
|
815
|
+
shape: Record<string, RuleChain>;
|
|
816
|
+
}>;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Per-field introspection returned inside `toJSON().schema`. */
|
|
820
|
+
export type SchemaIntrospection = Record<
|
|
821
|
+
string,
|
|
822
|
+
{ rules: string[]; optional: boolean; nullable: boolean }
|
|
823
|
+
>;
|
|
824
|
+
|
|
825
|
+
/** Describe every field's rules — the `schema` half of `toJSON()`. */
|
|
826
|
+
function introspect(fields: Record<string, RuleChain>): SchemaIntrospection {
|
|
827
|
+
return Object.fromEntries(
|
|
828
|
+
Object.entries(fields).map(([field, chain]) => [
|
|
829
|
+
field,
|
|
830
|
+
{
|
|
831
|
+
rules: chain.rules.map((rule) => rule.name),
|
|
832
|
+
optional: chain.isOptionalField,
|
|
833
|
+
nullable: chain.isNullable,
|
|
834
|
+
},
|
|
835
|
+
]),
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** Rule name → the JSON Schema fragment it contributes. */
|
|
840
|
+
const JSON_SCHEMA_TYPES: Record<string, string> = {
|
|
841
|
+
string: "string",
|
|
842
|
+
number: "number",
|
|
843
|
+
boolean: "boolean",
|
|
844
|
+
date: "string",
|
|
845
|
+
accepted: "boolean",
|
|
846
|
+
object: "object",
|
|
847
|
+
record: "object",
|
|
848
|
+
array: "array",
|
|
849
|
+
tuple: "array",
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Translate a field map to JSON Schema.
|
|
854
|
+
*
|
|
855
|
+
* Only rules with a real JSON Schema equivalent are emitted; a rule without one
|
|
856
|
+
* is OMITTED rather than approximated, because a schema that quietly drops a
|
|
857
|
+
* constraint is worse than one that says less.
|
|
858
|
+
*/
|
|
859
|
+
function chainToJSONSchema(
|
|
860
|
+
fields: Record<string, RuleChain>,
|
|
861
|
+
): Record<string, unknown> {
|
|
862
|
+
const properties: Record<string, unknown> = {};
|
|
863
|
+
const required: string[] = [];
|
|
864
|
+
for (const [field, chain] of Object.entries(fields)) {
|
|
865
|
+
const node: Record<string, unknown> = {};
|
|
866
|
+
for (const rule of chain.rules) {
|
|
867
|
+
const type = JSON_SCHEMA_TYPES[rule.name];
|
|
868
|
+
if (type !== undefined) node.type = type;
|
|
869
|
+
const args = rule.args ?? {};
|
|
870
|
+
if (rule.name === "minLength") node.minLength = args.min ?? rule.param;
|
|
871
|
+
if (rule.name === "maxLength") node.maxLength = args.max ?? rule.param;
|
|
872
|
+
if (rule.name === "fixedLength") {
|
|
873
|
+
node.minLength = args.length ?? rule.param;
|
|
874
|
+
node.maxLength = args.length ?? rule.param;
|
|
875
|
+
}
|
|
876
|
+
if (rule.name === "min") node.minimum = args.min ?? rule.param;
|
|
877
|
+
if (rule.name === "max") node.maximum = args.max ?? rule.param;
|
|
878
|
+
if (rule.name === "range") {
|
|
879
|
+
node.minimum = args.min;
|
|
880
|
+
node.maximum = args.max;
|
|
881
|
+
}
|
|
882
|
+
if (rule.name === "email") node.format = "email";
|
|
883
|
+
if (rule.name === "uuid") node.format = "uuid";
|
|
884
|
+
if (rule.name === "url") node.format = "uri";
|
|
885
|
+
if (rule.name === "date") node.format = "date-time";
|
|
886
|
+
if (rule.name === "regex" && typeof args.pattern === "string") {
|
|
887
|
+
node.pattern = args.pattern;
|
|
888
|
+
}
|
|
889
|
+
if (rule.name === "enum" && Array.isArray(args.values)) {
|
|
890
|
+
node.enum = args.values;
|
|
891
|
+
}
|
|
892
|
+
if (rule.name === "literal" && "value" in args) {
|
|
893
|
+
node.const = args.value;
|
|
894
|
+
}
|
|
895
|
+
if (rule.name === "notEmpty") node.minItems = 1;
|
|
896
|
+
if (rule.name === "distinct") node.uniqueItems = true;
|
|
897
|
+
if (rule.name === "withoutDecimals") node.type = "integer";
|
|
898
|
+
if (rule.name === "positive") node.exclusiveMinimum = 0;
|
|
899
|
+
if (rule.name === "negative") node.exclusiveMaximum = 0;
|
|
900
|
+
if (rule.name === "nonNegative") node.minimum = 0;
|
|
901
|
+
if (rule.name === "nonPositive") node.maximum = 0;
|
|
902
|
+
if (rule.name === "nullType") node.type = "null";
|
|
903
|
+
if (rule.name === "ulid") node.pattern = "^[0-7][0-9A-HJKMNP-TV-Z]{25}$";
|
|
904
|
+
if (rule.name === "alpha") node.pattern = "^[a-zA-Z]+$";
|
|
905
|
+
if (rule.name === "alphaNumeric") node.pattern = "^[a-zA-Z0-9]+$";
|
|
906
|
+
if (rule.name === "hexCode") node.format = "color";
|
|
907
|
+
if (rule.name === "ipAddress")
|
|
908
|
+
node.format = args.version === 6 ? "ipv6" : "ipv4";
|
|
909
|
+
if (rule.name === "file" || rule.name === "nativeFile") {
|
|
910
|
+
node.type = "string";
|
|
911
|
+
node.contentEncoding = "binary";
|
|
912
|
+
}
|
|
913
|
+
// A declarative rule may carry its own modifier too.
|
|
914
|
+
if (typeof rule.toJSONSchema === "function") {
|
|
915
|
+
Object.assign(node, rule.toJSONSchema(node, rule.args));
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// `.use()` and async rules live outside `chain.rules`, so reading only that
|
|
919
|
+
// register left a declared modifier unreachable from the public API.
|
|
920
|
+
let modified = node;
|
|
921
|
+
for (const rule of [...chain.useRules, ...chain.asyncRules]) {
|
|
922
|
+
if (typeof rule.toJSONSchema === "function") {
|
|
923
|
+
modified = rule.toJSONSchema(modified, rule.ruleOptions);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
if (chain.isNullable && typeof node.type === "string") {
|
|
927
|
+
node.type = [node.type, "null"];
|
|
928
|
+
}
|
|
929
|
+
const nested = chain.getProperties();
|
|
930
|
+
if (nested) {
|
|
931
|
+
Object.assign(modified, chainToJSONSchema(nested));
|
|
932
|
+
// A rune object DROPS undeclared keys unless allowUnknownProperties(),
|
|
933
|
+
// so the emitted schema must say so — otherwise a consumer generating a
|
|
934
|
+
// form from it would offer fields the validator silently discards.
|
|
935
|
+
modified.additionalProperties = chain.allowsUnknown;
|
|
936
|
+
}
|
|
937
|
+
if (chain.metadata) Object.assign(modified, chain.metadata);
|
|
938
|
+
|
|
939
|
+
// Containers: describe what they hold, not just that they are containers.
|
|
940
|
+
const itemChain = chain.arrayItem;
|
|
941
|
+
if (itemChain) {
|
|
942
|
+
modified.items = chainToJSONSchema({ item: itemChain }).properties as
|
|
943
|
+
| Record<string, unknown>
|
|
944
|
+
| undefined;
|
|
945
|
+
if (isRecordOfUnknown(modified.items))
|
|
946
|
+
modified.items = modified.items.item;
|
|
947
|
+
}
|
|
948
|
+
const tupleChains = chain.tupleItems;
|
|
949
|
+
if (tupleChains) {
|
|
950
|
+
modified.prefixItems = tupleChains.map((entry) => {
|
|
951
|
+
const built = chainToJSONSchema({ item: entry });
|
|
952
|
+
const props = built.properties;
|
|
953
|
+
return isRecordOfUnknown(props) ? props.item : {};
|
|
954
|
+
});
|
|
955
|
+
modified.items = false;
|
|
956
|
+
}
|
|
957
|
+
const recordChain = chain.recordValue;
|
|
958
|
+
if (recordChain) {
|
|
959
|
+
const built = chainToJSONSchema({ item: recordChain });
|
|
960
|
+
const props = built.properties;
|
|
961
|
+
modified.additionalProperties = isRecordOfUnknown(props)
|
|
962
|
+
? props.item
|
|
963
|
+
: true;
|
|
964
|
+
}
|
|
965
|
+
properties[field] = modified;
|
|
966
|
+
if (!chain.isOptionalField) required.push(field);
|
|
967
|
+
}
|
|
968
|
+
return {
|
|
969
|
+
type: "object",
|
|
970
|
+
properties,
|
|
971
|
+
...(required.length > 0 ? { required } : {}),
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** Narrow to a string-keyed record — used when threading nested JSON Schema. */
|
|
976
|
+
function isRecordOfUnknown(value: unknown): value is Record<string, unknown> {
|
|
977
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** `snake_case` / `kebab-case` / spaced key to `camelCase`. */
|
|
981
|
+
function toCamelCaseKey(key: string): string {
|
|
982
|
+
return key
|
|
983
|
+
.replace(/[-_\s]+(.)?/g, (_, c: string | undefined) =>
|
|
984
|
+
c ? c.toUpperCase() : "",
|
|
985
|
+
)
|
|
986
|
+
.replace(/^(.)/, (c) => c.toLowerCase());
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** Structural guard telling a plain shape from a {@link ConditionalGroup}. */
|
|
990
|
+
function isConditionalGroup(
|
|
991
|
+
value: Record<string, RuleChain> | ConditionalGroup,
|
|
992
|
+
): value is ConditionalGroup {
|
|
993
|
+
return "__rune" in value && value.__rune === "group";
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** Build a conditional group (VineJS `vine.group([...])`). */
|
|
997
|
+
export function group(
|
|
998
|
+
branches: ReadonlyArray<{
|
|
999
|
+
predicate: ((data: Record<string, unknown>) => boolean) | null;
|
|
1000
|
+
shape: Record<string, RuleChain>;
|
|
1001
|
+
}>,
|
|
1002
|
+
): ConditionalGroup {
|
|
1003
|
+
return { __rune: "group", branches };
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** A predicate-guarded group branch (`vine.group.if`). */
|
|
1007
|
+
export function groupIf(
|
|
1008
|
+
predicate: (data: Record<string, unknown>) => boolean,
|
|
1009
|
+
shape: Record<string, RuleChain>,
|
|
1010
|
+
): {
|
|
1011
|
+
predicate: (data: Record<string, unknown>) => boolean;
|
|
1012
|
+
shape: Record<string, RuleChain>;
|
|
1013
|
+
} {
|
|
1014
|
+
return { predicate, shape };
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** The unconditional fallback branch (`vine.group.else` / `.otherwise`). */
|
|
1018
|
+
export function groupElse(shape: Record<string, RuleChain>): {
|
|
1019
|
+
predicate: null;
|
|
1020
|
+
shape: Record<string, RuleChain>;
|
|
1021
|
+
} {
|
|
1022
|
+
return { predicate: null, shape };
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** A union branch guarded by a predicate — `vine.union.if(...)`. */
|
|
1026
|
+
export interface ConditionalBranch {
|
|
1027
|
+
/** `null` for an unconditional branch (`union.else`). */
|
|
1028
|
+
predicate: ((value: unknown, field: FieldContext) => boolean) | null;
|
|
1029
|
+
chain: RuleChain;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/** What `union()` accepts: a bare chain, or a guarded branch. */
|
|
1033
|
+
export type UnionBranch = RuleChain | ConditionalBranch;
|
|
1034
|
+
|
|
1035
|
+
/** Normalise a bare chain into an unconditional branch. */
|
|
1036
|
+
function toUnionBranch(branch: UnionBranch): ConditionalBranch {
|
|
1037
|
+
return branch instanceof RuleChain
|
|
1038
|
+
? { predicate: null, chain: branch }
|
|
1039
|
+
: branch;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Guarded union branch (VineJS `vine.union.if`). The predicate picks the branch;
|
|
1044
|
+
* the chosen branch's OWN errors are reported, which is what makes a union
|
|
1045
|
+
* diagnosable — "matches nothing" tells the caller nothing about which shape it
|
|
1046
|
+
* nearly matched.
|
|
1047
|
+
*/
|
|
1048
|
+
export function unionIf(
|
|
1049
|
+
predicate: (value: unknown, field: FieldContext) => boolean,
|
|
1050
|
+
chain: RuleChain,
|
|
1051
|
+
): ConditionalBranch {
|
|
1052
|
+
return { predicate, chain };
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** Fallback union branch (VineJS `vine.union.else`). */
|
|
1056
|
+
export function unionElse(chain: RuleChain): ConditionalBranch {
|
|
1057
|
+
return { predicate: null, chain };
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** The checkbox-style truthies VineJS `accepted` recognises. */
|
|
1061
|
+
function isAcceptedValue(value: unknown): boolean {
|
|
1062
|
+
return (
|
|
1063
|
+
value === true ||
|
|
1064
|
+
value === 1 ||
|
|
1065
|
+
(typeof value === "string" &&
|
|
1066
|
+
["1", "on", "yes", "true"].includes(value.toLowerCase()))
|
|
1067
|
+
);
|
|
142
1068
|
}
|
|
143
1069
|
|
|
144
1070
|
/** Default context for internal callers that don't supply one (no root available). */
|
|
@@ -160,6 +1086,16 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
160
1086
|
* The TS chain rules (minLength/uuid/alpha/in/enum/range/…) live only in the TS
|
|
161
1087
|
* validator, so they are deliberately absent here.
|
|
162
1088
|
*/
|
|
1089
|
+
/**
|
|
1090
|
+
* Rules whose default message is a TRANSLATABLE key (`validation.<rule>`).
|
|
1091
|
+
*
|
|
1092
|
+
* This set answers one question only: "does this rule have a canonical message?"
|
|
1093
|
+
* It used to answer a second one — "can the Rust engine run it?" — and that
|
|
1094
|
+
* conflation is why every divergence kept coming back: excluding a rule from the
|
|
1095
|
+
* native path silently un-translated it, and adding a TS-only option to a listed
|
|
1096
|
+
* rule silently made the option inert. {@link NATIVE_RULES} answers the routing
|
|
1097
|
+
* question now.
|
|
1098
|
+
*/
|
|
163
1099
|
const STANDARD_RULES: ReadonlySet<string> = new Set([
|
|
164
1100
|
"string",
|
|
165
1101
|
"number",
|
|
@@ -184,15 +1120,47 @@ const STANDARD_RULES: ReadonlySet<string> = new Set([
|
|
|
184
1120
|
"range",
|
|
185
1121
|
]);
|
|
186
1122
|
|
|
187
|
-
/**
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
1123
|
+
/**
|
|
1124
|
+
* Rules the Rust engine implements IDENTICALLY to the TS path.
|
|
1125
|
+
*
|
|
1126
|
+
* A rule belongs here only while both engines answer the same question for
|
|
1127
|
+
* every input. `email` is excluded on purpose: the TS check is structural
|
|
1128
|
+
* (quoted local parts, IP-literal domains, RFC length caps, validator.js-style
|
|
1129
|
+
* options) where Rust has one regex — routing there would give a different
|
|
1130
|
+
* answer for the same schema.
|
|
1131
|
+
*/
|
|
1132
|
+
const NATIVE_RULES: ReadonlySet<string> = new Set([
|
|
1133
|
+
"string",
|
|
1134
|
+
"number",
|
|
1135
|
+
"boolean",
|
|
1136
|
+
"min",
|
|
1137
|
+
"max",
|
|
1138
|
+
"positive",
|
|
1139
|
+
"minLength",
|
|
1140
|
+
"maxLength",
|
|
1141
|
+
"fixedLength",
|
|
1142
|
+
"uuid",
|
|
1143
|
+
"alpha",
|
|
1144
|
+
"alphaNumeric",
|
|
1145
|
+
"startsWith",
|
|
1146
|
+
"endsWith",
|
|
1147
|
+
"in",
|
|
1148
|
+
"notIn",
|
|
1149
|
+
"enum",
|
|
1150
|
+
"negative",
|
|
1151
|
+
"nonNegative",
|
|
1152
|
+
"range",
|
|
1153
|
+
]);
|
|
1154
|
+
|
|
1155
|
+
/** Default messages for standard rules — used only for translator-key fallback. */
|
|
1156
|
+
const STANDARD_MSGS: Readonly<Record<string, string>> = {
|
|
1157
|
+
string: "Must be a string",
|
|
1158
|
+
number: "Must be a number",
|
|
1159
|
+
boolean: "Must be a boolean",
|
|
1160
|
+
min: "Minimum",
|
|
1161
|
+
max: "Maximum",
|
|
1162
|
+
email: "Must be a valid email",
|
|
1163
|
+
positive: "Must be positive",
|
|
196
1164
|
minLength: "Too short",
|
|
197
1165
|
maxLength: "Too long",
|
|
198
1166
|
fixedLength: "Wrong length",
|
|
@@ -215,6 +1183,9 @@ const TYPE_RULE_NAMES: ReadonlySet<string> = new Set([
|
|
|
215
1183
|
"boolean",
|
|
216
1184
|
"object",
|
|
217
1185
|
"array",
|
|
1186
|
+
// `optional()` / `null()` are schema TYPES in VineJS, not modifiers.
|
|
1187
|
+
"optionalType",
|
|
1188
|
+
"nullType",
|
|
218
1189
|
]);
|
|
219
1190
|
let validationTranslator: ValidationTranslator | undefined;
|
|
220
1191
|
|
|
@@ -276,9 +1247,12 @@ function resolveRuleMessage(
|
|
|
276
1247
|
if (rule.name === "max" || rule.name === "maxLength")
|
|
277
1248
|
params.max = rule.param;
|
|
278
1249
|
}
|
|
1250
|
+
// STANDARD_MSGS is the last-resort default for a standard rule: a rule object
|
|
1251
|
+
// built without a message (or with an empty one) still gets the canonical
|
|
1252
|
+
// text rather than an empty error. `rule.message` wins when it carries one.
|
|
279
1253
|
return resolveValidationMessage(
|
|
280
1254
|
`validation.${rule.name}`,
|
|
281
|
-
rule.message,
|
|
1255
|
+
rule.message || STANDARD_MSGS[rule.name] || rule.name,
|
|
282
1256
|
params,
|
|
283
1257
|
);
|
|
284
1258
|
}
|
|
@@ -303,12 +1277,14 @@ function resolveRequiredMessage(field: string, ctx: RunContext): string {
|
|
|
303
1277
|
function detectHasCustomRules(fields: Record<string, RuleChain>): boolean {
|
|
304
1278
|
return Object.values(fields).some((chain) => {
|
|
305
1279
|
if (chain.useRules.length > 0) return true; // .use() rule — TS-only (Rust can't run JS)
|
|
1280
|
+
if (chain.asyncRules.length > 0) return true; // async rule — TS-only, needs validateResultAsync
|
|
306
1281
|
if (chain.hasConditionalRequired) return true; // requiredWhen — TS-only
|
|
307
1282
|
if (chain.preTransforms.length > 0) return true; // .parse() — TS-only
|
|
308
1283
|
if (chain.transforms.length > 0) return true; // .transform() — Rust gets only the NAME, can't run a JS fn
|
|
309
1284
|
if (chain.isNullable) return true; // .nullable() — the flag is not sent to the Rust engine
|
|
310
1285
|
return chain.rules.some((r) => {
|
|
311
|
-
if (!
|
|
1286
|
+
if (!NATIVE_RULES.has(r.name)) return true; // Rust cannot run it identically
|
|
1287
|
+
if (r.tsOnly === true) return true; // native-listed name, TS-only options
|
|
312
1288
|
if (hasCustomMessage(r)) return true; // custom message
|
|
313
1289
|
return false;
|
|
314
1290
|
});
|
|
@@ -356,20 +1332,46 @@ export type Infer<S> = Prettify<
|
|
|
356
1332
|
*/
|
|
357
1333
|
export function schema<S extends Record<string, RuleChain>>(
|
|
358
1334
|
fields: S,
|
|
1335
|
+
objectChain?: RuleChain,
|
|
359
1336
|
): ValidationSchema<Infer<S>>;
|
|
360
1337
|
export function schema<T = Record<string, unknown>>(
|
|
361
1338
|
fields: Record<string, RuleChain>,
|
|
1339
|
+
objectChain?: RuleChain,
|
|
362
1340
|
): ValidationSchema<T>;
|
|
363
1341
|
export function schema(
|
|
364
1342
|
fields: Record<string, RuleChain>,
|
|
1343
|
+
objectChain?: RuleChain,
|
|
365
1344
|
): ValidationSchema<Record<string, unknown>> {
|
|
1345
|
+
// Per-validator reporter (VineJS `validator.errorReporter = …`), overridable
|
|
1346
|
+
// per call. Mutable on purpose: that is how Vine exposes it.
|
|
1347
|
+
let validatorErrorReporter:
|
|
1348
|
+
| ErrorReporterFactory
|
|
1349
|
+
| ((error: ValidationError) => void)
|
|
1350
|
+
| null = null;
|
|
1351
|
+
// Set by the last run; the throwing entry points prefer the reporter's own
|
|
1352
|
+
// error, because VineJS lets the reporter decide the failure shape.
|
|
1353
|
+
let reporterError: (() => Error) | undefined;
|
|
1354
|
+
|
|
366
1355
|
// Computed once at construction time, not per validate() call.
|
|
367
1356
|
const hasCustomRules = detectHasCustomRules(fields);
|
|
1357
|
+
// Any field carrying async rules (`unique`/`exists`/`useAsync`) forces callers
|
|
1358
|
+
// onto the async path — the sync path throws rather than silently skipping them.
|
|
1359
|
+
const hasAsyncRules = Object.values(fields).some(
|
|
1360
|
+
(chain) => chain.hasAsyncRulesDeep,
|
|
1361
|
+
);
|
|
368
1362
|
|
|
369
|
-
function
|
|
370
|
-
|
|
1363
|
+
function validateResult(
|
|
1364
|
+
rawData: unknown,
|
|
371
1365
|
options?: ValidateOptions,
|
|
372
1366
|
): ValidationResult<Record<string, unknown>> {
|
|
1367
|
+
const data = convertEmptyStringsToNull
|
|
1368
|
+
? convertEmptyStrings(rawData)
|
|
1369
|
+
: rawData;
|
|
1370
|
+
if (hasAsyncRules) {
|
|
1371
|
+
throw new Error(
|
|
1372
|
+
"rune: this schema has async rules (unique/exists/useAsync) — call validateResultAsync() (result-based) or validate() (throwing) instead of validateResult().",
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
373
1375
|
if (!isPlainObject(data)) {
|
|
374
1376
|
return {
|
|
375
1377
|
valid: false,
|
|
@@ -379,10 +1381,29 @@ export function schema(
|
|
|
379
1381
|
};
|
|
380
1382
|
}
|
|
381
1383
|
|
|
382
|
-
|
|
1384
|
+
// The global provider counts exactly like a per-call one: the Rust engine
|
|
1385
|
+
// renders default messages, so routing there would silently ignore it.
|
|
1386
|
+
const provider =
|
|
1387
|
+
options?.messagesProvider ?? globalMessagesProvider ?? undefined;
|
|
383
1388
|
if (!hasCustomRules && !validationTranslator && !provider) {
|
|
384
1389
|
if (isNativeAvailable()) {
|
|
385
|
-
|
|
1390
|
+
const native = validateWithRust(fields, data);
|
|
1391
|
+
// Report here too: the native path returns before the TS traversal,
|
|
1392
|
+
// so instrumenting only the latter left the reporter silent exactly
|
|
1393
|
+
// when the fast path was taken.
|
|
1394
|
+
const nativeReporter = toReporter(
|
|
1395
|
+
options?.errorReporter ??
|
|
1396
|
+
validatorErrorReporter ??
|
|
1397
|
+
globalErrorReporter ??
|
|
1398
|
+
undefined,
|
|
1399
|
+
data,
|
|
1400
|
+
options?.meta ?? {},
|
|
1401
|
+
);
|
|
1402
|
+
if (nativeReporter) {
|
|
1403
|
+
for (const error of native.errors) nativeReporter.report(error);
|
|
1404
|
+
}
|
|
1405
|
+
reporterError = nativeReporter?.createError;
|
|
1406
|
+
return native;
|
|
386
1407
|
}
|
|
387
1408
|
// This schema would have used the native engine, but it isn't loaded —
|
|
388
1409
|
// surface the platform-dependent TS fallback once instead of diverging
|
|
@@ -396,6 +1417,7 @@ export function schema(
|
|
|
396
1417
|
data,
|
|
397
1418
|
parent: data,
|
|
398
1419
|
meta: options?.meta ?? {},
|
|
1420
|
+
errorReporter: options?.errorReporter,
|
|
399
1421
|
messagesProvider: provider,
|
|
400
1422
|
};
|
|
401
1423
|
|
|
@@ -411,6 +1433,18 @@ export function schema(
|
|
|
411
1433
|
}
|
|
412
1434
|
}
|
|
413
1435
|
|
|
1436
|
+
const reporter = toReporter(
|
|
1437
|
+
options?.errorReporter ??
|
|
1438
|
+
validatorErrorReporter ??
|
|
1439
|
+
globalErrorReporter ??
|
|
1440
|
+
undefined,
|
|
1441
|
+
data,
|
|
1442
|
+
options?.meta ?? {},
|
|
1443
|
+
);
|
|
1444
|
+
if (reporter) {
|
|
1445
|
+
for (const error of errors) reporter.report(error);
|
|
1446
|
+
}
|
|
1447
|
+
reporterError = reporter?.createError;
|
|
414
1448
|
if (errors.length === 0) {
|
|
415
1449
|
return { valid: true, errors, data: validated };
|
|
416
1450
|
}
|
|
@@ -421,14 +1455,258 @@ export function schema(
|
|
|
421
1455
|
data: unknown,
|
|
422
1456
|
options?: ValidateOptions,
|
|
423
1457
|
): Record<string, unknown> {
|
|
424
|
-
const result =
|
|
1458
|
+
const result = validateResult(data, options);
|
|
425
1459
|
if (result.valid) {
|
|
426
1460
|
return result.data;
|
|
427
1461
|
}
|
|
428
|
-
|
|
1462
|
+
// The reporter decides the failure shape when one is bound (VineJS).
|
|
1463
|
+
throw reporterError
|
|
1464
|
+
? reporterError()
|
|
1465
|
+
: new RuneValidationError(result.errors.map(toErrorNode));
|
|
429
1466
|
}
|
|
430
1467
|
|
|
431
|
-
|
|
1468
|
+
async function validateResultAsync(
|
|
1469
|
+
rawData: unknown,
|
|
1470
|
+
options?: ValidateOptions,
|
|
1471
|
+
): Promise<ValidationResult<Record<string, unknown>>> {
|
|
1472
|
+
const data = convertEmptyStringsToNull
|
|
1473
|
+
? convertEmptyStrings(rawData)
|
|
1474
|
+
: rawData;
|
|
1475
|
+
if (!isPlainObject(data)) {
|
|
1476
|
+
return {
|
|
1477
|
+
valid: false,
|
|
1478
|
+
errors: [
|
|
1479
|
+
{ field: "_root", rule: "type", message: "Input must be an object" },
|
|
1480
|
+
],
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
const errors: ValidationError[] = [];
|
|
1484
|
+
const validated: Record<string, unknown> = {};
|
|
1485
|
+
const rootCtx: RunContext = {
|
|
1486
|
+
data,
|
|
1487
|
+
parent: data,
|
|
1488
|
+
meta: options?.meta ?? {},
|
|
1489
|
+
errorReporter: options?.errorReporter,
|
|
1490
|
+
messagesProvider:
|
|
1491
|
+
options?.messagesProvider ?? globalMessagesProvider ?? undefined,
|
|
1492
|
+
};
|
|
1493
|
+
|
|
1494
|
+
for (const [field, chain] of Object.entries(fields)) {
|
|
1495
|
+
// One collector per top-level field, drained straight away, so async
|
|
1496
|
+
// errors stay grouped with their field rather than piling up at the end.
|
|
1497
|
+
const pending: PendingAsync[] = [];
|
|
1498
|
+
const result = chain._validateWithTransform(
|
|
1499
|
+
field,
|
|
1500
|
+
data[field],
|
|
1501
|
+
rootCtx,
|
|
1502
|
+
pending,
|
|
1503
|
+
);
|
|
1504
|
+
const fieldErrors = [...result.errors];
|
|
1505
|
+
// The collector already applied the gate at every depth: a chain records
|
|
1506
|
+
// itself only when its own subtree passed and its value is present —
|
|
1507
|
+
// mirrors Lucid skipping a DB rule on an already-invalid or absent field.
|
|
1508
|
+
for (const task of pending) {
|
|
1509
|
+
const asyncErrors = await task.chain._runAsyncRules(
|
|
1510
|
+
task.field,
|
|
1511
|
+
task.value,
|
|
1512
|
+
task.ctx,
|
|
1513
|
+
);
|
|
1514
|
+
fieldErrors.push(...asyncErrors);
|
|
1515
|
+
}
|
|
1516
|
+
errors.push(...fieldErrors);
|
|
1517
|
+
if (fieldErrors.length === 0 && result.transformed !== undefined) {
|
|
1518
|
+
validated[field] = result.transformed;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
const reporter = toReporter(
|
|
1523
|
+
options?.errorReporter ??
|
|
1524
|
+
validatorErrorReporter ??
|
|
1525
|
+
globalErrorReporter ??
|
|
1526
|
+
undefined,
|
|
1527
|
+
data,
|
|
1528
|
+
options?.meta ?? {},
|
|
1529
|
+
);
|
|
1530
|
+
if (reporter) {
|
|
1531
|
+
for (const error of errors) reporter.report(error);
|
|
1532
|
+
}
|
|
1533
|
+
reporterError = reporter?.createError;
|
|
1534
|
+
if (errors.length === 0) {
|
|
1535
|
+
return { valid: true, errors, data: validated };
|
|
1536
|
+
}
|
|
1537
|
+
return { valid: false, errors };
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* Non-throwing validation returning a `[error, null] | [null, data]` tuple
|
|
1542
|
+
* (VineJS `tryValidate`), for when a failure is an expected code path.
|
|
1543
|
+
*/
|
|
1544
|
+
function tryValidateSync(
|
|
1545
|
+
data: unknown,
|
|
1546
|
+
options?: ValidateOptions,
|
|
1547
|
+
): [RuneValidationError, null] | [null, Record<string, unknown>] {
|
|
1548
|
+
const result = validateResult(data, options);
|
|
1549
|
+
if (result.valid) return [null, result.data];
|
|
1550
|
+
return [new RuneValidationError(result.errors.map(toErrorNode)), null];
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/** Async counterpart of {@link tryValidate}. */
|
|
1554
|
+
async function tryValidate(
|
|
1555
|
+
data: unknown,
|
|
1556
|
+
options?: ValidateOptions,
|
|
1557
|
+
): Promise<[RuneValidationError, null] | [null, Record<string, unknown>]> {
|
|
1558
|
+
const result = await validateResultAsync(data, options);
|
|
1559
|
+
if (result.valid) return [null, result.data];
|
|
1560
|
+
return [new RuneValidationError(result.errors.map(toErrorNode)), null];
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
async function validateOrThrowAsync(
|
|
1564
|
+
data: unknown,
|
|
1565
|
+
options?: ValidateOptions,
|
|
1566
|
+
): Promise<Record<string, unknown>> {
|
|
1567
|
+
const result = await validateResultAsync(data, options);
|
|
1568
|
+
if (result.valid) {
|
|
1569
|
+
return result.data;
|
|
1570
|
+
}
|
|
1571
|
+
// The reporter decides the failure shape when one is bound (VineJS).
|
|
1572
|
+
throw reporterError
|
|
1573
|
+
? reporterError()
|
|
1574
|
+
: new RuneValidationError(result.errors.map(toErrorNode));
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* The VineJS contract: async, returns the payload, throws on failure. A
|
|
1579
|
+
* schema carrying async rules works here without the caller having to know,
|
|
1580
|
+
* which is the whole point of Vine's single entry point.
|
|
1581
|
+
*/
|
|
1582
|
+
async function validate(
|
|
1583
|
+
data: unknown,
|
|
1584
|
+
options?: ValidateOptions,
|
|
1585
|
+
): Promise<Record<string, unknown>> {
|
|
1586
|
+
return validateOrThrowAsync(data, options);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* Introspection of the compiled schema (VineJS `toJSON`): field names and the
|
|
1591
|
+
* rules attached to each, enough to render a form or diff two schemas.
|
|
1592
|
+
*/
|
|
1593
|
+
function toJSON(): { schema: SchemaIntrospection; refs: string[] } {
|
|
1594
|
+
// VineJS shape: `{ schema, refs }`. The flat `{ field: { rules } }` map was
|
|
1595
|
+
// rune's own invention, so a consumer written against Vine read undefined.
|
|
1596
|
+
return {
|
|
1597
|
+
schema: introspect(fields),
|
|
1598
|
+
refs: Object.keys(fields),
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* Emit a JSON Schema for the compiled validator (VineJS `toJSONSchema`).
|
|
1604
|
+
* Covers the rules that HAVE a JSON Schema equivalent; a custom rule
|
|
1605
|
+
* contributes its `jsonSchema` metadata when it declares one, and is
|
|
1606
|
+
* otherwise omitted rather than guessed at.
|
|
1607
|
+
*/
|
|
1608
|
+
function toJSONSchema(): Record<string, unknown> {
|
|
1609
|
+
return chainToJSONSchema(fields);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/**
|
|
1613
|
+
* Standard Schema v1 (`~standard`), the vendor-neutral contract VineJS also
|
|
1614
|
+
* implements — lets a consumer validate without knowing it holds a rune
|
|
1615
|
+
* schema.
|
|
1616
|
+
*/
|
|
1617
|
+
const standard = {
|
|
1618
|
+
version: 1 as const,
|
|
1619
|
+
vendor: "rune",
|
|
1620
|
+
/**
|
|
1621
|
+
* Standard JSON Schema v1 (`~standard.jsonSchema`), added by VineJS 4.3.
|
|
1622
|
+
* `input` describes what may be sent, `output` what validation returns.
|
|
1623
|
+
*/
|
|
1624
|
+
jsonSchema: {
|
|
1625
|
+
input: (): Record<string, unknown> => toJSONSchema(),
|
|
1626
|
+
output: (): Record<string, unknown> => toJSONSchema(),
|
|
1627
|
+
},
|
|
1628
|
+
validate: (
|
|
1629
|
+
value: unknown,
|
|
1630
|
+
): Promise<
|
|
1631
|
+
| { value: Record<string, unknown> }
|
|
1632
|
+
| { issues: ReadonlyArray<{ message: string; path: string[] }> }
|
|
1633
|
+
> =>
|
|
1634
|
+
validateResultAsync(value).then((result) =>
|
|
1635
|
+
result.valid
|
|
1636
|
+
? { value: result.data }
|
|
1637
|
+
: {
|
|
1638
|
+
issues: result.errors.map((error) => ({
|
|
1639
|
+
message: error.message,
|
|
1640
|
+
path: error.field.split("."),
|
|
1641
|
+
})),
|
|
1642
|
+
},
|
|
1643
|
+
),
|
|
1644
|
+
};
|
|
1645
|
+
|
|
1646
|
+
return {
|
|
1647
|
+
fields,
|
|
1648
|
+
/** Per-validator error reporter (VineJS `validator.errorReporter`). */
|
|
1649
|
+
get errorReporter() {
|
|
1650
|
+
return validatorErrorReporter;
|
|
1651
|
+
},
|
|
1652
|
+
set errorReporter(reporter:
|
|
1653
|
+
| ErrorReporterFactory
|
|
1654
|
+
| ((error: ValidationError) => void)
|
|
1655
|
+
| null,) {
|
|
1656
|
+
validatorErrorReporter = reporter;
|
|
1657
|
+
},
|
|
1658
|
+
// ALWAYS a chain, even when the validator was built from a bare field map:
|
|
1659
|
+
// VineJS documents `createUserValidator.schema.partial()`, and returning
|
|
1660
|
+
// the map left that broken on the most common Adonis path.
|
|
1661
|
+
schema: objectChain ?? new RuleChain().object(fields),
|
|
1662
|
+
"~standard": standard,
|
|
1663
|
+
toJSON,
|
|
1664
|
+
toJSONSchema,
|
|
1665
|
+
validate,
|
|
1666
|
+
validateResult,
|
|
1667
|
+
validateResultAsync,
|
|
1668
|
+
validateOrThrow,
|
|
1669
|
+
validateOrThrowAsync,
|
|
1670
|
+
tryValidate,
|
|
1671
|
+
tryValidateSync,
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/**
|
|
1676
|
+
* VineJS's `vine.create(...)`. Same thing as {@link schema} — the Adonis
|
|
1677
|
+
* spelling is provided so a validator reads the same in both frameworks.
|
|
1678
|
+
*/
|
|
1679
|
+
/**
|
|
1680
|
+
* VineJS's `vine.create(...)`. Accepts either a map of fields (rune's native
|
|
1681
|
+
* spelling) or the `RuleChain` produced by `rune.object({...})`, because
|
|
1682
|
+
* `vine.create(vine.object({...}))` is the form Adonis documents.
|
|
1683
|
+
*/
|
|
1684
|
+
export function create<S extends Record<string, RuleChain>>(
|
|
1685
|
+
fields: S,
|
|
1686
|
+
): ValidationSchema<Infer<S>>;
|
|
1687
|
+
export function create(chain: RuleChain): ValidationSchema;
|
|
1688
|
+
export function create(
|
|
1689
|
+
input: Record<string, RuleChain> | RuleChain,
|
|
1690
|
+
): ValidationSchema {
|
|
1691
|
+
return input instanceof RuleChain
|
|
1692
|
+
? schema(toFieldMap(input), input)
|
|
1693
|
+
: schema(input);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/** Unwrap `rune.object({...})` back to the field map `schema()` expects. */
|
|
1697
|
+
function toFieldMap(
|
|
1698
|
+
input: Record<string, RuleChain> | RuleChain,
|
|
1699
|
+
): Record<string, RuleChain> {
|
|
1700
|
+
if (!(input instanceof RuleChain)) return input;
|
|
1701
|
+
const shape = input.getProperties();
|
|
1702
|
+
if (!shape) {
|
|
1703
|
+
throw new RuneError(
|
|
1704
|
+
"NOT_AN_OBJECT",
|
|
1705
|
+
"create()/compile() received a chain that declares no object shape.",
|
|
1706
|
+
{ hint: "Use rune.object({ … }), or pass the field map directly." },
|
|
1707
|
+
);
|
|
1708
|
+
}
|
|
1709
|
+
return shape;
|
|
432
1710
|
}
|
|
433
1711
|
|
|
434
1712
|
/** Map an internal {@link ValidationError} to a {@link RuneErrorNode}. */
|
|
@@ -465,6 +1743,15 @@ export interface RuleDef {
|
|
|
465
1743
|
message: string;
|
|
466
1744
|
/** Set when `.message()` overrode this rule's default text. */
|
|
467
1745
|
hasCustomMessage?: boolean;
|
|
1746
|
+
/**
|
|
1747
|
+
* Keep this rule off the Rust path even though its NAME is in
|
|
1748
|
+
* {@link NATIVE_RULES}. Set by options the native engine does not know about
|
|
1749
|
+
* (`uuid({ version })`, a callback list for `in` / `notIn`): the engine would
|
|
1750
|
+
* run the rule without them and silently answer a different question.
|
|
1751
|
+
*/
|
|
1752
|
+
tsOnly?: boolean;
|
|
1753
|
+
/** Modifier this rule applies to its field's JSON Schema node. */
|
|
1754
|
+
toJSONSchema?: JsonSchemaModifier;
|
|
468
1755
|
}
|
|
469
1756
|
|
|
470
1757
|
/** A conditional-required condition (VineJS `requiredWhen` family). */
|
|
@@ -483,6 +1770,23 @@ const UUID_RE =
|
|
|
483
1770
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
484
1771
|
|
|
485
1772
|
/** Rule chain — fluent, phantom-typed validation builder. */
|
|
1773
|
+
/**
|
|
1774
|
+
* The value list accepted by `in` / `notIn` / `enum` — static, or computed at
|
|
1775
|
+
* validation time (VineJS parity).
|
|
1776
|
+
*/
|
|
1777
|
+
export type AllowedValues =
|
|
1778
|
+
| ReadonlyArray<string | number | boolean>
|
|
1779
|
+
| (() => ReadonlyArray<string | number | boolean>);
|
|
1780
|
+
|
|
1781
|
+
/** Normalise a static list or a callback into a getter. */
|
|
1782
|
+
function allowedValuesResolver(
|
|
1783
|
+
values: AllowedValues,
|
|
1784
|
+
): () => ReadonlyArray<string | number | boolean> {
|
|
1785
|
+
if (typeof values === "function") return values;
|
|
1786
|
+
const snapshot = [...values];
|
|
1787
|
+
return () => snapshot;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
486
1790
|
export class RuleChain<Output = unknown> {
|
|
487
1791
|
/** Phantom output type — drives {@link Infer}; never read at runtime. */
|
|
488
1792
|
declare readonly [OUTPUT]: Output;
|
|
@@ -490,15 +1794,52 @@ export class RuleChain<Output = unknown> {
|
|
|
490
1794
|
#rules: RuleDef[] = [];
|
|
491
1795
|
#isOptional = false;
|
|
492
1796
|
#isNullable = false;
|
|
493
|
-
|
|
1797
|
+
/**
|
|
1798
|
+
* VineJS validates a field in bail mode by DEFAULT — it stops at that field's
|
|
1799
|
+
* first failing rule (`FieldOptions.bail: true`). rune defaulted to `false`
|
|
1800
|
+
* and reported every failing rule, which silently produced a different error
|
|
1801
|
+
* array for the same schema. `.bail(false)` restores the exhaustive mode.
|
|
1802
|
+
*/
|
|
1803
|
+
#bail = true;
|
|
494
1804
|
#transforms: Array<{
|
|
495
1805
|
name: string;
|
|
496
1806
|
fn: (value: unknown, field: FieldContext) => unknown;
|
|
497
1807
|
}> = [];
|
|
498
|
-
#preTransforms: Array<(value: unknown) => unknown> = [];
|
|
1808
|
+
#preTransforms: Array<(value: unknown, ctx: ParseContext) => unknown> = [];
|
|
1809
|
+
/**
|
|
1810
|
+
* Type coercions (VineJS accepts `"32"` for a number). Kept OUT of
|
|
1811
|
+
* `#preTransforms` on purpose: a pre-transform forces the TS path, and the
|
|
1812
|
+
* Rust engine implements the very same coercion from the rule's `strict`
|
|
1813
|
+
* param, so both engines agree without giving up the native path.
|
|
1814
|
+
*/
|
|
1815
|
+
#coercions: Array<(value: unknown) => unknown> = [];
|
|
1816
|
+
/** Formats accepted by `date()` — also used to parse `afterField` siblings. */
|
|
1817
|
+
#dateFormats: DateFormat[] | null = null;
|
|
499
1818
|
#nestedSchema: Record<string, RuleChain> | null = null;
|
|
500
1819
|
#arrayItemChain: RuleChain | null = null;
|
|
1820
|
+
#allowUnknown = false;
|
|
1821
|
+
#metadata: Record<string, unknown> | null = null;
|
|
1822
|
+
/** Extensions / MIME types declared by `file()` / `mimeTypes()`. */
|
|
1823
|
+
#declaredExtnames: readonly string[] | null = null;
|
|
1824
|
+
#declaredMimeTypes: readonly string[] | null = null;
|
|
1825
|
+
/** `true` once the content-verification rule has been registered. */
|
|
1826
|
+
#contentVerified = false;
|
|
1827
|
+
/** Set by `{ verifyContent: false }` — an explicit, auditable opt-out. */
|
|
1828
|
+
#contentVerificationOff = false;
|
|
1829
|
+
#camelCaseKeys = false;
|
|
1830
|
+
#groups: ConditionalGroup[] = [];
|
|
1831
|
+
#recordValueChain: RuleChain | null = null;
|
|
1832
|
+
#tupleChains: RuleChain[] | null = null;
|
|
1833
|
+
#unionChains: ConditionalBranch[] | null = null;
|
|
501
1834
|
#useRules: CompiledRule[] = [];
|
|
1835
|
+
#asyncRules: AsyncCompiledRule[] = [];
|
|
1836
|
+
/** Last rule added, whichever register it landed in — the `message()` target. */
|
|
1837
|
+
#lastRule:
|
|
1838
|
+
| { kind: "value"; ref: RuleDef }
|
|
1839
|
+
| { kind: "reporting"; ref: CompiledRule | AsyncCompiledRule }
|
|
1840
|
+
| null = null;
|
|
1841
|
+
/** `.message()` overrides for rules that report their own text from `run`. */
|
|
1842
|
+
#ruleMessages = new Map<CompiledRule | AsyncCompiledRule, string>();
|
|
502
1843
|
#requiredConditions: RequiredCondition[] = [];
|
|
503
1844
|
|
|
504
1845
|
/** Public read access to rules (for OpenAPI generation, Rust bridge). */
|
|
@@ -522,8 +1863,62 @@ export class RuleChain<Output = unknown> {
|
|
|
522
1863
|
get useRules(): readonly CompiledRule[] {
|
|
523
1864
|
return this.#useRules;
|
|
524
1865
|
}
|
|
1866
|
+
/** Public read access to async rules (`unique`/`exists`/`useAsync`) — run by `validateResultAsync`. */
|
|
1867
|
+
get asyncRules(): readonly AsyncCompiledRule[] {
|
|
1868
|
+
return this.#asyncRules;
|
|
1869
|
+
}
|
|
1870
|
+
/**
|
|
1871
|
+
* Does this chain — or anything nested under it (object fields, array items) —
|
|
1872
|
+
* carry async rules? The schema-level detection used to inspect only the
|
|
1873
|
+
* top-level chains, so a nested `unique`/`exists` was invisible: `validate()`
|
|
1874
|
+
* did not throw and the async pass never ran the rule, silently accepting
|
|
1875
|
+
* an unchecked value.
|
|
1876
|
+
*/
|
|
1877
|
+
get hasAsyncRulesDeep(): boolean {
|
|
1878
|
+
if (this.#asyncRules.length > 0) return true;
|
|
1879
|
+
if (this.#nestedSchema) {
|
|
1880
|
+
for (const chain of Object.values(this.#nestedSchema)) {
|
|
1881
|
+
if (chain.hasAsyncRulesDeep) return true;
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
if (this.#arrayItemChain?.hasAsyncRulesDeep) return true;
|
|
1885
|
+
if (this.#recordValueChain?.hasAsyncRulesDeep) return true;
|
|
1886
|
+
for (const chain of [
|
|
1887
|
+
...(this.#tupleChains ?? []),
|
|
1888
|
+
...(this.#unionChains ?? []).map((b) => b.chain),
|
|
1889
|
+
]) {
|
|
1890
|
+
if (chain.hasAsyncRulesDeep) return true;
|
|
1891
|
+
}
|
|
1892
|
+
return false;
|
|
1893
|
+
}
|
|
1894
|
+
/** Does this object keep keys its shape does not declare? */
|
|
1895
|
+
get allowsUnknown(): boolean {
|
|
1896
|
+
return this.#allowUnknown;
|
|
1897
|
+
}
|
|
1898
|
+
/** Free-form JSON Schema metadata attached with `meta()`. */
|
|
1899
|
+
get metadata(): Record<string, unknown> | null {
|
|
1900
|
+
return this.#metadata;
|
|
1901
|
+
}
|
|
1902
|
+
/** The item chain of an `array()`, if declared. */
|
|
1903
|
+
get arrayItem(): RuleChain | null {
|
|
1904
|
+
return this.#arrayItemChain;
|
|
1905
|
+
}
|
|
1906
|
+
/** The positional chains of a `tuple()`, if declared. */
|
|
1907
|
+
get tupleItems(): RuleChain[] | null {
|
|
1908
|
+
return this.#tupleChains;
|
|
1909
|
+
}
|
|
1910
|
+
/** The value chain of a `record()`, if declared. */
|
|
1911
|
+
get recordValue(): RuleChain | null {
|
|
1912
|
+
return this.#recordValueChain;
|
|
1913
|
+
}
|
|
1914
|
+
/** Whether this chain stops at its first failing rule (VineJS `bail`). */
|
|
1915
|
+
get bails(): boolean {
|
|
1916
|
+
return this.#bail;
|
|
1917
|
+
}
|
|
525
1918
|
/** Public read access to `.parse()` pre-transforms (kept off the native path). */
|
|
526
|
-
get preTransforms(): ReadonlyArray<
|
|
1919
|
+
get preTransforms(): ReadonlyArray<
|
|
1920
|
+
(value: unknown, ctx: ParseContext) => unknown
|
|
1921
|
+
> {
|
|
527
1922
|
return this.#preTransforms;
|
|
528
1923
|
}
|
|
529
1924
|
/** Whether this chain carries a `requiredWhen`-family condition. */
|
|
@@ -544,10 +1939,26 @@ export class RuleChain<Output = unknown> {
|
|
|
544
1939
|
next.#isNullable = this.#isNullable;
|
|
545
1940
|
next.#bail = this.#bail;
|
|
546
1941
|
next.#transforms = [...this.#transforms];
|
|
1942
|
+
next.#dateFormats = this.#dateFormats;
|
|
1943
|
+
next.#coercions = [...this.#coercions];
|
|
1944
|
+
next.#allowUnknown = this.#allowUnknown;
|
|
1945
|
+
next.#metadata = this.#metadata ? { ...this.#metadata } : null;
|
|
1946
|
+
next.#declaredExtnames = this.#declaredExtnames;
|
|
1947
|
+
next.#declaredMimeTypes = this.#declaredMimeTypes;
|
|
1948
|
+
next.#contentVerified = this.#contentVerified;
|
|
1949
|
+
next.#contentVerificationOff = this.#contentVerificationOff;
|
|
1950
|
+
next.#camelCaseKeys = this.#camelCaseKeys;
|
|
1951
|
+
next.#groups = [...this.#groups];
|
|
1952
|
+
next.#recordValueChain = this.#recordValueChain;
|
|
1953
|
+
next.#tupleChains = this.#tupleChains;
|
|
1954
|
+
next.#unionChains = this.#unionChains;
|
|
1955
|
+
next.#ruleMessages = new Map(this.#ruleMessages);
|
|
1956
|
+
next.#lastRule = this.#lastRule;
|
|
547
1957
|
next.#preTransforms = [...this.#preTransforms];
|
|
548
1958
|
next.#nestedSchema = this.#nestedSchema;
|
|
549
1959
|
next.#arrayItemChain = this.#arrayItemChain;
|
|
550
1960
|
next.#useRules = [...this.#useRules];
|
|
1961
|
+
next.#asyncRules = [...this.#asyncRules];
|
|
551
1962
|
next.#requiredConditions = [...this.#requiredConditions];
|
|
552
1963
|
return next;
|
|
553
1964
|
}
|
|
@@ -581,7 +1992,7 @@ export class RuleChain<Output = unknown> {
|
|
|
581
1992
|
object<Sh extends Record<string, RuleChain>>(
|
|
582
1993
|
shape: Sh,
|
|
583
1994
|
): RuleChain<Infer<Sh>> {
|
|
584
|
-
this.#
|
|
1995
|
+
this.#pushRule({
|
|
585
1996
|
name: "object",
|
|
586
1997
|
validate: (v) => isPlainObject(v),
|
|
587
1998
|
message: "Must be an object",
|
|
@@ -592,7 +2003,7 @@ export class RuleChain<Output = unknown> {
|
|
|
592
2003
|
|
|
593
2004
|
/** Must be an array. Items validated by the provided chain. */
|
|
594
2005
|
array<Item extends RuleChain>(itemChain?: Item): RuleChain<OutputOf<Item>[]> {
|
|
595
|
-
this.#
|
|
2006
|
+
this.#pushRule({
|
|
596
2007
|
name: "array",
|
|
597
2008
|
validate: (v) => Array.isArray(v),
|
|
598
2009
|
message: "Must be an array",
|
|
@@ -603,7 +2014,7 @@ export class RuleChain<Output = unknown> {
|
|
|
603
2014
|
|
|
604
2015
|
/** Must be a string. */
|
|
605
2016
|
string(): RuleChain<string> {
|
|
606
|
-
this.#
|
|
2017
|
+
this.#pushRule({
|
|
607
2018
|
name: "string",
|
|
608
2019
|
validate: (v) => typeof v === "string",
|
|
609
2020
|
message: "Must be a string",
|
|
@@ -611,10 +2022,17 @@ export class RuleChain<Output = unknown> {
|
|
|
611
2022
|
return this.#retype<string>();
|
|
612
2023
|
}
|
|
613
2024
|
|
|
614
|
-
/**
|
|
615
|
-
|
|
616
|
-
|
|
2025
|
+
/**
|
|
2026
|
+
* Must be a number. Like VineJS, a numeric STRING is coerced (`"32"` → `32`)
|
|
2027
|
+
* — HTML form bodies and query strings carry numbers as text, so requiring
|
|
2028
|
+
* `typeof v === "number"` rejected the values Adonis accepts. Pass
|
|
2029
|
+
* `{ strict: true }` to refuse anything that is not already a number.
|
|
2030
|
+
*/
|
|
2031
|
+
number(options?: { strict?: boolean }): RuleChain<number> {
|
|
2032
|
+
if (!options?.strict) this.#coercions.push(coerceNumber);
|
|
2033
|
+
this.#pushRule({
|
|
617
2034
|
name: "number",
|
|
2035
|
+
args: { strict: options?.strict === true },
|
|
618
2036
|
validate: (v) =>
|
|
619
2037
|
typeof v === "number" && !Number.isNaN(v) && Number.isFinite(v),
|
|
620
2038
|
message: "Must be a number",
|
|
@@ -622,22 +2040,641 @@ export class RuleChain<Output = unknown> {
|
|
|
622
2040
|
return this.#retype<number>();
|
|
623
2041
|
}
|
|
624
2042
|
|
|
625
|
-
/**
|
|
626
|
-
|
|
627
|
-
|
|
2043
|
+
/**
|
|
2044
|
+
* Must be a boolean. Like VineJS, `"true"`, `"false"`, `"on"`, `"off"`,
|
|
2045
|
+
* `"1"`, `"0"`, `1` and `0` are coerced; `{ strict: true }` refuses them.
|
|
2046
|
+
*/
|
|
2047
|
+
boolean(options?: { strict?: boolean }): RuleChain<boolean> {
|
|
2048
|
+
if (!options?.strict) this.#coercions.push(coerceBoolean);
|
|
2049
|
+
this.#pushRule({
|
|
628
2050
|
name: "boolean",
|
|
2051
|
+
args: { strict: options?.strict === true },
|
|
629
2052
|
validate: (v) => typeof v === "boolean",
|
|
630
2053
|
message: "Must be a boolean",
|
|
631
2054
|
});
|
|
632
2055
|
return this.#retype<boolean>();
|
|
633
2056
|
}
|
|
634
2057
|
|
|
2058
|
+
/**
|
|
2059
|
+
* Must be a date (VineJS `vine.date()`). ISO 8601 by default; pass `formats`
|
|
2060
|
+
* for unix timestamps (`x` = ms, `X` = seconds) or a token format such as
|
|
2061
|
+
* `DD/MM/YYYY`. Parsing is calendar-strict — `2026-02-31` is rejected.
|
|
2062
|
+
*
|
|
2063
|
+
* The validated output is a `Date`; bind {@link setDateTransform} to map it
|
|
2064
|
+
* to your own type once at boot.
|
|
2065
|
+
*/
|
|
2066
|
+
date(options?: { formats?: DateFormat[] }): RuleChain<Date> {
|
|
2067
|
+
const formats = options?.formats ?? ["iso8601"];
|
|
2068
|
+
this.#dateFormats = formats;
|
|
2069
|
+
this.#pushRule({
|
|
2070
|
+
name: "date",
|
|
2071
|
+
args: { formats },
|
|
2072
|
+
validate: (v) => parseDateValue(v, formats) !== null,
|
|
2073
|
+
message: "Must be a valid date",
|
|
2074
|
+
});
|
|
2075
|
+
// Parse to a real `Date` BEFORE the comparison rules run, so `after`/
|
|
2076
|
+
// `before` never re-parse and never compare strings lexicographically.
|
|
2077
|
+
this.#transforms.push({
|
|
2078
|
+
name: "date",
|
|
2079
|
+
fn: (value) => parseDateValue(value, formats) ?? value,
|
|
2080
|
+
});
|
|
2081
|
+
return this.#retype<Date>();
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
/** Must be strictly after `operand` (`'today'`, an ISO string, or a `Date`). */
|
|
2085
|
+
after(operand: unknown, options?: DateCompareOptions): this {
|
|
2086
|
+
return this.#compareDate("after", operand, (a, b) => a > b, options);
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
/** Must be strictly before `operand`. */
|
|
2090
|
+
before(operand: unknown, options?: DateCompareOptions): this {
|
|
2091
|
+
return this.#compareDate("before", operand, (a, b) => a < b, options);
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/** Must be after `operand`, or equal to it. */
|
|
2095
|
+
afterOrEqual(operand: unknown, options?: DateCompareOptions): this {
|
|
2096
|
+
return this.#compareDate(
|
|
2097
|
+
"afterOrEqual",
|
|
2098
|
+
operand,
|
|
2099
|
+
(a, b) => a >= b,
|
|
2100
|
+
options,
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
/** Must be before `operand`, or equal to it. */
|
|
2105
|
+
beforeOrEqual(operand: unknown, options?: DateCompareOptions): this {
|
|
2106
|
+
return this.#compareDate(
|
|
2107
|
+
"beforeOrEqual",
|
|
2108
|
+
operand,
|
|
2109
|
+
(a, b) => a <= b,
|
|
2110
|
+
options,
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
/** Must be after the date held by a sibling field (VineJS `afterField`). */
|
|
2115
|
+
afterField(otherField: string, options?: DateCompareOptions): this {
|
|
2116
|
+
return this.#compareDateField(
|
|
2117
|
+
"afterField",
|
|
2118
|
+
otherField,
|
|
2119
|
+
options,
|
|
2120
|
+
(a, b) => a > b,
|
|
2121
|
+
);
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
/** Must be before the date held by a sibling field. */
|
|
2125
|
+
beforeField(otherField: string, options?: DateCompareOptions): this {
|
|
2126
|
+
return this.#compareDateField(
|
|
2127
|
+
"beforeField",
|
|
2128
|
+
otherField,
|
|
2129
|
+
options,
|
|
2130
|
+
(a, b) => a < b,
|
|
2131
|
+
);
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
/** Must be the same instant as `operand` (VineJS `equals`). */
|
|
2135
|
+
equals(operand: unknown, options?: DateCompareOptions): this {
|
|
2136
|
+
return this.#compareDate("equals", operand, (a, b) => a === b, options);
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
/** Must be after the sibling's date, or the same instant (VineJS `afterOrSameAs`). */
|
|
2140
|
+
afterOrSameAs(otherField: string, options?: DateCompareOptions): this {
|
|
2141
|
+
return this.#compareDateField(
|
|
2142
|
+
"afterOrSameAs",
|
|
2143
|
+
otherField,
|
|
2144
|
+
options,
|
|
2145
|
+
(a, b) => a >= b,
|
|
2146
|
+
);
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
/** Must be before the sibling's date, or the same instant. */
|
|
2150
|
+
beforeOrSameAs(otherField: string, options?: DateCompareOptions): this {
|
|
2151
|
+
return this.#compareDateField(
|
|
2152
|
+
"beforeOrSameAs",
|
|
2153
|
+
otherField,
|
|
2154
|
+
options,
|
|
2155
|
+
(a, b) => a <= b,
|
|
2156
|
+
);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
/** Must fall on a Saturday or Sunday (VineJS `weekend`). */
|
|
2160
|
+
weekend(): this {
|
|
2161
|
+
this.#pushRule({
|
|
2162
|
+
name: "weekend",
|
|
2163
|
+
validate: (v) =>
|
|
2164
|
+
v instanceof Date && (v.getDay() === 0 || v.getDay() === 6),
|
|
2165
|
+
message: "Must be a weekend date",
|
|
2166
|
+
});
|
|
2167
|
+
return this;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/** Must fall on a Monday-to-Friday day (VineJS `weekday`). */
|
|
2171
|
+
weekday(): this {
|
|
2172
|
+
this.#pushRule({
|
|
2173
|
+
name: "weekday",
|
|
2174
|
+
validate: (v) => v instanceof Date && v.getDay() > 0 && v.getDay() < 6,
|
|
2175
|
+
message: "Must be a weekday date",
|
|
2176
|
+
});
|
|
2177
|
+
return this;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
/** Shared body of the `after`/`before`/`*OrEqual` literal comparisons. */
|
|
2181
|
+
#compareDate(
|
|
2182
|
+
name: string,
|
|
2183
|
+
operand: unknown,
|
|
2184
|
+
cmp: (a: number, b: number) => boolean,
|
|
2185
|
+
options?: DateCompareOptions,
|
|
2186
|
+
): this {
|
|
2187
|
+
// VineJS: `options.compare || "day"`. A bare `after('today')` is about the
|
|
2188
|
+
// calendar date, not the clock — comparing exact timestamps made every
|
|
2189
|
+
// same-day value fail a rule the caller read as "today or later".
|
|
2190
|
+
const unit: CompareUnit = options?.compare ?? "day";
|
|
2191
|
+
const formats = options?.format ? [options.format] : null;
|
|
2192
|
+
this.#pushRule({
|
|
2193
|
+
name,
|
|
2194
|
+
// A callable operand is resolved per validation, not once at build
|
|
2195
|
+
// time — otherwise `after(() => Date.now())` would freeze the boundary
|
|
2196
|
+
// at the moment the schema was declared (VineJS allows the callback).
|
|
2197
|
+
args: typeof operand === "function" ? undefined : { operand },
|
|
2198
|
+
validate: (v) => {
|
|
2199
|
+
const raw =
|
|
2200
|
+
typeof operand === "function"
|
|
2201
|
+
? (operand as () => unknown)()
|
|
2202
|
+
: operand;
|
|
2203
|
+
const other =
|
|
2204
|
+
formats && typeof raw === "string"
|
|
2205
|
+
? parseDateValue(raw, formats)
|
|
2206
|
+
: resolveOperand(raw);
|
|
2207
|
+
if (!(v instanceof Date) || other === null) return false;
|
|
2208
|
+
return cmp(truncateTo(v, unit), truncateTo(other, unit));
|
|
2209
|
+
},
|
|
2210
|
+
message: `Must be ${name.replace(/([A-Z])/g, " $1").toLowerCase()} ${String(operand)}`,
|
|
2211
|
+
});
|
|
2212
|
+
return this;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
/** Shared body of the `afterField`/`beforeField` sibling comparisons. */
|
|
2216
|
+
#compareDateField(
|
|
2217
|
+
name: string,
|
|
2218
|
+
otherField: string,
|
|
2219
|
+
options: DateCompareOptions | undefined,
|
|
2220
|
+
cmp: (a: number, b: number) => boolean,
|
|
2221
|
+
): this {
|
|
2222
|
+
const formats = options?.format
|
|
2223
|
+
? [options.format]
|
|
2224
|
+
: (this.#dateFormats ?? ["iso8601"]);
|
|
2225
|
+
const unit: CompareUnit = options?.compare ?? "day";
|
|
2226
|
+
this.#pushUse({
|
|
2227
|
+
__rune: "rule",
|
|
2228
|
+
run: (value, field) => {
|
|
2229
|
+
const other = parseDateValue(readSibling(field, otherField), formats);
|
|
2230
|
+
if (!(value instanceof Date) || other === null) {
|
|
2231
|
+
field.report(`Cannot compare with ${otherField}`, name);
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
if (!cmp(truncateTo(value, unit), truncateTo(other, unit))) {
|
|
2235
|
+
field.report(
|
|
2236
|
+
`Must be ${name.replace("Field", "")} ${otherField}`,
|
|
2237
|
+
name,
|
|
2238
|
+
);
|
|
2239
|
+
}
|
|
2240
|
+
},
|
|
2241
|
+
});
|
|
2242
|
+
return this;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
/**
|
|
2246
|
+
* Keep keys the object shape does not declare (VineJS
|
|
2247
|
+
* `allowUnknownProperties`). Off by default: dropping undeclared keys is what
|
|
2248
|
+
* makes a validated payload safe to hand to a mass assignment.
|
|
2249
|
+
*/
|
|
2250
|
+
allowUnknownProperties(): this {
|
|
2251
|
+
this.#allowUnknown = true;
|
|
2252
|
+
return this;
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
/**
|
|
2256
|
+
* Convert the object's KEYS to camelCase in the output (VineJS
|
|
2257
|
+
* `object.toCamelCase()`), so a snake_case payload hydrates camelCase
|
|
2258
|
+
* properties. Distinct from the string `toCamelCase()`, which rewrites a
|
|
2259
|
+
* VALUE — that one was never a substitute for this.
|
|
2260
|
+
*/
|
|
2261
|
+
toCamelCaseKeys(): this {
|
|
2262
|
+
return this.toCamelCase();
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
/**
|
|
2266
|
+
* Merge extra properties into this object's shape (VineJS `merge`). Accepts a
|
|
2267
|
+
* plain shape or a {@link ConditionalGroup} whose branch is chosen per
|
|
2268
|
+
* payload — `vine.group` in VineJS.
|
|
2269
|
+
*/
|
|
2270
|
+
merge(extra: Record<string, RuleChain> | ConditionalGroup): this {
|
|
2271
|
+
if (!this.#nestedSchema) {
|
|
2272
|
+
throw new RuneError(
|
|
2273
|
+
"NOT_AN_OBJECT",
|
|
2274
|
+
"merge() needs an object() shape to merge into.",
|
|
2275
|
+
{ hint: "rules.any().object({ … }).merge({ … })" },
|
|
2276
|
+
);
|
|
2277
|
+
}
|
|
2278
|
+
if (isConditionalGroup(extra)) {
|
|
2279
|
+
this.#groups.push(extra);
|
|
2280
|
+
return this;
|
|
2281
|
+
}
|
|
2282
|
+
this.#nestedSchema = { ...this.#nestedSchema, ...extra };
|
|
2283
|
+
return this;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
/** The nested shape declared by `object()`, if any (VineJS `getProperties`). */
|
|
2287
|
+
getProperties(): Record<string, RuleChain> | null {
|
|
2288
|
+
// CLONE each chain, not just the map. A shallow copy shares the chain
|
|
2289
|
+
// instances, so mutating one through the copy relaxes the source schema —
|
|
2290
|
+
// the same trap that made `partial()` mutate its origin.
|
|
2291
|
+
if (!this.#nestedSchema) return null;
|
|
2292
|
+
return Object.fromEntries(
|
|
2293
|
+
Object.entries(this.#nestedSchema).map(([key, chain]) => [
|
|
2294
|
+
key,
|
|
2295
|
+
chain.clone(),
|
|
2296
|
+
]),
|
|
2297
|
+
);
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
/** Independent copy of this chain (VineJS `clone`). */
|
|
2301
|
+
clone(): RuleChain<Output> {
|
|
2302
|
+
return this.#retype<Output>();
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
/**
|
|
2306
|
+
* A CLONED subset of the object's properties (VineJS `pick`).
|
|
2307
|
+
*
|
|
2308
|
+
* Returns a properties record, not a schema — VineJS types it
|
|
2309
|
+
* `Pick<Properties, Keys>` precisely so it composes by spread:
|
|
2310
|
+
* `rules.any().object({ ...userShape.pick(["id"]) })`. Returning a chain here
|
|
2311
|
+
* broke that idiom.
|
|
2312
|
+
*/
|
|
2313
|
+
pick<K extends string>(keys: readonly K[]): Record<string, RuleChain> {
|
|
2314
|
+
return this.#subsetOfProperties((key) => keys.includes(key as K));
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
/** A cloned copy of the properties EXCLUDING `keys` (VineJS `omit`). */
|
|
2318
|
+
omit<K extends string>(keys: readonly K[]): Record<string, RuleChain> {
|
|
2319
|
+
return this.#subsetOfProperties((key) => !keys.includes(key as K));
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
/** Shared body of `pick`/`omit` — clones so the source stays untouched. */
|
|
2323
|
+
#subsetOfProperties(
|
|
2324
|
+
keep: (key: string) => boolean,
|
|
2325
|
+
): Record<string, RuleChain> {
|
|
2326
|
+
const shape = this.getProperties();
|
|
2327
|
+
if (!shape) {
|
|
2328
|
+
throw new RuneError(
|
|
2329
|
+
"NOT_AN_OBJECT",
|
|
2330
|
+
"pick()/omit() need an object() shape to work on.",
|
|
2331
|
+
{ hint: "rules.any().object({ … }).pick([…])" },
|
|
2332
|
+
);
|
|
2333
|
+
}
|
|
2334
|
+
return Object.fromEntries(
|
|
2335
|
+
Object.entries(shape).filter(([key]) => keep(key)),
|
|
2336
|
+
);
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
/** Make every property of an object shape optional (VineJS `partial`). */
|
|
2340
|
+
partial(keys?: readonly string[]): RuleChain<Output> {
|
|
2341
|
+
// `optional()` mutates and returns the SAME chain, so calling it on the
|
|
2342
|
+
// stored properties made the source shape optional too — `base.partial()`
|
|
2343
|
+
// silently relaxed `base`. Clone each property first, like VineJS does.
|
|
2344
|
+
return this.#reshape((shape) =>
|
|
2345
|
+
Object.fromEntries(
|
|
2346
|
+
Object.entries(shape).map(([key, chain]) => [
|
|
2347
|
+
key,
|
|
2348
|
+
keys === undefined || keys.includes(key)
|
|
2349
|
+
? chain.clone().optional()
|
|
2350
|
+
: chain,
|
|
2351
|
+
]),
|
|
2352
|
+
),
|
|
2353
|
+
);
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
/** Shared body of `pick`/`omit`/`partial` — rebuilds the nested shape on a clone. */
|
|
2357
|
+
#reshape(
|
|
2358
|
+
transform: (shape: Record<string, RuleChain>) => Record<string, RuleChain>,
|
|
2359
|
+
): RuleChain<Output> {
|
|
2360
|
+
if (!this.#nestedSchema) {
|
|
2361
|
+
throw new RuneError(
|
|
2362
|
+
"NOT_AN_OBJECT",
|
|
2363
|
+
"pick()/omit()/partial() need an object() shape to work on.",
|
|
2364
|
+
{
|
|
2365
|
+
hint: "Declare the shape first: rules.any().object({ … }).pick([…])",
|
|
2366
|
+
},
|
|
2367
|
+
);
|
|
2368
|
+
}
|
|
2369
|
+
const next = this.#retype<Output>();
|
|
2370
|
+
next.#nestedSchema = transform(this.#nestedSchema);
|
|
2371
|
+
return next;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
/**
|
|
2375
|
+
* Must be an "accepted" value — `true`, `1`, `"1"`, `"on"`, `"yes"`,
|
|
2376
|
+
* `"true"` (VineJS `accepted`, for checkbox-style consent fields).
|
|
2377
|
+
*/
|
|
2378
|
+
accepted(): RuleChain<true> {
|
|
2379
|
+
this.#pushRule({
|
|
2380
|
+
name: "accepted",
|
|
2381
|
+
validate: isAcceptedValue,
|
|
2382
|
+
message: "Must be accepted",
|
|
2383
|
+
});
|
|
2384
|
+
// Normalise ONLY an accepted value: a blanket `() => true` would rewrite a
|
|
2385
|
+
// refused value into an accepted one before the rule ever saw it.
|
|
2386
|
+
this.#transforms.push({
|
|
2387
|
+
name: "accepted",
|
|
2388
|
+
fn: (value) => (isAcceptedValue(value) ? true : value),
|
|
2389
|
+
});
|
|
2390
|
+
return this.#retype<true>();
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
/**
|
|
2394
|
+
* Object with arbitrary keys, every value validated by `valueChain`
|
|
2395
|
+
* (VineJS `record`).
|
|
2396
|
+
*/
|
|
2397
|
+
record<Item extends RuleChain>(
|
|
2398
|
+
valueChain: Item,
|
|
2399
|
+
): RuleChain<Record<string, OutputOf<Item>>> {
|
|
2400
|
+
this.#pushRule({
|
|
2401
|
+
name: "record",
|
|
2402
|
+
validate: (v) => isPlainObject(v),
|
|
2403
|
+
message: "Must be an object",
|
|
2404
|
+
});
|
|
2405
|
+
this.#recordValueChain = valueChain;
|
|
2406
|
+
return this.#retype<Record<string, OutputOf<Item>>>();
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
/**
|
|
2410
|
+
* Fixed-length array with a schema per position (VineJS `tuple`). Extra
|
|
2411
|
+
* items are rejected — a tuple that silently ignores a trailing element is
|
|
2412
|
+
* how unvalidated data slips through.
|
|
2413
|
+
*/
|
|
2414
|
+
tuple<const Items extends readonly RuleChain[]>(
|
|
2415
|
+
items: Items,
|
|
2416
|
+
): RuleChain<{ [K in keyof Items]: OutputOf<Items[K]> }> {
|
|
2417
|
+
this.#pushRule({
|
|
2418
|
+
name: "tuple",
|
|
2419
|
+
args: { length: items.length },
|
|
2420
|
+
validate: (v) => Array.isArray(v) && v.length === items.length,
|
|
2421
|
+
message: `Must be an array of exactly ${items.length} items`,
|
|
2422
|
+
});
|
|
2423
|
+
this.#tupleChains = [...items];
|
|
2424
|
+
return this.#retype<{ [K in keyof Items]: OutputOf<Items[K]> }>();
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
/**
|
|
2428
|
+
* Value must satisfy at least one of `chains`.
|
|
2429
|
+
*
|
|
2430
|
+
* Two forms, both supported:
|
|
2431
|
+
*
|
|
2432
|
+
* - guarded (VineJS parity): `union([rules.union.if(pred, chain), …,
|
|
2433
|
+
* rules.union.else(fallback)])` — the predicate SELECTS the branch and
|
|
2434
|
+
* that branch's own errors are reported, so a failure says which shape was
|
|
2435
|
+
* meant and why it did not fit.
|
|
2436
|
+
* - bare chains: tried in order, first match wins, and a total miss reports a
|
|
2437
|
+
* single `union` error rather than every losing branch's noise.
|
|
2438
|
+
*/
|
|
2439
|
+
union(chains: readonly UnionBranch[]): this {
|
|
2440
|
+
this.#unionChains = chains.map(toUnionBranch);
|
|
2441
|
+
// Marker rule: its name is not in NATIVE_RULES, which is what keeps a
|
|
2442
|
+
// union off the native path. The Rust engine knows nothing about branches
|
|
2443
|
+
// and would silently accept anything.
|
|
2444
|
+
this.#pushRule({
|
|
2445
|
+
name: "union",
|
|
2446
|
+
validate: () => true,
|
|
2447
|
+
message: "Does not match any allowed shape",
|
|
2448
|
+
});
|
|
2449
|
+
return this;
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
/**
|
|
2453
|
+
* Must be an uploaded file (VineJS/Adonis `vine.file()`).
|
|
2454
|
+
*
|
|
2455
|
+
* Named deviation: Adonis validates a bodyparser `MultipartFile`, which rune
|
|
2456
|
+
* cannot import and stay agnostic. It checks the STRUCTURE instead — any
|
|
2457
|
+
* object exposing `size` and a name/extension — so an Adonis MultipartFile
|
|
2458
|
+
* satisfies it, and so does any other upload representation.
|
|
2459
|
+
*
|
|
2460
|
+
* `size` is a byte count; `extnames` are compared lowercase, without the dot.
|
|
2461
|
+
*/
|
|
2462
|
+
file(options?: {
|
|
2463
|
+
size?: number | string;
|
|
2464
|
+
extnames?: readonly string[];
|
|
2465
|
+
/**
|
|
2466
|
+
* Skip the magic-number check. Default `true` — Adonis derives `extname`
|
|
2467
|
+
* from the real bytes before validation, so trusting the declaration is
|
|
2468
|
+
* NOT the safe default: a `.exe` renamed `.png` satisfies every
|
|
2469
|
+
* declarative check, since all of them come from the uploader.
|
|
2470
|
+
*/
|
|
2471
|
+
verifyContent?: boolean;
|
|
2472
|
+
}): RuleChain<FileLike> {
|
|
2473
|
+
// Adonis documents `size: '2mb'`; a numeric-only option meant a
|
|
2474
|
+
// transcribed validator either failed the typecheck or, in JS, silently
|
|
2475
|
+
// stopped capping.
|
|
2476
|
+
const maxBytes =
|
|
2477
|
+
options?.size === undefined ? undefined : parseByteSize(options.size);
|
|
2478
|
+
if (options?.extnames) this.#declaredExtnames = options.extnames;
|
|
2479
|
+
if (options?.verifyContent === false) this.#contentVerificationOff = true;
|
|
2480
|
+
this.#pushRule({
|
|
2481
|
+
name: "file",
|
|
2482
|
+
args: options ? { ...options } : undefined,
|
|
2483
|
+
validate: (v) => {
|
|
2484
|
+
if (!isFileLike(v)) return false;
|
|
2485
|
+
if (maxBytes !== undefined && v.size > maxBytes) return false;
|
|
2486
|
+
if (options?.extnames) {
|
|
2487
|
+
const ext = fileExtension(v);
|
|
2488
|
+
if (ext === null) return false;
|
|
2489
|
+
if (!options.extnames.map((e) => e.toLowerCase()).includes(ext)) {
|
|
2490
|
+
return false;
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
return true;
|
|
2494
|
+
},
|
|
2495
|
+
message: "Must be a valid file",
|
|
2496
|
+
});
|
|
2497
|
+
// Declaring an allowed extension list is a SECURITY statement, so the
|
|
2498
|
+
// bytes are checked by default. `{ verifyContent: false }` opts out
|
|
2499
|
+
// explicitly and leaves a trace in the schema.
|
|
2500
|
+
if (options?.extnames) this.#ensureContentVerification();
|
|
2501
|
+
return this.#retype<FileLike>();
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
/**
|
|
2505
|
+
* Uploaded file with VineJS `nativeFile` options — `minSize`, `maxSize`,
|
|
2506
|
+
* `mimeTypes`. Same structural contract as {@link file}: rune never reads
|
|
2507
|
+
* bytes, so the MIME type is the one the upload REPORTS.
|
|
2508
|
+
*/
|
|
2509
|
+
nativeFile(options?: {
|
|
2510
|
+
minSize?: number | string;
|
|
2511
|
+
maxSize?: number | string;
|
|
2512
|
+
mimeTypes?: readonly string[];
|
|
2513
|
+
}): RuleChain<FileLike> {
|
|
2514
|
+
const min =
|
|
2515
|
+
options?.minSize === undefined
|
|
2516
|
+
? undefined
|
|
2517
|
+
: parseByteSize(options.minSize);
|
|
2518
|
+
const max =
|
|
2519
|
+
options?.maxSize === undefined
|
|
2520
|
+
? undefined
|
|
2521
|
+
: parseByteSize(options.maxSize);
|
|
2522
|
+
this.#pushRule({
|
|
2523
|
+
name: "nativeFile",
|
|
2524
|
+
args: options ? { ...options } : undefined,
|
|
2525
|
+
validate: (v) => {
|
|
2526
|
+
if (!isFileLike(v)) return false;
|
|
2527
|
+
if (min !== undefined && v.size < min) return false;
|
|
2528
|
+
if (max !== undefined && v.size > max) return false;
|
|
2529
|
+
if (options?.mimeTypes) {
|
|
2530
|
+
const type = typeof v.type === "string" ? v.type.toLowerCase() : null;
|
|
2531
|
+
if (type === null) return false;
|
|
2532
|
+
if (!options.mimeTypes.map((m) => m.toLowerCase()).includes(type)) {
|
|
2533
|
+
return false;
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
return true;
|
|
2537
|
+
},
|
|
2538
|
+
message: "Must be a valid file",
|
|
2539
|
+
});
|
|
2540
|
+
// Declaring allowed MIME types is a SECURITY statement, so the bytes are
|
|
2541
|
+
// checked by default.
|
|
2542
|
+
if (options?.mimeTypes) this.#ensureContentVerification();
|
|
2543
|
+
return this.#retype<FileLike>();
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
/** Minimum upload size (VineJS `nativeFile().minSize()`). */
|
|
2547
|
+
minSize(size: number | string): this {
|
|
2548
|
+
const min = parseByteSize(size);
|
|
2549
|
+
this.#pushRule({
|
|
2550
|
+
name: "minSize",
|
|
2551
|
+
args: { size },
|
|
2552
|
+
validate: (v) => isFileLike(v) && v.size >= min,
|
|
2553
|
+
message: `Must be at least ${size} in size`,
|
|
2554
|
+
});
|
|
2555
|
+
return this;
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
/** Maximum upload size (VineJS `nativeFile().maxSize()`). */
|
|
2559
|
+
maxSize(size: number | string): this {
|
|
2560
|
+
const max = parseByteSize(size);
|
|
2561
|
+
this.#pushRule({
|
|
2562
|
+
name: "maxSize",
|
|
2563
|
+
args: { size },
|
|
2564
|
+
validate: (v) => isFileLike(v) && v.size <= max,
|
|
2565
|
+
message: `Must be at most ${size} in size`,
|
|
2566
|
+
});
|
|
2567
|
+
return this;
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
/**
|
|
2571
|
+
* Allowed MIME types (VineJS `nativeFile().mimeTypes()`). The type is the one
|
|
2572
|
+
* the upload REPORTS — rune never reads bytes, see {@link file}.
|
|
2573
|
+
*/
|
|
2574
|
+
mimeTypes(types: readonly string[]): this {
|
|
2575
|
+
const allowed = types.map((t) => t.toLowerCase());
|
|
2576
|
+
this.#declaredMimeTypes = allowed;
|
|
2577
|
+
this.#ensureContentVerification();
|
|
2578
|
+
this.#pushRule({
|
|
2579
|
+
name: "mimeTypes",
|
|
2580
|
+
args: { types: allowed },
|
|
2581
|
+
validate: (v) =>
|
|
2582
|
+
isFileLike(v) &&
|
|
2583
|
+
typeof v.type === "string" &&
|
|
2584
|
+
allowed.includes(v.type.toLowerCase()),
|
|
2585
|
+
message: `Must be one of ${allowed.join(", ")}`,
|
|
2586
|
+
});
|
|
2587
|
+
return this;
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
/**
|
|
2591
|
+
* Verify the file's REAL type against its magic number (Adonis parity).
|
|
2592
|
+
*
|
|
2593
|
+
* A `.exe` renamed `.jpg` passes every declarative check — size, extension,
|
|
2594
|
+
* reported MIME — because all three come from the uploader. This reads the
|
|
2595
|
+
* leading bytes and refuses a mismatch.
|
|
2596
|
+
*
|
|
2597
|
+
* Async by nature (it touches the filesystem), so the schema must run with
|
|
2598
|
+
* `validateResultAsync` / `validate`. Needs a byte source on the file object
|
|
2599
|
+
* (`buffer`, `tmpPath`, `filePath` or `path`) — an Adonis `MultipartFile`
|
|
2600
|
+
* carries `tmpPath`. With NO source it FAILS: a content check that cannot
|
|
2601
|
+
* run must never look like one that passed.
|
|
2602
|
+
*/
|
|
2603
|
+
verifyContent(): this {
|
|
2604
|
+
this.#contentVerificationOff = false;
|
|
2605
|
+
return this.#ensureContentVerification();
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
/** Register the content check once, honouring an explicit opt-out. */
|
|
2609
|
+
#ensureContentVerification(): this {
|
|
2610
|
+
if (this.#contentVerified || this.#contentVerificationOff) return this;
|
|
2611
|
+
this.#contentVerified = true;
|
|
2612
|
+
return this.#registerContentVerification();
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
/** The async rule itself — reads the bytes and confronts the declaration. */
|
|
2616
|
+
#registerContentVerification(): this {
|
|
2617
|
+
const extnames = this.#declaredExtnames;
|
|
2618
|
+
const mimeTypes = this.#declaredMimeTypes;
|
|
2619
|
+
this.#pushAsync({
|
|
2620
|
+
__rune: "asyncRule",
|
|
2621
|
+
async run(value: unknown, field: FieldContext): Promise<void> {
|
|
2622
|
+
if (!isFileLike(value)) {
|
|
2623
|
+
field.report("Must be a valid file", "verifyContent");
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
const head = await readFileHead(value);
|
|
2627
|
+
if (head === null) {
|
|
2628
|
+
field.report(
|
|
2629
|
+
"Cannot read the file's content to verify its type",
|
|
2630
|
+
"verifyContent",
|
|
2631
|
+
);
|
|
2632
|
+
return;
|
|
2633
|
+
}
|
|
2634
|
+
const detected = detectFileType(head);
|
|
2635
|
+
if (detected === null) {
|
|
2636
|
+
field.report("File type could not be recognised", "verifyContent");
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
// The declared extension must agree with the bytes.
|
|
2640
|
+
const declaredExt =
|
|
2641
|
+
typeof value.extname === "string" && value.extname.length > 0
|
|
2642
|
+
? value.extname
|
|
2643
|
+
: null;
|
|
2644
|
+
if (declaredExt && !extensionMatches(detected.ext, declaredExt)) {
|
|
2645
|
+
field.report(
|
|
2646
|
+
`Content is ${detected.ext}, not ${declaredExt.replace(/^\./, "")}`,
|
|
2647
|
+
"verifyContent",
|
|
2648
|
+
);
|
|
2649
|
+
return;
|
|
2650
|
+
}
|
|
2651
|
+
if (
|
|
2652
|
+
extnames &&
|
|
2653
|
+
!extnames.some((allowed) => extensionMatches(detected.ext, allowed))
|
|
2654
|
+
) {
|
|
2655
|
+
field.report(
|
|
2656
|
+
`Content is ${detected.ext}, which is not allowed`,
|
|
2657
|
+
"verifyContent",
|
|
2658
|
+
);
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2661
|
+
if (mimeTypes && !mimeTypes.includes(detected.mime)) {
|
|
2662
|
+
field.report(
|
|
2663
|
+
`Content is ${detected.mime}, which is not allowed`,
|
|
2664
|
+
"verifyContent",
|
|
2665
|
+
);
|
|
2666
|
+
}
|
|
2667
|
+
},
|
|
2668
|
+
});
|
|
2669
|
+
return this;
|
|
2670
|
+
}
|
|
2671
|
+
|
|
635
2672
|
/** Must equal one of `values` (enum). Narrows the output to the union. */
|
|
636
2673
|
enum<const V extends readonly (string | number | boolean)[]>(
|
|
637
2674
|
values: V,
|
|
638
2675
|
): RuleChain<V[number]> {
|
|
639
2676
|
const allowed = [...values];
|
|
640
|
-
this.#
|
|
2677
|
+
this.#pushRule({
|
|
641
2678
|
name: "enum",
|
|
642
2679
|
args: { values: allowed },
|
|
643
2680
|
validate: (v) => allowed.includes(asPrimitive(v)),
|
|
@@ -648,9 +2685,9 @@ export class RuleChain<Output = unknown> {
|
|
|
648
2685
|
|
|
649
2686
|
/** Must equal a literal value. */
|
|
650
2687
|
literal<V extends string | number | boolean>(value: V): RuleChain<V> {
|
|
651
|
-
this.#
|
|
2688
|
+
this.#pushRule({
|
|
652
2689
|
name: "literal",
|
|
653
|
-
args: { expectedValue: value },
|
|
2690
|
+
args: { value, expectedValue: value },
|
|
654
2691
|
validate: (v) => v === value,
|
|
655
2692
|
message: `Must be ${String(value)}`,
|
|
656
2693
|
});
|
|
@@ -659,7 +2696,7 @@ export class RuleChain<Output = unknown> {
|
|
|
659
2696
|
|
|
660
2697
|
/** Minimum length (string) or minimum value (number). Alias of min/minLength. */
|
|
661
2698
|
min(n: number): this {
|
|
662
|
-
this.#
|
|
2699
|
+
this.#pushRule({
|
|
663
2700
|
name: "min",
|
|
664
2701
|
param: n,
|
|
665
2702
|
args: { min: n },
|
|
@@ -676,7 +2713,7 @@ export class RuleChain<Output = unknown> {
|
|
|
676
2713
|
|
|
677
2714
|
/** Maximum length (string) or maximum value (number). Alias of max/maxLength. */
|
|
678
2715
|
max(n: number): this {
|
|
679
|
-
this.#
|
|
2716
|
+
this.#pushRule({
|
|
680
2717
|
name: "max",
|
|
681
2718
|
param: n,
|
|
682
2719
|
args: { max: n },
|
|
@@ -691,105 +2728,544 @@ export class RuleChain<Output = unknown> {
|
|
|
691
2728
|
return this;
|
|
692
2729
|
}
|
|
693
2730
|
|
|
694
|
-
/** Minimum length for a string or array (VineJS `minLength`). */
|
|
695
|
-
minLength(n: number): this {
|
|
696
|
-
this.#
|
|
697
|
-
name: "minLength",
|
|
698
|
-
param: n,
|
|
699
|
-
args: { min: n },
|
|
700
|
-
validate: (v) => sizedLength(v) >= n,
|
|
701
|
-
message: `Must have at least ${n} characters`,
|
|
2731
|
+
/** Minimum length for a string or array (VineJS `minLength`). */
|
|
2732
|
+
minLength(n: number): this {
|
|
2733
|
+
this.#pushRule({
|
|
2734
|
+
name: "minLength",
|
|
2735
|
+
param: n,
|
|
2736
|
+
args: { min: n },
|
|
2737
|
+
validate: (v) => sizedLength(v) >= n,
|
|
2738
|
+
message: `Must have at least ${n} characters`,
|
|
2739
|
+
});
|
|
2740
|
+
return this;
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
/** Maximum length for a string or array (VineJS `maxLength`). */
|
|
2744
|
+
maxLength(n: number): this {
|
|
2745
|
+
this.#pushRule({
|
|
2746
|
+
name: "maxLength",
|
|
2747
|
+
param: n,
|
|
2748
|
+
args: { max: n },
|
|
2749
|
+
validate: (v) => {
|
|
2750
|
+
const len = sizedLength(v);
|
|
2751
|
+
return len >= 0 && len <= n;
|
|
2752
|
+
},
|
|
2753
|
+
message: `Must not exceed ${n} characters`,
|
|
2754
|
+
});
|
|
2755
|
+
return this;
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
/** Exact length for a string or array (VineJS `fixedLength`). */
|
|
2759
|
+
fixedLength(n: number): this {
|
|
2760
|
+
this.#pushRule({
|
|
2761
|
+
name: "fixedLength",
|
|
2762
|
+
param: n,
|
|
2763
|
+
args: { size: n },
|
|
2764
|
+
validate: (v) => sizedLength(v) === n,
|
|
2765
|
+
message: `Must be exactly ${n} characters`,
|
|
2766
|
+
});
|
|
2767
|
+
return this;
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
/** Must be a valid email. */
|
|
2771
|
+
email(options?: EmailOptions): this {
|
|
2772
|
+
return this.#stringRule(
|
|
2773
|
+
"email",
|
|
2774
|
+
(v) => isEmail(v, options),
|
|
2775
|
+
"Must be a valid email address",
|
|
2776
|
+
options ? { ...options } : undefined,
|
|
2777
|
+
);
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
/** Must match a regular expression (TS-only — never dispatched to Rust). */
|
|
2781
|
+
regex(pattern: RegExp): this {
|
|
2782
|
+
this.#pushRule({
|
|
2783
|
+
name: "regex",
|
|
2784
|
+
validate: (v) => typeof v === "string" && pattern.test(v),
|
|
2785
|
+
message: "Invalid format",
|
|
2786
|
+
});
|
|
2787
|
+
return this;
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
/** Must be a valid URL (TS-only — uses the WHATWG URL parser). */
|
|
2791
|
+
url(options?: UrlOptions): this {
|
|
2792
|
+
this.#pushRule({
|
|
2793
|
+
name: "url",
|
|
2794
|
+
args: options ? { ...options } : undefined,
|
|
2795
|
+
validate: (v) =>
|
|
2796
|
+
typeof v === "string" &&
|
|
2797
|
+
(options ? isUrlWithOptions(v, options) : isValidUrl(v)),
|
|
2798
|
+
message: "Must be a valid URL",
|
|
2799
|
+
});
|
|
2800
|
+
return this;
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
/**
|
|
2804
|
+
* The host must actually resolve (VineJS `activeUrl`).
|
|
2805
|
+
*
|
|
2806
|
+
* The only rule needing the network, which rune cannot do and stay agnostic
|
|
2807
|
+
* and zero-dependency — so it runs through a resolver bound once at boot,
|
|
2808
|
+
* exactly like `unique()`. Async by nature: run the schema with
|
|
2809
|
+
* `validateResultAsync`. Unbound it THROWS, rather than passing a host nobody
|
|
2810
|
+
* checked.
|
|
2811
|
+
*/
|
|
2812
|
+
activeUrl(): this {
|
|
2813
|
+
this.#pushAsync({
|
|
2814
|
+
__rune: "asyncRule",
|
|
2815
|
+
async run(value: unknown, field: FieldContext): Promise<void> {
|
|
2816
|
+
if (!hostResolver) {
|
|
2817
|
+
throw new RuneError(
|
|
2818
|
+
"NO_HOST_RESOLVER",
|
|
2819
|
+
"activeUrl() needs a host resolver.",
|
|
2820
|
+
{ hint: "Call bindHostResolver(resolver) once at boot." },
|
|
2821
|
+
);
|
|
2822
|
+
}
|
|
2823
|
+
let host: string;
|
|
2824
|
+
try {
|
|
2825
|
+
host = new URL(String(value)).hostname;
|
|
2826
|
+
} catch {
|
|
2827
|
+
field.report("Must be a valid URL", "activeUrl");
|
|
2828
|
+
return;
|
|
2829
|
+
}
|
|
2830
|
+
if (!(await hostResolver.resolves(host))) {
|
|
2831
|
+
field.report("Must be an active URL", "activeUrl");
|
|
2832
|
+
}
|
|
2833
|
+
},
|
|
2834
|
+
});
|
|
2835
|
+
return this;
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
/**
|
|
2839
|
+
* Must be a valid UUID, optionally restricted to given versions
|
|
2840
|
+
* (VineJS `uuid({ version: [4] })`, versions 1 through 8).
|
|
2841
|
+
*/
|
|
2842
|
+
uuid(options?: { version?: number | number[] }): this {
|
|
2843
|
+
const versions =
|
|
2844
|
+
options?.version === undefined ? undefined : [options.version].flat();
|
|
2845
|
+
this.#pushRule({
|
|
2846
|
+
name: "uuid",
|
|
2847
|
+
args: versions === undefined ? {} : { version: versions },
|
|
2848
|
+
// The Rust engine checks UUID shape only; a version constraint would
|
|
2849
|
+
// be dropped there.
|
|
2850
|
+
tsOnly: versions !== undefined,
|
|
2851
|
+
validate: (v) => {
|
|
2852
|
+
if (typeof v !== "string" || !UUID_RE.test(v)) return false;
|
|
2853
|
+
if (versions === undefined) return true;
|
|
2854
|
+
// Version nibble: first character of the third group.
|
|
2855
|
+
const version = Number.parseInt(v[14] ?? "", 16);
|
|
2856
|
+
return versions.includes(version);
|
|
2857
|
+
},
|
|
2858
|
+
message:
|
|
2859
|
+
versions === undefined
|
|
2860
|
+
? "Must be a valid UUID"
|
|
2861
|
+
: `Must be a UUID v${versions.join("/")}`,
|
|
2862
|
+
});
|
|
2863
|
+
return this;
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
/** Must be a ULID (VineJS `ulid`). */
|
|
2867
|
+
ulid(): this {
|
|
2868
|
+
return this.#stringRule("ulid", isUlid, "Must be a valid ULID");
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
/** Must be a JSON Web Token — three dot-separated base64url segments. */
|
|
2872
|
+
jwt(): this {
|
|
2873
|
+
return this.#stringRule("jwt", isJwt, "Must be a valid JWT");
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
/** Must contain only ASCII characters (VineJS `ascii`). */
|
|
2877
|
+
ascii(): this {
|
|
2878
|
+
return this.#stringRule(
|
|
2879
|
+
"ascii",
|
|
2880
|
+
isAscii,
|
|
2881
|
+
"Must contain only ASCII characters",
|
|
2882
|
+
);
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
/** Must be a CSS hex colour code, with or without the leading `#`. */
|
|
2886
|
+
hexCode(): this {
|
|
2887
|
+
return this.#stringRule("hexCode", isHexCode, "Must be a valid hex code");
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
/** Must be an IP address. Pass `version` to require v4 or v6 specifically. */
|
|
2891
|
+
ipAddress(options?: { version?: 4 | 6 }): this {
|
|
2892
|
+
const version = options?.version;
|
|
2893
|
+
return this.#stringRule(
|
|
2894
|
+
"ipAddress",
|
|
2895
|
+
(v) => isIpAddress(v, version),
|
|
2896
|
+
`Must be a valid IP address${version ? ` (v${version})` : ""}`,
|
|
2897
|
+
{ version },
|
|
2898
|
+
);
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
/** Must pass the Luhn checksum (VineJS `creditCard`). */
|
|
2902
|
+
creditCard(): this {
|
|
2903
|
+
return this.#stringRule(
|
|
2904
|
+
"creditCard",
|
|
2905
|
+
isCreditCard,
|
|
2906
|
+
"Must be a valid credit card number",
|
|
2907
|
+
);
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
/** Must be an IBAN passing the ISO 13616 mod-97 check. */
|
|
2911
|
+
iban(): this {
|
|
2912
|
+
return this.#stringRule("iban", isIban, "Must be a valid IBAN");
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
/** Must be a `"lat,lng"` pair within the valid ranges. */
|
|
2916
|
+
coordinates(): this {
|
|
2917
|
+
return this.#stringRule(
|
|
2918
|
+
"coordinates",
|
|
2919
|
+
isCoordinates,
|
|
2920
|
+
"Must be valid coordinates",
|
|
2921
|
+
);
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2924
|
+
/**
|
|
2925
|
+
* Must be a mobile number in E.164 form. Named deviation from VineJS: rune
|
|
2926
|
+
* carries no per-locale numbering plans, so there is no `locale` option.
|
|
2927
|
+
*/
|
|
2928
|
+
mobile(options?: { locale?: string | string[]; strictMode?: boolean }): this {
|
|
2929
|
+
const locales = options?.locale ? [options.locale].flat() : null;
|
|
2930
|
+
for (const locale of locales ?? []) {
|
|
2931
|
+
if (isMobileForLocale("", locale) === null) {
|
|
2932
|
+
throw new RuneError(
|
|
2933
|
+
"UNSUPPORTED_LOCALE",
|
|
2934
|
+
`mobile(): no numbering plan for locale '${locale}'.`,
|
|
2935
|
+
{
|
|
2936
|
+
hint: `Supported: ${SUPPORTED_MOBILE_LOCALES.join(", ")}. Omit the locale for E.164, or use .regex().`,
|
|
2937
|
+
},
|
|
2938
|
+
);
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
return this.#stringRule(
|
|
2942
|
+
"mobile",
|
|
2943
|
+
(v) => {
|
|
2944
|
+
// strictMode (validator.js): the number must carry its `+` country
|
|
2945
|
+
// prefix, so a national-format string is not silently accepted.
|
|
2946
|
+
if (options?.strictMode && !v.trim().startsWith("+")) return false;
|
|
2947
|
+
return locales
|
|
2948
|
+
? locales.some((locale) => isMobileForLocale(v, locale) === true)
|
|
2949
|
+
: isMobile(v);
|
|
2950
|
+
},
|
|
2951
|
+
"Must be a valid mobile number",
|
|
2952
|
+
(locales ?? options?.strictMode)
|
|
2953
|
+
? { locale: locales, strictMode: options?.strictMode }
|
|
2954
|
+
: undefined,
|
|
2955
|
+
);
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
/**
|
|
2959
|
+
* Must be a postal code for `countryCode`. Throws for a country rune has no
|
|
2960
|
+
* pattern for, rather than accepting the value unchecked.
|
|
2961
|
+
*/
|
|
2962
|
+
postalCode(
|
|
2963
|
+
options:
|
|
2964
|
+
| { countryCode: string | string[] }
|
|
2965
|
+
| ((field: FieldContext) => {
|
|
2966
|
+
countryCode: string | string[];
|
|
2967
|
+
}),
|
|
2968
|
+
): this {
|
|
2969
|
+
// The callback form resolves per validation (VineJS lets the country come
|
|
2970
|
+
// from a sibling field), so its countries cannot be checked up front.
|
|
2971
|
+
if (typeof options === "function") {
|
|
2972
|
+
this.#pushUse({
|
|
2973
|
+
__rune: "rule",
|
|
2974
|
+
run: (value, field) => {
|
|
2975
|
+
if (typeof value !== "string") return;
|
|
2976
|
+
const countries = [options(field).countryCode].flat();
|
|
2977
|
+
if (!countries.some((c) => isPostalCode(value, c) === true)) {
|
|
2978
|
+
field.report(
|
|
2979
|
+
`Must be a valid ${countries.join("/")} postal code`,
|
|
2980
|
+
"postalCode",
|
|
2981
|
+
);
|
|
2982
|
+
}
|
|
2983
|
+
},
|
|
2984
|
+
});
|
|
2985
|
+
return this;
|
|
2986
|
+
}
|
|
2987
|
+
const countries = [options.countryCode].flat();
|
|
2988
|
+
for (const country of countries) {
|
|
2989
|
+
if (isPostalCode("", country) === null) {
|
|
2990
|
+
throw new RuneError(
|
|
2991
|
+
"UNSUPPORTED_COUNTRY",
|
|
2992
|
+
`postalCode(): no pattern for country '${country}'.`,
|
|
2993
|
+
{
|
|
2994
|
+
hint: `Supported: ${SUPPORTED_POSTAL_CODES.join(", ")}. Use .regex() for others.`,
|
|
2995
|
+
},
|
|
2996
|
+
);
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
return this.#stringRule(
|
|
3000
|
+
"postalCode",
|
|
3001
|
+
(v) => countries.some((c) => isPostalCode(v, c) === true),
|
|
3002
|
+
`Must be a valid ${countries.join("/").toUpperCase()} postal code`,
|
|
3003
|
+
{ countryCode: countries },
|
|
3004
|
+
);
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
/**
|
|
3008
|
+
* Must be a valid VAT number (VineJS 4.2 `vat`). Accepts a country list or a
|
|
3009
|
+
* callback resolving it per payload.
|
|
3010
|
+
*
|
|
3011
|
+
* Checksums are run where the country defines a short, well-defined one
|
|
3012
|
+
* (BE, DE, NL, IT, PT, LU, CH); the others are FORMAT-only, which is stated
|
|
3013
|
+
* rather than implied. An unknown country LEVES rather than accepting the
|
|
3014
|
+
* value unchecked.
|
|
3015
|
+
*/
|
|
3016
|
+
vat(options: VatOptions | ((field: FieldContext) => VatOptions)): this {
|
|
3017
|
+
if (typeof options === "function") {
|
|
3018
|
+
this.#pushUse({
|
|
3019
|
+
__rune: "rule",
|
|
3020
|
+
run: (value, field) => {
|
|
3021
|
+
if (typeof value !== "string") return;
|
|
3022
|
+
const countries = [options(field).countryCode].flat();
|
|
3023
|
+
if (!countries.some((c) => isVat(value, c) === true)) {
|
|
3024
|
+
field.report(
|
|
3025
|
+
`Must be a valid ${countries.join("/")} VAT number`,
|
|
3026
|
+
"vat",
|
|
3027
|
+
);
|
|
3028
|
+
}
|
|
3029
|
+
},
|
|
3030
|
+
});
|
|
3031
|
+
return this;
|
|
3032
|
+
}
|
|
3033
|
+
const countries = [options.countryCode].flat();
|
|
3034
|
+
for (const country of countries) {
|
|
3035
|
+
if (isVat("", country) === null) {
|
|
3036
|
+
throw new RuneError(
|
|
3037
|
+
"UNSUPPORTED_COUNTRY",
|
|
3038
|
+
`vat(): no rule for country '${country}'.`,
|
|
3039
|
+
{
|
|
3040
|
+
hint: `Supported: ${SUPPORTED_VAT_COUNTRIES.join(", ")}. Use .regex() for others.`,
|
|
3041
|
+
},
|
|
3042
|
+
);
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
return this.#stringRule(
|
|
3046
|
+
"vat",
|
|
3047
|
+
(v) => countries.some((c) => isVat(v, c) === true),
|
|
3048
|
+
`Must be a valid ${countries.join("/").toUpperCase()} VAT number`,
|
|
3049
|
+
{ countryCode: countries },
|
|
3050
|
+
);
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
/** Must differ from a sibling field (VineJS `notSameAs`). */
|
|
3054
|
+
notSameAs(otherField: string): this {
|
|
3055
|
+
const formats = this.#dateFormats;
|
|
3056
|
+
this.#pushUse({
|
|
3057
|
+
__rune: "rule",
|
|
3058
|
+
run: (value, field) => {
|
|
3059
|
+
const other = readSibling(field, otherField);
|
|
3060
|
+
if (formats !== null && value instanceof Date) {
|
|
3061
|
+
const parsed = parseDateValue(other, formats);
|
|
3062
|
+
if (parsed !== null && parsed.getTime() === value.getTime()) {
|
|
3063
|
+
field.report(`Must be different from ${otherField}`, "notSameAs");
|
|
3064
|
+
}
|
|
3065
|
+
return;
|
|
3066
|
+
}
|
|
3067
|
+
if (value === other) {
|
|
3068
|
+
field.report(`Must be different from ${otherField}`, "notSameAs");
|
|
3069
|
+
}
|
|
3070
|
+
},
|
|
3071
|
+
});
|
|
3072
|
+
return this;
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
/** Array items must be unique — optionally compared on `field` (VineJS `distinct`). */
|
|
3076
|
+
distinct(field?: string | string[]): this {
|
|
3077
|
+
this.#pushRule({
|
|
3078
|
+
name: "distinct",
|
|
3079
|
+
args: { field },
|
|
3080
|
+
validate: (v) => {
|
|
3081
|
+
if (!Array.isArray(v)) return false;
|
|
3082
|
+
const fieldList = field === undefined ? null : [field].flat();
|
|
3083
|
+
const keys: string[] = [];
|
|
3084
|
+
for (const item of v) {
|
|
3085
|
+
// VineJS ignores null/undefined items entirely: `[1, null, 2, null]`
|
|
3086
|
+
// is distinct. Serialising them would make the second one a
|
|
3087
|
+
// duplicate of the first.
|
|
3088
|
+
if (item === null || item === undefined) continue;
|
|
3089
|
+
if (fieldList === null) {
|
|
3090
|
+
keys.push(JSON.stringify(item));
|
|
3091
|
+
continue;
|
|
3092
|
+
}
|
|
3093
|
+
if (!isPlainObject(item)) continue;
|
|
3094
|
+
// VineJS skips an item missing the key(s): two absent values are
|
|
3095
|
+
// not a duplicate of each other.
|
|
3096
|
+
if (
|
|
3097
|
+
fieldList.some((k) => item[k] === undefined || item[k] === null)
|
|
3098
|
+
) {
|
|
3099
|
+
continue;
|
|
3100
|
+
}
|
|
3101
|
+
keys.push(JSON.stringify(fieldList.map((k) => item[k])));
|
|
3102
|
+
}
|
|
3103
|
+
return new Set(keys).size === keys.length;
|
|
3104
|
+
},
|
|
3105
|
+
message: field
|
|
3106
|
+
? `Items must have a unique ${field}`
|
|
3107
|
+
: "Items must be unique",
|
|
702
3108
|
});
|
|
703
3109
|
return this;
|
|
704
3110
|
}
|
|
705
3111
|
|
|
706
|
-
/**
|
|
707
|
-
|
|
708
|
-
this.#
|
|
709
|
-
name: "
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
validate: (v) => {
|
|
713
|
-
const len = sizedLength(v);
|
|
714
|
-
return len >= 0 && len <= n;
|
|
715
|
-
},
|
|
716
|
-
message: `Must not exceed ${n} characters`,
|
|
3112
|
+
/** Must be less than or equal to zero (VineJS `nonPositive`). */
|
|
3113
|
+
nonPositive(): this {
|
|
3114
|
+
this.#pushRule({
|
|
3115
|
+
name: "nonPositive",
|
|
3116
|
+
validate: (v) => typeof v === "number" && v <= 0,
|
|
3117
|
+
message: "Must be zero or negative",
|
|
717
3118
|
});
|
|
718
3119
|
return this;
|
|
719
3120
|
}
|
|
720
3121
|
|
|
721
|
-
/**
|
|
722
|
-
|
|
723
|
-
this.#
|
|
724
|
-
name: "
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
validate: (v) => sizedLength(v) === n,
|
|
728
|
-
message: `Must be exactly ${n} characters`,
|
|
3122
|
+
/** Array must hold at least one item (VineJS `notEmpty`). */
|
|
3123
|
+
notEmpty(): this {
|
|
3124
|
+
this.#pushRule({
|
|
3125
|
+
name: "notEmpty",
|
|
3126
|
+
validate: (v) => Array.isArray(v) && v.length > 0,
|
|
3127
|
+
message: "Must not be empty",
|
|
729
3128
|
});
|
|
730
3129
|
return this;
|
|
731
3130
|
}
|
|
732
3131
|
|
|
733
|
-
/**
|
|
734
|
-
|
|
735
|
-
this.#
|
|
736
|
-
name: "
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
3132
|
+
/** Drop `null`, `undefined` and `""` items before the item rules run. */
|
|
3133
|
+
compact(): this {
|
|
3134
|
+
this.#transforms.push({
|
|
3135
|
+
name: "compact",
|
|
3136
|
+
fn: (value) =>
|
|
3137
|
+
Array.isArray(value)
|
|
3138
|
+
? value.filter(
|
|
3139
|
+
(item) => item !== null && item !== undefined && item !== "",
|
|
3140
|
+
)
|
|
3141
|
+
: value,
|
|
742
3142
|
});
|
|
743
3143
|
return this;
|
|
744
3144
|
}
|
|
745
3145
|
|
|
746
|
-
/**
|
|
747
|
-
|
|
748
|
-
this.#
|
|
749
|
-
name: "
|
|
750
|
-
validate: (v) => typeof v === "
|
|
751
|
-
message: "
|
|
3146
|
+
/** Number must have no fractional part (VineJS `withoutDecimals`). */
|
|
3147
|
+
withoutDecimals(): this {
|
|
3148
|
+
this.#pushRule({
|
|
3149
|
+
name: "withoutDecimals",
|
|
3150
|
+
validate: (v) => typeof v === "number" && Number.isInteger(v),
|
|
3151
|
+
message: "Must not have decimals",
|
|
752
3152
|
});
|
|
753
3153
|
return this;
|
|
754
3154
|
}
|
|
755
3155
|
|
|
756
|
-
/**
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
3156
|
+
/** Shared body of the string-format rules: reject non-strings, then check. */
|
|
3157
|
+
#stringRule(
|
|
3158
|
+
name: string,
|
|
3159
|
+
check: (value: string) => boolean,
|
|
3160
|
+
message: string,
|
|
3161
|
+
args?: Record<string, unknown>,
|
|
3162
|
+
): this {
|
|
3163
|
+
this.#pushRule({
|
|
3164
|
+
name,
|
|
3165
|
+
args,
|
|
3166
|
+
validate: (v) => typeof v === "string" && check(v),
|
|
3167
|
+
message,
|
|
762
3168
|
});
|
|
763
3169
|
return this;
|
|
764
3170
|
}
|
|
765
3171
|
|
|
766
|
-
/** Must be a
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
3172
|
+
/** Must be a passport number for `countryCode`. Throws for an uncovered country. */
|
|
3173
|
+
passport(options: { countryCode: string | string[] }): this {
|
|
3174
|
+
const countries = [options.countryCode].flat();
|
|
3175
|
+
for (const country of countries) {
|
|
3176
|
+
if (isPassport("", country) === null) {
|
|
3177
|
+
throw new RuneError(
|
|
3178
|
+
"UNSUPPORTED_COUNTRY",
|
|
3179
|
+
`passport(): no pattern for country '${country}'.`,
|
|
3180
|
+
{
|
|
3181
|
+
hint: `Supported: ${SUPPORTED_PASSPORTS.join(", ")}. Use .regex() for others.`,
|
|
3182
|
+
},
|
|
3183
|
+
);
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
return this.#stringRule(
|
|
3187
|
+
"passport",
|
|
3188
|
+
(v) => countries.some((c) => isPassport(v, c) === true),
|
|
3189
|
+
`Must be a valid ${countries.join("/").toUpperCase()} passport number`,
|
|
3190
|
+
{ countryCode: countries },
|
|
3191
|
+
);
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
/** Lowercase the value (VineJS `toLowerCase`). */
|
|
3195
|
+
toLowerCase(): this {
|
|
3196
|
+
return this.#stringMutation("toLowerCase", (v) => v.toLowerCase());
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
/** Uppercase the value (VineJS `toUpperCase`). */
|
|
3200
|
+
toUpperCase(): this {
|
|
3201
|
+
return this.#stringMutation("toUpperCase", (v) => v.toUpperCase());
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
/**
|
|
3205
|
+
* VineJS `toCamelCase()`, on both shapes it exists for:
|
|
3206
|
+
*
|
|
3207
|
+
* - on an `object()` chain it camelCases the object's KEYS
|
|
3208
|
+
* (`VineObject.toCamelCase`);
|
|
3209
|
+
* - on any other chain it camelCases the string VALUE (`VineString`).
|
|
3210
|
+
*
|
|
3211
|
+
* One name, because Vine has one name. Dispatching on whether a nested shape
|
|
3212
|
+
* was declared is what keeps a transcribed validator behaving the same.
|
|
3213
|
+
*/
|
|
3214
|
+
toCamelCase(): this {
|
|
3215
|
+
if (this.#nestedSchema) {
|
|
3216
|
+
this.#camelCaseKeys = true;
|
|
3217
|
+
return this;
|
|
3218
|
+
}
|
|
3219
|
+
return this.#stringMutation("toCamelCase", toCamelCase);
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
/** HTML-escape `& < > " '` (VineJS `escape`). */
|
|
3223
|
+
escape(): this {
|
|
3224
|
+
return this.#stringMutation("escape", escapeHtml);
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
/** Normalise an email address (VineJS `normalizeEmail`). */
|
|
3228
|
+
normalizeEmail(options?: NormalizeEmailOptions): this {
|
|
3229
|
+
return this.#stringMutation("normalizeEmail", (v) =>
|
|
3230
|
+
normalizeEmail(v, options),
|
|
3231
|
+
);
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
/** Normalise a URL (VineJS `normalizeUrl`). */
|
|
3235
|
+
normalizeUrl(options?: NormalizeUrlOptions): this {
|
|
3236
|
+
return this.#stringMutation("normalizeUrl", (v) =>
|
|
3237
|
+
normalizeUrl(v, options),
|
|
3238
|
+
);
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
/** Shared body of the string mutations — non-strings pass through untouched. */
|
|
3242
|
+
#stringMutation(name: string, fn: (value: string) => string): this {
|
|
3243
|
+
this.#transforms.push({
|
|
3244
|
+
name,
|
|
3245
|
+
fn: (value) => (typeof value === "string" ? fn(value) : value),
|
|
772
3246
|
});
|
|
773
3247
|
return this;
|
|
774
3248
|
}
|
|
775
3249
|
|
|
776
3250
|
/** Must contain only ASCII letters. */
|
|
777
|
-
alpha(): this {
|
|
778
|
-
|
|
3251
|
+
alpha(options?: AlphaOptions): this {
|
|
3252
|
+
const pattern = alphaPattern("a-zA-Z", options);
|
|
3253
|
+
this.#pushRule({
|
|
779
3254
|
name: "alpha",
|
|
780
|
-
|
|
781
|
-
|
|
3255
|
+
args: options ? { ...options } : undefined,
|
|
3256
|
+
validate: (v) => typeof v === "string" && v.length > 0 && pattern.test(v),
|
|
782
3257
|
message: "Must contain only letters",
|
|
783
3258
|
});
|
|
784
3259
|
return this;
|
|
785
3260
|
}
|
|
786
3261
|
|
|
787
3262
|
/** Must contain only ASCII letters and digits. */
|
|
788
|
-
alphaNumeric(): this {
|
|
789
|
-
|
|
3263
|
+
alphaNumeric(options?: AlphaOptions): this {
|
|
3264
|
+
const pattern = alphaPattern("a-zA-Z0-9", options);
|
|
3265
|
+
this.#pushRule({
|
|
790
3266
|
name: "alphaNumeric",
|
|
791
|
-
|
|
792
|
-
|
|
3267
|
+
args: options ? { ...options } : undefined,
|
|
3268
|
+
validate: (v) => typeof v === "string" && v.length > 0 && pattern.test(v),
|
|
793
3269
|
message: "Must contain only letters and numbers",
|
|
794
3270
|
});
|
|
795
3271
|
return this;
|
|
@@ -797,7 +3273,7 @@ export class RuleChain<Output = unknown> {
|
|
|
797
3273
|
|
|
798
3274
|
/** String must start with `substring`. */
|
|
799
3275
|
startsWith(substring: string): this {
|
|
800
|
-
this.#
|
|
3276
|
+
this.#pushRule({
|
|
801
3277
|
name: "startsWith",
|
|
802
3278
|
args: { substring },
|
|
803
3279
|
validate: (v) => typeof v === "string" && v.startsWith(substring),
|
|
@@ -808,7 +3284,7 @@ export class RuleChain<Output = unknown> {
|
|
|
808
3284
|
|
|
809
3285
|
/** String must end with `substring`. */
|
|
810
3286
|
endsWith(substring: string): this {
|
|
811
|
-
this.#
|
|
3287
|
+
this.#pushRule({
|
|
812
3288
|
name: "endsWith",
|
|
813
3289
|
args: { substring },
|
|
814
3290
|
validate: (v) => typeof v === "string" && v.endsWith(substring),
|
|
@@ -817,25 +3293,35 @@ export class RuleChain<Output = unknown> {
|
|
|
817
3293
|
return this;
|
|
818
3294
|
}
|
|
819
3295
|
|
|
820
|
-
/**
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
3296
|
+
/**
|
|
3297
|
+
* Value must be one of `values`.
|
|
3298
|
+
*
|
|
3299
|
+
* VineJS also accepts a callback so the list can be computed at validation
|
|
3300
|
+
* time (tenant-scoped roles, values read from config…). A static array is
|
|
3301
|
+
* snapshotted; a callback is invoked on every check.
|
|
3302
|
+
*/
|
|
3303
|
+
in(values: AllowedValues): this {
|
|
3304
|
+
const resolve = allowedValuesResolver(values);
|
|
3305
|
+
this.#pushRule({
|
|
824
3306
|
name: "in",
|
|
825
|
-
args: { values:
|
|
826
|
-
|
|
3307
|
+
args: typeof values === "function" ? {} : { values: [...values] },
|
|
3308
|
+
// A callback list is computed per call — the native engine only ever
|
|
3309
|
+
// sees a static array, so it must not run this rule.
|
|
3310
|
+
tsOnly: typeof values === "function",
|
|
3311
|
+
validate: (v) => resolve().includes(asPrimitive(v)),
|
|
827
3312
|
message: "Invalid value",
|
|
828
3313
|
});
|
|
829
3314
|
return this;
|
|
830
3315
|
}
|
|
831
3316
|
|
|
832
3317
|
/** Value must NOT be one of `values`. */
|
|
833
|
-
notIn(values:
|
|
834
|
-
const
|
|
835
|
-
this.#
|
|
3318
|
+
notIn(values: AllowedValues): this {
|
|
3319
|
+
const resolve = allowedValuesResolver(values);
|
|
3320
|
+
this.#pushRule({
|
|
836
3321
|
name: "notIn",
|
|
837
|
-
args: { values:
|
|
838
|
-
|
|
3322
|
+
args: typeof values === "function" ? {} : { values: [...values] },
|
|
3323
|
+
tsOnly: typeof values === "function",
|
|
3324
|
+
validate: (v) => !resolve().includes(asPrimitive(v)),
|
|
839
3325
|
message: "Invalid value",
|
|
840
3326
|
});
|
|
841
3327
|
return this;
|
|
@@ -843,7 +3329,7 @@ export class RuleChain<Output = unknown> {
|
|
|
843
3329
|
|
|
844
3330
|
/** Number must be positive (> 0) and finite. */
|
|
845
3331
|
positive(): this {
|
|
846
|
-
this.#
|
|
3332
|
+
this.#pushRule({
|
|
847
3333
|
name: "positive",
|
|
848
3334
|
validate: (v) => typeof v === "number" && Number.isFinite(v) && v > 0,
|
|
849
3335
|
message: "Must be positive",
|
|
@@ -853,7 +3339,7 @@ export class RuleChain<Output = unknown> {
|
|
|
853
3339
|
|
|
854
3340
|
/** Number must be negative (< 0) and finite. */
|
|
855
3341
|
negative(): this {
|
|
856
|
-
this.#
|
|
3342
|
+
this.#pushRule({
|
|
857
3343
|
name: "negative",
|
|
858
3344
|
validate: (v) => typeof v === "number" && Number.isFinite(v) && v < 0,
|
|
859
3345
|
message: "Must be negative",
|
|
@@ -863,7 +3349,7 @@ export class RuleChain<Output = unknown> {
|
|
|
863
3349
|
|
|
864
3350
|
/** Number must be >= 0 and finite. */
|
|
865
3351
|
nonNegative(): this {
|
|
866
|
-
this.#
|
|
3352
|
+
this.#pushRule({
|
|
867
3353
|
name: "nonNegative",
|
|
868
3354
|
validate: (v) => typeof v === "number" && Number.isFinite(v) && v >= 0,
|
|
869
3355
|
message: "Must be positive or zero",
|
|
@@ -872,38 +3358,55 @@ export class RuleChain<Output = unknown> {
|
|
|
872
3358
|
}
|
|
873
3359
|
|
|
874
3360
|
/** Number must fall within `[min, max]` (inclusive). */
|
|
875
|
-
range(min: number, max: number): this {
|
|
876
|
-
|
|
3361
|
+
range(bounds: [min: number, max: number]): this {
|
|
3362
|
+
// VineJS signature is a TUPLE (`range([18, 60])`); the two-argument form
|
|
3363
|
+
// silently dropped `max` when an Adonis validator was transcribed as-is.
|
|
3364
|
+
const [min, max] = bounds;
|
|
3365
|
+
this.#pushRule({
|
|
877
3366
|
name: "range",
|
|
878
3367
|
args: { min, max },
|
|
879
|
-
validate: (v) =>
|
|
880
|
-
typeof v === "number" && Number.isFinite(v) && v >= min && v <= max,
|
|
3368
|
+
validate: (v) => typeof v === "number" && v >= min && v <= max,
|
|
881
3369
|
message: `Must be between ${min} and ${max}`,
|
|
882
3370
|
});
|
|
883
3371
|
return this;
|
|
884
3372
|
}
|
|
885
3373
|
|
|
886
3374
|
/** Number must have at most `digits` decimal places (TS-only). */
|
|
887
|
-
decimal(digits: number): this {
|
|
888
|
-
|
|
3375
|
+
decimal(digits: number | [number, number]): this {
|
|
3376
|
+
// VineJS accepts a `[min, max]` range as well as a single maximum.
|
|
3377
|
+
const [min, max] = Array.isArray(digits) ? digits : [0, digits];
|
|
3378
|
+
this.#pushRule({
|
|
889
3379
|
name: "decimal",
|
|
890
3380
|
args: { digits },
|
|
891
3381
|
validate: (v) => {
|
|
892
3382
|
if (typeof v !== "number" || !Number.isFinite(v)) return false;
|
|
893
|
-
const
|
|
894
|
-
return
|
|
3383
|
+
const places = String(v).split(".")[1]?.length ?? 0;
|
|
3384
|
+
return places >= min && places <= max;
|
|
895
3385
|
},
|
|
896
|
-
message:
|
|
3386
|
+
message: Array.isArray(digits)
|
|
3387
|
+
? `Must have between ${min} and ${max} decimal places`
|
|
3388
|
+
: `Must have at most ${max} decimal places`,
|
|
897
3389
|
});
|
|
898
3390
|
return this;
|
|
899
3391
|
}
|
|
900
3392
|
|
|
901
3393
|
/** Must equal a sibling field (VineJS `sameAs`). Cross-field → TS-only. */
|
|
902
3394
|
sameAs(otherField: string): this {
|
|
903
|
-
this.#
|
|
3395
|
+
const formats = this.#dateFormats;
|
|
3396
|
+
this.#pushUse({
|
|
904
3397
|
__rune: "rule",
|
|
905
3398
|
run: (value, field) => {
|
|
906
3399
|
const other = readSibling(field, otherField);
|
|
3400
|
+
// On a date chain the value is a parsed `Date` and the sibling is
|
|
3401
|
+
// still raw, so `!==` would compare a Date to a string and always
|
|
3402
|
+
// fail. Compare instants instead.
|
|
3403
|
+
if (formats !== null && value instanceof Date) {
|
|
3404
|
+
const parsed = parseDateValue(other, formats);
|
|
3405
|
+
if (parsed === null || parsed.getTime() !== value.getTime()) {
|
|
3406
|
+
field.report(`Must match ${otherField}`, "sameAs");
|
|
3407
|
+
}
|
|
3408
|
+
return;
|
|
3409
|
+
}
|
|
907
3410
|
if (value !== other) {
|
|
908
3411
|
field.report(`Must match ${otherField}`, "sameAs");
|
|
909
3412
|
}
|
|
@@ -913,14 +3416,24 @@ export class RuleChain<Output = unknown> {
|
|
|
913
3416
|
}
|
|
914
3417
|
|
|
915
3418
|
/** Must equal its `<field>_confirmation` sibling (VineJS `confirmed`). */
|
|
916
|
-
confirmed(options?: { confirmationField?: string }): this {
|
|
917
|
-
this.#
|
|
3419
|
+
confirmed(options?: { as?: string; confirmationField?: string }): this {
|
|
3420
|
+
this.#pushUse({
|
|
918
3421
|
__rune: "rule",
|
|
919
3422
|
run: (value, field) => {
|
|
920
3423
|
const leaf = field.field.split(".").pop() ?? field.field;
|
|
921
|
-
|
|
3424
|
+
// `as` is the current VineJS spelling; `confirmationField` is its
|
|
3425
|
+
// deprecated alias, kept so existing callers keep working.
|
|
3426
|
+
const other =
|
|
3427
|
+
options?.as ?? options?.confirmationField ?? `${leaf}_confirmation`;
|
|
922
3428
|
if (value !== readSibling(field, other)) {
|
|
923
|
-
field
|
|
3429
|
+
// VineJS reports on the CONFIRMATION field: that is the input the
|
|
3430
|
+
// user has to fix, and where a form renders the message.
|
|
3431
|
+
const prefix = field.field.slice(0, -leaf.length);
|
|
3432
|
+
field.report(
|
|
3433
|
+
"Confirmation does not match",
|
|
3434
|
+
"confirmed",
|
|
3435
|
+
`${prefix}${other}`,
|
|
3436
|
+
);
|
|
924
3437
|
}
|
|
925
3438
|
},
|
|
926
3439
|
});
|
|
@@ -975,7 +3488,7 @@ export class RuleChain<Output = unknown> {
|
|
|
975
3488
|
}
|
|
976
3489
|
|
|
977
3490
|
/** Pre-validation transform of the raw input (VineJS `parse`). */
|
|
978
|
-
parse(fn: (value: unknown) => unknown): this {
|
|
3491
|
+
parse(fn: (value: unknown, ctx: ParseContext) => unknown): this {
|
|
979
3492
|
this.#preTransforms.push(fn);
|
|
980
3493
|
return this;
|
|
981
3494
|
}
|
|
@@ -986,7 +3499,7 @@ export class RuleChain<Output = unknown> {
|
|
|
986
3499
|
validate: (value: unknown) => boolean,
|
|
987
3500
|
message?: string,
|
|
988
3501
|
): this {
|
|
989
|
-
this.#
|
|
3502
|
+
this.#pushRule({
|
|
990
3503
|
name,
|
|
991
3504
|
validate,
|
|
992
3505
|
message: message ?? `Failed custom rule: ${name}`,
|
|
@@ -999,7 +3512,12 @@ export class RuleChain<Output = unknown> {
|
|
|
999
3512
|
* receives a {@link FieldContext} with the root `data` and `parent`, so it can
|
|
1000
3513
|
* validate across fields. Runs after this field's type/value rules.
|
|
1001
3514
|
*/
|
|
1002
|
-
use(rule: CompiledRule): this {
|
|
3515
|
+
use(rule: CompiledRule | AsyncCompiledRule): this {
|
|
3516
|
+
// A rule built with `{ isAsync: true }` arrives here (VineJS has one
|
|
3517
|
+
// `use`); routing it to the sync register would drop the await.
|
|
3518
|
+
if (rule.__rune === "asyncRule") {
|
|
3519
|
+
return this.useAsync(rule);
|
|
3520
|
+
}
|
|
1003
3521
|
if (rule?.__rune !== "rule" || typeof rule.run !== "function") {
|
|
1004
3522
|
throw new RuneError(
|
|
1005
3523
|
"INVALID_RULE",
|
|
@@ -1007,21 +3525,243 @@ export class RuleChain<Output = unknown> {
|
|
|
1007
3525
|
{ hint: "use(myRule()) or use(myRule(options)), not use(myRule)" },
|
|
1008
3526
|
);
|
|
1009
3527
|
}
|
|
1010
|
-
this.#
|
|
3528
|
+
this.#pushUse(rule);
|
|
3529
|
+
return this;
|
|
3530
|
+
}
|
|
3531
|
+
|
|
3532
|
+
/**
|
|
3533
|
+
* Attach an async rule (from {@link createAsyncRule}). The schema must then be
|
|
3534
|
+
* run with `validateResultAsync` — sync `validate()` throws for such a schema.
|
|
3535
|
+
*/
|
|
3536
|
+
useAsync(rule: AsyncCompiledRule): this {
|
|
3537
|
+
if (rule?.__rune !== "asyncRule" || typeof rule.run !== "function") {
|
|
3538
|
+
throw new RuneError(
|
|
3539
|
+
"INVALID_RULE",
|
|
3540
|
+
"useAsync() expects a compiled async rule — call the factory first",
|
|
3541
|
+
{ hint: "useAsync(myRule()) or useAsync(myRule(options))" },
|
|
3542
|
+
);
|
|
3543
|
+
}
|
|
3544
|
+
this.#pushAsync(rule);
|
|
3545
|
+
return this;
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
/**
|
|
3549
|
+
* DB-backed uniqueness rule (Adonis Lucid `unique`). `check(value, field)`
|
|
3550
|
+
* resolves `true` when the value is unique (valid). rune stays agnostic — the
|
|
3551
|
+
* check does the query (e.g. against atlas). Requires the async path (`validateResultAsync` / `validate`).
|
|
3552
|
+
*
|
|
3553
|
+
* rules.string().email().unique(async (value) => {
|
|
3554
|
+
* const row = await db.from('users').where('email', value).first()
|
|
3555
|
+
* return !row
|
|
3556
|
+
* })
|
|
3557
|
+
*/
|
|
3558
|
+
unique(
|
|
3559
|
+
check: (value: unknown, field: FieldContext) => boolean | Promise<boolean>,
|
|
3560
|
+
message?: string,
|
|
3561
|
+
): this;
|
|
3562
|
+
unique(options: DatabaseRuleOptions, message?: string): this;
|
|
3563
|
+
unique(
|
|
3564
|
+
checkOrOptions:
|
|
3565
|
+
| ((value: unknown, field: FieldContext) => boolean | Promise<boolean>)
|
|
3566
|
+
| DatabaseRuleOptions,
|
|
3567
|
+
message?: string,
|
|
3568
|
+
): this {
|
|
3569
|
+
const check = toDatabaseCheck(checkOrOptions, "unique");
|
|
3570
|
+
this.#pushAsync({
|
|
3571
|
+
__rune: "asyncRule",
|
|
3572
|
+
async run(value: unknown, field: FieldContext): Promise<void> {
|
|
3573
|
+
const ok = await check(value, field);
|
|
3574
|
+
if (!ok) {
|
|
3575
|
+
field.report(
|
|
3576
|
+
message ?? `The ${field.field} has already been taken`,
|
|
3577
|
+
"database.unique",
|
|
3578
|
+
);
|
|
3579
|
+
}
|
|
3580
|
+
},
|
|
3581
|
+
});
|
|
3582
|
+
return this;
|
|
3583
|
+
}
|
|
3584
|
+
|
|
3585
|
+
/**
|
|
3586
|
+
* DB-backed existence rule (Adonis Lucid `exists`). `check(value, field)`
|
|
3587
|
+
* resolves `true` when a matching row exists (valid). Requires the async path (`validateResultAsync` / `validate`).
|
|
3588
|
+
*/
|
|
3589
|
+
exists(
|
|
3590
|
+
check: (value: unknown, field: FieldContext) => boolean | Promise<boolean>,
|
|
3591
|
+
message?: string,
|
|
3592
|
+
): this;
|
|
3593
|
+
exists(options: DatabaseRuleOptions, message?: string): this;
|
|
3594
|
+
exists(
|
|
3595
|
+
checkOrOptions:
|
|
3596
|
+
| ((value: unknown, field: FieldContext) => boolean | Promise<boolean>)
|
|
3597
|
+
| DatabaseRuleOptions,
|
|
3598
|
+
message?: string,
|
|
3599
|
+
): this {
|
|
3600
|
+
const check = toDatabaseCheck(checkOrOptions, "exists");
|
|
3601
|
+
this.#pushAsync({
|
|
3602
|
+
__rune: "asyncRule",
|
|
3603
|
+
async run(value: unknown, field: FieldContext): Promise<void> {
|
|
3604
|
+
const ok = await check(value, field);
|
|
3605
|
+
if (!ok) {
|
|
3606
|
+
field.report(
|
|
3607
|
+
message ?? `The selected ${field.field} is invalid`,
|
|
3608
|
+
"database.exists",
|
|
3609
|
+
);
|
|
3610
|
+
}
|
|
3611
|
+
},
|
|
3612
|
+
});
|
|
3613
|
+
return this;
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
/**
|
|
3617
|
+
* Attach free-form JSON Schema metadata (VineJS `meta()`) — `title`,
|
|
3618
|
+
* `description`, `examples`, `deprecated`… Merged verbatim into the field's
|
|
3619
|
+
* node by `toJSONSchema()`.
|
|
3620
|
+
*/
|
|
3621
|
+
meta(metadata: Record<string, unknown>): this {
|
|
3622
|
+
this.#metadata = { ...this.#metadata, ...metadata };
|
|
1011
3623
|
return this;
|
|
1012
3624
|
}
|
|
1013
3625
|
|
|
1014
|
-
/**
|
|
3626
|
+
/**
|
|
3627
|
+
* Set a custom error message for the rule that was just added.
|
|
3628
|
+
*
|
|
3629
|
+
* "The last rule" spans all three registers: value rules (`#rules`),
|
|
3630
|
+
* cross-field `.use()` rules (`sameAs`, `confirmed`, `afterField`,
|
|
3631
|
+
* `notSameAs`) and async rules (`unique`, `exists`, `useAsync`). Targeting
|
|
3632
|
+
* `#rules` alone silently retargeted the PREVIOUS value rule — or threw
|
|
3633
|
+
* `NO_RULE` — whenever the preceding call was a cross-field or async rule.
|
|
3634
|
+
*/
|
|
1015
3635
|
message(msg: string): this {
|
|
1016
|
-
|
|
3636
|
+
const target = this.#lastRule;
|
|
3637
|
+
if (!target) {
|
|
1017
3638
|
throw new RuneError("NO_RULE", "message() must be called after a rule");
|
|
1018
3639
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
3640
|
+
if (target.kind === "value") {
|
|
3641
|
+
target.ref.message = msg;
|
|
3642
|
+
target.ref.hasCustomMessage = true;
|
|
3643
|
+
} else {
|
|
3644
|
+
// `.use()` / async rules report their own text from inside `run`, so the
|
|
3645
|
+
// override is applied when the rule reports rather than stored on it.
|
|
3646
|
+
this.#ruleMessages.set(target.ref, msg);
|
|
3647
|
+
}
|
|
1022
3648
|
return this;
|
|
1023
3649
|
}
|
|
1024
3650
|
|
|
3651
|
+
/**
|
|
3652
|
+
* Build the {@link FieldContext} handed to `.use()` / async rules. Shared so
|
|
3653
|
+
* the sync and async paths cannot drift on what a rule can see.
|
|
3654
|
+
*/
|
|
3655
|
+
#makeFieldContext(
|
|
3656
|
+
field: string,
|
|
3657
|
+
value: unknown,
|
|
3658
|
+
ctx: RunContext,
|
|
3659
|
+
errors: ValidationError[],
|
|
3660
|
+
onMutate: (next: unknown) => void,
|
|
3661
|
+
): FieldContext {
|
|
3662
|
+
const segments = field.split(".");
|
|
3663
|
+
return {
|
|
3664
|
+
value,
|
|
3665
|
+
data: ctx.data,
|
|
3666
|
+
parent: ctx.parent,
|
|
3667
|
+
field,
|
|
3668
|
+
meta: ctx.meta,
|
|
3669
|
+
isValid: errors.length === 0,
|
|
3670
|
+
name: segments[segments.length - 1] ?? field,
|
|
3671
|
+
wildCardPath: toWildcardPath(field),
|
|
3672
|
+
isArrayMember: Array.isArray(ctx.parent),
|
|
3673
|
+
isDefined: value !== undefined && value !== null,
|
|
3674
|
+
isValidDataType: errors.length === 0,
|
|
3675
|
+
getFieldPath: () => field,
|
|
3676
|
+
mutate: onMutate,
|
|
3677
|
+
report(
|
|
3678
|
+
message: string,
|
|
3679
|
+
rule: string,
|
|
3680
|
+
reportedField?: string | FieldContext,
|
|
3681
|
+
args?: Record<string, unknown>,
|
|
3682
|
+
): void {
|
|
3683
|
+
// VineJS plugins pass the FIELD CONTEXT here, not a path. Accepting
|
|
3684
|
+
// only a string let the object through and produced a
|
|
3685
|
+
// `ValidationError.field` that was not a string at runtime.
|
|
3686
|
+
const target =
|
|
3687
|
+
typeof reportedField === "string"
|
|
3688
|
+
? reportedField
|
|
3689
|
+
: (reportedField?.getFieldPath() ?? field);
|
|
3690
|
+
errors.push({
|
|
3691
|
+
field: target,
|
|
3692
|
+
rule,
|
|
3693
|
+
message,
|
|
3694
|
+
...(args ? { meta: args } : {}),
|
|
3695
|
+
});
|
|
3696
|
+
},
|
|
3697
|
+
};
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
/**
|
|
3701
|
+
* Run only the implicit `.use()` rules against an absent value. A rule
|
|
3702
|
+
* declared `{ implicit: true }` exists to police `undefined`/`null`, so the
|
|
3703
|
+
* early return for optional fields must not skip it.
|
|
3704
|
+
*/
|
|
3705
|
+
#runImplicitRules(
|
|
3706
|
+
field: string,
|
|
3707
|
+
value: unknown,
|
|
3708
|
+
ctx: RunContext,
|
|
3709
|
+
pending?: PendingAsync[],
|
|
3710
|
+
): ValidationError[] {
|
|
3711
|
+
// An implicit ASYNC rule polices an absent value too, so it has to be
|
|
3712
|
+
// queued here as well — filtering `#useRules` alone dropped it silently.
|
|
3713
|
+
if (pending && this.#asyncRules.some((rule) => rule.implicit)) {
|
|
3714
|
+
pending.push({ chain: this, field, value, ctx });
|
|
3715
|
+
}
|
|
3716
|
+
const implicitRules = this.#useRules.filter((rule) => rule.implicit);
|
|
3717
|
+
if (implicitRules.length === 0) return [];
|
|
3718
|
+
const errors: ValidationError[] = [];
|
|
3719
|
+
const fieldCtx = this.#makeFieldContext(
|
|
3720
|
+
field,
|
|
3721
|
+
value,
|
|
3722
|
+
ctx,
|
|
3723
|
+
errors,
|
|
3724
|
+
() => {},
|
|
3725
|
+
);
|
|
3726
|
+
for (const rule of implicitRules) {
|
|
3727
|
+
fieldCtx.isValid = errors.length === 0;
|
|
3728
|
+
rule.run(value, fieldCtx);
|
|
3729
|
+
}
|
|
3730
|
+
return errors;
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
/**
|
|
3734
|
+
* Register a TYPE rule from outside the chain — used by the `optional()` and
|
|
3735
|
+
* `null()` factories, which are types in their own right.
|
|
3736
|
+
* @internal
|
|
3737
|
+
*/
|
|
3738
|
+
pushTypeRule(rule: RuleDef): void {
|
|
3739
|
+
this.#pushRule(rule);
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
/** Re-type this chain in place, without cloning. @internal */
|
|
3743
|
+
retypeTo<U>(): RuleChain<U> {
|
|
3744
|
+
return this.#retype<U>();
|
|
3745
|
+
}
|
|
3746
|
+
|
|
3747
|
+
/** Add a value rule and remember it as the `message()` target. */
|
|
3748
|
+
#pushRule(rule: RuleDef): void {
|
|
3749
|
+
this.#rules.push(rule);
|
|
3750
|
+
this.#lastRule = { kind: "value", ref: rule };
|
|
3751
|
+
}
|
|
3752
|
+
|
|
3753
|
+
/** Add a cross-field `.use()` rule and remember it as the `message()` target. */
|
|
3754
|
+
#pushUse(rule: CompiledRule): void {
|
|
3755
|
+
this.#useRules.push(rule);
|
|
3756
|
+
this.#lastRule = { kind: "reporting", ref: rule };
|
|
3757
|
+
}
|
|
3758
|
+
|
|
3759
|
+
/** Add an async rule and remember it as the `message()` target. */
|
|
3760
|
+
#pushAsync(rule: AsyncCompiledRule): void {
|
|
3761
|
+
this.#asyncRules.push(rule);
|
|
3762
|
+
this.#lastRule = { kind: "reporting", ref: rule };
|
|
3763
|
+
}
|
|
3764
|
+
|
|
1025
3765
|
/** Whether the field is required given the surrounding data (conditionals). */
|
|
1026
3766
|
#isRequired(ctx: RunContext): boolean {
|
|
1027
3767
|
if (this.#requiredConditions.length === 0) return true;
|
|
@@ -1030,36 +3770,68 @@ export class RuleChain<Output = unknown> {
|
|
|
1030
3770
|
);
|
|
1031
3771
|
}
|
|
1032
3772
|
|
|
1033
|
-
/**
|
|
3773
|
+
/**
|
|
3774
|
+
* Internal: validate a field value and return errors + transformed value.
|
|
3775
|
+
*
|
|
3776
|
+
* `pending` is the async-rule collector. The traversal itself stays sync (it
|
|
3777
|
+
* is shared with `validate()`); when a collector is supplied, every chain in
|
|
3778
|
+
* the tree that carries async rules and passed its sync rules records itself
|
|
3779
|
+
* for the async path to await. Without it, nested async rules never ran.
|
|
3780
|
+
*/
|
|
1034
3781
|
_validateWithTransform(
|
|
1035
3782
|
field: string,
|
|
1036
3783
|
rawValue: unknown,
|
|
1037
3784
|
ctx: RunContext = EMPTY_RUN_CONTEXT,
|
|
3785
|
+
pending?: PendingAsync[],
|
|
1038
3786
|
): { errors: ValidationError[]; transformed: unknown } {
|
|
1039
|
-
// 0. Pre-validation parse() transforms run on the raw value first.
|
|
3787
|
+
// 0. Pre-validation parse() transforms run on the raw value first. VineJS
|
|
3788
|
+
// hands them `(value, { data, parent, meta })` — without the context a
|
|
3789
|
+
// parser cannot look at a sibling, which is half its purpose.
|
|
1040
3790
|
let value = rawValue;
|
|
3791
|
+
const parseCtx: ParseContext = {
|
|
3792
|
+
data: ctx.data,
|
|
3793
|
+
parent: ctx.parent,
|
|
3794
|
+
meta: ctx.meta,
|
|
3795
|
+
};
|
|
1041
3796
|
for (const pre of this.#preTransforms) {
|
|
1042
|
-
value = pre(value);
|
|
3797
|
+
value = pre(value, parseCtx);
|
|
1043
3798
|
}
|
|
1044
3799
|
|
|
1045
3800
|
if (value === undefined) {
|
|
1046
3801
|
if (this.#isOptional || !this.#isRequired(ctx)) {
|
|
1047
|
-
|
|
3802
|
+
// Implicit rules are precisely the ones that must see an absent value.
|
|
3803
|
+
return {
|
|
3804
|
+
errors: this.#runImplicitRules(field, value, ctx, pending),
|
|
3805
|
+
transformed: value,
|
|
3806
|
+
};
|
|
1048
3807
|
}
|
|
1049
3808
|
return { errors: [this.#requiredError(field, ctx)], transformed: value };
|
|
1050
3809
|
}
|
|
1051
3810
|
if (value === null) {
|
|
1052
|
-
//
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
3811
|
+
// VineJS split, now matched exactly: `nullable()` accepts null AND keeps
|
|
3812
|
+
// it in the output; `optional()` accepts null but DROPS the key. rune
|
|
3813
|
+
// used to keep null in both cases, so an optional field silently added
|
|
3814
|
+
// `key: null` to a payload VineJS would have left without the key.
|
|
3815
|
+
if (this.#isNullable) {
|
|
3816
|
+
return {
|
|
3817
|
+
errors: this.#runImplicitRules(field, value, ctx, pending),
|
|
3818
|
+
transformed: value,
|
|
3819
|
+
};
|
|
3820
|
+
}
|
|
3821
|
+
if (this.#isOptional || !this.#isRequired(ctx)) {
|
|
3822
|
+
return {
|
|
3823
|
+
errors: this.#runImplicitRules(field, value, ctx, pending),
|
|
3824
|
+
transformed: undefined,
|
|
3825
|
+
};
|
|
1059
3826
|
}
|
|
1060
3827
|
return { errors: [this.#requiredError(field, ctx)], transformed: value };
|
|
1061
3828
|
}
|
|
1062
3829
|
|
|
3830
|
+
// 0b. Coerce before the type rules — a coerced value is the validated value.
|
|
3831
|
+
for (const coerce of this.#coercions) {
|
|
3832
|
+
value = coerce(value);
|
|
3833
|
+
}
|
|
3834
|
+
|
|
1063
3835
|
// 1. Type rules first on the raw value — bail on type mismatch.
|
|
1064
3836
|
const typeError = this.#runTypeRules(field, value, ctx);
|
|
1065
3837
|
if (typeError) return { errors: [typeError], transformed: value };
|
|
@@ -1071,24 +3843,162 @@ export class RuleChain<Output = unknown> {
|
|
|
1071
3843
|
// 3. Vine-style .use() rules — run with a FieldContext exposing the root
|
|
1072
3844
|
// `data` and `parent`, so a rule can validate across fields.
|
|
1073
3845
|
if (this.#useRules.length > 0 && !(this.#bail && errors.length > 0)) {
|
|
1074
|
-
|
|
3846
|
+
// `.use()` rules may call `field.mutate()`, so the value can change here.
|
|
3847
|
+
transformed = this.#runUseRules(field, transformed, ctx, errors);
|
|
3848
|
+
}
|
|
3849
|
+
|
|
3850
|
+
// 3b. Date output mapping (VineJS `VineDate.transform`). Deliberately AFTER
|
|
3851
|
+
// the comparison rules so `after`/`before`/`afterField` always see a
|
|
3852
|
+
// real `Date`, whatever type the consumer maps it to.
|
|
3853
|
+
if (
|
|
3854
|
+
this.#dateFormats !== null &&
|
|
3855
|
+
dateOutputTransform !== null &&
|
|
3856
|
+
transformed instanceof Date
|
|
3857
|
+
) {
|
|
3858
|
+
transformed = dateOutputTransform(transformed);
|
|
1075
3859
|
}
|
|
1076
3860
|
|
|
1077
3861
|
// 4. Nested object validation (only if type check passed — not arrays)
|
|
1078
3862
|
if (this.#nestedSchema && isPlainObject(transformed)) {
|
|
1079
|
-
|
|
3863
|
+
// Start from the DECLARED keys only. Spreading the input kept every
|
|
3864
|
+
// undeclared key, so the mass-assignment guarantee that holds at the
|
|
3865
|
+
// top level silently stopped holding one level down:
|
|
3866
|
+
// `object({ name })` let an `isAdmin` through. `allowUnknownProperties()`
|
|
3867
|
+
// is the opt-in, as in VineJS.
|
|
3868
|
+
const source: Record<string, unknown> = transformed;
|
|
3869
|
+
const obj: Record<string, unknown> = this.#allowUnknown
|
|
3870
|
+
? { ...source }
|
|
3871
|
+
: {};
|
|
1080
3872
|
transformed = obj;
|
|
1081
|
-
|
|
3873
|
+
// A conditional group contributes its branch's properties for THIS
|
|
3874
|
+
// payload, so the shape is resolved per validation, not at build time.
|
|
3875
|
+
let shape = this.#nestedSchema;
|
|
3876
|
+
for (const grp of this.#groups) {
|
|
3877
|
+
const branch =
|
|
3878
|
+
grp.branches.find(
|
|
3879
|
+
(candidate) => candidate.predicate?.(source) === true,
|
|
3880
|
+
) ?? grp.branches.find((candidate) => candidate.predicate === null);
|
|
3881
|
+
if (branch) shape = { ...shape, ...branch.shape };
|
|
3882
|
+
}
|
|
3883
|
+
for (const [nestedField, chain] of Object.entries(shape)) {
|
|
1082
3884
|
const nestedResult = chain._validateWithTransform(
|
|
1083
3885
|
`${field}.${nestedField}`,
|
|
1084
|
-
|
|
1085
|
-
{ ...ctx, parent:
|
|
3886
|
+
source[nestedField],
|
|
3887
|
+
{ ...ctx, parent: source },
|
|
3888
|
+
pending,
|
|
1086
3889
|
);
|
|
1087
3890
|
errors.push(...nestedResult.errors);
|
|
1088
3891
|
if (nestedResult.transformed !== undefined) {
|
|
1089
|
-
obj[nestedField] =
|
|
3892
|
+
obj[this.#camelCaseKeys ? toCamelCaseKey(nestedField) : nestedField] =
|
|
3893
|
+
nestedResult.transformed;
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3896
|
+
if (this.#camelCaseKeys && this.#allowUnknown) {
|
|
3897
|
+
// Undeclared keys are camelCased too, otherwise the output would mix
|
|
3898
|
+
// both spellings depending on whether a key was declared.
|
|
3899
|
+
for (const [key, value] of Object.entries(source)) {
|
|
3900
|
+
const camel = toCamelCaseKey(key);
|
|
3901
|
+
if (!(camel in obj)) obj[camel] = value;
|
|
3902
|
+
}
|
|
3903
|
+
}
|
|
3904
|
+
}
|
|
3905
|
+
|
|
3906
|
+
// 4b. Record values — same shape as the nested-object walk, arbitrary keys.
|
|
3907
|
+
if (this.#recordValueChain && isPlainObject(transformed)) {
|
|
3908
|
+
const obj: Record<string, unknown> = { ...transformed };
|
|
3909
|
+
transformed = obj;
|
|
3910
|
+
for (const key of Object.keys(obj)) {
|
|
3911
|
+
const res = this.#recordValueChain._validateWithTransform(
|
|
3912
|
+
`${field}.${key}`,
|
|
3913
|
+
obj[key],
|
|
3914
|
+
{ ...ctx, parent: obj },
|
|
3915
|
+
pending,
|
|
3916
|
+
);
|
|
3917
|
+
errors.push(...res.errors);
|
|
3918
|
+
if (res.transformed !== undefined) obj[key] = res.transformed;
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3922
|
+
// 4c. Tuple positions — length was already enforced by the `tuple` rule.
|
|
3923
|
+
if (this.#tupleChains && Array.isArray(transformed)) {
|
|
3924
|
+
const arr: unknown[] = [...transformed];
|
|
3925
|
+
transformed = arr;
|
|
3926
|
+
this.#tupleChains.forEach((chain, i) => {
|
|
3927
|
+
const res = chain._validateWithTransform(
|
|
3928
|
+
`${field}.${i}`,
|
|
3929
|
+
arr[i],
|
|
3930
|
+
{ ...ctx, parent: arr },
|
|
3931
|
+
pending,
|
|
3932
|
+
);
|
|
3933
|
+
for (const e of res.errors) if (e.index === undefined) e.index = i;
|
|
3934
|
+
errors.push(...res.errors);
|
|
3935
|
+
if (res.transformed !== undefined) arr[i] = res.transformed;
|
|
3936
|
+
});
|
|
3937
|
+
}
|
|
3938
|
+
|
|
3939
|
+
// 4d. Union — first branch that validates wins; its transform is kept.
|
|
3940
|
+
if (this.#unionChains) {
|
|
3941
|
+
let matched = false;
|
|
3942
|
+
// A guarded branch (union.if) is SELECTED by its predicate, and its own
|
|
3943
|
+
// errors are reported — that is the diagnosable half of VineJS's union.
|
|
3944
|
+
const guarded = this.#unionChains.filter((b) => b.predicate !== null);
|
|
3945
|
+
if (guarded.length > 0) {
|
|
3946
|
+
const probe = this.#makeFieldContext(
|
|
3947
|
+
field,
|
|
3948
|
+
transformed,
|
|
3949
|
+
ctx,
|
|
3950
|
+
[],
|
|
3951
|
+
() => {},
|
|
3952
|
+
);
|
|
3953
|
+
const chosen =
|
|
3954
|
+
guarded.find((b) => b.predicate?.(transformed, probe)) ??
|
|
3955
|
+
this.#unionChains.find((b) => b.predicate === null);
|
|
3956
|
+
if (chosen) {
|
|
3957
|
+
const res = chosen.chain._validateWithTransform(
|
|
3958
|
+
field,
|
|
3959
|
+
transformed,
|
|
3960
|
+
ctx,
|
|
3961
|
+
pending,
|
|
3962
|
+
);
|
|
3963
|
+
transformed = res.transformed;
|
|
3964
|
+
errors.push(...res.errors);
|
|
3965
|
+
matched = true;
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3968
|
+
for (const branch of matched ? [] : this.#unionChains) {
|
|
3969
|
+
// Each branch collects into its OWN buffer: a losing branch must not
|
|
3970
|
+
// leave async work queued, and the winning one must not lose it —
|
|
3971
|
+
// without this, a `unique()` inside the matching branch was never
|
|
3972
|
+
// awaited, which reads exactly like a check that passed.
|
|
3973
|
+
const branchPending: PendingAsync[] = [];
|
|
3974
|
+
const res = branch.chain._validateWithTransform(
|
|
3975
|
+
field,
|
|
3976
|
+
transformed,
|
|
3977
|
+
ctx,
|
|
3978
|
+
pending ? branchPending : undefined,
|
|
3979
|
+
);
|
|
3980
|
+
if (res.errors.length === 0) {
|
|
3981
|
+
transformed = res.transformed;
|
|
3982
|
+
matched = true;
|
|
3983
|
+
if (pending) pending.push(...branchPending);
|
|
3984
|
+
break;
|
|
1090
3985
|
}
|
|
1091
3986
|
}
|
|
3987
|
+
if (!matched) {
|
|
3988
|
+
errors.push({
|
|
3989
|
+
field,
|
|
3990
|
+
rule: "union",
|
|
3991
|
+
message: resolveRuleMessage(
|
|
3992
|
+
field,
|
|
3993
|
+
{
|
|
3994
|
+
name: "union",
|
|
3995
|
+
validate: () => false,
|
|
3996
|
+
message: "Does not match any allowed shape",
|
|
3997
|
+
},
|
|
3998
|
+
ctx,
|
|
3999
|
+
),
|
|
4000
|
+
});
|
|
4001
|
+
}
|
|
1092
4002
|
}
|
|
1093
4003
|
|
|
1094
4004
|
// 5. Array item validation
|
|
@@ -1100,6 +4010,7 @@ export class RuleChain<Output = unknown> {
|
|
|
1100
4010
|
`${field}.${i}`,
|
|
1101
4011
|
arr[i],
|
|
1102
4012
|
{ ...ctx, parent: arr },
|
|
4013
|
+
pending,
|
|
1103
4014
|
);
|
|
1104
4015
|
for (const e of itemResult.errors) {
|
|
1105
4016
|
if (e.index === undefined) e.index = i;
|
|
@@ -1111,6 +4022,19 @@ export class RuleChain<Output = unknown> {
|
|
|
1111
4022
|
}
|
|
1112
4023
|
}
|
|
1113
4024
|
|
|
4025
|
+
// 6. Record this chain's async rules for the async path to await. Mirrors
|
|
4026
|
+
// Lucid skipping a DB rule on an already-invalid or absent field: only a
|
|
4027
|
+
// clean, present value is worth a round-trip.
|
|
4028
|
+
if (
|
|
4029
|
+
pending &&
|
|
4030
|
+
this.#asyncRules.length > 0 &&
|
|
4031
|
+
errors.length === 0 &&
|
|
4032
|
+
transformed !== undefined &&
|
|
4033
|
+
transformed !== null
|
|
4034
|
+
) {
|
|
4035
|
+
pending.push({ chain: this, field, value: transformed, ctx });
|
|
4036
|
+
}
|
|
4037
|
+
|
|
1114
4038
|
return { errors, transformed };
|
|
1115
4039
|
}
|
|
1116
4040
|
|
|
@@ -1120,22 +4044,72 @@ export class RuleChain<Output = unknown> {
|
|
|
1120
4044
|
transformed: unknown,
|
|
1121
4045
|
ctx: RunContext,
|
|
1122
4046
|
errors: ValidationError[],
|
|
1123
|
-
):
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
4047
|
+
): unknown {
|
|
4048
|
+
// Set per iteration so `report` can substitute the `.message()` override of
|
|
4049
|
+
// the rule currently running — these rules carry their text inside `run`.
|
|
4050
|
+
let override: string | undefined;
|
|
4051
|
+
let current = transformed;
|
|
4052
|
+
const fieldCtx = this.#makeFieldContext(
|
|
1128
4053
|
field,
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
4054
|
+
transformed,
|
|
4055
|
+
ctx,
|
|
4056
|
+
errors,
|
|
4057
|
+
(next) => {
|
|
4058
|
+
current = next;
|
|
4059
|
+
fieldCtx.value = next;
|
|
1133
4060
|
},
|
|
1134
|
-
|
|
4061
|
+
);
|
|
4062
|
+
const report = fieldCtx.report.bind(fieldCtx);
|
|
4063
|
+
fieldCtx.report = (message, rule, reportedField, args) =>
|
|
4064
|
+
report(override ?? message, rule, reportedField, args);
|
|
1135
4065
|
for (const rule of this.#useRules) {
|
|
4066
|
+
// A non-implicit rule is skipped on an absent value (VineJS semantics);
|
|
4067
|
+
// `implicit: true` is what lets a custom rule police undefined/null.
|
|
4068
|
+
if (!rule.implicit && (current === undefined || current === null))
|
|
4069
|
+
continue;
|
|
4070
|
+
fieldCtx.isValid = errors.length === 0;
|
|
4071
|
+
fieldCtx.isDefined = current !== undefined && current !== null;
|
|
4072
|
+
override = this.#ruleMessages.get(rule);
|
|
4073
|
+
rule.run(current, fieldCtx);
|
|
4074
|
+
}
|
|
4075
|
+
return current;
|
|
4076
|
+
}
|
|
4077
|
+
|
|
4078
|
+
/**
|
|
4079
|
+
* Run this chain's async rules on the (already sync-validated) value, awaiting
|
|
4080
|
+
* each in order. Returns the errors they reported. Used by `validateResultAsync`.
|
|
4081
|
+
* @internal
|
|
4082
|
+
*/
|
|
4083
|
+
async _runAsyncRules(
|
|
4084
|
+
field: string,
|
|
4085
|
+
transformed: unknown,
|
|
4086
|
+
ctx: RunContext,
|
|
4087
|
+
): Promise<ValidationError[]> {
|
|
4088
|
+
const errors: ValidationError[] = [];
|
|
4089
|
+
let override: string | undefined;
|
|
4090
|
+
let current = transformed;
|
|
4091
|
+
const fieldCtx = this.#makeFieldContext(
|
|
4092
|
+
field,
|
|
4093
|
+
transformed,
|
|
4094
|
+
ctx,
|
|
4095
|
+
errors,
|
|
4096
|
+
(next) => {
|
|
4097
|
+
current = next;
|
|
4098
|
+
fieldCtx.value = next;
|
|
4099
|
+
},
|
|
4100
|
+
);
|
|
4101
|
+
const report = fieldCtx.report.bind(fieldCtx);
|
|
4102
|
+
fieldCtx.report = (message, rule, reportedField, args) =>
|
|
4103
|
+
report(override ?? message, rule, reportedField, args);
|
|
4104
|
+
for (const rule of this.#asyncRules) {
|
|
4105
|
+
if (!rule.implicit && (current === undefined || current === null))
|
|
4106
|
+
continue;
|
|
1136
4107
|
fieldCtx.isValid = errors.length === 0;
|
|
1137
|
-
|
|
4108
|
+
fieldCtx.isDefined = current !== undefined && current !== null;
|
|
4109
|
+
override = this.#ruleMessages.get(rule);
|
|
4110
|
+
await rule.run(current, fieldCtx);
|
|
1138
4111
|
}
|
|
4112
|
+
return errors;
|
|
1139
4113
|
}
|
|
1140
4114
|
|
|
1141
4115
|
#requiredError(field: string, ctx: RunContext): ValidationError {
|
|
@@ -1224,6 +4198,13 @@ const LAST_FIELD: FieldContext = {
|
|
|
1224
4198
|
field: "",
|
|
1225
4199
|
meta: {},
|
|
1226
4200
|
isValid: true,
|
|
4201
|
+
name: "",
|
|
4202
|
+
wildCardPath: "",
|
|
4203
|
+
isArrayMember: false,
|
|
4204
|
+
isDefined: false,
|
|
4205
|
+
isValidDataType: true,
|
|
4206
|
+
getFieldPath: () => "",
|
|
4207
|
+
mutate: (): void => {},
|
|
1227
4208
|
report(): void {},
|
|
1228
4209
|
};
|
|
1229
4210
|
|
|
@@ -1309,16 +4290,120 @@ function evalRequiredCondition(
|
|
|
1309
4290
|
}
|
|
1310
4291
|
|
|
1311
4292
|
/** No-op alias of {@link schema} — VineJS `vine.compile()` API parity. */
|
|
1312
|
-
export function compile<T extends ValidationSchema>(s: T): T
|
|
1313
|
-
|
|
4293
|
+
export function compile<T extends ValidationSchema>(s: T): T;
|
|
4294
|
+
export function compile(chain: RuleChain): ValidationSchema;
|
|
4295
|
+
export function compile(input: ValidationSchema | RuleChain): ValidationSchema {
|
|
4296
|
+
// A rune schema is already compiled, so this is identity for that form; the
|
|
4297
|
+
// `RuleChain` form exists because `vine.compile(vine.object({…}))` is the
|
|
4298
|
+
// shape Adonis documents.
|
|
4299
|
+
return input instanceof RuleChain ? schema(toFieldMap(input), input) : input;
|
|
1314
4300
|
}
|
|
1315
4301
|
|
|
1316
4302
|
/** Entry point for building rules. */
|
|
1317
4303
|
export const rules = {
|
|
1318
4304
|
string: (): RuleChain<string> => new RuleChain().string(),
|
|
1319
|
-
number: (): RuleChain<number> =>
|
|
1320
|
-
|
|
4305
|
+
number: (options?: { strict?: boolean }): RuleChain<number> =>
|
|
4306
|
+
new RuleChain().number(options),
|
|
4307
|
+
boolean: (options?: { strict?: boolean }): RuleChain<boolean> =>
|
|
4308
|
+
new RuleChain().boolean(options),
|
|
1321
4309
|
any: (): RuleChain<unknown> => new RuleChain(),
|
|
4310
|
+
date: (options?: { formats?: DateFormat[] }): RuleChain<Date> =>
|
|
4311
|
+
new RuleChain().date(options),
|
|
4312
|
+
accepted: (): RuleChain<true> => new RuleChain().accepted(),
|
|
4313
|
+
file: (options?: {
|
|
4314
|
+
size?: number | string;
|
|
4315
|
+
extnames?: readonly string[];
|
|
4316
|
+
verifyContent?: boolean;
|
|
4317
|
+
}): RuleChain<FileLike> => new RuleChain().file(options),
|
|
4318
|
+
nativeFile: (options?: {
|
|
4319
|
+
minSize?: number | string;
|
|
4320
|
+
maxSize?: number | string;
|
|
4321
|
+
mimeTypes?: readonly string[];
|
|
4322
|
+
}): RuleChain<FileLike> => new RuleChain().nativeFile(options),
|
|
4323
|
+
record: <Item extends RuleChain>(
|
|
4324
|
+
valueChain: Item,
|
|
4325
|
+
): RuleChain<Record<string, OutputOf<Item>>> =>
|
|
4326
|
+
new RuleChain().record(valueChain),
|
|
4327
|
+
tuple: <const Items extends readonly RuleChain[]>(
|
|
4328
|
+
items: Items,
|
|
4329
|
+
): RuleChain<{ [K in keyof Items]: OutputOf<Items[K]> }> =>
|
|
4330
|
+
new RuleChain().tuple(items),
|
|
4331
|
+
union: Object.assign(
|
|
4332
|
+
(chains: readonly UnionBranch[]): RuleChain =>
|
|
4333
|
+
new RuleChain().union(chains),
|
|
4334
|
+
// `otherwise` is VineJS's spelling of the fallback branch; `else` stays
|
|
4335
|
+
// because it reads better in some call styles.
|
|
4336
|
+
{ if: unionIf, else: unionElse, otherwise: unionElse },
|
|
4337
|
+
),
|
|
4338
|
+
/**
|
|
4339
|
+
* Union discriminated by the value's TYPE (VineJS `unionOfTypes`): the first
|
|
4340
|
+
* branch whose own type rule accepts the value wins.
|
|
4341
|
+
*/
|
|
4342
|
+
/**
|
|
4343
|
+
* Make every property of a shape optional (VineJS `vine.helpers.optional`).
|
|
4344
|
+
* A properties TRANSFORMER, like `pick`/`omit` — it returns a record to
|
|
4345
|
+
* spread, not a schema.
|
|
4346
|
+
*/
|
|
4347
|
+
/**
|
|
4348
|
+
* A field that must be ABSENT (VineJS `vine.optional()` → `VineOptional`,
|
|
4349
|
+
* `builder.d.ts:135`). Mostly a `unionOfTypes` branch. Distinct from
|
|
4350
|
+
* `.optional()` on a chain, which relaxes an existing type — this one IS the
|
|
4351
|
+
* type. The properties transformer that used to squat this name moved to
|
|
4352
|
+
* `helpers.optional`, where VineJS keeps it.
|
|
4353
|
+
*/
|
|
4354
|
+
optional: (): RuleChain<undefined> => {
|
|
4355
|
+
const chain = new RuleChain();
|
|
4356
|
+
chain.pushTypeRule({
|
|
4357
|
+
name: "optionalType",
|
|
4358
|
+
validate: (v) => v === undefined,
|
|
4359
|
+
message: "Must not be provided",
|
|
4360
|
+
});
|
|
4361
|
+
return chain.optional().retypeTo<undefined>();
|
|
4362
|
+
},
|
|
4363
|
+
/** A field that must be `null` (VineJS `vine.null()` → `VineNull`). */
|
|
4364
|
+
null: (): RuleChain<null> => {
|
|
4365
|
+
const chain = new RuleChain();
|
|
4366
|
+
chain.pushTypeRule({
|
|
4367
|
+
name: "nullType",
|
|
4368
|
+
validate: (v) => v === null,
|
|
4369
|
+
message: "Must be null",
|
|
4370
|
+
});
|
|
4371
|
+
return chain.nullable().retypeTo<null>();
|
|
4372
|
+
},
|
|
4373
|
+
unionOfTypes: (chains: readonly RuleChain[]): RuleChain => {
|
|
4374
|
+
// VineJS requires DISTINCT types: two branches claiming the same type make
|
|
4375
|
+
// the discrimination meaningless, and the second would be dead code.
|
|
4376
|
+
const seen = new Set<string>();
|
|
4377
|
+
for (const chain of chains) {
|
|
4378
|
+
const typeRule = chain.rules.find((rule) =>
|
|
4379
|
+
TYPE_RULE_NAMES.has(rule.name),
|
|
4380
|
+
);
|
|
4381
|
+
const name = typeRule?.name;
|
|
4382
|
+
if (name === undefined) {
|
|
4383
|
+
throw new RuneError(
|
|
4384
|
+
"NO_TYPE_RULE",
|
|
4385
|
+
"unionOfTypes() needs every branch to declare a type (string/number/…).",
|
|
4386
|
+
{ hint: "Use union([...]) for predicate-based branches." },
|
|
4387
|
+
);
|
|
4388
|
+
}
|
|
4389
|
+
if (seen.has(name)) {
|
|
4390
|
+
throw new RuneError(
|
|
4391
|
+
"DUPLICATE_UNION_TYPE",
|
|
4392
|
+
`unionOfTypes() got two '${name}' branches — the second can never be reached.`,
|
|
4393
|
+
{ hint: "Give each branch a distinct type, or use union([...])." },
|
|
4394
|
+
);
|
|
4395
|
+
}
|
|
4396
|
+
seen.add(name);
|
|
4397
|
+
}
|
|
4398
|
+
return new RuleChain().union(
|
|
4399
|
+
chains.map((chain) => {
|
|
4400
|
+
const typeRule = chain.rules.find((rule) =>
|
|
4401
|
+
TYPE_RULE_NAMES.has(rule.name),
|
|
4402
|
+
);
|
|
4403
|
+
return unionIf((value) => typeRule?.validate(value) === true, chain);
|
|
4404
|
+
}),
|
|
4405
|
+
);
|
|
4406
|
+
},
|
|
1322
4407
|
object: <Sh extends Record<string, RuleChain>>(
|
|
1323
4408
|
shape: Sh,
|
|
1324
4409
|
): RuleChain<Infer<Sh>> => new RuleChain().object(shape),
|
|
@@ -1342,6 +4427,7 @@ function validateWithRust(
|
|
|
1342
4427
|
rules: Array<{ name: string; params: unknown }>;
|
|
1343
4428
|
optional: boolean;
|
|
1344
4429
|
transforms: string[];
|
|
4430
|
+
bail: boolean;
|
|
1345
4431
|
}
|
|
1346
4432
|
> = {};
|
|
1347
4433
|
|
|
@@ -1356,6 +4442,8 @@ function validateWithRust(
|
|
|
1356
4442
|
rules: ruleDescs,
|
|
1357
4443
|
optional: chain.isOptionalField,
|
|
1358
4444
|
transforms: chain.transforms.map((t) => t.name),
|
|
4445
|
+
// Sent explicitly so the Rust engine and the TS path agree on bail.
|
|
4446
|
+
bail: chain.bails,
|
|
1359
4447
|
};
|
|
1360
4448
|
}
|
|
1361
4449
|
|