@c9up/rune 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/MessagesProvider.d.ts +5 -0
  2. package/dist/MessagesProvider.d.ts.map +1 -1
  3. package/dist/MessagesProvider.js +1 -1
  4. package/dist/MessagesProvider.js.map +1 -1
  5. package/dist/Schema.d.ts +831 -35
  6. package/dist/Schema.d.ts.map +1 -1
  7. package/dist/Schema.js +2342 -140
  8. package/dist/Schema.js.map +1 -1
  9. package/dist/date.d.ts +36 -0
  10. package/dist/date.d.ts.map +1 -0
  11. package/dist/date.js +275 -0
  12. package/dist/date.js.map +1 -0
  13. package/dist/errors.d.ts +10 -0
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +10 -0
  16. package/dist/errors.js.map +1 -1
  17. package/dist/formats.d.ts +148 -0
  18. package/dist/formats.d.ts.map +1 -0
  19. package/dist/formats.js +671 -0
  20. package/dist/formats.js.map +1 -0
  21. package/dist/index.d.ts +150 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +180 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/magic.d.ts +30 -0
  26. package/dist/magic.d.ts.map +1 -0
  27. package/dist/magic.js +154 -0
  28. package/dist/magic.js.map +1 -0
  29. package/dist/native.d.ts +18 -6
  30. package/dist/native.d.ts.map +1 -1
  31. package/dist/native.js +34 -19
  32. package/dist/native.js.map +1 -1
  33. package/dist/types.d.ts +15 -0
  34. package/dist/types.d.ts.map +1 -0
  35. package/dist/types.js +12 -0
  36. package/dist/types.js.map +1 -0
  37. package/index.darwin-arm64.node +0 -0
  38. package/index.darwin-x64.node +0 -0
  39. package/index.linux-arm64-gnu.node +0 -0
  40. package/index.linux-x64-gnu.node +0 -0
  41. package/index.win32-x64-msvc.node +0 -0
  42. package/package.json +9 -1
  43. package/src/MessagesProvider.ts +1 -1
  44. package/src/Schema.ts +3389 -177
  45. package/src/date.ts +320 -0
  46. package/src/errors.ts +11 -0
  47. package/src/formats.ts +776 -0
  48. package/src/index.ts +269 -0
  49. package/src/magic.ts +181 -0
  50. package/src/native.ts +36 -21
  51. package/src/types.ts +55 -0
package/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";
8
- import { isNativeAvailable, validateNative, warnNativeUnavailableOnce, } from "./native.js";
9
- export function createRule(validator) {
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";
12
+ import { assertNativeAvailable, isNativeAvailable, validateNative, } from "./native.js";
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,15 +713,30 @@ 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
- // This schema would have used the native engine, but it isn't loaded —
177
- // surface the platform-dependent TS fallback once instead of diverging
178
- // silently.
179
- warnNativeUnavailableOnce();
736
+ // This schema carries nothing the engine cannot run, so the engine is
737
+ // what must run it. Falling back to the TypeScript validator here made
738
+ // the verdict depend on whether a binary loaded.
739
+ assertNativeAvailable();
180
740
  }
181
741
  const errors = [];
182
742
  const validated = {};
@@ -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));
864
+ }
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);
211
893
  }
212
- return { fields, validate, validateOrThrow };
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,60 @@ 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
+ #enumChoices = null;
1024
+ #recordKeysCheck = null;
1025
+ #tupleChains = null;
1026
+ #unionChains = null;
1027
+ #unionNoMatch = null;
245
1028
  #useRules = [];
1029
+ #asyncRules = [];
1030
+ /** Last rule added, whichever register it landed in — the `message()` target. */
1031
+ #lastRule = null;
1032
+ /** `.message()` overrides for rules that report their own text from `run`. */
1033
+ #ruleMessages = new Map();
246
1034
  #requiredConditions = [];
247
1035
  /** Public read access to rules (for OpenAPI generation, Rust bridge). */
248
1036
  get rules() {
@@ -262,6 +1050,63 @@ export class RuleChain {
262
1050
  get useRules() {
263
1051
  return this.#useRules;
264
1052
  }
1053
+ /** Public read access to async rules (`unique`/`exists`/`useAsync`) — run by `validateResultAsync`. */
1054
+ get asyncRules() {
1055
+ return this.#asyncRules;
1056
+ }
1057
+ /**
1058
+ * Does this chain — or anything nested under it (object fields, array items) —
1059
+ * carry async rules? The schema-level detection used to inspect only the
1060
+ * top-level chains, so a nested `unique`/`exists` was invisible: `validate()`
1061
+ * did not throw and the async pass never ran the rule, silently accepting
1062
+ * an unchecked value.
1063
+ */
1064
+ get hasAsyncRulesDeep() {
1065
+ if (this.#asyncRules.length > 0)
1066
+ return true;
1067
+ if (this.#nestedSchema) {
1068
+ for (const chain of Object.values(this.#nestedSchema)) {
1069
+ if (chain.hasAsyncRulesDeep)
1070
+ return true;
1071
+ }
1072
+ }
1073
+ if (this.#arrayItemChain?.hasAsyncRulesDeep)
1074
+ return true;
1075
+ if (this.#recordValueChain?.hasAsyncRulesDeep)
1076
+ return true;
1077
+ for (const chain of [
1078
+ ...(this.#tupleChains ?? []),
1079
+ ...(this.#unionChains ?? []).map((b) => b.chain),
1080
+ ]) {
1081
+ if (chain.hasAsyncRulesDeep)
1082
+ return true;
1083
+ }
1084
+ return false;
1085
+ }
1086
+ /** Does this object keep keys its shape does not declare? */
1087
+ get allowsUnknown() {
1088
+ return this.#allowUnknown;
1089
+ }
1090
+ /** Free-form JSON Schema metadata attached with `meta()`. */
1091
+ get metadata() {
1092
+ return this.#metadata;
1093
+ }
1094
+ /** The item chain of an `array()`, if declared. */
1095
+ get arrayItem() {
1096
+ return this.#arrayItemChain;
1097
+ }
1098
+ /** The positional chains of a `tuple()`, if declared. */
1099
+ get tupleItems() {
1100
+ return this.#tupleChains;
1101
+ }
1102
+ /** The value chain of a `record()`, if declared. */
1103
+ get recordValue() {
1104
+ return this.#recordValueChain;
1105
+ }
1106
+ /** Whether this chain stops at its first failing rule (VineJS `bail`). */
1107
+ get bails() {
1108
+ return this.#bail;
1109
+ }
265
1110
  /** Public read access to `.parse()` pre-transforms (kept off the native path). */
266
1111
  get preTransforms() {
267
1112
  return this.#preTransforms;
@@ -283,10 +1128,29 @@ export class RuleChain {
283
1128
  next.#isNullable = this.#isNullable;
284
1129
  next.#bail = this.#bail;
285
1130
  next.#transforms = [...this.#transforms];
1131
+ next.#dateFormats = this.#dateFormats;
1132
+ next.#coercions = [...this.#coercions];
1133
+ next.#allowUnknown = this.#allowUnknown;
1134
+ next.#metadata = this.#metadata ? { ...this.#metadata } : null;
1135
+ next.#declaredExtnames = this.#declaredExtnames;
1136
+ next.#declaredMimeTypes = this.#declaredMimeTypes;
1137
+ next.#contentVerified = this.#contentVerified;
1138
+ next.#contentVerificationOff = this.#contentVerificationOff;
1139
+ next.#camelCaseKeys = this.#camelCaseKeys;
1140
+ next.#groups = [...this.#groups];
1141
+ next.#recordValueChain = this.#recordValueChain;
1142
+ next.#enumChoices = this.#enumChoices;
1143
+ next.#recordKeysCheck = this.#recordKeysCheck;
1144
+ next.#tupleChains = this.#tupleChains;
1145
+ next.#unionChains = this.#unionChains;
1146
+ next.#unionNoMatch = this.#unionNoMatch;
1147
+ next.#ruleMessages = new Map(this.#ruleMessages);
1148
+ next.#lastRule = this.#lastRule;
286
1149
  next.#preTransforms = [...this.#preTransforms];
287
1150
  next.#nestedSchema = this.#nestedSchema;
288
1151
  next.#arrayItemChain = this.#arrayItemChain;
289
1152
  next.#useRules = [...this.#useRules];
1153
+ next.#asyncRules = [...this.#asyncRules];
290
1154
  next.#requiredConditions = [...this.#requiredConditions];
291
1155
  return next;
292
1156
  }
@@ -313,7 +1177,7 @@ export class RuleChain {
313
1177
  }
314
1178
  /** Must be an object matching a nested schema. */
315
1179
  object(shape) {
316
- this.#rules.push({
1180
+ this.#pushRule({
317
1181
  name: "object",
318
1182
  validate: (v) => isPlainObject(v),
319
1183
  message: "Must be an object",
@@ -323,7 +1187,7 @@ export class RuleChain {
323
1187
  }
324
1188
  /** Must be an array. Items validated by the provided chain. */
325
1189
  array(itemChain) {
326
- this.#rules.push({
1190
+ this.#pushRule({
327
1191
  name: "array",
328
1192
  validate: (v) => Array.isArray(v),
329
1193
  message: "Must be an array",
@@ -333,47 +1197,591 @@ export class RuleChain {
333
1197
  }
334
1198
  /** Must be a string. */
335
1199
  string() {
336
- this.#rules.push({
1200
+ this.#pushRule({
337
1201
  name: "string",
338
1202
  validate: (v) => typeof v === "string",
339
1203
  message: "Must be a string",
340
1204
  });
341
1205
  return this.#retype();
342
1206
  }
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",
1207
+ /**
1208
+ * Must be a number. Like VineJS, a numeric STRING is coerced (`"32"` → `32`)
1209
+ * — HTML form bodies and query strings carry numbers as text, so requiring
1210
+ * `typeof v === "number"` rejected the values Adonis accepts. Pass
1211
+ * `{ strict: true }` to refuse anything that is not already a number.
1212
+ */
1213
+ number(options) {
1214
+ if (!options?.strict)
1215
+ this.#coercions.push(coerceNumber);
1216
+ this.#pushRule({
1217
+ name: "number",
1218
+ args: { strict: options?.strict === true },
1219
+ validate: (v) => typeof v === "number" && !Number.isNaN(v) && Number.isFinite(v),
1220
+ message: "Must be a number",
1221
+ });
1222
+ return this.#retype();
1223
+ }
1224
+ /**
1225
+ * Must be a boolean. Like VineJS, `"true"`, `"false"`, `"on"`, `"off"`,
1226
+ * `"1"`, `"0"`, `1` and `0` are coerced; `{ strict: true }` refuses them.
1227
+ */
1228
+ boolean(options) {
1229
+ if (!options?.strict)
1230
+ this.#coercions.push(coerceBoolean);
1231
+ this.#pushRule({
1232
+ name: "boolean",
1233
+ args: { strict: options?.strict === true },
1234
+ validate: (v) => typeof v === "boolean",
1235
+ message: "Must be a boolean",
1236
+ });
1237
+ return this.#retype();
1238
+ }
1239
+ /**
1240
+ * Must be a date (VineJS `vine.date()`). ISO 8601 by default; pass `formats`
1241
+ * for unix timestamps (`x` = ms, `X` = seconds) or a token format such as
1242
+ * `DD/MM/YYYY`. Parsing is calendar-strict — `2026-02-31` is rejected.
1243
+ *
1244
+ * The validated output is a `Date`; bind {@link setDateTransform} to map it
1245
+ * to your own type once at boot.
1246
+ */
1247
+ date(options) {
1248
+ const formats = options?.formats ?? ["iso8601"];
1249
+ this.#dateFormats = formats;
1250
+ this.#pushRule({
1251
+ name: "date",
1252
+ args: { formats },
1253
+ validate: (v) => parseDateValue(v, formats) !== null,
1254
+ message: "Must be a valid date",
1255
+ });
1256
+ // Parse to a real `Date` BEFORE the comparison rules run, so `after`/
1257
+ // `before` never re-parse and never compare strings lexicographically.
1258
+ this.#transforms.push({
1259
+ name: "date",
1260
+ fn: (value) => parseDateValue(value, formats) ?? value,
1261
+ });
1262
+ return this.#retype();
1263
+ }
1264
+ /** Must be strictly after `operand` (`'today'`, an ISO string, or a `Date`). */
1265
+ after(operand, options) {
1266
+ return this.#compareDate("after", operand, (a, b) => a > b, options);
1267
+ }
1268
+ /** Must be strictly before `operand`. */
1269
+ before(operand, options) {
1270
+ return this.#compareDate("before", operand, (a, b) => a < b, options);
1271
+ }
1272
+ /** Must be after `operand`, or equal to it. */
1273
+ afterOrEqual(operand, options) {
1274
+ return this.#compareDate("afterOrEqual", operand, (a, b) => a >= b, options);
1275
+ }
1276
+ /** Must be before `operand`, or equal to it. */
1277
+ beforeOrEqual(operand, options) {
1278
+ return this.#compareDate("beforeOrEqual", operand, (a, b) => a <= b, options);
1279
+ }
1280
+ /** Must be after the date held by a sibling field (VineJS `afterField`). */
1281
+ afterField(otherField, options) {
1282
+ return this.#compareDateField("afterField", otherField, options, (a, b) => a > b);
1283
+ }
1284
+ /** Must be before the date held by a sibling field. */
1285
+ beforeField(otherField, options) {
1286
+ return this.#compareDateField("beforeField", otherField, options, (a, b) => a < b);
1287
+ }
1288
+ /** Must be the same instant as `operand` (VineJS `equals`). */
1289
+ equals(operand, options) {
1290
+ return this.#compareDate("equals", operand, (a, b) => a === b, options);
1291
+ }
1292
+ /** Must be after the sibling's date, or the same instant (VineJS `afterOrSameAs`). */
1293
+ afterOrSameAs(otherField, options) {
1294
+ return this.#compareDateField("afterOrSameAs", otherField, options, (a, b) => a >= b);
1295
+ }
1296
+ /** Must be before the sibling's date, or the same instant. */
1297
+ beforeOrSameAs(otherField, options) {
1298
+ return this.#compareDateField("beforeOrSameAs", otherField, options, (a, b) => a <= b);
1299
+ }
1300
+ /** Must fall on a Saturday or Sunday (VineJS `weekend`). */
1301
+ weekend() {
1302
+ this.#pushRule({
1303
+ name: "weekend",
1304
+ validate: (v) => v instanceof Date && (v.getDay() === 0 || v.getDay() === 6),
1305
+ message: "Must be a weekend date",
1306
+ });
1307
+ return this;
1308
+ }
1309
+ /** Must fall on a Monday-to-Friday day (VineJS `weekday`). */
1310
+ weekday() {
1311
+ this.#pushRule({
1312
+ name: "weekday",
1313
+ validate: (v) => v instanceof Date && v.getDay() > 0 && v.getDay() < 6,
1314
+ message: "Must be a weekday date",
1315
+ });
1316
+ return this;
1317
+ }
1318
+ /** Shared body of the `after`/`before`/`*OrEqual` literal comparisons. */
1319
+ #compareDate(name, operand, cmp, options) {
1320
+ // VineJS: `options.compare || "day"`. A bare `after('today')` is about the
1321
+ // calendar date, not the clock — comparing exact timestamps made every
1322
+ // same-day value fail a rule the caller read as "today or later".
1323
+ const unit = options?.compare ?? "day";
1324
+ const formats = options?.format ? [options.format] : null;
1325
+ this.#pushRule({
1326
+ name,
1327
+ // A callable operand is resolved per validation, not once at build
1328
+ // time — otherwise `after(() => Date.now())` would freeze the boundary
1329
+ // at the moment the schema was declared (VineJS allows the callback).
1330
+ args: typeof operand === "function" ? undefined : { operand },
1331
+ validate: (v) => {
1332
+ const raw = typeof operand === "function"
1333
+ ? operand()
1334
+ : operand;
1335
+ const other = formats && typeof raw === "string"
1336
+ ? parseDateValue(raw, formats)
1337
+ : resolveOperand(raw);
1338
+ if (!(v instanceof Date) || other === null)
1339
+ return false;
1340
+ return cmp(truncateTo(v, unit), truncateTo(other, unit));
1341
+ },
1342
+ message: `Must be ${name.replace(/([A-Z])/g, " $1").toLowerCase()} ${String(operand)}`,
1343
+ });
1344
+ return this;
1345
+ }
1346
+ /** Shared body of the `afterField`/`beforeField` sibling comparisons. */
1347
+ #compareDateField(name, otherField, options, cmp) {
1348
+ const formats = options?.format
1349
+ ? [options.format]
1350
+ : (this.#dateFormats ?? ["iso8601"]);
1351
+ const unit = options?.compare ?? "day";
1352
+ this.#pushUse({
1353
+ __rune: "rule",
1354
+ run: (value, field) => {
1355
+ const other = parseDateValue(readSibling(field, otherField), formats);
1356
+ if (!(value instanceof Date) || other === null) {
1357
+ field.report(`Cannot compare with ${otherField}`, name);
1358
+ return;
1359
+ }
1360
+ if (!cmp(truncateTo(value, unit), truncateTo(other, unit))) {
1361
+ field.report(`Must be ${name.replace("Field", "")} ${otherField}`, name);
1362
+ }
1363
+ },
1364
+ });
1365
+ return this;
1366
+ }
1367
+ /**
1368
+ * Keep keys the object shape does not declare (VineJS
1369
+ * `allowUnknownProperties`). Off by default: dropping undeclared keys is what
1370
+ * makes a validated payload safe to hand to a mass assignment.
1371
+ */
1372
+ allowUnknownProperties() {
1373
+ this.#allowUnknown = true;
1374
+ return this;
1375
+ }
1376
+ /**
1377
+ * Convert the object's KEYS to camelCase in the output (VineJS
1378
+ * `object.toCamelCase()`), so a snake_case payload hydrates camelCase
1379
+ * properties. Distinct from the string `toCamelCase()`, which rewrites a
1380
+ * VALUE — that one was never a substitute for this.
1381
+ */
1382
+ toCamelCaseKeys() {
1383
+ return this.toCamelCase();
1384
+ }
1385
+ /**
1386
+ * Merge extra properties into this object's shape (VineJS `merge`). Accepts a
1387
+ * plain shape or a {@link ConditionalGroup} whose branch is chosen per
1388
+ * payload — `vine.group` in VineJS.
1389
+ */
1390
+ merge(extra) {
1391
+ if (!this.#nestedSchema) {
1392
+ throw new RuneError("NOT_AN_OBJECT", "merge() needs an object() shape to merge into.", { hint: "rules.any().object({ … }).merge({ … })" });
1393
+ }
1394
+ if (isConditionalGroup(extra)) {
1395
+ this.#groups.push(extra);
1396
+ return this;
1397
+ }
1398
+ this.#nestedSchema = { ...this.#nestedSchema, ...extra };
1399
+ return this;
1400
+ }
1401
+ /** The nested shape declared by `object()`, if any (VineJS `getProperties`). */
1402
+ getProperties() {
1403
+ // CLONE each chain, not just the map. A shallow copy shares the chain
1404
+ // instances, so mutating one through the copy relaxes the source schema —
1405
+ // the same trap that made `partial()` mutate its origin.
1406
+ if (!this.#nestedSchema)
1407
+ return null;
1408
+ return Object.fromEntries(Object.entries(this.#nestedSchema).map(([key, chain]) => [
1409
+ key,
1410
+ chain.clone(),
1411
+ ]));
1412
+ }
1413
+ /** Independent copy of this chain (VineJS `clone`). */
1414
+ clone() {
1415
+ return this.#retype();
1416
+ }
1417
+ /**
1418
+ * A CLONED subset of the object's properties (VineJS `pick`).
1419
+ *
1420
+ * Returns a properties record, not a schema — VineJS types it
1421
+ * `Pick<Properties, Keys>` precisely so it composes by spread:
1422
+ * `rules.any().object({ ...userShape.pick(["id"]) })`. Returning a chain here
1423
+ * broke that idiom.
1424
+ */
1425
+ pick(keys) {
1426
+ return this.#subsetOfProperties((key) => keys.includes(key));
1427
+ }
1428
+ /** A cloned copy of the properties EXCLUDING `keys` (VineJS `omit`). */
1429
+ omit(keys) {
1430
+ return this.#subsetOfProperties((key) => !keys.includes(key));
1431
+ }
1432
+ /** Shared body of `pick`/`omit` — clones so the source stays untouched. */
1433
+ #subsetOfProperties(keep) {
1434
+ const shape = this.getProperties();
1435
+ if (!shape) {
1436
+ throw new RuneError("NOT_AN_OBJECT", "pick()/omit() need an object() shape to work on.", { hint: "rules.any().object({ … }).pick([…])" });
1437
+ }
1438
+ return Object.fromEntries(Object.entries(shape).filter(([key]) => keep(key)));
1439
+ }
1440
+ /** Make every property of an object shape optional (VineJS `partial`). */
1441
+ partial(keys) {
1442
+ // `optional()` mutates and returns the SAME chain, so calling it on the
1443
+ // stored properties made the source shape optional too — `base.partial()`
1444
+ // silently relaxed `base`. Clone each property first, like VineJS does.
1445
+ return this.#reshape((shape) => Object.fromEntries(Object.entries(shape).map(([key, chain]) => [
1446
+ key,
1447
+ keys === undefined || keys.includes(key)
1448
+ ? chain.clone().optional()
1449
+ : chain,
1450
+ ])));
1451
+ }
1452
+ /** Shared body of `pick`/`omit`/`partial` — rebuilds the nested shape on a clone. */
1453
+ #reshape(transform) {
1454
+ if (!this.#nestedSchema) {
1455
+ throw new RuneError("NOT_AN_OBJECT", "pick()/omit()/partial() need an object() shape to work on.", {
1456
+ hint: "Declare the shape first: rules.any().object({ … }).pick([…])",
1457
+ });
1458
+ }
1459
+ const next = this.#retype();
1460
+ next.#nestedSchema = transform(this.#nestedSchema);
1461
+ return next;
1462
+ }
1463
+ /**
1464
+ * Must be an "accepted" value — `true`, `1`, `"1"`, `"on"`, `"yes"`,
1465
+ * `"true"` (VineJS `accepted`, for checkbox-style consent fields).
1466
+ */
1467
+ accepted() {
1468
+ this.#pushRule({
1469
+ name: "accepted",
1470
+ validate: isAcceptedValue,
1471
+ message: "Must be accepted",
1472
+ });
1473
+ // Normalise ONLY an accepted value: a blanket `() => true` would rewrite a
1474
+ // refused value into an accepted one before the rule ever saw it.
1475
+ this.#transforms.push({
1476
+ name: "accepted",
1477
+ fn: (value) => (isAcceptedValue(value) ? true : value),
1478
+ });
1479
+ return this.#retype();
1480
+ }
1481
+ /**
1482
+ * Object with arbitrary keys, every value validated by `valueChain`
1483
+ * (VineJS `record`).
1484
+ */
1485
+ record(valueChain) {
1486
+ this.#pushRule({
1487
+ name: "record",
1488
+ validate: (v) => isPlainObject(v),
1489
+ message: "Must be an object",
1490
+ });
1491
+ this.#recordValueChain = valueChain;
1492
+ return this.#retype();
1493
+ }
1494
+ /**
1495
+ * Check the record's KEYS, not its values (VineJS `record().validateKeys()`).
1496
+ *
1497
+ * The callback receives every key at once and reports through the field
1498
+ * context — the set is what matters when keys must be exclusive, exhaustive,
1499
+ * or drawn from a list only known at runtime.
1500
+ */
1501
+ validateKeys(callback) {
1502
+ this.#recordKeysCheck = callback;
1503
+ return this;
1504
+ }
1505
+ /**
1506
+ * Fixed-length array with a schema per position (VineJS `tuple`). Extra
1507
+ * items are rejected — a tuple that silently ignores a trailing element is
1508
+ * how unvalidated data slips through.
1509
+ */
1510
+ tuple(items) {
1511
+ this.#pushRule({
1512
+ name: "tuple",
1513
+ args: { length: items.length },
1514
+ validate: (v) => Array.isArray(v) && v.length === items.length,
1515
+ message: `Must be an array of exactly ${items.length} items`,
1516
+ });
1517
+ this.#tupleChains = [...items];
1518
+ return this.#retype();
1519
+ }
1520
+ /**
1521
+ * Value must satisfy at least one of `chains`.
1522
+ *
1523
+ * Two forms, both supported:
1524
+ *
1525
+ * - guarded (VineJS parity): `union([rules.union.if(pred, chain), …,
1526
+ * rules.union.else(fallback)])` — the predicate SELECTS the branch and
1527
+ * that branch's own errors are reported, so a failure says which shape was
1528
+ * meant and why it did not fit.
1529
+ * - bare chains: tried in order, first match wins, and a total miss reports a
1530
+ * single `union` error rather than every losing branch's noise.
1531
+ */
1532
+ /**
1533
+ * What to do when NO union branch matched (VineJS `union().otherwise()`).
1534
+ *
1535
+ * The callback receives the value and the field, and reports the error it
1536
+ * wants — the point being that "matches nothing" is a useless message when
1537
+ * the caller knows which shapes were on offer. Reporting nothing from the
1538
+ * callback suppresses the generic error entirely, which is how a union
1539
+ * folded into a larger check stays quiet.
1540
+ */
1541
+ otherwise(callback) {
1542
+ this.#unionNoMatch = callback;
1543
+ return this;
1544
+ }
1545
+ union(chains) {
1546
+ this.#unionChains = chains.map(toUnionBranch);
1547
+ // Marker rule: its name is not in NATIVE_RULES, which is what keeps a
1548
+ // union off the native path. The Rust engine knows nothing about branches
1549
+ // and would silently accept anything.
1550
+ this.#pushRule({
1551
+ name: "union",
1552
+ validate: () => true,
1553
+ message: "Does not match any allowed shape",
1554
+ });
1555
+ return this;
1556
+ }
1557
+ /**
1558
+ * Must be an uploaded file (VineJS/Adonis `vine.file()`).
1559
+ *
1560
+ * Named deviation: Adonis validates a bodyparser `MultipartFile`, which rune
1561
+ * cannot import and stay agnostic. It checks the STRUCTURE instead — any
1562
+ * object exposing `size` and a name/extension — so an Adonis MultipartFile
1563
+ * satisfies it, and so does any other upload representation.
1564
+ *
1565
+ * `size` is a byte count; `extnames` are compared lowercase, without the dot.
1566
+ */
1567
+ file(options) {
1568
+ // Adonis documents `size: '2mb'`; a numeric-only option meant a
1569
+ // transcribed validator either failed the typecheck or, in JS, silently
1570
+ // stopped capping.
1571
+ const maxBytes = options?.size === undefined ? undefined : parseByteSize(options.size);
1572
+ if (options?.extnames)
1573
+ this.#declaredExtnames = options.extnames;
1574
+ if (options?.verifyContent === false)
1575
+ this.#contentVerificationOff = true;
1576
+ this.#pushRule({
1577
+ name: "file",
1578
+ args: options ? { ...options } : undefined,
1579
+ validate: (v) => {
1580
+ if (!isFileLike(v))
1581
+ return false;
1582
+ if (maxBytes !== undefined && v.size > maxBytes)
1583
+ return false;
1584
+ if (options?.extnames) {
1585
+ const ext = fileExtension(v);
1586
+ if (ext === null)
1587
+ return false;
1588
+ if (!options.extnames.map((e) => e.toLowerCase()).includes(ext)) {
1589
+ return false;
1590
+ }
1591
+ }
1592
+ return true;
1593
+ },
1594
+ message: "Must be a valid file",
1595
+ });
1596
+ // Declaring an allowed extension list is a SECURITY statement, so the
1597
+ // bytes are checked by default. `{ verifyContent: false }` opts out
1598
+ // explicitly and leaves a trace in the schema.
1599
+ if (options?.extnames)
1600
+ this.#ensureContentVerification();
1601
+ return this.#retype();
1602
+ }
1603
+ /**
1604
+ * Uploaded file with VineJS `nativeFile` options — `minSize`, `maxSize`,
1605
+ * `mimeTypes`. Same structural contract as {@link file}: rune never reads
1606
+ * bytes, so the MIME type is the one the upload REPORTS.
1607
+ */
1608
+ nativeFile(options) {
1609
+ const min = options?.minSize === undefined
1610
+ ? undefined
1611
+ : parseByteSize(options.minSize);
1612
+ const max = options?.maxSize === undefined
1613
+ ? undefined
1614
+ : parseByteSize(options.maxSize);
1615
+ this.#pushRule({
1616
+ name: "nativeFile",
1617
+ args: options ? { ...options } : undefined,
1618
+ validate: (v) => {
1619
+ if (!isFileLike(v))
1620
+ return false;
1621
+ if (min !== undefined && v.size < min)
1622
+ return false;
1623
+ if (max !== undefined && v.size > max)
1624
+ return false;
1625
+ if (options?.mimeTypes) {
1626
+ const type = typeof v.type === "string" ? v.type.toLowerCase() : null;
1627
+ if (type === null)
1628
+ return false;
1629
+ if (!options.mimeTypes.map((m) => m.toLowerCase()).includes(type)) {
1630
+ return false;
1631
+ }
1632
+ }
1633
+ return true;
1634
+ },
1635
+ message: "Must be a valid file",
1636
+ });
1637
+ // Declaring allowed MIME types is a SECURITY statement, so the bytes are
1638
+ // checked by default.
1639
+ if (options?.mimeTypes)
1640
+ this.#ensureContentVerification();
1641
+ return this.#retype();
1642
+ }
1643
+ /** Minimum upload size (VineJS `nativeFile().minSize()`). */
1644
+ minSize(size) {
1645
+ const min = parseByteSize(size);
1646
+ this.#pushRule({
1647
+ name: "minSize",
1648
+ args: { size },
1649
+ validate: (v) => isFileLike(v) && v.size >= min,
1650
+ message: `Must be at least ${size} in size`,
349
1651
  });
350
- return this.#retype();
1652
+ return this;
351
1653
  }
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",
1654
+ /** Maximum upload size (VineJS `nativeFile().maxSize()`). */
1655
+ maxSize(size) {
1656
+ const max = parseByteSize(size);
1657
+ this.#pushRule({
1658
+ name: "maxSize",
1659
+ args: { size },
1660
+ validate: (v) => isFileLike(v) && v.size <= max,
1661
+ message: `Must be at most ${size} in size`,
358
1662
  });
359
- return this.#retype();
1663
+ return this;
1664
+ }
1665
+ /**
1666
+ * Allowed MIME types (VineJS `nativeFile().mimeTypes()`). The type is the one
1667
+ * the upload REPORTS — rune never reads bytes, see {@link file}.
1668
+ */
1669
+ mimeTypes(types) {
1670
+ const allowed = types.map((t) => t.toLowerCase());
1671
+ this.#declaredMimeTypes = allowed;
1672
+ this.#ensureContentVerification();
1673
+ this.#pushRule({
1674
+ name: "mimeTypes",
1675
+ args: { types: allowed },
1676
+ validate: (v) => isFileLike(v) &&
1677
+ typeof v.type === "string" &&
1678
+ allowed.includes(v.type.toLowerCase()),
1679
+ message: `Must be one of ${allowed.join(", ")}`,
1680
+ });
1681
+ return this;
1682
+ }
1683
+ /**
1684
+ * Verify the file's REAL type against its magic number (Adonis parity).
1685
+ *
1686
+ * A `.exe` renamed `.jpg` passes every declarative check — size, extension,
1687
+ * reported MIME — because all three come from the uploader. This reads the
1688
+ * leading bytes and refuses a mismatch.
1689
+ *
1690
+ * Async by nature (it touches the filesystem), so the schema must run with
1691
+ * `validateResultAsync` / `validate`. Needs a byte source on the file object
1692
+ * (`buffer`, `tmpPath`, `filePath` or `path`) — an Adonis `MultipartFile`
1693
+ * carries `tmpPath`. With NO source it FAILS: a content check that cannot
1694
+ * run must never look like one that passed.
1695
+ */
1696
+ verifyContent() {
1697
+ this.#contentVerificationOff = false;
1698
+ return this.#ensureContentVerification();
1699
+ }
1700
+ /** Register the content check once, honouring an explicit opt-out. */
1701
+ #ensureContentVerification() {
1702
+ if (this.#contentVerified || this.#contentVerificationOff)
1703
+ return this;
1704
+ this.#contentVerified = true;
1705
+ return this.#registerContentVerification();
1706
+ }
1707
+ /** The async rule itself — reads the bytes and confronts the declaration. */
1708
+ #registerContentVerification() {
1709
+ const extnames = this.#declaredExtnames;
1710
+ const mimeTypes = this.#declaredMimeTypes;
1711
+ this.#pushAsync({
1712
+ __rune: "asyncRule",
1713
+ async run(value, field) {
1714
+ if (!isFileLike(value)) {
1715
+ field.report("Must be a valid file", "verifyContent");
1716
+ return;
1717
+ }
1718
+ const head = await readFileHead(value);
1719
+ if (head === null) {
1720
+ field.report("Cannot read the file's content to verify its type", "verifyContent");
1721
+ return;
1722
+ }
1723
+ const detected = detectFileType(head);
1724
+ if (detected === null) {
1725
+ field.report("File type could not be recognised", "verifyContent");
1726
+ return;
1727
+ }
1728
+ // The declared extension must agree with the bytes.
1729
+ const declaredExt = typeof value.extname === "string" && value.extname.length > 0
1730
+ ? value.extname
1731
+ : null;
1732
+ if (declaredExt && !extensionMatches(detected.ext, declaredExt)) {
1733
+ field.report(`Content is ${detected.ext}, not ${declaredExt.replace(/^\./, "")}`, "verifyContent");
1734
+ return;
1735
+ }
1736
+ if (extnames &&
1737
+ !extnames.some((allowed) => extensionMatches(detected.ext, allowed))) {
1738
+ field.report(`Content is ${detected.ext}, which is not allowed`, "verifyContent");
1739
+ return;
1740
+ }
1741
+ if (mimeTypes && !mimeTypes.includes(detected.mime)) {
1742
+ field.report(`Content is ${detected.mime}, which is not allowed`, "verifyContent");
1743
+ }
1744
+ },
1745
+ });
1746
+ return this;
360
1747
  }
361
- /** Must equal one of `values` (enum). Narrows the output to the union. */
1748
+ /**
1749
+ * Must equal one of `values` (enum). Narrows the output to the union.
1750
+ *
1751
+ * `values` may be a callback receiving the field, which is how a list that
1752
+ * depends on the request — the roles this tenant allows, the statuses this
1753
+ * user may set — is computed per validation instead of frozen at import.
1754
+ */
362
1755
  enum(values) {
363
- const allowed = [...values];
364
- this.#rules.push({
1756
+ const lazy = typeof values === "function";
1757
+ const resolve = allowedValuesResolver(values);
1758
+ this.#enumChoices = lazy ? values : [...values];
1759
+ this.#pushRule({
365
1760
  name: "enum",
366
- args: { values: allowed },
367
- validate: (v) => allowed.includes(asPrimitive(v)),
1761
+ args: lazy ? {} : { values: [...values] },
1762
+ // A computed list is per-call; the native engine only ever sees a
1763
+ // static array, so it must not run this rule.
1764
+ tsOnly: lazy,
1765
+ validate: (v, field) => resolve(field).includes(asPrimitive(v)),
368
1766
  message: "Invalid value",
369
1767
  });
370
1768
  return this.#retype();
371
1769
  }
1770
+ /**
1771
+ * The choices this enum was declared with (VineJS `getChoices()`) — the list
1772
+ * itself, or the callback when it is computed per request.
1773
+ *
1774
+ * Reading them back is what lets a form render the same options the
1775
+ * validator will accept, from one declaration instead of two.
1776
+ */
1777
+ getChoices() {
1778
+ return this.#enumChoices ?? undefined;
1779
+ }
372
1780
  /** Must equal a literal value. */
373
1781
  literal(value) {
374
- this.#rules.push({
1782
+ this.#pushRule({
375
1783
  name: "literal",
376
- args: { expectedValue: value },
1784
+ args: { value, expectedValue: value },
377
1785
  validate: (v) => v === value,
378
1786
  message: `Must be ${String(value)}`,
379
1787
  });
@@ -381,7 +1789,7 @@ export class RuleChain {
381
1789
  }
382
1790
  /** Minimum length (string) or minimum value (number). Alias of min/minLength. */
383
1791
  min(n) {
384
- this.#rules.push({
1792
+ this.#pushRule({
385
1793
  name: "min",
386
1794
  param: n,
387
1795
  args: { min: n },
@@ -396,7 +1804,7 @@ export class RuleChain {
396
1804
  }
397
1805
  /** Maximum length (string) or maximum value (number). Alias of max/maxLength. */
398
1806
  max(n) {
399
- this.#rules.push({
1807
+ this.#pushRule({
400
1808
  name: "max",
401
1809
  param: n,
402
1810
  args: { max: n },
@@ -411,7 +1819,7 @@ export class RuleChain {
411
1819
  }
412
1820
  /** Minimum length for a string or array (VineJS `minLength`). */
413
1821
  minLength(n) {
414
- this.#rules.push({
1822
+ this.#pushRule({
415
1823
  name: "minLength",
416
1824
  param: n,
417
1825
  args: { min: n },
@@ -422,7 +1830,7 @@ export class RuleChain {
422
1830
  }
423
1831
  /** Maximum length for a string or array (VineJS `maxLength`). */
424
1832
  maxLength(n) {
425
- this.#rules.push({
1833
+ this.#pushRule({
426
1834
  name: "maxLength",
427
1835
  param: n,
428
1836
  args: { max: n },
@@ -436,7 +1844,7 @@ export class RuleChain {
436
1844
  }
437
1845
  /** Exact length for a string or array (VineJS `fixedLength`). */
438
1846
  fixedLength(n) {
439
- this.#rules.push({
1847
+ this.#pushRule({
440
1848
  name: "fixedLength",
441
1849
  param: n,
442
1850
  args: { size: n },
@@ -446,19 +1854,12 @@ export class RuleChain {
446
1854
  return this;
447
1855
  }
448
1856
  /** 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;
1857
+ email(options) {
1858
+ return this.#stringRule("email", (v) => isEmail(v, options), "Must be a valid email address", options ? { ...options } : undefined);
458
1859
  }
459
1860
  /** Must match a regular expression (TS-only — never dispatched to Rust). */
460
1861
  regex(pattern) {
461
- this.#rules.push({
1862
+ this.#pushRule({
462
1863
  name: "regex",
463
1864
  validate: (v) => typeof v === "string" && pattern.test(v),
464
1865
  message: "Invalid format",
@@ -466,44 +1867,389 @@ export class RuleChain {
466
1867
  return this;
467
1868
  }
468
1869
  /** Must be a valid URL (TS-only — uses the WHATWG URL parser). */
469
- url() {
470
- this.#rules.push({
1870
+ url(options) {
1871
+ this.#pushRule({
471
1872
  name: "url",
472
- validate: (v) => typeof v === "string" && isValidUrl(v),
1873
+ args: options ? { ...options } : undefined,
1874
+ validate: (v) => typeof v === "string" &&
1875
+ (options ? isUrlWithOptions(v, options) : isValidUrl(v)),
473
1876
  message: "Must be a valid URL",
474
1877
  });
475
1878
  return this;
476
1879
  }
477
- /** Must be a valid UUID. */
478
- uuid() {
479
- this.#rules.push({
1880
+ /**
1881
+ * The host must actually resolve (VineJS `activeUrl`).
1882
+ *
1883
+ * The only rule needing the network, which rune cannot do and stay agnostic
1884
+ * and zero-dependency — so it runs through a resolver bound once at boot,
1885
+ * exactly like `unique()`. Async by nature: run the schema with
1886
+ * `validateResultAsync`. Unbound it THROWS, rather than passing a host nobody
1887
+ * checked.
1888
+ */
1889
+ activeUrl() {
1890
+ this.#pushAsync({
1891
+ __rune: "asyncRule",
1892
+ async run(value, field) {
1893
+ if (!hostResolver) {
1894
+ throw new RuneError("NO_HOST_RESOLVER", "activeUrl() needs a host resolver.", { hint: "Call bindHostResolver(resolver) once at boot." });
1895
+ }
1896
+ let host;
1897
+ try {
1898
+ host = new URL(String(value)).hostname;
1899
+ }
1900
+ catch {
1901
+ field.report("Must be a valid URL", "activeUrl");
1902
+ return;
1903
+ }
1904
+ if (!(await hostResolver.resolves(host))) {
1905
+ field.report("Must be an active URL", "activeUrl");
1906
+ }
1907
+ },
1908
+ });
1909
+ return this;
1910
+ }
1911
+ /**
1912
+ * Must be a valid UUID, optionally restricted to given versions
1913
+ * (VineJS `uuid({ version: [4] })`, versions 1 through 8).
1914
+ */
1915
+ uuid(options) {
1916
+ const versions = options?.version === undefined ? undefined : [options.version].flat();
1917
+ this.#pushRule({
480
1918
  name: "uuid",
481
- validate: (v) => typeof v === "string" && UUID_RE.test(v),
482
- message: "Must be a valid UUID",
1919
+ args: versions === undefined ? {} : { version: versions },
1920
+ // The Rust engine checks UUID shape only; a version constraint would
1921
+ // be dropped there.
1922
+ tsOnly: versions !== undefined,
1923
+ validate: (v) => {
1924
+ if (typeof v !== "string" || !UUID_RE.test(v))
1925
+ return false;
1926
+ if (versions === undefined)
1927
+ return true;
1928
+ // Version nibble: first character of the third group.
1929
+ const version = Number.parseInt(v[14] ?? "", 16);
1930
+ return versions.includes(version);
1931
+ },
1932
+ message: versions === undefined
1933
+ ? "Must be a valid UUID"
1934
+ : `Must be a UUID v${versions.join("/")}`,
1935
+ });
1936
+ return this;
1937
+ }
1938
+ /** Must be a ULID (VineJS `ulid`). */
1939
+ ulid() {
1940
+ return this.#stringRule("ulid", isUlid, "Must be a valid ULID");
1941
+ }
1942
+ /** Must be a JSON Web Token — three dot-separated base64url segments. */
1943
+ jwt() {
1944
+ return this.#stringRule("jwt", isJwt, "Must be a valid JWT");
1945
+ }
1946
+ /** Must contain only ASCII characters (VineJS `ascii`). */
1947
+ ascii() {
1948
+ return this.#stringRule("ascii", isAscii, "Must contain only ASCII characters");
1949
+ }
1950
+ /** Must be a CSS hex colour code, with or without the leading `#`. */
1951
+ hexCode() {
1952
+ return this.#stringRule("hexCode", isHexCode, "Must be a valid hex code");
1953
+ }
1954
+ /** Must be an IP address. Pass `version` to require v4 or v6 specifically. */
1955
+ ipAddress(options) {
1956
+ const version = options?.version;
1957
+ return this.#stringRule("ipAddress", (v) => isIpAddress(v, version), `Must be a valid IP address${version ? ` (v${version})` : ""}`, { version });
1958
+ }
1959
+ /** Must pass the Luhn checksum (VineJS `creditCard`). */
1960
+ creditCard() {
1961
+ return this.#stringRule("creditCard", isCreditCard, "Must be a valid credit card number");
1962
+ }
1963
+ /** Must be an IBAN passing the ISO 13616 mod-97 check. */
1964
+ iban() {
1965
+ return this.#stringRule("iban", isIban, "Must be a valid IBAN");
1966
+ }
1967
+ /** Must be a `"lat,lng"` pair within the valid ranges. */
1968
+ coordinates() {
1969
+ return this.#stringRule("coordinates", isCoordinates, "Must be valid coordinates");
1970
+ }
1971
+ /**
1972
+ * Must be a mobile number (VineJS/Adonis `mobile()`).
1973
+ *
1974
+ * With no `locale`, the number must be in E.164 form. With one or more,
1975
+ * it must match one of their numbering plans, as in VineJS. rune carries
1976
+ * its own plans rather than validator.js', so it knows fewer locales — an
1977
+ * unknown one raises `UNSUPPORTED_LOCALE` at schema build, naming the ones
1978
+ * it does know, rather than silently accepting anything at request time.
1979
+ */
1980
+ mobile(options) {
1981
+ const locales = options?.locale ? [options.locale].flat() : null;
1982
+ for (const locale of locales ?? []) {
1983
+ if (isMobileForLocale("", locale) === null) {
1984
+ throw new RuneError("UNSUPPORTED_LOCALE", `mobile(): no numbering plan for locale '${locale}'.`, {
1985
+ hint: `Supported: ${SUPPORTED_MOBILE_LOCALES.join(", ")}. Omit the locale for E.164, or use .regex().`,
1986
+ });
1987
+ }
1988
+ }
1989
+ return this.#stringRule("mobile", (v) => {
1990
+ // strictMode (validator.js): the number must carry its `+` country
1991
+ // prefix, so a national-format string is not silently accepted.
1992
+ if (options?.strictMode && !v.trim().startsWith("+"))
1993
+ return false;
1994
+ return locales
1995
+ ? locales.some((locale) => isMobileForLocale(v, locale) === true)
1996
+ : isMobile(v);
1997
+ }, "Must be a valid mobile number", (locales ?? options?.strictMode)
1998
+ ? { locale: locales, strictMode: options?.strictMode }
1999
+ : undefined);
2000
+ }
2001
+ /**
2002
+ * Must be a postal code for `countryCode`. Throws for a country rune has no
2003
+ * pattern for, rather than accepting the value unchecked.
2004
+ */
2005
+ postalCode(options) {
2006
+ // The callback form resolves per validation (VineJS lets the country come
2007
+ // from a sibling field), so its countries cannot be checked up front.
2008
+ if (typeof options === "function") {
2009
+ this.#pushUse({
2010
+ __rune: "rule",
2011
+ run: (value, field) => {
2012
+ if (typeof value !== "string")
2013
+ return;
2014
+ const countries = [options(field).countryCode].flat();
2015
+ if (!countries.some((c) => isPostalCode(value, c) === true)) {
2016
+ field.report(`Must be a valid ${countries.join("/")} postal code`, "postalCode");
2017
+ }
2018
+ },
2019
+ });
2020
+ return this;
2021
+ }
2022
+ const countries = [options.countryCode].flat();
2023
+ for (const country of countries) {
2024
+ if (isPostalCode("", country) === null) {
2025
+ throw new RuneError("UNSUPPORTED_COUNTRY", `postalCode(): no pattern for country '${country}'.`, {
2026
+ hint: `Supported: ${SUPPORTED_POSTAL_CODES.join(", ")}. Use .regex() for others.`,
2027
+ });
2028
+ }
2029
+ }
2030
+ return this.#stringRule("postalCode", (v) => countries.some((c) => isPostalCode(v, c) === true), `Must be a valid ${countries.join("/").toUpperCase()} postal code`, { countryCode: countries });
2031
+ }
2032
+ /**
2033
+ * Must be a valid VAT number (VineJS 4.2 `vat`). Accepts a country list or a
2034
+ * callback resolving it per payload.
2035
+ *
2036
+ * Checksums are run where the country defines a short, well-defined one
2037
+ * (BE, DE, NL, IT, PT, LU, CH); the others are FORMAT-only, which is stated
2038
+ * rather than implied. An unknown country LEVES rather than accepting the
2039
+ * value unchecked.
2040
+ */
2041
+ vat(options) {
2042
+ if (typeof options === "function") {
2043
+ this.#pushUse({
2044
+ __rune: "rule",
2045
+ run: (value, field) => {
2046
+ if (typeof value !== "string")
2047
+ return;
2048
+ const countries = [options(field).countryCode].flat();
2049
+ if (!countries.some((c) => isVat(value, c) === true)) {
2050
+ field.report(`Must be a valid ${countries.join("/")} VAT number`, "vat");
2051
+ }
2052
+ },
2053
+ });
2054
+ return this;
2055
+ }
2056
+ const countries = [options.countryCode].flat();
2057
+ for (const country of countries) {
2058
+ if (isVat("", country) === null) {
2059
+ throw new RuneError("UNSUPPORTED_COUNTRY", `vat(): no rule for country '${country}'.`, {
2060
+ hint: `Supported: ${SUPPORTED_VAT_COUNTRIES.join(", ")}. Use .regex() for others.`,
2061
+ });
2062
+ }
2063
+ }
2064
+ return this.#stringRule("vat", (v) => countries.some((c) => isVat(v, c) === true), `Must be a valid ${countries.join("/").toUpperCase()} VAT number`, { countryCode: countries });
2065
+ }
2066
+ /** Must differ from a sibling field (VineJS `notSameAs`). */
2067
+ notSameAs(otherField) {
2068
+ const formats = this.#dateFormats;
2069
+ this.#pushUse({
2070
+ __rune: "rule",
2071
+ run: (value, field) => {
2072
+ const other = readSibling(field, otherField);
2073
+ if (formats !== null && value instanceof Date) {
2074
+ const parsed = parseDateValue(other, formats);
2075
+ if (parsed !== null && parsed.getTime() === value.getTime()) {
2076
+ field.report(`Must be different from ${otherField}`, "notSameAs");
2077
+ }
2078
+ return;
2079
+ }
2080
+ if (value === other) {
2081
+ field.report(`Must be different from ${otherField}`, "notSameAs");
2082
+ }
2083
+ },
2084
+ });
2085
+ return this;
2086
+ }
2087
+ /** Array items must be unique — optionally compared on `field` (VineJS `distinct`). */
2088
+ distinct(field) {
2089
+ this.#pushRule({
2090
+ name: "distinct",
2091
+ args: { field },
2092
+ validate: (v) => {
2093
+ if (!Array.isArray(v))
2094
+ return false;
2095
+ const fieldList = field === undefined ? null : [field].flat();
2096
+ const keys = [];
2097
+ for (const item of v) {
2098
+ // VineJS ignores null/undefined items entirely: `[1, null, 2, null]`
2099
+ // is distinct. Serialising them would make the second one a
2100
+ // duplicate of the first.
2101
+ if (item === null || item === undefined)
2102
+ continue;
2103
+ if (fieldList === null) {
2104
+ keys.push(JSON.stringify(item));
2105
+ continue;
2106
+ }
2107
+ if (!isPlainObject(item))
2108
+ continue;
2109
+ // VineJS skips an item missing the key(s): two absent values are
2110
+ // not a duplicate of each other.
2111
+ if (fieldList.some((k) => item[k] === undefined || item[k] === null)) {
2112
+ continue;
2113
+ }
2114
+ keys.push(JSON.stringify(fieldList.map((k) => item[k])));
2115
+ }
2116
+ return new Set(keys).size === keys.length;
2117
+ },
2118
+ message: field
2119
+ ? `Items must have a unique ${field}`
2120
+ : "Items must be unique",
2121
+ });
2122
+ return this;
2123
+ }
2124
+ /** Must be less than or equal to zero (VineJS `nonPositive`). */
2125
+ nonPositive() {
2126
+ this.#pushRule({
2127
+ name: "nonPositive",
2128
+ validate: (v) => typeof v === "number" && v <= 0,
2129
+ message: "Must be zero or negative",
2130
+ });
2131
+ return this;
2132
+ }
2133
+ /** Array must hold at least one item (VineJS `notEmpty`). */
2134
+ notEmpty() {
2135
+ this.#pushRule({
2136
+ name: "notEmpty",
2137
+ validate: (v) => Array.isArray(v) && v.length > 0,
2138
+ message: "Must not be empty",
2139
+ });
2140
+ return this;
2141
+ }
2142
+ /** Drop `null`, `undefined` and `""` items before the item rules run. */
2143
+ compact() {
2144
+ this.#transforms.push({
2145
+ name: "compact",
2146
+ fn: (value) => Array.isArray(value)
2147
+ ? value.filter((item) => item !== null && item !== undefined && item !== "")
2148
+ : value,
2149
+ });
2150
+ return this;
2151
+ }
2152
+ /** Number must have no fractional part (VineJS `withoutDecimals`). */
2153
+ withoutDecimals() {
2154
+ this.#pushRule({
2155
+ name: "withoutDecimals",
2156
+ validate: (v) => typeof v === "number" && Number.isInteger(v),
2157
+ message: "Must not have decimals",
2158
+ });
2159
+ return this;
2160
+ }
2161
+ /** Shared body of the string-format rules: reject non-strings, then check. */
2162
+ #stringRule(name, check, message, args) {
2163
+ this.#pushRule({
2164
+ name,
2165
+ args,
2166
+ validate: (v) => typeof v === "string" && check(v),
2167
+ message,
2168
+ });
2169
+ return this;
2170
+ }
2171
+ /** Must be a passport number for `countryCode`. Throws for an uncovered country. */
2172
+ passport(options) {
2173
+ const countries = [options.countryCode].flat();
2174
+ for (const country of countries) {
2175
+ if (isPassport("", country) === null) {
2176
+ throw new RuneError("UNSUPPORTED_COUNTRY", `passport(): no pattern for country '${country}'.`, {
2177
+ hint: `Supported: ${SUPPORTED_PASSPORTS.join(", ")}. Use .regex() for others.`,
2178
+ });
2179
+ }
2180
+ }
2181
+ return this.#stringRule("passport", (v) => countries.some((c) => isPassport(v, c) === true), `Must be a valid ${countries.join("/").toUpperCase()} passport number`, { countryCode: countries });
2182
+ }
2183
+ /** Lowercase the value (VineJS `toLowerCase`). */
2184
+ toLowerCase() {
2185
+ return this.#stringMutation("toLowerCase", (v) => v.toLowerCase());
2186
+ }
2187
+ /** Uppercase the value (VineJS `toUpperCase`). */
2188
+ toUpperCase() {
2189
+ return this.#stringMutation("toUpperCase", (v) => v.toUpperCase());
2190
+ }
2191
+ /**
2192
+ * VineJS `toCamelCase()`, on both shapes it exists for:
2193
+ *
2194
+ * - on an `object()` chain it camelCases the object's KEYS
2195
+ * (`VineObject.toCamelCase`);
2196
+ * - on any other chain it camelCases the string VALUE (`VineString`).
2197
+ *
2198
+ * One name, because Vine has one name. Dispatching on whether a nested shape
2199
+ * was declared is what keeps a transcribed validator behaving the same.
2200
+ */
2201
+ toCamelCase() {
2202
+ if (this.#nestedSchema) {
2203
+ this.#camelCaseKeys = true;
2204
+ return this;
2205
+ }
2206
+ return this.#stringMutation("toCamelCase", toCamelCase);
2207
+ }
2208
+ /** HTML-escape `& < > " '` (VineJS `escape`). */
2209
+ escape() {
2210
+ return this.#stringMutation("escape", escapeHtml);
2211
+ }
2212
+ /** Normalise an email address (VineJS `normalizeEmail`). */
2213
+ normalizeEmail(options) {
2214
+ return this.#stringMutation("normalizeEmail", (v) => normalizeEmail(v, options));
2215
+ }
2216
+ /** Normalise a URL (VineJS `normalizeUrl`). */
2217
+ normalizeUrl(options) {
2218
+ return this.#stringMutation("normalizeUrl", (v) => normalizeUrl(v, options));
2219
+ }
2220
+ /** Shared body of the string mutations — non-strings pass through untouched. */
2221
+ #stringMutation(name, fn) {
2222
+ this.#transforms.push({
2223
+ name,
2224
+ fn: (value) => (typeof value === "string" ? fn(value) : value),
483
2225
  });
484
2226
  return this;
485
2227
  }
486
2228
  /** Must contain only ASCII letters. */
487
- alpha() {
488
- this.#rules.push({
2229
+ alpha(options) {
2230
+ const pattern = alphaPattern("a-zA-Z", options);
2231
+ this.#pushRule({
489
2232
  name: "alpha",
490
- validate: (v) => typeof v === "string" && v.length > 0 && /^[a-zA-Z]+$/.test(v),
2233
+ args: options ? { ...options } : undefined,
2234
+ validate: (v) => typeof v === "string" && v.length > 0 && pattern.test(v),
491
2235
  message: "Must contain only letters",
492
2236
  });
493
2237
  return this;
494
2238
  }
495
2239
  /** Must contain only ASCII letters and digits. */
496
- alphaNumeric() {
497
- this.#rules.push({
2240
+ alphaNumeric(options) {
2241
+ const pattern = alphaPattern("a-zA-Z0-9", options);
2242
+ this.#pushRule({
498
2243
  name: "alphaNumeric",
499
- validate: (v) => typeof v === "string" && v.length > 0 && /^[a-zA-Z0-9]+$/.test(v),
2244
+ args: options ? { ...options } : undefined,
2245
+ validate: (v) => typeof v === "string" && v.length > 0 && pattern.test(v),
500
2246
  message: "Must contain only letters and numbers",
501
2247
  });
502
2248
  return this;
503
2249
  }
504
2250
  /** String must start with `substring`. */
505
2251
  startsWith(substring) {
506
- this.#rules.push({
2252
+ this.#pushRule({
507
2253
  name: "startsWith",
508
2254
  args: { substring },
509
2255
  validate: (v) => typeof v === "string" && v.startsWith(substring),
@@ -513,7 +2259,7 @@ export class RuleChain {
513
2259
  }
514
2260
  /** String must end with `substring`. */
515
2261
  endsWith(substring) {
516
- this.#rules.push({
2262
+ this.#pushRule({
517
2263
  name: "endsWith",
518
2264
  args: { substring },
519
2265
  validate: (v) => typeof v === "string" && v.endsWith(substring),
@@ -521,31 +2267,41 @@ export class RuleChain {
521
2267
  });
522
2268
  return this;
523
2269
  }
524
- /** Value must be one of `values`. */
2270
+ /**
2271
+ * Value must be one of `values`.
2272
+ *
2273
+ * VineJS also accepts a callback so the list can be computed at validation
2274
+ * time (tenant-scoped roles, values read from config…). A static array is
2275
+ * snapshotted; a callback is invoked on every check.
2276
+ */
525
2277
  in(values) {
526
- const allowed = [...values];
527
- this.#rules.push({
2278
+ const resolve = allowedValuesResolver(values);
2279
+ this.#pushRule({
528
2280
  name: "in",
529
- args: { values: allowed },
530
- validate: (v) => allowed.includes(asPrimitive(v)),
2281
+ args: typeof values === "function" ? {} : { values: [...values] },
2282
+ // A callback list is computed per call — the native engine only ever
2283
+ // sees a static array, so it must not run this rule.
2284
+ tsOnly: typeof values === "function",
2285
+ validate: (v, field) => resolve(field).includes(asPrimitive(v)),
531
2286
  message: "Invalid value",
532
2287
  });
533
2288
  return this;
534
2289
  }
535
2290
  /** Value must NOT be one of `values`. */
536
2291
  notIn(values) {
537
- const denied = [...values];
538
- this.#rules.push({
2292
+ const resolve = allowedValuesResolver(values);
2293
+ this.#pushRule({
539
2294
  name: "notIn",
540
- args: { values: denied },
541
- validate: (v) => !denied.includes(asPrimitive(v)),
2295
+ args: typeof values === "function" ? {} : { values: [...values] },
2296
+ tsOnly: typeof values === "function",
2297
+ validate: (v, field) => !resolve(field).includes(asPrimitive(v)),
542
2298
  message: "Invalid value",
543
2299
  });
544
2300
  return this;
545
2301
  }
546
2302
  /** Number must be positive (> 0) and finite. */
547
2303
  positive() {
548
- this.#rules.push({
2304
+ this.#pushRule({
549
2305
  name: "positive",
550
2306
  validate: (v) => typeof v === "number" && Number.isFinite(v) && v > 0,
551
2307
  message: "Must be positive",
@@ -554,7 +2310,7 @@ export class RuleChain {
554
2310
  }
555
2311
  /** Number must be negative (< 0) and finite. */
556
2312
  negative() {
557
- this.#rules.push({
2313
+ this.#pushRule({
558
2314
  name: "negative",
559
2315
  validate: (v) => typeof v === "number" && Number.isFinite(v) && v < 0,
560
2316
  message: "Must be negative",
@@ -563,7 +2319,7 @@ export class RuleChain {
563
2319
  }
564
2320
  /** Number must be >= 0 and finite. */
565
2321
  nonNegative() {
566
- this.#rules.push({
2322
+ this.#pushRule({
567
2323
  name: "nonNegative",
568
2324
  validate: (v) => typeof v === "number" && Number.isFinite(v) && v >= 0,
569
2325
  message: "Must be positive or zero",
@@ -571,36 +2327,54 @@ export class RuleChain {
571
2327
  return this;
572
2328
  }
573
2329
  /** Number must fall within `[min, max]` (inclusive). */
574
- range(min, max) {
575
- this.#rules.push({
2330
+ range(bounds) {
2331
+ // VineJS signature is a TUPLE (`range([18, 60])`); the two-argument form
2332
+ // silently dropped `max` when an Adonis validator was transcribed as-is.
2333
+ const [min, max] = bounds;
2334
+ this.#pushRule({
576
2335
  name: "range",
577
2336
  args: { min, max },
578
- validate: (v) => typeof v === "number" && Number.isFinite(v) && v >= min && v <= max,
2337
+ validate: (v) => typeof v === "number" && v >= min && v <= max,
579
2338
  message: `Must be between ${min} and ${max}`,
580
2339
  });
581
2340
  return this;
582
2341
  }
583
2342
  /** Number must have at most `digits` decimal places (TS-only). */
584
2343
  decimal(digits) {
585
- this.#rules.push({
2344
+ // VineJS accepts a `[min, max]` range as well as a single maximum.
2345
+ const [min, max] = Array.isArray(digits) ? digits : [0, digits];
2346
+ this.#pushRule({
586
2347
  name: "decimal",
587
2348
  args: { digits },
588
2349
  validate: (v) => {
589
2350
  if (typeof v !== "number" || !Number.isFinite(v))
590
2351
  return false;
591
- const parts = String(v).split(".");
592
- return (parts[1]?.length ?? 0) <= digits;
2352
+ const places = String(v).split(".")[1]?.length ?? 0;
2353
+ return places >= min && places <= max;
593
2354
  },
594
- message: `Must have at most ${digits} decimal places`,
2355
+ message: Array.isArray(digits)
2356
+ ? `Must have between ${min} and ${max} decimal places`
2357
+ : `Must have at most ${max} decimal places`,
595
2358
  });
596
2359
  return this;
597
2360
  }
598
2361
  /** Must equal a sibling field (VineJS `sameAs`). Cross-field → TS-only. */
599
2362
  sameAs(otherField) {
600
- this.#useRules.push({
2363
+ const formats = this.#dateFormats;
2364
+ this.#pushUse({
601
2365
  __rune: "rule",
602
2366
  run: (value, field) => {
603
2367
  const other = readSibling(field, otherField);
2368
+ // On a date chain the value is a parsed `Date` and the sibling is
2369
+ // still raw, so `!==` would compare a Date to a string and always
2370
+ // fail. Compare instants instead.
2371
+ if (formats !== null && value instanceof Date) {
2372
+ const parsed = parseDateValue(other, formats);
2373
+ if (parsed === null || parsed.getTime() !== value.getTime()) {
2374
+ field.report(`Must match ${otherField}`, "sameAs");
2375
+ }
2376
+ return;
2377
+ }
604
2378
  if (value !== other) {
605
2379
  field.report(`Must match ${otherField}`, "sameAs");
606
2380
  }
@@ -610,13 +2384,18 @@ export class RuleChain {
610
2384
  }
611
2385
  /** Must equal its `<field>_confirmation` sibling (VineJS `confirmed`). */
612
2386
  confirmed(options) {
613
- this.#useRules.push({
2387
+ this.#pushUse({
614
2388
  __rune: "rule",
615
2389
  run: (value, field) => {
616
2390
  const leaf = field.field.split(".").pop() ?? field.field;
617
- const other = options?.confirmationField ?? `${leaf}_confirmation`;
2391
+ // `as` is the current VineJS spelling; `confirmationField` is its
2392
+ // deprecated alias, kept so existing callers keep working.
2393
+ const other = options?.as ?? options?.confirmationField ?? `${leaf}_confirmation`;
618
2394
  if (value !== readSibling(field, other)) {
619
- field.report("Confirmation does not match", "confirmed");
2395
+ // VineJS reports on the CONFIRMATION field: that is the input the
2396
+ // user has to fix, and where a form renders the message.
2397
+ const prefix = field.field.slice(0, -leaf.length);
2398
+ field.report("Confirmation does not match", "confirmed", `${prefix}${other}`);
620
2399
  }
621
2400
  },
622
2401
  });
@@ -667,7 +2446,7 @@ export class RuleChain {
667
2446
  }
668
2447
  /** Custom validation rule. */
669
2448
  custom(name, validate, message) {
670
- this.#rules.push({
2449
+ this.#pushRule({
671
2450
  name,
672
2451
  validate,
673
2452
  message: message ?? `Failed custom rule: ${name}`,
@@ -680,52 +2459,233 @@ export class RuleChain {
680
2459
  * validate across fields. Runs after this field's type/value rules.
681
2460
  */
682
2461
  use(rule) {
2462
+ // A rule built with `{ isAsync: true }` arrives here (VineJS has one
2463
+ // `use`); routing it to the sync register would drop the await.
2464
+ if (rule.__rune === "asyncRule") {
2465
+ return this.useAsync(rule);
2466
+ }
683
2467
  if (rule?.__rune !== "rule" || typeof rule.run !== "function") {
684
2468
  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
2469
  }
686
- this.#useRules.push(rule);
2470
+ this.#pushUse(rule);
2471
+ return this;
2472
+ }
2473
+ /**
2474
+ * Attach an async rule (from {@link createAsyncRule}). The schema must then be
2475
+ * run with `validateResultAsync` — sync `validate()` throws for such a schema.
2476
+ */
2477
+ useAsync(rule) {
2478
+ if (rule?.__rune !== "asyncRule" || typeof rule.run !== "function") {
2479
+ throw new RuneError("INVALID_RULE", "useAsync() expects a compiled async rule — call the factory first", { hint: "useAsync(myRule()) or useAsync(myRule(options))" });
2480
+ }
2481
+ this.#pushAsync(rule);
2482
+ return this;
2483
+ }
2484
+ unique(checkOrOptions, message) {
2485
+ const check = toDatabaseCheck(checkOrOptions, "unique");
2486
+ this.#pushAsync({
2487
+ __rune: "asyncRule",
2488
+ async run(value, field) {
2489
+ const ok = await check(value, field);
2490
+ if (!ok) {
2491
+ field.report(message ?? `The ${field.field} has already been taken`, "database.unique");
2492
+ }
2493
+ },
2494
+ });
2495
+ return this;
2496
+ }
2497
+ exists(checkOrOptions, message) {
2498
+ const check = toDatabaseCheck(checkOrOptions, "exists");
2499
+ this.#pushAsync({
2500
+ __rune: "asyncRule",
2501
+ async run(value, field) {
2502
+ const ok = await check(value, field);
2503
+ if (!ok) {
2504
+ field.report(message ?? `The selected ${field.field} is invalid`, "database.exists");
2505
+ }
2506
+ },
2507
+ });
2508
+ return this;
2509
+ }
2510
+ /**
2511
+ * Attach free-form JSON Schema metadata (VineJS `meta()`) — `title`,
2512
+ * `description`, `examples`, `deprecated`… Merged verbatim into the field's
2513
+ * node by `toJSONSchema()`.
2514
+ */
2515
+ meta(metadata) {
2516
+ this.#metadata = { ...this.#metadata, ...metadata };
687
2517
  return this;
688
2518
  }
689
- /** Set custom error message for the last rule. */
2519
+ /**
2520
+ * Set a custom error message for the rule that was just added.
2521
+ *
2522
+ * "The last rule" spans all three registers: value rules (`#rules`),
2523
+ * cross-field `.use()` rules (`sameAs`, `confirmed`, `afterField`,
2524
+ * `notSameAs`) and async rules (`unique`, `exists`, `useAsync`). Targeting
2525
+ * `#rules` alone silently retargeted the PREVIOUS value rule — or threw
2526
+ * `NO_RULE` — whenever the preceding call was a cross-field or async rule.
2527
+ */
690
2528
  message(msg) {
691
- if (this.#rules.length === 0) {
2529
+ const target = this.#lastRule;
2530
+ if (!target) {
692
2531
  throw new RuneError("NO_RULE", "message() must be called after a rule");
693
2532
  }
694
- const last = this.#rules[this.#rules.length - 1];
695
- last.message = msg;
696
- last.hasCustomMessage = true;
2533
+ if (target.kind === "value") {
2534
+ target.ref.message = msg;
2535
+ target.ref.hasCustomMessage = true;
2536
+ }
2537
+ else {
2538
+ // `.use()` / async rules report their own text from inside `run`, so the
2539
+ // override is applied when the rule reports rather than stored on it.
2540
+ this.#ruleMessages.set(target.ref, msg);
2541
+ }
697
2542
  return this;
698
2543
  }
2544
+ /**
2545
+ * Build the {@link FieldContext} handed to `.use()` / async rules. Shared so
2546
+ * the sync and async paths cannot drift on what a rule can see.
2547
+ */
2548
+ #makeFieldContext(field, value, ctx, errors, onMutate) {
2549
+ const segments = field.split(".");
2550
+ return {
2551
+ value,
2552
+ data: ctx.data,
2553
+ parent: ctx.parent,
2554
+ field,
2555
+ meta: ctx.meta,
2556
+ isValid: errors.length === 0,
2557
+ name: segments[segments.length - 1] ?? field,
2558
+ wildCardPath: toWildcardPath(field),
2559
+ isArrayMember: Array.isArray(ctx.parent),
2560
+ isDefined: value !== undefined && value !== null,
2561
+ isValidDataType: errors.length === 0,
2562
+ getFieldPath: () => field,
2563
+ mutate: onMutate,
2564
+ report(message, rule, reportedField, args) {
2565
+ // VineJS plugins pass the FIELD CONTEXT here, not a path. Accepting
2566
+ // only a string let the object through and produced a
2567
+ // `ValidationError.field` that was not a string at runtime.
2568
+ const target = typeof reportedField === "string"
2569
+ ? reportedField
2570
+ : (reportedField?.getFieldPath() ?? field);
2571
+ errors.push({
2572
+ field: target,
2573
+ rule,
2574
+ message,
2575
+ ...(args ? { meta: args } : {}),
2576
+ });
2577
+ },
2578
+ };
2579
+ }
2580
+ /**
2581
+ * Run only the implicit `.use()` rules against an absent value. A rule
2582
+ * declared `{ implicit: true }` exists to police `undefined`/`null`, so the
2583
+ * early return for optional fields must not skip it.
2584
+ */
2585
+ #runImplicitRules(field, value, ctx, pending) {
2586
+ // An implicit ASYNC rule polices an absent value too, so it has to be
2587
+ // queued here as well — filtering `#useRules` alone dropped it silently.
2588
+ if (pending && this.#asyncRules.some((rule) => rule.implicit)) {
2589
+ pending.push({ chain: this, field, value, ctx });
2590
+ }
2591
+ const implicitRules = this.#useRules.filter((rule) => rule.implicit);
2592
+ if (implicitRules.length === 0)
2593
+ return [];
2594
+ const errors = [];
2595
+ const fieldCtx = this.#makeFieldContext(field, value, ctx, errors, () => { });
2596
+ for (const rule of implicitRules) {
2597
+ fieldCtx.isValid = errors.length === 0;
2598
+ rule.run(value, fieldCtx);
2599
+ }
2600
+ return errors;
2601
+ }
2602
+ /**
2603
+ * Register a TYPE rule from outside the chain — used by the `optional()` and
2604
+ * `null()` factories, which are types in their own right.
2605
+ * @internal
2606
+ */
2607
+ pushTypeRule(rule) {
2608
+ this.#pushRule(rule);
2609
+ }
2610
+ /** Re-type this chain in place, without cloning. @internal */
2611
+ retypeTo() {
2612
+ return this.#retype();
2613
+ }
2614
+ /** Add a value rule and remember it as the `message()` target. */
2615
+ #pushRule(rule) {
2616
+ this.#rules.push(rule);
2617
+ this.#lastRule = { kind: "value", ref: rule };
2618
+ }
2619
+ /** Add a cross-field `.use()` rule and remember it as the `message()` target. */
2620
+ #pushUse(rule) {
2621
+ this.#useRules.push(rule);
2622
+ this.#lastRule = { kind: "reporting", ref: rule };
2623
+ }
2624
+ /** Add an async rule and remember it as the `message()` target. */
2625
+ #pushAsync(rule) {
2626
+ this.#asyncRules.push(rule);
2627
+ this.#lastRule = { kind: "reporting", ref: rule };
2628
+ }
699
2629
  /** Whether the field is required given the surrounding data (conditionals). */
700
2630
  #isRequired(ctx) {
701
2631
  if (this.#requiredConditions.length === 0)
702
2632
  return true;
703
2633
  return this.#requiredConditions.some((cond) => evalRequiredCondition(cond, ctx));
704
2634
  }
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.
2635
+ /**
2636
+ * Internal: validate a field value and return errors + transformed value.
2637
+ *
2638
+ * `pending` is the async-rule collector. The traversal itself stays sync (it
2639
+ * is shared with `validate()`); when a collector is supplied, every chain in
2640
+ * the tree that carries async rules and passed its sync rules records itself
2641
+ * for the async path to await. Without it, nested async rules never ran.
2642
+ */
2643
+ _validateWithTransform(field, rawValue, ctx = EMPTY_RUN_CONTEXT, pending) {
2644
+ // 0. Pre-validation parse() transforms run on the raw value first. VineJS
2645
+ // hands them `(value, { data, parent, meta })` — without the context a
2646
+ // parser cannot look at a sibling, which is half its purpose.
708
2647
  let value = rawValue;
2648
+ const parseCtx = {
2649
+ data: ctx.data,
2650
+ parent: ctx.parent,
2651
+ meta: ctx.meta,
2652
+ };
709
2653
  for (const pre of this.#preTransforms) {
710
- value = pre(value);
2654
+ value = pre(value, parseCtx);
711
2655
  }
712
2656
  if (value === undefined) {
713
2657
  if (this.#isOptional || !this.#isRequired(ctx)) {
714
- return { errors: [], transformed: value };
2658
+ // Implicit rules are precisely the ones that must see an absent value.
2659
+ return {
2660
+ errors: this.#runImplicitRules(field, value, ctx, pending),
2661
+ transformed: value,
2662
+ };
715
2663
  }
716
2664
  return { errors: [this.#requiredError(field, ctx)], transformed: value };
717
2665
  }
718
2666
  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 };
2667
+ // VineJS split, now matched exactly: `nullable()` accepts null AND keeps
2668
+ // it in the output; `optional()` accepts null but DROPS the key. rune
2669
+ // used to keep null in both cases, so an optional field silently added
2670
+ // `key: null` to a payload VineJS would have left without the key.
2671
+ if (this.#isNullable) {
2672
+ return {
2673
+ errors: this.#runImplicitRules(field, value, ctx, pending),
2674
+ transformed: value,
2675
+ };
2676
+ }
2677
+ if (this.#isOptional || !this.#isRequired(ctx)) {
2678
+ return {
2679
+ errors: this.#runImplicitRules(field, value, ctx, pending),
2680
+ transformed: undefined,
2681
+ };
726
2682
  }
727
2683
  return { errors: [this.#requiredError(field, ctx)], transformed: value };
728
2684
  }
2685
+ // 0b. Coerce before the type rules — a coerced value is the validated value.
2686
+ for (const coerce of this.#coercions) {
2687
+ value = coerce(value);
2688
+ }
729
2689
  // 1. Type rules first on the raw value — bail on type mismatch.
730
2690
  const typeError = this.#runTypeRules(field, value, ctx);
731
2691
  if (typeError)
@@ -736,17 +2696,135 @@ export class RuleChain {
736
2696
  // 3. Vine-style .use() rules — run with a FieldContext exposing the root
737
2697
  // `data` and `parent`, so a rule can validate across fields.
738
2698
  if (this.#useRules.length > 0 && !(this.#bail && errors.length > 0)) {
739
- this.#runUseRules(field, transformed, ctx, errors);
2699
+ // `.use()` rules may call `field.mutate()`, so the value can change here.
2700
+ transformed = this.#runUseRules(field, transformed, ctx, errors);
2701
+ }
2702
+ // 3b. Date output mapping (VineJS `VineDate.transform`). Deliberately AFTER
2703
+ // the comparison rules so `after`/`before`/`afterField` always see a
2704
+ // real `Date`, whatever type the consumer maps it to.
2705
+ if (this.#dateFormats !== null &&
2706
+ dateOutputTransform !== null &&
2707
+ transformed instanceof Date) {
2708
+ transformed = dateOutputTransform(transformed);
740
2709
  }
741
2710
  // 4. Nested object validation (only if type check passed — not arrays)
742
2711
  if (this.#nestedSchema && isPlainObject(transformed)) {
743
- const obj = { ...transformed };
2712
+ // Start from the DECLARED keys only. Spreading the input kept every
2713
+ // undeclared key, so the mass-assignment guarantee that holds at the
2714
+ // top level silently stopped holding one level down:
2715
+ // `object({ name })` let an `isAdmin` through. `allowUnknownProperties()`
2716
+ // is the opt-in, as in VineJS.
2717
+ const source = transformed;
2718
+ const obj = this.#allowUnknown
2719
+ ? { ...source }
2720
+ : {};
744
2721
  transformed = obj;
745
- for (const [nestedField, chain] of Object.entries(this.#nestedSchema)) {
746
- const nestedResult = chain._validateWithTransform(`${field}.${nestedField}`, obj[nestedField], { ...ctx, parent: obj });
2722
+ // A conditional group contributes its branch's properties for THIS
2723
+ // payload, so the shape is resolved per validation, not at build time.
2724
+ let shape = this.#nestedSchema;
2725
+ for (const grp of this.#groups) {
2726
+ const branch = grp.branches.find((candidate) => candidate.predicate?.(source) === true) ?? grp.branches.find((candidate) => candidate.predicate === null);
2727
+ if (branch)
2728
+ shape = { ...shape, ...branch.shape };
2729
+ }
2730
+ for (const [nestedField, chain] of Object.entries(shape)) {
2731
+ const nestedResult = chain._validateWithTransform(`${field}.${nestedField}`, source[nestedField], { ...ctx, parent: source }, pending);
747
2732
  errors.push(...nestedResult.errors);
748
2733
  if (nestedResult.transformed !== undefined) {
749
- obj[nestedField] = nestedResult.transformed;
2734
+ obj[this.#camelCaseKeys ? toCamelCaseKey(nestedField) : nestedField] =
2735
+ nestedResult.transformed;
2736
+ }
2737
+ }
2738
+ if (this.#camelCaseKeys && this.#allowUnknown) {
2739
+ // Undeclared keys are camelCased too, otherwise the output would mix
2740
+ // both spellings depending on whether a key was declared.
2741
+ for (const [key, value] of Object.entries(source)) {
2742
+ const camel = toCamelCaseKey(key);
2743
+ if (!(camel in obj))
2744
+ obj[camel] = value;
2745
+ }
2746
+ }
2747
+ }
2748
+ // 4b. Record values — same shape as the nested-object walk, arbitrary keys.
2749
+ if (this.#recordKeysCheck && isPlainObject(transformed)) {
2750
+ // Keys first: a key-level rule that rejects the shape makes the
2751
+ // per-value errors that would follow noise.
2752
+ this.#recordKeysCheck(Object.keys(transformed), this.#makeFieldContext(field, transformed, ctx, errors, () => { }));
2753
+ }
2754
+ if (this.#recordValueChain && isPlainObject(transformed)) {
2755
+ const obj = { ...transformed };
2756
+ transformed = obj;
2757
+ for (const key of Object.keys(obj)) {
2758
+ const res = this.#recordValueChain._validateWithTransform(`${field}.${key}`, obj[key], { ...ctx, parent: obj }, pending);
2759
+ errors.push(...res.errors);
2760
+ if (res.transformed !== undefined)
2761
+ obj[key] = res.transformed;
2762
+ }
2763
+ }
2764
+ // 4c. Tuple positions — length was already enforced by the `tuple` rule.
2765
+ if (this.#tupleChains && Array.isArray(transformed)) {
2766
+ const arr = [...transformed];
2767
+ transformed = arr;
2768
+ this.#tupleChains.forEach((chain, i) => {
2769
+ const res = chain._validateWithTransform(`${field}.${i}`, arr[i], { ...ctx, parent: arr }, pending);
2770
+ for (const e of res.errors)
2771
+ if (e.index === undefined)
2772
+ e.index = i;
2773
+ errors.push(...res.errors);
2774
+ if (res.transformed !== undefined)
2775
+ arr[i] = res.transformed;
2776
+ });
2777
+ }
2778
+ // 4d. Union — first branch that validates wins; its transform is kept.
2779
+ if (this.#unionChains) {
2780
+ let matched = false;
2781
+ // A guarded branch (union.if) is SELECTED by its predicate, and its own
2782
+ // errors are reported — that is the diagnosable half of VineJS's union.
2783
+ const guarded = this.#unionChains.filter((b) => b.predicate !== null);
2784
+ if (guarded.length > 0) {
2785
+ const probe = this.#makeFieldContext(field, transformed, ctx, [], () => { });
2786
+ const chosen = guarded.find((b) => b.predicate?.(transformed, probe)) ??
2787
+ this.#unionChains.find((b) => b.predicate === null);
2788
+ if (chosen) {
2789
+ const res = chosen.chain._validateWithTransform(field, transformed, ctx, pending);
2790
+ transformed = res.transformed;
2791
+ errors.push(...res.errors);
2792
+ matched = true;
2793
+ }
2794
+ }
2795
+ for (const branch of matched ? [] : this.#unionChains) {
2796
+ // Each branch collects into its OWN buffer: a losing branch must not
2797
+ // leave async work queued, and the winning one must not lose it —
2798
+ // without this, a `unique()` inside the matching branch was never
2799
+ // awaited, which reads exactly like a check that passed.
2800
+ const branchPending = [];
2801
+ const res = branch.chain._validateWithTransform(field, transformed, ctx, pending ? branchPending : undefined);
2802
+ if (res.errors.length === 0) {
2803
+ transformed = res.transformed;
2804
+ matched = true;
2805
+ if (pending)
2806
+ pending.push(...branchPending);
2807
+ break;
2808
+ }
2809
+ }
2810
+ if (!matched) {
2811
+ if (this.#unionNoMatch) {
2812
+ // The callback owns the reporting: whatever it pushes is the
2813
+ // error, and pushing nothing means it handled the case itself.
2814
+ const reported = [];
2815
+ this.#unionNoMatch(transformed, this.#makeFieldContext(field, transformed, ctx, reported, () => { }));
2816
+ errors.push(...reported);
2817
+ }
2818
+ else {
2819
+ errors.push({
2820
+ field,
2821
+ rule: "union",
2822
+ message: resolveRuleMessage(field, {
2823
+ name: "union",
2824
+ validate: () => false,
2825
+ message: "Does not match any allowed shape",
2826
+ }, ctx),
2827
+ });
750
2828
  }
751
2829
  }
752
2830
  }
@@ -755,7 +2833,7 @@ export class RuleChain {
755
2833
  const arr = [...transformed];
756
2834
  transformed = arr;
757
2835
  for (let i = 0; i < arr.length; i++) {
758
- const itemResult = this.#arrayItemChain._validateWithTransform(`${field}.${i}`, arr[i], { ...ctx, parent: arr });
2836
+ const itemResult = this.#arrayItemChain._validateWithTransform(`${field}.${i}`, arr[i], { ...ctx, parent: arr }, pending);
759
2837
  for (const e of itemResult.errors) {
760
2838
  if (e.index === undefined)
761
2839
  e.index = i;
@@ -766,25 +2844,66 @@ export class RuleChain {
766
2844
  }
767
2845
  }
768
2846
  }
2847
+ // 6. Record this chain's async rules for the async path to await. Mirrors
2848
+ // Lucid skipping a DB rule on an already-invalid or absent field: only a
2849
+ // clean, present value is worth a round-trip.
2850
+ if (pending &&
2851
+ this.#asyncRules.length > 0 &&
2852
+ errors.length === 0 &&
2853
+ transformed !== undefined &&
2854
+ transformed !== null) {
2855
+ pending.push({ chain: this, field, value: transformed, ctx });
2856
+ }
769
2857
  return { errors, transformed };
770
2858
  }
771
2859
  /** Run `.use()` rules on the transformed value with a fresh FieldContext. */
772
2860
  #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
- };
2861
+ // Set per iteration so `report` can substitute the `.message()` override of
2862
+ // the rule currently running — these rules carry their text inside `run`.
2863
+ let override;
2864
+ let current = transformed;
2865
+ const fieldCtx = this.#makeFieldContext(field, transformed, ctx, errors, (next) => {
2866
+ current = next;
2867
+ fieldCtx.value = next;
2868
+ });
2869
+ const report = fieldCtx.report.bind(fieldCtx);
2870
+ fieldCtx.report = (message, rule, reportedField, args) => report(override ?? message, rule, reportedField, args);
784
2871
  for (const rule of this.#useRules) {
2872
+ // A non-implicit rule is skipped on an absent value (VineJS semantics);
2873
+ // `implicit: true` is what lets a custom rule police undefined/null.
2874
+ if (!rule.implicit && (current === undefined || current === null))
2875
+ continue;
2876
+ fieldCtx.isValid = errors.length === 0;
2877
+ fieldCtx.isDefined = current !== undefined && current !== null;
2878
+ override = this.#ruleMessages.get(rule);
2879
+ rule.run(current, fieldCtx);
2880
+ }
2881
+ return current;
2882
+ }
2883
+ /**
2884
+ * Run this chain's async rules on the (already sync-validated) value, awaiting
2885
+ * each in order. Returns the errors they reported. Used by `validateResultAsync`.
2886
+ * @internal
2887
+ */
2888
+ async _runAsyncRules(field, transformed, ctx) {
2889
+ const errors = [];
2890
+ let override;
2891
+ let current = transformed;
2892
+ const fieldCtx = this.#makeFieldContext(field, transformed, ctx, errors, (next) => {
2893
+ current = next;
2894
+ fieldCtx.value = next;
2895
+ });
2896
+ const report = fieldCtx.report.bind(fieldCtx);
2897
+ fieldCtx.report = (message, rule, reportedField, args) => report(override ?? message, rule, reportedField, args);
2898
+ for (const rule of this.#asyncRules) {
2899
+ if (!rule.implicit && (current === undefined || current === null))
2900
+ continue;
785
2901
  fieldCtx.isValid = errors.length === 0;
786
- rule.run(transformed, fieldCtx);
2902
+ fieldCtx.isDefined = current !== undefined && current !== null;
2903
+ override = this.#ruleMessages.get(rule);
2904
+ await rule.run(current, fieldCtx);
787
2905
  }
2906
+ return errors;
788
2907
  }
789
2908
  #requiredError(field, ctx) {
790
2909
  return {
@@ -795,8 +2914,11 @@ export class RuleChain {
795
2914
  }
796
2915
  /** Run the type rules (string/number/…) on the raw value; first failure bails. */
797
2916
  #runTypeRules(field, value, ctx) {
2917
+ let context;
2918
+ const fieldContext = () => (context ??= this.#makeFieldContext(field, value, ctx, [], () => { }));
798
2919
  for (const rule of this.#rules) {
799
- if (TYPE_RULE_NAMES.has(rule.name) && !rule.validate(value)) {
2920
+ if (TYPE_RULE_NAMES.has(rule.name) &&
2921
+ !rule.validate(value, fieldContext())) {
800
2922
  return {
801
2923
  field,
802
2924
  rule: rule.name,
@@ -810,12 +2932,16 @@ export class RuleChain {
810
2932
  /** Run the non-type rules (min/max/email/…) on the transformed value. */
811
2933
  #runValueRules(field, transformed, ctx) {
812
2934
  const errors = [];
2935
+ // Built once and only if a rule actually reads it: most rules take the
2936
+ // value alone, and a context per rule per field is pure waste.
2937
+ let context;
2938
+ const fieldContext = () => (context ??= this.#makeFieldContext(field, transformed, ctx, [], () => { }));
813
2939
  for (const rule of this.#rules) {
814
2940
  if (TYPE_RULE_NAMES.has(rule.name))
815
2941
  continue;
816
2942
  if (this.#bail && errors.length > 0)
817
2943
  break;
818
- if (!rule.validate(transformed)) {
2944
+ if (!rule.validate(transformed, fieldContext())) {
819
2945
  errors.push({
820
2946
  field,
821
2947
  rule: rule.name,
@@ -861,6 +2987,13 @@ const LAST_FIELD = {
861
2987
  field: "",
862
2988
  meta: {},
863
2989
  isValid: true,
2990
+ name: "",
2991
+ wildCardPath: "",
2992
+ isArrayMember: false,
2993
+ isDefined: false,
2994
+ isValidDataType: true,
2995
+ getFieldPath: () => "",
2996
+ mutate: () => { },
864
2997
  report() { },
865
2998
  };
866
2999
  /** Coerce a value to a comparable primitive for `in`/`enum` membership. */
@@ -938,16 +3071,83 @@ function evalRequiredCondition(cond, ctx) {
938
3071
  return false;
939
3072
  }
940
3073
  }
941
- /** No-op alias of {@link schema} — VineJS `vine.compile()` API parity. */
942
- export function compile(s) {
943
- return s;
3074
+ export function compile(input) {
3075
+ // A rune schema is already compiled, so this is identity for that form; the
3076
+ // `RuleChain` form exists because `vine.compile(vine.object({…}))` is the
3077
+ // shape Adonis documents.
3078
+ return input instanceof RuleChain ? schema(toFieldMap(input), input) : input;
944
3079
  }
945
3080
  /** Entry point for building rules. */
946
3081
  export const rules = {
947
3082
  string: () => new RuleChain().string(),
948
- number: () => new RuleChain().number(),
949
- boolean: () => new RuleChain().boolean(),
3083
+ number: (options) => new RuleChain().number(options),
3084
+ boolean: (options) => new RuleChain().boolean(options),
950
3085
  any: () => new RuleChain(),
3086
+ date: (options) => new RuleChain().date(options),
3087
+ accepted: () => new RuleChain().accepted(),
3088
+ file: (options) => new RuleChain().file(options),
3089
+ nativeFile: (options) => new RuleChain().nativeFile(options),
3090
+ record: (valueChain) => new RuleChain().record(valueChain),
3091
+ tuple: (items) => new RuleChain().tuple(items),
3092
+ union: Object.assign((chains) => new RuleChain().union(chains),
3093
+ // `otherwise` is VineJS's spelling of the fallback branch; `else` stays
3094
+ // because it reads better in some call styles.
3095
+ { if: unionIf, else: unionElse, otherwise: unionElse }),
3096
+ /**
3097
+ * Union discriminated by the value's TYPE (VineJS `unionOfTypes`): the first
3098
+ * branch whose own type rule accepts the value wins.
3099
+ */
3100
+ /**
3101
+ * Make every property of a shape optional (VineJS `vine.helpers.optional`).
3102
+ * A properties TRANSFORMER, like `pick`/`omit` — it returns a record to
3103
+ * spread, not a schema.
3104
+ */
3105
+ /**
3106
+ * A field that must be ABSENT (VineJS `vine.optional()` → `VineOptional`,
3107
+ * `builder.d.ts:135`). Mostly a `unionOfTypes` branch. Distinct from
3108
+ * `.optional()` on a chain, which relaxes an existing type — this one IS the
3109
+ * type. The properties transformer that used to squat this name moved to
3110
+ * `helpers.optional`, where VineJS keeps it.
3111
+ */
3112
+ optional: () => {
3113
+ const chain = new RuleChain();
3114
+ chain.pushTypeRule({
3115
+ name: "optionalType",
3116
+ validate: (v) => v === undefined,
3117
+ message: "Must not be provided",
3118
+ });
3119
+ return chain.optional().retypeTo();
3120
+ },
3121
+ /** A field that must be `null` (VineJS `vine.null()` → `VineNull`). */
3122
+ null: () => {
3123
+ const chain = new RuleChain();
3124
+ chain.pushTypeRule({
3125
+ name: "nullType",
3126
+ validate: (v) => v === null,
3127
+ message: "Must be null",
3128
+ });
3129
+ return chain.nullable().retypeTo();
3130
+ },
3131
+ unionOfTypes: (chains) => {
3132
+ // VineJS requires DISTINCT types: two branches claiming the same type make
3133
+ // the discrimination meaningless, and the second would be dead code.
3134
+ const seen = new Set();
3135
+ for (const chain of chains) {
3136
+ const typeRule = chain.rules.find((rule) => TYPE_RULE_NAMES.has(rule.name));
3137
+ const name = typeRule?.name;
3138
+ if (name === undefined) {
3139
+ throw new RuneError("NO_TYPE_RULE", "unionOfTypes() needs every branch to declare a type (string/number/…).", { hint: "Use union([...]) for predicate-based branches." });
3140
+ }
3141
+ if (seen.has(name)) {
3142
+ 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([...])." });
3143
+ }
3144
+ seen.add(name);
3145
+ }
3146
+ return new RuleChain().union(chains.map((chain) => {
3147
+ const typeRule = chain.rules.find((rule) => TYPE_RULE_NAMES.has(rule.name));
3148
+ return unionIf((value, field) => typeRule?.validate(value, field) === true, chain);
3149
+ }));
3150
+ },
951
3151
  object: (shape) => new RuleChain().object(shape),
952
3152
  array: (item) => new RuleChain().array(item),
953
3153
  enum: (values) => new RuleChain().enum(values),
@@ -967,6 +3167,8 @@ function validateWithRust(fields, data) {
967
3167
  rules: ruleDescs,
968
3168
  optional: chain.isOptionalField,
969
3169
  transforms: chain.transforms.map((t) => t.name),
3170
+ // Sent explicitly so the Rust engine and the TS path agree on bail.
3171
+ bail: chain.bails,
970
3172
  };
971
3173
  }
972
3174
  const request = JSON.stringify({ schema: schemaDesc, data });