@c9up/rune 0.1.6 → 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 +156 -26
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +628 -131
- 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 +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- 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 +814 -147
- package/src/errors.ts +48 -0
- package/src/index.ts +4 -1
package/dist/Schema.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
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";
|
|
8
9
|
export function createRule(validator) {
|
|
9
10
|
return (options) => ({
|
|
@@ -19,7 +20,17 @@ const EMPTY_RUN_CONTEXT = { data: {}, parent: {}, meta: {} };
|
|
|
19
20
|
function isPlainObject(value) {
|
|
20
21
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
22
|
}
|
|
22
|
-
/**
|
|
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
|
+
*/
|
|
23
34
|
const STANDARD_RULES = new Set([
|
|
24
35
|
"string",
|
|
25
36
|
"number",
|
|
@@ -28,8 +39,22 @@ const STANDARD_RULES = new Set([
|
|
|
28
39
|
"max",
|
|
29
40
|
"email",
|
|
30
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",
|
|
31
56
|
]);
|
|
32
|
-
/** Default messages for standard rules — used
|
|
57
|
+
/** Default messages for standard rules — used only for translator-key fallback. */
|
|
33
58
|
const STANDARD_MSGS = {
|
|
34
59
|
string: "Must be a string",
|
|
35
60
|
number: "Must be a number",
|
|
@@ -38,6 +63,20 @@ const STANDARD_MSGS = {
|
|
|
38
63
|
max: "Maximum",
|
|
39
64
|
email: "Must be a valid email",
|
|
40
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",
|
|
41
80
|
};
|
|
42
81
|
const TYPE_RULE_NAMES = new Set([
|
|
43
82
|
"string",
|
|
@@ -47,15 +86,8 @@ const TYPE_RULE_NAMES = new Set([
|
|
|
47
86
|
"array",
|
|
48
87
|
]);
|
|
49
88
|
let validationTranslator;
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
if (typeof rule.param !== "number")
|
|
53
|
-
return false;
|
|
54
|
-
const expected = `${rule.name === "min" ? "Minimum" : "Maximum"} ${rule.param}`;
|
|
55
|
-
return rule.message === expected;
|
|
56
|
-
}
|
|
57
|
-
const defaultMsg = STANDARD_MSGS[rule.name];
|
|
58
|
-
return defaultMsg !== undefined && rule.message === defaultMsg;
|
|
89
|
+
function hasCustomMessage(rule) {
|
|
90
|
+
return rule.hasCustomMessage === true;
|
|
59
91
|
}
|
|
60
92
|
function resolveValidationMessage(key, fallback, params) {
|
|
61
93
|
const translated = validationTranslator?.(key, params);
|
|
@@ -64,104 +96,133 @@ function resolveValidationMessage(key, fallback, params) {
|
|
|
64
96
|
}
|
|
65
97
|
return fallback;
|
|
66
98
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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;
|
|
71
113
|
}
|
|
72
|
-
|
|
73
|
-
|
|
114
|
+
const args = ruleArgs(rule);
|
|
115
|
+
if (ctx.messagesProvider) {
|
|
116
|
+
return ctx.messagesProvider.getMessage(rule.message, rule.name, field, args);
|
|
117
|
+
}
|
|
118
|
+
if (!STANDARD_RULES.has(rule.name)) {
|
|
119
|
+
return rule.message;
|
|
74
120
|
}
|
|
75
121
|
const params = { field };
|
|
76
|
-
if (
|
|
77
|
-
|
|
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;
|
|
78
127
|
}
|
|
79
|
-
|
|
80
|
-
|
|
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);
|
|
81
134
|
}
|
|
82
|
-
return resolveValidationMessage(
|
|
135
|
+
return resolveValidationMessage("validation.required", `${field} is required`, { field });
|
|
83
136
|
}
|
|
84
137
|
/** Compute once: does any field rule prevent dispatching to Rust? */
|
|
85
138
|
function detectHasCustomRules(fields) {
|
|
86
139
|
return Object.values(fields).some((chain) => {
|
|
87
140
|
if (chain.useRules.length > 0)
|
|
88
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
|
|
89
150
|
return chain.rules.some((r) => {
|
|
90
151
|
if (!STANDARD_RULES.has(r.name))
|
|
91
152
|
return true; // custom rule
|
|
92
|
-
if (
|
|
153
|
+
if (hasCustomMessage(r))
|
|
93
154
|
return true; // custom message
|
|
94
155
|
return false;
|
|
95
156
|
});
|
|
96
157
|
});
|
|
97
158
|
}
|
|
98
|
-
/**
|
|
99
|
-
* Create a validation schema.
|
|
100
|
-
*
|
|
101
|
-
* Pass `T` explicitly when the caller wants `result.data` typed as a concrete
|
|
102
|
-
* shape after `result.valid === true` narrows the union — runtime validation
|
|
103
|
-
* is unchanged, the generic only types the success branch.
|
|
104
|
-
*
|
|
105
|
-
* const RegisterValidator = schema<{ email: string; password: string }>({
|
|
106
|
-
* email: rules.string().email(),
|
|
107
|
-
* password: rules.string().min(8),
|
|
108
|
-
* });
|
|
109
|
-
*
|
|
110
|
-
* The default `Record<string, unknown>` matches the historical untyped surface
|
|
111
|
-
* so existing call sites that read `result.data` field-by-field with their
|
|
112
|
-
* own narrowing continue to compile.
|
|
113
|
-
*/
|
|
114
159
|
export function schema(fields) {
|
|
115
160
|
// Computed once at construction time, not per validate() call.
|
|
116
161
|
const hasCustomRules = detectHasCustomRules(fields);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
{
|
|
125
|
-
field: "_root",
|
|
126
|
-
rule: "type",
|
|
127
|
-
message: "Input must be an object",
|
|
128
|
-
},
|
|
129
|
-
],
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
if (!hasCustomRules && !validationTranslator) {
|
|
133
|
-
if (isNativeAvailable()) {
|
|
134
|
-
return validateWithRust(fields, data);
|
|
135
|
-
}
|
|
136
|
-
// This schema would have used the native engine, but it isn't
|
|
137
|
-
// loaded — surface the platform-dependent TS fallback once instead
|
|
138
|
-
// of diverging silently. (Schemas with custom rules / a translator
|
|
139
|
-
// always run on TS by design and don't warn.)
|
|
140
|
-
warnNativeUnavailableOnce();
|
|
141
|
-
}
|
|
142
|
-
const errors = [];
|
|
143
|
-
const validated = {};
|
|
144
|
-
// Root context: `data` is the root, `parent` of a top-level field is the
|
|
145
|
-
// root too; nested/array recursion narrows `parent` as it descends.
|
|
146
|
-
const rootCtx = {
|
|
147
|
-
data,
|
|
148
|
-
parent: data,
|
|
149
|
-
meta: options?.meta ?? {},
|
|
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
|
+
],
|
|
150
169
|
};
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
validated[field] = result.transformed;
|
|
157
|
-
}
|
|
170
|
+
}
|
|
171
|
+
const provider = options?.messagesProvider;
|
|
172
|
+
if (!hasCustomRules && !validationTranslator && !provider) {
|
|
173
|
+
if (isNativeAvailable()) {
|
|
174
|
+
return validateWithRust(fields, data);
|
|
158
175
|
}
|
|
159
|
-
|
|
160
|
-
|
|
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;
|
|
161
198
|
}
|
|
162
|
-
|
|
163
|
-
|
|
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,
|
|
164
220
|
};
|
|
221
|
+
if (error.index !== undefined)
|
|
222
|
+
node.index = error.index;
|
|
223
|
+
if (error.meta !== undefined)
|
|
224
|
+
node.meta = error.meta;
|
|
225
|
+
return node;
|
|
165
226
|
}
|
|
166
227
|
export function setValidationTranslator(translator) {
|
|
167
228
|
validationTranslator = translator;
|
|
@@ -169,14 +230,20 @@ export function setValidationTranslator(translator) {
|
|
|
169
230
|
export function bindRosetta(rosetta) {
|
|
170
231
|
setValidationTranslator((key, params) => rosetta.t(key, params));
|
|
171
232
|
}
|
|
172
|
-
/**
|
|
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. */
|
|
173
236
|
export class RuleChain {
|
|
174
237
|
#rules = [];
|
|
175
238
|
#isOptional = false;
|
|
239
|
+
#isNullable = false;
|
|
240
|
+
#bail = false;
|
|
176
241
|
#transforms = [];
|
|
242
|
+
#preTransforms = [];
|
|
177
243
|
#nestedSchema = null;
|
|
178
244
|
#arrayItemChain = null;
|
|
179
245
|
#useRules = [];
|
|
246
|
+
#requiredConditions = [];
|
|
180
247
|
/** Public read access to rules (for OpenAPI generation, Rust bridge). */
|
|
181
248
|
get rules() {
|
|
182
249
|
return this.#rules;
|
|
@@ -184,6 +251,10 @@ export class RuleChain {
|
|
|
184
251
|
get isOptionalField() {
|
|
185
252
|
return this.#isOptional;
|
|
186
253
|
}
|
|
254
|
+
/** Public read access to the `.nullable()` flag (keeps such schemas off the native path). */
|
|
255
|
+
get isNullable() {
|
|
256
|
+
return this.#isNullable;
|
|
257
|
+
}
|
|
187
258
|
get transforms() {
|
|
188
259
|
return this.#transforms;
|
|
189
260
|
}
|
|
@@ -191,20 +262,64 @@ export class RuleChain {
|
|
|
191
262
|
get useRules() {
|
|
192
263
|
return this.#useRules;
|
|
193
264
|
}
|
|
194
|
-
/**
|
|
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). */
|
|
195
294
|
optional() {
|
|
196
295
|
this.#isOptional = true;
|
|
197
296
|
return this;
|
|
198
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
|
+
}
|
|
199
314
|
/** Must be an object matching a nested schema. */
|
|
200
315
|
object(shape) {
|
|
201
316
|
this.#rules.push({
|
|
202
317
|
name: "object",
|
|
203
|
-
validate: (v) =>
|
|
318
|
+
validate: (v) => isPlainObject(v),
|
|
204
319
|
message: "Must be an object",
|
|
205
320
|
});
|
|
206
321
|
this.#nestedSchema = shape;
|
|
207
|
-
return this;
|
|
322
|
+
return this.#retype();
|
|
208
323
|
}
|
|
209
324
|
/** Must be an array. Items validated by the provided chain. */
|
|
210
325
|
array(itemChain) {
|
|
@@ -214,7 +329,7 @@ export class RuleChain {
|
|
|
214
329
|
message: "Must be an array",
|
|
215
330
|
});
|
|
216
331
|
this.#arrayItemChain = itemChain ?? null;
|
|
217
|
-
return this;
|
|
332
|
+
return this.#retype();
|
|
218
333
|
}
|
|
219
334
|
/** Must be a string. */
|
|
220
335
|
string() {
|
|
@@ -223,7 +338,7 @@ export class RuleChain {
|
|
|
223
338
|
validate: (v) => typeof v === "string",
|
|
224
339
|
message: "Must be a string",
|
|
225
340
|
});
|
|
226
|
-
return this;
|
|
341
|
+
return this.#retype();
|
|
227
342
|
}
|
|
228
343
|
/** Must be a number. */
|
|
229
344
|
number() {
|
|
@@ -232,7 +347,7 @@ export class RuleChain {
|
|
|
232
347
|
validate: (v) => typeof v === "number" && !Number.isNaN(v) && Number.isFinite(v),
|
|
233
348
|
message: "Must be a number",
|
|
234
349
|
});
|
|
235
|
-
return this;
|
|
350
|
+
return this.#retype();
|
|
236
351
|
}
|
|
237
352
|
/** Must be a boolean. */
|
|
238
353
|
boolean() {
|
|
@@ -241,13 +356,35 @@ export class RuleChain {
|
|
|
241
356
|
validate: (v) => typeof v === "boolean",
|
|
242
357
|
message: "Must be a boolean",
|
|
243
358
|
});
|
|
244
|
-
return this;
|
|
359
|
+
return this.#retype();
|
|
245
360
|
}
|
|
246
|
-
/**
|
|
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();
|
|
381
|
+
}
|
|
382
|
+
/** Minimum length (string) or minimum value (number). Alias of min/minLength. */
|
|
247
383
|
min(n) {
|
|
248
384
|
this.#rules.push({
|
|
249
385
|
name: "min",
|
|
250
386
|
param: n,
|
|
387
|
+
args: { min: n },
|
|
251
388
|
validate: (v) => typeof v === "string"
|
|
252
389
|
? [...v].length >= n
|
|
253
390
|
: typeof v === "number"
|
|
@@ -257,11 +394,12 @@ export class RuleChain {
|
|
|
257
394
|
});
|
|
258
395
|
return this;
|
|
259
396
|
}
|
|
260
|
-
/** Maximum length (string) or maximum value (number). */
|
|
397
|
+
/** Maximum length (string) or maximum value (number). Alias of max/maxLength. */
|
|
261
398
|
max(n) {
|
|
262
399
|
this.#rules.push({
|
|
263
400
|
name: "max",
|
|
264
401
|
param: n,
|
|
402
|
+
args: { max: n },
|
|
265
403
|
validate: (v) => typeof v === "string"
|
|
266
404
|
? [...v].length <= n
|
|
267
405
|
: typeof v === "number"
|
|
@@ -271,20 +409,141 @@ export class RuleChain {
|
|
|
271
409
|
});
|
|
272
410
|
return this;
|
|
273
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
|
+
}
|
|
274
448
|
/** Must be a valid email. */
|
|
275
449
|
email() {
|
|
276
450
|
this.#rules.push({
|
|
277
451
|
name: "email",
|
|
278
|
-
// Mirror the Rust engine's regex exactly
|
|
279
|
-
//
|
|
280
|
-
// binary loaded: no whitespace anywhere (the old TS rule rejected only
|
|
281
|
-
// \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.
|
|
282
454
|
validate: (v) => typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
|
|
283
455
|
message: "Must be a valid email",
|
|
284
456
|
});
|
|
285
457
|
return this;
|
|
286
458
|
}
|
|
287
|
-
/** 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. */
|
|
288
547
|
positive() {
|
|
289
548
|
this.#rules.push({
|
|
290
549
|
name: "positive",
|
|
@@ -293,6 +552,96 @@ export class RuleChain {
|
|
|
293
552
|
});
|
|
294
553
|
return this;
|
|
295
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
|
+
}
|
|
296
645
|
/** Trim whitespace (transform). */
|
|
297
646
|
trim() {
|
|
298
647
|
this.#transforms.push({
|
|
@@ -301,6 +650,21 @@ export class RuleChain {
|
|
|
301
650
|
});
|
|
302
651
|
return this;
|
|
303
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
|
+
}
|
|
304
668
|
/** Custom validation rule. */
|
|
305
669
|
custom(name, validate, message) {
|
|
306
670
|
this.#rules.push({
|
|
@@ -327,26 +691,51 @@ export class RuleChain {
|
|
|
327
691
|
if (this.#rules.length === 0) {
|
|
328
692
|
throw new RuneError("NO_RULE", "message() must be called after a rule");
|
|
329
693
|
}
|
|
330
|
-
this.#rules[this.#rules.length - 1]
|
|
694
|
+
const last = this.#rules[this.#rules.length - 1];
|
|
695
|
+
last.message = msg;
|
|
696
|
+
last.hasCustomMessage = true;
|
|
331
697
|
return this;
|
|
332
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
|
+
}
|
|
333
705
|
/** Internal: validate a field value and return errors + transformed value. */
|
|
334
|
-
_validateWithTransform(field,
|
|
335
|
-
|
|
336
|
-
|
|
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)) {
|
|
714
|
+
return { errors: [], transformed: value };
|
|
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)) {
|
|
337
725
|
return { errors: [], transformed: value };
|
|
338
|
-
|
|
726
|
+
}
|
|
727
|
+
return { errors: [this.#requiredError(field, ctx)], transformed: value };
|
|
339
728
|
}
|
|
340
729
|
// 1. Type rules first on the raw value — bail on type mismatch.
|
|
341
|
-
const typeError = this.#runTypeRules(field, value);
|
|
730
|
+
const typeError = this.#runTypeRules(field, value, ctx);
|
|
342
731
|
if (typeError)
|
|
343
732
|
return { errors: [typeError], transformed: value };
|
|
344
733
|
// 2. Apply transforms (trim, etc.), then run value rules on the result.
|
|
345
|
-
let transformed = this.#applyTransformsTo(value);
|
|
346
|
-
const errors = this.#runValueRules(field, transformed);
|
|
734
|
+
let transformed = this.#applyTransformsTo(value, field, ctx);
|
|
735
|
+
const errors = this.#runValueRules(field, transformed, ctx);
|
|
347
736
|
// 3. Vine-style .use() rules — run with a FieldContext exposing the root
|
|
348
737
|
// `data` and `parent`, so a rule can validate across fields.
|
|
349
|
-
if (this.#useRules.length > 0) {
|
|
738
|
+
if (this.#useRules.length > 0 && !(this.#bail && errors.length > 0)) {
|
|
350
739
|
this.#runUseRules(field, transformed, ctx, errors);
|
|
351
740
|
}
|
|
352
741
|
// 4. Nested object validation (only if type check passed — not arrays)
|
|
@@ -354,7 +743,7 @@ export class RuleChain {
|
|
|
354
743
|
const obj = { ...transformed };
|
|
355
744
|
transformed = obj;
|
|
356
745
|
for (const [nestedField, chain] of Object.entries(this.#nestedSchema)) {
|
|
357
|
-
const nestedResult = chain._validateWithTransform(`${field}.${nestedField}`, obj[nestedField], {
|
|
746
|
+
const nestedResult = chain._validateWithTransform(`${field}.${nestedField}`, obj[nestedField], { ...ctx, parent: obj });
|
|
358
747
|
errors.push(...nestedResult.errors);
|
|
359
748
|
if (nestedResult.transformed !== undefined) {
|
|
360
749
|
obj[nestedField] = nestedResult.transformed;
|
|
@@ -366,7 +755,11 @@ export class RuleChain {
|
|
|
366
755
|
const arr = [...transformed];
|
|
367
756
|
transformed = arr;
|
|
368
757
|
for (let i = 0; i < arr.length; i++) {
|
|
369
|
-
const itemResult = this.#arrayItemChain._validateWithTransform(`${field}.${i}`, arr[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
|
+
}
|
|
370
763
|
errors.push(...itemResult.errors);
|
|
371
764
|
if (itemResult.transformed !== undefined) {
|
|
372
765
|
arr[i] = itemResult.transformed;
|
|
@@ -389,40 +782,45 @@ export class RuleChain {
|
|
|
389
782
|
},
|
|
390
783
|
};
|
|
391
784
|
for (const rule of this.#useRules) {
|
|
392
|
-
// Refresh isValid so a rule can early-return once the field has failed.
|
|
393
785
|
fieldCtx.isValid = errors.length === 0;
|
|
394
786
|
rule.run(transformed, fieldCtx);
|
|
395
787
|
}
|
|
396
788
|
}
|
|
397
|
-
#requiredError(field) {
|
|
789
|
+
#requiredError(field, ctx) {
|
|
398
790
|
return {
|
|
399
791
|
field,
|
|
400
792
|
rule: "required",
|
|
401
|
-
message:
|
|
793
|
+
message: resolveRequiredMessage(field, ctx),
|
|
402
794
|
};
|
|
403
795
|
}
|
|
404
796
|
/** Run the type rules (string/number/…) on the raw value; first failure bails. */
|
|
405
|
-
#runTypeRules(field, value) {
|
|
797
|
+
#runTypeRules(field, value, ctx) {
|
|
406
798
|
for (const rule of this.#rules) {
|
|
407
799
|
if (TYPE_RULE_NAMES.has(rule.name) && !rule.validate(value)) {
|
|
408
800
|
return {
|
|
409
801
|
field,
|
|
410
802
|
rule: rule.name,
|
|
411
|
-
message: resolveRuleMessage(field, rule),
|
|
803
|
+
message: resolveRuleMessage(field, rule, ctx),
|
|
804
|
+
...(ruleArgs(rule) ? { meta: ruleArgs(rule) } : {}),
|
|
412
805
|
};
|
|
413
806
|
}
|
|
414
807
|
}
|
|
415
808
|
return null;
|
|
416
809
|
}
|
|
417
810
|
/** Run the non-type rules (min/max/email/…) on the transformed value. */
|
|
418
|
-
#runValueRules(field, transformed) {
|
|
811
|
+
#runValueRules(field, transformed, ctx) {
|
|
419
812
|
const errors = [];
|
|
420
813
|
for (const rule of this.#rules) {
|
|
421
|
-
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)) {
|
|
422
819
|
errors.push({
|
|
423
820
|
field,
|
|
424
821
|
rule: rule.name,
|
|
425
|
-
message: resolveRuleMessage(field, rule),
|
|
822
|
+
message: resolveRuleMessage(field, rule, ctx),
|
|
823
|
+
...(ruleArgs(rule) ? { meta: ruleArgs(rule) } : {}),
|
|
426
824
|
});
|
|
427
825
|
}
|
|
428
826
|
}
|
|
@@ -434,40 +832,139 @@ export class RuleChain {
|
|
|
434
832
|
}
|
|
435
833
|
/** Internal: apply transforms. */
|
|
436
834
|
_transform(value) {
|
|
437
|
-
return this.#applyTransformsTo(value);
|
|
835
|
+
return this.#applyTransformsTo(value, "", EMPTY_RUN_CONTEXT);
|
|
438
836
|
}
|
|
439
|
-
#applyTransformsTo(value) {
|
|
837
|
+
#applyTransformsTo(value, field, ctx) {
|
|
440
838
|
let result = value;
|
|
441
839
|
for (const transform of this.#transforms) {
|
|
442
|
-
|
|
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);
|
|
443
846
|
}
|
|
444
847
|
return result;
|
|
445
848
|
}
|
|
446
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
|
+
}
|
|
447
945
|
/** Entry point for building rules. */
|
|
448
946
|
export const rules = {
|
|
449
947
|
string: () => new RuleChain().string(),
|
|
450
948
|
number: () => new RuleChain().number(),
|
|
451
949
|
boolean: () => new RuleChain().boolean(),
|
|
452
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),
|
|
453
955
|
};
|
|
454
956
|
/** Serialize schema + data and validate via Rust NAPI. */
|
|
455
957
|
function validateWithRust(fields, data) {
|
|
456
958
|
const schemaDesc = {};
|
|
457
959
|
for (const [field, chain] of Object.entries(fields)) {
|
|
458
|
-
const
|
|
960
|
+
const ruleDescs = chain.rules.map((r) => ({
|
|
459
961
|
name: r.name,
|
|
460
|
-
// Serialize THIS rule's own
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
params: r.name === "min"
|
|
464
|
-
? { min: r.param }
|
|
465
|
-
: r.name === "max"
|
|
466
|
-
? { max: r.param }
|
|
467
|
-
: null,
|
|
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,
|
|
468
965
|
}));
|
|
469
966
|
schemaDesc[field] = {
|
|
470
|
-
rules,
|
|
967
|
+
rules: ruleDescs,
|
|
471
968
|
optional: chain.isOptionalField,
|
|
472
969
|
transforms: chain.transforms.map((t) => t.name),
|
|
473
970
|
};
|