@c9up/rune 0.1.5 → 0.1.7
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 +40 -0
- package/dist/MessagesProvider.d.ts.map +1 -0
- package/dist/MessagesProvider.js +77 -0
- package/dist/MessagesProvider.js.map +1 -0
- package/dist/Schema.d.ts +226 -27
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +688 -134
- package/dist/Schema.js.map +1 -1
- package/dist/errors.d.ts +32 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +27 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- 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 +3 -1
- package/src/MessagesProvider.ts +115 -0
- package/src/Schema.ts +965 -154
- package/src/errors.ts +48 -0
- package/src/index.ts +9 -1
package/dist/Schema.js
CHANGED
|
@@ -3,13 +3,34 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @implements FR38, FR39, FR40, FR41
|
|
5
5
|
*/
|
|
6
|
-
|
|
6
|
+
var _a;
|
|
7
|
+
import { RuneError, RuneValidationError } from "./errors.js";
|
|
7
8
|
import { isNativeAvailable, validateNative, warnNativeUnavailableOnce, } from "./native.js";
|
|
9
|
+
export function createRule(validator) {
|
|
10
|
+
return (options) => ({
|
|
11
|
+
__rune: "rule",
|
|
12
|
+
run(value, field) {
|
|
13
|
+
validator(value, options, field);
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/** Default context for internal callers that don't supply one (no root available). */
|
|
18
|
+
const EMPTY_RUN_CONTEXT = { data: {}, parent: {}, meta: {} };
|
|
8
19
|
/** Type guard: narrows `unknown` to a plain object (non-null, non-array, typeof 'object'). */
|
|
9
20
|
function isPlainObject(value) {
|
|
10
21
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
22
|
}
|
|
12
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Rules the Rust validation engine (`crates/rune-engine/src/engine.rs`)
|
|
25
|
+
* ACTUALLY implements. A schema built from only these rules can be validated
|
|
26
|
+
* natively; anything else routes to the TS path (`rule.validate`).
|
|
27
|
+
*
|
|
28
|
+
* CRITICAL: a rule name here that the Rust engine does not implement is a
|
|
29
|
+
* SILENT VALIDATION BYPASS — the engine's `_ => {}` arm skips unknown rules, so
|
|
30
|
+
* the constraint never runs. Every entry MUST have a matching arm in engine.rs.
|
|
31
|
+
* The TS chain rules (minLength/uuid/alpha/in/enum/range/…) live only in the TS
|
|
32
|
+
* validator, so they are deliberately absent here.
|
|
33
|
+
*/
|
|
13
34
|
const STANDARD_RULES = new Set([
|
|
14
35
|
"string",
|
|
15
36
|
"number",
|
|
@@ -18,8 +39,22 @@ const STANDARD_RULES = new Set([
|
|
|
18
39
|
"max",
|
|
19
40
|
"email",
|
|
20
41
|
"positive",
|
|
42
|
+
"minLength",
|
|
43
|
+
"maxLength",
|
|
44
|
+
"fixedLength",
|
|
45
|
+
"uuid",
|
|
46
|
+
"alpha",
|
|
47
|
+
"alphaNumeric",
|
|
48
|
+
"startsWith",
|
|
49
|
+
"endsWith",
|
|
50
|
+
"in",
|
|
51
|
+
"notIn",
|
|
52
|
+
"enum",
|
|
53
|
+
"negative",
|
|
54
|
+
"nonNegative",
|
|
55
|
+
"range",
|
|
21
56
|
]);
|
|
22
|
-
/** Default messages for standard rules — used
|
|
57
|
+
/** Default messages for standard rules — used only for translator-key fallback. */
|
|
23
58
|
const STANDARD_MSGS = {
|
|
24
59
|
string: "Must be a string",
|
|
25
60
|
number: "Must be a number",
|
|
@@ -28,6 +63,20 @@ const STANDARD_MSGS = {
|
|
|
28
63
|
max: "Maximum",
|
|
29
64
|
email: "Must be a valid email",
|
|
30
65
|
positive: "Must be positive",
|
|
66
|
+
minLength: "Too short",
|
|
67
|
+
maxLength: "Too long",
|
|
68
|
+
fixedLength: "Wrong length",
|
|
69
|
+
alpha: "Must contain only letters",
|
|
70
|
+
alphaNumeric: "Must contain only letters and numbers",
|
|
71
|
+
startsWith: "Invalid prefix",
|
|
72
|
+
endsWith: "Invalid suffix",
|
|
73
|
+
uuid: "Must be a valid UUID",
|
|
74
|
+
in: "Invalid value",
|
|
75
|
+
notIn: "Invalid value",
|
|
76
|
+
enum: "Invalid value",
|
|
77
|
+
range: "Out of range",
|
|
78
|
+
negative: "Must be negative",
|
|
79
|
+
nonNegative: "Must be positive or zero",
|
|
31
80
|
};
|
|
32
81
|
const TYPE_RULE_NAMES = new Set([
|
|
33
82
|
"string",
|
|
@@ -37,15 +86,8 @@ const TYPE_RULE_NAMES = new Set([
|
|
|
37
86
|
"array",
|
|
38
87
|
]);
|
|
39
88
|
let validationTranslator;
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
if (typeof rule.param !== "number")
|
|
43
|
-
return false;
|
|
44
|
-
const expected = `${rule.name === "min" ? "Minimum" : "Maximum"} ${rule.param}`;
|
|
45
|
-
return rule.message === expected;
|
|
46
|
-
}
|
|
47
|
-
const defaultMsg = STANDARD_MSGS[rule.name];
|
|
48
|
-
return defaultMsg !== undefined && rule.message === defaultMsg;
|
|
89
|
+
function hasCustomMessage(rule) {
|
|
90
|
+
return rule.hasCustomMessage === true;
|
|
49
91
|
}
|
|
50
92
|
function resolveValidationMessage(key, fallback, params) {
|
|
51
93
|
const translated = validationTranslator?.(key, params);
|
|
@@ -54,95 +96,133 @@ function resolveValidationMessage(key, fallback, params) {
|
|
|
54
96
|
}
|
|
55
97
|
return fallback;
|
|
56
98
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
99
|
+
/** Args carried on a rule, exposed to i18n/providers via message interpolation. */
|
|
100
|
+
function ruleArgs(rule) {
|
|
101
|
+
return rule.args;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Resolve the final message for a failing rule. Precedence:
|
|
105
|
+
* 1. explicit `.message()` override (always wins),
|
|
106
|
+
* 2. a per-call {@link MessagesProviderContract} (VineJS parity),
|
|
107
|
+
* 3. a globally bound translator (rune's rosetta superset),
|
|
108
|
+
* 4. the rule's raw default message.
|
|
109
|
+
*/
|
|
110
|
+
function resolveRuleMessage(field, rule, ctx) {
|
|
111
|
+
if (hasCustomMessage(rule)) {
|
|
112
|
+
return rule.message;
|
|
113
|
+
}
|
|
114
|
+
const args = ruleArgs(rule);
|
|
115
|
+
if (ctx.messagesProvider) {
|
|
116
|
+
return ctx.messagesProvider.getMessage(rule.message, rule.name, field, args);
|
|
61
117
|
}
|
|
62
|
-
if (!
|
|
63
|
-
return
|
|
118
|
+
if (!STANDARD_RULES.has(rule.name)) {
|
|
119
|
+
return rule.message;
|
|
64
120
|
}
|
|
65
121
|
const params = { field };
|
|
66
|
-
if (
|
|
67
|
-
|
|
122
|
+
if (typeof rule.param === "number") {
|
|
123
|
+
if (rule.name === "min" || rule.name === "minLength")
|
|
124
|
+
params.min = rule.param;
|
|
125
|
+
if (rule.name === "max" || rule.name === "maxLength")
|
|
126
|
+
params.max = rule.param;
|
|
68
127
|
}
|
|
69
|
-
|
|
70
|
-
|
|
128
|
+
return resolveValidationMessage(`validation.${rule.name}`, rule.message, params);
|
|
129
|
+
}
|
|
130
|
+
/** Resolve the "required" message through provider → translator → fallback. */
|
|
131
|
+
function resolveRequiredMessage(field, ctx) {
|
|
132
|
+
if (ctx.messagesProvider) {
|
|
133
|
+
return ctx.messagesProvider.getMessage(`${field} is required`, "required", field);
|
|
71
134
|
}
|
|
72
|
-
return resolveValidationMessage(
|
|
135
|
+
return resolveValidationMessage("validation.required", `${field} is required`, { field });
|
|
73
136
|
}
|
|
74
137
|
/** Compute once: does any field rule prevent dispatching to Rust? */
|
|
75
138
|
function detectHasCustomRules(fields) {
|
|
76
139
|
return Object.values(fields).some((chain) => {
|
|
140
|
+
if (chain.useRules.length > 0)
|
|
141
|
+
return true; // .use() rule — TS-only (Rust can't run JS)
|
|
142
|
+
if (chain.hasConditionalRequired)
|
|
143
|
+
return true; // requiredWhen — TS-only
|
|
144
|
+
if (chain.preTransforms.length > 0)
|
|
145
|
+
return true; // .parse() — TS-only
|
|
146
|
+
if (chain.transforms.length > 0)
|
|
147
|
+
return true; // .transform() — Rust gets only the NAME, can't run a JS fn
|
|
148
|
+
if (chain.isNullable)
|
|
149
|
+
return true; // .nullable() — the flag is not sent to the Rust engine
|
|
77
150
|
return chain.rules.some((r) => {
|
|
78
151
|
if (!STANDARD_RULES.has(r.name))
|
|
79
152
|
return true; // custom rule
|
|
80
|
-
if (
|
|
153
|
+
if (hasCustomMessage(r))
|
|
81
154
|
return true; // custom message
|
|
82
155
|
return false;
|
|
83
156
|
});
|
|
84
157
|
});
|
|
85
158
|
}
|
|
86
|
-
/**
|
|
87
|
-
* Create a validation schema.
|
|
88
|
-
*
|
|
89
|
-
* Pass `T` explicitly when the caller wants `result.data` typed as a concrete
|
|
90
|
-
* shape after `result.valid === true` narrows the union — runtime validation
|
|
91
|
-
* is unchanged, the generic only types the success branch.
|
|
92
|
-
*
|
|
93
|
-
* const RegisterValidator = schema<{ email: string; password: string }>({
|
|
94
|
-
* email: rules.string().email(),
|
|
95
|
-
* password: rules.string().min(8),
|
|
96
|
-
* });
|
|
97
|
-
*
|
|
98
|
-
* The default `Record<string, unknown>` matches the historical untyped surface
|
|
99
|
-
* so existing call sites that read `result.data` field-by-field with their
|
|
100
|
-
* own narrowing continue to compile.
|
|
101
|
-
*/
|
|
102
159
|
export function schema(fields) {
|
|
103
160
|
// Computed once at construction time, not per validate() call.
|
|
104
161
|
const hasCustomRules = detectHasCustomRules(fields);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
if (!hasCustomRules && !validationTranslator) {
|
|
121
|
-
if (isNativeAvailable()) {
|
|
122
|
-
return validateWithRust(fields, data);
|
|
123
|
-
}
|
|
124
|
-
// This schema would have used the native engine, but it isn't
|
|
125
|
-
// loaded — surface the platform-dependent TS fallback once instead
|
|
126
|
-
// of diverging silently. (Schemas with custom rules / a translator
|
|
127
|
-
// always run on TS by design and don't warn.)
|
|
128
|
-
warnNativeUnavailableOnce();
|
|
129
|
-
}
|
|
130
|
-
const errors = [];
|
|
131
|
-
const validated = {};
|
|
132
|
-
for (const [field, chain] of Object.entries(fields)) {
|
|
133
|
-
const value = data[field];
|
|
134
|
-
const result = chain._validateWithTransform(field, value);
|
|
135
|
-
errors.push(...result.errors);
|
|
136
|
-
if (result.errors.length === 0 && value !== undefined) {
|
|
137
|
-
validated[field] = result.transformed;
|
|
138
|
-
}
|
|
162
|
+
function validate(data, options) {
|
|
163
|
+
if (!isPlainObject(data)) {
|
|
164
|
+
return {
|
|
165
|
+
valid: false,
|
|
166
|
+
errors: [
|
|
167
|
+
{ field: "_root", rule: "type", message: "Input must be an object" },
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const provider = options?.messagesProvider;
|
|
172
|
+
if (!hasCustomRules && !validationTranslator && !provider) {
|
|
173
|
+
if (isNativeAvailable()) {
|
|
174
|
+
return validateWithRust(fields, data);
|
|
139
175
|
}
|
|
140
|
-
|
|
141
|
-
|
|
176
|
+
// This schema would have used the native engine, but it isn't loaded —
|
|
177
|
+
// surface the platform-dependent TS fallback once instead of diverging
|
|
178
|
+
// silently.
|
|
179
|
+
warnNativeUnavailableOnce();
|
|
180
|
+
}
|
|
181
|
+
const errors = [];
|
|
182
|
+
const validated = {};
|
|
183
|
+
const rootCtx = {
|
|
184
|
+
data,
|
|
185
|
+
parent: data,
|
|
186
|
+
meta: options?.meta ?? {},
|
|
187
|
+
messagesProvider: provider,
|
|
188
|
+
};
|
|
189
|
+
for (const [field, chain] of Object.entries(fields)) {
|
|
190
|
+
const value = data[field];
|
|
191
|
+
const result = chain._validateWithTransform(field, value, rootCtx);
|
|
192
|
+
errors.push(...result.errors);
|
|
193
|
+
// Gate on the TRANSFORMED result, not the raw input: a pre-transform
|
|
194
|
+
// (`parse(() => 42)`) can produce a value for an absent field, and that
|
|
195
|
+
// value must land in `data` — testing the raw `value` dropped it.
|
|
196
|
+
if (result.errors.length === 0 && result.transformed !== undefined) {
|
|
197
|
+
validated[field] = result.transformed;
|
|
142
198
|
}
|
|
143
|
-
|
|
144
|
-
|
|
199
|
+
}
|
|
200
|
+
if (errors.length === 0) {
|
|
201
|
+
return { valid: true, errors, data: validated };
|
|
202
|
+
}
|
|
203
|
+
return { valid: false, errors };
|
|
204
|
+
}
|
|
205
|
+
function validateOrThrow(data, options) {
|
|
206
|
+
const result = validate(data, options);
|
|
207
|
+
if (result.valid) {
|
|
208
|
+
return result.data;
|
|
209
|
+
}
|
|
210
|
+
throw new RuneValidationError(result.errors.map(toErrorNode));
|
|
211
|
+
}
|
|
212
|
+
return { fields, validate, validateOrThrow };
|
|
213
|
+
}
|
|
214
|
+
/** Map an internal {@link ValidationError} to a {@link RuneErrorNode}. */
|
|
215
|
+
function toErrorNode(error) {
|
|
216
|
+
const node = {
|
|
217
|
+
message: error.message,
|
|
218
|
+
rule: error.rule,
|
|
219
|
+
field: error.field,
|
|
145
220
|
};
|
|
221
|
+
if (error.index !== undefined)
|
|
222
|
+
node.index = error.index;
|
|
223
|
+
if (error.meta !== undefined)
|
|
224
|
+
node.meta = error.meta;
|
|
225
|
+
return node;
|
|
146
226
|
}
|
|
147
227
|
export function setValidationTranslator(translator) {
|
|
148
228
|
validationTranslator = translator;
|
|
@@ -150,13 +230,20 @@ export function setValidationTranslator(translator) {
|
|
|
150
230
|
export function bindRosetta(rosetta) {
|
|
151
231
|
setValidationTranslator((key, params) => rosetta.t(key, params));
|
|
152
232
|
}
|
|
153
|
-
/**
|
|
233
|
+
/** UUID (any version/variant) — identical to the Rust engine's pattern. */
|
|
234
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
235
|
+
/** Rule chain — fluent, phantom-typed validation builder. */
|
|
154
236
|
export class RuleChain {
|
|
155
237
|
#rules = [];
|
|
156
238
|
#isOptional = false;
|
|
239
|
+
#isNullable = false;
|
|
240
|
+
#bail = false;
|
|
157
241
|
#transforms = [];
|
|
242
|
+
#preTransforms = [];
|
|
158
243
|
#nestedSchema = null;
|
|
159
244
|
#arrayItemChain = null;
|
|
245
|
+
#useRules = [];
|
|
246
|
+
#requiredConditions = [];
|
|
160
247
|
/** Public read access to rules (for OpenAPI generation, Rust bridge). */
|
|
161
248
|
get rules() {
|
|
162
249
|
return this.#rules;
|
|
@@ -164,23 +251,75 @@ export class RuleChain {
|
|
|
164
251
|
get isOptionalField() {
|
|
165
252
|
return this.#isOptional;
|
|
166
253
|
}
|
|
254
|
+
/** Public read access to the `.nullable()` flag (keeps such schemas off the native path). */
|
|
255
|
+
get isNullable() {
|
|
256
|
+
return this.#isNullable;
|
|
257
|
+
}
|
|
167
258
|
get transforms() {
|
|
168
259
|
return this.#transforms;
|
|
169
260
|
}
|
|
170
|
-
/**
|
|
261
|
+
/** Public read access to `.use()` rules (used to keep such schemas off the native path). */
|
|
262
|
+
get useRules() {
|
|
263
|
+
return this.#useRules;
|
|
264
|
+
}
|
|
265
|
+
/** Public read access to `.parse()` pre-transforms (kept off the native path). */
|
|
266
|
+
get preTransforms() {
|
|
267
|
+
return this.#preTransforms;
|
|
268
|
+
}
|
|
269
|
+
/** Whether this chain carries a `requiredWhen`-family condition. */
|
|
270
|
+
get hasConditionalRequired() {
|
|
271
|
+
return this.#requiredConditions.length > 0;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Re-type this chain to a new phantom output while carrying its runtime state
|
|
275
|
+
* forward. Cast-free: `new RuleChain<U>()` is genuinely `RuleChain<U>` because
|
|
276
|
+
* the brand is `declare`-only. State arrays are copied so the abandoned source
|
|
277
|
+
* chain can't be mutated through the new one.
|
|
278
|
+
*/
|
|
279
|
+
#retype() {
|
|
280
|
+
const next = new _a();
|
|
281
|
+
next.#rules = [...this.#rules];
|
|
282
|
+
next.#isOptional = this.#isOptional;
|
|
283
|
+
next.#isNullable = this.#isNullable;
|
|
284
|
+
next.#bail = this.#bail;
|
|
285
|
+
next.#transforms = [...this.#transforms];
|
|
286
|
+
next.#preTransforms = [...this.#preTransforms];
|
|
287
|
+
next.#nestedSchema = this.#nestedSchema;
|
|
288
|
+
next.#arrayItemChain = this.#arrayItemChain;
|
|
289
|
+
next.#useRules = [...this.#useRules];
|
|
290
|
+
next.#requiredConditions = [...this.#requiredConditions];
|
|
291
|
+
return next;
|
|
292
|
+
}
|
|
293
|
+
/** Mark field as optional (absent / `undefined` allowed). */
|
|
171
294
|
optional() {
|
|
172
295
|
this.#isOptional = true;
|
|
173
296
|
return this;
|
|
174
297
|
}
|
|
298
|
+
/** Mark field as nullable (`null` allowed, kept in the output). */
|
|
299
|
+
nullable() {
|
|
300
|
+
this.#isNullable = true;
|
|
301
|
+
return this;
|
|
302
|
+
}
|
|
303
|
+
/** Mark field as both optional and nullable. */
|
|
304
|
+
nullish() {
|
|
305
|
+
this.#isOptional = true;
|
|
306
|
+
this.#isNullable = true;
|
|
307
|
+
return this;
|
|
308
|
+
}
|
|
309
|
+
/** Stop at the first failing rule for this field (VineJS bail). */
|
|
310
|
+
bail(enabled = true) {
|
|
311
|
+
this.#bail = enabled;
|
|
312
|
+
return this;
|
|
313
|
+
}
|
|
175
314
|
/** Must be an object matching a nested schema. */
|
|
176
315
|
object(shape) {
|
|
177
316
|
this.#rules.push({
|
|
178
317
|
name: "object",
|
|
179
|
-
validate: (v) =>
|
|
318
|
+
validate: (v) => isPlainObject(v),
|
|
180
319
|
message: "Must be an object",
|
|
181
320
|
});
|
|
182
321
|
this.#nestedSchema = shape;
|
|
183
|
-
return this;
|
|
322
|
+
return this.#retype();
|
|
184
323
|
}
|
|
185
324
|
/** Must be an array. Items validated by the provided chain. */
|
|
186
325
|
array(itemChain) {
|
|
@@ -190,7 +329,7 @@ export class RuleChain {
|
|
|
190
329
|
message: "Must be an array",
|
|
191
330
|
});
|
|
192
331
|
this.#arrayItemChain = itemChain ?? null;
|
|
193
|
-
return this;
|
|
332
|
+
return this.#retype();
|
|
194
333
|
}
|
|
195
334
|
/** Must be a string. */
|
|
196
335
|
string() {
|
|
@@ -199,7 +338,7 @@ export class RuleChain {
|
|
|
199
338
|
validate: (v) => typeof v === "string",
|
|
200
339
|
message: "Must be a string",
|
|
201
340
|
});
|
|
202
|
-
return this;
|
|
341
|
+
return this.#retype();
|
|
203
342
|
}
|
|
204
343
|
/** Must be a number. */
|
|
205
344
|
number() {
|
|
@@ -208,7 +347,7 @@ export class RuleChain {
|
|
|
208
347
|
validate: (v) => typeof v === "number" && !Number.isNaN(v) && Number.isFinite(v),
|
|
209
348
|
message: "Must be a number",
|
|
210
349
|
});
|
|
211
|
-
return this;
|
|
350
|
+
return this.#retype();
|
|
212
351
|
}
|
|
213
352
|
/** Must be a boolean. */
|
|
214
353
|
boolean() {
|
|
@@ -217,13 +356,35 @@ export class RuleChain {
|
|
|
217
356
|
validate: (v) => typeof v === "boolean",
|
|
218
357
|
message: "Must be a boolean",
|
|
219
358
|
});
|
|
220
|
-
return this;
|
|
359
|
+
return this.#retype();
|
|
360
|
+
}
|
|
361
|
+
/** Must equal one of `values` (enum). Narrows the output to the union. */
|
|
362
|
+
enum(values) {
|
|
363
|
+
const allowed = [...values];
|
|
364
|
+
this.#rules.push({
|
|
365
|
+
name: "enum",
|
|
366
|
+
args: { values: allowed },
|
|
367
|
+
validate: (v) => allowed.includes(asPrimitive(v)),
|
|
368
|
+
message: "Invalid value",
|
|
369
|
+
});
|
|
370
|
+
return this.#retype();
|
|
371
|
+
}
|
|
372
|
+
/** Must equal a literal value. */
|
|
373
|
+
literal(value) {
|
|
374
|
+
this.#rules.push({
|
|
375
|
+
name: "literal",
|
|
376
|
+
args: { expectedValue: value },
|
|
377
|
+
validate: (v) => v === value,
|
|
378
|
+
message: `Must be ${String(value)}`,
|
|
379
|
+
});
|
|
380
|
+
return this.#retype();
|
|
221
381
|
}
|
|
222
|
-
/** Minimum length (string) or minimum value (number). */
|
|
382
|
+
/** Minimum length (string) or minimum value (number). Alias of min/minLength. */
|
|
223
383
|
min(n) {
|
|
224
384
|
this.#rules.push({
|
|
225
385
|
name: "min",
|
|
226
386
|
param: n,
|
|
387
|
+
args: { min: n },
|
|
227
388
|
validate: (v) => typeof v === "string"
|
|
228
389
|
? [...v].length >= n
|
|
229
390
|
: typeof v === "number"
|
|
@@ -233,11 +394,12 @@ export class RuleChain {
|
|
|
233
394
|
});
|
|
234
395
|
return this;
|
|
235
396
|
}
|
|
236
|
-
/** Maximum length (string) or maximum value (number). */
|
|
397
|
+
/** Maximum length (string) or maximum value (number). Alias of max/maxLength. */
|
|
237
398
|
max(n) {
|
|
238
399
|
this.#rules.push({
|
|
239
400
|
name: "max",
|
|
240
401
|
param: n,
|
|
402
|
+
args: { max: n },
|
|
241
403
|
validate: (v) => typeof v === "string"
|
|
242
404
|
? [...v].length <= n
|
|
243
405
|
: typeof v === "number"
|
|
@@ -247,20 +409,141 @@ export class RuleChain {
|
|
|
247
409
|
});
|
|
248
410
|
return this;
|
|
249
411
|
}
|
|
412
|
+
/** Minimum length for a string or array (VineJS `minLength`). */
|
|
413
|
+
minLength(n) {
|
|
414
|
+
this.#rules.push({
|
|
415
|
+
name: "minLength",
|
|
416
|
+
param: n,
|
|
417
|
+
args: { min: n },
|
|
418
|
+
validate: (v) => sizedLength(v) >= n,
|
|
419
|
+
message: `Must have at least ${n} characters`,
|
|
420
|
+
});
|
|
421
|
+
return this;
|
|
422
|
+
}
|
|
423
|
+
/** Maximum length for a string or array (VineJS `maxLength`). */
|
|
424
|
+
maxLength(n) {
|
|
425
|
+
this.#rules.push({
|
|
426
|
+
name: "maxLength",
|
|
427
|
+
param: n,
|
|
428
|
+
args: { max: n },
|
|
429
|
+
validate: (v) => {
|
|
430
|
+
const len = sizedLength(v);
|
|
431
|
+
return len >= 0 && len <= n;
|
|
432
|
+
},
|
|
433
|
+
message: `Must not exceed ${n} characters`,
|
|
434
|
+
});
|
|
435
|
+
return this;
|
|
436
|
+
}
|
|
437
|
+
/** Exact length for a string or array (VineJS `fixedLength`). */
|
|
438
|
+
fixedLength(n) {
|
|
439
|
+
this.#rules.push({
|
|
440
|
+
name: "fixedLength",
|
|
441
|
+
param: n,
|
|
442
|
+
args: { size: n },
|
|
443
|
+
validate: (v) => sizedLength(v) === n,
|
|
444
|
+
message: `Must be exactly ${n} characters`,
|
|
445
|
+
});
|
|
446
|
+
return this;
|
|
447
|
+
}
|
|
250
448
|
/** Must be a valid email. */
|
|
251
449
|
email() {
|
|
252
450
|
this.#rules.push({
|
|
253
451
|
name: "email",
|
|
254
|
-
// Mirror the Rust engine's regex exactly
|
|
255
|
-
//
|
|
256
|
-
// binary loaded: no whitespace anywhere (the old TS rule rejected only
|
|
257
|
-
// \r\n, silently accepting interior spaces), a single @, dotted domain.
|
|
452
|
+
// Mirror the Rust engine's regex exactly so the SAME schema validates
|
|
453
|
+
// identically whether or not the native binary loaded.
|
|
258
454
|
validate: (v) => typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
|
|
259
455
|
message: "Must be a valid email",
|
|
260
456
|
});
|
|
261
457
|
return this;
|
|
262
458
|
}
|
|
263
|
-
/** Must
|
|
459
|
+
/** Must match a regular expression (TS-only — never dispatched to Rust). */
|
|
460
|
+
regex(pattern) {
|
|
461
|
+
this.#rules.push({
|
|
462
|
+
name: "regex",
|
|
463
|
+
validate: (v) => typeof v === "string" && pattern.test(v),
|
|
464
|
+
message: "Invalid format",
|
|
465
|
+
});
|
|
466
|
+
return this;
|
|
467
|
+
}
|
|
468
|
+
/** Must be a valid URL (TS-only — uses the WHATWG URL parser). */
|
|
469
|
+
url() {
|
|
470
|
+
this.#rules.push({
|
|
471
|
+
name: "url",
|
|
472
|
+
validate: (v) => typeof v === "string" && isValidUrl(v),
|
|
473
|
+
message: "Must be a valid URL",
|
|
474
|
+
});
|
|
475
|
+
return this;
|
|
476
|
+
}
|
|
477
|
+
/** Must be a valid UUID. */
|
|
478
|
+
uuid() {
|
|
479
|
+
this.#rules.push({
|
|
480
|
+
name: "uuid",
|
|
481
|
+
validate: (v) => typeof v === "string" && UUID_RE.test(v),
|
|
482
|
+
message: "Must be a valid UUID",
|
|
483
|
+
});
|
|
484
|
+
return this;
|
|
485
|
+
}
|
|
486
|
+
/** Must contain only ASCII letters. */
|
|
487
|
+
alpha() {
|
|
488
|
+
this.#rules.push({
|
|
489
|
+
name: "alpha",
|
|
490
|
+
validate: (v) => typeof v === "string" && v.length > 0 && /^[a-zA-Z]+$/.test(v),
|
|
491
|
+
message: "Must contain only letters",
|
|
492
|
+
});
|
|
493
|
+
return this;
|
|
494
|
+
}
|
|
495
|
+
/** Must contain only ASCII letters and digits. */
|
|
496
|
+
alphaNumeric() {
|
|
497
|
+
this.#rules.push({
|
|
498
|
+
name: "alphaNumeric",
|
|
499
|
+
validate: (v) => typeof v === "string" && v.length > 0 && /^[a-zA-Z0-9]+$/.test(v),
|
|
500
|
+
message: "Must contain only letters and numbers",
|
|
501
|
+
});
|
|
502
|
+
return this;
|
|
503
|
+
}
|
|
504
|
+
/** String must start with `substring`. */
|
|
505
|
+
startsWith(substring) {
|
|
506
|
+
this.#rules.push({
|
|
507
|
+
name: "startsWith",
|
|
508
|
+
args: { substring },
|
|
509
|
+
validate: (v) => typeof v === "string" && v.startsWith(substring),
|
|
510
|
+
message: `Must start with ${substring}`,
|
|
511
|
+
});
|
|
512
|
+
return this;
|
|
513
|
+
}
|
|
514
|
+
/** String must end with `substring`. */
|
|
515
|
+
endsWith(substring) {
|
|
516
|
+
this.#rules.push({
|
|
517
|
+
name: "endsWith",
|
|
518
|
+
args: { substring },
|
|
519
|
+
validate: (v) => typeof v === "string" && v.endsWith(substring),
|
|
520
|
+
message: `Must end with ${substring}`,
|
|
521
|
+
});
|
|
522
|
+
return this;
|
|
523
|
+
}
|
|
524
|
+
/** Value must be one of `values`. */
|
|
525
|
+
in(values) {
|
|
526
|
+
const allowed = [...values];
|
|
527
|
+
this.#rules.push({
|
|
528
|
+
name: "in",
|
|
529
|
+
args: { values: allowed },
|
|
530
|
+
validate: (v) => allowed.includes(asPrimitive(v)),
|
|
531
|
+
message: "Invalid value",
|
|
532
|
+
});
|
|
533
|
+
return this;
|
|
534
|
+
}
|
|
535
|
+
/** Value must NOT be one of `values`. */
|
|
536
|
+
notIn(values) {
|
|
537
|
+
const denied = [...values];
|
|
538
|
+
this.#rules.push({
|
|
539
|
+
name: "notIn",
|
|
540
|
+
args: { values: denied },
|
|
541
|
+
validate: (v) => !denied.includes(asPrimitive(v)),
|
|
542
|
+
message: "Invalid value",
|
|
543
|
+
});
|
|
544
|
+
return this;
|
|
545
|
+
}
|
|
546
|
+
/** Number must be positive (> 0) and finite. */
|
|
264
547
|
positive() {
|
|
265
548
|
this.#rules.push({
|
|
266
549
|
name: "positive",
|
|
@@ -269,6 +552,96 @@ export class RuleChain {
|
|
|
269
552
|
});
|
|
270
553
|
return this;
|
|
271
554
|
}
|
|
555
|
+
/** Number must be negative (< 0) and finite. */
|
|
556
|
+
negative() {
|
|
557
|
+
this.#rules.push({
|
|
558
|
+
name: "negative",
|
|
559
|
+
validate: (v) => typeof v === "number" && Number.isFinite(v) && v < 0,
|
|
560
|
+
message: "Must be negative",
|
|
561
|
+
});
|
|
562
|
+
return this;
|
|
563
|
+
}
|
|
564
|
+
/** Number must be >= 0 and finite. */
|
|
565
|
+
nonNegative() {
|
|
566
|
+
this.#rules.push({
|
|
567
|
+
name: "nonNegative",
|
|
568
|
+
validate: (v) => typeof v === "number" && Number.isFinite(v) && v >= 0,
|
|
569
|
+
message: "Must be positive or zero",
|
|
570
|
+
});
|
|
571
|
+
return this;
|
|
572
|
+
}
|
|
573
|
+
/** Number must fall within `[min, max]` (inclusive). */
|
|
574
|
+
range(min, max) {
|
|
575
|
+
this.#rules.push({
|
|
576
|
+
name: "range",
|
|
577
|
+
args: { min, max },
|
|
578
|
+
validate: (v) => typeof v === "number" && Number.isFinite(v) && v >= min && v <= max,
|
|
579
|
+
message: `Must be between ${min} and ${max}`,
|
|
580
|
+
});
|
|
581
|
+
return this;
|
|
582
|
+
}
|
|
583
|
+
/** Number must have at most `digits` decimal places (TS-only). */
|
|
584
|
+
decimal(digits) {
|
|
585
|
+
this.#rules.push({
|
|
586
|
+
name: "decimal",
|
|
587
|
+
args: { digits },
|
|
588
|
+
validate: (v) => {
|
|
589
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
590
|
+
return false;
|
|
591
|
+
const parts = String(v).split(".");
|
|
592
|
+
return (parts[1]?.length ?? 0) <= digits;
|
|
593
|
+
},
|
|
594
|
+
message: `Must have at most ${digits} decimal places`,
|
|
595
|
+
});
|
|
596
|
+
return this;
|
|
597
|
+
}
|
|
598
|
+
/** Must equal a sibling field (VineJS `sameAs`). Cross-field → TS-only. */
|
|
599
|
+
sameAs(otherField) {
|
|
600
|
+
this.#useRules.push({
|
|
601
|
+
__rune: "rule",
|
|
602
|
+
run: (value, field) => {
|
|
603
|
+
const other = readSibling(field, otherField);
|
|
604
|
+
if (value !== other) {
|
|
605
|
+
field.report(`Must match ${otherField}`, "sameAs");
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
return this;
|
|
610
|
+
}
|
|
611
|
+
/** Must equal its `<field>_confirmation` sibling (VineJS `confirmed`). */
|
|
612
|
+
confirmed(options) {
|
|
613
|
+
this.#useRules.push({
|
|
614
|
+
__rune: "rule",
|
|
615
|
+
run: (value, field) => {
|
|
616
|
+
const leaf = field.field.split(".").pop() ?? field.field;
|
|
617
|
+
const other = options?.confirmationField ?? `${leaf}_confirmation`;
|
|
618
|
+
if (value !== readSibling(field, other)) {
|
|
619
|
+
field.report("Confirmation does not match", "confirmed");
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
return this;
|
|
624
|
+
}
|
|
625
|
+
/** Required only when `otherField` is present (non-null) — else optional. */
|
|
626
|
+
requiredIfExists(otherField) {
|
|
627
|
+
this.#requiredConditions.push({ kind: "exists", otherField });
|
|
628
|
+
return this;
|
|
629
|
+
}
|
|
630
|
+
/** Required only when `otherField` is absent/null — else optional. */
|
|
631
|
+
requiredIfMissing(otherField) {
|
|
632
|
+
this.#requiredConditions.push({ kind: "missing", otherField });
|
|
633
|
+
return this;
|
|
634
|
+
}
|
|
635
|
+
/** Required only when `otherField <op> value` holds — else optional. */
|
|
636
|
+
requiredWhen(otherField, operator, value) {
|
|
637
|
+
this.#requiredConditions.push({
|
|
638
|
+
kind: "when",
|
|
639
|
+
otherField,
|
|
640
|
+
operator,
|
|
641
|
+
value,
|
|
642
|
+
});
|
|
643
|
+
return this;
|
|
644
|
+
}
|
|
272
645
|
/** Trim whitespace (transform). */
|
|
273
646
|
trim() {
|
|
274
647
|
this.#transforms.push({
|
|
@@ -277,6 +650,21 @@ export class RuleChain {
|
|
|
277
650
|
});
|
|
278
651
|
return this;
|
|
279
652
|
}
|
|
653
|
+
/**
|
|
654
|
+
* Post-validation transform changing the output type (VineJS `transform`).
|
|
655
|
+
* `value` is `unknown` — narrow it in the callback (the no-cast rule forbids
|
|
656
|
+
* lying about a dynamically-produced value's static type).
|
|
657
|
+
*/
|
|
658
|
+
transform(fn) {
|
|
659
|
+
const next = this.#retype();
|
|
660
|
+
next.#transforms.push({ name: "transform", fn: (v, f) => fn(v, f) });
|
|
661
|
+
return next;
|
|
662
|
+
}
|
|
663
|
+
/** Pre-validation transform of the raw input (VineJS `parse`). */
|
|
664
|
+
parse(fn) {
|
|
665
|
+
this.#preTransforms.push(fn);
|
|
666
|
+
return this;
|
|
667
|
+
}
|
|
280
668
|
/** Custom validation rule. */
|
|
281
669
|
custom(name, validate, message) {
|
|
282
670
|
this.#rules.push({
|
|
@@ -286,86 +674,153 @@ export class RuleChain {
|
|
|
286
674
|
});
|
|
287
675
|
return this;
|
|
288
676
|
}
|
|
677
|
+
/**
|
|
678
|
+
* Attach a `.use()` rule (from {@link createRule}). Unlike `custom`, the rule
|
|
679
|
+
* receives a {@link FieldContext} with the root `data` and `parent`, so it can
|
|
680
|
+
* validate across fields. Runs after this field's type/value rules.
|
|
681
|
+
*/
|
|
682
|
+
use(rule) {
|
|
683
|
+
if (rule?.__rune !== "rule" || typeof rule.run !== "function") {
|
|
684
|
+
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
|
+
}
|
|
686
|
+
this.#useRules.push(rule);
|
|
687
|
+
return this;
|
|
688
|
+
}
|
|
289
689
|
/** Set custom error message for the last rule. */
|
|
290
690
|
message(msg) {
|
|
291
691
|
if (this.#rules.length === 0) {
|
|
292
692
|
throw new RuneError("NO_RULE", "message() must be called after a rule");
|
|
293
693
|
}
|
|
294
|
-
this.#rules[this.#rules.length - 1]
|
|
694
|
+
const last = this.#rules[this.#rules.length - 1];
|
|
695
|
+
last.message = msg;
|
|
696
|
+
last.hasCustomMessage = true;
|
|
295
697
|
return this;
|
|
296
698
|
}
|
|
699
|
+
/** Whether the field is required given the surrounding data (conditionals). */
|
|
700
|
+
#isRequired(ctx) {
|
|
701
|
+
if (this.#requiredConditions.length === 0)
|
|
702
|
+
return true;
|
|
703
|
+
return this.#requiredConditions.some((cond) => evalRequiredCondition(cond, ctx));
|
|
704
|
+
}
|
|
297
705
|
/** Internal: validate a field value and return errors + transformed value. */
|
|
298
|
-
_validateWithTransform(field,
|
|
299
|
-
|
|
300
|
-
|
|
706
|
+
_validateWithTransform(field, rawValue, ctx = EMPTY_RUN_CONTEXT) {
|
|
707
|
+
// 0. Pre-validation parse() transforms run on the raw value first.
|
|
708
|
+
let value = rawValue;
|
|
709
|
+
for (const pre of this.#preTransforms) {
|
|
710
|
+
value = pre(value);
|
|
711
|
+
}
|
|
712
|
+
if (value === undefined) {
|
|
713
|
+
if (this.#isOptional || !this.#isRequired(ctx)) {
|
|
301
714
|
return { errors: [], transformed: value };
|
|
302
|
-
|
|
715
|
+
}
|
|
716
|
+
return { errors: [this.#requiredError(field, ctx)], transformed: value };
|
|
717
|
+
}
|
|
718
|
+
if (value === null) {
|
|
719
|
+
// rune treats a `null` value as "absent" for optional/nullable/
|
|
720
|
+
// non-required fields (a deliberate, cross-engine-conformance-tested
|
|
721
|
+
// choice — see the Rust↔TS parity suite). `.nullable()` additionally
|
|
722
|
+
// keeps it in the output. This unifies null/undefined for optional
|
|
723
|
+
// fields, a documented deviation from VineJS's stricter split.
|
|
724
|
+
if (this.#isNullable || this.#isOptional || !this.#isRequired(ctx)) {
|
|
725
|
+
return { errors: [], transformed: value };
|
|
726
|
+
}
|
|
727
|
+
return { errors: [this.#requiredError(field, ctx)], transformed: value };
|
|
303
728
|
}
|
|
304
729
|
// 1. Type rules first on the raw value — bail on type mismatch.
|
|
305
|
-
const typeError = this.#runTypeRules(field, value);
|
|
730
|
+
const typeError = this.#runTypeRules(field, value, ctx);
|
|
306
731
|
if (typeError)
|
|
307
732
|
return { errors: [typeError], transformed: value };
|
|
308
733
|
// 2. Apply transforms (trim, etc.), then run value rules on the result.
|
|
309
|
-
let transformed = this.#applyTransformsTo(value);
|
|
310
|
-
const errors = this.#runValueRules(field, transformed);
|
|
734
|
+
let transformed = this.#applyTransformsTo(value, field, ctx);
|
|
735
|
+
const errors = this.#runValueRules(field, transformed, ctx);
|
|
736
|
+
// 3. Vine-style .use() rules — run with a FieldContext exposing the root
|
|
737
|
+
// `data` and `parent`, so a rule can validate across fields.
|
|
738
|
+
if (this.#useRules.length > 0 && !(this.#bail && errors.length > 0)) {
|
|
739
|
+
this.#runUseRules(field, transformed, ctx, errors);
|
|
740
|
+
}
|
|
311
741
|
// 4. Nested object validation (only if type check passed — not arrays)
|
|
312
|
-
if (this.#nestedSchema &&
|
|
313
|
-
|
|
314
|
-
transformed
|
|
315
|
-
!Array.isArray(transformed)) {
|
|
316
|
-
transformed = { ...transformed };
|
|
742
|
+
if (this.#nestedSchema && isPlainObject(transformed)) {
|
|
743
|
+
const obj = { ...transformed };
|
|
744
|
+
transformed = obj;
|
|
317
745
|
for (const [nestedField, chain] of Object.entries(this.#nestedSchema)) {
|
|
318
|
-
const
|
|
319
|
-
const nestedResult = chain._validateWithTransform(`${field}.${nestedField}`, nestedValue);
|
|
746
|
+
const nestedResult = chain._validateWithTransform(`${field}.${nestedField}`, obj[nestedField], { ...ctx, parent: obj });
|
|
320
747
|
errors.push(...nestedResult.errors);
|
|
321
748
|
if (nestedResult.transformed !== undefined) {
|
|
322
|
-
|
|
323
|
-
nestedResult.transformed;
|
|
749
|
+
obj[nestedField] = nestedResult.transformed;
|
|
324
750
|
}
|
|
325
751
|
}
|
|
326
752
|
}
|
|
327
753
|
// 5. Array item validation
|
|
328
754
|
if (this.#arrayItemChain && Array.isArray(transformed)) {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
755
|
+
const arr = [...transformed];
|
|
756
|
+
transformed = arr;
|
|
757
|
+
for (let i = 0; i < arr.length; i++) {
|
|
758
|
+
const itemResult = this.#arrayItemChain._validateWithTransform(`${field}.${i}`, arr[i], { ...ctx, parent: arr });
|
|
759
|
+
for (const e of itemResult.errors) {
|
|
760
|
+
if (e.index === undefined)
|
|
761
|
+
e.index = i;
|
|
762
|
+
}
|
|
332
763
|
errors.push(...itemResult.errors);
|
|
333
764
|
if (itemResult.transformed !== undefined) {
|
|
334
|
-
|
|
765
|
+
arr[i] = itemResult.transformed;
|
|
335
766
|
}
|
|
336
767
|
}
|
|
337
768
|
}
|
|
338
769
|
return { errors, transformed };
|
|
339
770
|
}
|
|
340
|
-
|
|
771
|
+
/** Run `.use()` rules on the transformed value with a fresh FieldContext. */
|
|
772
|
+
#runUseRules(field, transformed, ctx, errors) {
|
|
773
|
+
const fieldCtx = {
|
|
774
|
+
value: transformed,
|
|
775
|
+
data: ctx.data,
|
|
776
|
+
parent: ctx.parent,
|
|
777
|
+
field,
|
|
778
|
+
meta: ctx.meta,
|
|
779
|
+
isValid: errors.length === 0,
|
|
780
|
+
report(message, rule) {
|
|
781
|
+
errors.push({ field, rule, message });
|
|
782
|
+
},
|
|
783
|
+
};
|
|
784
|
+
for (const rule of this.#useRules) {
|
|
785
|
+
fieldCtx.isValid = errors.length === 0;
|
|
786
|
+
rule.run(transformed, fieldCtx);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
#requiredError(field, ctx) {
|
|
341
790
|
return {
|
|
342
791
|
field,
|
|
343
792
|
rule: "required",
|
|
344
|
-
message:
|
|
793
|
+
message: resolveRequiredMessage(field, ctx),
|
|
345
794
|
};
|
|
346
795
|
}
|
|
347
796
|
/** Run the type rules (string/number/…) on the raw value; first failure bails. */
|
|
348
|
-
#runTypeRules(field, value) {
|
|
797
|
+
#runTypeRules(field, value, ctx) {
|
|
349
798
|
for (const rule of this.#rules) {
|
|
350
799
|
if (TYPE_RULE_NAMES.has(rule.name) && !rule.validate(value)) {
|
|
351
800
|
return {
|
|
352
801
|
field,
|
|
353
802
|
rule: rule.name,
|
|
354
|
-
message: resolveRuleMessage(field, rule),
|
|
803
|
+
message: resolveRuleMessage(field, rule, ctx),
|
|
804
|
+
...(ruleArgs(rule) ? { meta: ruleArgs(rule) } : {}),
|
|
355
805
|
};
|
|
356
806
|
}
|
|
357
807
|
}
|
|
358
808
|
return null;
|
|
359
809
|
}
|
|
360
810
|
/** Run the non-type rules (min/max/email/…) on the transformed value. */
|
|
361
|
-
#runValueRules(field, transformed) {
|
|
811
|
+
#runValueRules(field, transformed, ctx) {
|
|
362
812
|
const errors = [];
|
|
363
813
|
for (const rule of this.#rules) {
|
|
364
|
-
if (
|
|
814
|
+
if (TYPE_RULE_NAMES.has(rule.name))
|
|
815
|
+
continue;
|
|
816
|
+
if (this.#bail && errors.length > 0)
|
|
817
|
+
break;
|
|
818
|
+
if (!rule.validate(transformed)) {
|
|
365
819
|
errors.push({
|
|
366
820
|
field,
|
|
367
821
|
rule: rule.name,
|
|
368
|
-
message: resolveRuleMessage(field, rule),
|
|
822
|
+
message: resolveRuleMessage(field, rule, ctx),
|
|
823
|
+
...(ruleArgs(rule) ? { meta: ruleArgs(rule) } : {}),
|
|
369
824
|
});
|
|
370
825
|
}
|
|
371
826
|
}
|
|
@@ -377,40 +832,139 @@ export class RuleChain {
|
|
|
377
832
|
}
|
|
378
833
|
/** Internal: apply transforms. */
|
|
379
834
|
_transform(value) {
|
|
380
|
-
return this.#applyTransformsTo(value);
|
|
835
|
+
return this.#applyTransformsTo(value, "", EMPTY_RUN_CONTEXT);
|
|
381
836
|
}
|
|
382
|
-
#applyTransformsTo(value) {
|
|
837
|
+
#applyTransformsTo(value, field, ctx) {
|
|
383
838
|
let result = value;
|
|
384
839
|
for (const transform of this.#transforms) {
|
|
385
|
-
|
|
840
|
+
LAST_FIELD.value = result;
|
|
841
|
+
LAST_FIELD.data = ctx.data;
|
|
842
|
+
LAST_FIELD.parent = ctx.parent;
|
|
843
|
+
LAST_FIELD.field = field;
|
|
844
|
+
LAST_FIELD.meta = ctx.meta;
|
|
845
|
+
result = transform.fn(result, LAST_FIELD);
|
|
386
846
|
}
|
|
387
847
|
return result;
|
|
388
848
|
}
|
|
389
849
|
}
|
|
850
|
+
_a = RuleChain;
|
|
851
|
+
/**
|
|
852
|
+
* Scratch FieldContext reused for `.transform()` callbacks — transforms run
|
|
853
|
+
* inline in {@link RuleChain.#applyTransformsTo}, which repopulates it before
|
|
854
|
+
* each call. A shared object avoids per-transform allocation; it is never
|
|
855
|
+
* retained across calls.
|
|
856
|
+
*/
|
|
857
|
+
const LAST_FIELD = {
|
|
858
|
+
value: undefined,
|
|
859
|
+
data: {},
|
|
860
|
+
parent: {},
|
|
861
|
+
field: "",
|
|
862
|
+
meta: {},
|
|
863
|
+
isValid: true,
|
|
864
|
+
report() { },
|
|
865
|
+
};
|
|
866
|
+
/** Coerce a value to a comparable primitive for `in`/`enum` membership. */
|
|
867
|
+
function asPrimitive(value) {
|
|
868
|
+
if (typeof value === "string" ||
|
|
869
|
+
typeof value === "number" ||
|
|
870
|
+
typeof value === "boolean") {
|
|
871
|
+
return value;
|
|
872
|
+
}
|
|
873
|
+
// Non-primitive values can never be a member of a primitive set; return a
|
|
874
|
+
// sentinel that no allowed entry equals.
|
|
875
|
+
return Symbol.iterator.toString();
|
|
876
|
+
}
|
|
877
|
+
/** Code-point length for a string, element count for an array, else -1. */
|
|
878
|
+
function sizedLength(value) {
|
|
879
|
+
if (typeof value === "string")
|
|
880
|
+
return [...value].length;
|
|
881
|
+
if (Array.isArray(value))
|
|
882
|
+
return value.length;
|
|
883
|
+
return -1;
|
|
884
|
+
}
|
|
885
|
+
/** WHATWG URL validity — accepts only http/https to avoid `mailto:` etc. */
|
|
886
|
+
function isValidUrl(value) {
|
|
887
|
+
try {
|
|
888
|
+
const url = new URL(value);
|
|
889
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
890
|
+
}
|
|
891
|
+
catch {
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
/** Read a sibling field's value from the immediate parent (object only). */
|
|
896
|
+
function readSibling(field, name) {
|
|
897
|
+
const parent = field.parent;
|
|
898
|
+
if (Array.isArray(parent))
|
|
899
|
+
return undefined;
|
|
900
|
+
return parent[name];
|
|
901
|
+
}
|
|
902
|
+
/** Evaluate whether a `requiredWhen`-family condition makes the field required. */
|
|
903
|
+
function evalRequiredCondition(cond, ctx) {
|
|
904
|
+
const other = isPlainObject(ctx.parent)
|
|
905
|
+
? ctx.parent[cond.otherField]
|
|
906
|
+
: ctx.data[cond.otherField];
|
|
907
|
+
const present = other !== undefined && other !== null;
|
|
908
|
+
if (cond.kind === "exists")
|
|
909
|
+
return present;
|
|
910
|
+
if (cond.kind === "missing")
|
|
911
|
+
return !present;
|
|
912
|
+
switch (cond.operator) {
|
|
913
|
+
case "=":
|
|
914
|
+
return other === cond.value;
|
|
915
|
+
case "!=":
|
|
916
|
+
return other !== cond.value;
|
|
917
|
+
case ">":
|
|
918
|
+
return typeof other === "number" && typeof cond.value === "number"
|
|
919
|
+
? other > cond.value
|
|
920
|
+
: false;
|
|
921
|
+
case "<":
|
|
922
|
+
return typeof other === "number" && typeof cond.value === "number"
|
|
923
|
+
? other < cond.value
|
|
924
|
+
: false;
|
|
925
|
+
case ">=":
|
|
926
|
+
return typeof other === "number" && typeof cond.value === "number"
|
|
927
|
+
? other >= cond.value
|
|
928
|
+
: false;
|
|
929
|
+
case "<=":
|
|
930
|
+
return typeof other === "number" && typeof cond.value === "number"
|
|
931
|
+
? other <= cond.value
|
|
932
|
+
: false;
|
|
933
|
+
case "in":
|
|
934
|
+
return Array.isArray(cond.value) && cond.value.includes(other);
|
|
935
|
+
case "notIn":
|
|
936
|
+
return Array.isArray(cond.value) && !cond.value.includes(other);
|
|
937
|
+
default:
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
/** No-op alias of {@link schema} — VineJS `vine.compile()` API parity. */
|
|
942
|
+
export function compile(s) {
|
|
943
|
+
return s;
|
|
944
|
+
}
|
|
390
945
|
/** Entry point for building rules. */
|
|
391
946
|
export const rules = {
|
|
392
947
|
string: () => new RuleChain().string(),
|
|
393
948
|
number: () => new RuleChain().number(),
|
|
394
949
|
boolean: () => new RuleChain().boolean(),
|
|
395
950
|
any: () => new RuleChain(),
|
|
951
|
+
object: (shape) => new RuleChain().object(shape),
|
|
952
|
+
array: (item) => new RuleChain().array(item),
|
|
953
|
+
enum: (values) => new RuleChain().enum(values),
|
|
954
|
+
literal: (value) => new RuleChain().literal(value),
|
|
396
955
|
};
|
|
397
956
|
/** Serialize schema + data and validate via Rust NAPI. */
|
|
398
957
|
function validateWithRust(fields, data) {
|
|
399
958
|
const schemaDesc = {};
|
|
400
959
|
for (const [field, chain] of Object.entries(fields)) {
|
|
401
|
-
const
|
|
960
|
+
const ruleDescs = chain.rules.map((r) => ({
|
|
402
961
|
name: r.name,
|
|
403
|
-
// Serialize THIS rule's own
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
params: r.name === "min"
|
|
407
|
-
? { min: r.param }
|
|
408
|
-
: r.name === "max"
|
|
409
|
-
? { max: r.param }
|
|
410
|
-
: null,
|
|
962
|
+
// Serialize THIS rule's own args (per-rule, so min(3).min(5) keeps both
|
|
963
|
+
// bounds — a find-first lookup previously collapsed them).
|
|
964
|
+
params: r.args ?? null,
|
|
411
965
|
}));
|
|
412
966
|
schemaDesc[field] = {
|
|
413
|
-
rules,
|
|
967
|
+
rules: ruleDescs,
|
|
414
968
|
optional: chain.isOptionalField,
|
|
415
969
|
transforms: chain.transforms.map((t) => t.name),
|
|
416
970
|
};
|