@c9up/rune 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/MessagesProvider.d.ts +5 -0
  2. package/dist/MessagesProvider.d.ts.map +1 -1
  3. package/dist/MessagesProvider.js +1 -1
  4. package/dist/MessagesProvider.js.map +1 -1
  5. package/dist/Schema.d.ts +775 -31
  6. package/dist/Schema.d.ts.map +1 -1
  7. package/dist/Schema.js +2254 -129
  8. package/dist/Schema.js.map +1 -1
  9. package/dist/date.d.ts +36 -0
  10. package/dist/date.d.ts.map +1 -0
  11. package/dist/date.js +275 -0
  12. package/dist/date.js.map +1 -0
  13. package/dist/errors.d.ts +10 -0
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +10 -0
  16. package/dist/errors.js.map +1 -1
  17. package/dist/formats.d.ts +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 +149 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +179 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/magic.d.ts +30 -0
  26. package/dist/magic.d.ts.map +1 -0
  27. package/dist/magic.js +154 -0
  28. package/dist/magic.js.map +1 -0
  29. package/dist/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 +9 -1
  39. package/src/MessagesProvider.ts +1 -1
  40. package/src/Schema.ts +3260 -172
  41. package/src/date.ts +320 -0
  42. package/src/errors.ts +11 -0
  43. package/src/formats.ts +721 -0
  44. package/src/index.ts +262 -0
  45. package/src/magic.ts +181 -0
  46. package/src/types.ts +55 -0
package/dist/Schema.js CHANGED
@@ -4,16 +4,495 @@
4
4
  * @implements FR38, FR39, FR40, FR41
5
5
  */
6
6
  var _a;
7
+ import { parseDateValue, resolveOperand, truncateTo, } from "./date.js";
7
8
  import { RuneError, RuneValidationError } from "./errors.js";
9
+ import { alphaPattern, escapeHtml, isAscii, isCoordinates, isCreditCard, isEmail, isHexCode, isIban, isIpAddress, isJwt, isMobile, isMobileForLocale, isPassport, isPostalCode, isUlid, isUrlWithOptions, isVat, normalizeEmail, normalizeUrl, SUPPORTED_MOBILE_LOCALES, SUPPORTED_PASSPORTS, SUPPORTED_POSTAL_CODES, SUPPORTED_VAT_COUNTRIES, toCamelCase, } from "./formats.js";
10
+ import { toWildcardPath } from "./MessagesProvider.js";
11
+ import { detectFileType, extensionMatches, MAGIC_HEAD_BYTES, readHead, } from "./magic.js";
8
12
  import { isNativeAvailable, validateNative, warnNativeUnavailableOnce, } from "./native.js";
9
- export function createRule(validator) {
13
+ export function createRule(validator, ruleOptions) {
14
+ if (ruleOptions?.isAsync) {
15
+ // VineJS expresses "async" as an option on createRule, so honour it by
16
+ // BUILDING the async rule rather than refusing: `.use()` routes an
17
+ // async-marked rule to the awaited register.
18
+ const asyncBuilder = createAsyncRule(validator, { ...ruleOptions, isAsync: undefined });
19
+ return asyncBuilder;
20
+ }
10
21
  return (options) => ({
11
22
  __rune: "rule",
23
+ implicit: ruleOptions?.implicit ?? false,
24
+ name: ruleOptions?.name,
25
+ toJSONSchema: ruleOptions?.toJSONSchema,
26
+ ruleOptions: options,
12
27
  run(value, field) {
13
28
  validator(value, options, field);
14
29
  },
15
30
  });
16
31
  }
32
+ export function createAsyncRule(validator, ruleOptions) {
33
+ return (options) => ({
34
+ __rune: "asyncRule",
35
+ implicit: ruleOptions?.implicit ?? false,
36
+ name: ruleOptions?.name,
37
+ toJSONSchema: ruleOptions?.toJSONSchema,
38
+ ruleOptions: options,
39
+ async run(value, field) {
40
+ await validator(value, options, field);
41
+ },
42
+ });
43
+ }
44
+ /**
45
+ * Normalise either accepted spelling into one "report this error" callback.
46
+ * A factory is built ONCE per validation, so a stateful Vine reporter sees the
47
+ * whole run and can assemble its own error shape.
48
+ */
49
+ function toReporter(reporter, data, meta) {
50
+ if (!reporter)
51
+ return undefined;
52
+ if (reporter.length > 0) {
53
+ // Plain observer: it consumes each error and never decides the outcome.
54
+ const observe = reporter;
55
+ return { report: observe };
56
+ }
57
+ const built = reporter();
58
+ return {
59
+ report(error) {
60
+ // VineJS hands the reporter a FieldContext, not a path string: a real
61
+ // reporter reads `getFieldPath()` / `name` / `wildCardPath` off it.
62
+ built.report(error.message, error.rule, reportedFieldContext(error, data, meta), error.meta);
63
+ },
64
+ createError: () => built.createError(),
65
+ };
66
+ }
67
+ /**
68
+ * Rebuild the {@link FieldContext} a reporter expects from a collected error.
69
+ *
70
+ * The traversal reports post-hoc (it collects, then hands the batch over), so
71
+ * the original context is gone by then — but everything a reporter actually
72
+ * reads is derivable from the field path plus the root data.
73
+ */
74
+ function reportedFieldContext(error, data, meta) {
75
+ const segments = error.field.split(".");
76
+ const root = isPlainObject(data) ? data : {};
77
+ return {
78
+ value: undefined,
79
+ data: root,
80
+ parent: root,
81
+ field: error.field,
82
+ meta,
83
+ isValid: false,
84
+ name: segments[segments.length - 1] ?? error.field,
85
+ wildCardPath: toWildcardPath(error.field),
86
+ isArrayMember: /\.\d+$/.test(error.field),
87
+ isDefined: false,
88
+ isValidDataType: false,
89
+ getFieldPath: () => error.field,
90
+ mutate: () => { },
91
+ report: () => { },
92
+ };
93
+ }
94
+ /**
95
+ * Global output mapper for `rules.date()` — VineJS's `VineDate.transform` seam.
96
+ *
97
+ * rune has zero runtime dependencies, so a validated date is a plain `Date`. A
98
+ * consumer that wants its own type (e.g. a `@c9up/chronos` `DateTime`, which is
99
+ * what atlas hands back on read) binds it here once at boot, exactly like
100
+ * `bindRosetta` does for translations. Applied AFTER the comparison rules, so
101
+ * `after`/`before` always compare real `Date`s.
102
+ */
103
+ let dateOutputTransform = null;
104
+ /**
105
+ * Process-wide messages provider (VineJS `vine.messagesProvider`). A provider
106
+ * passed per call still wins — global is the fallback, not an override.
107
+ */
108
+ let globalMessagesProvider = null;
109
+ /**
110
+ * Process-wide error reporter (VineJS `vine.errorReporter = …`). A per-call
111
+ * option wins over a per-validator one, which wins over this.
112
+ */
113
+ let globalErrorReporter = null;
114
+ /** Bind (or clear) the process-wide error reporter. */
115
+ export function setGlobalErrorReporter(reporter) {
116
+ globalErrorReporter = reporter;
117
+ }
118
+ /** Read the process-wide error reporter. */
119
+ export function getGlobalErrorReporter() {
120
+ return globalErrorReporter;
121
+ }
122
+ let hostResolver = null;
123
+ /** See `rune.convertEmptyStringsToNull`. */
124
+ let convertEmptyStringsToNull = false;
125
+ /** Toggle the global `"" -> null` conversion (VineJS `convertEmptyStringsToNull`). */
126
+ export function setConvertEmptyStringsToNull(enabled) {
127
+ convertEmptyStringsToNull = enabled;
128
+ }
129
+ /** Read the global `"" -> null` conversion flag. */
130
+ export function getConvertEmptyStringsToNull() {
131
+ return convertEmptyStringsToNull;
132
+ }
133
+ /**
134
+ * Rewrite every `""` to `null`, deeply, before validation.
135
+ *
136
+ * Applied to the DATA rather than inside each chain: it has to happen before
137
+ * the optional/nullable decision, and before the native-engine routing — the
138
+ * Rust engine never sees this flag, so converting later would have made the
139
+ * behaviour depend on whether the binary was loadable.
140
+ */
141
+ function convertEmptyStrings(value) {
142
+ if (value === "")
143
+ return null;
144
+ if (Array.isArray(value))
145
+ return value.map(convertEmptyStrings);
146
+ if (isPlainObject(value)) {
147
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, convertEmptyStrings(v)]));
148
+ }
149
+ return value;
150
+ }
151
+ /** Bind (or clear, with `null`) the resolver backing `activeUrl()`. */
152
+ export function bindHostResolver(resolver) {
153
+ hostResolver = resolver;
154
+ }
155
+ /** Bind (or clear) the process-wide messages provider. */
156
+ export function setGlobalMessagesProvider(provider) {
157
+ globalMessagesProvider = provider;
158
+ }
159
+ /** Read the process-wide messages provider. */
160
+ export function getGlobalMessagesProvider() {
161
+ return globalMessagesProvider;
162
+ }
163
+ /** Bind (or clear, with `null`) the global `rules.date()` output mapper. */
164
+ export function setDateTransform(fn) {
165
+ dateOutputTransform = fn;
166
+ }
167
+ let databaseResolver = null;
168
+ /** Bind (or clear, with `null`) the resolver backing `unique()` / `exists()`. */
169
+ export function bindDatabase(resolver) {
170
+ databaseResolver = resolver;
171
+ }
172
+ /**
173
+ * Turn the options form of `unique`/`exists` into the callback the rule runs.
174
+ * Fails loudly when no resolver is bound: a uniqueness check that cannot run
175
+ * must never look like one that passed.
176
+ */
177
+ function toDatabaseCheck(checkOrOptions, kind) {
178
+ if (typeof checkOrOptions === "function")
179
+ return checkOrOptions;
180
+ const options = checkOrOptions;
181
+ return async (value, field) => {
182
+ if (!databaseResolver) {
183
+ throw new RuneError("NO_DATABASE_RESOLVER", `rules.${kind}({ table }) needs a database resolver.`, {
184
+ hint: "Call bindDatabase(resolver) once at boot, or pass a callback.",
185
+ });
186
+ }
187
+ const found = await databaseResolver.exists({
188
+ table: options.table,
189
+ column: options.column ?? field.name,
190
+ value,
191
+ where: options.where,
192
+ whereNot: options.whereNot,
193
+ });
194
+ return kind === "unique" ? !found : found;
195
+ };
196
+ }
197
+ /** VineJS number coercion: a numeric string becomes a number, the rest is untouched. */
198
+ function coerceNumber(value) {
199
+ if (typeof value !== "string")
200
+ return value;
201
+ const trimmed = value.trim();
202
+ if (trimmed === "")
203
+ return value;
204
+ const n = Number(trimmed);
205
+ return Number.isFinite(n) ? n : value;
206
+ }
207
+ /** VineJS boolean coercion over the usual form-encoded spellings. */
208
+ function coerceBoolean(value) {
209
+ if (value === 1 || value === 0)
210
+ return value === 1;
211
+ if (typeof value !== "string")
212
+ return value;
213
+ const v = value.trim().toLowerCase();
214
+ if (["true", "on", "1"].includes(v))
215
+ return true;
216
+ if (["false", "off", "0"].includes(v))
217
+ return false;
218
+ return value;
219
+ }
220
+ /** Byte multipliers for the size spellings Adonis accepts. */
221
+ const BYTE_UNITS = {
222
+ b: 1,
223
+ kb: 1024,
224
+ mb: 1024 ** 2,
225
+ gb: 1024 ** 3,
226
+ tb: 1024 ** 4,
227
+ };
228
+ /**
229
+ * Parse a size limit — a byte count, or Adonis's `"2mb"` / `"512kb"` spelling.
230
+ * Throws on an unreadable unit rather than falling back to "unlimited": a cap
231
+ * that silently stops capping is worse than no cap at all.
232
+ */
233
+ export function parseByteSize(size) {
234
+ if (typeof size === "number")
235
+ return size;
236
+ const match = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)\s*$/i.exec(size);
237
+ if (!match) {
238
+ throw new RuneError("INVALID_SIZE", `file(): cannot read size '${size}'.`, {
239
+ hint: 'Use a byte count, or "2mb" / "512kb" / "1gb".',
240
+ });
241
+ }
242
+ return Math.round(Number(match[1]) * BYTE_UNITS[match[2].toLowerCase()]);
243
+ }
244
+ /**
245
+ * Get the leading bytes of an upload, from whichever source it exposes.
246
+ * Returns `null` when there is none — the caller must treat that as a FAILURE,
247
+ * not as "nothing to check".
248
+ */
249
+ async function readFileHead(file) {
250
+ if (file.buffer instanceof Uint8Array) {
251
+ return file.buffer.subarray(0, MAGIC_HEAD_BYTES);
252
+ }
253
+ const path = file.tmpPath ?? file.filePath ?? file.path;
254
+ if (typeof path !== "string" || path.length === 0)
255
+ return null;
256
+ try {
257
+ return await readHead(path);
258
+ }
259
+ catch {
260
+ return null;
261
+ }
262
+ }
263
+ /** Structural guard for {@link FileLike}. */
264
+ function isFileLike(value) {
265
+ return (typeof value === "object" &&
266
+ value !== null &&
267
+ "size" in value &&
268
+ typeof value.size === "number");
269
+ }
270
+ /** Lowercase extension without the dot, from `extname` or a file name. */
271
+ function fileExtension(file) {
272
+ if (typeof file.extname === "string" && file.extname.length > 0) {
273
+ return file.extname.replace(/^\./, "").toLowerCase();
274
+ }
275
+ const name = file.clientName ?? file.name;
276
+ if (typeof name !== "string")
277
+ return null;
278
+ const dot = name.lastIndexOf(".");
279
+ return dot > 0 ? name.slice(dot + 1).toLowerCase() : null;
280
+ }
281
+ /** Describe every field's rules — the `schema` half of `toJSON()`. */
282
+ function introspect(fields) {
283
+ return Object.fromEntries(Object.entries(fields).map(([field, chain]) => [
284
+ field,
285
+ {
286
+ rules: chain.rules.map((rule) => rule.name),
287
+ optional: chain.isOptionalField,
288
+ nullable: chain.isNullable,
289
+ },
290
+ ]));
291
+ }
292
+ /** Rule name → the JSON Schema fragment it contributes. */
293
+ const JSON_SCHEMA_TYPES = {
294
+ string: "string",
295
+ number: "number",
296
+ boolean: "boolean",
297
+ date: "string",
298
+ accepted: "boolean",
299
+ object: "object",
300
+ record: "object",
301
+ array: "array",
302
+ tuple: "array",
303
+ };
304
+ /**
305
+ * Translate a field map to JSON Schema.
306
+ *
307
+ * Only rules with a real JSON Schema equivalent are emitted; a rule without one
308
+ * is OMITTED rather than approximated, because a schema that quietly drops a
309
+ * constraint is worse than one that says less.
310
+ */
311
+ function chainToJSONSchema(fields) {
312
+ const properties = {};
313
+ const required = [];
314
+ for (const [field, chain] of Object.entries(fields)) {
315
+ const node = {};
316
+ for (const rule of chain.rules) {
317
+ const type = JSON_SCHEMA_TYPES[rule.name];
318
+ if (type !== undefined)
319
+ node.type = type;
320
+ const args = rule.args ?? {};
321
+ if (rule.name === "minLength")
322
+ node.minLength = args.min ?? rule.param;
323
+ if (rule.name === "maxLength")
324
+ node.maxLength = args.max ?? rule.param;
325
+ if (rule.name === "fixedLength") {
326
+ node.minLength = args.length ?? rule.param;
327
+ node.maxLength = args.length ?? rule.param;
328
+ }
329
+ if (rule.name === "min")
330
+ node.minimum = args.min ?? rule.param;
331
+ if (rule.name === "max")
332
+ node.maximum = args.max ?? rule.param;
333
+ if (rule.name === "range") {
334
+ node.minimum = args.min;
335
+ node.maximum = args.max;
336
+ }
337
+ if (rule.name === "email")
338
+ node.format = "email";
339
+ if (rule.name === "uuid")
340
+ node.format = "uuid";
341
+ if (rule.name === "url")
342
+ node.format = "uri";
343
+ if (rule.name === "date")
344
+ node.format = "date-time";
345
+ if (rule.name === "regex" && typeof args.pattern === "string") {
346
+ node.pattern = args.pattern;
347
+ }
348
+ if (rule.name === "enum" && Array.isArray(args.values)) {
349
+ node.enum = args.values;
350
+ }
351
+ if (rule.name === "literal" && "value" in args) {
352
+ node.const = args.value;
353
+ }
354
+ if (rule.name === "notEmpty")
355
+ node.minItems = 1;
356
+ if (rule.name === "distinct")
357
+ node.uniqueItems = true;
358
+ if (rule.name === "withoutDecimals")
359
+ node.type = "integer";
360
+ if (rule.name === "positive")
361
+ node.exclusiveMinimum = 0;
362
+ if (rule.name === "negative")
363
+ node.exclusiveMaximum = 0;
364
+ if (rule.name === "nonNegative")
365
+ node.minimum = 0;
366
+ if (rule.name === "nonPositive")
367
+ node.maximum = 0;
368
+ if (rule.name === "nullType")
369
+ node.type = "null";
370
+ if (rule.name === "ulid")
371
+ node.pattern = "^[0-7][0-9A-HJKMNP-TV-Z]{25}$";
372
+ if (rule.name === "alpha")
373
+ node.pattern = "^[a-zA-Z]+$";
374
+ if (rule.name === "alphaNumeric")
375
+ node.pattern = "^[a-zA-Z0-9]+$";
376
+ if (rule.name === "hexCode")
377
+ node.format = "color";
378
+ if (rule.name === "ipAddress")
379
+ node.format = args.version === 6 ? "ipv6" : "ipv4";
380
+ if (rule.name === "file" || rule.name === "nativeFile") {
381
+ node.type = "string";
382
+ node.contentEncoding = "binary";
383
+ }
384
+ // A declarative rule may carry its own modifier too.
385
+ if (typeof rule.toJSONSchema === "function") {
386
+ Object.assign(node, rule.toJSONSchema(node, rule.args));
387
+ }
388
+ }
389
+ // `.use()` and async rules live outside `chain.rules`, so reading only that
390
+ // register left a declared modifier unreachable from the public API.
391
+ let modified = node;
392
+ for (const rule of [...chain.useRules, ...chain.asyncRules]) {
393
+ if (typeof rule.toJSONSchema === "function") {
394
+ modified = rule.toJSONSchema(modified, rule.ruleOptions);
395
+ }
396
+ }
397
+ if (chain.isNullable && typeof node.type === "string") {
398
+ node.type = [node.type, "null"];
399
+ }
400
+ const nested = chain.getProperties();
401
+ if (nested) {
402
+ Object.assign(modified, chainToJSONSchema(nested));
403
+ // A rune object DROPS undeclared keys unless allowUnknownProperties(),
404
+ // so the emitted schema must say so — otherwise a consumer generating a
405
+ // form from it would offer fields the validator silently discards.
406
+ modified.additionalProperties = chain.allowsUnknown;
407
+ }
408
+ if (chain.metadata)
409
+ Object.assign(modified, chain.metadata);
410
+ // Containers: describe what they hold, not just that they are containers.
411
+ const itemChain = chain.arrayItem;
412
+ if (itemChain) {
413
+ modified.items = chainToJSONSchema({ item: itemChain }).properties;
414
+ if (isRecordOfUnknown(modified.items))
415
+ modified.items = modified.items.item;
416
+ }
417
+ const tupleChains = chain.tupleItems;
418
+ if (tupleChains) {
419
+ modified.prefixItems = tupleChains.map((entry) => {
420
+ const built = chainToJSONSchema({ item: entry });
421
+ const props = built.properties;
422
+ return isRecordOfUnknown(props) ? props.item : {};
423
+ });
424
+ modified.items = false;
425
+ }
426
+ const recordChain = chain.recordValue;
427
+ if (recordChain) {
428
+ const built = chainToJSONSchema({ item: recordChain });
429
+ const props = built.properties;
430
+ modified.additionalProperties = isRecordOfUnknown(props)
431
+ ? props.item
432
+ : true;
433
+ }
434
+ properties[field] = modified;
435
+ if (!chain.isOptionalField)
436
+ required.push(field);
437
+ }
438
+ return {
439
+ type: "object",
440
+ properties,
441
+ ...(required.length > 0 ? { required } : {}),
442
+ };
443
+ }
444
+ /** Narrow to a string-keyed record — used when threading nested JSON Schema. */
445
+ function isRecordOfUnknown(value) {
446
+ return typeof value === "object" && value !== null && !Array.isArray(value);
447
+ }
448
+ /** `snake_case` / `kebab-case` / spaced key to `camelCase`. */
449
+ function toCamelCaseKey(key) {
450
+ return key
451
+ .replace(/[-_\s]+(.)?/g, (_, c) => c ? c.toUpperCase() : "")
452
+ .replace(/^(.)/, (c) => c.toLowerCase());
453
+ }
454
+ /** Structural guard telling a plain shape from a {@link ConditionalGroup}. */
455
+ function isConditionalGroup(value) {
456
+ return "__rune" in value && value.__rune === "group";
457
+ }
458
+ /** Build a conditional group (VineJS `vine.group([...])`). */
459
+ export function group(branches) {
460
+ return { __rune: "group", branches };
461
+ }
462
+ /** A predicate-guarded group branch (`vine.group.if`). */
463
+ export function groupIf(predicate, shape) {
464
+ return { predicate, shape };
465
+ }
466
+ /** The unconditional fallback branch (`vine.group.else` / `.otherwise`). */
467
+ export function groupElse(shape) {
468
+ return { predicate: null, shape };
469
+ }
470
+ /** Normalise a bare chain into an unconditional branch. */
471
+ function toUnionBranch(branch) {
472
+ return branch instanceof RuleChain
473
+ ? { predicate: null, chain: branch }
474
+ : branch;
475
+ }
476
+ /**
477
+ * Guarded union branch (VineJS `vine.union.if`). The predicate picks the branch;
478
+ * the chosen branch's OWN errors are reported, which is what makes a union
479
+ * diagnosable — "matches nothing" tells the caller nothing about which shape it
480
+ * nearly matched.
481
+ */
482
+ export function unionIf(predicate, chain) {
483
+ return { predicate, chain };
484
+ }
485
+ /** Fallback union branch (VineJS `vine.union.else`). */
486
+ export function unionElse(chain) {
487
+ return { predicate: null, chain };
488
+ }
489
+ /** The checkbox-style truthies VineJS `accepted` recognises. */
490
+ function isAcceptedValue(value) {
491
+ return (value === true ||
492
+ value === 1 ||
493
+ (typeof value === "string" &&
494
+ ["1", "on", "yes", "true"].includes(value.toLowerCase())));
495
+ }
17
496
  /** Default context for internal callers that don't supply one (no root available). */
18
497
  const EMPTY_RUN_CONTEXT = { data: {}, parent: {}, meta: {} };
19
498
  /** Type guard: narrows `unknown` to a plain object (non-null, non-array, typeof 'object'). */
@@ -31,6 +510,16 @@ function isPlainObject(value) {
31
510
  * The TS chain rules (minLength/uuid/alpha/in/enum/range/…) live only in the TS
32
511
  * validator, so they are deliberately absent here.
33
512
  */
513
+ /**
514
+ * Rules whose default message is a TRANSLATABLE key (`validation.<rule>`).
515
+ *
516
+ * This set answers one question only: "does this rule have a canonical message?"
517
+ * It used to answer a second one — "can the Rust engine run it?" — and that
518
+ * conflation is why every divergence kept coming back: excluding a rule from the
519
+ * native path silently un-translated it, and adding a TS-only option to a listed
520
+ * rule silently made the option inert. {@link NATIVE_RULES} answers the routing
521
+ * question now.
522
+ */
34
523
  const STANDARD_RULES = new Set([
35
524
  "string",
36
525
  "number",
@@ -54,6 +543,37 @@ const STANDARD_RULES = new Set([
54
543
  "nonNegative",
55
544
  "range",
56
545
  ]);
546
+ /**
547
+ * Rules the Rust engine implements IDENTICALLY to the TS path.
548
+ *
549
+ * A rule belongs here only while both engines answer the same question for
550
+ * every input. `email` is excluded on purpose: the TS check is structural
551
+ * (quoted local parts, IP-literal domains, RFC length caps, validator.js-style
552
+ * options) where Rust has one regex — routing there would give a different
553
+ * answer for the same schema.
554
+ */
555
+ const NATIVE_RULES = new Set([
556
+ "string",
557
+ "number",
558
+ "boolean",
559
+ "min",
560
+ "max",
561
+ "positive",
562
+ "minLength",
563
+ "maxLength",
564
+ "fixedLength",
565
+ "uuid",
566
+ "alpha",
567
+ "alphaNumeric",
568
+ "startsWith",
569
+ "endsWith",
570
+ "in",
571
+ "notIn",
572
+ "enum",
573
+ "negative",
574
+ "nonNegative",
575
+ "range",
576
+ ]);
57
577
  /** Default messages for standard rules — used only for translator-key fallback. */
58
578
  const STANDARD_MSGS = {
59
579
  string: "Must be a string",
@@ -84,6 +604,9 @@ const TYPE_RULE_NAMES = new Set([
84
604
  "boolean",
85
605
  "object",
86
606
  "array",
607
+ // `optional()` / `null()` are schema TYPES in VineJS, not modifiers.
608
+ "optionalType",
609
+ "nullType",
87
610
  ]);
88
611
  let validationTranslator;
89
612
  function hasCustomMessage(rule) {
@@ -125,7 +648,10 @@ function resolveRuleMessage(field, rule, ctx) {
125
648
  if (rule.name === "max" || rule.name === "maxLength")
126
649
  params.max = rule.param;
127
650
  }
128
- return resolveValidationMessage(`validation.${rule.name}`, rule.message, params);
651
+ // STANDARD_MSGS is the last-resort default for a standard rule: a rule object
652
+ // built without a message (or with an empty one) still gets the canonical
653
+ // text rather than an empty error. `rule.message` wins when it carries one.
654
+ return resolveValidationMessage(`validation.${rule.name}`, rule.message || STANDARD_MSGS[rule.name] || rule.name, params);
129
655
  }
130
656
  /** Resolve the "required" message through provider → translator → fallback. */
131
657
  function resolveRequiredMessage(field, ctx) {
@@ -139,6 +665,8 @@ function detectHasCustomRules(fields) {
139
665
  return Object.values(fields).some((chain) => {
140
666
  if (chain.useRules.length > 0)
141
667
  return true; // .use() rule — TS-only (Rust can't run JS)
668
+ if (chain.asyncRules.length > 0)
669
+ return true; // async rule — TS-only, needs validateResultAsync
142
670
  if (chain.hasConditionalRequired)
143
671
  return true; // requiredWhen — TS-only
144
672
  if (chain.preTransforms.length > 0)
@@ -148,18 +676,35 @@ function detectHasCustomRules(fields) {
148
676
  if (chain.isNullable)
149
677
  return true; // .nullable() — the flag is not sent to the Rust engine
150
678
  return chain.rules.some((r) => {
151
- if (!STANDARD_RULES.has(r.name))
152
- return true; // custom rule
679
+ if (!NATIVE_RULES.has(r.name))
680
+ return true; // Rust cannot run it identically
681
+ if (r.tsOnly === true)
682
+ return true; // native-listed name, TS-only options
153
683
  if (hasCustomMessage(r))
154
684
  return true; // custom message
155
685
  return false;
156
686
  });
157
687
  });
158
688
  }
159
- export function schema(fields) {
689
+ export function schema(fields, objectChain) {
690
+ // Per-validator reporter (VineJS `validator.errorReporter = …`), overridable
691
+ // per call. Mutable on purpose: that is how Vine exposes it.
692
+ let validatorErrorReporter = null;
693
+ // Set by the last run; the throwing entry points prefer the reporter's own
694
+ // error, because VineJS lets the reporter decide the failure shape.
695
+ let reporterError;
160
696
  // Computed once at construction time, not per validate() call.
161
697
  const hasCustomRules = detectHasCustomRules(fields);
162
- function validate(data, options) {
698
+ // Any field carrying async rules (`unique`/`exists`/`useAsync`) forces callers
699
+ // onto the async path — the sync path throws rather than silently skipping them.
700
+ const hasAsyncRules = Object.values(fields).some((chain) => chain.hasAsyncRulesDeep);
701
+ function validateResult(rawData, options) {
702
+ const data = convertEmptyStringsToNull
703
+ ? convertEmptyStrings(rawData)
704
+ : rawData;
705
+ if (hasAsyncRules) {
706
+ throw new Error("rune: this schema has async rules (unique/exists/useAsync) — call validateResultAsync() (result-based) or validate() (throwing) instead of validateResult().");
707
+ }
163
708
  if (!isPlainObject(data)) {
164
709
  return {
165
710
  valid: false,
@@ -168,10 +713,25 @@ export function schema(fields) {
168
713
  ],
169
714
  };
170
715
  }
171
- const provider = options?.messagesProvider;
716
+ // The global provider counts exactly like a per-call one: the Rust engine
717
+ // renders default messages, so routing there would silently ignore it.
718
+ const provider = options?.messagesProvider ?? globalMessagesProvider ?? undefined;
172
719
  if (!hasCustomRules && !validationTranslator && !provider) {
173
720
  if (isNativeAvailable()) {
174
- return validateWithRust(fields, data);
721
+ const native = validateWithRust(fields, data);
722
+ // Report here too: the native path returns before the TS traversal,
723
+ // so instrumenting only the latter left the reporter silent exactly
724
+ // when the fast path was taken.
725
+ const nativeReporter = toReporter(options?.errorReporter ??
726
+ validatorErrorReporter ??
727
+ globalErrorReporter ??
728
+ undefined, data, options?.meta ?? {});
729
+ if (nativeReporter) {
730
+ for (const error of native.errors)
731
+ nativeReporter.report(error);
732
+ }
733
+ reporterError = nativeReporter?.createError;
734
+ return native;
175
735
  }
176
736
  // This schema would have used the native engine, but it isn't loaded —
177
737
  // surface the platform-dependent TS fallback once instead of diverging
@@ -184,6 +744,7 @@ export function schema(fields) {
184
744
  data,
185
745
  parent: data,
186
746
  meta: options?.meta ?? {},
747
+ errorReporter: options?.errorReporter,
187
748
  messagesProvider: provider,
188
749
  };
189
750
  for (const [field, chain] of Object.entries(fields)) {
@@ -197,19 +758,203 @@ export function schema(fields) {
197
758
  validated[field] = result.transformed;
198
759
  }
199
760
  }
761
+ const reporter = toReporter(options?.errorReporter ??
762
+ validatorErrorReporter ??
763
+ globalErrorReporter ??
764
+ undefined, data, options?.meta ?? {});
765
+ if (reporter) {
766
+ for (const error of errors)
767
+ reporter.report(error);
768
+ }
769
+ reporterError = reporter?.createError;
200
770
  if (errors.length === 0) {
201
771
  return { valid: true, errors, data: validated };
202
772
  }
203
773
  return { valid: false, errors };
204
774
  }
205
775
  function validateOrThrow(data, options) {
206
- const result = validate(data, options);
776
+ const result = validateResult(data, options);
777
+ if (result.valid) {
778
+ return result.data;
779
+ }
780
+ // The reporter decides the failure shape when one is bound (VineJS).
781
+ throw reporterError
782
+ ? reporterError()
783
+ : new RuneValidationError(result.errors.map(toErrorNode));
784
+ }
785
+ async function validateResultAsync(rawData, options) {
786
+ const data = convertEmptyStringsToNull
787
+ ? convertEmptyStrings(rawData)
788
+ : rawData;
789
+ if (!isPlainObject(data)) {
790
+ return {
791
+ valid: false,
792
+ errors: [
793
+ { field: "_root", rule: "type", message: "Input must be an object" },
794
+ ],
795
+ };
796
+ }
797
+ const errors = [];
798
+ const validated = {};
799
+ const rootCtx = {
800
+ data,
801
+ parent: data,
802
+ meta: options?.meta ?? {},
803
+ errorReporter: options?.errorReporter,
804
+ messagesProvider: options?.messagesProvider ?? globalMessagesProvider ?? undefined,
805
+ };
806
+ for (const [field, chain] of Object.entries(fields)) {
807
+ // One collector per top-level field, drained straight away, so async
808
+ // errors stay grouped with their field rather than piling up at the end.
809
+ const pending = [];
810
+ const result = chain._validateWithTransform(field, data[field], rootCtx, pending);
811
+ const fieldErrors = [...result.errors];
812
+ // The collector already applied the gate at every depth: a chain records
813
+ // itself only when its own subtree passed and its value is present —
814
+ // mirrors Lucid skipping a DB rule on an already-invalid or absent field.
815
+ for (const task of pending) {
816
+ const asyncErrors = await task.chain._runAsyncRules(task.field, task.value, task.ctx);
817
+ fieldErrors.push(...asyncErrors);
818
+ }
819
+ errors.push(...fieldErrors);
820
+ if (fieldErrors.length === 0 && result.transformed !== undefined) {
821
+ validated[field] = result.transformed;
822
+ }
823
+ }
824
+ const reporter = toReporter(options?.errorReporter ??
825
+ validatorErrorReporter ??
826
+ globalErrorReporter ??
827
+ undefined, data, options?.meta ?? {});
828
+ if (reporter) {
829
+ for (const error of errors)
830
+ reporter.report(error);
831
+ }
832
+ reporterError = reporter?.createError;
833
+ if (errors.length === 0) {
834
+ return { valid: true, errors, data: validated };
835
+ }
836
+ return { valid: false, errors };
837
+ }
838
+ /**
839
+ * Non-throwing validation returning a `[error, null] | [null, data]` tuple
840
+ * (VineJS `tryValidate`), for when a failure is an expected code path.
841
+ */
842
+ function tryValidateSync(data, options) {
843
+ const result = validateResult(data, options);
844
+ if (result.valid)
845
+ return [null, result.data];
846
+ return [new RuneValidationError(result.errors.map(toErrorNode)), null];
847
+ }
848
+ /** Async counterpart of {@link tryValidate}. */
849
+ async function tryValidate(data, options) {
850
+ const result = await validateResultAsync(data, options);
851
+ if (result.valid)
852
+ return [null, result.data];
853
+ return [new RuneValidationError(result.errors.map(toErrorNode)), null];
854
+ }
855
+ async function validateOrThrowAsync(data, options) {
856
+ const result = await validateResultAsync(data, options);
207
857
  if (result.valid) {
208
858
  return result.data;
209
859
  }
210
- throw new RuneValidationError(result.errors.map(toErrorNode));
860
+ // The reporter decides the failure shape when one is bound (VineJS).
861
+ throw reporterError
862
+ ? reporterError()
863
+ : new RuneValidationError(result.errors.map(toErrorNode));
211
864
  }
212
- return { fields, validate, validateOrThrow };
865
+ /**
866
+ * The VineJS contract: async, returns the payload, throws on failure. A
867
+ * schema carrying async rules works here without the caller having to know,
868
+ * which is the whole point of Vine's single entry point.
869
+ */
870
+ async function validate(data, options) {
871
+ return validateOrThrowAsync(data, options);
872
+ }
873
+ /**
874
+ * Introspection of the compiled schema (VineJS `toJSON`): field names and the
875
+ * rules attached to each, enough to render a form or diff two schemas.
876
+ */
877
+ function toJSON() {
878
+ // VineJS shape: `{ schema, refs }`. The flat `{ field: { rules } }` map was
879
+ // rune's own invention, so a consumer written against Vine read undefined.
880
+ return {
881
+ schema: introspect(fields),
882
+ refs: Object.keys(fields),
883
+ };
884
+ }
885
+ /**
886
+ * Emit a JSON Schema for the compiled validator (VineJS `toJSONSchema`).
887
+ * Covers the rules that HAVE a JSON Schema equivalent; a custom rule
888
+ * contributes its `jsonSchema` metadata when it declares one, and is
889
+ * otherwise omitted rather than guessed at.
890
+ */
891
+ function toJSONSchema() {
892
+ return chainToJSONSchema(fields);
893
+ }
894
+ /**
895
+ * Standard Schema v1 (`~standard`), the vendor-neutral contract VineJS also
896
+ * implements — lets a consumer validate without knowing it holds a rune
897
+ * schema.
898
+ */
899
+ const standard = {
900
+ version: 1,
901
+ vendor: "rune",
902
+ /**
903
+ * Standard JSON Schema v1 (`~standard.jsonSchema`), added by VineJS 4.3.
904
+ * `input` describes what may be sent, `output` what validation returns.
905
+ */
906
+ jsonSchema: {
907
+ input: () => toJSONSchema(),
908
+ output: () => toJSONSchema(),
909
+ },
910
+ validate: (value) => validateResultAsync(value).then((result) => result.valid
911
+ ? { value: result.data }
912
+ : {
913
+ issues: result.errors.map((error) => ({
914
+ message: error.message,
915
+ path: error.field.split("."),
916
+ })),
917
+ }),
918
+ };
919
+ return {
920
+ fields,
921
+ /** Per-validator error reporter (VineJS `validator.errorReporter`). */
922
+ get errorReporter() {
923
+ return validatorErrorReporter;
924
+ },
925
+ set errorReporter(reporter) {
926
+ validatorErrorReporter = reporter;
927
+ },
928
+ // ALWAYS a chain, even when the validator was built from a bare field map:
929
+ // VineJS documents `createUserValidator.schema.partial()`, and returning
930
+ // the map left that broken on the most common Adonis path.
931
+ schema: objectChain ?? new RuleChain().object(fields),
932
+ "~standard": standard,
933
+ toJSON,
934
+ toJSONSchema,
935
+ validate,
936
+ validateResult,
937
+ validateResultAsync,
938
+ validateOrThrow,
939
+ validateOrThrowAsync,
940
+ tryValidate,
941
+ tryValidateSync,
942
+ };
943
+ }
944
+ export function create(input) {
945
+ return input instanceof RuleChain
946
+ ? schema(toFieldMap(input), input)
947
+ : schema(input);
948
+ }
949
+ /** Unwrap `rune.object({...})` back to the field map `schema()` expects. */
950
+ function toFieldMap(input) {
951
+ if (!(input instanceof RuleChain))
952
+ return input;
953
+ const shape = input.getProperties();
954
+ if (!shape) {
955
+ throw new RuneError("NOT_AN_OBJECT", "create()/compile() received a chain that declares no object shape.", { hint: "Use rune.object({ … }), or pass the field map directly." });
956
+ }
957
+ return shape;
213
958
  }
214
959
  /** Map an internal {@link ValidationError} to a {@link RuneErrorNode}. */
215
960
  function toErrorNode(error) {
@@ -232,17 +977,57 @@ export function bindRosetta(rosetta) {
232
977
  }
233
978
  /** UUID (any version/variant) — identical to the Rust engine's pattern. */
234
979
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
235
- /** Rule chain fluent, phantom-typed validation builder. */
980
+ /** Normalise a static list or a callback into a getter. */
981
+ function allowedValuesResolver(values) {
982
+ if (typeof values === "function")
983
+ return values;
984
+ const snapshot = [...values];
985
+ return () => snapshot;
986
+ }
236
987
  export class RuleChain {
237
988
  #rules = [];
238
989
  #isOptional = false;
239
990
  #isNullable = false;
240
- #bail = false;
991
+ /**
992
+ * VineJS validates a field in bail mode by DEFAULT — it stops at that field's
993
+ * first failing rule (`FieldOptions.bail: true`). rune defaulted to `false`
994
+ * and reported every failing rule, which silently produced a different error
995
+ * array for the same schema. `.bail(false)` restores the exhaustive mode.
996
+ */
997
+ #bail = true;
241
998
  #transforms = [];
242
999
  #preTransforms = [];
1000
+ /**
1001
+ * Type coercions (VineJS accepts `"32"` for a number). Kept OUT of
1002
+ * `#preTransforms` on purpose: a pre-transform forces the TS path, and the
1003
+ * Rust engine implements the very same coercion from the rule's `strict`
1004
+ * param, so both engines agree without giving up the native path.
1005
+ */
1006
+ #coercions = [];
1007
+ /** Formats accepted by `date()` — also used to parse `afterField` siblings. */
1008
+ #dateFormats = null;
243
1009
  #nestedSchema = null;
244
1010
  #arrayItemChain = null;
1011
+ #allowUnknown = false;
1012
+ #metadata = null;
1013
+ /** Extensions / MIME types declared by `file()` / `mimeTypes()`. */
1014
+ #declaredExtnames = null;
1015
+ #declaredMimeTypes = null;
1016
+ /** `true` once the content-verification rule has been registered. */
1017
+ #contentVerified = false;
1018
+ /** Set by `{ verifyContent: false }` — an explicit, auditable opt-out. */
1019
+ #contentVerificationOff = false;
1020
+ #camelCaseKeys = false;
1021
+ #groups = [];
1022
+ #recordValueChain = null;
1023
+ #tupleChains = null;
1024
+ #unionChains = null;
245
1025
  #useRules = [];
1026
+ #asyncRules = [];
1027
+ /** Last rule added, whichever register it landed in — the `message()` target. */
1028
+ #lastRule = null;
1029
+ /** `.message()` overrides for rules that report their own text from `run`. */
1030
+ #ruleMessages = new Map();
246
1031
  #requiredConditions = [];
247
1032
  /** Public read access to rules (for OpenAPI generation, Rust bridge). */
248
1033
  get rules() {
@@ -262,6 +1047,63 @@ export class RuleChain {
262
1047
  get useRules() {
263
1048
  return this.#useRules;
264
1049
  }
1050
+ /** Public read access to async rules (`unique`/`exists`/`useAsync`) — run by `validateResultAsync`. */
1051
+ get asyncRules() {
1052
+ return this.#asyncRules;
1053
+ }
1054
+ /**
1055
+ * Does this chain — or anything nested under it (object fields, array items) —
1056
+ * carry async rules? The schema-level detection used to inspect only the
1057
+ * top-level chains, so a nested `unique`/`exists` was invisible: `validate()`
1058
+ * did not throw and the async pass never ran the rule, silently accepting
1059
+ * an unchecked value.
1060
+ */
1061
+ get hasAsyncRulesDeep() {
1062
+ if (this.#asyncRules.length > 0)
1063
+ return true;
1064
+ if (this.#nestedSchema) {
1065
+ for (const chain of Object.values(this.#nestedSchema)) {
1066
+ if (chain.hasAsyncRulesDeep)
1067
+ return true;
1068
+ }
1069
+ }
1070
+ if (this.#arrayItemChain?.hasAsyncRulesDeep)
1071
+ return true;
1072
+ if (this.#recordValueChain?.hasAsyncRulesDeep)
1073
+ return true;
1074
+ for (const chain of [
1075
+ ...(this.#tupleChains ?? []),
1076
+ ...(this.#unionChains ?? []).map((b) => b.chain),
1077
+ ]) {
1078
+ if (chain.hasAsyncRulesDeep)
1079
+ return true;
1080
+ }
1081
+ return false;
1082
+ }
1083
+ /** Does this object keep keys its shape does not declare? */
1084
+ get allowsUnknown() {
1085
+ return this.#allowUnknown;
1086
+ }
1087
+ /** Free-form JSON Schema metadata attached with `meta()`. */
1088
+ get metadata() {
1089
+ return this.#metadata;
1090
+ }
1091
+ /** The item chain of an `array()`, if declared. */
1092
+ get arrayItem() {
1093
+ return this.#arrayItemChain;
1094
+ }
1095
+ /** The positional chains of a `tuple()`, if declared. */
1096
+ get tupleItems() {
1097
+ return this.#tupleChains;
1098
+ }
1099
+ /** The value chain of a `record()`, if declared. */
1100
+ get recordValue() {
1101
+ return this.#recordValueChain;
1102
+ }
1103
+ /** Whether this chain stops at its first failing rule (VineJS `bail`). */
1104
+ get bails() {
1105
+ return this.#bail;
1106
+ }
265
1107
  /** Public read access to `.parse()` pre-transforms (kept off the native path). */
266
1108
  get preTransforms() {
267
1109
  return this.#preTransforms;
@@ -283,10 +1125,26 @@ export class RuleChain {
283
1125
  next.#isNullable = this.#isNullable;
284
1126
  next.#bail = this.#bail;
285
1127
  next.#transforms = [...this.#transforms];
1128
+ next.#dateFormats = this.#dateFormats;
1129
+ next.#coercions = [...this.#coercions];
1130
+ next.#allowUnknown = this.#allowUnknown;
1131
+ next.#metadata = this.#metadata ? { ...this.#metadata } : null;
1132
+ next.#declaredExtnames = this.#declaredExtnames;
1133
+ next.#declaredMimeTypes = this.#declaredMimeTypes;
1134
+ next.#contentVerified = this.#contentVerified;
1135
+ next.#contentVerificationOff = this.#contentVerificationOff;
1136
+ next.#camelCaseKeys = this.#camelCaseKeys;
1137
+ next.#groups = [...this.#groups];
1138
+ next.#recordValueChain = this.#recordValueChain;
1139
+ next.#tupleChains = this.#tupleChains;
1140
+ next.#unionChains = this.#unionChains;
1141
+ next.#ruleMessages = new Map(this.#ruleMessages);
1142
+ next.#lastRule = this.#lastRule;
286
1143
  next.#preTransforms = [...this.#preTransforms];
287
1144
  next.#nestedSchema = this.#nestedSchema;
288
1145
  next.#arrayItemChain = this.#arrayItemChain;
289
1146
  next.#useRules = [...this.#useRules];
1147
+ next.#asyncRules = [...this.#asyncRules];
290
1148
  next.#requiredConditions = [...this.#requiredConditions];
291
1149
  return next;
292
1150
  }
@@ -313,7 +1171,7 @@ export class RuleChain {
313
1171
  }
314
1172
  /** Must be an object matching a nested schema. */
315
1173
  object(shape) {
316
- this.#rules.push({
1174
+ this.#pushRule({
317
1175
  name: "object",
318
1176
  validate: (v) => isPlainObject(v),
319
1177
  message: "Must be an object",
@@ -323,7 +1181,7 @@ export class RuleChain {
323
1181
  }
324
1182
  /** Must be an array. Items validated by the provided chain. */
325
1183
  array(itemChain) {
326
- this.#rules.push({
1184
+ this.#pushRule({
327
1185
  name: "array",
328
1186
  validate: (v) => Array.isArray(v),
329
1187
  message: "Must be an array",
@@ -333,35 +1191,534 @@ export class RuleChain {
333
1191
  }
334
1192
  /** Must be a string. */
335
1193
  string() {
336
- this.#rules.push({
1194
+ this.#pushRule({
337
1195
  name: "string",
338
1196
  validate: (v) => typeof v === "string",
339
1197
  message: "Must be a string",
340
1198
  });
341
1199
  return this.#retype();
342
1200
  }
343
- /** Must be a number. */
344
- number() {
345
- this.#rules.push({
346
- name: "number",
347
- validate: (v) => typeof v === "number" && !Number.isNaN(v) && Number.isFinite(v),
348
- message: "Must be a number",
1201
+ /**
1202
+ * Must be a number. Like VineJS, a numeric STRING is coerced (`"32"` → `32`)
1203
+ * — HTML form bodies and query strings carry numbers as text, so requiring
1204
+ * `typeof v === "number"` rejected the values Adonis accepts. Pass
1205
+ * `{ strict: true }` to refuse anything that is not already a number.
1206
+ */
1207
+ number(options) {
1208
+ if (!options?.strict)
1209
+ this.#coercions.push(coerceNumber);
1210
+ this.#pushRule({
1211
+ name: "number",
1212
+ args: { strict: options?.strict === true },
1213
+ validate: (v) => typeof v === "number" && !Number.isNaN(v) && Number.isFinite(v),
1214
+ message: "Must be a number",
1215
+ });
1216
+ return this.#retype();
1217
+ }
1218
+ /**
1219
+ * Must be a boolean. Like VineJS, `"true"`, `"false"`, `"on"`, `"off"`,
1220
+ * `"1"`, `"0"`, `1` and `0` are coerced; `{ strict: true }` refuses them.
1221
+ */
1222
+ boolean(options) {
1223
+ if (!options?.strict)
1224
+ this.#coercions.push(coerceBoolean);
1225
+ this.#pushRule({
1226
+ name: "boolean",
1227
+ args: { strict: options?.strict === true },
1228
+ validate: (v) => typeof v === "boolean",
1229
+ message: "Must be a boolean",
1230
+ });
1231
+ return this.#retype();
1232
+ }
1233
+ /**
1234
+ * Must be a date (VineJS `vine.date()`). ISO 8601 by default; pass `formats`
1235
+ * for unix timestamps (`x` = ms, `X` = seconds) or a token format such as
1236
+ * `DD/MM/YYYY`. Parsing is calendar-strict — `2026-02-31` is rejected.
1237
+ *
1238
+ * The validated output is a `Date`; bind {@link setDateTransform} to map it
1239
+ * to your own type once at boot.
1240
+ */
1241
+ date(options) {
1242
+ const formats = options?.formats ?? ["iso8601"];
1243
+ this.#dateFormats = formats;
1244
+ this.#pushRule({
1245
+ name: "date",
1246
+ args: { formats },
1247
+ validate: (v) => parseDateValue(v, formats) !== null,
1248
+ message: "Must be a valid date",
1249
+ });
1250
+ // Parse to a real `Date` BEFORE the comparison rules run, so `after`/
1251
+ // `before` never re-parse and never compare strings lexicographically.
1252
+ this.#transforms.push({
1253
+ name: "date",
1254
+ fn: (value) => parseDateValue(value, formats) ?? value,
1255
+ });
1256
+ return this.#retype();
1257
+ }
1258
+ /** Must be strictly after `operand` (`'today'`, an ISO string, or a `Date`). */
1259
+ after(operand, options) {
1260
+ return this.#compareDate("after", operand, (a, b) => a > b, options);
1261
+ }
1262
+ /** Must be strictly before `operand`. */
1263
+ before(operand, options) {
1264
+ return this.#compareDate("before", operand, (a, b) => a < b, options);
1265
+ }
1266
+ /** Must be after `operand`, or equal to it. */
1267
+ afterOrEqual(operand, options) {
1268
+ return this.#compareDate("afterOrEqual", operand, (a, b) => a >= b, options);
1269
+ }
1270
+ /** Must be before `operand`, or equal to it. */
1271
+ beforeOrEqual(operand, options) {
1272
+ return this.#compareDate("beforeOrEqual", operand, (a, b) => a <= b, options);
1273
+ }
1274
+ /** Must be after the date held by a sibling field (VineJS `afterField`). */
1275
+ afterField(otherField, options) {
1276
+ return this.#compareDateField("afterField", otherField, options, (a, b) => a > b);
1277
+ }
1278
+ /** Must be before the date held by a sibling field. */
1279
+ beforeField(otherField, options) {
1280
+ return this.#compareDateField("beforeField", otherField, options, (a, b) => a < b);
1281
+ }
1282
+ /** Must be the same instant as `operand` (VineJS `equals`). */
1283
+ equals(operand, options) {
1284
+ return this.#compareDate("equals", operand, (a, b) => a === b, options);
1285
+ }
1286
+ /** Must be after the sibling's date, or the same instant (VineJS `afterOrSameAs`). */
1287
+ afterOrSameAs(otherField, options) {
1288
+ return this.#compareDateField("afterOrSameAs", otherField, options, (a, b) => a >= b);
1289
+ }
1290
+ /** Must be before the sibling's date, or the same instant. */
1291
+ beforeOrSameAs(otherField, options) {
1292
+ return this.#compareDateField("beforeOrSameAs", otherField, options, (a, b) => a <= b);
1293
+ }
1294
+ /** Must fall on a Saturday or Sunday (VineJS `weekend`). */
1295
+ weekend() {
1296
+ this.#pushRule({
1297
+ name: "weekend",
1298
+ validate: (v) => v instanceof Date && (v.getDay() === 0 || v.getDay() === 6),
1299
+ message: "Must be a weekend date",
1300
+ });
1301
+ return this;
1302
+ }
1303
+ /** Must fall on a Monday-to-Friday day (VineJS `weekday`). */
1304
+ weekday() {
1305
+ this.#pushRule({
1306
+ name: "weekday",
1307
+ validate: (v) => v instanceof Date && v.getDay() > 0 && v.getDay() < 6,
1308
+ message: "Must be a weekday date",
1309
+ });
1310
+ return this;
1311
+ }
1312
+ /** Shared body of the `after`/`before`/`*OrEqual` literal comparisons. */
1313
+ #compareDate(name, operand, cmp, options) {
1314
+ // VineJS: `options.compare || "day"`. A bare `after('today')` is about the
1315
+ // calendar date, not the clock — comparing exact timestamps made every
1316
+ // same-day value fail a rule the caller read as "today or later".
1317
+ const unit = options?.compare ?? "day";
1318
+ const formats = options?.format ? [options.format] : null;
1319
+ this.#pushRule({
1320
+ name,
1321
+ // A callable operand is resolved per validation, not once at build
1322
+ // time — otherwise `after(() => Date.now())` would freeze the boundary
1323
+ // at the moment the schema was declared (VineJS allows the callback).
1324
+ args: typeof operand === "function" ? undefined : { operand },
1325
+ validate: (v) => {
1326
+ const raw = typeof operand === "function"
1327
+ ? operand()
1328
+ : operand;
1329
+ const other = formats && typeof raw === "string"
1330
+ ? parseDateValue(raw, formats)
1331
+ : resolveOperand(raw);
1332
+ if (!(v instanceof Date) || other === null)
1333
+ return false;
1334
+ return cmp(truncateTo(v, unit), truncateTo(other, unit));
1335
+ },
1336
+ message: `Must be ${name.replace(/([A-Z])/g, " $1").toLowerCase()} ${String(operand)}`,
1337
+ });
1338
+ return this;
1339
+ }
1340
+ /** Shared body of the `afterField`/`beforeField` sibling comparisons. */
1341
+ #compareDateField(name, otherField, options, cmp) {
1342
+ const formats = options?.format
1343
+ ? [options.format]
1344
+ : (this.#dateFormats ?? ["iso8601"]);
1345
+ const unit = options?.compare ?? "day";
1346
+ this.#pushUse({
1347
+ __rune: "rule",
1348
+ run: (value, field) => {
1349
+ const other = parseDateValue(readSibling(field, otherField), formats);
1350
+ if (!(value instanceof Date) || other === null) {
1351
+ field.report(`Cannot compare with ${otherField}`, name);
1352
+ return;
1353
+ }
1354
+ if (!cmp(truncateTo(value, unit), truncateTo(other, unit))) {
1355
+ field.report(`Must be ${name.replace("Field", "")} ${otherField}`, name);
1356
+ }
1357
+ },
1358
+ });
1359
+ return this;
1360
+ }
1361
+ /**
1362
+ * Keep keys the object shape does not declare (VineJS
1363
+ * `allowUnknownProperties`). Off by default: dropping undeclared keys is what
1364
+ * makes a validated payload safe to hand to a mass assignment.
1365
+ */
1366
+ allowUnknownProperties() {
1367
+ this.#allowUnknown = true;
1368
+ return this;
1369
+ }
1370
+ /**
1371
+ * Convert the object's KEYS to camelCase in the output (VineJS
1372
+ * `object.toCamelCase()`), so a snake_case payload hydrates camelCase
1373
+ * properties. Distinct from the string `toCamelCase()`, which rewrites a
1374
+ * VALUE — that one was never a substitute for this.
1375
+ */
1376
+ toCamelCaseKeys() {
1377
+ return this.toCamelCase();
1378
+ }
1379
+ /**
1380
+ * Merge extra properties into this object's shape (VineJS `merge`). Accepts a
1381
+ * plain shape or a {@link ConditionalGroup} whose branch is chosen per
1382
+ * payload — `vine.group` in VineJS.
1383
+ */
1384
+ merge(extra) {
1385
+ if (!this.#nestedSchema) {
1386
+ throw new RuneError("NOT_AN_OBJECT", "merge() needs an object() shape to merge into.", { hint: "rules.any().object({ … }).merge({ … })" });
1387
+ }
1388
+ if (isConditionalGroup(extra)) {
1389
+ this.#groups.push(extra);
1390
+ return this;
1391
+ }
1392
+ this.#nestedSchema = { ...this.#nestedSchema, ...extra };
1393
+ return this;
1394
+ }
1395
+ /** The nested shape declared by `object()`, if any (VineJS `getProperties`). */
1396
+ getProperties() {
1397
+ // CLONE each chain, not just the map. A shallow copy shares the chain
1398
+ // instances, so mutating one through the copy relaxes the source schema —
1399
+ // the same trap that made `partial()` mutate its origin.
1400
+ if (!this.#nestedSchema)
1401
+ return null;
1402
+ return Object.fromEntries(Object.entries(this.#nestedSchema).map(([key, chain]) => [
1403
+ key,
1404
+ chain.clone(),
1405
+ ]));
1406
+ }
1407
+ /** Independent copy of this chain (VineJS `clone`). */
1408
+ clone() {
1409
+ return this.#retype();
1410
+ }
1411
+ /**
1412
+ * A CLONED subset of the object's properties (VineJS `pick`).
1413
+ *
1414
+ * Returns a properties record, not a schema — VineJS types it
1415
+ * `Pick<Properties, Keys>` precisely so it composes by spread:
1416
+ * `rules.any().object({ ...userShape.pick(["id"]) })`. Returning a chain here
1417
+ * broke that idiom.
1418
+ */
1419
+ pick(keys) {
1420
+ return this.#subsetOfProperties((key) => keys.includes(key));
1421
+ }
1422
+ /** A cloned copy of the properties EXCLUDING `keys` (VineJS `omit`). */
1423
+ omit(keys) {
1424
+ return this.#subsetOfProperties((key) => !keys.includes(key));
1425
+ }
1426
+ /** Shared body of `pick`/`omit` — clones so the source stays untouched. */
1427
+ #subsetOfProperties(keep) {
1428
+ const shape = this.getProperties();
1429
+ if (!shape) {
1430
+ throw new RuneError("NOT_AN_OBJECT", "pick()/omit() need an object() shape to work on.", { hint: "rules.any().object({ … }).pick([…])" });
1431
+ }
1432
+ return Object.fromEntries(Object.entries(shape).filter(([key]) => keep(key)));
1433
+ }
1434
+ /** Make every property of an object shape optional (VineJS `partial`). */
1435
+ partial(keys) {
1436
+ // `optional()` mutates and returns the SAME chain, so calling it on the
1437
+ // stored properties made the source shape optional too — `base.partial()`
1438
+ // silently relaxed `base`. Clone each property first, like VineJS does.
1439
+ return this.#reshape((shape) => Object.fromEntries(Object.entries(shape).map(([key, chain]) => [
1440
+ key,
1441
+ keys === undefined || keys.includes(key)
1442
+ ? chain.clone().optional()
1443
+ : chain,
1444
+ ])));
1445
+ }
1446
+ /** Shared body of `pick`/`omit`/`partial` — rebuilds the nested shape on a clone. */
1447
+ #reshape(transform) {
1448
+ if (!this.#nestedSchema) {
1449
+ throw new RuneError("NOT_AN_OBJECT", "pick()/omit()/partial() need an object() shape to work on.", {
1450
+ hint: "Declare the shape first: rules.any().object({ … }).pick([…])",
1451
+ });
1452
+ }
1453
+ const next = this.#retype();
1454
+ next.#nestedSchema = transform(this.#nestedSchema);
1455
+ return next;
1456
+ }
1457
+ /**
1458
+ * Must be an "accepted" value — `true`, `1`, `"1"`, `"on"`, `"yes"`,
1459
+ * `"true"` (VineJS `accepted`, for checkbox-style consent fields).
1460
+ */
1461
+ accepted() {
1462
+ this.#pushRule({
1463
+ name: "accepted",
1464
+ validate: isAcceptedValue,
1465
+ message: "Must be accepted",
1466
+ });
1467
+ // Normalise ONLY an accepted value: a blanket `() => true` would rewrite a
1468
+ // refused value into an accepted one before the rule ever saw it.
1469
+ this.#transforms.push({
1470
+ name: "accepted",
1471
+ fn: (value) => (isAcceptedValue(value) ? true : value),
1472
+ });
1473
+ return this.#retype();
1474
+ }
1475
+ /**
1476
+ * Object with arbitrary keys, every value validated by `valueChain`
1477
+ * (VineJS `record`).
1478
+ */
1479
+ record(valueChain) {
1480
+ this.#pushRule({
1481
+ name: "record",
1482
+ validate: (v) => isPlainObject(v),
1483
+ message: "Must be an object",
1484
+ });
1485
+ this.#recordValueChain = valueChain;
1486
+ return this.#retype();
1487
+ }
1488
+ /**
1489
+ * Fixed-length array with a schema per position (VineJS `tuple`). Extra
1490
+ * items are rejected — a tuple that silently ignores a trailing element is
1491
+ * how unvalidated data slips through.
1492
+ */
1493
+ tuple(items) {
1494
+ this.#pushRule({
1495
+ name: "tuple",
1496
+ args: { length: items.length },
1497
+ validate: (v) => Array.isArray(v) && v.length === items.length,
1498
+ message: `Must be an array of exactly ${items.length} items`,
1499
+ });
1500
+ this.#tupleChains = [...items];
1501
+ return this.#retype();
1502
+ }
1503
+ /**
1504
+ * Value must satisfy at least one of `chains`.
1505
+ *
1506
+ * Two forms, both supported:
1507
+ *
1508
+ * - guarded (VineJS parity): `union([rules.union.if(pred, chain), …,
1509
+ * rules.union.else(fallback)])` — the predicate SELECTS the branch and
1510
+ * that branch's own errors are reported, so a failure says which shape was
1511
+ * meant and why it did not fit.
1512
+ * - bare chains: tried in order, first match wins, and a total miss reports a
1513
+ * single `union` error rather than every losing branch's noise.
1514
+ */
1515
+ union(chains) {
1516
+ this.#unionChains = chains.map(toUnionBranch);
1517
+ // Marker rule: its name is not in NATIVE_RULES, which is what keeps a
1518
+ // union off the native path. The Rust engine knows nothing about branches
1519
+ // and would silently accept anything.
1520
+ this.#pushRule({
1521
+ name: "union",
1522
+ validate: () => true,
1523
+ message: "Does not match any allowed shape",
1524
+ });
1525
+ return this;
1526
+ }
1527
+ /**
1528
+ * Must be an uploaded file (VineJS/Adonis `vine.file()`).
1529
+ *
1530
+ * Named deviation: Adonis validates a bodyparser `MultipartFile`, which rune
1531
+ * cannot import and stay agnostic. It checks the STRUCTURE instead — any
1532
+ * object exposing `size` and a name/extension — so an Adonis MultipartFile
1533
+ * satisfies it, and so does any other upload representation.
1534
+ *
1535
+ * `size` is a byte count; `extnames` are compared lowercase, without the dot.
1536
+ */
1537
+ file(options) {
1538
+ // Adonis documents `size: '2mb'`; a numeric-only option meant a
1539
+ // transcribed validator either failed the typecheck or, in JS, silently
1540
+ // stopped capping.
1541
+ const maxBytes = options?.size === undefined ? undefined : parseByteSize(options.size);
1542
+ if (options?.extnames)
1543
+ this.#declaredExtnames = options.extnames;
1544
+ if (options?.verifyContent === false)
1545
+ this.#contentVerificationOff = true;
1546
+ this.#pushRule({
1547
+ name: "file",
1548
+ args: options ? { ...options } : undefined,
1549
+ validate: (v) => {
1550
+ if (!isFileLike(v))
1551
+ return false;
1552
+ if (maxBytes !== undefined && v.size > maxBytes)
1553
+ return false;
1554
+ if (options?.extnames) {
1555
+ const ext = fileExtension(v);
1556
+ if (ext === null)
1557
+ return false;
1558
+ if (!options.extnames.map((e) => e.toLowerCase()).includes(ext)) {
1559
+ return false;
1560
+ }
1561
+ }
1562
+ return true;
1563
+ },
1564
+ message: "Must be a valid file",
1565
+ });
1566
+ // Declaring an allowed extension list is a SECURITY statement, so the
1567
+ // bytes are checked by default. `{ verifyContent: false }` opts out
1568
+ // explicitly and leaves a trace in the schema.
1569
+ if (options?.extnames)
1570
+ this.#ensureContentVerification();
1571
+ return this.#retype();
1572
+ }
1573
+ /**
1574
+ * Uploaded file with VineJS `nativeFile` options — `minSize`, `maxSize`,
1575
+ * `mimeTypes`. Same structural contract as {@link file}: rune never reads
1576
+ * bytes, so the MIME type is the one the upload REPORTS.
1577
+ */
1578
+ nativeFile(options) {
1579
+ const min = options?.minSize === undefined
1580
+ ? undefined
1581
+ : parseByteSize(options.minSize);
1582
+ const max = options?.maxSize === undefined
1583
+ ? undefined
1584
+ : parseByteSize(options.maxSize);
1585
+ this.#pushRule({
1586
+ name: "nativeFile",
1587
+ args: options ? { ...options } : undefined,
1588
+ validate: (v) => {
1589
+ if (!isFileLike(v))
1590
+ return false;
1591
+ if (min !== undefined && v.size < min)
1592
+ return false;
1593
+ if (max !== undefined && v.size > max)
1594
+ return false;
1595
+ if (options?.mimeTypes) {
1596
+ const type = typeof v.type === "string" ? v.type.toLowerCase() : null;
1597
+ if (type === null)
1598
+ return false;
1599
+ if (!options.mimeTypes.map((m) => m.toLowerCase()).includes(type)) {
1600
+ return false;
1601
+ }
1602
+ }
1603
+ return true;
1604
+ },
1605
+ message: "Must be a valid file",
1606
+ });
1607
+ // Declaring allowed MIME types is a SECURITY statement, so the bytes are
1608
+ // checked by default.
1609
+ if (options?.mimeTypes)
1610
+ this.#ensureContentVerification();
1611
+ return this.#retype();
1612
+ }
1613
+ /** Minimum upload size (VineJS `nativeFile().minSize()`). */
1614
+ minSize(size) {
1615
+ const min = parseByteSize(size);
1616
+ this.#pushRule({
1617
+ name: "minSize",
1618
+ args: { size },
1619
+ validate: (v) => isFileLike(v) && v.size >= min,
1620
+ message: `Must be at least ${size} in size`,
349
1621
  });
350
- return this.#retype();
1622
+ return this;
351
1623
  }
352
- /** Must be a boolean. */
353
- boolean() {
354
- this.#rules.push({
355
- name: "boolean",
356
- validate: (v) => typeof v === "boolean",
357
- message: "Must be a boolean",
1624
+ /** Maximum upload size (VineJS `nativeFile().maxSize()`). */
1625
+ maxSize(size) {
1626
+ const max = parseByteSize(size);
1627
+ this.#pushRule({
1628
+ name: "maxSize",
1629
+ args: { size },
1630
+ validate: (v) => isFileLike(v) && v.size <= max,
1631
+ message: `Must be at most ${size} in size`,
358
1632
  });
359
- return this.#retype();
1633
+ return this;
1634
+ }
1635
+ /**
1636
+ * Allowed MIME types (VineJS `nativeFile().mimeTypes()`). The type is the one
1637
+ * the upload REPORTS — rune never reads bytes, see {@link file}.
1638
+ */
1639
+ mimeTypes(types) {
1640
+ const allowed = types.map((t) => t.toLowerCase());
1641
+ this.#declaredMimeTypes = allowed;
1642
+ this.#ensureContentVerification();
1643
+ this.#pushRule({
1644
+ name: "mimeTypes",
1645
+ args: { types: allowed },
1646
+ validate: (v) => isFileLike(v) &&
1647
+ typeof v.type === "string" &&
1648
+ allowed.includes(v.type.toLowerCase()),
1649
+ message: `Must be one of ${allowed.join(", ")}`,
1650
+ });
1651
+ return this;
1652
+ }
1653
+ /**
1654
+ * Verify the file's REAL type against its magic number (Adonis parity).
1655
+ *
1656
+ * A `.exe` renamed `.jpg` passes every declarative check — size, extension,
1657
+ * reported MIME — because all three come from the uploader. This reads the
1658
+ * leading bytes and refuses a mismatch.
1659
+ *
1660
+ * Async by nature (it touches the filesystem), so the schema must run with
1661
+ * `validateResultAsync` / `validate`. Needs a byte source on the file object
1662
+ * (`buffer`, `tmpPath`, `filePath` or `path`) — an Adonis `MultipartFile`
1663
+ * carries `tmpPath`. With NO source it FAILS: a content check that cannot
1664
+ * run must never look like one that passed.
1665
+ */
1666
+ verifyContent() {
1667
+ this.#contentVerificationOff = false;
1668
+ return this.#ensureContentVerification();
1669
+ }
1670
+ /** Register the content check once, honouring an explicit opt-out. */
1671
+ #ensureContentVerification() {
1672
+ if (this.#contentVerified || this.#contentVerificationOff)
1673
+ return this;
1674
+ this.#contentVerified = true;
1675
+ return this.#registerContentVerification();
1676
+ }
1677
+ /** The async rule itself — reads the bytes and confronts the declaration. */
1678
+ #registerContentVerification() {
1679
+ const extnames = this.#declaredExtnames;
1680
+ const mimeTypes = this.#declaredMimeTypes;
1681
+ this.#pushAsync({
1682
+ __rune: "asyncRule",
1683
+ async run(value, field) {
1684
+ if (!isFileLike(value)) {
1685
+ field.report("Must be a valid file", "verifyContent");
1686
+ return;
1687
+ }
1688
+ const head = await readFileHead(value);
1689
+ if (head === null) {
1690
+ field.report("Cannot read the file's content to verify its type", "verifyContent");
1691
+ return;
1692
+ }
1693
+ const detected = detectFileType(head);
1694
+ if (detected === null) {
1695
+ field.report("File type could not be recognised", "verifyContent");
1696
+ return;
1697
+ }
1698
+ // The declared extension must agree with the bytes.
1699
+ const declaredExt = typeof value.extname === "string" && value.extname.length > 0
1700
+ ? value.extname
1701
+ : null;
1702
+ if (declaredExt && !extensionMatches(detected.ext, declaredExt)) {
1703
+ field.report(`Content is ${detected.ext}, not ${declaredExt.replace(/^\./, "")}`, "verifyContent");
1704
+ return;
1705
+ }
1706
+ if (extnames &&
1707
+ !extnames.some((allowed) => extensionMatches(detected.ext, allowed))) {
1708
+ field.report(`Content is ${detected.ext}, which is not allowed`, "verifyContent");
1709
+ return;
1710
+ }
1711
+ if (mimeTypes && !mimeTypes.includes(detected.mime)) {
1712
+ field.report(`Content is ${detected.mime}, which is not allowed`, "verifyContent");
1713
+ }
1714
+ },
1715
+ });
1716
+ return this;
360
1717
  }
361
1718
  /** Must equal one of `values` (enum). Narrows the output to the union. */
362
1719
  enum(values) {
363
1720
  const allowed = [...values];
364
- this.#rules.push({
1721
+ this.#pushRule({
365
1722
  name: "enum",
366
1723
  args: { values: allowed },
367
1724
  validate: (v) => allowed.includes(asPrimitive(v)),
@@ -371,9 +1728,9 @@ export class RuleChain {
371
1728
  }
372
1729
  /** Must equal a literal value. */
373
1730
  literal(value) {
374
- this.#rules.push({
1731
+ this.#pushRule({
375
1732
  name: "literal",
376
- args: { expectedValue: value },
1733
+ args: { value, expectedValue: value },
377
1734
  validate: (v) => v === value,
378
1735
  message: `Must be ${String(value)}`,
379
1736
  });
@@ -381,7 +1738,7 @@ export class RuleChain {
381
1738
  }
382
1739
  /** Minimum length (string) or minimum value (number). Alias of min/minLength. */
383
1740
  min(n) {
384
- this.#rules.push({
1741
+ this.#pushRule({
385
1742
  name: "min",
386
1743
  param: n,
387
1744
  args: { min: n },
@@ -396,7 +1753,7 @@ export class RuleChain {
396
1753
  }
397
1754
  /** Maximum length (string) or maximum value (number). Alias of max/maxLength. */
398
1755
  max(n) {
399
- this.#rules.push({
1756
+ this.#pushRule({
400
1757
  name: "max",
401
1758
  param: n,
402
1759
  args: { max: n },
@@ -411,7 +1768,7 @@ export class RuleChain {
411
1768
  }
412
1769
  /** Minimum length for a string or array (VineJS `minLength`). */
413
1770
  minLength(n) {
414
- this.#rules.push({
1771
+ this.#pushRule({
415
1772
  name: "minLength",
416
1773
  param: n,
417
1774
  args: { min: n },
@@ -422,7 +1779,7 @@ export class RuleChain {
422
1779
  }
423
1780
  /** Maximum length for a string or array (VineJS `maxLength`). */
424
1781
  maxLength(n) {
425
- this.#rules.push({
1782
+ this.#pushRule({
426
1783
  name: "maxLength",
427
1784
  param: n,
428
1785
  args: { max: n },
@@ -436,7 +1793,7 @@ export class RuleChain {
436
1793
  }
437
1794
  /** Exact length for a string or array (VineJS `fixedLength`). */
438
1795
  fixedLength(n) {
439
- this.#rules.push({
1796
+ this.#pushRule({
440
1797
  name: "fixedLength",
441
1798
  param: n,
442
1799
  args: { size: n },
@@ -446,19 +1803,12 @@ export class RuleChain {
446
1803
  return this;
447
1804
  }
448
1805
  /** Must be a valid email. */
449
- email() {
450
- this.#rules.push({
451
- name: "email",
452
- // Mirror the Rust engine's regex exactly so the SAME schema validates
453
- // identically whether or not the native binary loaded.
454
- validate: (v) => typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
455
- message: "Must be a valid email",
456
- });
457
- return this;
1806
+ email(options) {
1807
+ return this.#stringRule("email", (v) => isEmail(v, options), "Must be a valid email address", options ? { ...options } : undefined);
458
1808
  }
459
1809
  /** Must match a regular expression (TS-only — never dispatched to Rust). */
460
1810
  regex(pattern) {
461
- this.#rules.push({
1811
+ this.#pushRule({
462
1812
  name: "regex",
463
1813
  validate: (v) => typeof v === "string" && pattern.test(v),
464
1814
  message: "Invalid format",
@@ -466,44 +1816,384 @@ export class RuleChain {
466
1816
  return this;
467
1817
  }
468
1818
  /** Must be a valid URL (TS-only — uses the WHATWG URL parser). */
469
- url() {
470
- this.#rules.push({
1819
+ url(options) {
1820
+ this.#pushRule({
471
1821
  name: "url",
472
- validate: (v) => typeof v === "string" && isValidUrl(v),
1822
+ args: options ? { ...options } : undefined,
1823
+ validate: (v) => typeof v === "string" &&
1824
+ (options ? isUrlWithOptions(v, options) : isValidUrl(v)),
473
1825
  message: "Must be a valid URL",
474
1826
  });
475
1827
  return this;
476
1828
  }
477
- /** Must be a valid UUID. */
478
- uuid() {
479
- this.#rules.push({
1829
+ /**
1830
+ * The host must actually resolve (VineJS `activeUrl`).
1831
+ *
1832
+ * The only rule needing the network, which rune cannot do and stay agnostic
1833
+ * and zero-dependency — so it runs through a resolver bound once at boot,
1834
+ * exactly like `unique()`. Async by nature: run the schema with
1835
+ * `validateResultAsync`. Unbound it THROWS, rather than passing a host nobody
1836
+ * checked.
1837
+ */
1838
+ activeUrl() {
1839
+ this.#pushAsync({
1840
+ __rune: "asyncRule",
1841
+ async run(value, field) {
1842
+ if (!hostResolver) {
1843
+ throw new RuneError("NO_HOST_RESOLVER", "activeUrl() needs a host resolver.", { hint: "Call bindHostResolver(resolver) once at boot." });
1844
+ }
1845
+ let host;
1846
+ try {
1847
+ host = new URL(String(value)).hostname;
1848
+ }
1849
+ catch {
1850
+ field.report("Must be a valid URL", "activeUrl");
1851
+ return;
1852
+ }
1853
+ if (!(await hostResolver.resolves(host))) {
1854
+ field.report("Must be an active URL", "activeUrl");
1855
+ }
1856
+ },
1857
+ });
1858
+ return this;
1859
+ }
1860
+ /**
1861
+ * Must be a valid UUID, optionally restricted to given versions
1862
+ * (VineJS `uuid({ version: [4] })`, versions 1 through 8).
1863
+ */
1864
+ uuid(options) {
1865
+ const versions = options?.version === undefined ? undefined : [options.version].flat();
1866
+ this.#pushRule({
480
1867
  name: "uuid",
481
- validate: (v) => typeof v === "string" && UUID_RE.test(v),
482
- message: "Must be a valid UUID",
1868
+ args: versions === undefined ? {} : { version: versions },
1869
+ // The Rust engine checks UUID shape only; a version constraint would
1870
+ // be dropped there.
1871
+ tsOnly: versions !== undefined,
1872
+ validate: (v) => {
1873
+ if (typeof v !== "string" || !UUID_RE.test(v))
1874
+ return false;
1875
+ if (versions === undefined)
1876
+ return true;
1877
+ // Version nibble: first character of the third group.
1878
+ const version = Number.parseInt(v[14] ?? "", 16);
1879
+ return versions.includes(version);
1880
+ },
1881
+ message: versions === undefined
1882
+ ? "Must be a valid UUID"
1883
+ : `Must be a UUID v${versions.join("/")}`,
1884
+ });
1885
+ return this;
1886
+ }
1887
+ /** Must be a ULID (VineJS `ulid`). */
1888
+ ulid() {
1889
+ return this.#stringRule("ulid", isUlid, "Must be a valid ULID");
1890
+ }
1891
+ /** Must be a JSON Web Token — three dot-separated base64url segments. */
1892
+ jwt() {
1893
+ return this.#stringRule("jwt", isJwt, "Must be a valid JWT");
1894
+ }
1895
+ /** Must contain only ASCII characters (VineJS `ascii`). */
1896
+ ascii() {
1897
+ return this.#stringRule("ascii", isAscii, "Must contain only ASCII characters");
1898
+ }
1899
+ /** Must be a CSS hex colour code, with or without the leading `#`. */
1900
+ hexCode() {
1901
+ return this.#stringRule("hexCode", isHexCode, "Must be a valid hex code");
1902
+ }
1903
+ /** Must be an IP address. Pass `version` to require v4 or v6 specifically. */
1904
+ ipAddress(options) {
1905
+ const version = options?.version;
1906
+ return this.#stringRule("ipAddress", (v) => isIpAddress(v, version), `Must be a valid IP address${version ? ` (v${version})` : ""}`, { version });
1907
+ }
1908
+ /** Must pass the Luhn checksum (VineJS `creditCard`). */
1909
+ creditCard() {
1910
+ return this.#stringRule("creditCard", isCreditCard, "Must be a valid credit card number");
1911
+ }
1912
+ /** Must be an IBAN passing the ISO 13616 mod-97 check. */
1913
+ iban() {
1914
+ return this.#stringRule("iban", isIban, "Must be a valid IBAN");
1915
+ }
1916
+ /** Must be a `"lat,lng"` pair within the valid ranges. */
1917
+ coordinates() {
1918
+ return this.#stringRule("coordinates", isCoordinates, "Must be valid coordinates");
1919
+ }
1920
+ /**
1921
+ * Must be a mobile number in E.164 form. Named deviation from VineJS: rune
1922
+ * carries no per-locale numbering plans, so there is no `locale` option.
1923
+ */
1924
+ mobile(options) {
1925
+ const locales = options?.locale ? [options.locale].flat() : null;
1926
+ for (const locale of locales ?? []) {
1927
+ if (isMobileForLocale("", locale) === null) {
1928
+ throw new RuneError("UNSUPPORTED_LOCALE", `mobile(): no numbering plan for locale '${locale}'.`, {
1929
+ hint: `Supported: ${SUPPORTED_MOBILE_LOCALES.join(", ")}. Omit the locale for E.164, or use .regex().`,
1930
+ });
1931
+ }
1932
+ }
1933
+ return this.#stringRule("mobile", (v) => {
1934
+ // strictMode (validator.js): the number must carry its `+` country
1935
+ // prefix, so a national-format string is not silently accepted.
1936
+ if (options?.strictMode && !v.trim().startsWith("+"))
1937
+ return false;
1938
+ return locales
1939
+ ? locales.some((locale) => isMobileForLocale(v, locale) === true)
1940
+ : isMobile(v);
1941
+ }, "Must be a valid mobile number", (locales ?? options?.strictMode)
1942
+ ? { locale: locales, strictMode: options?.strictMode }
1943
+ : undefined);
1944
+ }
1945
+ /**
1946
+ * Must be a postal code for `countryCode`. Throws for a country rune has no
1947
+ * pattern for, rather than accepting the value unchecked.
1948
+ */
1949
+ postalCode(options) {
1950
+ // The callback form resolves per validation (VineJS lets the country come
1951
+ // from a sibling field), so its countries cannot be checked up front.
1952
+ if (typeof options === "function") {
1953
+ this.#pushUse({
1954
+ __rune: "rule",
1955
+ run: (value, field) => {
1956
+ if (typeof value !== "string")
1957
+ return;
1958
+ const countries = [options(field).countryCode].flat();
1959
+ if (!countries.some((c) => isPostalCode(value, c) === true)) {
1960
+ field.report(`Must be a valid ${countries.join("/")} postal code`, "postalCode");
1961
+ }
1962
+ },
1963
+ });
1964
+ return this;
1965
+ }
1966
+ const countries = [options.countryCode].flat();
1967
+ for (const country of countries) {
1968
+ if (isPostalCode("", country) === null) {
1969
+ throw new RuneError("UNSUPPORTED_COUNTRY", `postalCode(): no pattern for country '${country}'.`, {
1970
+ hint: `Supported: ${SUPPORTED_POSTAL_CODES.join(", ")}. Use .regex() for others.`,
1971
+ });
1972
+ }
1973
+ }
1974
+ return this.#stringRule("postalCode", (v) => countries.some((c) => isPostalCode(v, c) === true), `Must be a valid ${countries.join("/").toUpperCase()} postal code`, { countryCode: countries });
1975
+ }
1976
+ /**
1977
+ * Must be a valid VAT number (VineJS 4.2 `vat`). Accepts a country list or a
1978
+ * callback resolving it per payload.
1979
+ *
1980
+ * Checksums are run where the country defines a short, well-defined one
1981
+ * (BE, DE, NL, IT, PT, LU, CH); the others are FORMAT-only, which is stated
1982
+ * rather than implied. An unknown country LEVES rather than accepting the
1983
+ * value unchecked.
1984
+ */
1985
+ vat(options) {
1986
+ if (typeof options === "function") {
1987
+ this.#pushUse({
1988
+ __rune: "rule",
1989
+ run: (value, field) => {
1990
+ if (typeof value !== "string")
1991
+ return;
1992
+ const countries = [options(field).countryCode].flat();
1993
+ if (!countries.some((c) => isVat(value, c) === true)) {
1994
+ field.report(`Must be a valid ${countries.join("/")} VAT number`, "vat");
1995
+ }
1996
+ },
1997
+ });
1998
+ return this;
1999
+ }
2000
+ const countries = [options.countryCode].flat();
2001
+ for (const country of countries) {
2002
+ if (isVat("", country) === null) {
2003
+ throw new RuneError("UNSUPPORTED_COUNTRY", `vat(): no rule for country '${country}'.`, {
2004
+ hint: `Supported: ${SUPPORTED_VAT_COUNTRIES.join(", ")}. Use .regex() for others.`,
2005
+ });
2006
+ }
2007
+ }
2008
+ return this.#stringRule("vat", (v) => countries.some((c) => isVat(v, c) === true), `Must be a valid ${countries.join("/").toUpperCase()} VAT number`, { countryCode: countries });
2009
+ }
2010
+ /** Must differ from a sibling field (VineJS `notSameAs`). */
2011
+ notSameAs(otherField) {
2012
+ const formats = this.#dateFormats;
2013
+ this.#pushUse({
2014
+ __rune: "rule",
2015
+ run: (value, field) => {
2016
+ const other = readSibling(field, otherField);
2017
+ if (formats !== null && value instanceof Date) {
2018
+ const parsed = parseDateValue(other, formats);
2019
+ if (parsed !== null && parsed.getTime() === value.getTime()) {
2020
+ field.report(`Must be different from ${otherField}`, "notSameAs");
2021
+ }
2022
+ return;
2023
+ }
2024
+ if (value === other) {
2025
+ field.report(`Must be different from ${otherField}`, "notSameAs");
2026
+ }
2027
+ },
2028
+ });
2029
+ return this;
2030
+ }
2031
+ /** Array items must be unique — optionally compared on `field` (VineJS `distinct`). */
2032
+ distinct(field) {
2033
+ this.#pushRule({
2034
+ name: "distinct",
2035
+ args: { field },
2036
+ validate: (v) => {
2037
+ if (!Array.isArray(v))
2038
+ return false;
2039
+ const fieldList = field === undefined ? null : [field].flat();
2040
+ const keys = [];
2041
+ for (const item of v) {
2042
+ // VineJS ignores null/undefined items entirely: `[1, null, 2, null]`
2043
+ // is distinct. Serialising them would make the second one a
2044
+ // duplicate of the first.
2045
+ if (item === null || item === undefined)
2046
+ continue;
2047
+ if (fieldList === null) {
2048
+ keys.push(JSON.stringify(item));
2049
+ continue;
2050
+ }
2051
+ if (!isPlainObject(item))
2052
+ continue;
2053
+ // VineJS skips an item missing the key(s): two absent values are
2054
+ // not a duplicate of each other.
2055
+ if (fieldList.some((k) => item[k] === undefined || item[k] === null)) {
2056
+ continue;
2057
+ }
2058
+ keys.push(JSON.stringify(fieldList.map((k) => item[k])));
2059
+ }
2060
+ return new Set(keys).size === keys.length;
2061
+ },
2062
+ message: field
2063
+ ? `Items must have a unique ${field}`
2064
+ : "Items must be unique",
2065
+ });
2066
+ return this;
2067
+ }
2068
+ /** Must be less than or equal to zero (VineJS `nonPositive`). */
2069
+ nonPositive() {
2070
+ this.#pushRule({
2071
+ name: "nonPositive",
2072
+ validate: (v) => typeof v === "number" && v <= 0,
2073
+ message: "Must be zero or negative",
2074
+ });
2075
+ return this;
2076
+ }
2077
+ /** Array must hold at least one item (VineJS `notEmpty`). */
2078
+ notEmpty() {
2079
+ this.#pushRule({
2080
+ name: "notEmpty",
2081
+ validate: (v) => Array.isArray(v) && v.length > 0,
2082
+ message: "Must not be empty",
2083
+ });
2084
+ return this;
2085
+ }
2086
+ /** Drop `null`, `undefined` and `""` items before the item rules run. */
2087
+ compact() {
2088
+ this.#transforms.push({
2089
+ name: "compact",
2090
+ fn: (value) => Array.isArray(value)
2091
+ ? value.filter((item) => item !== null && item !== undefined && item !== "")
2092
+ : value,
2093
+ });
2094
+ return this;
2095
+ }
2096
+ /** Number must have no fractional part (VineJS `withoutDecimals`). */
2097
+ withoutDecimals() {
2098
+ this.#pushRule({
2099
+ name: "withoutDecimals",
2100
+ validate: (v) => typeof v === "number" && Number.isInteger(v),
2101
+ message: "Must not have decimals",
2102
+ });
2103
+ return this;
2104
+ }
2105
+ /** Shared body of the string-format rules: reject non-strings, then check. */
2106
+ #stringRule(name, check, message, args) {
2107
+ this.#pushRule({
2108
+ name,
2109
+ args,
2110
+ validate: (v) => typeof v === "string" && check(v),
2111
+ message,
2112
+ });
2113
+ return this;
2114
+ }
2115
+ /** Must be a passport number for `countryCode`. Throws for an uncovered country. */
2116
+ passport(options) {
2117
+ const countries = [options.countryCode].flat();
2118
+ for (const country of countries) {
2119
+ if (isPassport("", country) === null) {
2120
+ throw new RuneError("UNSUPPORTED_COUNTRY", `passport(): no pattern for country '${country}'.`, {
2121
+ hint: `Supported: ${SUPPORTED_PASSPORTS.join(", ")}. Use .regex() for others.`,
2122
+ });
2123
+ }
2124
+ }
2125
+ return this.#stringRule("passport", (v) => countries.some((c) => isPassport(v, c) === true), `Must be a valid ${countries.join("/").toUpperCase()} passport number`, { countryCode: countries });
2126
+ }
2127
+ /** Lowercase the value (VineJS `toLowerCase`). */
2128
+ toLowerCase() {
2129
+ return this.#stringMutation("toLowerCase", (v) => v.toLowerCase());
2130
+ }
2131
+ /** Uppercase the value (VineJS `toUpperCase`). */
2132
+ toUpperCase() {
2133
+ return this.#stringMutation("toUpperCase", (v) => v.toUpperCase());
2134
+ }
2135
+ /**
2136
+ * VineJS `toCamelCase()`, on both shapes it exists for:
2137
+ *
2138
+ * - on an `object()` chain it camelCases the object's KEYS
2139
+ * (`VineObject.toCamelCase`);
2140
+ * - on any other chain it camelCases the string VALUE (`VineString`).
2141
+ *
2142
+ * One name, because Vine has one name. Dispatching on whether a nested shape
2143
+ * was declared is what keeps a transcribed validator behaving the same.
2144
+ */
2145
+ toCamelCase() {
2146
+ if (this.#nestedSchema) {
2147
+ this.#camelCaseKeys = true;
2148
+ return this;
2149
+ }
2150
+ return this.#stringMutation("toCamelCase", toCamelCase);
2151
+ }
2152
+ /** HTML-escape `& < > " '` (VineJS `escape`). */
2153
+ escape() {
2154
+ return this.#stringMutation("escape", escapeHtml);
2155
+ }
2156
+ /** Normalise an email address (VineJS `normalizeEmail`). */
2157
+ normalizeEmail(options) {
2158
+ return this.#stringMutation("normalizeEmail", (v) => normalizeEmail(v, options));
2159
+ }
2160
+ /** Normalise a URL (VineJS `normalizeUrl`). */
2161
+ normalizeUrl(options) {
2162
+ return this.#stringMutation("normalizeUrl", (v) => normalizeUrl(v, options));
2163
+ }
2164
+ /** Shared body of the string mutations — non-strings pass through untouched. */
2165
+ #stringMutation(name, fn) {
2166
+ this.#transforms.push({
2167
+ name,
2168
+ fn: (value) => (typeof value === "string" ? fn(value) : value),
483
2169
  });
484
2170
  return this;
485
2171
  }
486
2172
  /** Must contain only ASCII letters. */
487
- alpha() {
488
- this.#rules.push({
2173
+ alpha(options) {
2174
+ const pattern = alphaPattern("a-zA-Z", options);
2175
+ this.#pushRule({
489
2176
  name: "alpha",
490
- validate: (v) => typeof v === "string" && v.length > 0 && /^[a-zA-Z]+$/.test(v),
2177
+ args: options ? { ...options } : undefined,
2178
+ validate: (v) => typeof v === "string" && v.length > 0 && pattern.test(v),
491
2179
  message: "Must contain only letters",
492
2180
  });
493
2181
  return this;
494
2182
  }
495
2183
  /** Must contain only ASCII letters and digits. */
496
- alphaNumeric() {
497
- this.#rules.push({
2184
+ alphaNumeric(options) {
2185
+ const pattern = alphaPattern("a-zA-Z0-9", options);
2186
+ this.#pushRule({
498
2187
  name: "alphaNumeric",
499
- validate: (v) => typeof v === "string" && v.length > 0 && /^[a-zA-Z0-9]+$/.test(v),
2188
+ args: options ? { ...options } : undefined,
2189
+ validate: (v) => typeof v === "string" && v.length > 0 && pattern.test(v),
500
2190
  message: "Must contain only letters and numbers",
501
2191
  });
502
2192
  return this;
503
2193
  }
504
2194
  /** String must start with `substring`. */
505
2195
  startsWith(substring) {
506
- this.#rules.push({
2196
+ this.#pushRule({
507
2197
  name: "startsWith",
508
2198
  args: { substring },
509
2199
  validate: (v) => typeof v === "string" && v.startsWith(substring),
@@ -513,7 +2203,7 @@ export class RuleChain {
513
2203
  }
514
2204
  /** String must end with `substring`. */
515
2205
  endsWith(substring) {
516
- this.#rules.push({
2206
+ this.#pushRule({
517
2207
  name: "endsWith",
518
2208
  args: { substring },
519
2209
  validate: (v) => typeof v === "string" && v.endsWith(substring),
@@ -521,31 +2211,41 @@ export class RuleChain {
521
2211
  });
522
2212
  return this;
523
2213
  }
524
- /** Value must be one of `values`. */
2214
+ /**
2215
+ * Value must be one of `values`.
2216
+ *
2217
+ * VineJS also accepts a callback so the list can be computed at validation
2218
+ * time (tenant-scoped roles, values read from config…). A static array is
2219
+ * snapshotted; a callback is invoked on every check.
2220
+ */
525
2221
  in(values) {
526
- const allowed = [...values];
527
- this.#rules.push({
2222
+ const resolve = allowedValuesResolver(values);
2223
+ this.#pushRule({
528
2224
  name: "in",
529
- args: { values: allowed },
530
- validate: (v) => allowed.includes(asPrimitive(v)),
2225
+ args: typeof values === "function" ? {} : { values: [...values] },
2226
+ // A callback list is computed per call — the native engine only ever
2227
+ // sees a static array, so it must not run this rule.
2228
+ tsOnly: typeof values === "function",
2229
+ validate: (v) => resolve().includes(asPrimitive(v)),
531
2230
  message: "Invalid value",
532
2231
  });
533
2232
  return this;
534
2233
  }
535
2234
  /** Value must NOT be one of `values`. */
536
2235
  notIn(values) {
537
- const denied = [...values];
538
- this.#rules.push({
2236
+ const resolve = allowedValuesResolver(values);
2237
+ this.#pushRule({
539
2238
  name: "notIn",
540
- args: { values: denied },
541
- validate: (v) => !denied.includes(asPrimitive(v)),
2239
+ args: typeof values === "function" ? {} : { values: [...values] },
2240
+ tsOnly: typeof values === "function",
2241
+ validate: (v) => !resolve().includes(asPrimitive(v)),
542
2242
  message: "Invalid value",
543
2243
  });
544
2244
  return this;
545
2245
  }
546
2246
  /** Number must be positive (> 0) and finite. */
547
2247
  positive() {
548
- this.#rules.push({
2248
+ this.#pushRule({
549
2249
  name: "positive",
550
2250
  validate: (v) => typeof v === "number" && Number.isFinite(v) && v > 0,
551
2251
  message: "Must be positive",
@@ -554,7 +2254,7 @@ export class RuleChain {
554
2254
  }
555
2255
  /** Number must be negative (< 0) and finite. */
556
2256
  negative() {
557
- this.#rules.push({
2257
+ this.#pushRule({
558
2258
  name: "negative",
559
2259
  validate: (v) => typeof v === "number" && Number.isFinite(v) && v < 0,
560
2260
  message: "Must be negative",
@@ -563,7 +2263,7 @@ export class RuleChain {
563
2263
  }
564
2264
  /** Number must be >= 0 and finite. */
565
2265
  nonNegative() {
566
- this.#rules.push({
2266
+ this.#pushRule({
567
2267
  name: "nonNegative",
568
2268
  validate: (v) => typeof v === "number" && Number.isFinite(v) && v >= 0,
569
2269
  message: "Must be positive or zero",
@@ -571,36 +2271,54 @@ export class RuleChain {
571
2271
  return this;
572
2272
  }
573
2273
  /** Number must fall within `[min, max]` (inclusive). */
574
- range(min, max) {
575
- this.#rules.push({
2274
+ range(bounds) {
2275
+ // VineJS signature is a TUPLE (`range([18, 60])`); the two-argument form
2276
+ // silently dropped `max` when an Adonis validator was transcribed as-is.
2277
+ const [min, max] = bounds;
2278
+ this.#pushRule({
576
2279
  name: "range",
577
2280
  args: { min, max },
578
- validate: (v) => typeof v === "number" && Number.isFinite(v) && v >= min && v <= max,
2281
+ validate: (v) => typeof v === "number" && v >= min && v <= max,
579
2282
  message: `Must be between ${min} and ${max}`,
580
2283
  });
581
2284
  return this;
582
2285
  }
583
2286
  /** Number must have at most `digits` decimal places (TS-only). */
584
2287
  decimal(digits) {
585
- this.#rules.push({
2288
+ // VineJS accepts a `[min, max]` range as well as a single maximum.
2289
+ const [min, max] = Array.isArray(digits) ? digits : [0, digits];
2290
+ this.#pushRule({
586
2291
  name: "decimal",
587
2292
  args: { digits },
588
2293
  validate: (v) => {
589
2294
  if (typeof v !== "number" || !Number.isFinite(v))
590
2295
  return false;
591
- const parts = String(v).split(".");
592
- return (parts[1]?.length ?? 0) <= digits;
2296
+ const places = String(v).split(".")[1]?.length ?? 0;
2297
+ return places >= min && places <= max;
593
2298
  },
594
- message: `Must have at most ${digits} decimal places`,
2299
+ message: Array.isArray(digits)
2300
+ ? `Must have between ${min} and ${max} decimal places`
2301
+ : `Must have at most ${max} decimal places`,
595
2302
  });
596
2303
  return this;
597
2304
  }
598
2305
  /** Must equal a sibling field (VineJS `sameAs`). Cross-field → TS-only. */
599
2306
  sameAs(otherField) {
600
- this.#useRules.push({
2307
+ const formats = this.#dateFormats;
2308
+ this.#pushUse({
601
2309
  __rune: "rule",
602
2310
  run: (value, field) => {
603
2311
  const other = readSibling(field, otherField);
2312
+ // On a date chain the value is a parsed `Date` and the sibling is
2313
+ // still raw, so `!==` would compare a Date to a string and always
2314
+ // fail. Compare instants instead.
2315
+ if (formats !== null && value instanceof Date) {
2316
+ const parsed = parseDateValue(other, formats);
2317
+ if (parsed === null || parsed.getTime() !== value.getTime()) {
2318
+ field.report(`Must match ${otherField}`, "sameAs");
2319
+ }
2320
+ return;
2321
+ }
604
2322
  if (value !== other) {
605
2323
  field.report(`Must match ${otherField}`, "sameAs");
606
2324
  }
@@ -610,13 +2328,18 @@ export class RuleChain {
610
2328
  }
611
2329
  /** Must equal its `<field>_confirmation` sibling (VineJS `confirmed`). */
612
2330
  confirmed(options) {
613
- this.#useRules.push({
2331
+ this.#pushUse({
614
2332
  __rune: "rule",
615
2333
  run: (value, field) => {
616
2334
  const leaf = field.field.split(".").pop() ?? field.field;
617
- const other = options?.confirmationField ?? `${leaf}_confirmation`;
2335
+ // `as` is the current VineJS spelling; `confirmationField` is its
2336
+ // deprecated alias, kept so existing callers keep working.
2337
+ const other = options?.as ?? options?.confirmationField ?? `${leaf}_confirmation`;
618
2338
  if (value !== readSibling(field, other)) {
619
- field.report("Confirmation does not match", "confirmed");
2339
+ // VineJS reports on the CONFIRMATION field: that is the input the
2340
+ // user has to fix, and where a form renders the message.
2341
+ const prefix = field.field.slice(0, -leaf.length);
2342
+ field.report("Confirmation does not match", "confirmed", `${prefix}${other}`);
620
2343
  }
621
2344
  },
622
2345
  });
@@ -667,7 +2390,7 @@ export class RuleChain {
667
2390
  }
668
2391
  /** Custom validation rule. */
669
2392
  custom(name, validate, message) {
670
- this.#rules.push({
2393
+ this.#pushRule({
671
2394
  name,
672
2395
  validate,
673
2396
  message: message ?? `Failed custom rule: ${name}`,
@@ -680,52 +2403,233 @@ export class RuleChain {
680
2403
  * validate across fields. Runs after this field's type/value rules.
681
2404
  */
682
2405
  use(rule) {
2406
+ // A rule built with `{ isAsync: true }` arrives here (VineJS has one
2407
+ // `use`); routing it to the sync register would drop the await.
2408
+ if (rule.__rune === "asyncRule") {
2409
+ return this.useAsync(rule);
2410
+ }
683
2411
  if (rule?.__rune !== "rule" || typeof rule.run !== "function") {
684
2412
  throw new RuneError("INVALID_RULE", "use() expects a compiled rule — call the factory first", { hint: "use(myRule()) or use(myRule(options)), not use(myRule)" });
685
2413
  }
686
- this.#useRules.push(rule);
2414
+ this.#pushUse(rule);
2415
+ return this;
2416
+ }
2417
+ /**
2418
+ * Attach an async rule (from {@link createAsyncRule}). The schema must then be
2419
+ * run with `validateResultAsync` — sync `validate()` throws for such a schema.
2420
+ */
2421
+ useAsync(rule) {
2422
+ if (rule?.__rune !== "asyncRule" || typeof rule.run !== "function") {
2423
+ throw new RuneError("INVALID_RULE", "useAsync() expects a compiled async rule — call the factory first", { hint: "useAsync(myRule()) or useAsync(myRule(options))" });
2424
+ }
2425
+ this.#pushAsync(rule);
2426
+ return this;
2427
+ }
2428
+ unique(checkOrOptions, message) {
2429
+ const check = toDatabaseCheck(checkOrOptions, "unique");
2430
+ this.#pushAsync({
2431
+ __rune: "asyncRule",
2432
+ async run(value, field) {
2433
+ const ok = await check(value, field);
2434
+ if (!ok) {
2435
+ field.report(message ?? `The ${field.field} has already been taken`, "database.unique");
2436
+ }
2437
+ },
2438
+ });
2439
+ return this;
2440
+ }
2441
+ exists(checkOrOptions, message) {
2442
+ const check = toDatabaseCheck(checkOrOptions, "exists");
2443
+ this.#pushAsync({
2444
+ __rune: "asyncRule",
2445
+ async run(value, field) {
2446
+ const ok = await check(value, field);
2447
+ if (!ok) {
2448
+ field.report(message ?? `The selected ${field.field} is invalid`, "database.exists");
2449
+ }
2450
+ },
2451
+ });
2452
+ return this;
2453
+ }
2454
+ /**
2455
+ * Attach free-form JSON Schema metadata (VineJS `meta()`) — `title`,
2456
+ * `description`, `examples`, `deprecated`… Merged verbatim into the field's
2457
+ * node by `toJSONSchema()`.
2458
+ */
2459
+ meta(metadata) {
2460
+ this.#metadata = { ...this.#metadata, ...metadata };
687
2461
  return this;
688
2462
  }
689
- /** Set custom error message for the last rule. */
2463
+ /**
2464
+ * Set a custom error message for the rule that was just added.
2465
+ *
2466
+ * "The last rule" spans all three registers: value rules (`#rules`),
2467
+ * cross-field `.use()` rules (`sameAs`, `confirmed`, `afterField`,
2468
+ * `notSameAs`) and async rules (`unique`, `exists`, `useAsync`). Targeting
2469
+ * `#rules` alone silently retargeted the PREVIOUS value rule — or threw
2470
+ * `NO_RULE` — whenever the preceding call was a cross-field or async rule.
2471
+ */
690
2472
  message(msg) {
691
- if (this.#rules.length === 0) {
2473
+ const target = this.#lastRule;
2474
+ if (!target) {
692
2475
  throw new RuneError("NO_RULE", "message() must be called after a rule");
693
2476
  }
694
- const last = this.#rules[this.#rules.length - 1];
695
- last.message = msg;
696
- last.hasCustomMessage = true;
2477
+ if (target.kind === "value") {
2478
+ target.ref.message = msg;
2479
+ target.ref.hasCustomMessage = true;
2480
+ }
2481
+ else {
2482
+ // `.use()` / async rules report their own text from inside `run`, so the
2483
+ // override is applied when the rule reports rather than stored on it.
2484
+ this.#ruleMessages.set(target.ref, msg);
2485
+ }
697
2486
  return this;
698
2487
  }
2488
+ /**
2489
+ * Build the {@link FieldContext} handed to `.use()` / async rules. Shared so
2490
+ * the sync and async paths cannot drift on what a rule can see.
2491
+ */
2492
+ #makeFieldContext(field, value, ctx, errors, onMutate) {
2493
+ const segments = field.split(".");
2494
+ return {
2495
+ value,
2496
+ data: ctx.data,
2497
+ parent: ctx.parent,
2498
+ field,
2499
+ meta: ctx.meta,
2500
+ isValid: errors.length === 0,
2501
+ name: segments[segments.length - 1] ?? field,
2502
+ wildCardPath: toWildcardPath(field),
2503
+ isArrayMember: Array.isArray(ctx.parent),
2504
+ isDefined: value !== undefined && value !== null,
2505
+ isValidDataType: errors.length === 0,
2506
+ getFieldPath: () => field,
2507
+ mutate: onMutate,
2508
+ report(message, rule, reportedField, args) {
2509
+ // VineJS plugins pass the FIELD CONTEXT here, not a path. Accepting
2510
+ // only a string let the object through and produced a
2511
+ // `ValidationError.field` that was not a string at runtime.
2512
+ const target = typeof reportedField === "string"
2513
+ ? reportedField
2514
+ : (reportedField?.getFieldPath() ?? field);
2515
+ errors.push({
2516
+ field: target,
2517
+ rule,
2518
+ message,
2519
+ ...(args ? { meta: args } : {}),
2520
+ });
2521
+ },
2522
+ };
2523
+ }
2524
+ /**
2525
+ * Run only the implicit `.use()` rules against an absent value. A rule
2526
+ * declared `{ implicit: true }` exists to police `undefined`/`null`, so the
2527
+ * early return for optional fields must not skip it.
2528
+ */
2529
+ #runImplicitRules(field, value, ctx, pending) {
2530
+ // An implicit ASYNC rule polices an absent value too, so it has to be
2531
+ // queued here as well — filtering `#useRules` alone dropped it silently.
2532
+ if (pending && this.#asyncRules.some((rule) => rule.implicit)) {
2533
+ pending.push({ chain: this, field, value, ctx });
2534
+ }
2535
+ const implicitRules = this.#useRules.filter((rule) => rule.implicit);
2536
+ if (implicitRules.length === 0)
2537
+ return [];
2538
+ const errors = [];
2539
+ const fieldCtx = this.#makeFieldContext(field, value, ctx, errors, () => { });
2540
+ for (const rule of implicitRules) {
2541
+ fieldCtx.isValid = errors.length === 0;
2542
+ rule.run(value, fieldCtx);
2543
+ }
2544
+ return errors;
2545
+ }
2546
+ /**
2547
+ * Register a TYPE rule from outside the chain — used by the `optional()` and
2548
+ * `null()` factories, which are types in their own right.
2549
+ * @internal
2550
+ */
2551
+ pushTypeRule(rule) {
2552
+ this.#pushRule(rule);
2553
+ }
2554
+ /** Re-type this chain in place, without cloning. @internal */
2555
+ retypeTo() {
2556
+ return this.#retype();
2557
+ }
2558
+ /** Add a value rule and remember it as the `message()` target. */
2559
+ #pushRule(rule) {
2560
+ this.#rules.push(rule);
2561
+ this.#lastRule = { kind: "value", ref: rule };
2562
+ }
2563
+ /** Add a cross-field `.use()` rule and remember it as the `message()` target. */
2564
+ #pushUse(rule) {
2565
+ this.#useRules.push(rule);
2566
+ this.#lastRule = { kind: "reporting", ref: rule };
2567
+ }
2568
+ /** Add an async rule and remember it as the `message()` target. */
2569
+ #pushAsync(rule) {
2570
+ this.#asyncRules.push(rule);
2571
+ this.#lastRule = { kind: "reporting", ref: rule };
2572
+ }
699
2573
  /** Whether the field is required given the surrounding data (conditionals). */
700
2574
  #isRequired(ctx) {
701
2575
  if (this.#requiredConditions.length === 0)
702
2576
  return true;
703
2577
  return this.#requiredConditions.some((cond) => evalRequiredCondition(cond, ctx));
704
2578
  }
705
- /** Internal: validate a field value and return errors + transformed value. */
706
- _validateWithTransform(field, rawValue, ctx = EMPTY_RUN_CONTEXT) {
707
- // 0. Pre-validation parse() transforms run on the raw value first.
2579
+ /**
2580
+ * Internal: validate a field value and return errors + transformed value.
2581
+ *
2582
+ * `pending` is the async-rule collector. The traversal itself stays sync (it
2583
+ * is shared with `validate()`); when a collector is supplied, every chain in
2584
+ * the tree that carries async rules and passed its sync rules records itself
2585
+ * for the async path to await. Without it, nested async rules never ran.
2586
+ */
2587
+ _validateWithTransform(field, rawValue, ctx = EMPTY_RUN_CONTEXT, pending) {
2588
+ // 0. Pre-validation parse() transforms run on the raw value first. VineJS
2589
+ // hands them `(value, { data, parent, meta })` — without the context a
2590
+ // parser cannot look at a sibling, which is half its purpose.
708
2591
  let value = rawValue;
2592
+ const parseCtx = {
2593
+ data: ctx.data,
2594
+ parent: ctx.parent,
2595
+ meta: ctx.meta,
2596
+ };
709
2597
  for (const pre of this.#preTransforms) {
710
- value = pre(value);
2598
+ value = pre(value, parseCtx);
711
2599
  }
712
2600
  if (value === undefined) {
713
2601
  if (this.#isOptional || !this.#isRequired(ctx)) {
714
- return { errors: [], transformed: value };
2602
+ // Implicit rules are precisely the ones that must see an absent value.
2603
+ return {
2604
+ errors: this.#runImplicitRules(field, value, ctx, pending),
2605
+ transformed: value,
2606
+ };
715
2607
  }
716
2608
  return { errors: [this.#requiredError(field, ctx)], transformed: value };
717
2609
  }
718
2610
  if (value === null) {
719
- // rune treats a `null` value as "absent" for optional/nullable/
720
- // non-required fields (a deliberate, cross-engine-conformance-tested
721
- // choice see the Rust↔TS parity suite). `.nullable()` additionally
722
- // keeps it in the output. This unifies null/undefined for optional
723
- // fields, a documented deviation from VineJS's stricter split.
724
- if (this.#isNullable || this.#isOptional || !this.#isRequired(ctx)) {
725
- return { errors: [], transformed: value };
2611
+ // VineJS split, now matched exactly: `nullable()` accepts null AND keeps
2612
+ // it in the output; `optional()` accepts null but DROPS the key. rune
2613
+ // used to keep null in both cases, so an optional field silently added
2614
+ // `key: null` to a payload VineJS would have left without the key.
2615
+ if (this.#isNullable) {
2616
+ return {
2617
+ errors: this.#runImplicitRules(field, value, ctx, pending),
2618
+ transformed: value,
2619
+ };
2620
+ }
2621
+ if (this.#isOptional || !this.#isRequired(ctx)) {
2622
+ return {
2623
+ errors: this.#runImplicitRules(field, value, ctx, pending),
2624
+ transformed: undefined,
2625
+ };
726
2626
  }
727
2627
  return { errors: [this.#requiredError(field, ctx)], transformed: value };
728
2628
  }
2629
+ // 0b. Coerce before the type rules — a coerced value is the validated value.
2630
+ for (const coerce of this.#coercions) {
2631
+ value = coerce(value);
2632
+ }
729
2633
  // 1. Type rules first on the raw value — bail on type mismatch.
730
2634
  const typeError = this.#runTypeRules(field, value, ctx);
731
2635
  if (typeError)
@@ -736,26 +2640,130 @@ export class RuleChain {
736
2640
  // 3. Vine-style .use() rules — run with a FieldContext exposing the root
737
2641
  // `data` and `parent`, so a rule can validate across fields.
738
2642
  if (this.#useRules.length > 0 && !(this.#bail && errors.length > 0)) {
739
- this.#runUseRules(field, transformed, ctx, errors);
2643
+ // `.use()` rules may call `field.mutate()`, so the value can change here.
2644
+ transformed = this.#runUseRules(field, transformed, ctx, errors);
2645
+ }
2646
+ // 3b. Date output mapping (VineJS `VineDate.transform`). Deliberately AFTER
2647
+ // the comparison rules so `after`/`before`/`afterField` always see a
2648
+ // real `Date`, whatever type the consumer maps it to.
2649
+ if (this.#dateFormats !== null &&
2650
+ dateOutputTransform !== null &&
2651
+ transformed instanceof Date) {
2652
+ transformed = dateOutputTransform(transformed);
740
2653
  }
741
2654
  // 4. Nested object validation (only if type check passed — not arrays)
742
2655
  if (this.#nestedSchema && isPlainObject(transformed)) {
743
- const obj = { ...transformed };
2656
+ // Start from the DECLARED keys only. Spreading the input kept every
2657
+ // undeclared key, so the mass-assignment guarantee that holds at the
2658
+ // top level silently stopped holding one level down:
2659
+ // `object({ name })` let an `isAdmin` through. `allowUnknownProperties()`
2660
+ // is the opt-in, as in VineJS.
2661
+ const source = transformed;
2662
+ const obj = this.#allowUnknown
2663
+ ? { ...source }
2664
+ : {};
744
2665
  transformed = obj;
745
- for (const [nestedField, chain] of Object.entries(this.#nestedSchema)) {
746
- const nestedResult = chain._validateWithTransform(`${field}.${nestedField}`, obj[nestedField], { ...ctx, parent: obj });
2666
+ // A conditional group contributes its branch's properties for THIS
2667
+ // payload, so the shape is resolved per validation, not at build time.
2668
+ let shape = this.#nestedSchema;
2669
+ for (const grp of this.#groups) {
2670
+ const branch = grp.branches.find((candidate) => candidate.predicate?.(source) === true) ?? grp.branches.find((candidate) => candidate.predicate === null);
2671
+ if (branch)
2672
+ shape = { ...shape, ...branch.shape };
2673
+ }
2674
+ for (const [nestedField, chain] of Object.entries(shape)) {
2675
+ const nestedResult = chain._validateWithTransform(`${field}.${nestedField}`, source[nestedField], { ...ctx, parent: source }, pending);
747
2676
  errors.push(...nestedResult.errors);
748
2677
  if (nestedResult.transformed !== undefined) {
749
- obj[nestedField] = nestedResult.transformed;
2678
+ obj[this.#camelCaseKeys ? toCamelCaseKey(nestedField) : nestedField] =
2679
+ nestedResult.transformed;
2680
+ }
2681
+ }
2682
+ if (this.#camelCaseKeys && this.#allowUnknown) {
2683
+ // Undeclared keys are camelCased too, otherwise the output would mix
2684
+ // both spellings depending on whether a key was declared.
2685
+ for (const [key, value] of Object.entries(source)) {
2686
+ const camel = toCamelCaseKey(key);
2687
+ if (!(camel in obj))
2688
+ obj[camel] = value;
2689
+ }
2690
+ }
2691
+ }
2692
+ // 4b. Record values — same shape as the nested-object walk, arbitrary keys.
2693
+ if (this.#recordValueChain && isPlainObject(transformed)) {
2694
+ const obj = { ...transformed };
2695
+ transformed = obj;
2696
+ for (const key of Object.keys(obj)) {
2697
+ const res = this.#recordValueChain._validateWithTransform(`${field}.${key}`, obj[key], { ...ctx, parent: obj }, pending);
2698
+ errors.push(...res.errors);
2699
+ if (res.transformed !== undefined)
2700
+ obj[key] = res.transformed;
2701
+ }
2702
+ }
2703
+ // 4c. Tuple positions — length was already enforced by the `tuple` rule.
2704
+ if (this.#tupleChains && Array.isArray(transformed)) {
2705
+ const arr = [...transformed];
2706
+ transformed = arr;
2707
+ this.#tupleChains.forEach((chain, i) => {
2708
+ const res = chain._validateWithTransform(`${field}.${i}`, arr[i], { ...ctx, parent: arr }, pending);
2709
+ for (const e of res.errors)
2710
+ if (e.index === undefined)
2711
+ e.index = i;
2712
+ errors.push(...res.errors);
2713
+ if (res.transformed !== undefined)
2714
+ arr[i] = res.transformed;
2715
+ });
2716
+ }
2717
+ // 4d. Union — first branch that validates wins; its transform is kept.
2718
+ if (this.#unionChains) {
2719
+ let matched = false;
2720
+ // A guarded branch (union.if) is SELECTED by its predicate, and its own
2721
+ // errors are reported — that is the diagnosable half of VineJS's union.
2722
+ const guarded = this.#unionChains.filter((b) => b.predicate !== null);
2723
+ if (guarded.length > 0) {
2724
+ const probe = this.#makeFieldContext(field, transformed, ctx, [], () => { });
2725
+ const chosen = guarded.find((b) => b.predicate?.(transformed, probe)) ??
2726
+ this.#unionChains.find((b) => b.predicate === null);
2727
+ if (chosen) {
2728
+ const res = chosen.chain._validateWithTransform(field, transformed, ctx, pending);
2729
+ transformed = res.transformed;
2730
+ errors.push(...res.errors);
2731
+ matched = true;
2732
+ }
2733
+ }
2734
+ for (const branch of matched ? [] : this.#unionChains) {
2735
+ // Each branch collects into its OWN buffer: a losing branch must not
2736
+ // leave async work queued, and the winning one must not lose it —
2737
+ // without this, a `unique()` inside the matching branch was never
2738
+ // awaited, which reads exactly like a check that passed.
2739
+ const branchPending = [];
2740
+ const res = branch.chain._validateWithTransform(field, transformed, ctx, pending ? branchPending : undefined);
2741
+ if (res.errors.length === 0) {
2742
+ transformed = res.transformed;
2743
+ matched = true;
2744
+ if (pending)
2745
+ pending.push(...branchPending);
2746
+ break;
750
2747
  }
751
2748
  }
2749
+ if (!matched) {
2750
+ errors.push({
2751
+ field,
2752
+ rule: "union",
2753
+ message: resolveRuleMessage(field, {
2754
+ name: "union",
2755
+ validate: () => false,
2756
+ message: "Does not match any allowed shape",
2757
+ }, ctx),
2758
+ });
2759
+ }
752
2760
  }
753
2761
  // 5. Array item validation
754
2762
  if (this.#arrayItemChain && Array.isArray(transformed)) {
755
2763
  const arr = [...transformed];
756
2764
  transformed = arr;
757
2765
  for (let i = 0; i < arr.length; i++) {
758
- const itemResult = this.#arrayItemChain._validateWithTransform(`${field}.${i}`, arr[i], { ...ctx, parent: arr });
2766
+ const itemResult = this.#arrayItemChain._validateWithTransform(`${field}.${i}`, arr[i], { ...ctx, parent: arr }, pending);
759
2767
  for (const e of itemResult.errors) {
760
2768
  if (e.index === undefined)
761
2769
  e.index = i;
@@ -766,25 +2774,66 @@ export class RuleChain {
766
2774
  }
767
2775
  }
768
2776
  }
2777
+ // 6. Record this chain's async rules for the async path to await. Mirrors
2778
+ // Lucid skipping a DB rule on an already-invalid or absent field: only a
2779
+ // clean, present value is worth a round-trip.
2780
+ if (pending &&
2781
+ this.#asyncRules.length > 0 &&
2782
+ errors.length === 0 &&
2783
+ transformed !== undefined &&
2784
+ transformed !== null) {
2785
+ pending.push({ chain: this, field, value: transformed, ctx });
2786
+ }
769
2787
  return { errors, transformed };
770
2788
  }
771
2789
  /** Run `.use()` rules on the transformed value with a fresh FieldContext. */
772
2790
  #runUseRules(field, transformed, ctx, errors) {
773
- const fieldCtx = {
774
- value: transformed,
775
- data: ctx.data,
776
- parent: ctx.parent,
777
- field,
778
- meta: ctx.meta,
779
- isValid: errors.length === 0,
780
- report(message, rule) {
781
- errors.push({ field, rule, message });
782
- },
783
- };
2791
+ // Set per iteration so `report` can substitute the `.message()` override of
2792
+ // the rule currently running — these rules carry their text inside `run`.
2793
+ let override;
2794
+ let current = transformed;
2795
+ const fieldCtx = this.#makeFieldContext(field, transformed, ctx, errors, (next) => {
2796
+ current = next;
2797
+ fieldCtx.value = next;
2798
+ });
2799
+ const report = fieldCtx.report.bind(fieldCtx);
2800
+ fieldCtx.report = (message, rule, reportedField, args) => report(override ?? message, rule, reportedField, args);
784
2801
  for (const rule of this.#useRules) {
2802
+ // A non-implicit rule is skipped on an absent value (VineJS semantics);
2803
+ // `implicit: true` is what lets a custom rule police undefined/null.
2804
+ if (!rule.implicit && (current === undefined || current === null))
2805
+ continue;
2806
+ fieldCtx.isValid = errors.length === 0;
2807
+ fieldCtx.isDefined = current !== undefined && current !== null;
2808
+ override = this.#ruleMessages.get(rule);
2809
+ rule.run(current, fieldCtx);
2810
+ }
2811
+ return current;
2812
+ }
2813
+ /**
2814
+ * Run this chain's async rules on the (already sync-validated) value, awaiting
2815
+ * each in order. Returns the errors they reported. Used by `validateResultAsync`.
2816
+ * @internal
2817
+ */
2818
+ async _runAsyncRules(field, transformed, ctx) {
2819
+ const errors = [];
2820
+ let override;
2821
+ let current = transformed;
2822
+ const fieldCtx = this.#makeFieldContext(field, transformed, ctx, errors, (next) => {
2823
+ current = next;
2824
+ fieldCtx.value = next;
2825
+ });
2826
+ const report = fieldCtx.report.bind(fieldCtx);
2827
+ fieldCtx.report = (message, rule, reportedField, args) => report(override ?? message, rule, reportedField, args);
2828
+ for (const rule of this.#asyncRules) {
2829
+ if (!rule.implicit && (current === undefined || current === null))
2830
+ continue;
785
2831
  fieldCtx.isValid = errors.length === 0;
786
- rule.run(transformed, fieldCtx);
2832
+ fieldCtx.isDefined = current !== undefined && current !== null;
2833
+ override = this.#ruleMessages.get(rule);
2834
+ await rule.run(current, fieldCtx);
787
2835
  }
2836
+ return errors;
788
2837
  }
789
2838
  #requiredError(field, ctx) {
790
2839
  return {
@@ -861,6 +2910,13 @@ const LAST_FIELD = {
861
2910
  field: "",
862
2911
  meta: {},
863
2912
  isValid: true,
2913
+ name: "",
2914
+ wildCardPath: "",
2915
+ isArrayMember: false,
2916
+ isDefined: false,
2917
+ isValidDataType: true,
2918
+ getFieldPath: () => "",
2919
+ mutate: () => { },
864
2920
  report() { },
865
2921
  };
866
2922
  /** Coerce a value to a comparable primitive for `in`/`enum` membership. */
@@ -938,16 +2994,83 @@ function evalRequiredCondition(cond, ctx) {
938
2994
  return false;
939
2995
  }
940
2996
  }
941
- /** No-op alias of {@link schema} — VineJS `vine.compile()` API parity. */
942
- export function compile(s) {
943
- return s;
2997
+ export function compile(input) {
2998
+ // A rune schema is already compiled, so this is identity for that form; the
2999
+ // `RuleChain` form exists because `vine.compile(vine.object({…}))` is the
3000
+ // shape Adonis documents.
3001
+ return input instanceof RuleChain ? schema(toFieldMap(input), input) : input;
944
3002
  }
945
3003
  /** Entry point for building rules. */
946
3004
  export const rules = {
947
3005
  string: () => new RuleChain().string(),
948
- number: () => new RuleChain().number(),
949
- boolean: () => new RuleChain().boolean(),
3006
+ number: (options) => new RuleChain().number(options),
3007
+ boolean: (options) => new RuleChain().boolean(options),
950
3008
  any: () => new RuleChain(),
3009
+ date: (options) => new RuleChain().date(options),
3010
+ accepted: () => new RuleChain().accepted(),
3011
+ file: (options) => new RuleChain().file(options),
3012
+ nativeFile: (options) => new RuleChain().nativeFile(options),
3013
+ record: (valueChain) => new RuleChain().record(valueChain),
3014
+ tuple: (items) => new RuleChain().tuple(items),
3015
+ union: Object.assign((chains) => new RuleChain().union(chains),
3016
+ // `otherwise` is VineJS's spelling of the fallback branch; `else` stays
3017
+ // because it reads better in some call styles.
3018
+ { if: unionIf, else: unionElse, otherwise: unionElse }),
3019
+ /**
3020
+ * Union discriminated by the value's TYPE (VineJS `unionOfTypes`): the first
3021
+ * branch whose own type rule accepts the value wins.
3022
+ */
3023
+ /**
3024
+ * Make every property of a shape optional (VineJS `vine.helpers.optional`).
3025
+ * A properties TRANSFORMER, like `pick`/`omit` — it returns a record to
3026
+ * spread, not a schema.
3027
+ */
3028
+ /**
3029
+ * A field that must be ABSENT (VineJS `vine.optional()` → `VineOptional`,
3030
+ * `builder.d.ts:135`). Mostly a `unionOfTypes` branch. Distinct from
3031
+ * `.optional()` on a chain, which relaxes an existing type — this one IS the
3032
+ * type. The properties transformer that used to squat this name moved to
3033
+ * `helpers.optional`, where VineJS keeps it.
3034
+ */
3035
+ optional: () => {
3036
+ const chain = new RuleChain();
3037
+ chain.pushTypeRule({
3038
+ name: "optionalType",
3039
+ validate: (v) => v === undefined,
3040
+ message: "Must not be provided",
3041
+ });
3042
+ return chain.optional().retypeTo();
3043
+ },
3044
+ /** A field that must be `null` (VineJS `vine.null()` → `VineNull`). */
3045
+ null: () => {
3046
+ const chain = new RuleChain();
3047
+ chain.pushTypeRule({
3048
+ name: "nullType",
3049
+ validate: (v) => v === null,
3050
+ message: "Must be null",
3051
+ });
3052
+ return chain.nullable().retypeTo();
3053
+ },
3054
+ unionOfTypes: (chains) => {
3055
+ // VineJS requires DISTINCT types: two branches claiming the same type make
3056
+ // the discrimination meaningless, and the second would be dead code.
3057
+ const seen = new Set();
3058
+ for (const chain of chains) {
3059
+ const typeRule = chain.rules.find((rule) => TYPE_RULE_NAMES.has(rule.name));
3060
+ const name = typeRule?.name;
3061
+ if (name === undefined) {
3062
+ throw new RuneError("NO_TYPE_RULE", "unionOfTypes() needs every branch to declare a type (string/number/…).", { hint: "Use union([...]) for predicate-based branches." });
3063
+ }
3064
+ if (seen.has(name)) {
3065
+ throw new RuneError("DUPLICATE_UNION_TYPE", `unionOfTypes() got two '${name}' branches — the second can never be reached.`, { hint: "Give each branch a distinct type, or use union([...])." });
3066
+ }
3067
+ seen.add(name);
3068
+ }
3069
+ return new RuleChain().union(chains.map((chain) => {
3070
+ const typeRule = chain.rules.find((rule) => TYPE_RULE_NAMES.has(rule.name));
3071
+ return unionIf((value) => typeRule?.validate(value) === true, chain);
3072
+ }));
3073
+ },
951
3074
  object: (shape) => new RuleChain().object(shape),
952
3075
  array: (item) => new RuleChain().array(item),
953
3076
  enum: (values) => new RuleChain().enum(values),
@@ -967,6 +3090,8 @@ function validateWithRust(fields, data) {
967
3090
  rules: ruleDescs,
968
3091
  optional: chain.isOptionalField,
969
3092
  transforms: chain.transforms.map((t) => t.name),
3093
+ // Sent explicitly so the Rust engine and the TS path agree on bail.
3094
+ bail: chain.bails,
970
3095
  };
971
3096
  }
972
3097
  const request = JSON.stringify({ schema: schemaDesc, data });