@c9up/rune 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/MessagesProvider.d.ts +45 -0
  2. package/dist/MessagesProvider.d.ts.map +1 -0
  3. package/dist/MessagesProvider.js +77 -0
  4. package/dist/MessagesProvider.js.map +1 -0
  5. package/dist/Schema.d.ts +912 -38
  6. package/dist/Schema.d.ts.map +1 -1
  7. package/dist/Schema.js +2841 -219
  8. package/dist/Schema.js.map +1 -1
  9. package/dist/date.d.ts +36 -0
  10. package/dist/date.d.ts.map +1 -0
  11. package/dist/date.js +275 -0
  12. package/dist/date.js.map +1 -0
  13. package/dist/errors.d.ts +42 -0
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +37 -0
  16. package/dist/errors.js.map +1 -1
  17. package/dist/formats.d.ts +149 -0
  18. package/dist/formats.d.ts.map +1 -0
  19. package/dist/formats.js +612 -0
  20. package/dist/formats.js.map +1 -0
  21. package/dist/index.d.ts +152 -3
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +181 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/magic.d.ts +30 -0
  26. package/dist/magic.d.ts.map +1 -0
  27. package/dist/magic.js +154 -0
  28. package/dist/magic.js.map +1 -0
  29. package/dist/types.d.ts +15 -0
  30. package/dist/types.d.ts.map +1 -0
  31. package/dist/types.js +12 -0
  32. package/dist/types.js.map +1 -0
  33. package/index.darwin-arm64.node +0 -0
  34. package/index.darwin-x64.node +0 -0
  35. package/index.linux-arm64-gnu.node +0 -0
  36. package/index.linux-x64-gnu.node +0 -0
  37. package/index.win32-x64-msvc.node +0 -0
  38. package/package.json +11 -1
  39. package/src/MessagesProvider.ts +115 -0
  40. package/src/Schema.ts +3970 -215
  41. package/src/date.ts +320 -0
  42. package/src/errors.ts +59 -0
  43. package/src/formats.ts +721 -0
  44. package/src/index.ts +266 -1
  45. package/src/magic.ts +181 -0
  46. package/src/types.ts +55 -0
package/dist/Schema.js CHANGED
@@ -3,23 +3,523 @@
3
3
  *
4
4
  * @implements FR38, FR39, FR40, FR41
5
5
  */
6
- import { RuneError } from "./errors.js";
6
+ var _a;
7
+ import { parseDateValue, resolveOperand, truncateTo, } from "./date.js";
8
+ import { RuneError, RuneValidationError } from "./errors.js";
9
+ import { alphaPattern, escapeHtml, isAscii, isCoordinates, isCreditCard, isEmail, isHexCode, isIban, isIpAddress, isJwt, isMobile, isMobileForLocale, isPassport, isPostalCode, isUlid, isUrlWithOptions, isVat, normalizeEmail, normalizeUrl, SUPPORTED_MOBILE_LOCALES, SUPPORTED_PASSPORTS, SUPPORTED_POSTAL_CODES, SUPPORTED_VAT_COUNTRIES, toCamelCase, } from "./formats.js";
10
+ import { toWildcardPath } from "./MessagesProvider.js";
11
+ import { detectFileType, extensionMatches, MAGIC_HEAD_BYTES, readHead, } from "./magic.js";
7
12
  import { isNativeAvailable, validateNative, warnNativeUnavailableOnce, } from "./native.js";
8
- export function createRule(validator) {
13
+ export function createRule(validator, ruleOptions) {
14
+ if (ruleOptions?.isAsync) {
15
+ // VineJS expresses "async" as an option on createRule, so honour it by
16
+ // BUILDING the async rule rather than refusing: `.use()` routes an
17
+ // async-marked rule to the awaited register.
18
+ const asyncBuilder = createAsyncRule(validator, { ...ruleOptions, isAsync: undefined });
19
+ return asyncBuilder;
20
+ }
9
21
  return (options) => ({
10
22
  __rune: "rule",
23
+ implicit: ruleOptions?.implicit ?? false,
24
+ name: ruleOptions?.name,
25
+ toJSONSchema: ruleOptions?.toJSONSchema,
26
+ ruleOptions: options,
11
27
  run(value, field) {
12
28
  validator(value, options, field);
13
29
  },
14
30
  });
15
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
+ }
16
496
  /** Default context for internal callers that don't supply one (no root available). */
17
497
  const EMPTY_RUN_CONTEXT = { data: {}, parent: {}, meta: {} };
18
498
  /** Type guard: narrows `unknown` to a plain object (non-null, non-array, typeof 'object'). */
19
499
  function isPlainObject(value) {
20
500
  return typeof value === "object" && value !== null && !Array.isArray(value);
21
501
  }
22
- /** Rules the Rust validation engine can handle natively. */
502
+ /**
503
+ * Rules the Rust validation engine (`crates/rune-engine/src/engine.rs`)
504
+ * ACTUALLY implements. A schema built from only these rules can be validated
505
+ * natively; anything else routes to the TS path (`rule.validate`).
506
+ *
507
+ * CRITICAL: a rule name here that the Rust engine does not implement is a
508
+ * SILENT VALIDATION BYPASS — the engine's `_ => {}` arm skips unknown rules, so
509
+ * the constraint never runs. Every entry MUST have a matching arm in engine.rs.
510
+ * The TS chain rules (minLength/uuid/alpha/in/enum/range/…) live only in the TS
511
+ * validator, so they are deliberately absent here.
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
+ */
23
523
  const STANDARD_RULES = new Set([
24
524
  "string",
25
525
  "number",
@@ -28,8 +528,53 @@ const STANDARD_RULES = new Set([
28
528
  "max",
29
529
  "email",
30
530
  "positive",
531
+ "minLength",
532
+ "maxLength",
533
+ "fixedLength",
534
+ "uuid",
535
+ "alpha",
536
+ "alphaNumeric",
537
+ "startsWith",
538
+ "endsWith",
539
+ "in",
540
+ "notIn",
541
+ "enum",
542
+ "negative",
543
+ "nonNegative",
544
+ "range",
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",
31
576
  ]);
32
- /** Default messages for standard rules — used to detect custom-message overrides. */
577
+ /** Default messages for standard rules — used only for translator-key fallback. */
33
578
  const STANDARD_MSGS = {
34
579
  string: "Must be a string",
35
580
  number: "Must be a number",
@@ -38,6 +583,20 @@ const STANDARD_MSGS = {
38
583
  max: "Maximum",
39
584
  email: "Must be a valid email",
40
585
  positive: "Must be positive",
586
+ minLength: "Too short",
587
+ maxLength: "Too long",
588
+ fixedLength: "Wrong length",
589
+ alpha: "Must contain only letters",
590
+ alphaNumeric: "Must contain only letters and numbers",
591
+ startsWith: "Invalid prefix",
592
+ endsWith: "Invalid suffix",
593
+ uuid: "Must be a valid UUID",
594
+ in: "Invalid value",
595
+ notIn: "Invalid value",
596
+ enum: "Invalid value",
597
+ range: "Out of range",
598
+ negative: "Must be negative",
599
+ nonNegative: "Must be positive or zero",
41
600
  };
42
601
  const TYPE_RULE_NAMES = new Set([
43
602
  "string",
@@ -45,17 +604,13 @@ const TYPE_RULE_NAMES = new Set([
45
604
  "boolean",
46
605
  "object",
47
606
  "array",
607
+ // `optional()` / `null()` are schema TYPES in VineJS, not modifiers.
608
+ "optionalType",
609
+ "nullType",
48
610
  ]);
49
611
  let validationTranslator;
50
- function hasDefaultMessage(rule) {
51
- if (rule.name === "min" || rule.name === "max") {
52
- if (typeof rule.param !== "number")
53
- return false;
54
- const expected = `${rule.name === "min" ? "Minimum" : "Maximum"} ${rule.param}`;
55
- return rule.message === expected;
56
- }
57
- const defaultMsg = STANDARD_MSGS[rule.name];
58
- return defaultMsg !== undefined && rule.message === defaultMsg;
612
+ function hasCustomMessage(rule) {
613
+ return rule.hasCustomMessage === true;
59
614
  }
60
615
  function resolveValidationMessage(key, fallback, params) {
61
616
  const translated = validationTranslator?.(key, params);
@@ -64,104 +619,355 @@ function resolveValidationMessage(key, fallback, params) {
64
619
  }
65
620
  return fallback;
66
621
  }
67
- function resolveRuleMessage(field, rule) {
68
- const fallback = rule.message;
69
- if (!STANDARD_RULES.has(rule.name)) {
70
- return fallback;
622
+ /** Args carried on a rule, exposed to i18n/providers via message interpolation. */
623
+ function ruleArgs(rule) {
624
+ return rule.args;
625
+ }
626
+ /**
627
+ * Resolve the final message for a failing rule. Precedence:
628
+ * 1. explicit `.message()` override (always wins),
629
+ * 2. a per-call {@link MessagesProviderContract} (VineJS parity),
630
+ * 3. a globally bound translator (rune's rosetta superset),
631
+ * 4. the rule's raw default message.
632
+ */
633
+ function resolveRuleMessage(field, rule, ctx) {
634
+ if (hasCustomMessage(rule)) {
635
+ return rule.message;
636
+ }
637
+ const args = ruleArgs(rule);
638
+ if (ctx.messagesProvider) {
639
+ return ctx.messagesProvider.getMessage(rule.message, rule.name, field, args);
71
640
  }
72
- if (!hasDefaultMessage(rule)) {
73
- return fallback;
641
+ if (!STANDARD_RULES.has(rule.name)) {
642
+ return rule.message;
74
643
  }
75
644
  const params = { field };
76
- if (rule.name === "min" && typeof rule.param === "number") {
77
- params.min = rule.param;
645
+ if (typeof rule.param === "number") {
646
+ if (rule.name === "min" || rule.name === "minLength")
647
+ params.min = rule.param;
648
+ if (rule.name === "max" || rule.name === "maxLength")
649
+ params.max = rule.param;
78
650
  }
79
- if (rule.name === "max" && typeof rule.param === "number") {
80
- params.max = rule.param;
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);
655
+ }
656
+ /** Resolve the "required" message through provider → translator → fallback. */
657
+ function resolveRequiredMessage(field, ctx) {
658
+ if (ctx.messagesProvider) {
659
+ return ctx.messagesProvider.getMessage(`${field} is required`, "required", field);
81
660
  }
82
- return resolveValidationMessage(`validation.${rule.name}`, fallback, params);
661
+ return resolveValidationMessage("validation.required", `${field} is required`, { field });
83
662
  }
84
663
  /** Compute once: does any field rule prevent dispatching to Rust? */
85
664
  function detectHasCustomRules(fields) {
86
665
  return Object.values(fields).some((chain) => {
87
666
  if (chain.useRules.length > 0)
88
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
670
+ if (chain.hasConditionalRequired)
671
+ return true; // requiredWhen — TS-only
672
+ if (chain.preTransforms.length > 0)
673
+ return true; // .parse() — TS-only
674
+ if (chain.transforms.length > 0)
675
+ return true; // .transform() — Rust gets only the NAME, can't run a JS fn
676
+ if (chain.isNullable)
677
+ return true; // .nullable() — the flag is not sent to the Rust engine
89
678
  return chain.rules.some((r) => {
90
- if (!STANDARD_RULES.has(r.name))
91
- return true; // custom rule
92
- if (!hasDefaultMessage(r))
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
683
+ if (hasCustomMessage(r))
93
684
  return true; // custom message
94
685
  return false;
95
686
  });
96
687
  });
97
688
  }
98
- /**
99
- * Create a validation schema.
100
- *
101
- * Pass `T` explicitly when the caller wants `result.data` typed as a concrete
102
- * shape after `result.valid === true` narrows the union runtime validation
103
- * is unchanged, the generic only types the success branch.
104
- *
105
- * const RegisterValidator = schema<{ email: string; password: string }>({
106
- * email: rules.string().email(),
107
- * password: rules.string().min(8),
108
- * });
109
- *
110
- * The default `Record<string, unknown>` matches the historical untyped surface
111
- * so existing call sites that read `result.data` field-by-field with their
112
- * own narrowing continue to compile.
113
- */
114
- 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;
115
696
  // Computed once at construction time, not per validate() call.
116
697
  const hasCustomRules = detectHasCustomRules(fields);
117
- return {
118
- fields,
119
- validate(data, options) {
120
- if (!isPlainObject(data)) {
121
- return {
122
- valid: false,
123
- errors: [
124
- {
125
- field: "_root",
126
- rule: "type",
127
- message: "Input must be an object",
128
- },
129
- ],
130
- };
131
- }
132
- if (!hasCustomRules && !validationTranslator) {
133
- if (isNativeAvailable()) {
134
- return validateWithRust(fields, data);
135
- }
136
- // This schema would have used the native engine, but it isn't
137
- // loaded — surface the platform-dependent TS fallback once instead
138
- // of diverging silently. (Schemas with custom rules / a translator
139
- // always run on TS by design and don't warn.)
140
- warnNativeUnavailableOnce();
141
- }
142
- const errors = [];
143
- const validated = {};
144
- // Root context: `data` is the root, `parent` of a top-level field is the
145
- // root too; nested/array recursion narrows `parent` as it descends.
146
- const rootCtx = {
147
- data,
148
- parent: data,
149
- meta: options?.meta ?? {},
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
+ }
708
+ if (!isPlainObject(data)) {
709
+ return {
710
+ valid: false,
711
+ errors: [
712
+ { field: "_root", rule: "type", message: "Input must be an object" },
713
+ ],
150
714
  };
151
- for (const [field, chain] of Object.entries(fields)) {
152
- const value = data[field];
153
- const result = chain._validateWithTransform(field, value, rootCtx);
154
- errors.push(...result.errors);
155
- if (result.errors.length === 0 && value !== undefined) {
156
- validated[field] = result.transformed;
715
+ }
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;
719
+ if (!hasCustomRules && !validationTranslator && !provider) {
720
+ if (isNativeAvailable()) {
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);
157
732
  }
733
+ reporterError = nativeReporter?.createError;
734
+ return native;
158
735
  }
159
- if (errors.length === 0) {
160
- return { valid: true, errors, data: validated };
736
+ // This schema would have used the native engine, but it isn't loaded —
737
+ // surface the platform-dependent TS fallback once instead of diverging
738
+ // silently.
739
+ warnNativeUnavailableOnce();
740
+ }
741
+ const errors = [];
742
+ const validated = {};
743
+ const rootCtx = {
744
+ data,
745
+ parent: data,
746
+ meta: options?.meta ?? {},
747
+ errorReporter: options?.errorReporter,
748
+ messagesProvider: provider,
749
+ };
750
+ for (const [field, chain] of Object.entries(fields)) {
751
+ const value = data[field];
752
+ const result = chain._validateWithTransform(field, value, rootCtx);
753
+ errors.push(...result.errors);
754
+ // Gate on the TRANSFORMED result, not the raw input: a pre-transform
755
+ // (`parse(() => 42)`) can produce a value for an absent field, and that
756
+ // value must land in `data` — testing the raw `value` dropped it.
757
+ if (result.errors.length === 0 && result.transformed !== undefined) {
758
+ validated[field] = result.transformed;
161
759
  }
162
- return { valid: false, errors };
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;
770
+ if (errors.length === 0) {
771
+ return { valid: true, errors, data: validated };
772
+ }
773
+ return { valid: false, errors };
774
+ }
775
+ function validateOrThrow(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);
857
+ if (result.valid) {
858
+ return result.data;
859
+ }
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);
893
+ }
894
+ /**
895
+ * Standard Schema v1 (`~standard`), the vendor-neutral contract VineJS also
896
+ * implements — lets a consumer validate without knowing it holds a rune
897
+ * schema.
898
+ */
899
+ const standard = {
900
+ version: 1,
901
+ vendor: "rune",
902
+ /**
903
+ * Standard JSON Schema v1 (`~standard.jsonSchema`), added by VineJS 4.3.
904
+ * `input` describes what may be sent, `output` what validation returns.
905
+ */
906
+ jsonSchema: {
907
+ input: () => toJSONSchema(),
908
+ output: () => toJSONSchema(),
163
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
+ }),
164
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;
958
+ }
959
+ /** Map an internal {@link ValidationError} to a {@link RuneErrorNode}. */
960
+ function toErrorNode(error) {
961
+ const node = {
962
+ message: error.message,
963
+ rule: error.rule,
964
+ field: error.field,
965
+ };
966
+ if (error.index !== undefined)
967
+ node.index = error.index;
968
+ if (error.meta !== undefined)
969
+ node.meta = error.meta;
970
+ return node;
165
971
  }
166
972
  export function setValidationTranslator(translator) {
167
973
  validationTranslator = translator;
@@ -169,130 +975,1396 @@ export function setValidationTranslator(translator) {
169
975
  export function bindRosetta(rosetta) {
170
976
  setValidationTranslator((key, params) => rosetta.t(key, params));
171
977
  }
172
- /** Rule chainfluent validation builder. */
978
+ /** UUID (any version/variant) identical to the Rust engine's pattern. */
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;
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
+ }
173
987
  export class RuleChain {
174
988
  #rules = [];
175
989
  #isOptional = false;
990
+ #isNullable = 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;
176
998
  #transforms = [];
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;
177
1009
  #nestedSchema = null;
178
1010
  #arrayItemChain = null;
1011
+ #allowUnknown = false;
1012
+ #metadata = null;
1013
+ /** Extensions / MIME types declared by `file()` / `mimeTypes()`. */
1014
+ #declaredExtnames = null;
1015
+ #declaredMimeTypes = null;
1016
+ /** `true` once the content-verification rule has been registered. */
1017
+ #contentVerified = false;
1018
+ /** Set by `{ verifyContent: false }` — an explicit, auditable opt-out. */
1019
+ #contentVerificationOff = false;
1020
+ #camelCaseKeys = false;
1021
+ #groups = [];
1022
+ #recordValueChain = null;
1023
+ #tupleChains = null;
1024
+ #unionChains = null;
179
1025
  #useRules = [];
1026
+ #asyncRules = [];
1027
+ /** Last rule added, whichever register it landed in — the `message()` target. */
1028
+ #lastRule = null;
1029
+ /** `.message()` overrides for rules that report their own text from `run`. */
1030
+ #ruleMessages = new Map();
1031
+ #requiredConditions = [];
180
1032
  /** Public read access to rules (for OpenAPI generation, Rust bridge). */
181
1033
  get rules() {
182
1034
  return this.#rules;
183
1035
  }
184
- get isOptionalField() {
185
- return this.#isOptional;
1036
+ get isOptionalField() {
1037
+ return this.#isOptional;
1038
+ }
1039
+ /** Public read access to the `.nullable()` flag (keeps such schemas off the native path). */
1040
+ get isNullable() {
1041
+ return this.#isNullable;
1042
+ }
1043
+ get transforms() {
1044
+ return this.#transforms;
1045
+ }
1046
+ /** Public read access to `.use()` rules (used to keep such schemas off the native path). */
1047
+ get useRules() {
1048
+ return this.#useRules;
1049
+ }
1050
+ /** Public read access to async rules (`unique`/`exists`/`useAsync`) — run by `validateResultAsync`. */
1051
+ get asyncRules() {
1052
+ return this.#asyncRules;
1053
+ }
1054
+ /**
1055
+ * Does this chain — or anything nested under it (object fields, array items) —
1056
+ * carry async rules? The schema-level detection used to inspect only the
1057
+ * top-level chains, so a nested `unique`/`exists` was invisible: `validate()`
1058
+ * did not throw and the async pass never ran the rule, silently accepting
1059
+ * an unchecked value.
1060
+ */
1061
+ get hasAsyncRulesDeep() {
1062
+ if (this.#asyncRules.length > 0)
1063
+ return true;
1064
+ if (this.#nestedSchema) {
1065
+ for (const chain of Object.values(this.#nestedSchema)) {
1066
+ if (chain.hasAsyncRulesDeep)
1067
+ return true;
1068
+ }
1069
+ }
1070
+ if (this.#arrayItemChain?.hasAsyncRulesDeep)
1071
+ return true;
1072
+ if (this.#recordValueChain?.hasAsyncRulesDeep)
1073
+ return true;
1074
+ for (const chain of [
1075
+ ...(this.#tupleChains ?? []),
1076
+ ...(this.#unionChains ?? []).map((b) => b.chain),
1077
+ ]) {
1078
+ if (chain.hasAsyncRulesDeep)
1079
+ return true;
1080
+ }
1081
+ return false;
1082
+ }
1083
+ /** Does this object keep keys its shape does not declare? */
1084
+ get allowsUnknown() {
1085
+ return this.#allowUnknown;
1086
+ }
1087
+ /** Free-form JSON Schema metadata attached with `meta()`. */
1088
+ get metadata() {
1089
+ return this.#metadata;
1090
+ }
1091
+ /** The item chain of an `array()`, if declared. */
1092
+ get arrayItem() {
1093
+ return this.#arrayItemChain;
1094
+ }
1095
+ /** The positional chains of a `tuple()`, if declared. */
1096
+ get tupleItems() {
1097
+ return this.#tupleChains;
1098
+ }
1099
+ /** The value chain of a `record()`, if declared. */
1100
+ get recordValue() {
1101
+ return this.#recordValueChain;
1102
+ }
1103
+ /** Whether this chain stops at its first failing rule (VineJS `bail`). */
1104
+ get bails() {
1105
+ return this.#bail;
1106
+ }
1107
+ /** Public read access to `.parse()` pre-transforms (kept off the native path). */
1108
+ get preTransforms() {
1109
+ return this.#preTransforms;
1110
+ }
1111
+ /** Whether this chain carries a `requiredWhen`-family condition. */
1112
+ get hasConditionalRequired() {
1113
+ return this.#requiredConditions.length > 0;
1114
+ }
1115
+ /**
1116
+ * Re-type this chain to a new phantom output while carrying its runtime state
1117
+ * forward. Cast-free: `new RuleChain<U>()` is genuinely `RuleChain<U>` because
1118
+ * the brand is `declare`-only. State arrays are copied so the abandoned source
1119
+ * chain can't be mutated through the new one.
1120
+ */
1121
+ #retype() {
1122
+ const next = new _a();
1123
+ next.#rules = [...this.#rules];
1124
+ next.#isOptional = this.#isOptional;
1125
+ next.#isNullable = this.#isNullable;
1126
+ next.#bail = this.#bail;
1127
+ next.#transforms = [...this.#transforms];
1128
+ next.#dateFormats = this.#dateFormats;
1129
+ next.#coercions = [...this.#coercions];
1130
+ next.#allowUnknown = this.#allowUnknown;
1131
+ next.#metadata = this.#metadata ? { ...this.#metadata } : null;
1132
+ next.#declaredExtnames = this.#declaredExtnames;
1133
+ next.#declaredMimeTypes = this.#declaredMimeTypes;
1134
+ next.#contentVerified = this.#contentVerified;
1135
+ next.#contentVerificationOff = this.#contentVerificationOff;
1136
+ next.#camelCaseKeys = this.#camelCaseKeys;
1137
+ next.#groups = [...this.#groups];
1138
+ next.#recordValueChain = this.#recordValueChain;
1139
+ next.#tupleChains = this.#tupleChains;
1140
+ next.#unionChains = this.#unionChains;
1141
+ next.#ruleMessages = new Map(this.#ruleMessages);
1142
+ next.#lastRule = this.#lastRule;
1143
+ next.#preTransforms = [...this.#preTransforms];
1144
+ next.#nestedSchema = this.#nestedSchema;
1145
+ next.#arrayItemChain = this.#arrayItemChain;
1146
+ next.#useRules = [...this.#useRules];
1147
+ next.#asyncRules = [...this.#asyncRules];
1148
+ next.#requiredConditions = [...this.#requiredConditions];
1149
+ return next;
1150
+ }
1151
+ /** Mark field as optional (absent / `undefined` allowed). */
1152
+ optional() {
1153
+ this.#isOptional = true;
1154
+ return this;
1155
+ }
1156
+ /** Mark field as nullable (`null` allowed, kept in the output). */
1157
+ nullable() {
1158
+ this.#isNullable = true;
1159
+ return this;
1160
+ }
1161
+ /** Mark field as both optional and nullable. */
1162
+ nullish() {
1163
+ this.#isOptional = true;
1164
+ this.#isNullable = true;
1165
+ return this;
1166
+ }
1167
+ /** Stop at the first failing rule for this field (VineJS bail). */
1168
+ bail(enabled = true) {
1169
+ this.#bail = enabled;
1170
+ return this;
1171
+ }
1172
+ /** Must be an object matching a nested schema. */
1173
+ object(shape) {
1174
+ this.#pushRule({
1175
+ name: "object",
1176
+ validate: (v) => isPlainObject(v),
1177
+ message: "Must be an object",
1178
+ });
1179
+ this.#nestedSchema = shape;
1180
+ return this.#retype();
1181
+ }
1182
+ /** Must be an array. Items validated by the provided chain. */
1183
+ array(itemChain) {
1184
+ this.#pushRule({
1185
+ name: "array",
1186
+ validate: (v) => Array.isArray(v),
1187
+ message: "Must be an array",
1188
+ });
1189
+ this.#arrayItemChain = itemChain ?? null;
1190
+ return this.#retype();
1191
+ }
1192
+ /** Must be a string. */
1193
+ string() {
1194
+ this.#pushRule({
1195
+ name: "string",
1196
+ validate: (v) => typeof v === "string",
1197
+ message: "Must be a string",
1198
+ });
1199
+ return this.#retype();
1200
+ }
1201
+ /**
1202
+ * Must be a number. Like VineJS, a numeric STRING is coerced (`"32"` → `32`)
1203
+ * — HTML form bodies and query strings carry numbers as text, so requiring
1204
+ * `typeof v === "number"` rejected the values Adonis accepts. Pass
1205
+ * `{ strict: true }` to refuse anything that is not already a number.
1206
+ */
1207
+ number(options) {
1208
+ if (!options?.strict)
1209
+ this.#coercions.push(coerceNumber);
1210
+ this.#pushRule({
1211
+ name: "number",
1212
+ args: { strict: options?.strict === true },
1213
+ validate: (v) => typeof v === "number" && !Number.isNaN(v) && Number.isFinite(v),
1214
+ message: "Must be a number",
1215
+ });
1216
+ return this.#retype();
1217
+ }
1218
+ /**
1219
+ * Must be a boolean. Like VineJS, `"true"`, `"false"`, `"on"`, `"off"`,
1220
+ * `"1"`, `"0"`, `1` and `0` are coerced; `{ strict: true }` refuses them.
1221
+ */
1222
+ boolean(options) {
1223
+ if (!options?.strict)
1224
+ this.#coercions.push(coerceBoolean);
1225
+ this.#pushRule({
1226
+ name: "boolean",
1227
+ args: { strict: options?.strict === true },
1228
+ validate: (v) => typeof v === "boolean",
1229
+ message: "Must be a boolean",
1230
+ });
1231
+ return this.#retype();
1232
+ }
1233
+ /**
1234
+ * Must be a date (VineJS `vine.date()`). ISO 8601 by default; pass `formats`
1235
+ * for unix timestamps (`x` = ms, `X` = seconds) or a token format such as
1236
+ * `DD/MM/YYYY`. Parsing is calendar-strict — `2026-02-31` is rejected.
1237
+ *
1238
+ * The validated output is a `Date`; bind {@link setDateTransform} to map it
1239
+ * to your own type once at boot.
1240
+ */
1241
+ date(options) {
1242
+ const formats = options?.formats ?? ["iso8601"];
1243
+ this.#dateFormats = formats;
1244
+ this.#pushRule({
1245
+ name: "date",
1246
+ args: { formats },
1247
+ validate: (v) => parseDateValue(v, formats) !== null,
1248
+ message: "Must be a valid date",
1249
+ });
1250
+ // Parse to a real `Date` BEFORE the comparison rules run, so `after`/
1251
+ // `before` never re-parse and never compare strings lexicographically.
1252
+ this.#transforms.push({
1253
+ name: "date",
1254
+ fn: (value) => parseDateValue(value, formats) ?? value,
1255
+ });
1256
+ return this.#retype();
1257
+ }
1258
+ /** Must be strictly after `operand` (`'today'`, an ISO string, or a `Date`). */
1259
+ after(operand, options) {
1260
+ return this.#compareDate("after", operand, (a, b) => a > b, options);
1261
+ }
1262
+ /** Must be strictly before `operand`. */
1263
+ before(operand, options) {
1264
+ return this.#compareDate("before", operand, (a, b) => a < b, options);
1265
+ }
1266
+ /** Must be after `operand`, or equal to it. */
1267
+ afterOrEqual(operand, options) {
1268
+ return this.#compareDate("afterOrEqual", operand, (a, b) => a >= b, options);
1269
+ }
1270
+ /** Must be before `operand`, or equal to it. */
1271
+ beforeOrEqual(operand, options) {
1272
+ return this.#compareDate("beforeOrEqual", operand, (a, b) => a <= b, options);
1273
+ }
1274
+ /** Must be after the date held by a sibling field (VineJS `afterField`). */
1275
+ afterField(otherField, options) {
1276
+ return this.#compareDateField("afterField", otherField, options, (a, b) => a > b);
1277
+ }
1278
+ /** Must be before the date held by a sibling field. */
1279
+ beforeField(otherField, options) {
1280
+ return this.#compareDateField("beforeField", otherField, options, (a, b) => a < b);
1281
+ }
1282
+ /** Must be the same instant as `operand` (VineJS `equals`). */
1283
+ equals(operand, options) {
1284
+ return this.#compareDate("equals", operand, (a, b) => a === b, options);
1285
+ }
1286
+ /** Must be after the sibling's date, or the same instant (VineJS `afterOrSameAs`). */
1287
+ afterOrSameAs(otherField, options) {
1288
+ return this.#compareDateField("afterOrSameAs", otherField, options, (a, b) => a >= b);
1289
+ }
1290
+ /** Must be before the sibling's date, or the same instant. */
1291
+ beforeOrSameAs(otherField, options) {
1292
+ return this.#compareDateField("beforeOrSameAs", otherField, options, (a, b) => a <= b);
1293
+ }
1294
+ /** Must fall on a Saturday or Sunday (VineJS `weekend`). */
1295
+ weekend() {
1296
+ this.#pushRule({
1297
+ name: "weekend",
1298
+ validate: (v) => v instanceof Date && (v.getDay() === 0 || v.getDay() === 6),
1299
+ message: "Must be a weekend date",
1300
+ });
1301
+ return this;
1302
+ }
1303
+ /** Must fall on a Monday-to-Friday day (VineJS `weekday`). */
1304
+ weekday() {
1305
+ this.#pushRule({
1306
+ name: "weekday",
1307
+ validate: (v) => v instanceof Date && v.getDay() > 0 && v.getDay() < 6,
1308
+ message: "Must be a weekday date",
1309
+ });
1310
+ return this;
1311
+ }
1312
+ /** Shared body of the `after`/`before`/`*OrEqual` literal comparisons. */
1313
+ #compareDate(name, operand, cmp, options) {
1314
+ // VineJS: `options.compare || "day"`. A bare `after('today')` is about the
1315
+ // calendar date, not the clock — comparing exact timestamps made every
1316
+ // same-day value fail a rule the caller read as "today or later".
1317
+ const unit = options?.compare ?? "day";
1318
+ const formats = options?.format ? [options.format] : null;
1319
+ this.#pushRule({
1320
+ name,
1321
+ // A callable operand is resolved per validation, not once at build
1322
+ // time — otherwise `after(() => Date.now())` would freeze the boundary
1323
+ // at the moment the schema was declared (VineJS allows the callback).
1324
+ args: typeof operand === "function" ? undefined : { operand },
1325
+ validate: (v) => {
1326
+ const raw = typeof operand === "function"
1327
+ ? operand()
1328
+ : operand;
1329
+ const other = formats && typeof raw === "string"
1330
+ ? parseDateValue(raw, formats)
1331
+ : resolveOperand(raw);
1332
+ if (!(v instanceof Date) || other === null)
1333
+ return false;
1334
+ return cmp(truncateTo(v, unit), truncateTo(other, unit));
1335
+ },
1336
+ message: `Must be ${name.replace(/([A-Z])/g, " $1").toLowerCase()} ${String(operand)}`,
1337
+ });
1338
+ return this;
1339
+ }
1340
+ /** Shared body of the `afterField`/`beforeField` sibling comparisons. */
1341
+ #compareDateField(name, otherField, options, cmp) {
1342
+ const formats = options?.format
1343
+ ? [options.format]
1344
+ : (this.#dateFormats ?? ["iso8601"]);
1345
+ const unit = options?.compare ?? "day";
1346
+ this.#pushUse({
1347
+ __rune: "rule",
1348
+ run: (value, field) => {
1349
+ const other = parseDateValue(readSibling(field, otherField), formats);
1350
+ if (!(value instanceof Date) || other === null) {
1351
+ field.report(`Cannot compare with ${otherField}`, name);
1352
+ return;
1353
+ }
1354
+ if (!cmp(truncateTo(value, unit), truncateTo(other, unit))) {
1355
+ field.report(`Must be ${name.replace("Field", "")} ${otherField}`, name);
1356
+ }
1357
+ },
1358
+ });
1359
+ return this;
1360
+ }
1361
+ /**
1362
+ * Keep keys the object shape does not declare (VineJS
1363
+ * `allowUnknownProperties`). Off by default: dropping undeclared keys is what
1364
+ * makes a validated payload safe to hand to a mass assignment.
1365
+ */
1366
+ allowUnknownProperties() {
1367
+ this.#allowUnknown = true;
1368
+ return this;
1369
+ }
1370
+ /**
1371
+ * Convert the object's KEYS to camelCase in the output (VineJS
1372
+ * `object.toCamelCase()`), so a snake_case payload hydrates camelCase
1373
+ * properties. Distinct from the string `toCamelCase()`, which rewrites a
1374
+ * VALUE — that one was never a substitute for this.
1375
+ */
1376
+ toCamelCaseKeys() {
1377
+ return this.toCamelCase();
1378
+ }
1379
+ /**
1380
+ * Merge extra properties into this object's shape (VineJS `merge`). Accepts a
1381
+ * plain shape or a {@link ConditionalGroup} whose branch is chosen per
1382
+ * payload — `vine.group` in VineJS.
1383
+ */
1384
+ merge(extra) {
1385
+ if (!this.#nestedSchema) {
1386
+ throw new RuneError("NOT_AN_OBJECT", "merge() needs an object() shape to merge into.", { hint: "rules.any().object({ … }).merge({ … })" });
1387
+ }
1388
+ if (isConditionalGroup(extra)) {
1389
+ this.#groups.push(extra);
1390
+ return this;
1391
+ }
1392
+ this.#nestedSchema = { ...this.#nestedSchema, ...extra };
1393
+ return this;
1394
+ }
1395
+ /** The nested shape declared by `object()`, if any (VineJS `getProperties`). */
1396
+ getProperties() {
1397
+ // CLONE each chain, not just the map. A shallow copy shares the chain
1398
+ // instances, so mutating one through the copy relaxes the source schema —
1399
+ // the same trap that made `partial()` mutate its origin.
1400
+ if (!this.#nestedSchema)
1401
+ return null;
1402
+ return Object.fromEntries(Object.entries(this.#nestedSchema).map(([key, chain]) => [
1403
+ key,
1404
+ chain.clone(),
1405
+ ]));
1406
+ }
1407
+ /** Independent copy of this chain (VineJS `clone`). */
1408
+ clone() {
1409
+ return this.#retype();
1410
+ }
1411
+ /**
1412
+ * A CLONED subset of the object's properties (VineJS `pick`).
1413
+ *
1414
+ * Returns a properties record, not a schema — VineJS types it
1415
+ * `Pick<Properties, Keys>` precisely so it composes by spread:
1416
+ * `rules.any().object({ ...userShape.pick(["id"]) })`. Returning a chain here
1417
+ * broke that idiom.
1418
+ */
1419
+ pick(keys) {
1420
+ return this.#subsetOfProperties((key) => keys.includes(key));
1421
+ }
1422
+ /** A cloned copy of the properties EXCLUDING `keys` (VineJS `omit`). */
1423
+ omit(keys) {
1424
+ return this.#subsetOfProperties((key) => !keys.includes(key));
1425
+ }
1426
+ /** Shared body of `pick`/`omit` — clones so the source stays untouched. */
1427
+ #subsetOfProperties(keep) {
1428
+ const shape = this.getProperties();
1429
+ if (!shape) {
1430
+ throw new RuneError("NOT_AN_OBJECT", "pick()/omit() need an object() shape to work on.", { hint: "rules.any().object({ … }).pick([…])" });
1431
+ }
1432
+ return Object.fromEntries(Object.entries(shape).filter(([key]) => keep(key)));
1433
+ }
1434
+ /** Make every property of an object shape optional (VineJS `partial`). */
1435
+ partial(keys) {
1436
+ // `optional()` mutates and returns the SAME chain, so calling it on the
1437
+ // stored properties made the source shape optional too — `base.partial()`
1438
+ // silently relaxed `base`. Clone each property first, like VineJS does.
1439
+ return this.#reshape((shape) => Object.fromEntries(Object.entries(shape).map(([key, chain]) => [
1440
+ key,
1441
+ keys === undefined || keys.includes(key)
1442
+ ? chain.clone().optional()
1443
+ : chain,
1444
+ ])));
1445
+ }
1446
+ /** Shared body of `pick`/`omit`/`partial` — rebuilds the nested shape on a clone. */
1447
+ #reshape(transform) {
1448
+ if (!this.#nestedSchema) {
1449
+ throw new RuneError("NOT_AN_OBJECT", "pick()/omit()/partial() need an object() shape to work on.", {
1450
+ hint: "Declare the shape first: rules.any().object({ … }).pick([…])",
1451
+ });
1452
+ }
1453
+ const next = this.#retype();
1454
+ next.#nestedSchema = transform(this.#nestedSchema);
1455
+ return next;
1456
+ }
1457
+ /**
1458
+ * Must be an "accepted" value — `true`, `1`, `"1"`, `"on"`, `"yes"`,
1459
+ * `"true"` (VineJS `accepted`, for checkbox-style consent fields).
1460
+ */
1461
+ accepted() {
1462
+ this.#pushRule({
1463
+ name: "accepted",
1464
+ validate: isAcceptedValue,
1465
+ message: "Must be accepted",
1466
+ });
1467
+ // Normalise ONLY an accepted value: a blanket `() => true` would rewrite a
1468
+ // refused value into an accepted one before the rule ever saw it.
1469
+ this.#transforms.push({
1470
+ name: "accepted",
1471
+ fn: (value) => (isAcceptedValue(value) ? true : value),
1472
+ });
1473
+ return this.#retype();
1474
+ }
1475
+ /**
1476
+ * Object with arbitrary keys, every value validated by `valueChain`
1477
+ * (VineJS `record`).
1478
+ */
1479
+ record(valueChain) {
1480
+ this.#pushRule({
1481
+ name: "record",
1482
+ validate: (v) => isPlainObject(v),
1483
+ message: "Must be an object",
1484
+ });
1485
+ this.#recordValueChain = valueChain;
1486
+ return this.#retype();
1487
+ }
1488
+ /**
1489
+ * Fixed-length array with a schema per position (VineJS `tuple`). Extra
1490
+ * items are rejected — a tuple that silently ignores a trailing element is
1491
+ * how unvalidated data slips through.
1492
+ */
1493
+ tuple(items) {
1494
+ this.#pushRule({
1495
+ name: "tuple",
1496
+ args: { length: items.length },
1497
+ validate: (v) => Array.isArray(v) && v.length === items.length,
1498
+ message: `Must be an array of exactly ${items.length} items`,
1499
+ });
1500
+ this.#tupleChains = [...items];
1501
+ return this.#retype();
1502
+ }
1503
+ /**
1504
+ * Value must satisfy at least one of `chains`.
1505
+ *
1506
+ * Two forms, both supported:
1507
+ *
1508
+ * - guarded (VineJS parity): `union([rules.union.if(pred, chain), …,
1509
+ * rules.union.else(fallback)])` — the predicate SELECTS the branch and
1510
+ * that branch's own errors are reported, so a failure says which shape was
1511
+ * meant and why it did not fit.
1512
+ * - bare chains: tried in order, first match wins, and a total miss reports a
1513
+ * single `union` error rather than every losing branch's noise.
1514
+ */
1515
+ union(chains) {
1516
+ this.#unionChains = chains.map(toUnionBranch);
1517
+ // Marker rule: its name is not in NATIVE_RULES, which is what keeps a
1518
+ // union off the native path. The Rust engine knows nothing about branches
1519
+ // and would silently accept anything.
1520
+ this.#pushRule({
1521
+ name: "union",
1522
+ validate: () => true,
1523
+ message: "Does not match any allowed shape",
1524
+ });
1525
+ return this;
1526
+ }
1527
+ /**
1528
+ * Must be an uploaded file (VineJS/Adonis `vine.file()`).
1529
+ *
1530
+ * Named deviation: Adonis validates a bodyparser `MultipartFile`, which rune
1531
+ * cannot import and stay agnostic. It checks the STRUCTURE instead — any
1532
+ * object exposing `size` and a name/extension — so an Adonis MultipartFile
1533
+ * satisfies it, and so does any other upload representation.
1534
+ *
1535
+ * `size` is a byte count; `extnames` are compared lowercase, without the dot.
1536
+ */
1537
+ file(options) {
1538
+ // Adonis documents `size: '2mb'`; a numeric-only option meant a
1539
+ // transcribed validator either failed the typecheck or, in JS, silently
1540
+ // stopped capping.
1541
+ const maxBytes = options?.size === undefined ? undefined : parseByteSize(options.size);
1542
+ if (options?.extnames)
1543
+ this.#declaredExtnames = options.extnames;
1544
+ if (options?.verifyContent === false)
1545
+ this.#contentVerificationOff = true;
1546
+ this.#pushRule({
1547
+ name: "file",
1548
+ args: options ? { ...options } : undefined,
1549
+ validate: (v) => {
1550
+ if (!isFileLike(v))
1551
+ return false;
1552
+ if (maxBytes !== undefined && v.size > maxBytes)
1553
+ return false;
1554
+ if (options?.extnames) {
1555
+ const ext = fileExtension(v);
1556
+ if (ext === null)
1557
+ return false;
1558
+ if (!options.extnames.map((e) => e.toLowerCase()).includes(ext)) {
1559
+ return false;
1560
+ }
1561
+ }
1562
+ return true;
1563
+ },
1564
+ message: "Must be a valid file",
1565
+ });
1566
+ // Declaring an allowed extension list is a SECURITY statement, so the
1567
+ // bytes are checked by default. `{ verifyContent: false }` opts out
1568
+ // explicitly and leaves a trace in the schema.
1569
+ if (options?.extnames)
1570
+ this.#ensureContentVerification();
1571
+ return this.#retype();
1572
+ }
1573
+ /**
1574
+ * Uploaded file with VineJS `nativeFile` options — `minSize`, `maxSize`,
1575
+ * `mimeTypes`. Same structural contract as {@link file}: rune never reads
1576
+ * bytes, so the MIME type is the one the upload REPORTS.
1577
+ */
1578
+ nativeFile(options) {
1579
+ const min = options?.minSize === undefined
1580
+ ? undefined
1581
+ : parseByteSize(options.minSize);
1582
+ const max = options?.maxSize === undefined
1583
+ ? undefined
1584
+ : parseByteSize(options.maxSize);
1585
+ this.#pushRule({
1586
+ name: "nativeFile",
1587
+ args: options ? { ...options } : undefined,
1588
+ validate: (v) => {
1589
+ if (!isFileLike(v))
1590
+ return false;
1591
+ if (min !== undefined && v.size < min)
1592
+ return false;
1593
+ if (max !== undefined && v.size > max)
1594
+ return false;
1595
+ if (options?.mimeTypes) {
1596
+ const type = typeof v.type === "string" ? v.type.toLowerCase() : null;
1597
+ if (type === null)
1598
+ return false;
1599
+ if (!options.mimeTypes.map((m) => m.toLowerCase()).includes(type)) {
1600
+ return false;
1601
+ }
1602
+ }
1603
+ return true;
1604
+ },
1605
+ message: "Must be a valid file",
1606
+ });
1607
+ // Declaring allowed MIME types is a SECURITY statement, so the bytes are
1608
+ // checked by default.
1609
+ if (options?.mimeTypes)
1610
+ this.#ensureContentVerification();
1611
+ return this.#retype();
1612
+ }
1613
+ /** Minimum upload size (VineJS `nativeFile().minSize()`). */
1614
+ minSize(size) {
1615
+ const min = parseByteSize(size);
1616
+ this.#pushRule({
1617
+ name: "minSize",
1618
+ args: { size },
1619
+ validate: (v) => isFileLike(v) && v.size >= min,
1620
+ message: `Must be at least ${size} in size`,
1621
+ });
1622
+ return this;
1623
+ }
1624
+ /** Maximum upload size (VineJS `nativeFile().maxSize()`). */
1625
+ maxSize(size) {
1626
+ const max = parseByteSize(size);
1627
+ this.#pushRule({
1628
+ name: "maxSize",
1629
+ args: { size },
1630
+ validate: (v) => isFileLike(v) && v.size <= max,
1631
+ message: `Must be at most ${size} in size`,
1632
+ });
1633
+ return this;
1634
+ }
1635
+ /**
1636
+ * Allowed MIME types (VineJS `nativeFile().mimeTypes()`). The type is the one
1637
+ * the upload REPORTS — rune never reads bytes, see {@link file}.
1638
+ */
1639
+ mimeTypes(types) {
1640
+ const allowed = types.map((t) => t.toLowerCase());
1641
+ this.#declaredMimeTypes = allowed;
1642
+ this.#ensureContentVerification();
1643
+ this.#pushRule({
1644
+ name: "mimeTypes",
1645
+ args: { types: allowed },
1646
+ validate: (v) => isFileLike(v) &&
1647
+ typeof v.type === "string" &&
1648
+ allowed.includes(v.type.toLowerCase()),
1649
+ message: `Must be one of ${allowed.join(", ")}`,
1650
+ });
1651
+ return this;
1652
+ }
1653
+ /**
1654
+ * Verify the file's REAL type against its magic number (Adonis parity).
1655
+ *
1656
+ * A `.exe` renamed `.jpg` passes every declarative check — size, extension,
1657
+ * reported MIME — because all three come from the uploader. This reads the
1658
+ * leading bytes and refuses a mismatch.
1659
+ *
1660
+ * Async by nature (it touches the filesystem), so the schema must run with
1661
+ * `validateResultAsync` / `validate`. Needs a byte source on the file object
1662
+ * (`buffer`, `tmpPath`, `filePath` or `path`) — an Adonis `MultipartFile`
1663
+ * carries `tmpPath`. With NO source it FAILS: a content check that cannot
1664
+ * run must never look like one that passed.
1665
+ */
1666
+ verifyContent() {
1667
+ this.#contentVerificationOff = false;
1668
+ return this.#ensureContentVerification();
1669
+ }
1670
+ /** Register the content check once, honouring an explicit opt-out. */
1671
+ #ensureContentVerification() {
1672
+ if (this.#contentVerified || this.#contentVerificationOff)
1673
+ return this;
1674
+ this.#contentVerified = true;
1675
+ return this.#registerContentVerification();
1676
+ }
1677
+ /** The async rule itself — reads the bytes and confronts the declaration. */
1678
+ #registerContentVerification() {
1679
+ const extnames = this.#declaredExtnames;
1680
+ const mimeTypes = this.#declaredMimeTypes;
1681
+ this.#pushAsync({
1682
+ __rune: "asyncRule",
1683
+ async run(value, field) {
1684
+ if (!isFileLike(value)) {
1685
+ field.report("Must be a valid file", "verifyContent");
1686
+ return;
1687
+ }
1688
+ const head = await readFileHead(value);
1689
+ if (head === null) {
1690
+ field.report("Cannot read the file's content to verify its type", "verifyContent");
1691
+ return;
1692
+ }
1693
+ const detected = detectFileType(head);
1694
+ if (detected === null) {
1695
+ field.report("File type could not be recognised", "verifyContent");
1696
+ return;
1697
+ }
1698
+ // The declared extension must agree with the bytes.
1699
+ const declaredExt = typeof value.extname === "string" && value.extname.length > 0
1700
+ ? value.extname
1701
+ : null;
1702
+ if (declaredExt && !extensionMatches(detected.ext, declaredExt)) {
1703
+ field.report(`Content is ${detected.ext}, not ${declaredExt.replace(/^\./, "")}`, "verifyContent");
1704
+ return;
1705
+ }
1706
+ if (extnames &&
1707
+ !extnames.some((allowed) => extensionMatches(detected.ext, allowed))) {
1708
+ field.report(`Content is ${detected.ext}, which is not allowed`, "verifyContent");
1709
+ return;
1710
+ }
1711
+ if (mimeTypes && !mimeTypes.includes(detected.mime)) {
1712
+ field.report(`Content is ${detected.mime}, which is not allowed`, "verifyContent");
1713
+ }
1714
+ },
1715
+ });
1716
+ return this;
1717
+ }
1718
+ /** Must equal one of `values` (enum). Narrows the output to the union. */
1719
+ enum(values) {
1720
+ const allowed = [...values];
1721
+ this.#pushRule({
1722
+ name: "enum",
1723
+ args: { values: allowed },
1724
+ validate: (v) => allowed.includes(asPrimitive(v)),
1725
+ message: "Invalid value",
1726
+ });
1727
+ return this.#retype();
1728
+ }
1729
+ /** Must equal a literal value. */
1730
+ literal(value) {
1731
+ this.#pushRule({
1732
+ name: "literal",
1733
+ args: { value, expectedValue: value },
1734
+ validate: (v) => v === value,
1735
+ message: `Must be ${String(value)}`,
1736
+ });
1737
+ return this.#retype();
1738
+ }
1739
+ /** Minimum length (string) or minimum value (number). Alias of min/minLength. */
1740
+ min(n) {
1741
+ this.#pushRule({
1742
+ name: "min",
1743
+ param: n,
1744
+ args: { min: n },
1745
+ validate: (v) => typeof v === "string"
1746
+ ? [...v].length >= n
1747
+ : typeof v === "number"
1748
+ ? v >= n
1749
+ : false,
1750
+ message: `Minimum ${n}`,
1751
+ });
1752
+ return this;
1753
+ }
1754
+ /** Maximum length (string) or maximum value (number). Alias of max/maxLength. */
1755
+ max(n) {
1756
+ this.#pushRule({
1757
+ name: "max",
1758
+ param: n,
1759
+ args: { max: n },
1760
+ validate: (v) => typeof v === "string"
1761
+ ? [...v].length <= n
1762
+ : typeof v === "number"
1763
+ ? v <= n
1764
+ : false,
1765
+ message: `Maximum ${n}`,
1766
+ });
1767
+ return this;
1768
+ }
1769
+ /** Minimum length for a string or array (VineJS `minLength`). */
1770
+ minLength(n) {
1771
+ this.#pushRule({
1772
+ name: "minLength",
1773
+ param: n,
1774
+ args: { min: n },
1775
+ validate: (v) => sizedLength(v) >= n,
1776
+ message: `Must have at least ${n} characters`,
1777
+ });
1778
+ return this;
1779
+ }
1780
+ /** Maximum length for a string or array (VineJS `maxLength`). */
1781
+ maxLength(n) {
1782
+ this.#pushRule({
1783
+ name: "maxLength",
1784
+ param: n,
1785
+ args: { max: n },
1786
+ validate: (v) => {
1787
+ const len = sizedLength(v);
1788
+ return len >= 0 && len <= n;
1789
+ },
1790
+ message: `Must not exceed ${n} characters`,
1791
+ });
1792
+ return this;
1793
+ }
1794
+ /** Exact length for a string or array (VineJS `fixedLength`). */
1795
+ fixedLength(n) {
1796
+ this.#pushRule({
1797
+ name: "fixedLength",
1798
+ param: n,
1799
+ args: { size: n },
1800
+ validate: (v) => sizedLength(v) === n,
1801
+ message: `Must be exactly ${n} characters`,
1802
+ });
1803
+ return this;
1804
+ }
1805
+ /** Must be a valid email. */
1806
+ email(options) {
1807
+ return this.#stringRule("email", (v) => isEmail(v, options), "Must be a valid email address", options ? { ...options } : undefined);
1808
+ }
1809
+ /** Must match a regular expression (TS-only — never dispatched to Rust). */
1810
+ regex(pattern) {
1811
+ this.#pushRule({
1812
+ name: "regex",
1813
+ validate: (v) => typeof v === "string" && pattern.test(v),
1814
+ message: "Invalid format",
1815
+ });
1816
+ return this;
1817
+ }
1818
+ /** Must be a valid URL (TS-only — uses the WHATWG URL parser). */
1819
+ url(options) {
1820
+ this.#pushRule({
1821
+ name: "url",
1822
+ args: options ? { ...options } : undefined,
1823
+ validate: (v) => typeof v === "string" &&
1824
+ (options ? isUrlWithOptions(v, options) : isValidUrl(v)),
1825
+ message: "Must be a valid URL",
1826
+ });
1827
+ return this;
1828
+ }
1829
+ /**
1830
+ * The host must actually resolve (VineJS `activeUrl`).
1831
+ *
1832
+ * The only rule needing the network, which rune cannot do and stay agnostic
1833
+ * and zero-dependency — so it runs through a resolver bound once at boot,
1834
+ * exactly like `unique()`. Async by nature: run the schema with
1835
+ * `validateResultAsync`. Unbound it THROWS, rather than passing a host nobody
1836
+ * checked.
1837
+ */
1838
+ activeUrl() {
1839
+ this.#pushAsync({
1840
+ __rune: "asyncRule",
1841
+ async run(value, field) {
1842
+ if (!hostResolver) {
1843
+ throw new RuneError("NO_HOST_RESOLVER", "activeUrl() needs a host resolver.", { hint: "Call bindHostResolver(resolver) once at boot." });
1844
+ }
1845
+ let host;
1846
+ try {
1847
+ host = new URL(String(value)).hostname;
1848
+ }
1849
+ catch {
1850
+ field.report("Must be a valid URL", "activeUrl");
1851
+ return;
1852
+ }
1853
+ if (!(await hostResolver.resolves(host))) {
1854
+ field.report("Must be an active URL", "activeUrl");
1855
+ }
1856
+ },
1857
+ });
1858
+ return this;
1859
+ }
1860
+ /**
1861
+ * Must be a valid UUID, optionally restricted to given versions
1862
+ * (VineJS `uuid({ version: [4] })`, versions 1 through 8).
1863
+ */
1864
+ uuid(options) {
1865
+ const versions = options?.version === undefined ? undefined : [options.version].flat();
1866
+ this.#pushRule({
1867
+ name: "uuid",
1868
+ args: versions === undefined ? {} : { version: versions },
1869
+ // The Rust engine checks UUID shape only; a version constraint would
1870
+ // be dropped there.
1871
+ tsOnly: versions !== undefined,
1872
+ validate: (v) => {
1873
+ if (typeof v !== "string" || !UUID_RE.test(v))
1874
+ return false;
1875
+ if (versions === undefined)
1876
+ return true;
1877
+ // Version nibble: first character of the third group.
1878
+ const version = Number.parseInt(v[14] ?? "", 16);
1879
+ return versions.includes(version);
1880
+ },
1881
+ message: versions === undefined
1882
+ ? "Must be a valid UUID"
1883
+ : `Must be a UUID v${versions.join("/")}`,
1884
+ });
1885
+ return this;
1886
+ }
1887
+ /** Must be a ULID (VineJS `ulid`). */
1888
+ ulid() {
1889
+ return this.#stringRule("ulid", isUlid, "Must be a valid ULID");
1890
+ }
1891
+ /** Must be a JSON Web Token — three dot-separated base64url segments. */
1892
+ jwt() {
1893
+ return this.#stringRule("jwt", isJwt, "Must be a valid JWT");
1894
+ }
1895
+ /** Must contain only ASCII characters (VineJS `ascii`). */
1896
+ ascii() {
1897
+ return this.#stringRule("ascii", isAscii, "Must contain only ASCII characters");
1898
+ }
1899
+ /** Must be a CSS hex colour code, with or without the leading `#`. */
1900
+ hexCode() {
1901
+ return this.#stringRule("hexCode", isHexCode, "Must be a valid hex code");
1902
+ }
1903
+ /** Must be an IP address. Pass `version` to require v4 or v6 specifically. */
1904
+ ipAddress(options) {
1905
+ const version = options?.version;
1906
+ return this.#stringRule("ipAddress", (v) => isIpAddress(v, version), `Must be a valid IP address${version ? ` (v${version})` : ""}`, { version });
1907
+ }
1908
+ /** Must pass the Luhn checksum (VineJS `creditCard`). */
1909
+ creditCard() {
1910
+ return this.#stringRule("creditCard", isCreditCard, "Must be a valid credit card number");
1911
+ }
1912
+ /** Must be an IBAN passing the ISO 13616 mod-97 check. */
1913
+ iban() {
1914
+ return this.#stringRule("iban", isIban, "Must be a valid IBAN");
1915
+ }
1916
+ /** Must be a `"lat,lng"` pair within the valid ranges. */
1917
+ coordinates() {
1918
+ return this.#stringRule("coordinates", isCoordinates, "Must be valid coordinates");
1919
+ }
1920
+ /**
1921
+ * Must be a mobile number in E.164 form. Named deviation from VineJS: rune
1922
+ * carries no per-locale numbering plans, so there is no `locale` option.
1923
+ */
1924
+ mobile(options) {
1925
+ const locales = options?.locale ? [options.locale].flat() : null;
1926
+ for (const locale of locales ?? []) {
1927
+ if (isMobileForLocale("", locale) === null) {
1928
+ throw new RuneError("UNSUPPORTED_LOCALE", `mobile(): no numbering plan for locale '${locale}'.`, {
1929
+ hint: `Supported: ${SUPPORTED_MOBILE_LOCALES.join(", ")}. Omit the locale for E.164, or use .regex().`,
1930
+ });
1931
+ }
1932
+ }
1933
+ return this.#stringRule("mobile", (v) => {
1934
+ // strictMode (validator.js): the number must carry its `+` country
1935
+ // prefix, so a national-format string is not silently accepted.
1936
+ if (options?.strictMode && !v.trim().startsWith("+"))
1937
+ return false;
1938
+ return locales
1939
+ ? locales.some((locale) => isMobileForLocale(v, locale) === true)
1940
+ : isMobile(v);
1941
+ }, "Must be a valid mobile number", (locales ?? options?.strictMode)
1942
+ ? { locale: locales, strictMode: options?.strictMode }
1943
+ : undefined);
1944
+ }
1945
+ /**
1946
+ * Must be a postal code for `countryCode`. Throws for a country rune has no
1947
+ * pattern for, rather than accepting the value unchecked.
1948
+ */
1949
+ postalCode(options) {
1950
+ // The callback form resolves per validation (VineJS lets the country come
1951
+ // from a sibling field), so its countries cannot be checked up front.
1952
+ if (typeof options === "function") {
1953
+ this.#pushUse({
1954
+ __rune: "rule",
1955
+ run: (value, field) => {
1956
+ if (typeof value !== "string")
1957
+ return;
1958
+ const countries = [options(field).countryCode].flat();
1959
+ if (!countries.some((c) => isPostalCode(value, c) === true)) {
1960
+ field.report(`Must be a valid ${countries.join("/")} postal code`, "postalCode");
1961
+ }
1962
+ },
1963
+ });
1964
+ return this;
1965
+ }
1966
+ const countries = [options.countryCode].flat();
1967
+ for (const country of countries) {
1968
+ if (isPostalCode("", country) === null) {
1969
+ throw new RuneError("UNSUPPORTED_COUNTRY", `postalCode(): no pattern for country '${country}'.`, {
1970
+ hint: `Supported: ${SUPPORTED_POSTAL_CODES.join(", ")}. Use .regex() for others.`,
1971
+ });
1972
+ }
1973
+ }
1974
+ return this.#stringRule("postalCode", (v) => countries.some((c) => isPostalCode(v, c) === true), `Must be a valid ${countries.join("/").toUpperCase()} postal code`, { countryCode: countries });
1975
+ }
1976
+ /**
1977
+ * Must be a valid VAT number (VineJS 4.2 `vat`). Accepts a country list or a
1978
+ * callback resolving it per payload.
1979
+ *
1980
+ * Checksums are run where the country defines a short, well-defined one
1981
+ * (BE, DE, NL, IT, PT, LU, CH); the others are FORMAT-only, which is stated
1982
+ * rather than implied. An unknown country LEVES rather than accepting the
1983
+ * value unchecked.
1984
+ */
1985
+ vat(options) {
1986
+ if (typeof options === "function") {
1987
+ this.#pushUse({
1988
+ __rune: "rule",
1989
+ run: (value, field) => {
1990
+ if (typeof value !== "string")
1991
+ return;
1992
+ const countries = [options(field).countryCode].flat();
1993
+ if (!countries.some((c) => isVat(value, c) === true)) {
1994
+ field.report(`Must be a valid ${countries.join("/")} VAT number`, "vat");
1995
+ }
1996
+ },
1997
+ });
1998
+ return this;
1999
+ }
2000
+ const countries = [options.countryCode].flat();
2001
+ for (const country of countries) {
2002
+ if (isVat("", country) === null) {
2003
+ throw new RuneError("UNSUPPORTED_COUNTRY", `vat(): no rule for country '${country}'.`, {
2004
+ hint: `Supported: ${SUPPORTED_VAT_COUNTRIES.join(", ")}. Use .regex() for others.`,
2005
+ });
2006
+ }
2007
+ }
2008
+ return this.#stringRule("vat", (v) => countries.some((c) => isVat(v, c) === true), `Must be a valid ${countries.join("/").toUpperCase()} VAT number`, { countryCode: countries });
2009
+ }
2010
+ /** Must differ from a sibling field (VineJS `notSameAs`). */
2011
+ notSameAs(otherField) {
2012
+ const formats = this.#dateFormats;
2013
+ this.#pushUse({
2014
+ __rune: "rule",
2015
+ run: (value, field) => {
2016
+ const other = readSibling(field, otherField);
2017
+ if (formats !== null && value instanceof Date) {
2018
+ const parsed = parseDateValue(other, formats);
2019
+ if (parsed !== null && parsed.getTime() === value.getTime()) {
2020
+ field.report(`Must be different from ${otherField}`, "notSameAs");
2021
+ }
2022
+ return;
2023
+ }
2024
+ if (value === other) {
2025
+ field.report(`Must be different from ${otherField}`, "notSameAs");
2026
+ }
2027
+ },
2028
+ });
2029
+ return this;
2030
+ }
2031
+ /** Array items must be unique — optionally compared on `field` (VineJS `distinct`). */
2032
+ distinct(field) {
2033
+ this.#pushRule({
2034
+ name: "distinct",
2035
+ args: { field },
2036
+ validate: (v) => {
2037
+ if (!Array.isArray(v))
2038
+ return false;
2039
+ const fieldList = field === undefined ? null : [field].flat();
2040
+ const keys = [];
2041
+ for (const item of v) {
2042
+ // VineJS ignores null/undefined items entirely: `[1, null, 2, null]`
2043
+ // is distinct. Serialising them would make the second one a
2044
+ // duplicate of the first.
2045
+ if (item === null || item === undefined)
2046
+ continue;
2047
+ if (fieldList === null) {
2048
+ keys.push(JSON.stringify(item));
2049
+ continue;
2050
+ }
2051
+ if (!isPlainObject(item))
2052
+ continue;
2053
+ // VineJS skips an item missing the key(s): two absent values are
2054
+ // not a duplicate of each other.
2055
+ if (fieldList.some((k) => item[k] === undefined || item[k] === null)) {
2056
+ continue;
2057
+ }
2058
+ keys.push(JSON.stringify(fieldList.map((k) => item[k])));
2059
+ }
2060
+ return new Set(keys).size === keys.length;
2061
+ },
2062
+ message: field
2063
+ ? `Items must have a unique ${field}`
2064
+ : "Items must be unique",
2065
+ });
2066
+ return this;
186
2067
  }
187
- get transforms() {
188
- return this.#transforms;
2068
+ /** Must be less than or equal to zero (VineJS `nonPositive`). */
2069
+ nonPositive() {
2070
+ this.#pushRule({
2071
+ name: "nonPositive",
2072
+ validate: (v) => typeof v === "number" && v <= 0,
2073
+ message: "Must be zero or negative",
2074
+ });
2075
+ return this;
189
2076
  }
190
- /** Public read access to `.use()` rules (used to keep such schemas off the native path). */
191
- get useRules() {
192
- return this.#useRules;
2077
+ /** Array must hold at least one item (VineJS `notEmpty`). */
2078
+ notEmpty() {
2079
+ this.#pushRule({
2080
+ name: "notEmpty",
2081
+ validate: (v) => Array.isArray(v) && v.length > 0,
2082
+ message: "Must not be empty",
2083
+ });
2084
+ return this;
193
2085
  }
194
- /** Mark field as optional. */
195
- optional() {
196
- this.#isOptional = true;
2086
+ /** Drop `null`, `undefined` and `""` items before the item rules run. */
2087
+ compact() {
2088
+ this.#transforms.push({
2089
+ name: "compact",
2090
+ fn: (value) => Array.isArray(value)
2091
+ ? value.filter((item) => item !== null && item !== undefined && item !== "")
2092
+ : value,
2093
+ });
197
2094
  return this;
198
2095
  }
199
- /** Must be an object matching a nested schema. */
200
- object(shape) {
201
- this.#rules.push({
202
- name: "object",
203
- validate: (v) => typeof v === "object" && v !== null && !Array.isArray(v),
204
- message: "Must be an object",
2096
+ /** Number must have no fractional part (VineJS `withoutDecimals`). */
2097
+ withoutDecimals() {
2098
+ this.#pushRule({
2099
+ name: "withoutDecimals",
2100
+ validate: (v) => typeof v === "number" && Number.isInteger(v),
2101
+ message: "Must not have decimals",
205
2102
  });
206
- this.#nestedSchema = shape;
207
2103
  return this;
208
2104
  }
209
- /** Must be an array. Items validated by the provided chain. */
210
- array(itemChain) {
211
- this.#rules.push({
212
- name: "array",
213
- validate: (v) => Array.isArray(v),
214
- message: "Must be an array",
2105
+ /** Shared body of the string-format rules: reject non-strings, then check. */
2106
+ #stringRule(name, check, message, args) {
2107
+ this.#pushRule({
2108
+ name,
2109
+ args,
2110
+ validate: (v) => typeof v === "string" && check(v),
2111
+ message,
215
2112
  });
216
- this.#arrayItemChain = itemChain ?? null;
217
2113
  return this;
218
2114
  }
219
- /** Must be a string. */
220
- string() {
221
- this.#rules.push({
222
- name: "string",
223
- validate: (v) => typeof v === "string",
224
- message: "Must be a string",
2115
+ /** Must be a passport number for `countryCode`. Throws for an uncovered country. */
2116
+ passport(options) {
2117
+ const countries = [options.countryCode].flat();
2118
+ for (const country of countries) {
2119
+ if (isPassport("", country) === null) {
2120
+ throw new RuneError("UNSUPPORTED_COUNTRY", `passport(): no pattern for country '${country}'.`, {
2121
+ hint: `Supported: ${SUPPORTED_PASSPORTS.join(", ")}. Use .regex() for others.`,
2122
+ });
2123
+ }
2124
+ }
2125
+ return this.#stringRule("passport", (v) => countries.some((c) => isPassport(v, c) === true), `Must be a valid ${countries.join("/").toUpperCase()} passport number`, { countryCode: countries });
2126
+ }
2127
+ /** Lowercase the value (VineJS `toLowerCase`). */
2128
+ toLowerCase() {
2129
+ return this.#stringMutation("toLowerCase", (v) => v.toLowerCase());
2130
+ }
2131
+ /** Uppercase the value (VineJS `toUpperCase`). */
2132
+ toUpperCase() {
2133
+ return this.#stringMutation("toUpperCase", (v) => v.toUpperCase());
2134
+ }
2135
+ /**
2136
+ * VineJS `toCamelCase()`, on both shapes it exists for:
2137
+ *
2138
+ * - on an `object()` chain it camelCases the object's KEYS
2139
+ * (`VineObject.toCamelCase`);
2140
+ * - on any other chain it camelCases the string VALUE (`VineString`).
2141
+ *
2142
+ * One name, because Vine has one name. Dispatching on whether a nested shape
2143
+ * was declared is what keeps a transcribed validator behaving the same.
2144
+ */
2145
+ toCamelCase() {
2146
+ if (this.#nestedSchema) {
2147
+ this.#camelCaseKeys = true;
2148
+ return this;
2149
+ }
2150
+ return this.#stringMutation("toCamelCase", toCamelCase);
2151
+ }
2152
+ /** HTML-escape `& < > " '` (VineJS `escape`). */
2153
+ escape() {
2154
+ return this.#stringMutation("escape", escapeHtml);
2155
+ }
2156
+ /** Normalise an email address (VineJS `normalizeEmail`). */
2157
+ normalizeEmail(options) {
2158
+ return this.#stringMutation("normalizeEmail", (v) => normalizeEmail(v, options));
2159
+ }
2160
+ /** Normalise a URL (VineJS `normalizeUrl`). */
2161
+ normalizeUrl(options) {
2162
+ return this.#stringMutation("normalizeUrl", (v) => normalizeUrl(v, options));
2163
+ }
2164
+ /** Shared body of the string mutations — non-strings pass through untouched. */
2165
+ #stringMutation(name, fn) {
2166
+ this.#transforms.push({
2167
+ name,
2168
+ fn: (value) => (typeof value === "string" ? fn(value) : value),
225
2169
  });
226
2170
  return this;
227
2171
  }
228
- /** Must be a number. */
229
- number() {
230
- this.#rules.push({
231
- name: "number",
232
- validate: (v) => typeof v === "number" && !Number.isNaN(v) && Number.isFinite(v),
233
- message: "Must be a number",
2172
+ /** Must contain only ASCII letters. */
2173
+ alpha(options) {
2174
+ const pattern = alphaPattern("a-zA-Z", options);
2175
+ this.#pushRule({
2176
+ name: "alpha",
2177
+ args: options ? { ...options } : undefined,
2178
+ validate: (v) => typeof v === "string" && v.length > 0 && pattern.test(v),
2179
+ message: "Must contain only letters",
234
2180
  });
235
2181
  return this;
236
2182
  }
237
- /** Must be a boolean. */
238
- boolean() {
239
- this.#rules.push({
240
- name: "boolean",
241
- validate: (v) => typeof v === "boolean",
242
- message: "Must be a boolean",
2183
+ /** Must contain only ASCII letters and digits. */
2184
+ alphaNumeric(options) {
2185
+ const pattern = alphaPattern("a-zA-Z0-9", options);
2186
+ this.#pushRule({
2187
+ name: "alphaNumeric",
2188
+ args: options ? { ...options } : undefined,
2189
+ validate: (v) => typeof v === "string" && v.length > 0 && pattern.test(v),
2190
+ message: "Must contain only letters and numbers",
243
2191
  });
244
2192
  return this;
245
2193
  }
246
- /** Minimum length (string) or minimum value (number). */
247
- min(n) {
248
- this.#rules.push({
249
- name: "min",
250
- param: n,
251
- validate: (v) => typeof v === "string"
252
- ? [...v].length >= n
253
- : typeof v === "number"
254
- ? v >= n
255
- : false,
256
- message: `Minimum ${n}`,
2194
+ /** String must start with `substring`. */
2195
+ startsWith(substring) {
2196
+ this.#pushRule({
2197
+ name: "startsWith",
2198
+ args: { substring },
2199
+ validate: (v) => typeof v === "string" && v.startsWith(substring),
2200
+ message: `Must start with ${substring}`,
257
2201
  });
258
2202
  return this;
259
2203
  }
260
- /** Maximum length (string) or maximum value (number). */
261
- max(n) {
262
- this.#rules.push({
263
- name: "max",
264
- param: n,
265
- validate: (v) => typeof v === "string"
266
- ? [...v].length <= n
267
- : typeof v === "number"
268
- ? v <= n
269
- : false,
270
- message: `Maximum ${n}`,
2204
+ /** String must end with `substring`. */
2205
+ endsWith(substring) {
2206
+ this.#pushRule({
2207
+ name: "endsWith",
2208
+ args: { substring },
2209
+ validate: (v) => typeof v === "string" && v.endsWith(substring),
2210
+ message: `Must end with ${substring}`,
271
2211
  });
272
2212
  return this;
273
2213
  }
274
- /** Must be a valid email. */
275
- email() {
276
- this.#rules.push({
277
- name: "email",
278
- // Mirror the Rust engine's regex exactly (crates/rune-engine/src/engine.rs)
279
- // so the SAME schema validates identically whether or not the native
280
- // binary loaded: no whitespace anywhere (the old TS rule rejected only
281
- // \r\n, silently accepting interior spaces), a single @, dotted domain.
282
- validate: (v) => typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
283
- message: "Must be a valid email",
2214
+ /**
2215
+ * Value must be one of `values`.
2216
+ *
2217
+ * VineJS also accepts a callback so the list can be computed at validation
2218
+ * time (tenant-scoped roles, values read from config…). A static array is
2219
+ * snapshotted; a callback is invoked on every check.
2220
+ */
2221
+ in(values) {
2222
+ const resolve = allowedValuesResolver(values);
2223
+ this.#pushRule({
2224
+ name: "in",
2225
+ args: typeof values === "function" ? {} : { values: [...values] },
2226
+ // A callback list is computed per call — the native engine only ever
2227
+ // sees a static array, so it must not run this rule.
2228
+ tsOnly: typeof values === "function",
2229
+ validate: (v) => resolve().includes(asPrimitive(v)),
2230
+ message: "Invalid value",
2231
+ });
2232
+ return this;
2233
+ }
2234
+ /** Value must NOT be one of `values`. */
2235
+ notIn(values) {
2236
+ const resolve = allowedValuesResolver(values);
2237
+ this.#pushRule({
2238
+ name: "notIn",
2239
+ args: typeof values === "function" ? {} : { values: [...values] },
2240
+ tsOnly: typeof values === "function",
2241
+ validate: (v) => !resolve().includes(asPrimitive(v)),
2242
+ message: "Invalid value",
284
2243
  });
285
2244
  return this;
286
2245
  }
287
- /** Must be positive (> 0) and finite. */
2246
+ /** Number must be positive (> 0) and finite. */
288
2247
  positive() {
289
- this.#rules.push({
2248
+ this.#pushRule({
290
2249
  name: "positive",
291
2250
  validate: (v) => typeof v === "number" && Number.isFinite(v) && v > 0,
292
2251
  message: "Must be positive",
293
2252
  });
294
2253
  return this;
295
2254
  }
2255
+ /** Number must be negative (< 0) and finite. */
2256
+ negative() {
2257
+ this.#pushRule({
2258
+ name: "negative",
2259
+ validate: (v) => typeof v === "number" && Number.isFinite(v) && v < 0,
2260
+ message: "Must be negative",
2261
+ });
2262
+ return this;
2263
+ }
2264
+ /** Number must be >= 0 and finite. */
2265
+ nonNegative() {
2266
+ this.#pushRule({
2267
+ name: "nonNegative",
2268
+ validate: (v) => typeof v === "number" && Number.isFinite(v) && v >= 0,
2269
+ message: "Must be positive or zero",
2270
+ });
2271
+ return this;
2272
+ }
2273
+ /** Number must fall within `[min, max]` (inclusive). */
2274
+ range(bounds) {
2275
+ // VineJS signature is a TUPLE (`range([18, 60])`); the two-argument form
2276
+ // silently dropped `max` when an Adonis validator was transcribed as-is.
2277
+ const [min, max] = bounds;
2278
+ this.#pushRule({
2279
+ name: "range",
2280
+ args: { min, max },
2281
+ validate: (v) => typeof v === "number" && v >= min && v <= max,
2282
+ message: `Must be between ${min} and ${max}`,
2283
+ });
2284
+ return this;
2285
+ }
2286
+ /** Number must have at most `digits` decimal places (TS-only). */
2287
+ decimal(digits) {
2288
+ // VineJS accepts a `[min, max]` range as well as a single maximum.
2289
+ const [min, max] = Array.isArray(digits) ? digits : [0, digits];
2290
+ this.#pushRule({
2291
+ name: "decimal",
2292
+ args: { digits },
2293
+ validate: (v) => {
2294
+ if (typeof v !== "number" || !Number.isFinite(v))
2295
+ return false;
2296
+ const places = String(v).split(".")[1]?.length ?? 0;
2297
+ return places >= min && places <= max;
2298
+ },
2299
+ message: Array.isArray(digits)
2300
+ ? `Must have between ${min} and ${max} decimal places`
2301
+ : `Must have at most ${max} decimal places`,
2302
+ });
2303
+ return this;
2304
+ }
2305
+ /** Must equal a sibling field (VineJS `sameAs`). Cross-field → TS-only. */
2306
+ sameAs(otherField) {
2307
+ const formats = this.#dateFormats;
2308
+ this.#pushUse({
2309
+ __rune: "rule",
2310
+ run: (value, field) => {
2311
+ const other = readSibling(field, otherField);
2312
+ // On a date chain the value is a parsed `Date` and the sibling is
2313
+ // still raw, so `!==` would compare a Date to a string and always
2314
+ // fail. Compare instants instead.
2315
+ if (formats !== null && value instanceof Date) {
2316
+ const parsed = parseDateValue(other, formats);
2317
+ if (parsed === null || parsed.getTime() !== value.getTime()) {
2318
+ field.report(`Must match ${otherField}`, "sameAs");
2319
+ }
2320
+ return;
2321
+ }
2322
+ if (value !== other) {
2323
+ field.report(`Must match ${otherField}`, "sameAs");
2324
+ }
2325
+ },
2326
+ });
2327
+ return this;
2328
+ }
2329
+ /** Must equal its `<field>_confirmation` sibling (VineJS `confirmed`). */
2330
+ confirmed(options) {
2331
+ this.#pushUse({
2332
+ __rune: "rule",
2333
+ run: (value, field) => {
2334
+ const leaf = field.field.split(".").pop() ?? field.field;
2335
+ // `as` is the current VineJS spelling; `confirmationField` is its
2336
+ // deprecated alias, kept so existing callers keep working.
2337
+ const other = options?.as ?? options?.confirmationField ?? `${leaf}_confirmation`;
2338
+ if (value !== readSibling(field, other)) {
2339
+ // VineJS reports on the CONFIRMATION field: that is the input the
2340
+ // user has to fix, and where a form renders the message.
2341
+ const prefix = field.field.slice(0, -leaf.length);
2342
+ field.report("Confirmation does not match", "confirmed", `${prefix}${other}`);
2343
+ }
2344
+ },
2345
+ });
2346
+ return this;
2347
+ }
2348
+ /** Required only when `otherField` is present (non-null) — else optional. */
2349
+ requiredIfExists(otherField) {
2350
+ this.#requiredConditions.push({ kind: "exists", otherField });
2351
+ return this;
2352
+ }
2353
+ /** Required only when `otherField` is absent/null — else optional. */
2354
+ requiredIfMissing(otherField) {
2355
+ this.#requiredConditions.push({ kind: "missing", otherField });
2356
+ return this;
2357
+ }
2358
+ /** Required only when `otherField <op> value` holds — else optional. */
2359
+ requiredWhen(otherField, operator, value) {
2360
+ this.#requiredConditions.push({
2361
+ kind: "when",
2362
+ otherField,
2363
+ operator,
2364
+ value,
2365
+ });
2366
+ return this;
2367
+ }
296
2368
  /** Trim whitespace (transform). */
297
2369
  trim() {
298
2370
  this.#transforms.push({
@@ -301,9 +2373,24 @@ export class RuleChain {
301
2373
  });
302
2374
  return this;
303
2375
  }
2376
+ /**
2377
+ * Post-validation transform changing the output type (VineJS `transform`).
2378
+ * `value` is `unknown` — narrow it in the callback (the no-cast rule forbids
2379
+ * lying about a dynamically-produced value's static type).
2380
+ */
2381
+ transform(fn) {
2382
+ const next = this.#retype();
2383
+ next.#transforms.push({ name: "transform", fn: (v, f) => fn(v, f) });
2384
+ return next;
2385
+ }
2386
+ /** Pre-validation transform of the raw input (VineJS `parse`). */
2387
+ parse(fn) {
2388
+ this.#preTransforms.push(fn);
2389
+ return this;
2390
+ }
304
2391
  /** Custom validation rule. */
305
2392
  custom(name, validate, message) {
306
- this.#rules.push({
2393
+ this.#pushRule({
307
2394
  name,
308
2395
  validate,
309
2396
  message: message ?? `Failed custom rule: ${name}`,
@@ -316,113 +2403,473 @@ export class RuleChain {
316
2403
  * validate across fields. Runs after this field's type/value rules.
317
2404
  */
318
2405
  use(rule) {
2406
+ // A rule built with `{ isAsync: true }` arrives here (VineJS has one
2407
+ // `use`); routing it to the sync register would drop the await.
2408
+ if (rule.__rune === "asyncRule") {
2409
+ return this.useAsync(rule);
2410
+ }
319
2411
  if (rule?.__rune !== "rule" || typeof rule.run !== "function") {
320
2412
  throw new RuneError("INVALID_RULE", "use() expects a compiled rule — call the factory first", { hint: "use(myRule()) or use(myRule(options)), not use(myRule)" });
321
2413
  }
322
- this.#useRules.push(rule);
2414
+ this.#pushUse(rule);
2415
+ return this;
2416
+ }
2417
+ /**
2418
+ * Attach an async rule (from {@link createAsyncRule}). The schema must then be
2419
+ * run with `validateResultAsync` — sync `validate()` throws for such a schema.
2420
+ */
2421
+ useAsync(rule) {
2422
+ if (rule?.__rune !== "asyncRule" || typeof rule.run !== "function") {
2423
+ throw new RuneError("INVALID_RULE", "useAsync() expects a compiled async rule — call the factory first", { hint: "useAsync(myRule()) or useAsync(myRule(options))" });
2424
+ }
2425
+ this.#pushAsync(rule);
2426
+ return this;
2427
+ }
2428
+ unique(checkOrOptions, message) {
2429
+ const check = toDatabaseCheck(checkOrOptions, "unique");
2430
+ this.#pushAsync({
2431
+ __rune: "asyncRule",
2432
+ async run(value, field) {
2433
+ const ok = await check(value, field);
2434
+ if (!ok) {
2435
+ field.report(message ?? `The ${field.field} has already been taken`, "database.unique");
2436
+ }
2437
+ },
2438
+ });
2439
+ return this;
2440
+ }
2441
+ exists(checkOrOptions, message) {
2442
+ const check = toDatabaseCheck(checkOrOptions, "exists");
2443
+ this.#pushAsync({
2444
+ __rune: "asyncRule",
2445
+ async run(value, field) {
2446
+ const ok = await check(value, field);
2447
+ if (!ok) {
2448
+ field.report(message ?? `The selected ${field.field} is invalid`, "database.exists");
2449
+ }
2450
+ },
2451
+ });
2452
+ return this;
2453
+ }
2454
+ /**
2455
+ * Attach free-form JSON Schema metadata (VineJS `meta()`) — `title`,
2456
+ * `description`, `examples`, `deprecated`… Merged verbatim into the field's
2457
+ * node by `toJSONSchema()`.
2458
+ */
2459
+ meta(metadata) {
2460
+ this.#metadata = { ...this.#metadata, ...metadata };
323
2461
  return this;
324
2462
  }
325
- /** Set custom error message for the last rule. */
2463
+ /**
2464
+ * Set a custom error message for the rule that was just added.
2465
+ *
2466
+ * "The last rule" spans all three registers: value rules (`#rules`),
2467
+ * cross-field `.use()` rules (`sameAs`, `confirmed`, `afterField`,
2468
+ * `notSameAs`) and async rules (`unique`, `exists`, `useAsync`). Targeting
2469
+ * `#rules` alone silently retargeted the PREVIOUS value rule — or threw
2470
+ * `NO_RULE` — whenever the preceding call was a cross-field or async rule.
2471
+ */
326
2472
  message(msg) {
327
- if (this.#rules.length === 0) {
2473
+ const target = this.#lastRule;
2474
+ if (!target) {
328
2475
  throw new RuneError("NO_RULE", "message() must be called after a rule");
329
2476
  }
330
- this.#rules[this.#rules.length - 1].message = msg;
2477
+ if (target.kind === "value") {
2478
+ target.ref.message = msg;
2479
+ target.ref.hasCustomMessage = true;
2480
+ }
2481
+ else {
2482
+ // `.use()` / async rules report their own text from inside `run`, so the
2483
+ // override is applied when the rule reports rather than stored on it.
2484
+ this.#ruleMessages.set(target.ref, msg);
2485
+ }
331
2486
  return this;
332
2487
  }
333
- /** Internal: validate a field value and return errors + transformed value. */
334
- _validateWithTransform(field, value, ctx = EMPTY_RUN_CONTEXT) {
335
- if (value === undefined || value === null) {
336
- if (this.#isOptional)
337
- return { errors: [], transformed: value };
338
- return { errors: [this.#requiredError(field)], transformed: value };
2488
+ /**
2489
+ * Build the {@link FieldContext} handed to `.use()` / async rules. Shared so
2490
+ * the sync and async paths cannot drift on what a rule can see.
2491
+ */
2492
+ #makeFieldContext(field, value, ctx, errors, onMutate) {
2493
+ const segments = field.split(".");
2494
+ return {
2495
+ value,
2496
+ data: ctx.data,
2497
+ parent: ctx.parent,
2498
+ field,
2499
+ meta: ctx.meta,
2500
+ isValid: errors.length === 0,
2501
+ name: segments[segments.length - 1] ?? field,
2502
+ wildCardPath: toWildcardPath(field),
2503
+ isArrayMember: Array.isArray(ctx.parent),
2504
+ isDefined: value !== undefined && value !== null,
2505
+ isValidDataType: errors.length === 0,
2506
+ getFieldPath: () => field,
2507
+ mutate: onMutate,
2508
+ report(message, rule, reportedField, args) {
2509
+ // VineJS plugins pass the FIELD CONTEXT here, not a path. Accepting
2510
+ // only a string let the object through and produced a
2511
+ // `ValidationError.field` that was not a string at runtime.
2512
+ const target = typeof reportedField === "string"
2513
+ ? reportedField
2514
+ : (reportedField?.getFieldPath() ?? field);
2515
+ errors.push({
2516
+ field: target,
2517
+ rule,
2518
+ message,
2519
+ ...(args ? { meta: args } : {}),
2520
+ });
2521
+ },
2522
+ };
2523
+ }
2524
+ /**
2525
+ * Run only the implicit `.use()` rules against an absent value. A rule
2526
+ * declared `{ implicit: true }` exists to police `undefined`/`null`, so the
2527
+ * early return for optional fields must not skip it.
2528
+ */
2529
+ #runImplicitRules(field, value, ctx, pending) {
2530
+ // An implicit ASYNC rule polices an absent value too, so it has to be
2531
+ // queued here as well — filtering `#useRules` alone dropped it silently.
2532
+ if (pending && this.#asyncRules.some((rule) => rule.implicit)) {
2533
+ pending.push({ chain: this, field, value, ctx });
2534
+ }
2535
+ const implicitRules = this.#useRules.filter((rule) => rule.implicit);
2536
+ if (implicitRules.length === 0)
2537
+ return [];
2538
+ const errors = [];
2539
+ const fieldCtx = this.#makeFieldContext(field, value, ctx, errors, () => { });
2540
+ for (const rule of implicitRules) {
2541
+ fieldCtx.isValid = errors.length === 0;
2542
+ rule.run(value, fieldCtx);
2543
+ }
2544
+ return errors;
2545
+ }
2546
+ /**
2547
+ * Register a TYPE rule from outside the chain — used by the `optional()` and
2548
+ * `null()` factories, which are types in their own right.
2549
+ * @internal
2550
+ */
2551
+ pushTypeRule(rule) {
2552
+ this.#pushRule(rule);
2553
+ }
2554
+ /** Re-type this chain in place, without cloning. @internal */
2555
+ retypeTo() {
2556
+ return this.#retype();
2557
+ }
2558
+ /** Add a value rule and remember it as the `message()` target. */
2559
+ #pushRule(rule) {
2560
+ this.#rules.push(rule);
2561
+ this.#lastRule = { kind: "value", ref: rule };
2562
+ }
2563
+ /** Add a cross-field `.use()` rule and remember it as the `message()` target. */
2564
+ #pushUse(rule) {
2565
+ this.#useRules.push(rule);
2566
+ this.#lastRule = { kind: "reporting", ref: rule };
2567
+ }
2568
+ /** Add an async rule and remember it as the `message()` target. */
2569
+ #pushAsync(rule) {
2570
+ this.#asyncRules.push(rule);
2571
+ this.#lastRule = { kind: "reporting", ref: rule };
2572
+ }
2573
+ /** Whether the field is required given the surrounding data (conditionals). */
2574
+ #isRequired(ctx) {
2575
+ if (this.#requiredConditions.length === 0)
2576
+ return true;
2577
+ return this.#requiredConditions.some((cond) => evalRequiredCondition(cond, ctx));
2578
+ }
2579
+ /**
2580
+ * Internal: validate a field value and return errors + transformed value.
2581
+ *
2582
+ * `pending` is the async-rule collector. The traversal itself stays sync (it
2583
+ * is shared with `validate()`); when a collector is supplied, every chain in
2584
+ * the tree that carries async rules and passed its sync rules records itself
2585
+ * for the async path to await. Without it, nested async rules never ran.
2586
+ */
2587
+ _validateWithTransform(field, rawValue, ctx = EMPTY_RUN_CONTEXT, pending) {
2588
+ // 0. Pre-validation parse() transforms run on the raw value first. VineJS
2589
+ // hands them `(value, { data, parent, meta })` — without the context a
2590
+ // parser cannot look at a sibling, which is half its purpose.
2591
+ let value = rawValue;
2592
+ const parseCtx = {
2593
+ data: ctx.data,
2594
+ parent: ctx.parent,
2595
+ meta: ctx.meta,
2596
+ };
2597
+ for (const pre of this.#preTransforms) {
2598
+ value = pre(value, parseCtx);
2599
+ }
2600
+ if (value === undefined) {
2601
+ if (this.#isOptional || !this.#isRequired(ctx)) {
2602
+ // Implicit rules are precisely the ones that must see an absent value.
2603
+ return {
2604
+ errors: this.#runImplicitRules(field, value, ctx, pending),
2605
+ transformed: value,
2606
+ };
2607
+ }
2608
+ return { errors: [this.#requiredError(field, ctx)], transformed: value };
2609
+ }
2610
+ if (value === null) {
2611
+ // VineJS split, now matched exactly: `nullable()` accepts null AND keeps
2612
+ // it in the output; `optional()` accepts null but DROPS the key. rune
2613
+ // used to keep null in both cases, so an optional field silently added
2614
+ // `key: null` to a payload VineJS would have left without the key.
2615
+ if (this.#isNullable) {
2616
+ return {
2617
+ errors: this.#runImplicitRules(field, value, ctx, pending),
2618
+ transformed: value,
2619
+ };
2620
+ }
2621
+ if (this.#isOptional || !this.#isRequired(ctx)) {
2622
+ return {
2623
+ errors: this.#runImplicitRules(field, value, ctx, pending),
2624
+ transformed: undefined,
2625
+ };
2626
+ }
2627
+ return { errors: [this.#requiredError(field, ctx)], transformed: value };
2628
+ }
2629
+ // 0b. Coerce before the type rules — a coerced value is the validated value.
2630
+ for (const coerce of this.#coercions) {
2631
+ value = coerce(value);
339
2632
  }
340
2633
  // 1. Type rules first on the raw value — bail on type mismatch.
341
- const typeError = this.#runTypeRules(field, value);
2634
+ const typeError = this.#runTypeRules(field, value, ctx);
342
2635
  if (typeError)
343
2636
  return { errors: [typeError], transformed: value };
344
2637
  // 2. Apply transforms (trim, etc.), then run value rules on the result.
345
- let transformed = this.#applyTransformsTo(value);
346
- const errors = this.#runValueRules(field, transformed);
2638
+ let transformed = this.#applyTransformsTo(value, field, ctx);
2639
+ const errors = this.#runValueRules(field, transformed, ctx);
347
2640
  // 3. Vine-style .use() rules — run with a FieldContext exposing the root
348
2641
  // `data` and `parent`, so a rule can validate across fields.
349
- if (this.#useRules.length > 0) {
350
- this.#runUseRules(field, transformed, ctx, errors);
2642
+ if (this.#useRules.length > 0 && !(this.#bail && errors.length > 0)) {
2643
+ // `.use()` rules may call `field.mutate()`, so the value can change here.
2644
+ transformed = this.#runUseRules(field, transformed, ctx, errors);
2645
+ }
2646
+ // 3b. Date output mapping (VineJS `VineDate.transform`). Deliberately AFTER
2647
+ // the comparison rules so `after`/`before`/`afterField` always see a
2648
+ // real `Date`, whatever type the consumer maps it to.
2649
+ if (this.#dateFormats !== null &&
2650
+ dateOutputTransform !== null &&
2651
+ transformed instanceof Date) {
2652
+ transformed = dateOutputTransform(transformed);
351
2653
  }
352
2654
  // 4. Nested object validation (only if type check passed — not arrays)
353
2655
  if (this.#nestedSchema && isPlainObject(transformed)) {
354
- const obj = { ...transformed };
2656
+ // Start from the DECLARED keys only. Spreading the input kept every
2657
+ // undeclared key, so the mass-assignment guarantee that holds at the
2658
+ // top level silently stopped holding one level down:
2659
+ // `object({ name })` let an `isAdmin` through. `allowUnknownProperties()`
2660
+ // is the opt-in, as in VineJS.
2661
+ const source = transformed;
2662
+ const obj = this.#allowUnknown
2663
+ ? { ...source }
2664
+ : {};
355
2665
  transformed = obj;
356
- for (const [nestedField, chain] of Object.entries(this.#nestedSchema)) {
357
- const nestedResult = chain._validateWithTransform(`${field}.${nestedField}`, obj[nestedField], { data: ctx.data, parent: obj, meta: ctx.meta });
2666
+ // A conditional group contributes its branch's properties for THIS
2667
+ // payload, so the shape is resolved per validation, not at build time.
2668
+ let shape = this.#nestedSchema;
2669
+ for (const grp of this.#groups) {
2670
+ const branch = grp.branches.find((candidate) => candidate.predicate?.(source) === true) ?? grp.branches.find((candidate) => candidate.predicate === null);
2671
+ if (branch)
2672
+ shape = { ...shape, ...branch.shape };
2673
+ }
2674
+ for (const [nestedField, chain] of Object.entries(shape)) {
2675
+ const nestedResult = chain._validateWithTransform(`${field}.${nestedField}`, source[nestedField], { ...ctx, parent: source }, pending);
358
2676
  errors.push(...nestedResult.errors);
359
2677
  if (nestedResult.transformed !== undefined) {
360
- obj[nestedField] = nestedResult.transformed;
2678
+ obj[this.#camelCaseKeys ? toCamelCaseKey(nestedField) : nestedField] =
2679
+ nestedResult.transformed;
2680
+ }
2681
+ }
2682
+ if (this.#camelCaseKeys && this.#allowUnknown) {
2683
+ // Undeclared keys are camelCased too, otherwise the output would mix
2684
+ // both spellings depending on whether a key was declared.
2685
+ for (const [key, value] of Object.entries(source)) {
2686
+ const camel = toCamelCaseKey(key);
2687
+ if (!(camel in obj))
2688
+ obj[camel] = value;
2689
+ }
2690
+ }
2691
+ }
2692
+ // 4b. Record values — same shape as the nested-object walk, arbitrary keys.
2693
+ if (this.#recordValueChain && isPlainObject(transformed)) {
2694
+ const obj = { ...transformed };
2695
+ transformed = obj;
2696
+ for (const key of Object.keys(obj)) {
2697
+ const res = this.#recordValueChain._validateWithTransform(`${field}.${key}`, obj[key], { ...ctx, parent: obj }, pending);
2698
+ errors.push(...res.errors);
2699
+ if (res.transformed !== undefined)
2700
+ obj[key] = res.transformed;
2701
+ }
2702
+ }
2703
+ // 4c. Tuple positions — length was already enforced by the `tuple` rule.
2704
+ if (this.#tupleChains && Array.isArray(transformed)) {
2705
+ const arr = [...transformed];
2706
+ transformed = arr;
2707
+ this.#tupleChains.forEach((chain, i) => {
2708
+ const res = chain._validateWithTransform(`${field}.${i}`, arr[i], { ...ctx, parent: arr }, pending);
2709
+ for (const e of res.errors)
2710
+ if (e.index === undefined)
2711
+ e.index = i;
2712
+ errors.push(...res.errors);
2713
+ if (res.transformed !== undefined)
2714
+ arr[i] = res.transformed;
2715
+ });
2716
+ }
2717
+ // 4d. Union — first branch that validates wins; its transform is kept.
2718
+ if (this.#unionChains) {
2719
+ let matched = false;
2720
+ // A guarded branch (union.if) is SELECTED by its predicate, and its own
2721
+ // errors are reported — that is the diagnosable half of VineJS's union.
2722
+ const guarded = this.#unionChains.filter((b) => b.predicate !== null);
2723
+ if (guarded.length > 0) {
2724
+ const probe = this.#makeFieldContext(field, transformed, ctx, [], () => { });
2725
+ const chosen = guarded.find((b) => b.predicate?.(transformed, probe)) ??
2726
+ this.#unionChains.find((b) => b.predicate === null);
2727
+ if (chosen) {
2728
+ const res = chosen.chain._validateWithTransform(field, transformed, ctx, pending);
2729
+ transformed = res.transformed;
2730
+ errors.push(...res.errors);
2731
+ matched = true;
2732
+ }
2733
+ }
2734
+ for (const branch of matched ? [] : this.#unionChains) {
2735
+ // Each branch collects into its OWN buffer: a losing branch must not
2736
+ // leave async work queued, and the winning one must not lose it —
2737
+ // without this, a `unique()` inside the matching branch was never
2738
+ // awaited, which reads exactly like a check that passed.
2739
+ const branchPending = [];
2740
+ const res = branch.chain._validateWithTransform(field, transformed, ctx, pending ? branchPending : undefined);
2741
+ if (res.errors.length === 0) {
2742
+ transformed = res.transformed;
2743
+ matched = true;
2744
+ if (pending)
2745
+ pending.push(...branchPending);
2746
+ break;
361
2747
  }
362
2748
  }
2749
+ if (!matched) {
2750
+ errors.push({
2751
+ field,
2752
+ rule: "union",
2753
+ message: resolveRuleMessage(field, {
2754
+ name: "union",
2755
+ validate: () => false,
2756
+ message: "Does not match any allowed shape",
2757
+ }, ctx),
2758
+ });
2759
+ }
363
2760
  }
364
2761
  // 5. Array item validation
365
2762
  if (this.#arrayItemChain && Array.isArray(transformed)) {
366
2763
  const arr = [...transformed];
367
2764
  transformed = arr;
368
2765
  for (let i = 0; i < arr.length; i++) {
369
- const itemResult = this.#arrayItemChain._validateWithTransform(`${field}.${i}`, arr[i], { data: ctx.data, parent: arr, meta: ctx.meta });
2766
+ const itemResult = this.#arrayItemChain._validateWithTransform(`${field}.${i}`, arr[i], { ...ctx, parent: arr }, pending);
2767
+ for (const e of itemResult.errors) {
2768
+ if (e.index === undefined)
2769
+ e.index = i;
2770
+ }
370
2771
  errors.push(...itemResult.errors);
371
2772
  if (itemResult.transformed !== undefined) {
372
2773
  arr[i] = itemResult.transformed;
373
2774
  }
374
2775
  }
375
2776
  }
2777
+ // 6. Record this chain's async rules for the async path to await. Mirrors
2778
+ // Lucid skipping a DB rule on an already-invalid or absent field: only a
2779
+ // clean, present value is worth a round-trip.
2780
+ if (pending &&
2781
+ this.#asyncRules.length > 0 &&
2782
+ errors.length === 0 &&
2783
+ transformed !== undefined &&
2784
+ transformed !== null) {
2785
+ pending.push({ chain: this, field, value: transformed, ctx });
2786
+ }
376
2787
  return { errors, transformed };
377
2788
  }
378
2789
  /** Run `.use()` rules on the transformed value with a fresh FieldContext. */
379
2790
  #runUseRules(field, transformed, ctx, errors) {
380
- const fieldCtx = {
381
- value: transformed,
382
- data: ctx.data,
383
- parent: ctx.parent,
384
- field,
385
- meta: ctx.meta,
386
- isValid: errors.length === 0,
387
- report(message, rule) {
388
- errors.push({ field, rule, message });
389
- },
390
- };
2791
+ // Set per iteration so `report` can substitute the `.message()` override of
2792
+ // the rule currently running — these rules carry their text inside `run`.
2793
+ let override;
2794
+ let current = transformed;
2795
+ const fieldCtx = this.#makeFieldContext(field, transformed, ctx, errors, (next) => {
2796
+ current = next;
2797
+ fieldCtx.value = next;
2798
+ });
2799
+ const report = fieldCtx.report.bind(fieldCtx);
2800
+ fieldCtx.report = (message, rule, reportedField, args) => report(override ?? message, rule, reportedField, args);
391
2801
  for (const rule of this.#useRules) {
392
- // Refresh isValid so a rule can early-return once the field has failed.
2802
+ // A non-implicit rule is skipped on an absent value (VineJS semantics);
2803
+ // `implicit: true` is what lets a custom rule police undefined/null.
2804
+ if (!rule.implicit && (current === undefined || current === null))
2805
+ continue;
2806
+ fieldCtx.isValid = errors.length === 0;
2807
+ fieldCtx.isDefined = current !== undefined && current !== null;
2808
+ override = this.#ruleMessages.get(rule);
2809
+ rule.run(current, fieldCtx);
2810
+ }
2811
+ return current;
2812
+ }
2813
+ /**
2814
+ * Run this chain's async rules on the (already sync-validated) value, awaiting
2815
+ * each in order. Returns the errors they reported. Used by `validateResultAsync`.
2816
+ * @internal
2817
+ */
2818
+ async _runAsyncRules(field, transformed, ctx) {
2819
+ const errors = [];
2820
+ let override;
2821
+ let current = transformed;
2822
+ const fieldCtx = this.#makeFieldContext(field, transformed, ctx, errors, (next) => {
2823
+ current = next;
2824
+ fieldCtx.value = next;
2825
+ });
2826
+ const report = fieldCtx.report.bind(fieldCtx);
2827
+ fieldCtx.report = (message, rule, reportedField, args) => report(override ?? message, rule, reportedField, args);
2828
+ for (const rule of this.#asyncRules) {
2829
+ if (!rule.implicit && (current === undefined || current === null))
2830
+ continue;
393
2831
  fieldCtx.isValid = errors.length === 0;
394
- rule.run(transformed, fieldCtx);
2832
+ fieldCtx.isDefined = current !== undefined && current !== null;
2833
+ override = this.#ruleMessages.get(rule);
2834
+ await rule.run(current, fieldCtx);
395
2835
  }
2836
+ return errors;
396
2837
  }
397
- #requiredError(field) {
2838
+ #requiredError(field, ctx) {
398
2839
  return {
399
2840
  field,
400
2841
  rule: "required",
401
- message: resolveValidationMessage("validation.required", `${field} is required`, { field }),
2842
+ message: resolveRequiredMessage(field, ctx),
402
2843
  };
403
2844
  }
404
2845
  /** Run the type rules (string/number/…) on the raw value; first failure bails. */
405
- #runTypeRules(field, value) {
2846
+ #runTypeRules(field, value, ctx) {
406
2847
  for (const rule of this.#rules) {
407
2848
  if (TYPE_RULE_NAMES.has(rule.name) && !rule.validate(value)) {
408
2849
  return {
409
2850
  field,
410
2851
  rule: rule.name,
411
- message: resolveRuleMessage(field, rule),
2852
+ message: resolveRuleMessage(field, rule, ctx),
2853
+ ...(ruleArgs(rule) ? { meta: ruleArgs(rule) } : {}),
412
2854
  };
413
2855
  }
414
2856
  }
415
2857
  return null;
416
2858
  }
417
2859
  /** Run the non-type rules (min/max/email/…) on the transformed value. */
418
- #runValueRules(field, transformed) {
2860
+ #runValueRules(field, transformed, ctx) {
419
2861
  const errors = [];
420
2862
  for (const rule of this.#rules) {
421
- if (!TYPE_RULE_NAMES.has(rule.name) && !rule.validate(transformed)) {
2863
+ if (TYPE_RULE_NAMES.has(rule.name))
2864
+ continue;
2865
+ if (this.#bail && errors.length > 0)
2866
+ break;
2867
+ if (!rule.validate(transformed)) {
422
2868
  errors.push({
423
2869
  field,
424
2870
  rule: rule.name,
425
- message: resolveRuleMessage(field, rule),
2871
+ message: resolveRuleMessage(field, rule, ctx),
2872
+ ...(ruleArgs(rule) ? { meta: ruleArgs(rule) } : {}),
426
2873
  });
427
2874
  }
428
2875
  }
@@ -434,42 +2881,217 @@ export class RuleChain {
434
2881
  }
435
2882
  /** Internal: apply transforms. */
436
2883
  _transform(value) {
437
- return this.#applyTransformsTo(value);
2884
+ return this.#applyTransformsTo(value, "", EMPTY_RUN_CONTEXT);
438
2885
  }
439
- #applyTransformsTo(value) {
2886
+ #applyTransformsTo(value, field, ctx) {
440
2887
  let result = value;
441
2888
  for (const transform of this.#transforms) {
442
- result = transform.fn(result);
2889
+ LAST_FIELD.value = result;
2890
+ LAST_FIELD.data = ctx.data;
2891
+ LAST_FIELD.parent = ctx.parent;
2892
+ LAST_FIELD.field = field;
2893
+ LAST_FIELD.meta = ctx.meta;
2894
+ result = transform.fn(result, LAST_FIELD);
443
2895
  }
444
2896
  return result;
445
2897
  }
446
2898
  }
2899
+ _a = RuleChain;
2900
+ /**
2901
+ * Scratch FieldContext reused for `.transform()` callbacks — transforms run
2902
+ * inline in {@link RuleChain.#applyTransformsTo}, which repopulates it before
2903
+ * each call. A shared object avoids per-transform allocation; it is never
2904
+ * retained across calls.
2905
+ */
2906
+ const LAST_FIELD = {
2907
+ value: undefined,
2908
+ data: {},
2909
+ parent: {},
2910
+ field: "",
2911
+ meta: {},
2912
+ isValid: true,
2913
+ name: "",
2914
+ wildCardPath: "",
2915
+ isArrayMember: false,
2916
+ isDefined: false,
2917
+ isValidDataType: true,
2918
+ getFieldPath: () => "",
2919
+ mutate: () => { },
2920
+ report() { },
2921
+ };
2922
+ /** Coerce a value to a comparable primitive for `in`/`enum` membership. */
2923
+ function asPrimitive(value) {
2924
+ if (typeof value === "string" ||
2925
+ typeof value === "number" ||
2926
+ typeof value === "boolean") {
2927
+ return value;
2928
+ }
2929
+ // Non-primitive values can never be a member of a primitive set; return a
2930
+ // sentinel that no allowed entry equals.
2931
+ return Symbol.iterator.toString();
2932
+ }
2933
+ /** Code-point length for a string, element count for an array, else -1. */
2934
+ function sizedLength(value) {
2935
+ if (typeof value === "string")
2936
+ return [...value].length;
2937
+ if (Array.isArray(value))
2938
+ return value.length;
2939
+ return -1;
2940
+ }
2941
+ /** WHATWG URL validity — accepts only http/https to avoid `mailto:` etc. */
2942
+ function isValidUrl(value) {
2943
+ try {
2944
+ const url = new URL(value);
2945
+ return url.protocol === "http:" || url.protocol === "https:";
2946
+ }
2947
+ catch {
2948
+ return false;
2949
+ }
2950
+ }
2951
+ /** Read a sibling field's value from the immediate parent (object only). */
2952
+ function readSibling(field, name) {
2953
+ const parent = field.parent;
2954
+ if (Array.isArray(parent))
2955
+ return undefined;
2956
+ return parent[name];
2957
+ }
2958
+ /** Evaluate whether a `requiredWhen`-family condition makes the field required. */
2959
+ function evalRequiredCondition(cond, ctx) {
2960
+ const other = isPlainObject(ctx.parent)
2961
+ ? ctx.parent[cond.otherField]
2962
+ : ctx.data[cond.otherField];
2963
+ const present = other !== undefined && other !== null;
2964
+ if (cond.kind === "exists")
2965
+ return present;
2966
+ if (cond.kind === "missing")
2967
+ return !present;
2968
+ switch (cond.operator) {
2969
+ case "=":
2970
+ return other === cond.value;
2971
+ case "!=":
2972
+ return other !== cond.value;
2973
+ case ">":
2974
+ return typeof other === "number" && typeof cond.value === "number"
2975
+ ? other > cond.value
2976
+ : false;
2977
+ case "<":
2978
+ return typeof other === "number" && typeof cond.value === "number"
2979
+ ? other < cond.value
2980
+ : false;
2981
+ case ">=":
2982
+ return typeof other === "number" && typeof cond.value === "number"
2983
+ ? other >= cond.value
2984
+ : false;
2985
+ case "<=":
2986
+ return typeof other === "number" && typeof cond.value === "number"
2987
+ ? other <= cond.value
2988
+ : false;
2989
+ case "in":
2990
+ return Array.isArray(cond.value) && cond.value.includes(other);
2991
+ case "notIn":
2992
+ return Array.isArray(cond.value) && !cond.value.includes(other);
2993
+ default:
2994
+ return false;
2995
+ }
2996
+ }
2997
+ export function compile(input) {
2998
+ // A rune schema is already compiled, so this is identity for that form; the
2999
+ // `RuleChain` form exists because `vine.compile(vine.object({…}))` is the
3000
+ // shape Adonis documents.
3001
+ return input instanceof RuleChain ? schema(toFieldMap(input), input) : input;
3002
+ }
447
3003
  /** Entry point for building rules. */
448
3004
  export const rules = {
449
3005
  string: () => new RuleChain().string(),
450
- number: () => new RuleChain().number(),
451
- boolean: () => new RuleChain().boolean(),
3006
+ number: (options) => new RuleChain().number(options),
3007
+ boolean: (options) => new RuleChain().boolean(options),
452
3008
  any: () => new RuleChain(),
3009
+ date: (options) => new RuleChain().date(options),
3010
+ accepted: () => new RuleChain().accepted(),
3011
+ file: (options) => new RuleChain().file(options),
3012
+ nativeFile: (options) => new RuleChain().nativeFile(options),
3013
+ record: (valueChain) => new RuleChain().record(valueChain),
3014
+ tuple: (items) => new RuleChain().tuple(items),
3015
+ union: Object.assign((chains) => new RuleChain().union(chains),
3016
+ // `otherwise` is VineJS's spelling of the fallback branch; `else` stays
3017
+ // because it reads better in some call styles.
3018
+ { if: unionIf, else: unionElse, otherwise: unionElse }),
3019
+ /**
3020
+ * Union discriminated by the value's TYPE (VineJS `unionOfTypes`): the first
3021
+ * branch whose own type rule accepts the value wins.
3022
+ */
3023
+ /**
3024
+ * Make every property of a shape optional (VineJS `vine.helpers.optional`).
3025
+ * A properties TRANSFORMER, like `pick`/`omit` — it returns a record to
3026
+ * spread, not a schema.
3027
+ */
3028
+ /**
3029
+ * A field that must be ABSENT (VineJS `vine.optional()` → `VineOptional`,
3030
+ * `builder.d.ts:135`). Mostly a `unionOfTypes` branch. Distinct from
3031
+ * `.optional()` on a chain, which relaxes an existing type — this one IS the
3032
+ * type. The properties transformer that used to squat this name moved to
3033
+ * `helpers.optional`, where VineJS keeps it.
3034
+ */
3035
+ optional: () => {
3036
+ const chain = new RuleChain();
3037
+ chain.pushTypeRule({
3038
+ name: "optionalType",
3039
+ validate: (v) => v === undefined,
3040
+ message: "Must not be provided",
3041
+ });
3042
+ return chain.optional().retypeTo();
3043
+ },
3044
+ /** A field that must be `null` (VineJS `vine.null()` → `VineNull`). */
3045
+ null: () => {
3046
+ const chain = new RuleChain();
3047
+ chain.pushTypeRule({
3048
+ name: "nullType",
3049
+ validate: (v) => v === null,
3050
+ message: "Must be null",
3051
+ });
3052
+ return chain.nullable().retypeTo();
3053
+ },
3054
+ unionOfTypes: (chains) => {
3055
+ // VineJS requires DISTINCT types: two branches claiming the same type make
3056
+ // the discrimination meaningless, and the second would be dead code.
3057
+ const seen = new Set();
3058
+ for (const chain of chains) {
3059
+ const typeRule = chain.rules.find((rule) => TYPE_RULE_NAMES.has(rule.name));
3060
+ const name = typeRule?.name;
3061
+ if (name === undefined) {
3062
+ throw new RuneError("NO_TYPE_RULE", "unionOfTypes() needs every branch to declare a type (string/number/…).", { hint: "Use union([...]) for predicate-based branches." });
3063
+ }
3064
+ if (seen.has(name)) {
3065
+ throw new RuneError("DUPLICATE_UNION_TYPE", `unionOfTypes() got two '${name}' branches — the second can never be reached.`, { hint: "Give each branch a distinct type, or use union([...])." });
3066
+ }
3067
+ seen.add(name);
3068
+ }
3069
+ return new RuleChain().union(chains.map((chain) => {
3070
+ const typeRule = chain.rules.find((rule) => TYPE_RULE_NAMES.has(rule.name));
3071
+ return unionIf((value) => typeRule?.validate(value) === true, chain);
3072
+ }));
3073
+ },
3074
+ object: (shape) => new RuleChain().object(shape),
3075
+ array: (item) => new RuleChain().array(item),
3076
+ enum: (values) => new RuleChain().enum(values),
3077
+ literal: (value) => new RuleChain().literal(value),
453
3078
  };
454
3079
  /** Serialize schema + data and validate via Rust NAPI. */
455
3080
  function validateWithRust(fields, data) {
456
3081
  const schemaDesc = {};
457
3082
  for (const [field, chain] of Object.entries(fields)) {
458
- const rules = chain.rules.map((r) => ({
3083
+ const ruleDescs = chain.rules.map((r) => ({
459
3084
  name: r.name,
460
- // Serialize THIS rule's own param. Previously a find-first lookup by
461
- // rule name returned the first matching rule's param, so min(3).min(5)
462
- // sent both Rust entries as min=3, dropping the 5 bound (audit 2026-06-13).
463
- params: r.name === "min"
464
- ? { min: r.param }
465
- : r.name === "max"
466
- ? { max: r.param }
467
- : null,
3085
+ // Serialize THIS rule's own args (per-rule, so min(3).min(5) keeps both
3086
+ // bounds a find-first lookup previously collapsed them).
3087
+ params: r.args ?? null,
468
3088
  }));
469
3089
  schemaDesc[field] = {
470
- rules,
3090
+ rules: ruleDescs,
471
3091
  optional: chain.isOptionalField,
472
3092
  transforms: chain.transforms.map((t) => t.name),
3093
+ // Sent explicitly so the Rust engine and the TS path agree on bail.
3094
+ bail: chain.bails,
473
3095
  };
474
3096
  }
475
3097
  const request = JSON.stringify({ schema: schemaDesc, data });