@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.
- package/dist/MessagesProvider.d.ts +45 -0
- package/dist/MessagesProvider.d.ts.map +1 -0
- package/dist/MessagesProvider.js +77 -0
- package/dist/MessagesProvider.js.map +1 -0
- package/dist/Schema.d.ts +912 -38
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +2841 -219
- package/dist/Schema.js.map +1 -1
- package/dist/date.d.ts +36 -0
- package/dist/date.d.ts.map +1 -0
- package/dist/date.js +275 -0
- package/dist/date.js.map +1 -0
- package/dist/errors.d.ts +42 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +37 -0
- package/dist/errors.js.map +1 -1
- package/dist/formats.d.ts +149 -0
- package/dist/formats.d.ts.map +1 -0
- package/dist/formats.js +612 -0
- package/dist/formats.js.map +1 -0
- package/dist/index.d.ts +152 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +181 -2
- package/dist/index.js.map +1 -1
- package/dist/magic.d.ts +30 -0
- package/dist/magic.d.ts.map +1 -0
- package/dist/magic.js +154 -0
- package/dist/magic.js.map +1 -0
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +11 -1
- package/src/MessagesProvider.ts +115 -0
- package/src/Schema.ts +3970 -215
- package/src/date.ts +320 -0
- package/src/errors.ts +59 -0
- package/src/formats.ts +721 -0
- package/src/index.ts +266 -1
- package/src/magic.ts +181 -0
- 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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
51
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 (!
|
|
73
|
-
return
|
|
641
|
+
if (!STANDARD_RULES.has(rule.name)) {
|
|
642
|
+
return rule.message;
|
|
74
643
|
}
|
|
75
644
|
const params = { field };
|
|
76
|
-
if (
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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(
|
|
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 (!
|
|
91
|
-
return true; //
|
|
92
|
-
if (
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
/**
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
/**
|
|
195
|
-
|
|
196
|
-
this.#
|
|
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
|
-
/**
|
|
200
|
-
|
|
201
|
-
this.#
|
|
202
|
-
name: "
|
|
203
|
-
validate: (v) => typeof v === "
|
|
204
|
-
message: "Must
|
|
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
|
-
/**
|
|
210
|
-
|
|
211
|
-
this.#
|
|
212
|
-
name
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
/**
|
|
247
|
-
|
|
248
|
-
this.#
|
|
249
|
-
name: "
|
|
250
|
-
|
|
251
|
-
validate: (v) => typeof v === "string"
|
|
252
|
-
|
|
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
|
-
/**
|
|
261
|
-
|
|
262
|
-
this.#
|
|
263
|
-
name: "
|
|
264
|
-
|
|
265
|
-
validate: (v) => typeof v === "string"
|
|
266
|
-
|
|
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
|
-
/**
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
/**
|
|
2246
|
+
/** Number must be positive (> 0) and finite. */
|
|
288
2247
|
positive() {
|
|
289
|
-
this.#
|
|
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.#
|
|
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.#
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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] =
|
|
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], {
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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
|
|
3083
|
+
const ruleDescs = chain.rules.map((r) => ({
|
|
459
3084
|
name: r.name,
|
|
460
|
-
// Serialize THIS rule's own
|
|
461
|
-
//
|
|
462
|
-
|
|
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 });
|