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