@c9up/rune 0.1.6 → 0.1.8

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