@c9up/rune 0.1.7 → 0.1.9

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.
Files changed (51) hide show
  1. package/dist/MessagesProvider.d.ts +5 -0
  2. package/dist/MessagesProvider.d.ts.map +1 -1
  3. package/dist/MessagesProvider.js +1 -1
  4. package/dist/MessagesProvider.js.map +1 -1
  5. package/dist/Schema.d.ts +831 -35
  6. package/dist/Schema.d.ts.map +1 -1
  7. package/dist/Schema.js +2342 -140
  8. package/dist/Schema.js.map +1 -1
  9. package/dist/date.d.ts +36 -0
  10. package/dist/date.d.ts.map +1 -0
  11. package/dist/date.js +275 -0
  12. package/dist/date.js.map +1 -0
  13. package/dist/errors.d.ts +10 -0
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +10 -0
  16. package/dist/errors.js.map +1 -1
  17. package/dist/formats.d.ts +148 -0
  18. package/dist/formats.d.ts.map +1 -0
  19. package/dist/formats.js +671 -0
  20. package/dist/formats.js.map +1 -0
  21. package/dist/index.d.ts +150 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +180 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/magic.d.ts +30 -0
  26. package/dist/magic.d.ts.map +1 -0
  27. package/dist/magic.js +154 -0
  28. package/dist/magic.js.map +1 -0
  29. package/dist/native.d.ts +18 -6
  30. package/dist/native.d.ts.map +1 -1
  31. package/dist/native.js +34 -19
  32. package/dist/native.js.map +1 -1
  33. package/dist/types.d.ts +15 -0
  34. package/dist/types.d.ts.map +1 -0
  35. package/dist/types.js +12 -0
  36. package/dist/types.js.map +1 -0
  37. package/index.darwin-arm64.node +0 -0
  38. package/index.darwin-x64.node +0 -0
  39. package/index.linux-arm64-gnu.node +0 -0
  40. package/index.linux-x64-gnu.node +0 -0
  41. package/index.win32-x64-msvc.node +0 -0
  42. package/package.json +9 -1
  43. package/src/MessagesProvider.ts +1 -1
  44. package/src/Schema.ts +3389 -177
  45. package/src/date.ts +320 -0
  46. package/src/errors.ts +11 -0
  47. package/src/formats.ts +776 -0
  48. package/src/index.ts +269 -0
  49. package/src/magic.ts +181 -0
  50. package/src/native.ts +36 -21
  51. package/src/types.ts +55 -0
package/src/Schema.ts CHANGED
@@ -4,13 +4,59 @@
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";
10
50
  import {
51
+ detectFileType,
52
+ extensionMatches,
53
+ MAGIC_HEAD_BYTES,
54
+ readHead,
55
+ } from "./magic.js";
56
+ import {
57
+ assertNativeAvailable,
11
58
  isNativeAvailable,
12
59
  validateNative,
13
- warnNativeUnavailableOnce,
14
60
  } from "./native.js";
15
61
 
16
62
  export type ValidationMessageParams = Record<string, string | number | boolean>;
@@ -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
- /** Report a validation failure for this field. */
52
- report(message: string, rule: string): void;
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
- /** Result-based validation (superset) — never throws. */
127
- validate(data: unknown, options?: ValidateOptions): ValidationResult<T>;
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,594 @@ 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
+ /**
820
+ * Called when no union branch matched (VineJS `UnionNoMatchCallback`). Report
821
+ * through the field context; reporting nothing suppresses the generic error.
822
+ */
823
+ export type UnionNoMatchCallback = (
824
+ value: unknown,
825
+ field: FieldContext,
826
+ ) => void;
827
+
828
+ /**
829
+ * Called with a record's keys (VineJS `RecordKeysCallback`). Report through the
830
+ * field context; reporting nothing accepts the key set.
831
+ */
832
+ export type RecordKeysCallback = (keys: string[], field: FieldContext) => void;
833
+
834
+ /** Per-field introspection returned inside `toJSON().schema`. */
835
+ export type SchemaIntrospection = Record<
836
+ string,
837
+ { rules: string[]; optional: boolean; nullable: boolean }
838
+ >;
839
+
840
+ /** Describe every field's rules — the `schema` half of `toJSON()`. */
841
+ function introspect(fields: Record<string, RuleChain>): SchemaIntrospection {
842
+ return Object.fromEntries(
843
+ Object.entries(fields).map(([field, chain]) => [
844
+ field,
845
+ {
846
+ rules: chain.rules.map((rule) => rule.name),
847
+ optional: chain.isOptionalField,
848
+ nullable: chain.isNullable,
849
+ },
850
+ ]),
851
+ );
852
+ }
853
+
854
+ /** Rule name → the JSON Schema fragment it contributes. */
855
+ const JSON_SCHEMA_TYPES: Record<string, string> = {
856
+ string: "string",
857
+ number: "number",
858
+ boolean: "boolean",
859
+ date: "string",
860
+ accepted: "boolean",
861
+ object: "object",
862
+ record: "object",
863
+ array: "array",
864
+ tuple: "array",
865
+ };
866
+
867
+ /**
868
+ * Translate a field map to JSON Schema.
869
+ *
870
+ * Only rules with a real JSON Schema equivalent are emitted; a rule without one
871
+ * is OMITTED rather than approximated, because a schema that quietly drops a
872
+ * constraint is worse than one that says less.
873
+ */
874
+ function chainToJSONSchema(
875
+ fields: Record<string, RuleChain>,
876
+ ): Record<string, unknown> {
877
+ const properties: Record<string, unknown> = {};
878
+ const required: string[] = [];
879
+ for (const [field, chain] of Object.entries(fields)) {
880
+ const node: Record<string, unknown> = {};
881
+ for (const rule of chain.rules) {
882
+ const type = JSON_SCHEMA_TYPES[rule.name];
883
+ if (type !== undefined) node.type = type;
884
+ const args = rule.args ?? {};
885
+ if (rule.name === "minLength") node.minLength = args.min ?? rule.param;
886
+ if (rule.name === "maxLength") node.maxLength = args.max ?? rule.param;
887
+ if (rule.name === "fixedLength") {
888
+ node.minLength = args.length ?? rule.param;
889
+ node.maxLength = args.length ?? rule.param;
890
+ }
891
+ if (rule.name === "min") node.minimum = args.min ?? rule.param;
892
+ if (rule.name === "max") node.maximum = args.max ?? rule.param;
893
+ if (rule.name === "range") {
894
+ node.minimum = args.min;
895
+ node.maximum = args.max;
896
+ }
897
+ if (rule.name === "email") node.format = "email";
898
+ if (rule.name === "uuid") node.format = "uuid";
899
+ if (rule.name === "url") node.format = "uri";
900
+ if (rule.name === "date") node.format = "date-time";
901
+ if (rule.name === "regex" && typeof args.pattern === "string") {
902
+ node.pattern = args.pattern;
903
+ }
904
+ if (rule.name === "enum" && Array.isArray(args.values)) {
905
+ node.enum = args.values;
906
+ }
907
+ if (rule.name === "literal" && "value" in args) {
908
+ node.const = args.value;
909
+ }
910
+ if (rule.name === "notEmpty") node.minItems = 1;
911
+ if (rule.name === "distinct") node.uniqueItems = true;
912
+ if (rule.name === "withoutDecimals") node.type = "integer";
913
+ if (rule.name === "positive") node.exclusiveMinimum = 0;
914
+ if (rule.name === "negative") node.exclusiveMaximum = 0;
915
+ if (rule.name === "nonNegative") node.minimum = 0;
916
+ if (rule.name === "nonPositive") node.maximum = 0;
917
+ if (rule.name === "nullType") node.type = "null";
918
+ if (rule.name === "ulid") node.pattern = "^[0-7][0-9A-HJKMNP-TV-Z]{25}$";
919
+ if (rule.name === "alpha") node.pattern = "^[a-zA-Z]+$";
920
+ if (rule.name === "alphaNumeric") node.pattern = "^[a-zA-Z0-9]+$";
921
+ if (rule.name === "hexCode") node.format = "color";
922
+ if (rule.name === "ipAddress")
923
+ node.format = args.version === 6 ? "ipv6" : "ipv4";
924
+ if (rule.name === "file" || rule.name === "nativeFile") {
925
+ node.type = "string";
926
+ node.contentEncoding = "binary";
927
+ }
928
+ // A declarative rule may carry its own modifier too.
929
+ if (typeof rule.toJSONSchema === "function") {
930
+ Object.assign(node, rule.toJSONSchema(node, rule.args));
931
+ }
932
+ }
933
+ // `.use()` and async rules live outside `chain.rules`, so reading only that
934
+ // register left a declared modifier unreachable from the public API.
935
+ let modified = node;
936
+ for (const rule of [...chain.useRules, ...chain.asyncRules]) {
937
+ if (typeof rule.toJSONSchema === "function") {
938
+ modified = rule.toJSONSchema(modified, rule.ruleOptions);
939
+ }
940
+ }
941
+ if (chain.isNullable && typeof node.type === "string") {
942
+ node.type = [node.type, "null"];
943
+ }
944
+ const nested = chain.getProperties();
945
+ if (nested) {
946
+ Object.assign(modified, chainToJSONSchema(nested));
947
+ // A rune object DROPS undeclared keys unless allowUnknownProperties(),
948
+ // so the emitted schema must say so — otherwise a consumer generating a
949
+ // form from it would offer fields the validator silently discards.
950
+ modified.additionalProperties = chain.allowsUnknown;
951
+ }
952
+ if (chain.metadata) Object.assign(modified, chain.metadata);
953
+
954
+ // Containers: describe what they hold, not just that they are containers.
955
+ const itemChain = chain.arrayItem;
956
+ if (itemChain) {
957
+ modified.items = chainToJSONSchema({ item: itemChain }).properties as
958
+ | Record<string, unknown>
959
+ | undefined;
960
+ if (isRecordOfUnknown(modified.items))
961
+ modified.items = modified.items.item;
962
+ }
963
+ const tupleChains = chain.tupleItems;
964
+ if (tupleChains) {
965
+ modified.prefixItems = tupleChains.map((entry) => {
966
+ const built = chainToJSONSchema({ item: entry });
967
+ const props = built.properties;
968
+ return isRecordOfUnknown(props) ? props.item : {};
969
+ });
970
+ modified.items = false;
971
+ }
972
+ const recordChain = chain.recordValue;
973
+ if (recordChain) {
974
+ const built = chainToJSONSchema({ item: recordChain });
975
+ const props = built.properties;
976
+ modified.additionalProperties = isRecordOfUnknown(props)
977
+ ? props.item
978
+ : true;
979
+ }
980
+ properties[field] = modified;
981
+ if (!chain.isOptionalField) required.push(field);
982
+ }
983
+ return {
984
+ type: "object",
985
+ properties,
986
+ ...(required.length > 0 ? { required } : {}),
987
+ };
988
+ }
989
+
990
+ /** Narrow to a string-keyed record — used when threading nested JSON Schema. */
991
+ function isRecordOfUnknown(value: unknown): value is Record<string, unknown> {
992
+ return typeof value === "object" && value !== null && !Array.isArray(value);
993
+ }
994
+
995
+ /** `snake_case` / `kebab-case` / spaced key to `camelCase`. */
996
+ function toCamelCaseKey(key: string): string {
997
+ return key
998
+ .replace(/[-_\s]+(.)?/g, (_, c: string | undefined) =>
999
+ c ? c.toUpperCase() : "",
1000
+ )
1001
+ .replace(/^(.)/, (c) => c.toLowerCase());
1002
+ }
1003
+
1004
+ /** Structural guard telling a plain shape from a {@link ConditionalGroup}. */
1005
+ function isConditionalGroup(
1006
+ value: Record<string, RuleChain> | ConditionalGroup,
1007
+ ): value is ConditionalGroup {
1008
+ return "__rune" in value && value.__rune === "group";
1009
+ }
1010
+
1011
+ /** Build a conditional group (VineJS `vine.group([...])`). */
1012
+ export function group(
1013
+ branches: ReadonlyArray<{
1014
+ predicate: ((data: Record<string, unknown>) => boolean) | null;
1015
+ shape: Record<string, RuleChain>;
1016
+ }>,
1017
+ ): ConditionalGroup {
1018
+ return { __rune: "group", branches };
1019
+ }
1020
+
1021
+ /** A predicate-guarded group branch (`vine.group.if`). */
1022
+ export function groupIf(
1023
+ predicate: (data: Record<string, unknown>) => boolean,
1024
+ shape: Record<string, RuleChain>,
1025
+ ): {
1026
+ predicate: (data: Record<string, unknown>) => boolean;
1027
+ shape: Record<string, RuleChain>;
1028
+ } {
1029
+ return { predicate, shape };
1030
+ }
1031
+
1032
+ /** The unconditional fallback branch (`vine.group.else` / `.otherwise`). */
1033
+ export function groupElse(shape: Record<string, RuleChain>): {
1034
+ predicate: null;
1035
+ shape: Record<string, RuleChain>;
1036
+ } {
1037
+ return { predicate: null, shape };
1038
+ }
1039
+
1040
+ /** A union branch guarded by a predicate — `vine.union.if(...)`. */
1041
+ export interface ConditionalBranch {
1042
+ /** `null` for an unconditional branch (`union.else`). */
1043
+ predicate: ((value: unknown, field: FieldContext) => boolean) | null;
1044
+ chain: RuleChain;
1045
+ }
1046
+
1047
+ /** What `union()` accepts: a bare chain, or a guarded branch. */
1048
+ export type UnionBranch = RuleChain | ConditionalBranch;
1049
+
1050
+ /** Normalise a bare chain into an unconditional branch. */
1051
+ function toUnionBranch(branch: UnionBranch): ConditionalBranch {
1052
+ return branch instanceof RuleChain
1053
+ ? { predicate: null, chain: branch }
1054
+ : branch;
1055
+ }
1056
+
1057
+ /**
1058
+ * Guarded union branch (VineJS `vine.union.if`). The predicate picks the branch;
1059
+ * the chosen branch's OWN errors are reported, which is what makes a union
1060
+ * diagnosable — "matches nothing" tells the caller nothing about which shape it
1061
+ * nearly matched.
1062
+ */
1063
+ export function unionIf(
1064
+ predicate: (value: unknown, field: FieldContext) => boolean,
1065
+ chain: RuleChain,
1066
+ ): ConditionalBranch {
1067
+ return { predicate, chain };
1068
+ }
1069
+
1070
+ /** Fallback union branch (VineJS `vine.union.else`). */
1071
+ export function unionElse(chain: RuleChain): ConditionalBranch {
1072
+ return { predicate: null, chain };
1073
+ }
1074
+
1075
+ /** The checkbox-style truthies VineJS `accepted` recognises. */
1076
+ function isAcceptedValue(value: unknown): boolean {
1077
+ return (
1078
+ value === true ||
1079
+ value === 1 ||
1080
+ (typeof value === "string" &&
1081
+ ["1", "on", "yes", "true"].includes(value.toLowerCase()))
1082
+ );
142
1083
  }
143
1084
 
144
1085
  /** Default context for internal callers that don't supply one (no root available). */
@@ -160,6 +1101,16 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
160
1101
  * The TS chain rules (minLength/uuid/alpha/in/enum/range/…) live only in the TS
161
1102
  * validator, so they are deliberately absent here.
162
1103
  */
1104
+ /**
1105
+ * Rules whose default message is a TRANSLATABLE key (`validation.<rule>`).
1106
+ *
1107
+ * This set answers one question only: "does this rule have a canonical message?"
1108
+ * It used to answer a second one — "can the Rust engine run it?" — and that
1109
+ * conflation is why every divergence kept coming back: excluding a rule from the
1110
+ * native path silently un-translated it, and adding a TS-only option to a listed
1111
+ * rule silently made the option inert. {@link NATIVE_RULES} answers the routing
1112
+ * question now.
1113
+ */
163
1114
  const STANDARD_RULES: ReadonlySet<string> = new Set([
164
1115
  "string",
165
1116
  "number",
@@ -184,6 +1135,38 @@ const STANDARD_RULES: ReadonlySet<string> = new Set([
184
1135
  "range",
185
1136
  ]);
186
1137
 
1138
+ /**
1139
+ * Rules the Rust engine implements IDENTICALLY to the TS path.
1140
+ *
1141
+ * A rule belongs here only while both engines answer the same question for
1142
+ * every input. `email` is excluded on purpose: the TS check is structural
1143
+ * (quoted local parts, IP-literal domains, RFC length caps, validator.js-style
1144
+ * options) where Rust has one regex — routing there would give a different
1145
+ * answer for the same schema.
1146
+ */
1147
+ const NATIVE_RULES: ReadonlySet<string> = new Set([
1148
+ "string",
1149
+ "number",
1150
+ "boolean",
1151
+ "min",
1152
+ "max",
1153
+ "positive",
1154
+ "minLength",
1155
+ "maxLength",
1156
+ "fixedLength",
1157
+ "uuid",
1158
+ "alpha",
1159
+ "alphaNumeric",
1160
+ "startsWith",
1161
+ "endsWith",
1162
+ "in",
1163
+ "notIn",
1164
+ "enum",
1165
+ "negative",
1166
+ "nonNegative",
1167
+ "range",
1168
+ ]);
1169
+
187
1170
  /** Default messages for standard rules — used only for translator-key fallback. */
188
1171
  const STANDARD_MSGS: Readonly<Record<string, string>> = {
189
1172
  string: "Must be a string",
@@ -215,6 +1198,9 @@ const TYPE_RULE_NAMES: ReadonlySet<string> = new Set([
215
1198
  "boolean",
216
1199
  "object",
217
1200
  "array",
1201
+ // `optional()` / `null()` are schema TYPES in VineJS, not modifiers.
1202
+ "optionalType",
1203
+ "nullType",
218
1204
  ]);
219
1205
  let validationTranslator: ValidationTranslator | undefined;
220
1206
 
@@ -276,9 +1262,12 @@ function resolveRuleMessage(
276
1262
  if (rule.name === "max" || rule.name === "maxLength")
277
1263
  params.max = rule.param;
278
1264
  }
1265
+ // STANDARD_MSGS is the last-resort default for a standard rule: a rule object
1266
+ // built without a message (or with an empty one) still gets the canonical
1267
+ // text rather than an empty error. `rule.message` wins when it carries one.
279
1268
  return resolveValidationMessage(
280
1269
  `validation.${rule.name}`,
281
- rule.message,
1270
+ rule.message || STANDARD_MSGS[rule.name] || rule.name,
282
1271
  params,
283
1272
  );
284
1273
  }
@@ -303,12 +1292,14 @@ function resolveRequiredMessage(field: string, ctx: RunContext): string {
303
1292
  function detectHasCustomRules(fields: Record<string, RuleChain>): boolean {
304
1293
  return Object.values(fields).some((chain) => {
305
1294
  if (chain.useRules.length > 0) return true; // .use() rule — TS-only (Rust can't run JS)
1295
+ if (chain.asyncRules.length > 0) return true; // async rule — TS-only, needs validateResultAsync
306
1296
  if (chain.hasConditionalRequired) return true; // requiredWhen — TS-only
307
1297
  if (chain.preTransforms.length > 0) return true; // .parse() — TS-only
308
1298
  if (chain.transforms.length > 0) return true; // .transform() — Rust gets only the NAME, can't run a JS fn
309
1299
  if (chain.isNullable) return true; // .nullable() — the flag is not sent to the Rust engine
310
1300
  return chain.rules.some((r) => {
311
- if (!STANDARD_RULES.has(r.name)) return true; // custom rule
1301
+ if (!NATIVE_RULES.has(r.name)) return true; // Rust cannot run it identically
1302
+ if (r.tsOnly === true) return true; // native-listed name, TS-only options
312
1303
  if (hasCustomMessage(r)) return true; // custom message
313
1304
  return false;
314
1305
  });
@@ -356,20 +1347,46 @@ export type Infer<S> = Prettify<
356
1347
  */
357
1348
  export function schema<S extends Record<string, RuleChain>>(
358
1349
  fields: S,
1350
+ objectChain?: RuleChain,
359
1351
  ): ValidationSchema<Infer<S>>;
360
1352
  export function schema<T = Record<string, unknown>>(
361
1353
  fields: Record<string, RuleChain>,
1354
+ objectChain?: RuleChain,
362
1355
  ): ValidationSchema<T>;
363
1356
  export function schema(
364
1357
  fields: Record<string, RuleChain>,
1358
+ objectChain?: RuleChain,
365
1359
  ): ValidationSchema<Record<string, unknown>> {
1360
+ // Per-validator reporter (VineJS `validator.errorReporter = …`), overridable
1361
+ // per call. Mutable on purpose: that is how Vine exposes it.
1362
+ let validatorErrorReporter:
1363
+ | ErrorReporterFactory
1364
+ | ((error: ValidationError) => void)
1365
+ | null = null;
1366
+ // Set by the last run; the throwing entry points prefer the reporter's own
1367
+ // error, because VineJS lets the reporter decide the failure shape.
1368
+ let reporterError: (() => Error) | undefined;
1369
+
366
1370
  // Computed once at construction time, not per validate() call.
367
1371
  const hasCustomRules = detectHasCustomRules(fields);
1372
+ // Any field carrying async rules (`unique`/`exists`/`useAsync`) forces callers
1373
+ // onto the async path — the sync path throws rather than silently skipping them.
1374
+ const hasAsyncRules = Object.values(fields).some(
1375
+ (chain) => chain.hasAsyncRulesDeep,
1376
+ );
368
1377
 
369
- function validate(
370
- data: unknown,
1378
+ function validateResult(
1379
+ rawData: unknown,
371
1380
  options?: ValidateOptions,
372
1381
  ): ValidationResult<Record<string, unknown>> {
1382
+ const data = convertEmptyStringsToNull
1383
+ ? convertEmptyStrings(rawData)
1384
+ : rawData;
1385
+ if (hasAsyncRules) {
1386
+ throw new Error(
1387
+ "rune: this schema has async rules (unique/exists/useAsync) — call validateResultAsync() (result-based) or validate() (throwing) instead of validateResult().",
1388
+ );
1389
+ }
373
1390
  if (!isPlainObject(data)) {
374
1391
  return {
375
1392
  valid: false,
@@ -379,15 +1396,34 @@ export function schema(
379
1396
  };
380
1397
  }
381
1398
 
382
- const provider = options?.messagesProvider;
1399
+ // The global provider counts exactly like a per-call one: the Rust engine
1400
+ // renders default messages, so routing there would silently ignore it.
1401
+ const provider =
1402
+ options?.messagesProvider ?? globalMessagesProvider ?? undefined;
383
1403
  if (!hasCustomRules && !validationTranslator && !provider) {
384
1404
  if (isNativeAvailable()) {
385
- return validateWithRust(fields, data);
1405
+ const native = validateWithRust(fields, data);
1406
+ // Report here too: the native path returns before the TS traversal,
1407
+ // so instrumenting only the latter left the reporter silent exactly
1408
+ // when the fast path was taken.
1409
+ const nativeReporter = toReporter(
1410
+ options?.errorReporter ??
1411
+ validatorErrorReporter ??
1412
+ globalErrorReporter ??
1413
+ undefined,
1414
+ data,
1415
+ options?.meta ?? {},
1416
+ );
1417
+ if (nativeReporter) {
1418
+ for (const error of native.errors) nativeReporter.report(error);
1419
+ }
1420
+ reporterError = nativeReporter?.createError;
1421
+ return native;
386
1422
  }
387
- // This schema would have used the native engine, but it isn't loaded —
388
- // surface the platform-dependent TS fallback once instead of diverging
389
- // silently.
390
- warnNativeUnavailableOnce();
1423
+ // This schema carries nothing the engine cannot run, so the engine is
1424
+ // what must run it. Falling back to the TypeScript validator here made
1425
+ // the verdict depend on whether a binary loaded.
1426
+ assertNativeAvailable();
391
1427
  }
392
1428
 
393
1429
  const errors: ValidationError[] = [];
@@ -396,6 +1432,7 @@ export function schema(
396
1432
  data,
397
1433
  parent: data,
398
1434
  meta: options?.meta ?? {},
1435
+ errorReporter: options?.errorReporter,
399
1436
  messagesProvider: provider,
400
1437
  };
401
1438
 
@@ -411,6 +1448,18 @@ export function schema(
411
1448
  }
412
1449
  }
413
1450
 
1451
+ const reporter = toReporter(
1452
+ options?.errorReporter ??
1453
+ validatorErrorReporter ??
1454
+ globalErrorReporter ??
1455
+ undefined,
1456
+ data,
1457
+ options?.meta ?? {},
1458
+ );
1459
+ if (reporter) {
1460
+ for (const error of errors) reporter.report(error);
1461
+ }
1462
+ reporterError = reporter?.createError;
414
1463
  if (errors.length === 0) {
415
1464
  return { valid: true, errors, data: validated };
416
1465
  }
@@ -421,14 +1470,258 @@ export function schema(
421
1470
  data: unknown,
422
1471
  options?: ValidateOptions,
423
1472
  ): Record<string, unknown> {
424
- const result = validate(data, options);
1473
+ const result = validateResult(data, options);
425
1474
  if (result.valid) {
426
1475
  return result.data;
427
1476
  }
428
- throw new RuneValidationError(result.errors.map(toErrorNode));
1477
+ // The reporter decides the failure shape when one is bound (VineJS).
1478
+ throw reporterError
1479
+ ? reporterError()
1480
+ : new RuneValidationError(result.errors.map(toErrorNode));
429
1481
  }
430
1482
 
431
- return { fields, validate, validateOrThrow };
1483
+ async function validateResultAsync(
1484
+ rawData: unknown,
1485
+ options?: ValidateOptions,
1486
+ ): Promise<ValidationResult<Record<string, unknown>>> {
1487
+ const data = convertEmptyStringsToNull
1488
+ ? convertEmptyStrings(rawData)
1489
+ : rawData;
1490
+ if (!isPlainObject(data)) {
1491
+ return {
1492
+ valid: false,
1493
+ errors: [
1494
+ { field: "_root", rule: "type", message: "Input must be an object" },
1495
+ ],
1496
+ };
1497
+ }
1498
+ const errors: ValidationError[] = [];
1499
+ const validated: Record<string, unknown> = {};
1500
+ const rootCtx: RunContext = {
1501
+ data,
1502
+ parent: data,
1503
+ meta: options?.meta ?? {},
1504
+ errorReporter: options?.errorReporter,
1505
+ messagesProvider:
1506
+ options?.messagesProvider ?? globalMessagesProvider ?? undefined,
1507
+ };
1508
+
1509
+ for (const [field, chain] of Object.entries(fields)) {
1510
+ // One collector per top-level field, drained straight away, so async
1511
+ // errors stay grouped with their field rather than piling up at the end.
1512
+ const pending: PendingAsync[] = [];
1513
+ const result = chain._validateWithTransform(
1514
+ field,
1515
+ data[field],
1516
+ rootCtx,
1517
+ pending,
1518
+ );
1519
+ const fieldErrors = [...result.errors];
1520
+ // The collector already applied the gate at every depth: a chain records
1521
+ // itself only when its own subtree passed and its value is present —
1522
+ // mirrors Lucid skipping a DB rule on an already-invalid or absent field.
1523
+ for (const task of pending) {
1524
+ const asyncErrors = await task.chain._runAsyncRules(
1525
+ task.field,
1526
+ task.value,
1527
+ task.ctx,
1528
+ );
1529
+ fieldErrors.push(...asyncErrors);
1530
+ }
1531
+ errors.push(...fieldErrors);
1532
+ if (fieldErrors.length === 0 && result.transformed !== undefined) {
1533
+ validated[field] = result.transformed;
1534
+ }
1535
+ }
1536
+
1537
+ const reporter = toReporter(
1538
+ options?.errorReporter ??
1539
+ validatorErrorReporter ??
1540
+ globalErrorReporter ??
1541
+ undefined,
1542
+ data,
1543
+ options?.meta ?? {},
1544
+ );
1545
+ if (reporter) {
1546
+ for (const error of errors) reporter.report(error);
1547
+ }
1548
+ reporterError = reporter?.createError;
1549
+ if (errors.length === 0) {
1550
+ return { valid: true, errors, data: validated };
1551
+ }
1552
+ return { valid: false, errors };
1553
+ }
1554
+
1555
+ /**
1556
+ * Non-throwing validation returning a `[error, null] | [null, data]` tuple
1557
+ * (VineJS `tryValidate`), for when a failure is an expected code path.
1558
+ */
1559
+ function tryValidateSync(
1560
+ data: unknown,
1561
+ options?: ValidateOptions,
1562
+ ): [RuneValidationError, null] | [null, Record<string, unknown>] {
1563
+ const result = validateResult(data, options);
1564
+ if (result.valid) return [null, result.data];
1565
+ return [new RuneValidationError(result.errors.map(toErrorNode)), null];
1566
+ }
1567
+
1568
+ /** Async counterpart of {@link tryValidate}. */
1569
+ async function tryValidate(
1570
+ data: unknown,
1571
+ options?: ValidateOptions,
1572
+ ): Promise<[RuneValidationError, null] | [null, Record<string, unknown>]> {
1573
+ const result = await validateResultAsync(data, options);
1574
+ if (result.valid) return [null, result.data];
1575
+ return [new RuneValidationError(result.errors.map(toErrorNode)), null];
1576
+ }
1577
+
1578
+ async function validateOrThrowAsync(
1579
+ data: unknown,
1580
+ options?: ValidateOptions,
1581
+ ): Promise<Record<string, unknown>> {
1582
+ const result = await validateResultAsync(data, options);
1583
+ if (result.valid) {
1584
+ return result.data;
1585
+ }
1586
+ // The reporter decides the failure shape when one is bound (VineJS).
1587
+ throw reporterError
1588
+ ? reporterError()
1589
+ : new RuneValidationError(result.errors.map(toErrorNode));
1590
+ }
1591
+
1592
+ /**
1593
+ * The VineJS contract: async, returns the payload, throws on failure. A
1594
+ * schema carrying async rules works here without the caller having to know,
1595
+ * which is the whole point of Vine's single entry point.
1596
+ */
1597
+ async function validate(
1598
+ data: unknown,
1599
+ options?: ValidateOptions,
1600
+ ): Promise<Record<string, unknown>> {
1601
+ return validateOrThrowAsync(data, options);
1602
+ }
1603
+
1604
+ /**
1605
+ * Introspection of the compiled schema (VineJS `toJSON`): field names and the
1606
+ * rules attached to each, enough to render a form or diff two schemas.
1607
+ */
1608
+ function toJSON(): { schema: SchemaIntrospection; refs: string[] } {
1609
+ // VineJS shape: `{ schema, refs }`. The flat `{ field: { rules } }` map was
1610
+ // rune's own invention, so a consumer written against Vine read undefined.
1611
+ return {
1612
+ schema: introspect(fields),
1613
+ refs: Object.keys(fields),
1614
+ };
1615
+ }
1616
+
1617
+ /**
1618
+ * Emit a JSON Schema for the compiled validator (VineJS `toJSONSchema`).
1619
+ * Covers the rules that HAVE a JSON Schema equivalent; a custom rule
1620
+ * contributes its `jsonSchema` metadata when it declares one, and is
1621
+ * otherwise omitted rather than guessed at.
1622
+ */
1623
+ function toJSONSchema(): Record<string, unknown> {
1624
+ return chainToJSONSchema(fields);
1625
+ }
1626
+
1627
+ /**
1628
+ * Standard Schema v1 (`~standard`), the vendor-neutral contract VineJS also
1629
+ * implements — lets a consumer validate without knowing it holds a rune
1630
+ * schema.
1631
+ */
1632
+ const standard = {
1633
+ version: 1 as const,
1634
+ vendor: "rune",
1635
+ /**
1636
+ * Standard JSON Schema v1 (`~standard.jsonSchema`), added by VineJS 4.3.
1637
+ * `input` describes what may be sent, `output` what validation returns.
1638
+ */
1639
+ jsonSchema: {
1640
+ input: (): Record<string, unknown> => toJSONSchema(),
1641
+ output: (): Record<string, unknown> => toJSONSchema(),
1642
+ },
1643
+ validate: (
1644
+ value: unknown,
1645
+ ): Promise<
1646
+ | { value: Record<string, unknown> }
1647
+ | { issues: ReadonlyArray<{ message: string; path: string[] }> }
1648
+ > =>
1649
+ validateResultAsync(value).then((result) =>
1650
+ result.valid
1651
+ ? { value: result.data }
1652
+ : {
1653
+ issues: result.errors.map((error) => ({
1654
+ message: error.message,
1655
+ path: error.field.split("."),
1656
+ })),
1657
+ },
1658
+ ),
1659
+ };
1660
+
1661
+ return {
1662
+ fields,
1663
+ /** Per-validator error reporter (VineJS `validator.errorReporter`). */
1664
+ get errorReporter() {
1665
+ return validatorErrorReporter;
1666
+ },
1667
+ set errorReporter(reporter:
1668
+ | ErrorReporterFactory
1669
+ | ((error: ValidationError) => void)
1670
+ | null,) {
1671
+ validatorErrorReporter = reporter;
1672
+ },
1673
+ // ALWAYS a chain, even when the validator was built from a bare field map:
1674
+ // VineJS documents `createUserValidator.schema.partial()`, and returning
1675
+ // the map left that broken on the most common Adonis path.
1676
+ schema: objectChain ?? new RuleChain().object(fields),
1677
+ "~standard": standard,
1678
+ toJSON,
1679
+ toJSONSchema,
1680
+ validate,
1681
+ validateResult,
1682
+ validateResultAsync,
1683
+ validateOrThrow,
1684
+ validateOrThrowAsync,
1685
+ tryValidate,
1686
+ tryValidateSync,
1687
+ };
1688
+ }
1689
+
1690
+ /**
1691
+ * VineJS's `vine.create(...)`. Same thing as {@link schema} — the Adonis
1692
+ * spelling is provided so a validator reads the same in both frameworks.
1693
+ */
1694
+ /**
1695
+ * VineJS's `vine.create(...)`. Accepts either a map of fields (rune's native
1696
+ * spelling) or the `RuleChain` produced by `rune.object({...})`, because
1697
+ * `vine.create(vine.object({...}))` is the form Adonis documents.
1698
+ */
1699
+ export function create<S extends Record<string, RuleChain>>(
1700
+ fields: S,
1701
+ ): ValidationSchema<Infer<S>>;
1702
+ export function create(chain: RuleChain): ValidationSchema;
1703
+ export function create(
1704
+ input: Record<string, RuleChain> | RuleChain,
1705
+ ): ValidationSchema {
1706
+ return input instanceof RuleChain
1707
+ ? schema(toFieldMap(input), input)
1708
+ : schema(input);
1709
+ }
1710
+
1711
+ /** Unwrap `rune.object({...})` back to the field map `schema()` expects. */
1712
+ function toFieldMap(
1713
+ input: Record<string, RuleChain> | RuleChain,
1714
+ ): Record<string, RuleChain> {
1715
+ if (!(input instanceof RuleChain)) return input;
1716
+ const shape = input.getProperties();
1717
+ if (!shape) {
1718
+ throw new RuneError(
1719
+ "NOT_AN_OBJECT",
1720
+ "create()/compile() received a chain that declares no object shape.",
1721
+ { hint: "Use rune.object({ … }), or pass the field map directly." },
1722
+ );
1723
+ }
1724
+ return shape;
432
1725
  }
433
1726
 
434
1727
  /** Map an internal {@link ValidationError} to a {@link RuneErrorNode}. */
@@ -461,10 +1754,24 @@ export interface RuleDef {
461
1754
  param?: number;
462
1755
  /** Interpolation args exposed to i18n/messages providers (e.g. `{ min: 3 }`). */
463
1756
  args?: Record<string, unknown>;
464
- validate: (value: unknown) => boolean;
1757
+ /**
1758
+ * `field` is the context VineJS hands a rule — a callback-valued rule
1759
+ * (`in`, `notIn`, `enum`) reads `meta`/`parent` off it to compute its list
1760
+ * per request. Rules that do not need it simply declare one parameter.
1761
+ */
1762
+ validate: (value: unknown, field: FieldContext) => boolean;
465
1763
  message: string;
466
1764
  /** Set when `.message()` overrode this rule's default text. */
467
1765
  hasCustomMessage?: boolean;
1766
+ /**
1767
+ * Keep this rule off the Rust path even though its NAME is in
1768
+ * {@link NATIVE_RULES}. Set by options the native engine does not know about
1769
+ * (`uuid({ version })`, a callback list for `in` / `notIn`): the engine would
1770
+ * run the rule without them and silently answer a different question.
1771
+ */
1772
+ tsOnly?: boolean;
1773
+ /** Modifier this rule applies to its field's JSON Schema node. */
1774
+ toJSONSchema?: JsonSchemaModifier;
468
1775
  }
469
1776
 
470
1777
  /** A conditional-required condition (VineJS `requiredWhen` family). */
@@ -483,6 +1790,23 @@ const UUID_RE =
483
1790
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
484
1791
 
485
1792
  /** Rule chain — fluent, phantom-typed validation builder. */
1793
+ /**
1794
+ * The value list accepted by `in` / `notIn` / `enum` — static, or computed at
1795
+ * validation time (VineJS parity).
1796
+ */
1797
+ export type AllowedValues =
1798
+ | ReadonlyArray<string | number | boolean>
1799
+ | ((field: FieldContext) => ReadonlyArray<string | number | boolean>);
1800
+
1801
+ /** Normalise a static list or a callback into a getter. */
1802
+ function allowedValuesResolver(
1803
+ values: AllowedValues,
1804
+ ): (field: FieldContext) => ReadonlyArray<string | number | boolean> {
1805
+ if (typeof values === "function") return values;
1806
+ const snapshot = [...values];
1807
+ return () => snapshot;
1808
+ }
1809
+
486
1810
  export class RuleChain<Output = unknown> {
487
1811
  /** Phantom output type — drives {@link Infer}; never read at runtime. */
488
1812
  declare readonly [OUTPUT]: Output;
@@ -490,15 +1814,58 @@ export class RuleChain<Output = unknown> {
490
1814
  #rules: RuleDef[] = [];
491
1815
  #isOptional = false;
492
1816
  #isNullable = false;
493
- #bail = false;
1817
+ /**
1818
+ * VineJS validates a field in bail mode by DEFAULT — it stops at that field's
1819
+ * first failing rule (`FieldOptions.bail: true`). rune defaulted to `false`
1820
+ * and reported every failing rule, which silently produced a different error
1821
+ * array for the same schema. `.bail(false)` restores the exhaustive mode.
1822
+ */
1823
+ #bail = true;
494
1824
  #transforms: Array<{
495
1825
  name: string;
496
1826
  fn: (value: unknown, field: FieldContext) => unknown;
497
1827
  }> = [];
498
- #preTransforms: Array<(value: unknown) => unknown> = [];
1828
+ #preTransforms: Array<(value: unknown, ctx: ParseContext) => unknown> = [];
1829
+ /**
1830
+ * Type coercions (VineJS accepts `"32"` for a number). Kept OUT of
1831
+ * `#preTransforms` on purpose: a pre-transform forces the TS path, and the
1832
+ * Rust engine implements the very same coercion from the rule's `strict`
1833
+ * param, so both engines agree without giving up the native path.
1834
+ */
1835
+ #coercions: Array<(value: unknown) => unknown> = [];
1836
+ /** Formats accepted by `date()` — also used to parse `afterField` siblings. */
1837
+ #dateFormats: DateFormat[] | null = null;
499
1838
  #nestedSchema: Record<string, RuleChain> | null = null;
500
1839
  #arrayItemChain: RuleChain | null = null;
1840
+ #allowUnknown = false;
1841
+ #metadata: Record<string, unknown> | null = null;
1842
+ /** Extensions / MIME types declared by `file()` / `mimeTypes()`. */
1843
+ #declaredExtnames: readonly string[] | null = null;
1844
+ #declaredMimeTypes: readonly string[] | null = null;
1845
+ /** `true` once the content-verification rule has been registered. */
1846
+ #contentVerified = false;
1847
+ /** Set by `{ verifyContent: false }` — an explicit, auditable opt-out. */
1848
+ #contentVerificationOff = false;
1849
+ #camelCaseKeys = false;
1850
+ #groups: ConditionalGroup[] = [];
1851
+ #recordValueChain: RuleChain | null = null;
1852
+ #enumChoices:
1853
+ | ReadonlyArray<string | number | boolean>
1854
+ | ((field: FieldContext) => ReadonlyArray<string | number | boolean>)
1855
+ | null = null;
1856
+ #recordKeysCheck: RecordKeysCallback | null = null;
1857
+ #tupleChains: RuleChain[] | null = null;
1858
+ #unionChains: ConditionalBranch[] | null = null;
1859
+ #unionNoMatch: UnionNoMatchCallback | null = null;
501
1860
  #useRules: CompiledRule[] = [];
1861
+ #asyncRules: AsyncCompiledRule[] = [];
1862
+ /** Last rule added, whichever register it landed in — the `message()` target. */
1863
+ #lastRule:
1864
+ | { kind: "value"; ref: RuleDef }
1865
+ | { kind: "reporting"; ref: CompiledRule | AsyncCompiledRule }
1866
+ | null = null;
1867
+ /** `.message()` overrides for rules that report their own text from `run`. */
1868
+ #ruleMessages = new Map<CompiledRule | AsyncCompiledRule, string>();
502
1869
  #requiredConditions: RequiredCondition[] = [];
503
1870
 
504
1871
  /** Public read access to rules (for OpenAPI generation, Rust bridge). */
@@ -522,8 +1889,62 @@ export class RuleChain<Output = unknown> {
522
1889
  get useRules(): readonly CompiledRule[] {
523
1890
  return this.#useRules;
524
1891
  }
1892
+ /** Public read access to async rules (`unique`/`exists`/`useAsync`) — run by `validateResultAsync`. */
1893
+ get asyncRules(): readonly AsyncCompiledRule[] {
1894
+ return this.#asyncRules;
1895
+ }
1896
+ /**
1897
+ * Does this chain — or anything nested under it (object fields, array items) —
1898
+ * carry async rules? The schema-level detection used to inspect only the
1899
+ * top-level chains, so a nested `unique`/`exists` was invisible: `validate()`
1900
+ * did not throw and the async pass never ran the rule, silently accepting
1901
+ * an unchecked value.
1902
+ */
1903
+ get hasAsyncRulesDeep(): boolean {
1904
+ if (this.#asyncRules.length > 0) return true;
1905
+ if (this.#nestedSchema) {
1906
+ for (const chain of Object.values(this.#nestedSchema)) {
1907
+ if (chain.hasAsyncRulesDeep) return true;
1908
+ }
1909
+ }
1910
+ if (this.#arrayItemChain?.hasAsyncRulesDeep) return true;
1911
+ if (this.#recordValueChain?.hasAsyncRulesDeep) return true;
1912
+ for (const chain of [
1913
+ ...(this.#tupleChains ?? []),
1914
+ ...(this.#unionChains ?? []).map((b) => b.chain),
1915
+ ]) {
1916
+ if (chain.hasAsyncRulesDeep) return true;
1917
+ }
1918
+ return false;
1919
+ }
1920
+ /** Does this object keep keys its shape does not declare? */
1921
+ get allowsUnknown(): boolean {
1922
+ return this.#allowUnknown;
1923
+ }
1924
+ /** Free-form JSON Schema metadata attached with `meta()`. */
1925
+ get metadata(): Record<string, unknown> | null {
1926
+ return this.#metadata;
1927
+ }
1928
+ /** The item chain of an `array()`, if declared. */
1929
+ get arrayItem(): RuleChain | null {
1930
+ return this.#arrayItemChain;
1931
+ }
1932
+ /** The positional chains of a `tuple()`, if declared. */
1933
+ get tupleItems(): RuleChain[] | null {
1934
+ return this.#tupleChains;
1935
+ }
1936
+ /** The value chain of a `record()`, if declared. */
1937
+ get recordValue(): RuleChain | null {
1938
+ return this.#recordValueChain;
1939
+ }
1940
+ /** Whether this chain stops at its first failing rule (VineJS `bail`). */
1941
+ get bails(): boolean {
1942
+ return this.#bail;
1943
+ }
525
1944
  /** Public read access to `.parse()` pre-transforms (kept off the native path). */
526
- get preTransforms(): ReadonlyArray<(value: unknown) => unknown> {
1945
+ get preTransforms(): ReadonlyArray<
1946
+ (value: unknown, ctx: ParseContext) => unknown
1947
+ > {
527
1948
  return this.#preTransforms;
528
1949
  }
529
1950
  /** Whether this chain carries a `requiredWhen`-family condition. */
@@ -544,10 +1965,29 @@ export class RuleChain<Output = unknown> {
544
1965
  next.#isNullable = this.#isNullable;
545
1966
  next.#bail = this.#bail;
546
1967
  next.#transforms = [...this.#transforms];
1968
+ next.#dateFormats = this.#dateFormats;
1969
+ next.#coercions = [...this.#coercions];
1970
+ next.#allowUnknown = this.#allowUnknown;
1971
+ next.#metadata = this.#metadata ? { ...this.#metadata } : null;
1972
+ next.#declaredExtnames = this.#declaredExtnames;
1973
+ next.#declaredMimeTypes = this.#declaredMimeTypes;
1974
+ next.#contentVerified = this.#contentVerified;
1975
+ next.#contentVerificationOff = this.#contentVerificationOff;
1976
+ next.#camelCaseKeys = this.#camelCaseKeys;
1977
+ next.#groups = [...this.#groups];
1978
+ next.#recordValueChain = this.#recordValueChain;
1979
+ next.#enumChoices = this.#enumChoices;
1980
+ next.#recordKeysCheck = this.#recordKeysCheck;
1981
+ next.#tupleChains = this.#tupleChains;
1982
+ next.#unionChains = this.#unionChains;
1983
+ next.#unionNoMatch = this.#unionNoMatch;
1984
+ next.#ruleMessages = new Map(this.#ruleMessages);
1985
+ next.#lastRule = this.#lastRule;
547
1986
  next.#preTransforms = [...this.#preTransforms];
548
1987
  next.#nestedSchema = this.#nestedSchema;
549
1988
  next.#arrayItemChain = this.#arrayItemChain;
550
1989
  next.#useRules = [...this.#useRules];
1990
+ next.#asyncRules = [...this.#asyncRules];
551
1991
  next.#requiredConditions = [...this.#requiredConditions];
552
1992
  return next;
553
1993
  }
@@ -581,7 +2021,7 @@ export class RuleChain<Output = unknown> {
581
2021
  object<Sh extends Record<string, RuleChain>>(
582
2022
  shape: Sh,
583
2023
  ): RuleChain<Infer<Sh>> {
584
- this.#rules.push({
2024
+ this.#pushRule({
585
2025
  name: "object",
586
2026
  validate: (v) => isPlainObject(v),
587
2027
  message: "Must be an object",
@@ -592,7 +2032,7 @@ export class RuleChain<Output = unknown> {
592
2032
 
593
2033
  /** Must be an array. Items validated by the provided chain. */
594
2034
  array<Item extends RuleChain>(itemChain?: Item): RuleChain<OutputOf<Item>[]> {
595
- this.#rules.push({
2035
+ this.#pushRule({
596
2036
  name: "array",
597
2037
  validate: (v) => Array.isArray(v),
598
2038
  message: "Must be an array",
@@ -603,7 +2043,7 @@ export class RuleChain<Output = unknown> {
603
2043
 
604
2044
  /** Must be a string. */
605
2045
  string(): RuleChain<string> {
606
- this.#rules.push({
2046
+ this.#pushRule({
607
2047
  name: "string",
608
2048
  validate: (v) => typeof v === "string",
609
2049
  message: "Must be a string",
@@ -611,10 +2051,17 @@ export class RuleChain<Output = unknown> {
611
2051
  return this.#retype<string>();
612
2052
  }
613
2053
 
614
- /** Must be a number. */
615
- number(): RuleChain<number> {
616
- this.#rules.push({
2054
+ /**
2055
+ * Must be a number. Like VineJS, a numeric STRING is coerced (`"32"` `32`)
2056
+ * — HTML form bodies and query strings carry numbers as text, so requiring
2057
+ * `typeof v === "number"` rejected the values Adonis accepts. Pass
2058
+ * `{ strict: true }` to refuse anything that is not already a number.
2059
+ */
2060
+ number(options?: { strict?: boolean }): RuleChain<number> {
2061
+ if (!options?.strict) this.#coercions.push(coerceNumber);
2062
+ this.#pushRule({
617
2063
  name: "number",
2064
+ args: { strict: options?.strict === true },
618
2065
  validate: (v) =>
619
2066
  typeof v === "number" && !Number.isNaN(v) && Number.isFinite(v),
620
2067
  message: "Must be a number",
@@ -622,35 +2069,705 @@ export class RuleChain<Output = unknown> {
622
2069
  return this.#retype<number>();
623
2070
  }
624
2071
 
625
- /** Must be a boolean. */
626
- boolean(): RuleChain<boolean> {
627
- this.#rules.push({
2072
+ /**
2073
+ * Must be a boolean. Like VineJS, `"true"`, `"false"`, `"on"`, `"off"`,
2074
+ * `"1"`, `"0"`, `1` and `0` are coerced; `{ strict: true }` refuses them.
2075
+ */
2076
+ boolean(options?: { strict?: boolean }): RuleChain<boolean> {
2077
+ if (!options?.strict) this.#coercions.push(coerceBoolean);
2078
+ this.#pushRule({
628
2079
  name: "boolean",
2080
+ args: { strict: options?.strict === true },
629
2081
  validate: (v) => typeof v === "boolean",
630
2082
  message: "Must be a boolean",
631
2083
  });
632
2084
  return this.#retype<boolean>();
633
2085
  }
634
2086
 
635
- /** Must equal one of `values` (enum). Narrows the output to the union. */
2087
+ /**
2088
+ * Must be a date (VineJS `vine.date()`). ISO 8601 by default; pass `formats`
2089
+ * for unix timestamps (`x` = ms, `X` = seconds) or a token format such as
2090
+ * `DD/MM/YYYY`. Parsing is calendar-strict — `2026-02-31` is rejected.
2091
+ *
2092
+ * The validated output is a `Date`; bind {@link setDateTransform} to map it
2093
+ * to your own type once at boot.
2094
+ */
2095
+ date(options?: { formats?: DateFormat[] }): RuleChain<Date> {
2096
+ const formats = options?.formats ?? ["iso8601"];
2097
+ this.#dateFormats = formats;
2098
+ this.#pushRule({
2099
+ name: "date",
2100
+ args: { formats },
2101
+ validate: (v) => parseDateValue(v, formats) !== null,
2102
+ message: "Must be a valid date",
2103
+ });
2104
+ // Parse to a real `Date` BEFORE the comparison rules run, so `after`/
2105
+ // `before` never re-parse and never compare strings lexicographically.
2106
+ this.#transforms.push({
2107
+ name: "date",
2108
+ fn: (value) => parseDateValue(value, formats) ?? value,
2109
+ });
2110
+ return this.#retype<Date>();
2111
+ }
2112
+
2113
+ /** Must be strictly after `operand` (`'today'`, an ISO string, or a `Date`). */
2114
+ after(operand: unknown, options?: DateCompareOptions): this {
2115
+ return this.#compareDate("after", operand, (a, b) => a > b, options);
2116
+ }
2117
+
2118
+ /** Must be strictly before `operand`. */
2119
+ before(operand: unknown, options?: DateCompareOptions): this {
2120
+ return this.#compareDate("before", operand, (a, b) => a < b, options);
2121
+ }
2122
+
2123
+ /** Must be after `operand`, or equal to it. */
2124
+ afterOrEqual(operand: unknown, options?: DateCompareOptions): this {
2125
+ return this.#compareDate(
2126
+ "afterOrEqual",
2127
+ operand,
2128
+ (a, b) => a >= b,
2129
+ options,
2130
+ );
2131
+ }
2132
+
2133
+ /** Must be before `operand`, or equal to it. */
2134
+ beforeOrEqual(operand: unknown, options?: DateCompareOptions): this {
2135
+ return this.#compareDate(
2136
+ "beforeOrEqual",
2137
+ operand,
2138
+ (a, b) => a <= b,
2139
+ options,
2140
+ );
2141
+ }
2142
+
2143
+ /** Must be after the date held by a sibling field (VineJS `afterField`). */
2144
+ afterField(otherField: string, options?: DateCompareOptions): this {
2145
+ return this.#compareDateField(
2146
+ "afterField",
2147
+ otherField,
2148
+ options,
2149
+ (a, b) => a > b,
2150
+ );
2151
+ }
2152
+
2153
+ /** Must be before the date held by a sibling field. */
2154
+ beforeField(otherField: string, options?: DateCompareOptions): this {
2155
+ return this.#compareDateField(
2156
+ "beforeField",
2157
+ otherField,
2158
+ options,
2159
+ (a, b) => a < b,
2160
+ );
2161
+ }
2162
+
2163
+ /** Must be the same instant as `operand` (VineJS `equals`). */
2164
+ equals(operand: unknown, options?: DateCompareOptions): this {
2165
+ return this.#compareDate("equals", operand, (a, b) => a === b, options);
2166
+ }
2167
+
2168
+ /** Must be after the sibling's date, or the same instant (VineJS `afterOrSameAs`). */
2169
+ afterOrSameAs(otherField: string, options?: DateCompareOptions): this {
2170
+ return this.#compareDateField(
2171
+ "afterOrSameAs",
2172
+ otherField,
2173
+ options,
2174
+ (a, b) => a >= b,
2175
+ );
2176
+ }
2177
+
2178
+ /** Must be before the sibling's date, or the same instant. */
2179
+ beforeOrSameAs(otherField: string, options?: DateCompareOptions): this {
2180
+ return this.#compareDateField(
2181
+ "beforeOrSameAs",
2182
+ otherField,
2183
+ options,
2184
+ (a, b) => a <= b,
2185
+ );
2186
+ }
2187
+
2188
+ /** Must fall on a Saturday or Sunday (VineJS `weekend`). */
2189
+ weekend(): this {
2190
+ this.#pushRule({
2191
+ name: "weekend",
2192
+ validate: (v) =>
2193
+ v instanceof Date && (v.getDay() === 0 || v.getDay() === 6),
2194
+ message: "Must be a weekend date",
2195
+ });
2196
+ return this;
2197
+ }
2198
+
2199
+ /** Must fall on a Monday-to-Friday day (VineJS `weekday`). */
2200
+ weekday(): this {
2201
+ this.#pushRule({
2202
+ name: "weekday",
2203
+ validate: (v) => v instanceof Date && v.getDay() > 0 && v.getDay() < 6,
2204
+ message: "Must be a weekday date",
2205
+ });
2206
+ return this;
2207
+ }
2208
+
2209
+ /** Shared body of the `after`/`before`/`*OrEqual` literal comparisons. */
2210
+ #compareDate(
2211
+ name: string,
2212
+ operand: unknown,
2213
+ cmp: (a: number, b: number) => boolean,
2214
+ options?: DateCompareOptions,
2215
+ ): this {
2216
+ // VineJS: `options.compare || "day"`. A bare `after('today')` is about the
2217
+ // calendar date, not the clock — comparing exact timestamps made every
2218
+ // same-day value fail a rule the caller read as "today or later".
2219
+ const unit: CompareUnit = options?.compare ?? "day";
2220
+ const formats = options?.format ? [options.format] : null;
2221
+ this.#pushRule({
2222
+ name,
2223
+ // A callable operand is resolved per validation, not once at build
2224
+ // time — otherwise `after(() => Date.now())` would freeze the boundary
2225
+ // at the moment the schema was declared (VineJS allows the callback).
2226
+ args: typeof operand === "function" ? undefined : { operand },
2227
+ validate: (v) => {
2228
+ const raw =
2229
+ typeof operand === "function"
2230
+ ? (operand as () => unknown)()
2231
+ : operand;
2232
+ const other =
2233
+ formats && typeof raw === "string"
2234
+ ? parseDateValue(raw, formats)
2235
+ : resolveOperand(raw);
2236
+ if (!(v instanceof Date) || other === null) return false;
2237
+ return cmp(truncateTo(v, unit), truncateTo(other, unit));
2238
+ },
2239
+ message: `Must be ${name.replace(/([A-Z])/g, " $1").toLowerCase()} ${String(operand)}`,
2240
+ });
2241
+ return this;
2242
+ }
2243
+
2244
+ /** Shared body of the `afterField`/`beforeField` sibling comparisons. */
2245
+ #compareDateField(
2246
+ name: string,
2247
+ otherField: string,
2248
+ options: DateCompareOptions | undefined,
2249
+ cmp: (a: number, b: number) => boolean,
2250
+ ): this {
2251
+ const formats = options?.format
2252
+ ? [options.format]
2253
+ : (this.#dateFormats ?? ["iso8601"]);
2254
+ const unit: CompareUnit = options?.compare ?? "day";
2255
+ this.#pushUse({
2256
+ __rune: "rule",
2257
+ run: (value, field) => {
2258
+ const other = parseDateValue(readSibling(field, otherField), formats);
2259
+ if (!(value instanceof Date) || other === null) {
2260
+ field.report(`Cannot compare with ${otherField}`, name);
2261
+ return;
2262
+ }
2263
+ if (!cmp(truncateTo(value, unit), truncateTo(other, unit))) {
2264
+ field.report(
2265
+ `Must be ${name.replace("Field", "")} ${otherField}`,
2266
+ name,
2267
+ );
2268
+ }
2269
+ },
2270
+ });
2271
+ return this;
2272
+ }
2273
+
2274
+ /**
2275
+ * Keep keys the object shape does not declare (VineJS
2276
+ * `allowUnknownProperties`). Off by default: dropping undeclared keys is what
2277
+ * makes a validated payload safe to hand to a mass assignment.
2278
+ */
2279
+ allowUnknownProperties(): this {
2280
+ this.#allowUnknown = true;
2281
+ return this;
2282
+ }
2283
+
2284
+ /**
2285
+ * Convert the object's KEYS to camelCase in the output (VineJS
2286
+ * `object.toCamelCase()`), so a snake_case payload hydrates camelCase
2287
+ * properties. Distinct from the string `toCamelCase()`, which rewrites a
2288
+ * VALUE — that one was never a substitute for this.
2289
+ */
2290
+ toCamelCaseKeys(): this {
2291
+ return this.toCamelCase();
2292
+ }
2293
+
2294
+ /**
2295
+ * Merge extra properties into this object's shape (VineJS `merge`). Accepts a
2296
+ * plain shape or a {@link ConditionalGroup} whose branch is chosen per
2297
+ * payload — `vine.group` in VineJS.
2298
+ */
2299
+ merge(extra: Record<string, RuleChain> | ConditionalGroup): this {
2300
+ if (!this.#nestedSchema) {
2301
+ throw new RuneError(
2302
+ "NOT_AN_OBJECT",
2303
+ "merge() needs an object() shape to merge into.",
2304
+ { hint: "rules.any().object({ … }).merge({ … })" },
2305
+ );
2306
+ }
2307
+ if (isConditionalGroup(extra)) {
2308
+ this.#groups.push(extra);
2309
+ return this;
2310
+ }
2311
+ this.#nestedSchema = { ...this.#nestedSchema, ...extra };
2312
+ return this;
2313
+ }
2314
+
2315
+ /** The nested shape declared by `object()`, if any (VineJS `getProperties`). */
2316
+ getProperties(): Record<string, RuleChain> | null {
2317
+ // CLONE each chain, not just the map. A shallow copy shares the chain
2318
+ // instances, so mutating one through the copy relaxes the source schema —
2319
+ // the same trap that made `partial()` mutate its origin.
2320
+ if (!this.#nestedSchema) return null;
2321
+ return Object.fromEntries(
2322
+ Object.entries(this.#nestedSchema).map(([key, chain]) => [
2323
+ key,
2324
+ chain.clone(),
2325
+ ]),
2326
+ );
2327
+ }
2328
+
2329
+ /** Independent copy of this chain (VineJS `clone`). */
2330
+ clone(): RuleChain<Output> {
2331
+ return this.#retype<Output>();
2332
+ }
2333
+
2334
+ /**
2335
+ * A CLONED subset of the object's properties (VineJS `pick`).
2336
+ *
2337
+ * Returns a properties record, not a schema — VineJS types it
2338
+ * `Pick<Properties, Keys>` precisely so it composes by spread:
2339
+ * `rules.any().object({ ...userShape.pick(["id"]) })`. Returning a chain here
2340
+ * broke that idiom.
2341
+ */
2342
+ pick<K extends string>(keys: readonly K[]): Record<string, RuleChain> {
2343
+ return this.#subsetOfProperties((key) => keys.includes(key as K));
2344
+ }
2345
+
2346
+ /** A cloned copy of the properties EXCLUDING `keys` (VineJS `omit`). */
2347
+ omit<K extends string>(keys: readonly K[]): Record<string, RuleChain> {
2348
+ return this.#subsetOfProperties((key) => !keys.includes(key as K));
2349
+ }
2350
+
2351
+ /** Shared body of `pick`/`omit` — clones so the source stays untouched. */
2352
+ #subsetOfProperties(
2353
+ keep: (key: string) => boolean,
2354
+ ): Record<string, RuleChain> {
2355
+ const shape = this.getProperties();
2356
+ if (!shape) {
2357
+ throw new RuneError(
2358
+ "NOT_AN_OBJECT",
2359
+ "pick()/omit() need an object() shape to work on.",
2360
+ { hint: "rules.any().object({ … }).pick([…])" },
2361
+ );
2362
+ }
2363
+ return Object.fromEntries(
2364
+ Object.entries(shape).filter(([key]) => keep(key)),
2365
+ );
2366
+ }
2367
+
2368
+ /** Make every property of an object shape optional (VineJS `partial`). */
2369
+ partial(keys?: readonly string[]): RuleChain<Output> {
2370
+ // `optional()` mutates and returns the SAME chain, so calling it on the
2371
+ // stored properties made the source shape optional too — `base.partial()`
2372
+ // silently relaxed `base`. Clone each property first, like VineJS does.
2373
+ return this.#reshape((shape) =>
2374
+ Object.fromEntries(
2375
+ Object.entries(shape).map(([key, chain]) => [
2376
+ key,
2377
+ keys === undefined || keys.includes(key)
2378
+ ? chain.clone().optional()
2379
+ : chain,
2380
+ ]),
2381
+ ),
2382
+ );
2383
+ }
2384
+
2385
+ /** Shared body of `pick`/`omit`/`partial` — rebuilds the nested shape on a clone. */
2386
+ #reshape(
2387
+ transform: (shape: Record<string, RuleChain>) => Record<string, RuleChain>,
2388
+ ): RuleChain<Output> {
2389
+ if (!this.#nestedSchema) {
2390
+ throw new RuneError(
2391
+ "NOT_AN_OBJECT",
2392
+ "pick()/omit()/partial() need an object() shape to work on.",
2393
+ {
2394
+ hint: "Declare the shape first: rules.any().object({ … }).pick([…])",
2395
+ },
2396
+ );
2397
+ }
2398
+ const next = this.#retype<Output>();
2399
+ next.#nestedSchema = transform(this.#nestedSchema);
2400
+ return next;
2401
+ }
2402
+
2403
+ /**
2404
+ * Must be an "accepted" value — `true`, `1`, `"1"`, `"on"`, `"yes"`,
2405
+ * `"true"` (VineJS `accepted`, for checkbox-style consent fields).
2406
+ */
2407
+ accepted(): RuleChain<true> {
2408
+ this.#pushRule({
2409
+ name: "accepted",
2410
+ validate: isAcceptedValue,
2411
+ message: "Must be accepted",
2412
+ });
2413
+ // Normalise ONLY an accepted value: a blanket `() => true` would rewrite a
2414
+ // refused value into an accepted one before the rule ever saw it.
2415
+ this.#transforms.push({
2416
+ name: "accepted",
2417
+ fn: (value) => (isAcceptedValue(value) ? true : value),
2418
+ });
2419
+ return this.#retype<true>();
2420
+ }
2421
+
2422
+ /**
2423
+ * Object with arbitrary keys, every value validated by `valueChain`
2424
+ * (VineJS `record`).
2425
+ */
2426
+ record<Item extends RuleChain>(
2427
+ valueChain: Item,
2428
+ ): RuleChain<Record<string, OutputOf<Item>>> {
2429
+ this.#pushRule({
2430
+ name: "record",
2431
+ validate: (v) => isPlainObject(v),
2432
+ message: "Must be an object",
2433
+ });
2434
+ this.#recordValueChain = valueChain;
2435
+ return this.#retype<Record<string, OutputOf<Item>>>();
2436
+ }
2437
+
2438
+ /**
2439
+ * Check the record's KEYS, not its values (VineJS `record().validateKeys()`).
2440
+ *
2441
+ * The callback receives every key at once and reports through the field
2442
+ * context — the set is what matters when keys must be exclusive, exhaustive,
2443
+ * or drawn from a list only known at runtime.
2444
+ */
2445
+ validateKeys(callback: RecordKeysCallback): this {
2446
+ this.#recordKeysCheck = callback;
2447
+ return this;
2448
+ }
2449
+
2450
+ /**
2451
+ * Fixed-length array with a schema per position (VineJS `tuple`). Extra
2452
+ * items are rejected — a tuple that silently ignores a trailing element is
2453
+ * how unvalidated data slips through.
2454
+ */
2455
+ tuple<const Items extends readonly RuleChain[]>(
2456
+ items: Items,
2457
+ ): RuleChain<{ [K in keyof Items]: OutputOf<Items[K]> }> {
2458
+ this.#pushRule({
2459
+ name: "tuple",
2460
+ args: { length: items.length },
2461
+ validate: (v) => Array.isArray(v) && v.length === items.length,
2462
+ message: `Must be an array of exactly ${items.length} items`,
2463
+ });
2464
+ this.#tupleChains = [...items];
2465
+ return this.#retype<{ [K in keyof Items]: OutputOf<Items[K]> }>();
2466
+ }
2467
+
2468
+ /**
2469
+ * Value must satisfy at least one of `chains`.
2470
+ *
2471
+ * Two forms, both supported:
2472
+ *
2473
+ * - guarded (VineJS parity): `union([rules.union.if(pred, chain), …,
2474
+ * rules.union.else(fallback)])` — the predicate SELECTS the branch and
2475
+ * that branch's own errors are reported, so a failure says which shape was
2476
+ * meant and why it did not fit.
2477
+ * - bare chains: tried in order, first match wins, and a total miss reports a
2478
+ * single `union` error rather than every losing branch's noise.
2479
+ */
2480
+ /**
2481
+ * What to do when NO union branch matched (VineJS `union().otherwise()`).
2482
+ *
2483
+ * The callback receives the value and the field, and reports the error it
2484
+ * wants — the point being that "matches nothing" is a useless message when
2485
+ * the caller knows which shapes were on offer. Reporting nothing from the
2486
+ * callback suppresses the generic error entirely, which is how a union
2487
+ * folded into a larger check stays quiet.
2488
+ */
2489
+ otherwise(callback: UnionNoMatchCallback): this {
2490
+ this.#unionNoMatch = callback;
2491
+ return this;
2492
+ }
2493
+
2494
+ union(chains: readonly UnionBranch[]): this {
2495
+ this.#unionChains = chains.map(toUnionBranch);
2496
+ // Marker rule: its name is not in NATIVE_RULES, which is what keeps a
2497
+ // union off the native path. The Rust engine knows nothing about branches
2498
+ // and would silently accept anything.
2499
+ this.#pushRule({
2500
+ name: "union",
2501
+ validate: () => true,
2502
+ message: "Does not match any allowed shape",
2503
+ });
2504
+ return this;
2505
+ }
2506
+
2507
+ /**
2508
+ * Must be an uploaded file (VineJS/Adonis `vine.file()`).
2509
+ *
2510
+ * Named deviation: Adonis validates a bodyparser `MultipartFile`, which rune
2511
+ * cannot import and stay agnostic. It checks the STRUCTURE instead — any
2512
+ * object exposing `size` and a name/extension — so an Adonis MultipartFile
2513
+ * satisfies it, and so does any other upload representation.
2514
+ *
2515
+ * `size` is a byte count; `extnames` are compared lowercase, without the dot.
2516
+ */
2517
+ file(options?: {
2518
+ size?: number | string;
2519
+ extnames?: readonly string[];
2520
+ /**
2521
+ * Skip the magic-number check. Default `true` — Adonis derives `extname`
2522
+ * from the real bytes before validation, so trusting the declaration is
2523
+ * NOT the safe default: a `.exe` renamed `.png` satisfies every
2524
+ * declarative check, since all of them come from the uploader.
2525
+ */
2526
+ verifyContent?: boolean;
2527
+ }): RuleChain<FileLike> {
2528
+ // Adonis documents `size: '2mb'`; a numeric-only option meant a
2529
+ // transcribed validator either failed the typecheck or, in JS, silently
2530
+ // stopped capping.
2531
+ const maxBytes =
2532
+ options?.size === undefined ? undefined : parseByteSize(options.size);
2533
+ if (options?.extnames) this.#declaredExtnames = options.extnames;
2534
+ if (options?.verifyContent === false) this.#contentVerificationOff = true;
2535
+ this.#pushRule({
2536
+ name: "file",
2537
+ args: options ? { ...options } : undefined,
2538
+ validate: (v) => {
2539
+ if (!isFileLike(v)) return false;
2540
+ if (maxBytes !== undefined && v.size > maxBytes) return false;
2541
+ if (options?.extnames) {
2542
+ const ext = fileExtension(v);
2543
+ if (ext === null) return false;
2544
+ if (!options.extnames.map((e) => e.toLowerCase()).includes(ext)) {
2545
+ return false;
2546
+ }
2547
+ }
2548
+ return true;
2549
+ },
2550
+ message: "Must be a valid file",
2551
+ });
2552
+ // Declaring an allowed extension list is a SECURITY statement, so the
2553
+ // bytes are checked by default. `{ verifyContent: false }` opts out
2554
+ // explicitly and leaves a trace in the schema.
2555
+ if (options?.extnames) this.#ensureContentVerification();
2556
+ return this.#retype<FileLike>();
2557
+ }
2558
+
2559
+ /**
2560
+ * Uploaded file with VineJS `nativeFile` options — `minSize`, `maxSize`,
2561
+ * `mimeTypes`. Same structural contract as {@link file}: rune never reads
2562
+ * bytes, so the MIME type is the one the upload REPORTS.
2563
+ */
2564
+ nativeFile(options?: {
2565
+ minSize?: number | string;
2566
+ maxSize?: number | string;
2567
+ mimeTypes?: readonly string[];
2568
+ }): RuleChain<FileLike> {
2569
+ const min =
2570
+ options?.minSize === undefined
2571
+ ? undefined
2572
+ : parseByteSize(options.minSize);
2573
+ const max =
2574
+ options?.maxSize === undefined
2575
+ ? undefined
2576
+ : parseByteSize(options.maxSize);
2577
+ this.#pushRule({
2578
+ name: "nativeFile",
2579
+ args: options ? { ...options } : undefined,
2580
+ validate: (v) => {
2581
+ if (!isFileLike(v)) return false;
2582
+ if (min !== undefined && v.size < min) return false;
2583
+ if (max !== undefined && v.size > max) return false;
2584
+ if (options?.mimeTypes) {
2585
+ const type = typeof v.type === "string" ? v.type.toLowerCase() : null;
2586
+ if (type === null) return false;
2587
+ if (!options.mimeTypes.map((m) => m.toLowerCase()).includes(type)) {
2588
+ return false;
2589
+ }
2590
+ }
2591
+ return true;
2592
+ },
2593
+ message: "Must be a valid file",
2594
+ });
2595
+ // Declaring allowed MIME types is a SECURITY statement, so the bytes are
2596
+ // checked by default.
2597
+ if (options?.mimeTypes) this.#ensureContentVerification();
2598
+ return this.#retype<FileLike>();
2599
+ }
2600
+
2601
+ /** Minimum upload size (VineJS `nativeFile().minSize()`). */
2602
+ minSize(size: number | string): this {
2603
+ const min = parseByteSize(size);
2604
+ this.#pushRule({
2605
+ name: "minSize",
2606
+ args: { size },
2607
+ validate: (v) => isFileLike(v) && v.size >= min,
2608
+ message: `Must be at least ${size} in size`,
2609
+ });
2610
+ return this;
2611
+ }
2612
+
2613
+ /** Maximum upload size (VineJS `nativeFile().maxSize()`). */
2614
+ maxSize(size: number | string): this {
2615
+ const max = parseByteSize(size);
2616
+ this.#pushRule({
2617
+ name: "maxSize",
2618
+ args: { size },
2619
+ validate: (v) => isFileLike(v) && v.size <= max,
2620
+ message: `Must be at most ${size} in size`,
2621
+ });
2622
+ return this;
2623
+ }
2624
+
2625
+ /**
2626
+ * Allowed MIME types (VineJS `nativeFile().mimeTypes()`). The type is the one
2627
+ * the upload REPORTS — rune never reads bytes, see {@link file}.
2628
+ */
2629
+ mimeTypes(types: readonly string[]): this {
2630
+ const allowed = types.map((t) => t.toLowerCase());
2631
+ this.#declaredMimeTypes = allowed;
2632
+ this.#ensureContentVerification();
2633
+ this.#pushRule({
2634
+ name: "mimeTypes",
2635
+ args: { types: allowed },
2636
+ validate: (v) =>
2637
+ isFileLike(v) &&
2638
+ typeof v.type === "string" &&
2639
+ allowed.includes(v.type.toLowerCase()),
2640
+ message: `Must be one of ${allowed.join(", ")}`,
2641
+ });
2642
+ return this;
2643
+ }
2644
+
2645
+ /**
2646
+ * Verify the file's REAL type against its magic number (Adonis parity).
2647
+ *
2648
+ * A `.exe` renamed `.jpg` passes every declarative check — size, extension,
2649
+ * reported MIME — because all three come from the uploader. This reads the
2650
+ * leading bytes and refuses a mismatch.
2651
+ *
2652
+ * Async by nature (it touches the filesystem), so the schema must run with
2653
+ * `validateResultAsync` / `validate`. Needs a byte source on the file object
2654
+ * (`buffer`, `tmpPath`, `filePath` or `path`) — an Adonis `MultipartFile`
2655
+ * carries `tmpPath`. With NO source it FAILS: a content check that cannot
2656
+ * run must never look like one that passed.
2657
+ */
2658
+ verifyContent(): this {
2659
+ this.#contentVerificationOff = false;
2660
+ return this.#ensureContentVerification();
2661
+ }
2662
+
2663
+ /** Register the content check once, honouring an explicit opt-out. */
2664
+ #ensureContentVerification(): this {
2665
+ if (this.#contentVerified || this.#contentVerificationOff) return this;
2666
+ this.#contentVerified = true;
2667
+ return this.#registerContentVerification();
2668
+ }
2669
+
2670
+ /** The async rule itself — reads the bytes and confronts the declaration. */
2671
+ #registerContentVerification(): this {
2672
+ const extnames = this.#declaredExtnames;
2673
+ const mimeTypes = this.#declaredMimeTypes;
2674
+ this.#pushAsync({
2675
+ __rune: "asyncRule",
2676
+ async run(value: unknown, field: FieldContext): Promise<void> {
2677
+ if (!isFileLike(value)) {
2678
+ field.report("Must be a valid file", "verifyContent");
2679
+ return;
2680
+ }
2681
+ const head = await readFileHead(value);
2682
+ if (head === null) {
2683
+ field.report(
2684
+ "Cannot read the file's content to verify its type",
2685
+ "verifyContent",
2686
+ );
2687
+ return;
2688
+ }
2689
+ const detected = detectFileType(head);
2690
+ if (detected === null) {
2691
+ field.report("File type could not be recognised", "verifyContent");
2692
+ return;
2693
+ }
2694
+ // The declared extension must agree with the bytes.
2695
+ const declaredExt =
2696
+ typeof value.extname === "string" && value.extname.length > 0
2697
+ ? value.extname
2698
+ : null;
2699
+ if (declaredExt && !extensionMatches(detected.ext, declaredExt)) {
2700
+ field.report(
2701
+ `Content is ${detected.ext}, not ${declaredExt.replace(/^\./, "")}`,
2702
+ "verifyContent",
2703
+ );
2704
+ return;
2705
+ }
2706
+ if (
2707
+ extnames &&
2708
+ !extnames.some((allowed) => extensionMatches(detected.ext, allowed))
2709
+ ) {
2710
+ field.report(
2711
+ `Content is ${detected.ext}, which is not allowed`,
2712
+ "verifyContent",
2713
+ );
2714
+ return;
2715
+ }
2716
+ if (mimeTypes && !mimeTypes.includes(detected.mime)) {
2717
+ field.report(
2718
+ `Content is ${detected.mime}, which is not allowed`,
2719
+ "verifyContent",
2720
+ );
2721
+ }
2722
+ },
2723
+ });
2724
+ return this;
2725
+ }
2726
+
2727
+ /**
2728
+ * Must equal one of `values` (enum). Narrows the output to the union.
2729
+ *
2730
+ * `values` may be a callback receiving the field, which is how a list that
2731
+ * depends on the request — the roles this tenant allows, the statuses this
2732
+ * user may set — is computed per validation instead of frozen at import.
2733
+ */
636
2734
  enum<const V extends readonly (string | number | boolean)[]>(
637
- values: V,
2735
+ values: V | ((field: FieldContext) => V),
638
2736
  ): RuleChain<V[number]> {
639
- const allowed = [...values];
640
- this.#rules.push({
2737
+ const lazy = typeof values === "function";
2738
+ const resolve = allowedValuesResolver(values);
2739
+ this.#enumChoices = lazy ? values : [...values];
2740
+ this.#pushRule({
641
2741
  name: "enum",
642
- args: { values: allowed },
643
- validate: (v) => allowed.includes(asPrimitive(v)),
2742
+ args: lazy ? {} : { values: [...values] },
2743
+ // A computed list is per-call; the native engine only ever sees a
2744
+ // static array, so it must not run this rule.
2745
+ tsOnly: lazy,
2746
+ validate: (v, field) => resolve(field).includes(asPrimitive(v)),
644
2747
  message: "Invalid value",
645
2748
  });
646
2749
  return this.#retype<V[number]>();
647
2750
  }
648
2751
 
2752
+ /**
2753
+ * The choices this enum was declared with (VineJS `getChoices()`) — the list
2754
+ * itself, or the callback when it is computed per request.
2755
+ *
2756
+ * Reading them back is what lets a form render the same options the
2757
+ * validator will accept, from one declaration instead of two.
2758
+ */
2759
+ getChoices():
2760
+ | ReadonlyArray<string | number | boolean>
2761
+ | ((field: FieldContext) => ReadonlyArray<string | number | boolean>)
2762
+ | undefined {
2763
+ return this.#enumChoices ?? undefined;
2764
+ }
2765
+
649
2766
  /** Must equal a literal value. */
650
2767
  literal<V extends string | number | boolean>(value: V): RuleChain<V> {
651
- this.#rules.push({
2768
+ this.#pushRule({
652
2769
  name: "literal",
653
- args: { expectedValue: value },
2770
+ args: { value, expectedValue: value },
654
2771
  validate: (v) => v === value,
655
2772
  message: `Must be ${String(value)}`,
656
2773
  });
@@ -659,7 +2776,7 @@ export class RuleChain<Output = unknown> {
659
2776
 
660
2777
  /** Minimum length (string) or minimum value (number). Alias of min/minLength. */
661
2778
  min(n: number): this {
662
- this.#rules.push({
2779
+ this.#pushRule({
663
2780
  name: "min",
664
2781
  param: n,
665
2782
  args: { min: n },
@@ -676,7 +2793,7 @@ export class RuleChain<Output = unknown> {
676
2793
 
677
2794
  /** Maximum length (string) or maximum value (number). Alias of max/maxLength. */
678
2795
  max(n: number): this {
679
- this.#rules.push({
2796
+ this.#pushRule({
680
2797
  name: "max",
681
2798
  param: n,
682
2799
  args: { max: n },
@@ -691,105 +2808,549 @@ export class RuleChain<Output = unknown> {
691
2808
  return this;
692
2809
  }
693
2810
 
694
- /** Minimum length for a string or array (VineJS `minLength`). */
695
- minLength(n: number): this {
696
- this.#rules.push({
697
- name: "minLength",
698
- param: n,
699
- args: { min: n },
700
- validate: (v) => sizedLength(v) >= n,
701
- message: `Must have at least ${n} characters`,
2811
+ /** Minimum length for a string or array (VineJS `minLength`). */
2812
+ minLength(n: number): this {
2813
+ this.#pushRule({
2814
+ name: "minLength",
2815
+ param: n,
2816
+ args: { min: n },
2817
+ validate: (v) => sizedLength(v) >= n,
2818
+ message: `Must have at least ${n} characters`,
2819
+ });
2820
+ return this;
2821
+ }
2822
+
2823
+ /** Maximum length for a string or array (VineJS `maxLength`). */
2824
+ maxLength(n: number): this {
2825
+ this.#pushRule({
2826
+ name: "maxLength",
2827
+ param: n,
2828
+ args: { max: n },
2829
+ validate: (v) => {
2830
+ const len = sizedLength(v);
2831
+ return len >= 0 && len <= n;
2832
+ },
2833
+ message: `Must not exceed ${n} characters`,
2834
+ });
2835
+ return this;
2836
+ }
2837
+
2838
+ /** Exact length for a string or array (VineJS `fixedLength`). */
2839
+ fixedLength(n: number): this {
2840
+ this.#pushRule({
2841
+ name: "fixedLength",
2842
+ param: n,
2843
+ args: { size: n },
2844
+ validate: (v) => sizedLength(v) === n,
2845
+ message: `Must be exactly ${n} characters`,
2846
+ });
2847
+ return this;
2848
+ }
2849
+
2850
+ /** Must be a valid email. */
2851
+ email(options?: EmailOptions): this {
2852
+ return this.#stringRule(
2853
+ "email",
2854
+ (v) => isEmail(v, options),
2855
+ "Must be a valid email address",
2856
+ options ? { ...options } : undefined,
2857
+ );
2858
+ }
2859
+
2860
+ /** Must match a regular expression (TS-only — never dispatched to Rust). */
2861
+ regex(pattern: RegExp): this {
2862
+ this.#pushRule({
2863
+ name: "regex",
2864
+ validate: (v) => typeof v === "string" && pattern.test(v),
2865
+ message: "Invalid format",
2866
+ });
2867
+ return this;
2868
+ }
2869
+
2870
+ /** Must be a valid URL (TS-only — uses the WHATWG URL parser). */
2871
+ url(options?: UrlOptions): this {
2872
+ this.#pushRule({
2873
+ name: "url",
2874
+ args: options ? { ...options } : undefined,
2875
+ validate: (v) =>
2876
+ typeof v === "string" &&
2877
+ (options ? isUrlWithOptions(v, options) : isValidUrl(v)),
2878
+ message: "Must be a valid URL",
2879
+ });
2880
+ return this;
2881
+ }
2882
+
2883
+ /**
2884
+ * The host must actually resolve (VineJS `activeUrl`).
2885
+ *
2886
+ * The only rule needing the network, which rune cannot do and stay agnostic
2887
+ * and zero-dependency — so it runs through a resolver bound once at boot,
2888
+ * exactly like `unique()`. Async by nature: run the schema with
2889
+ * `validateResultAsync`. Unbound it THROWS, rather than passing a host nobody
2890
+ * checked.
2891
+ */
2892
+ activeUrl(): this {
2893
+ this.#pushAsync({
2894
+ __rune: "asyncRule",
2895
+ async run(value: unknown, field: FieldContext): Promise<void> {
2896
+ if (!hostResolver) {
2897
+ throw new RuneError(
2898
+ "NO_HOST_RESOLVER",
2899
+ "activeUrl() needs a host resolver.",
2900
+ { hint: "Call bindHostResolver(resolver) once at boot." },
2901
+ );
2902
+ }
2903
+ let host: string;
2904
+ try {
2905
+ host = new URL(String(value)).hostname;
2906
+ } catch {
2907
+ field.report("Must be a valid URL", "activeUrl");
2908
+ return;
2909
+ }
2910
+ if (!(await hostResolver.resolves(host))) {
2911
+ field.report("Must be an active URL", "activeUrl");
2912
+ }
2913
+ },
2914
+ });
2915
+ return this;
2916
+ }
2917
+
2918
+ /**
2919
+ * Must be a valid UUID, optionally restricted to given versions
2920
+ * (VineJS `uuid({ version: [4] })`, versions 1 through 8).
2921
+ */
2922
+ uuid(options?: { version?: number | number[] }): this {
2923
+ const versions =
2924
+ options?.version === undefined ? undefined : [options.version].flat();
2925
+ this.#pushRule({
2926
+ name: "uuid",
2927
+ args: versions === undefined ? {} : { version: versions },
2928
+ // The Rust engine checks UUID shape only; a version constraint would
2929
+ // be dropped there.
2930
+ tsOnly: versions !== undefined,
2931
+ validate: (v) => {
2932
+ if (typeof v !== "string" || !UUID_RE.test(v)) return false;
2933
+ if (versions === undefined) return true;
2934
+ // Version nibble: first character of the third group.
2935
+ const version = Number.parseInt(v[14] ?? "", 16);
2936
+ return versions.includes(version);
2937
+ },
2938
+ message:
2939
+ versions === undefined
2940
+ ? "Must be a valid UUID"
2941
+ : `Must be a UUID v${versions.join("/")}`,
2942
+ });
2943
+ return this;
2944
+ }
2945
+
2946
+ /** Must be a ULID (VineJS `ulid`). */
2947
+ ulid(): this {
2948
+ return this.#stringRule("ulid", isUlid, "Must be a valid ULID");
2949
+ }
2950
+
2951
+ /** Must be a JSON Web Token — three dot-separated base64url segments. */
2952
+ jwt(): this {
2953
+ return this.#stringRule("jwt", isJwt, "Must be a valid JWT");
2954
+ }
2955
+
2956
+ /** Must contain only ASCII characters (VineJS `ascii`). */
2957
+ ascii(): this {
2958
+ return this.#stringRule(
2959
+ "ascii",
2960
+ isAscii,
2961
+ "Must contain only ASCII characters",
2962
+ );
2963
+ }
2964
+
2965
+ /** Must be a CSS hex colour code, with or without the leading `#`. */
2966
+ hexCode(): this {
2967
+ return this.#stringRule("hexCode", isHexCode, "Must be a valid hex code");
2968
+ }
2969
+
2970
+ /** Must be an IP address. Pass `version` to require v4 or v6 specifically. */
2971
+ ipAddress(options?: { version?: 4 | 6 }): this {
2972
+ const version = options?.version;
2973
+ return this.#stringRule(
2974
+ "ipAddress",
2975
+ (v) => isIpAddress(v, version),
2976
+ `Must be a valid IP address${version ? ` (v${version})` : ""}`,
2977
+ { version },
2978
+ );
2979
+ }
2980
+
2981
+ /** Must pass the Luhn checksum (VineJS `creditCard`). */
2982
+ creditCard(): this {
2983
+ return this.#stringRule(
2984
+ "creditCard",
2985
+ isCreditCard,
2986
+ "Must be a valid credit card number",
2987
+ );
2988
+ }
2989
+
2990
+ /** Must be an IBAN passing the ISO 13616 mod-97 check. */
2991
+ iban(): this {
2992
+ return this.#stringRule("iban", isIban, "Must be a valid IBAN");
2993
+ }
2994
+
2995
+ /** Must be a `"lat,lng"` pair within the valid ranges. */
2996
+ coordinates(): this {
2997
+ return this.#stringRule(
2998
+ "coordinates",
2999
+ isCoordinates,
3000
+ "Must be valid coordinates",
3001
+ );
3002
+ }
3003
+
3004
+ /**
3005
+ * Must be a mobile number (VineJS/Adonis `mobile()`).
3006
+ *
3007
+ * With no `locale`, the number must be in E.164 form. With one or more,
3008
+ * it must match one of their numbering plans, as in VineJS. rune carries
3009
+ * its own plans rather than validator.js', so it knows fewer locales — an
3010
+ * unknown one raises `UNSUPPORTED_LOCALE` at schema build, naming the ones
3011
+ * it does know, rather than silently accepting anything at request time.
3012
+ */
3013
+ mobile(options?: { locale?: string | string[]; strictMode?: boolean }): this {
3014
+ const locales = options?.locale ? [options.locale].flat() : null;
3015
+ for (const locale of locales ?? []) {
3016
+ if (isMobileForLocale("", locale) === null) {
3017
+ throw new RuneError(
3018
+ "UNSUPPORTED_LOCALE",
3019
+ `mobile(): no numbering plan for locale '${locale}'.`,
3020
+ {
3021
+ hint: `Supported: ${SUPPORTED_MOBILE_LOCALES.join(", ")}. Omit the locale for E.164, or use .regex().`,
3022
+ },
3023
+ );
3024
+ }
3025
+ }
3026
+ return this.#stringRule(
3027
+ "mobile",
3028
+ (v) => {
3029
+ // strictMode (validator.js): the number must carry its `+` country
3030
+ // prefix, so a national-format string is not silently accepted.
3031
+ if (options?.strictMode && !v.trim().startsWith("+")) return false;
3032
+ return locales
3033
+ ? locales.some((locale) => isMobileForLocale(v, locale) === true)
3034
+ : isMobile(v);
3035
+ },
3036
+ "Must be a valid mobile number",
3037
+ (locales ?? options?.strictMode)
3038
+ ? { locale: locales, strictMode: options?.strictMode }
3039
+ : undefined,
3040
+ );
3041
+ }
3042
+
3043
+ /**
3044
+ * Must be a postal code for `countryCode`. Throws for a country rune has no
3045
+ * pattern for, rather than accepting the value unchecked.
3046
+ */
3047
+ postalCode(
3048
+ options:
3049
+ | { countryCode: string | string[] }
3050
+ | ((field: FieldContext) => {
3051
+ countryCode: string | string[];
3052
+ }),
3053
+ ): this {
3054
+ // The callback form resolves per validation (VineJS lets the country come
3055
+ // from a sibling field), so its countries cannot be checked up front.
3056
+ if (typeof options === "function") {
3057
+ this.#pushUse({
3058
+ __rune: "rule",
3059
+ run: (value, field) => {
3060
+ if (typeof value !== "string") return;
3061
+ const countries = [options(field).countryCode].flat();
3062
+ if (!countries.some((c) => isPostalCode(value, c) === true)) {
3063
+ field.report(
3064
+ `Must be a valid ${countries.join("/")} postal code`,
3065
+ "postalCode",
3066
+ );
3067
+ }
3068
+ },
3069
+ });
3070
+ return this;
3071
+ }
3072
+ const countries = [options.countryCode].flat();
3073
+ for (const country of countries) {
3074
+ if (isPostalCode("", country) === null) {
3075
+ throw new RuneError(
3076
+ "UNSUPPORTED_COUNTRY",
3077
+ `postalCode(): no pattern for country '${country}'.`,
3078
+ {
3079
+ hint: `Supported: ${SUPPORTED_POSTAL_CODES.join(", ")}. Use .regex() for others.`,
3080
+ },
3081
+ );
3082
+ }
3083
+ }
3084
+ return this.#stringRule(
3085
+ "postalCode",
3086
+ (v) => countries.some((c) => isPostalCode(v, c) === true),
3087
+ `Must be a valid ${countries.join("/").toUpperCase()} postal code`,
3088
+ { countryCode: countries },
3089
+ );
3090
+ }
3091
+
3092
+ /**
3093
+ * Must be a valid VAT number (VineJS 4.2 `vat`). Accepts a country list or a
3094
+ * callback resolving it per payload.
3095
+ *
3096
+ * Checksums are run where the country defines a short, well-defined one
3097
+ * (BE, DE, NL, IT, PT, LU, CH); the others are FORMAT-only, which is stated
3098
+ * rather than implied. An unknown country LEVES rather than accepting the
3099
+ * value unchecked.
3100
+ */
3101
+ vat(options: VatOptions | ((field: FieldContext) => VatOptions)): this {
3102
+ if (typeof options === "function") {
3103
+ this.#pushUse({
3104
+ __rune: "rule",
3105
+ run: (value, field) => {
3106
+ if (typeof value !== "string") return;
3107
+ const countries = [options(field).countryCode].flat();
3108
+ if (!countries.some((c) => isVat(value, c) === true)) {
3109
+ field.report(
3110
+ `Must be a valid ${countries.join("/")} VAT number`,
3111
+ "vat",
3112
+ );
3113
+ }
3114
+ },
3115
+ });
3116
+ return this;
3117
+ }
3118
+ const countries = [options.countryCode].flat();
3119
+ for (const country of countries) {
3120
+ if (isVat("", country) === null) {
3121
+ throw new RuneError(
3122
+ "UNSUPPORTED_COUNTRY",
3123
+ `vat(): no rule for country '${country}'.`,
3124
+ {
3125
+ hint: `Supported: ${SUPPORTED_VAT_COUNTRIES.join(", ")}. Use .regex() for others.`,
3126
+ },
3127
+ );
3128
+ }
3129
+ }
3130
+ return this.#stringRule(
3131
+ "vat",
3132
+ (v) => countries.some((c) => isVat(v, c) === true),
3133
+ `Must be a valid ${countries.join("/").toUpperCase()} VAT number`,
3134
+ { countryCode: countries },
3135
+ );
3136
+ }
3137
+
3138
+ /** Must differ from a sibling field (VineJS `notSameAs`). */
3139
+ notSameAs(otherField: string): this {
3140
+ const formats = this.#dateFormats;
3141
+ this.#pushUse({
3142
+ __rune: "rule",
3143
+ run: (value, field) => {
3144
+ const other = readSibling(field, otherField);
3145
+ if (formats !== null && value instanceof Date) {
3146
+ const parsed = parseDateValue(other, formats);
3147
+ if (parsed !== null && parsed.getTime() === value.getTime()) {
3148
+ field.report(`Must be different from ${otherField}`, "notSameAs");
3149
+ }
3150
+ return;
3151
+ }
3152
+ if (value === other) {
3153
+ field.report(`Must be different from ${otherField}`, "notSameAs");
3154
+ }
3155
+ },
3156
+ });
3157
+ return this;
3158
+ }
3159
+
3160
+ /** Array items must be unique — optionally compared on `field` (VineJS `distinct`). */
3161
+ distinct(field?: string | string[]): this {
3162
+ this.#pushRule({
3163
+ name: "distinct",
3164
+ args: { field },
3165
+ validate: (v) => {
3166
+ if (!Array.isArray(v)) return false;
3167
+ const fieldList = field === undefined ? null : [field].flat();
3168
+ const keys: string[] = [];
3169
+ for (const item of v) {
3170
+ // VineJS ignores null/undefined items entirely: `[1, null, 2, null]`
3171
+ // is distinct. Serialising them would make the second one a
3172
+ // duplicate of the first.
3173
+ if (item === null || item === undefined) continue;
3174
+ if (fieldList === null) {
3175
+ keys.push(JSON.stringify(item));
3176
+ continue;
3177
+ }
3178
+ if (!isPlainObject(item)) continue;
3179
+ // VineJS skips an item missing the key(s): two absent values are
3180
+ // not a duplicate of each other.
3181
+ if (
3182
+ fieldList.some((k) => item[k] === undefined || item[k] === null)
3183
+ ) {
3184
+ continue;
3185
+ }
3186
+ keys.push(JSON.stringify(fieldList.map((k) => item[k])));
3187
+ }
3188
+ return new Set(keys).size === keys.length;
3189
+ },
3190
+ message: field
3191
+ ? `Items must have a unique ${field}`
3192
+ : "Items must be unique",
702
3193
  });
703
3194
  return this;
704
3195
  }
705
3196
 
706
- /** Maximum length for a string or array (VineJS `maxLength`). */
707
- maxLength(n: number): this {
708
- this.#rules.push({
709
- name: "maxLength",
710
- param: n,
711
- args: { max: n },
712
- validate: (v) => {
713
- const len = sizedLength(v);
714
- return len >= 0 && len <= n;
715
- },
716
- message: `Must not exceed ${n} characters`,
3197
+ /** Must be less than or equal to zero (VineJS `nonPositive`). */
3198
+ nonPositive(): this {
3199
+ this.#pushRule({
3200
+ name: "nonPositive",
3201
+ validate: (v) => typeof v === "number" && v <= 0,
3202
+ message: "Must be zero or negative",
717
3203
  });
718
3204
  return this;
719
3205
  }
720
3206
 
721
- /** Exact length for a string or array (VineJS `fixedLength`). */
722
- fixedLength(n: number): this {
723
- this.#rules.push({
724
- name: "fixedLength",
725
- param: n,
726
- args: { size: n },
727
- validate: (v) => sizedLength(v) === n,
728
- message: `Must be exactly ${n} characters`,
3207
+ /** Array must hold at least one item (VineJS `notEmpty`). */
3208
+ notEmpty(): this {
3209
+ this.#pushRule({
3210
+ name: "notEmpty",
3211
+ validate: (v) => Array.isArray(v) && v.length > 0,
3212
+ message: "Must not be empty",
729
3213
  });
730
3214
  return this;
731
3215
  }
732
3216
 
733
- /** Must be a valid email. */
734
- email(): this {
735
- this.#rules.push({
736
- name: "email",
737
- // Mirror the Rust engine's regex exactly so the SAME schema validates
738
- // identically whether or not the native binary loaded.
739
- validate: (v) =>
740
- typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
741
- message: "Must be a valid email",
3217
+ /** Drop `null`, `undefined` and `""` items before the item rules run. */
3218
+ compact(): this {
3219
+ this.#transforms.push({
3220
+ name: "compact",
3221
+ fn: (value) =>
3222
+ Array.isArray(value)
3223
+ ? value.filter(
3224
+ (item) => item !== null && item !== undefined && item !== "",
3225
+ )
3226
+ : value,
742
3227
  });
743
3228
  return this;
744
3229
  }
745
3230
 
746
- /** Must match a regular expression (TS-only — never dispatched to Rust). */
747
- regex(pattern: RegExp): this {
748
- this.#rules.push({
749
- name: "regex",
750
- validate: (v) => typeof v === "string" && pattern.test(v),
751
- message: "Invalid format",
3231
+ /** Number must have no fractional part (VineJS `withoutDecimals`). */
3232
+ withoutDecimals(): this {
3233
+ this.#pushRule({
3234
+ name: "withoutDecimals",
3235
+ validate: (v) => typeof v === "number" && Number.isInteger(v),
3236
+ message: "Must not have decimals",
752
3237
  });
753
3238
  return this;
754
3239
  }
755
3240
 
756
- /** Must be a valid URL (TS-only uses the WHATWG URL parser). */
757
- url(): this {
758
- this.#rules.push({
759
- name: "url",
760
- validate: (v) => typeof v === "string" && isValidUrl(v),
761
- message: "Must be a valid URL",
3241
+ /** Shared body of the string-format rules: reject non-strings, then check. */
3242
+ #stringRule(
3243
+ name: string,
3244
+ check: (value: string) => boolean,
3245
+ message: string,
3246
+ args?: Record<string, unknown>,
3247
+ ): this {
3248
+ this.#pushRule({
3249
+ name,
3250
+ args,
3251
+ validate: (v) => typeof v === "string" && check(v),
3252
+ message,
762
3253
  });
763
3254
  return this;
764
3255
  }
765
3256
 
766
- /** Must be a valid UUID. */
767
- uuid(): this {
768
- this.#rules.push({
769
- name: "uuid",
770
- validate: (v) => typeof v === "string" && UUID_RE.test(v),
771
- message: "Must be a valid UUID",
3257
+ /** Must be a passport number for `countryCode`. Throws for an uncovered country. */
3258
+ passport(options: { countryCode: string | string[] }): this {
3259
+ const countries = [options.countryCode].flat();
3260
+ for (const country of countries) {
3261
+ if (isPassport("", country) === null) {
3262
+ throw new RuneError(
3263
+ "UNSUPPORTED_COUNTRY",
3264
+ `passport(): no pattern for country '${country}'.`,
3265
+ {
3266
+ hint: `Supported: ${SUPPORTED_PASSPORTS.join(", ")}. Use .regex() for others.`,
3267
+ },
3268
+ );
3269
+ }
3270
+ }
3271
+ return this.#stringRule(
3272
+ "passport",
3273
+ (v) => countries.some((c) => isPassport(v, c) === true),
3274
+ `Must be a valid ${countries.join("/").toUpperCase()} passport number`,
3275
+ { countryCode: countries },
3276
+ );
3277
+ }
3278
+
3279
+ /** Lowercase the value (VineJS `toLowerCase`). */
3280
+ toLowerCase(): this {
3281
+ return this.#stringMutation("toLowerCase", (v) => v.toLowerCase());
3282
+ }
3283
+
3284
+ /** Uppercase the value (VineJS `toUpperCase`). */
3285
+ toUpperCase(): this {
3286
+ return this.#stringMutation("toUpperCase", (v) => v.toUpperCase());
3287
+ }
3288
+
3289
+ /**
3290
+ * VineJS `toCamelCase()`, on both shapes it exists for:
3291
+ *
3292
+ * - on an `object()` chain it camelCases the object's KEYS
3293
+ * (`VineObject.toCamelCase`);
3294
+ * - on any other chain it camelCases the string VALUE (`VineString`).
3295
+ *
3296
+ * One name, because Vine has one name. Dispatching on whether a nested shape
3297
+ * was declared is what keeps a transcribed validator behaving the same.
3298
+ */
3299
+ toCamelCase(): this {
3300
+ if (this.#nestedSchema) {
3301
+ this.#camelCaseKeys = true;
3302
+ return this;
3303
+ }
3304
+ return this.#stringMutation("toCamelCase", toCamelCase);
3305
+ }
3306
+
3307
+ /** HTML-escape `& < > " '` (VineJS `escape`). */
3308
+ escape(): this {
3309
+ return this.#stringMutation("escape", escapeHtml);
3310
+ }
3311
+
3312
+ /** Normalise an email address (VineJS `normalizeEmail`). */
3313
+ normalizeEmail(options?: NormalizeEmailOptions): this {
3314
+ return this.#stringMutation("normalizeEmail", (v) =>
3315
+ normalizeEmail(v, options),
3316
+ );
3317
+ }
3318
+
3319
+ /** Normalise a URL (VineJS `normalizeUrl`). */
3320
+ normalizeUrl(options?: NormalizeUrlOptions): this {
3321
+ return this.#stringMutation("normalizeUrl", (v) =>
3322
+ normalizeUrl(v, options),
3323
+ );
3324
+ }
3325
+
3326
+ /** Shared body of the string mutations — non-strings pass through untouched. */
3327
+ #stringMutation(name: string, fn: (value: string) => string): this {
3328
+ this.#transforms.push({
3329
+ name,
3330
+ fn: (value) => (typeof value === "string" ? fn(value) : value),
772
3331
  });
773
3332
  return this;
774
3333
  }
775
3334
 
776
3335
  /** Must contain only ASCII letters. */
777
- alpha(): this {
778
- this.#rules.push({
3336
+ alpha(options?: AlphaOptions): this {
3337
+ const pattern = alphaPattern("a-zA-Z", options);
3338
+ this.#pushRule({
779
3339
  name: "alpha",
780
- validate: (v) =>
781
- typeof v === "string" && v.length > 0 && /^[a-zA-Z]+$/.test(v),
3340
+ args: options ? { ...options } : undefined,
3341
+ validate: (v) => typeof v === "string" && v.length > 0 && pattern.test(v),
782
3342
  message: "Must contain only letters",
783
3343
  });
784
3344
  return this;
785
3345
  }
786
3346
 
787
3347
  /** Must contain only ASCII letters and digits. */
788
- alphaNumeric(): this {
789
- this.#rules.push({
3348
+ alphaNumeric(options?: AlphaOptions): this {
3349
+ const pattern = alphaPattern("a-zA-Z0-9", options);
3350
+ this.#pushRule({
790
3351
  name: "alphaNumeric",
791
- validate: (v) =>
792
- typeof v === "string" && v.length > 0 && /^[a-zA-Z0-9]+$/.test(v),
3352
+ args: options ? { ...options } : undefined,
3353
+ validate: (v) => typeof v === "string" && v.length > 0 && pattern.test(v),
793
3354
  message: "Must contain only letters and numbers",
794
3355
  });
795
3356
  return this;
@@ -797,7 +3358,7 @@ export class RuleChain<Output = unknown> {
797
3358
 
798
3359
  /** String must start with `substring`. */
799
3360
  startsWith(substring: string): this {
800
- this.#rules.push({
3361
+ this.#pushRule({
801
3362
  name: "startsWith",
802
3363
  args: { substring },
803
3364
  validate: (v) => typeof v === "string" && v.startsWith(substring),
@@ -808,7 +3369,7 @@ export class RuleChain<Output = unknown> {
808
3369
 
809
3370
  /** String must end with `substring`. */
810
3371
  endsWith(substring: string): this {
811
- this.#rules.push({
3372
+ this.#pushRule({
812
3373
  name: "endsWith",
813
3374
  args: { substring },
814
3375
  validate: (v) => typeof v === "string" && v.endsWith(substring),
@@ -817,25 +3378,35 @@ export class RuleChain<Output = unknown> {
817
3378
  return this;
818
3379
  }
819
3380
 
820
- /** Value must be one of `values`. */
821
- in(values: ReadonlyArray<string | number | boolean>): this {
822
- const allowed = [...values];
823
- this.#rules.push({
3381
+ /**
3382
+ * Value must be one of `values`.
3383
+ *
3384
+ * VineJS also accepts a callback so the list can be computed at validation
3385
+ * time (tenant-scoped roles, values read from config…). A static array is
3386
+ * snapshotted; a callback is invoked on every check.
3387
+ */
3388
+ in(values: AllowedValues): this {
3389
+ const resolve = allowedValuesResolver(values);
3390
+ this.#pushRule({
824
3391
  name: "in",
825
- args: { values: allowed },
826
- validate: (v) => allowed.includes(asPrimitive(v)),
3392
+ args: typeof values === "function" ? {} : { values: [...values] },
3393
+ // A callback list is computed per call — the native engine only ever
3394
+ // sees a static array, so it must not run this rule.
3395
+ tsOnly: typeof values === "function",
3396
+ validate: (v, field) => resolve(field).includes(asPrimitive(v)),
827
3397
  message: "Invalid value",
828
3398
  });
829
3399
  return this;
830
3400
  }
831
3401
 
832
3402
  /** Value must NOT be one of `values`. */
833
- notIn(values: ReadonlyArray<string | number | boolean>): this {
834
- const denied = [...values];
835
- this.#rules.push({
3403
+ notIn(values: AllowedValues): this {
3404
+ const resolve = allowedValuesResolver(values);
3405
+ this.#pushRule({
836
3406
  name: "notIn",
837
- args: { values: denied },
838
- validate: (v) => !denied.includes(asPrimitive(v)),
3407
+ args: typeof values === "function" ? {} : { values: [...values] },
3408
+ tsOnly: typeof values === "function",
3409
+ validate: (v, field) => !resolve(field).includes(asPrimitive(v)),
839
3410
  message: "Invalid value",
840
3411
  });
841
3412
  return this;
@@ -843,7 +3414,7 @@ export class RuleChain<Output = unknown> {
843
3414
 
844
3415
  /** Number must be positive (> 0) and finite. */
845
3416
  positive(): this {
846
- this.#rules.push({
3417
+ this.#pushRule({
847
3418
  name: "positive",
848
3419
  validate: (v) => typeof v === "number" && Number.isFinite(v) && v > 0,
849
3420
  message: "Must be positive",
@@ -853,7 +3424,7 @@ export class RuleChain<Output = unknown> {
853
3424
 
854
3425
  /** Number must be negative (< 0) and finite. */
855
3426
  negative(): this {
856
- this.#rules.push({
3427
+ this.#pushRule({
857
3428
  name: "negative",
858
3429
  validate: (v) => typeof v === "number" && Number.isFinite(v) && v < 0,
859
3430
  message: "Must be negative",
@@ -863,7 +3434,7 @@ export class RuleChain<Output = unknown> {
863
3434
 
864
3435
  /** Number must be >= 0 and finite. */
865
3436
  nonNegative(): this {
866
- this.#rules.push({
3437
+ this.#pushRule({
867
3438
  name: "nonNegative",
868
3439
  validate: (v) => typeof v === "number" && Number.isFinite(v) && v >= 0,
869
3440
  message: "Must be positive or zero",
@@ -872,38 +3443,55 @@ export class RuleChain<Output = unknown> {
872
3443
  }
873
3444
 
874
3445
  /** Number must fall within `[min, max]` (inclusive). */
875
- range(min: number, max: number): this {
876
- this.#rules.push({
3446
+ range(bounds: [min: number, max: number]): this {
3447
+ // VineJS signature is a TUPLE (`range([18, 60])`); the two-argument form
3448
+ // silently dropped `max` when an Adonis validator was transcribed as-is.
3449
+ const [min, max] = bounds;
3450
+ this.#pushRule({
877
3451
  name: "range",
878
3452
  args: { min, max },
879
- validate: (v) =>
880
- typeof v === "number" && Number.isFinite(v) && v >= min && v <= max,
3453
+ validate: (v) => typeof v === "number" && v >= min && v <= max,
881
3454
  message: `Must be between ${min} and ${max}`,
882
3455
  });
883
3456
  return this;
884
3457
  }
885
3458
 
886
3459
  /** Number must have at most `digits` decimal places (TS-only). */
887
- decimal(digits: number): this {
888
- this.#rules.push({
3460
+ decimal(digits: number | [number, number]): this {
3461
+ // VineJS accepts a `[min, max]` range as well as a single maximum.
3462
+ const [min, max] = Array.isArray(digits) ? digits : [0, digits];
3463
+ this.#pushRule({
889
3464
  name: "decimal",
890
3465
  args: { digits },
891
3466
  validate: (v) => {
892
3467
  if (typeof v !== "number" || !Number.isFinite(v)) return false;
893
- const parts = String(v).split(".");
894
- return (parts[1]?.length ?? 0) <= digits;
3468
+ const places = String(v).split(".")[1]?.length ?? 0;
3469
+ return places >= min && places <= max;
895
3470
  },
896
- message: `Must have at most ${digits} decimal places`,
3471
+ message: Array.isArray(digits)
3472
+ ? `Must have between ${min} and ${max} decimal places`
3473
+ : `Must have at most ${max} decimal places`,
897
3474
  });
898
3475
  return this;
899
3476
  }
900
3477
 
901
3478
  /** Must equal a sibling field (VineJS `sameAs`). Cross-field → TS-only. */
902
3479
  sameAs(otherField: string): this {
903
- this.#useRules.push({
3480
+ const formats = this.#dateFormats;
3481
+ this.#pushUse({
904
3482
  __rune: "rule",
905
3483
  run: (value, field) => {
906
3484
  const other = readSibling(field, otherField);
3485
+ // On a date chain the value is a parsed `Date` and the sibling is
3486
+ // still raw, so `!==` would compare a Date to a string and always
3487
+ // fail. Compare instants instead.
3488
+ if (formats !== null && value instanceof Date) {
3489
+ const parsed = parseDateValue(other, formats);
3490
+ if (parsed === null || parsed.getTime() !== value.getTime()) {
3491
+ field.report(`Must match ${otherField}`, "sameAs");
3492
+ }
3493
+ return;
3494
+ }
907
3495
  if (value !== other) {
908
3496
  field.report(`Must match ${otherField}`, "sameAs");
909
3497
  }
@@ -913,14 +3501,24 @@ export class RuleChain<Output = unknown> {
913
3501
  }
914
3502
 
915
3503
  /** Must equal its `<field>_confirmation` sibling (VineJS `confirmed`). */
916
- confirmed(options?: { confirmationField?: string }): this {
917
- this.#useRules.push({
3504
+ confirmed(options?: { as?: string; confirmationField?: string }): this {
3505
+ this.#pushUse({
918
3506
  __rune: "rule",
919
3507
  run: (value, field) => {
920
3508
  const leaf = field.field.split(".").pop() ?? field.field;
921
- const other = options?.confirmationField ?? `${leaf}_confirmation`;
3509
+ // `as` is the current VineJS spelling; `confirmationField` is its
3510
+ // deprecated alias, kept so existing callers keep working.
3511
+ const other =
3512
+ options?.as ?? options?.confirmationField ?? `${leaf}_confirmation`;
922
3513
  if (value !== readSibling(field, other)) {
923
- field.report("Confirmation does not match", "confirmed");
3514
+ // VineJS reports on the CONFIRMATION field: that is the input the
3515
+ // user has to fix, and where a form renders the message.
3516
+ const prefix = field.field.slice(0, -leaf.length);
3517
+ field.report(
3518
+ "Confirmation does not match",
3519
+ "confirmed",
3520
+ `${prefix}${other}`,
3521
+ );
924
3522
  }
925
3523
  },
926
3524
  });
@@ -975,7 +3573,7 @@ export class RuleChain<Output = unknown> {
975
3573
  }
976
3574
 
977
3575
  /** Pre-validation transform of the raw input (VineJS `parse`). */
978
- parse(fn: (value: unknown) => unknown): this {
3576
+ parse(fn: (value: unknown, ctx: ParseContext) => unknown): this {
979
3577
  this.#preTransforms.push(fn);
980
3578
  return this;
981
3579
  }
@@ -986,7 +3584,7 @@ export class RuleChain<Output = unknown> {
986
3584
  validate: (value: unknown) => boolean,
987
3585
  message?: string,
988
3586
  ): this {
989
- this.#rules.push({
3587
+ this.#pushRule({
990
3588
  name,
991
3589
  validate,
992
3590
  message: message ?? `Failed custom rule: ${name}`,
@@ -999,7 +3597,12 @@ export class RuleChain<Output = unknown> {
999
3597
  * receives a {@link FieldContext} with the root `data` and `parent`, so it can
1000
3598
  * validate across fields. Runs after this field's type/value rules.
1001
3599
  */
1002
- use(rule: CompiledRule): this {
3600
+ use(rule: CompiledRule | AsyncCompiledRule): this {
3601
+ // A rule built with `{ isAsync: true }` arrives here (VineJS has one
3602
+ // `use`); routing it to the sync register would drop the await.
3603
+ if (rule.__rune === "asyncRule") {
3604
+ return this.useAsync(rule);
3605
+ }
1003
3606
  if (rule?.__rune !== "rule" || typeof rule.run !== "function") {
1004
3607
  throw new RuneError(
1005
3608
  "INVALID_RULE",
@@ -1007,21 +3610,243 @@ export class RuleChain<Output = unknown> {
1007
3610
  { hint: "use(myRule()) or use(myRule(options)), not use(myRule)" },
1008
3611
  );
1009
3612
  }
1010
- this.#useRules.push(rule);
3613
+ this.#pushUse(rule);
3614
+ return this;
3615
+ }
3616
+
3617
+ /**
3618
+ * Attach an async rule (from {@link createAsyncRule}). The schema must then be
3619
+ * run with `validateResultAsync` — sync `validate()` throws for such a schema.
3620
+ */
3621
+ useAsync(rule: AsyncCompiledRule): this {
3622
+ if (rule?.__rune !== "asyncRule" || typeof rule.run !== "function") {
3623
+ throw new RuneError(
3624
+ "INVALID_RULE",
3625
+ "useAsync() expects a compiled async rule — call the factory first",
3626
+ { hint: "useAsync(myRule()) or useAsync(myRule(options))" },
3627
+ );
3628
+ }
3629
+ this.#pushAsync(rule);
3630
+ return this;
3631
+ }
3632
+
3633
+ /**
3634
+ * DB-backed uniqueness rule (Adonis Lucid `unique`). `check(value, field)`
3635
+ * resolves `true` when the value is unique (valid). rune stays agnostic — the
3636
+ * check does the query (e.g. against atlas). Requires the async path (`validateResultAsync` / `validate`).
3637
+ *
3638
+ * rules.string().email().unique(async (value) => {
3639
+ * const row = await db.from('users').where('email', value).first()
3640
+ * return !row
3641
+ * })
3642
+ */
3643
+ unique(
3644
+ check: (value: unknown, field: FieldContext) => boolean | Promise<boolean>,
3645
+ message?: string,
3646
+ ): this;
3647
+ unique(options: DatabaseRuleOptions, message?: string): this;
3648
+ unique(
3649
+ checkOrOptions:
3650
+ | ((value: unknown, field: FieldContext) => boolean | Promise<boolean>)
3651
+ | DatabaseRuleOptions,
3652
+ message?: string,
3653
+ ): this {
3654
+ const check = toDatabaseCheck(checkOrOptions, "unique");
3655
+ this.#pushAsync({
3656
+ __rune: "asyncRule",
3657
+ async run(value: unknown, field: FieldContext): Promise<void> {
3658
+ const ok = await check(value, field);
3659
+ if (!ok) {
3660
+ field.report(
3661
+ message ?? `The ${field.field} has already been taken`,
3662
+ "database.unique",
3663
+ );
3664
+ }
3665
+ },
3666
+ });
3667
+ return this;
3668
+ }
3669
+
3670
+ /**
3671
+ * DB-backed existence rule (Adonis Lucid `exists`). `check(value, field)`
3672
+ * resolves `true` when a matching row exists (valid). Requires the async path (`validateResultAsync` / `validate`).
3673
+ */
3674
+ exists(
3675
+ check: (value: unknown, field: FieldContext) => boolean | Promise<boolean>,
3676
+ message?: string,
3677
+ ): this;
3678
+ exists(options: DatabaseRuleOptions, message?: string): this;
3679
+ exists(
3680
+ checkOrOptions:
3681
+ | ((value: unknown, field: FieldContext) => boolean | Promise<boolean>)
3682
+ | DatabaseRuleOptions,
3683
+ message?: string,
3684
+ ): this {
3685
+ const check = toDatabaseCheck(checkOrOptions, "exists");
3686
+ this.#pushAsync({
3687
+ __rune: "asyncRule",
3688
+ async run(value: unknown, field: FieldContext): Promise<void> {
3689
+ const ok = await check(value, field);
3690
+ if (!ok) {
3691
+ field.report(
3692
+ message ?? `The selected ${field.field} is invalid`,
3693
+ "database.exists",
3694
+ );
3695
+ }
3696
+ },
3697
+ });
3698
+ return this;
3699
+ }
3700
+
3701
+ /**
3702
+ * Attach free-form JSON Schema metadata (VineJS `meta()`) — `title`,
3703
+ * `description`, `examples`, `deprecated`… Merged verbatim into the field's
3704
+ * node by `toJSONSchema()`.
3705
+ */
3706
+ meta(metadata: Record<string, unknown>): this {
3707
+ this.#metadata = { ...this.#metadata, ...metadata };
1011
3708
  return this;
1012
3709
  }
1013
3710
 
1014
- /** Set custom error message for the last rule. */
3711
+ /**
3712
+ * Set a custom error message for the rule that was just added.
3713
+ *
3714
+ * "The last rule" spans all three registers: value rules (`#rules`),
3715
+ * cross-field `.use()` rules (`sameAs`, `confirmed`, `afterField`,
3716
+ * `notSameAs`) and async rules (`unique`, `exists`, `useAsync`). Targeting
3717
+ * `#rules` alone silently retargeted the PREVIOUS value rule — or threw
3718
+ * `NO_RULE` — whenever the preceding call was a cross-field or async rule.
3719
+ */
1015
3720
  message(msg: string): this {
1016
- if (this.#rules.length === 0) {
3721
+ const target = this.#lastRule;
3722
+ if (!target) {
1017
3723
  throw new RuneError("NO_RULE", "message() must be called after a rule");
1018
3724
  }
1019
- const last = this.#rules[this.#rules.length - 1];
1020
- last.message = msg;
1021
- last.hasCustomMessage = true;
3725
+ if (target.kind === "value") {
3726
+ target.ref.message = msg;
3727
+ target.ref.hasCustomMessage = true;
3728
+ } else {
3729
+ // `.use()` / async rules report their own text from inside `run`, so the
3730
+ // override is applied when the rule reports rather than stored on it.
3731
+ this.#ruleMessages.set(target.ref, msg);
3732
+ }
1022
3733
  return this;
1023
3734
  }
1024
3735
 
3736
+ /**
3737
+ * Build the {@link FieldContext} handed to `.use()` / async rules. Shared so
3738
+ * the sync and async paths cannot drift on what a rule can see.
3739
+ */
3740
+ #makeFieldContext(
3741
+ field: string,
3742
+ value: unknown,
3743
+ ctx: RunContext,
3744
+ errors: ValidationError[],
3745
+ onMutate: (next: unknown) => void,
3746
+ ): FieldContext {
3747
+ const segments = field.split(".");
3748
+ return {
3749
+ value,
3750
+ data: ctx.data,
3751
+ parent: ctx.parent,
3752
+ field,
3753
+ meta: ctx.meta,
3754
+ isValid: errors.length === 0,
3755
+ name: segments[segments.length - 1] ?? field,
3756
+ wildCardPath: toWildcardPath(field),
3757
+ isArrayMember: Array.isArray(ctx.parent),
3758
+ isDefined: value !== undefined && value !== null,
3759
+ isValidDataType: errors.length === 0,
3760
+ getFieldPath: () => field,
3761
+ mutate: onMutate,
3762
+ report(
3763
+ message: string,
3764
+ rule: string,
3765
+ reportedField?: string | FieldContext,
3766
+ args?: Record<string, unknown>,
3767
+ ): void {
3768
+ // VineJS plugins pass the FIELD CONTEXT here, not a path. Accepting
3769
+ // only a string let the object through and produced a
3770
+ // `ValidationError.field` that was not a string at runtime.
3771
+ const target =
3772
+ typeof reportedField === "string"
3773
+ ? reportedField
3774
+ : (reportedField?.getFieldPath() ?? field);
3775
+ errors.push({
3776
+ field: target,
3777
+ rule,
3778
+ message,
3779
+ ...(args ? { meta: args } : {}),
3780
+ });
3781
+ },
3782
+ };
3783
+ }
3784
+
3785
+ /**
3786
+ * Run only the implicit `.use()` rules against an absent value. A rule
3787
+ * declared `{ implicit: true }` exists to police `undefined`/`null`, so the
3788
+ * early return for optional fields must not skip it.
3789
+ */
3790
+ #runImplicitRules(
3791
+ field: string,
3792
+ value: unknown,
3793
+ ctx: RunContext,
3794
+ pending?: PendingAsync[],
3795
+ ): ValidationError[] {
3796
+ // An implicit ASYNC rule polices an absent value too, so it has to be
3797
+ // queued here as well — filtering `#useRules` alone dropped it silently.
3798
+ if (pending && this.#asyncRules.some((rule) => rule.implicit)) {
3799
+ pending.push({ chain: this, field, value, ctx });
3800
+ }
3801
+ const implicitRules = this.#useRules.filter((rule) => rule.implicit);
3802
+ if (implicitRules.length === 0) return [];
3803
+ const errors: ValidationError[] = [];
3804
+ const fieldCtx = this.#makeFieldContext(
3805
+ field,
3806
+ value,
3807
+ ctx,
3808
+ errors,
3809
+ () => {},
3810
+ );
3811
+ for (const rule of implicitRules) {
3812
+ fieldCtx.isValid = errors.length === 0;
3813
+ rule.run(value, fieldCtx);
3814
+ }
3815
+ return errors;
3816
+ }
3817
+
3818
+ /**
3819
+ * Register a TYPE rule from outside the chain — used by the `optional()` and
3820
+ * `null()` factories, which are types in their own right.
3821
+ * @internal
3822
+ */
3823
+ pushTypeRule(rule: RuleDef): void {
3824
+ this.#pushRule(rule);
3825
+ }
3826
+
3827
+ /** Re-type this chain in place, without cloning. @internal */
3828
+ retypeTo<U>(): RuleChain<U> {
3829
+ return this.#retype<U>();
3830
+ }
3831
+
3832
+ /** Add a value rule and remember it as the `message()` target. */
3833
+ #pushRule(rule: RuleDef): void {
3834
+ this.#rules.push(rule);
3835
+ this.#lastRule = { kind: "value", ref: rule };
3836
+ }
3837
+
3838
+ /** Add a cross-field `.use()` rule and remember it as the `message()` target. */
3839
+ #pushUse(rule: CompiledRule): void {
3840
+ this.#useRules.push(rule);
3841
+ this.#lastRule = { kind: "reporting", ref: rule };
3842
+ }
3843
+
3844
+ /** Add an async rule and remember it as the `message()` target. */
3845
+ #pushAsync(rule: AsyncCompiledRule): void {
3846
+ this.#asyncRules.push(rule);
3847
+ this.#lastRule = { kind: "reporting", ref: rule };
3848
+ }
3849
+
1025
3850
  /** Whether the field is required given the surrounding data (conditionals). */
1026
3851
  #isRequired(ctx: RunContext): boolean {
1027
3852
  if (this.#requiredConditions.length === 0) return true;
@@ -1030,36 +3855,68 @@ export class RuleChain<Output = unknown> {
1030
3855
  );
1031
3856
  }
1032
3857
 
1033
- /** Internal: validate a field value and return errors + transformed value. */
3858
+ /**
3859
+ * Internal: validate a field value and return errors + transformed value.
3860
+ *
3861
+ * `pending` is the async-rule collector. The traversal itself stays sync (it
3862
+ * is shared with `validate()`); when a collector is supplied, every chain in
3863
+ * the tree that carries async rules and passed its sync rules records itself
3864
+ * for the async path to await. Without it, nested async rules never ran.
3865
+ */
1034
3866
  _validateWithTransform(
1035
3867
  field: string,
1036
3868
  rawValue: unknown,
1037
3869
  ctx: RunContext = EMPTY_RUN_CONTEXT,
3870
+ pending?: PendingAsync[],
1038
3871
  ): { errors: ValidationError[]; transformed: unknown } {
1039
- // 0. Pre-validation parse() transforms run on the raw value first.
3872
+ // 0. Pre-validation parse() transforms run on the raw value first. VineJS
3873
+ // hands them `(value, { data, parent, meta })` — without the context a
3874
+ // parser cannot look at a sibling, which is half its purpose.
1040
3875
  let value = rawValue;
3876
+ const parseCtx: ParseContext = {
3877
+ data: ctx.data,
3878
+ parent: ctx.parent,
3879
+ meta: ctx.meta,
3880
+ };
1041
3881
  for (const pre of this.#preTransforms) {
1042
- value = pre(value);
3882
+ value = pre(value, parseCtx);
1043
3883
  }
1044
3884
 
1045
3885
  if (value === undefined) {
1046
3886
  if (this.#isOptional || !this.#isRequired(ctx)) {
1047
- return { errors: [], transformed: value };
3887
+ // Implicit rules are precisely the ones that must see an absent value.
3888
+ return {
3889
+ errors: this.#runImplicitRules(field, value, ctx, pending),
3890
+ transformed: value,
3891
+ };
1048
3892
  }
1049
3893
  return { errors: [this.#requiredError(field, ctx)], transformed: value };
1050
3894
  }
1051
3895
  if (value === null) {
1052
- // rune treats a `null` value as "absent" for optional/nullable/
1053
- // non-required fields (a deliberate, cross-engine-conformance-tested
1054
- // choice see the Rust↔TS parity suite). `.nullable()` additionally
1055
- // keeps it in the output. This unifies null/undefined for optional
1056
- // fields, a documented deviation from VineJS's stricter split.
1057
- if (this.#isNullable || this.#isOptional || !this.#isRequired(ctx)) {
1058
- return { errors: [], transformed: value };
3896
+ // VineJS split, now matched exactly: `nullable()` accepts null AND keeps
3897
+ // it in the output; `optional()` accepts null but DROPS the key. rune
3898
+ // used to keep null in both cases, so an optional field silently added
3899
+ // `key: null` to a payload VineJS would have left without the key.
3900
+ if (this.#isNullable) {
3901
+ return {
3902
+ errors: this.#runImplicitRules(field, value, ctx, pending),
3903
+ transformed: value,
3904
+ };
3905
+ }
3906
+ if (this.#isOptional || !this.#isRequired(ctx)) {
3907
+ return {
3908
+ errors: this.#runImplicitRules(field, value, ctx, pending),
3909
+ transformed: undefined,
3910
+ };
1059
3911
  }
1060
3912
  return { errors: [this.#requiredError(field, ctx)], transformed: value };
1061
3913
  }
1062
3914
 
3915
+ // 0b. Coerce before the type rules — a coerced value is the validated value.
3916
+ for (const coerce of this.#coercions) {
3917
+ value = coerce(value);
3918
+ }
3919
+
1063
3920
  // 1. Type rules first on the raw value — bail on type mismatch.
1064
3921
  const typeError = this.#runTypeRules(field, value, ctx);
1065
3922
  if (typeError) return { errors: [typeError], transformed: value };
@@ -1071,22 +3928,179 @@ export class RuleChain<Output = unknown> {
1071
3928
  // 3. Vine-style .use() rules — run with a FieldContext exposing the root
1072
3929
  // `data` and `parent`, so a rule can validate across fields.
1073
3930
  if (this.#useRules.length > 0 && !(this.#bail && errors.length > 0)) {
1074
- this.#runUseRules(field, transformed, ctx, errors);
3931
+ // `.use()` rules may call `field.mutate()`, so the value can change here.
3932
+ transformed = this.#runUseRules(field, transformed, ctx, errors);
3933
+ }
3934
+
3935
+ // 3b. Date output mapping (VineJS `VineDate.transform`). Deliberately AFTER
3936
+ // the comparison rules so `after`/`before`/`afterField` always see a
3937
+ // real `Date`, whatever type the consumer maps it to.
3938
+ if (
3939
+ this.#dateFormats !== null &&
3940
+ dateOutputTransform !== null &&
3941
+ transformed instanceof Date
3942
+ ) {
3943
+ transformed = dateOutputTransform(transformed);
1075
3944
  }
1076
3945
 
1077
3946
  // 4. Nested object validation (only if type check passed — not arrays)
1078
3947
  if (this.#nestedSchema && isPlainObject(transformed)) {
1079
- const obj: Record<string, unknown> = { ...transformed };
3948
+ // Start from the DECLARED keys only. Spreading the input kept every
3949
+ // undeclared key, so the mass-assignment guarantee that holds at the
3950
+ // top level silently stopped holding one level down:
3951
+ // `object({ name })` let an `isAdmin` through. `allowUnknownProperties()`
3952
+ // is the opt-in, as in VineJS.
3953
+ const source: Record<string, unknown> = transformed;
3954
+ const obj: Record<string, unknown> = this.#allowUnknown
3955
+ ? { ...source }
3956
+ : {};
1080
3957
  transformed = obj;
1081
- for (const [nestedField, chain] of Object.entries(this.#nestedSchema)) {
3958
+ // A conditional group contributes its branch's properties for THIS
3959
+ // payload, so the shape is resolved per validation, not at build time.
3960
+ let shape = this.#nestedSchema;
3961
+ for (const grp of this.#groups) {
3962
+ const branch =
3963
+ grp.branches.find(
3964
+ (candidate) => candidate.predicate?.(source) === true,
3965
+ ) ?? grp.branches.find((candidate) => candidate.predicate === null);
3966
+ if (branch) shape = { ...shape, ...branch.shape };
3967
+ }
3968
+ for (const [nestedField, chain] of Object.entries(shape)) {
1082
3969
  const nestedResult = chain._validateWithTransform(
1083
3970
  `${field}.${nestedField}`,
1084
- obj[nestedField],
1085
- { ...ctx, parent: obj },
3971
+ source[nestedField],
3972
+ { ...ctx, parent: source },
3973
+ pending,
1086
3974
  );
1087
3975
  errors.push(...nestedResult.errors);
1088
3976
  if (nestedResult.transformed !== undefined) {
1089
- obj[nestedField] = nestedResult.transformed;
3977
+ obj[this.#camelCaseKeys ? toCamelCaseKey(nestedField) : nestedField] =
3978
+ nestedResult.transformed;
3979
+ }
3980
+ }
3981
+ if (this.#camelCaseKeys && this.#allowUnknown) {
3982
+ // Undeclared keys are camelCased too, otherwise the output would mix
3983
+ // both spellings depending on whether a key was declared.
3984
+ for (const [key, value] of Object.entries(source)) {
3985
+ const camel = toCamelCaseKey(key);
3986
+ if (!(camel in obj)) obj[camel] = value;
3987
+ }
3988
+ }
3989
+ }
3990
+
3991
+ // 4b. Record values — same shape as the nested-object walk, arbitrary keys.
3992
+ if (this.#recordKeysCheck && isPlainObject(transformed)) {
3993
+ // Keys first: a key-level rule that rejects the shape makes the
3994
+ // per-value errors that would follow noise.
3995
+ this.#recordKeysCheck(
3996
+ Object.keys(transformed),
3997
+ this.#makeFieldContext(field, transformed, ctx, errors, () => {}),
3998
+ );
3999
+ }
4000
+ if (this.#recordValueChain && isPlainObject(transformed)) {
4001
+ const obj: Record<string, unknown> = { ...transformed };
4002
+ transformed = obj;
4003
+ for (const key of Object.keys(obj)) {
4004
+ const res = this.#recordValueChain._validateWithTransform(
4005
+ `${field}.${key}`,
4006
+ obj[key],
4007
+ { ...ctx, parent: obj },
4008
+ pending,
4009
+ );
4010
+ errors.push(...res.errors);
4011
+ if (res.transformed !== undefined) obj[key] = res.transformed;
4012
+ }
4013
+ }
4014
+
4015
+ // 4c. Tuple positions — length was already enforced by the `tuple` rule.
4016
+ if (this.#tupleChains && Array.isArray(transformed)) {
4017
+ const arr: unknown[] = [...transformed];
4018
+ transformed = arr;
4019
+ this.#tupleChains.forEach((chain, i) => {
4020
+ const res = chain._validateWithTransform(
4021
+ `${field}.${i}`,
4022
+ arr[i],
4023
+ { ...ctx, parent: arr },
4024
+ pending,
4025
+ );
4026
+ for (const e of res.errors) if (e.index === undefined) e.index = i;
4027
+ errors.push(...res.errors);
4028
+ if (res.transformed !== undefined) arr[i] = res.transformed;
4029
+ });
4030
+ }
4031
+
4032
+ // 4d. Union — first branch that validates wins; its transform is kept.
4033
+ if (this.#unionChains) {
4034
+ let matched = false;
4035
+ // A guarded branch (union.if) is SELECTED by its predicate, and its own
4036
+ // errors are reported — that is the diagnosable half of VineJS's union.
4037
+ const guarded = this.#unionChains.filter((b) => b.predicate !== null);
4038
+ if (guarded.length > 0) {
4039
+ const probe = this.#makeFieldContext(
4040
+ field,
4041
+ transformed,
4042
+ ctx,
4043
+ [],
4044
+ () => {},
4045
+ );
4046
+ const chosen =
4047
+ guarded.find((b) => b.predicate?.(transformed, probe)) ??
4048
+ this.#unionChains.find((b) => b.predicate === null);
4049
+ if (chosen) {
4050
+ const res = chosen.chain._validateWithTransform(
4051
+ field,
4052
+ transformed,
4053
+ ctx,
4054
+ pending,
4055
+ );
4056
+ transformed = res.transformed;
4057
+ errors.push(...res.errors);
4058
+ matched = true;
4059
+ }
4060
+ }
4061
+ for (const branch of matched ? [] : this.#unionChains) {
4062
+ // Each branch collects into its OWN buffer: a losing branch must not
4063
+ // leave async work queued, and the winning one must not lose it —
4064
+ // without this, a `unique()` inside the matching branch was never
4065
+ // awaited, which reads exactly like a check that passed.
4066
+ const branchPending: PendingAsync[] = [];
4067
+ const res = branch.chain._validateWithTransform(
4068
+ field,
4069
+ transformed,
4070
+ ctx,
4071
+ pending ? branchPending : undefined,
4072
+ );
4073
+ if (res.errors.length === 0) {
4074
+ transformed = res.transformed;
4075
+ matched = true;
4076
+ if (pending) pending.push(...branchPending);
4077
+ break;
4078
+ }
4079
+ }
4080
+ if (!matched) {
4081
+ if (this.#unionNoMatch) {
4082
+ // The callback owns the reporting: whatever it pushes is the
4083
+ // error, and pushing nothing means it handled the case itself.
4084
+ const reported: ValidationError[] = [];
4085
+ this.#unionNoMatch(
4086
+ transformed,
4087
+ this.#makeFieldContext(field, transformed, ctx, reported, () => {}),
4088
+ );
4089
+ errors.push(...reported);
4090
+ } else {
4091
+ errors.push({
4092
+ field,
4093
+ rule: "union",
4094
+ message: resolveRuleMessage(
4095
+ field,
4096
+ {
4097
+ name: "union",
4098
+ validate: () => false,
4099
+ message: "Does not match any allowed shape",
4100
+ },
4101
+ ctx,
4102
+ ),
4103
+ });
1090
4104
  }
1091
4105
  }
1092
4106
  }
@@ -1100,6 +4114,7 @@ export class RuleChain<Output = unknown> {
1100
4114
  `${field}.${i}`,
1101
4115
  arr[i],
1102
4116
  { ...ctx, parent: arr },
4117
+ pending,
1103
4118
  );
1104
4119
  for (const e of itemResult.errors) {
1105
4120
  if (e.index === undefined) e.index = i;
@@ -1111,6 +4126,19 @@ export class RuleChain<Output = unknown> {
1111
4126
  }
1112
4127
  }
1113
4128
 
4129
+ // 6. Record this chain's async rules for the async path to await. Mirrors
4130
+ // Lucid skipping a DB rule on an already-invalid or absent field: only a
4131
+ // clean, present value is worth a round-trip.
4132
+ if (
4133
+ pending &&
4134
+ this.#asyncRules.length > 0 &&
4135
+ errors.length === 0 &&
4136
+ transformed !== undefined &&
4137
+ transformed !== null
4138
+ ) {
4139
+ pending.push({ chain: this, field, value: transformed, ctx });
4140
+ }
4141
+
1114
4142
  return { errors, transformed };
1115
4143
  }
1116
4144
 
@@ -1120,22 +4148,72 @@ export class RuleChain<Output = unknown> {
1120
4148
  transformed: unknown,
1121
4149
  ctx: RunContext,
1122
4150
  errors: ValidationError[],
1123
- ): void {
1124
- const fieldCtx: FieldContext = {
1125
- value: transformed,
1126
- data: ctx.data,
1127
- parent: ctx.parent,
4151
+ ): unknown {
4152
+ // Set per iteration so `report` can substitute the `.message()` override of
4153
+ // the rule currently running — these rules carry their text inside `run`.
4154
+ let override: string | undefined;
4155
+ let current = transformed;
4156
+ const fieldCtx = this.#makeFieldContext(
1128
4157
  field,
1129
- meta: ctx.meta,
1130
- isValid: errors.length === 0,
1131
- report(message: string, rule: string): void {
1132
- errors.push({ field, rule, message });
4158
+ transformed,
4159
+ ctx,
4160
+ errors,
4161
+ (next) => {
4162
+ current = next;
4163
+ fieldCtx.value = next;
1133
4164
  },
1134
- };
4165
+ );
4166
+ const report = fieldCtx.report.bind(fieldCtx);
4167
+ fieldCtx.report = (message, rule, reportedField, args) =>
4168
+ report(override ?? message, rule, reportedField, args);
1135
4169
  for (const rule of this.#useRules) {
4170
+ // A non-implicit rule is skipped on an absent value (VineJS semantics);
4171
+ // `implicit: true` is what lets a custom rule police undefined/null.
4172
+ if (!rule.implicit && (current === undefined || current === null))
4173
+ continue;
4174
+ fieldCtx.isValid = errors.length === 0;
4175
+ fieldCtx.isDefined = current !== undefined && current !== null;
4176
+ override = this.#ruleMessages.get(rule);
4177
+ rule.run(current, fieldCtx);
4178
+ }
4179
+ return current;
4180
+ }
4181
+
4182
+ /**
4183
+ * Run this chain's async rules on the (already sync-validated) value, awaiting
4184
+ * each in order. Returns the errors they reported. Used by `validateResultAsync`.
4185
+ * @internal
4186
+ */
4187
+ async _runAsyncRules(
4188
+ field: string,
4189
+ transformed: unknown,
4190
+ ctx: RunContext,
4191
+ ): Promise<ValidationError[]> {
4192
+ const errors: ValidationError[] = [];
4193
+ let override: string | undefined;
4194
+ let current = transformed;
4195
+ const fieldCtx = this.#makeFieldContext(
4196
+ field,
4197
+ transformed,
4198
+ ctx,
4199
+ errors,
4200
+ (next) => {
4201
+ current = next;
4202
+ fieldCtx.value = next;
4203
+ },
4204
+ );
4205
+ const report = fieldCtx.report.bind(fieldCtx);
4206
+ fieldCtx.report = (message, rule, reportedField, args) =>
4207
+ report(override ?? message, rule, reportedField, args);
4208
+ for (const rule of this.#asyncRules) {
4209
+ if (!rule.implicit && (current === undefined || current === null))
4210
+ continue;
1136
4211
  fieldCtx.isValid = errors.length === 0;
1137
- rule.run(transformed, fieldCtx);
4212
+ fieldCtx.isDefined = current !== undefined && current !== null;
4213
+ override = this.#ruleMessages.get(rule);
4214
+ await rule.run(current, fieldCtx);
1138
4215
  }
4216
+ return errors;
1139
4217
  }
1140
4218
 
1141
4219
  #requiredError(field: string, ctx: RunContext): ValidationError {
@@ -1152,8 +4230,14 @@ export class RuleChain<Output = unknown> {
1152
4230
  value: unknown,
1153
4231
  ctx: RunContext,
1154
4232
  ): ValidationError | null {
4233
+ let context: FieldContext | undefined;
4234
+ const fieldContext = (): FieldContext =>
4235
+ (context ??= this.#makeFieldContext(field, value, ctx, [], () => {}));
1155
4236
  for (const rule of this.#rules) {
1156
- if (TYPE_RULE_NAMES.has(rule.name) && !rule.validate(value)) {
4237
+ if (
4238
+ TYPE_RULE_NAMES.has(rule.name) &&
4239
+ !rule.validate(value, fieldContext())
4240
+ ) {
1157
4241
  return {
1158
4242
  field,
1159
4243
  rule: rule.name,
@@ -1172,10 +4256,21 @@ export class RuleChain<Output = unknown> {
1172
4256
  ctx: RunContext,
1173
4257
  ): ValidationError[] {
1174
4258
  const errors: ValidationError[] = [];
4259
+ // Built once and only if a rule actually reads it: most rules take the
4260
+ // value alone, and a context per rule per field is pure waste.
4261
+ let context: FieldContext | undefined;
4262
+ const fieldContext = (): FieldContext =>
4263
+ (context ??= this.#makeFieldContext(
4264
+ field,
4265
+ transformed,
4266
+ ctx,
4267
+ [],
4268
+ () => {},
4269
+ ));
1175
4270
  for (const rule of this.#rules) {
1176
4271
  if (TYPE_RULE_NAMES.has(rule.name)) continue;
1177
4272
  if (this.#bail && errors.length > 0) break;
1178
- if (!rule.validate(transformed)) {
4273
+ if (!rule.validate(transformed, fieldContext())) {
1179
4274
  errors.push({
1180
4275
  field,
1181
4276
  rule: rule.name,
@@ -1224,6 +4319,13 @@ const LAST_FIELD: FieldContext = {
1224
4319
  field: "",
1225
4320
  meta: {},
1226
4321
  isValid: true,
4322
+ name: "",
4323
+ wildCardPath: "",
4324
+ isArrayMember: false,
4325
+ isDefined: false,
4326
+ isValidDataType: true,
4327
+ getFieldPath: () => "",
4328
+ mutate: (): void => {},
1227
4329
  report(): void {},
1228
4330
  };
1229
4331
 
@@ -1309,23 +4411,130 @@ function evalRequiredCondition(
1309
4411
  }
1310
4412
 
1311
4413
  /** No-op alias of {@link schema} — VineJS `vine.compile()` API parity. */
1312
- export function compile<T extends ValidationSchema>(s: T): T {
1313
- return s;
4414
+ export function compile<T extends ValidationSchema>(s: T): T;
4415
+ export function compile(chain: RuleChain): ValidationSchema;
4416
+ export function compile(input: ValidationSchema | RuleChain): ValidationSchema {
4417
+ // A rune schema is already compiled, so this is identity for that form; the
4418
+ // `RuleChain` form exists because `vine.compile(vine.object({…}))` is the
4419
+ // shape Adonis documents.
4420
+ return input instanceof RuleChain ? schema(toFieldMap(input), input) : input;
1314
4421
  }
1315
4422
 
1316
4423
  /** Entry point for building rules. */
1317
4424
  export const rules = {
1318
4425
  string: (): RuleChain<string> => new RuleChain().string(),
1319
- number: (): RuleChain<number> => new RuleChain().number(),
1320
- boolean: (): RuleChain<boolean> => new RuleChain().boolean(),
4426
+ number: (options?: { strict?: boolean }): RuleChain<number> =>
4427
+ new RuleChain().number(options),
4428
+ boolean: (options?: { strict?: boolean }): RuleChain<boolean> =>
4429
+ new RuleChain().boolean(options),
1321
4430
  any: (): RuleChain<unknown> => new RuleChain(),
4431
+ date: (options?: { formats?: DateFormat[] }): RuleChain<Date> =>
4432
+ new RuleChain().date(options),
4433
+ accepted: (): RuleChain<true> => new RuleChain().accepted(),
4434
+ file: (options?: {
4435
+ size?: number | string;
4436
+ extnames?: readonly string[];
4437
+ verifyContent?: boolean;
4438
+ }): RuleChain<FileLike> => new RuleChain().file(options),
4439
+ nativeFile: (options?: {
4440
+ minSize?: number | string;
4441
+ maxSize?: number | string;
4442
+ mimeTypes?: readonly string[];
4443
+ }): RuleChain<FileLike> => new RuleChain().nativeFile(options),
4444
+ record: <Item extends RuleChain>(
4445
+ valueChain: Item,
4446
+ ): RuleChain<Record<string, OutputOf<Item>>> =>
4447
+ new RuleChain().record(valueChain),
4448
+ tuple: <const Items extends readonly RuleChain[]>(
4449
+ items: Items,
4450
+ ): RuleChain<{ [K in keyof Items]: OutputOf<Items[K]> }> =>
4451
+ new RuleChain().tuple(items),
4452
+ union: Object.assign(
4453
+ (chains: readonly UnionBranch[]): RuleChain =>
4454
+ new RuleChain().union(chains),
4455
+ // `otherwise` is VineJS's spelling of the fallback branch; `else` stays
4456
+ // because it reads better in some call styles.
4457
+ { if: unionIf, else: unionElse, otherwise: unionElse },
4458
+ ),
4459
+ /**
4460
+ * Union discriminated by the value's TYPE (VineJS `unionOfTypes`): the first
4461
+ * branch whose own type rule accepts the value wins.
4462
+ */
4463
+ /**
4464
+ * Make every property of a shape optional (VineJS `vine.helpers.optional`).
4465
+ * A properties TRANSFORMER, like `pick`/`omit` — it returns a record to
4466
+ * spread, not a schema.
4467
+ */
4468
+ /**
4469
+ * A field that must be ABSENT (VineJS `vine.optional()` → `VineOptional`,
4470
+ * `builder.d.ts:135`). Mostly a `unionOfTypes` branch. Distinct from
4471
+ * `.optional()` on a chain, which relaxes an existing type — this one IS the
4472
+ * type. The properties transformer that used to squat this name moved to
4473
+ * `helpers.optional`, where VineJS keeps it.
4474
+ */
4475
+ optional: (): RuleChain<undefined> => {
4476
+ const chain = new RuleChain();
4477
+ chain.pushTypeRule({
4478
+ name: "optionalType",
4479
+ validate: (v) => v === undefined,
4480
+ message: "Must not be provided",
4481
+ });
4482
+ return chain.optional().retypeTo<undefined>();
4483
+ },
4484
+ /** A field that must be `null` (VineJS `vine.null()` → `VineNull`). */
4485
+ null: (): RuleChain<null> => {
4486
+ const chain = new RuleChain();
4487
+ chain.pushTypeRule({
4488
+ name: "nullType",
4489
+ validate: (v) => v === null,
4490
+ message: "Must be null",
4491
+ });
4492
+ return chain.nullable().retypeTo<null>();
4493
+ },
4494
+ unionOfTypes: (chains: readonly RuleChain[]): RuleChain => {
4495
+ // VineJS requires DISTINCT types: two branches claiming the same type make
4496
+ // the discrimination meaningless, and the second would be dead code.
4497
+ const seen = new Set<string>();
4498
+ for (const chain of chains) {
4499
+ const typeRule = chain.rules.find((rule) =>
4500
+ TYPE_RULE_NAMES.has(rule.name),
4501
+ );
4502
+ const name = typeRule?.name;
4503
+ if (name === undefined) {
4504
+ throw new RuneError(
4505
+ "NO_TYPE_RULE",
4506
+ "unionOfTypes() needs every branch to declare a type (string/number/…).",
4507
+ { hint: "Use union([...]) for predicate-based branches." },
4508
+ );
4509
+ }
4510
+ if (seen.has(name)) {
4511
+ throw new RuneError(
4512
+ "DUPLICATE_UNION_TYPE",
4513
+ `unionOfTypes() got two '${name}' branches — the second can never be reached.`,
4514
+ { hint: "Give each branch a distinct type, or use union([...])." },
4515
+ );
4516
+ }
4517
+ seen.add(name);
4518
+ }
4519
+ return new RuleChain().union(
4520
+ chains.map((chain) => {
4521
+ const typeRule = chain.rules.find((rule) =>
4522
+ TYPE_RULE_NAMES.has(rule.name),
4523
+ );
4524
+ return unionIf(
4525
+ (value, field) => typeRule?.validate(value, field) === true,
4526
+ chain,
4527
+ );
4528
+ }),
4529
+ );
4530
+ },
1322
4531
  object: <Sh extends Record<string, RuleChain>>(
1323
4532
  shape: Sh,
1324
4533
  ): RuleChain<Infer<Sh>> => new RuleChain().object(shape),
1325
4534
  array: <Item extends RuleChain>(item?: Item): RuleChain<OutputOf<Item>[]> =>
1326
4535
  new RuleChain().array(item),
1327
4536
  enum: <const V extends readonly (string | number | boolean)[]>(
1328
- values: V,
4537
+ values: V | ((field: FieldContext) => V),
1329
4538
  ): RuleChain<V[number]> => new RuleChain().enum(values),
1330
4539
  literal: <V extends string | number | boolean>(value: V): RuleChain<V> =>
1331
4540
  new RuleChain().literal(value),
@@ -1342,6 +4551,7 @@ function validateWithRust(
1342
4551
  rules: Array<{ name: string; params: unknown }>;
1343
4552
  optional: boolean;
1344
4553
  transforms: string[];
4554
+ bail: boolean;
1345
4555
  }
1346
4556
  > = {};
1347
4557
 
@@ -1356,6 +4566,8 @@ function validateWithRust(
1356
4566
  rules: ruleDescs,
1357
4567
  optional: chain.isOptionalField,
1358
4568
  transforms: chain.transforms.map((t) => t.name),
4569
+ // Sent explicitly so the Rust engine and the TS path agree on bail.
4570
+ bail: chain.bails,
1359
4571
  };
1360
4572
  }
1361
4573