@jarenjs/forms 0.9.2

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/src/rules.js ADDED
@@ -0,0 +1,404 @@
1
+ //@ts-check
2
+
3
+ /**
4
+ * `x-form` rules: cross-field form behavior as compiled Jaren JSON Queries.
5
+ *
6
+ * The `x-form` annotation is one namespaced keyword - safe under every
7
+ * metaschema, invisible to validators - carrying query documents for
8
+ * per-keystroke form behavior:
9
+ *
10
+ * visible - EBV query: should the field be shown?
11
+ * enabled - EBV query: should the field accept input?
12
+ * assert - EBV query: cross-field preemptive validation
13
+ * computed - query whose plain-JSON result is the field's derived value
14
+ * message - string shown when `assert` fails
15
+ *
16
+ * Unknown members are ignored (forward compatibility). Every rule kind
17
+ * shares one query context: the input document `$` is the WHOLE form data
18
+ * root (cross-field is the point), and exactly two externals are bound
19
+ * per evaluation - `value`, the field's current value (`null` when the
20
+ * field is absent: `undefined` is not a JSON value and has no defined
21
+ * behavior in the engine), and `pointer`, the field's data pointer
22
+ * string. Any other free name is a compile-time error naming it.
23
+ *
24
+ * Error policy (recorded here as the module contract):
25
+ * - `visible`/`enabled` runtime errors (JQ2xxx, e.g. the JQ2003
26
+ * multi-item EBV) evaluate to `true` - FAIL OPEN: a broken rule must
27
+ * never hide data or lock a control the user needs.
28
+ * - `assert` runtime errors evaluate to failed - FAIL CLOSED: an
29
+ * assertion that cannot be computed has not been satisfied.
30
+ * - `computed` runtime errors leave the value absent - there is no
31
+ * derived value to show.
32
+ * Compile errors always throw, with the field's data pointer prepended.
33
+ *
34
+ * Rules compile ONCE per model (compileFormRules) and evaluate per
35
+ * keystroke as cheap closures (evaluateFormRules) - the same two-stage
36
+ * shape as every other Jaren compiler. Rules on array item templates
37
+ * compile once and dispatch per element (see expandItemRule): this
38
+ * compiled-once/evaluate-per-node mechanism is the seed of the JSLT
39
+ * template layer (packages/json/docs/JSLT-PRELUDE.md section 7).
40
+ */
41
+
42
+ import {
43
+ compileJsonQuery,
44
+ JsonQueryRuntimeError,
45
+ } from '@jarenjs/json/query';
46
+
47
+ import {
48
+ compileJSONPointer,
49
+ JSONPOINTER_NOTHING,
50
+ } from '@jarenjs/json/pointer';
51
+
52
+ import { escapePointerKey } from './model.js';
53
+
54
+ /** The externals every rule query may reference, and no others. */
55
+ const ALLOWED_EXTERNALS = ['value', 'pointer'];
56
+
57
+ const DEFAULT_ASSERT_MESSAGE = 'Invalid value';
58
+
59
+ /**
60
+ * @typedef {object} RuleResult
61
+ * @property {boolean} [visible] - EBV of the field's `visible` rule
62
+ * @property {boolean} [enabled] - EBV of the field's `enabled` rule
63
+ * @property {any} [computed] - Plain-JSON result of the `computed` rule
64
+ * @property {Array<import('./validate.js').FieldError>} [errors]
65
+ * `[{ keyword: 'x-form/assert', message }]` when the `assert` rule fails
66
+ * (the validateField error shape, so error rendering works unchanged)
67
+ */
68
+
69
+ /**
70
+ * Marker for the array-item position in a template pointer's part list
71
+ * (the `-` segment of `/lines/-`): evaluation expands it per element.
72
+ */
73
+ const ITEM = Symbol('forms.ItemTemplate');
74
+
75
+ /**
76
+ * Compile one rule member, prefixing compile errors with the field's
77
+ * data pointer and enforcing the externals whitelist.
78
+ */
79
+ function compileRuleQuery(doc, fieldPointer, member, options) {
80
+ let query;
81
+ try {
82
+ query = compileJsonQuery(doc, options);
83
+ }
84
+ catch (e) {
85
+ if (e instanceof Error)
86
+ e.message = `${fieldPointer} x-form/${member}: ${e.message}`;
87
+ throw e;
88
+ }
89
+ const externals = query.externals;
90
+ for (let i = 0; i < externals.length; i++) {
91
+ const name = externals[i];
92
+ if (!ALLOWED_EXTERNALS.includes(name))
93
+ throw new Error(
94
+ `${fieldPointer} x-form/${member}: query cannot bind external '${name}' (only 'value' and 'pointer' are bound)`);
95
+ }
96
+ return query;
97
+ }
98
+
99
+ /**
100
+ * @typedef {object} CompiledFieldRules
101
+ * @property {string} pointer - The field's data pointer (template pointers keep `-`)
102
+ * @property {Array<string|symbol>} parts - Decoded segments; ITEM marks an array-item slot
103
+ * @property {boolean} templated - Whether `parts` contains an ITEM slot
104
+ * @property {((root: any) => any)|null} getValue - Compiled getter (non-template fields)
105
+ * @property {function|null} visible
106
+ * @property {function|null} enabled
107
+ * @property {function|null} assert
108
+ * @property {function|null} computed
109
+ * @property {string|null} message
110
+ */
111
+
112
+ /**
113
+ * @typedef {object} CompiledRules
114
+ * @property {Array<CompiledFieldRules>} rules
115
+ */
116
+
117
+ /**
118
+ * Compile every `x-form` rule of a form model into reusable closures.
119
+ *
120
+ * Walks the field tree once and runs `compileJsonQuery` per rule
121
+ * document. `options.compileTypeTest` passes through to the query
122
+ * compiler, so rules may use `$valid`/`$assert`/`$as` with a
123
+ * caller-supplied type-test compiler (the validator package's `query`
124
+ * module exports `createTypeTestCompiler()`; forms itself never imports
125
+ * the validator).
126
+ * Without the hook, a schema-using rule surfaces the engine's JQ0008.
127
+ *
128
+ * @param {import('./model.js').FormField} model - Root field from buildFormModel
129
+ * @param {object} [options]
130
+ * @param {(schemaJson: any, docPath: string) => ((value: any) => boolean)}
131
+ * [options.compileTypeTest] - hook for schema literals inside rules
132
+ * @returns {CompiledRules}
133
+ * @throws {Error} On a malformed rule document (field pointer prepended)
134
+ * or a rule referencing an external other than `value`/`pointer`
135
+ * @example
136
+ * const compiled = compileFormRules(model);
137
+ * const results = evaluateFormRules(compiled, data);
138
+ * results['/vatId']; // { visible: true, errors: [{ keyword: 'x-form/assert', ... }] }
139
+ */
140
+ export function compileFormRules(model, options = {}) {
141
+ const queryOptions = options.compileTypeTest !== undefined
142
+ ? { compileTypeTest: options.compileTypeTest }
143
+ : {};
144
+ /** @type {Array<CompiledFieldRules>} */
145
+ const rules = [];
146
+ walkField(model, [], rules, queryOptions);
147
+ return { rules };
148
+ }
149
+
150
+ function walkField(field, parts, rules, queryOptions) {
151
+ if (field == null) return;
152
+
153
+ const raw = field.rules;
154
+ if (raw != null) {
155
+ const pointer = field.pointer;
156
+ const templated = parts.includes(ITEM);
157
+ rules.push({
158
+ pointer,
159
+ parts,
160
+ templated,
161
+ getValue: templated ? null : compileJSONPointer(pointer),
162
+ visible: raw.visible !== undefined
163
+ ? compileRuleQuery(raw.visible, pointer, 'visible', queryOptions) : null,
164
+ enabled: raw.enabled !== undefined
165
+ ? compileRuleQuery(raw.enabled, pointer, 'enabled', queryOptions) : null,
166
+ assert: raw.assert !== undefined
167
+ ? compileRuleQuery(raw.assert, pointer, 'assert', queryOptions) : null,
168
+ computed: raw.computed !== undefined
169
+ ? compileRuleQuery(raw.computed, pointer, 'computed', queryOptions) : null,
170
+ message: typeof raw.message === 'string' ? raw.message : null,
171
+ });
172
+ }
173
+
174
+ if (field.children) {
175
+ for (const child of field.children)
176
+ walkField(child, [...parts, child.key], rules, queryOptions);
177
+ }
178
+ if (field.tuple) {
179
+ for (let i = 0; i < field.tuple.length; i++)
180
+ walkField(field.tuple[i], [...parts, String(i)], rules, queryOptions);
181
+ }
182
+ if (field.item)
183
+ walkField(field.item, [...parts, ITEM], rules, queryOptions);
184
+ }
185
+
186
+ /**
187
+ * Evaluate one field's compiled rules against the data root.
188
+ * The externals object is reused across rules: the compiled query copies
189
+ * externals into its frame before evaluating (see the query engine), so
190
+ * mutation between calls is safe and allocation-free.
191
+ */
192
+ function evaluateOne(rule, data, value, pointer, ext, results) {
193
+ ext.value = value === undefined ? null : value;
194
+ ext.pointer = pointer;
195
+
196
+ /** @type {RuleResult} */
197
+ const result = {};
198
+ if (rule.visible !== null)
199
+ result.visible = ebvFailOpen(rule.visible, data, ext);
200
+ if (rule.enabled !== null)
201
+ result.enabled = ebvFailOpen(rule.enabled, data, ext);
202
+ if (rule.computed !== null) {
203
+ try {
204
+ result.computed = rule.computed(data, ext);
205
+ }
206
+ catch (e) {
207
+ if (!(e instanceof JsonQueryRuntimeError)) throw e;
208
+ // no derived value to show
209
+ }
210
+ }
211
+ if (rule.assert !== null) {
212
+ let ok;
213
+ try {
214
+ ok = rule.assert.ebv(data, ext);
215
+ }
216
+ catch (e) {
217
+ if (!(e instanceof JsonQueryRuntimeError)) throw e;
218
+ ok = false; // fail closed: an uncomputable assertion is not satisfied
219
+ }
220
+ if (!ok) {
221
+ result.errors = [{
222
+ keyword: 'x-form/assert',
223
+ message: rule.message !== null ? rule.message : DEFAULT_ASSERT_MESSAGE,
224
+ }];
225
+ }
226
+ }
227
+ results[pointer] = result;
228
+ }
229
+
230
+ function ebvFailOpen(query, data, ext) {
231
+ try {
232
+ return query.ebv(data, ext);
233
+ }
234
+ catch (e) {
235
+ if (!(e instanceof JsonQueryRuntimeError)) throw e;
236
+ return true; // fail open: never hide data or lock a control on a broken rule
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Expand a template rule against the actual data: walk the decoded parts,
242
+ * and at each ITEM slot fan out over the array's real length, binding
243
+ * `value`/`pointer` per element (`/lines/-/amount` -> `/lines/2/amount`).
244
+ *
245
+ * This is the compiled-once/dispatch-many shape the JSLT template layer
246
+ * needs (JSLT-PRELUDE.md section 3, `$apply`): ONE compiled closure, one
247
+ * dispatcher deciding per node what it applies to. Keep the mechanism in
248
+ * this function.
249
+ */
250
+ function expandItemRule(rule, data, node, partIndex, pointer, ext, results) {
251
+ const parts = rule.parts;
252
+ for (let i = partIndex; i < parts.length; i++) {
253
+ const part = parts[i];
254
+ if (part === ITEM) {
255
+ if (!Array.isArray(node)) return; // nothing to expand into
256
+ for (let index = 0; index < node.length; index++)
257
+ expandItemRule(rule, data, node[index], i + 1, `${pointer}/${index}`, ext, results);
258
+ return;
259
+ }
260
+ pointer = `${pointer}/${escapePointerKey(part)}`;
261
+ node = (node != null && typeof node === 'object')
262
+ ? node[/** @type {string} */ (part)]
263
+ : undefined;
264
+ }
265
+ evaluateOne(rule, data, node, pointer, ext, results);
266
+ }
267
+
268
+ /**
269
+ * Evaluate compiled form rules against the current data root.
270
+ *
271
+ * Returns a map of data pointer -> RuleResult holding only the rules each
272
+ * field declares. Rules on array item templates are evaluated once per
273
+ * element of the actual array, keyed by the expanded pointer.
274
+ *
275
+ * @param {CompiledRules} compiled - From compileFormRules
276
+ * @param {any} data - The form data root (the query input `$`)
277
+ * @returns {Record<string, RuleResult>}
278
+ * @example
279
+ * const results = evaluateFormRules(compiled, { company: 'ACME', vatId: '' });
280
+ * results['/vatId'].errors; // [{ keyword: 'x-form/assert', message: '...' }]
281
+ */
282
+ export function evaluateFormRules(compiled, data) {
283
+ /** @type {Record<string, RuleResult>} */
284
+ const results = {};
285
+ const ext = { value: null, pointer: '' };
286
+ const rules = compiled.rules;
287
+ for (let i = 0; i < rules.length; i++) {
288
+ const rule = rules[i];
289
+ if (rule.templated) {
290
+ expandItemRule(rule, data, data, 0, '', ext, results);
291
+ }
292
+ else {
293
+ const value = rule.getValue(data);
294
+ evaluateOne(rule, data, value === JSONPOINTER_NOTHING ? undefined : value, rule.pointer, ext, results);
295
+ }
296
+ }
297
+ return results;
298
+ }
299
+
300
+ //#region $query synergy
301
+
302
+ /**
303
+ * Build the RFC 9535 name selector `['...']` for one pointer segment.
304
+ * Single-quoted string literal: `\` and `'` escape with a backslash,
305
+ * control characters as `\uXXXX`.
306
+ */
307
+ function pathNameSelector(key) {
308
+ let out = "['";
309
+ for (const ch of key) {
310
+ if (ch === '\\' || ch === "'")
311
+ out += `\\${ch}`;
312
+ else if (/** @type {number} */ (ch.codePointAt(0)) < 0x20)
313
+ out += `\\u${ch.codePointAt(0).toString(16).padStart(4, '0')}`;
314
+ else
315
+ out += ch;
316
+ }
317
+ return out + "']";
318
+ }
319
+
320
+ /**
321
+ * Copy every `x-form.assert` of a schema into a `$query` assertion, so a
322
+ * rule authored once for per-keystroke feedback is also enforced by the
323
+ * authoritative submit validation (the validator's `$query` keyword).
324
+ * Pure schema-to-schema transform - no validator import; the output only
325
+ * spells the keyword.
326
+ *
327
+ * The `$query` lands on the ROOT schema (where the query input `$` is the
328
+ * instance root, matching the rule context), with each assert wrapped to
329
+ * rebuild its bindings: `value` binds to the field's location, `pointer`
330
+ * to its pointer string. An assert on an array item template quantifies
331
+ * with `$every` over the actual elements (`pointer` then stays the
332
+ * template pointer - element indexes are a render-time notion). Multiple
333
+ * asserts conjoin under `$and`; an existing root `$query` is preserved by
334
+ * wrapping the new one in an `allOf` branch.
335
+ *
336
+ * The transform follows the same structural spine as buildFormModel
337
+ * (`properties`, `items`, `prefixItems`, `allOf`) but does not resolve
338
+ * `$ref`s - a `$def`'s data location depends on its use site.
339
+ *
340
+ * @param {object|boolean} schema - The root JSON schema
341
+ * @returns {object|boolean} A new root schema (input is not mutated;
342
+ * untouched subtrees are shared) with the collected `$query`, or the
343
+ * input itself when there is nothing to copy
344
+ * @example
345
+ * const submitSchema = formRulesToQueryAssertions(schema);
346
+ * const validate = new JarenValidator().compile(submitSchema); // caller-side
347
+ */
348
+ export function formRulesToQueryAssertions(schema) {
349
+ if (schema == null || typeof schema !== 'object' || Array.isArray(schema))
350
+ return schema;
351
+
352
+ /** @type {any[]} */
353
+ const assertions = [];
354
+ collectAsserts(schema, '', '$', 0, assertions);
355
+ if (assertions.length === 0)
356
+ return schema;
357
+
358
+ const queryDoc = assertions.length === 1 ? assertions[0] : { $and: assertions };
359
+ if (schema.$query !== undefined) {
360
+ const allOf = Array.isArray(schema.allOf) ? schema.allOf : [];
361
+ return { ...schema, allOf: [...allOf, { $query: queryDoc }] };
362
+ }
363
+ return { ...schema, $query: queryDoc };
364
+ }
365
+
366
+ /**
367
+ * Depth-first collection of `x-form.assert` documents with the pointer
368
+ * and root-relative JSONPath of their data location. `itemDepth` counts
369
+ * enclosing `[*]` expansions: inside one, `value` must quantify per
370
+ * element instead of binding the selected sequence.
371
+ */
372
+ function collectAsserts(schema, pointer, path, itemDepth, out) {
373
+ if (schema == null || typeof schema !== 'object' || Array.isArray(schema))
374
+ return;
375
+
376
+ const rules = schema['x-form'];
377
+ if (rules != null && typeof rules === 'object' && !Array.isArray(rules)
378
+ && rules.assert !== undefined) {
379
+ const bindPointer = { $const: pointer };
380
+ out.push(itemDepth === 0
381
+ ? { $let: { value: path, pointer: bindPointer }, $return: rules.assert }
382
+ : { $every: { value: path },
383
+ $satisfies: { $let: { pointer: bindPointer }, $return: rules.assert } });
384
+ }
385
+
386
+ if (schema.properties != null && typeof schema.properties === 'object') {
387
+ for (const [key, sub] of Object.entries(schema.properties)) {
388
+ collectAsserts(sub, `${pointer}/${escapePointerKey(key)}`,
389
+ path + pathNameSelector(key), itemDepth, out);
390
+ }
391
+ }
392
+ if (Array.isArray(schema.prefixItems)) {
393
+ for (let i = 0; i < schema.prefixItems.length; i++)
394
+ collectAsserts(schema.prefixItems[i], `${pointer}/${i}`, `${path}[${i}]`, itemDepth, out);
395
+ }
396
+ if (schema.items != null && typeof schema.items === 'object' && !Array.isArray(schema.items))
397
+ collectAsserts(schema.items, `${pointer}/-`, `${path}[*]`, itemDepth + 1, out);
398
+ if (Array.isArray(schema.allOf)) {
399
+ for (const branch of schema.allOf)
400
+ collectAsserts(branch, pointer, path, itemDepth, out);
401
+ }
402
+ }
403
+
404
+ //#endregion
@@ -0,0 +1,244 @@
1
+ //@ts-check
2
+
3
+ /**
4
+ * Preemptive per-field validation.
5
+ *
6
+ * These checks run synchronously on every keystroke, powered directly by
7
+ * @jarenjs/core primitives (grapheme-aware string length, unicode regexes,
8
+ * format testers, deep equality). They give the user immediate feedback
9
+ * per field BEFORE the complete compiled schema validation runs - which
10
+ * remains authoritative for cross-field rules (required combinations,
11
+ * dependencies, unevaluatedProperties, ...). Cross-field feedback per
12
+ * keystroke is rules.js territory (the `x-form` annotation).
13
+ */
14
+
15
+ import {
16
+ getStringLength,
17
+ createRegExp,
18
+ } from '@jarenjs/core/string';
19
+
20
+ import {
21
+ equalsDeep,
22
+ isUniqueDeepArray,
23
+ } from '@jarenjs/core/object';
24
+
25
+ import {
26
+ getFormatInfo,
27
+ } from './formats.js';
28
+
29
+ /**
30
+ * @typedef {object} FieldError
31
+ * @property {string} keyword - The JSON Schema keyword that failed
32
+ * @property {string} message - Human readable message
33
+ */
34
+
35
+ const regexCache = new Map();
36
+ function getPattern(source) {
37
+ let regex = regexCache.get(source);
38
+ if (regex === undefined) {
39
+ try {
40
+ regex = createRegExp(source);
41
+ }
42
+ catch (e) {
43
+ regex = null;
44
+ }
45
+ if (regexCache.size > 500) regexCache.clear();
46
+ regexCache.set(source, regex);
47
+ }
48
+ return regex;
49
+ }
50
+
51
+ function formatValue(value) {
52
+ return typeof value === 'string' ? `"${value}"` : JSON.stringify(value);
53
+ }
54
+
55
+ /**
56
+ * Validate a single field value against its own constraints.
57
+ *
58
+ * @param {import('./model.js').FormField} field - Field from buildFormModel
59
+ * @param {any} value - The TYPED value (see parseFieldInput); undefined = absent
60
+ * @returns {FieldError[]} Empty when the value passes every per-field check
61
+ * @example
62
+ * const errors = validateField(emailField, 'not-an-email');
63
+ * // [{ keyword: 'format', message: 'Must be a valid email' }]
64
+ */
65
+ export function validateField(field, value) {
66
+ /** @type {FieldError[]} */
67
+ const errors = [];
68
+ if (field == null) return errors;
69
+
70
+ // Absent value: only `required` applies
71
+ if (value === undefined || value === null) {
72
+ if (field.required && field.kind !== 'boolean') {
73
+ errors.push({ keyword: 'required', message: 'This field is required' });
74
+ }
75
+ return errors;
76
+ }
77
+
78
+ const c = field.constraints;
79
+
80
+ switch (field.kind) {
81
+ case 'const': {
82
+ if (!equalsDeep(value, field.constValue)) {
83
+ errors.push({ keyword: 'const', message: `Must be ${formatValue(field.constValue)}` });
84
+ }
85
+ return errors;
86
+ }
87
+
88
+ case 'enum': {
89
+ if (!field.enumValues?.some((option) => equalsDeep(value, option))) {
90
+ errors.push({
91
+ keyword: 'enum',
92
+ message: `Must be one of: ${field.enumValues?.map(formatValue).join(', ')}`,
93
+ });
94
+ }
95
+ return errors;
96
+ }
97
+
98
+ case 'string': {
99
+ if (typeof value !== 'string') {
100
+ errors.push({ keyword: 'type', message: 'Must be a string' });
101
+ return errors;
102
+ }
103
+ let len = -1;
104
+ if (c.minLength !== undefined || c.maxLength !== undefined) {
105
+ len = getStringLength(value, true); // grapheme-aware, like the validator
106
+ }
107
+ if (c.minLength !== undefined && len < c.minLength) {
108
+ errors.push({
109
+ keyword: 'minLength',
110
+ message: `Must be at least ${c.minLength} character${c.minLength === 1 ? '' : 's'} (currently ${len})`,
111
+ });
112
+ }
113
+ if (c.maxLength !== undefined && len > c.maxLength) {
114
+ errors.push({
115
+ keyword: 'maxLength',
116
+ message: `Must be at most ${c.maxLength} character${c.maxLength === 1 ? '' : 's'} (currently ${len})`,
117
+ });
118
+ }
119
+ if (c.pattern !== undefined) {
120
+ const regex = getPattern(c.pattern);
121
+ if (regex != null && !regex.test(value)) {
122
+ errors.push({ keyword: 'pattern', message: `Must match pattern ${c.pattern}` });
123
+ }
124
+ }
125
+ if (c.format !== undefined && value !== '') {
126
+ const info = getFormatInfo(c.format);
127
+ if (info != null && !info.test(value)) {
128
+ errors.push({ keyword: 'format', message: `Must be a valid ${c.format}` });
129
+ }
130
+ }
131
+ return errors;
132
+ }
133
+
134
+ case 'number':
135
+ case 'integer': {
136
+ const num = typeof value === 'number' ? value : Number(value);
137
+ if (typeof value === 'boolean' || Number.isNaN(num)) {
138
+ errors.push({ keyword: 'type', message: 'Must be a number' });
139
+ return errors;
140
+ }
141
+ if (field.kind === 'integer' && !Number.isInteger(num)) {
142
+ errors.push({ keyword: 'type', message: 'Must be an integer' });
143
+ }
144
+ if (c.minimum !== undefined && num < c.minimum) {
145
+ errors.push({ keyword: 'minimum', message: `Must be at least ${c.minimum}` });
146
+ }
147
+ if (c.maximum !== undefined && num > c.maximum) {
148
+ errors.push({ keyword: 'maximum', message: `Must be at most ${c.maximum}` });
149
+ }
150
+ if (c.exclusiveMinimum !== undefined && num <= c.exclusiveMinimum) {
151
+ errors.push({ keyword: 'exclusiveMinimum', message: `Must be greater than ${c.exclusiveMinimum}` });
152
+ }
153
+ if (c.exclusiveMaximum !== undefined && num >= c.exclusiveMaximum) {
154
+ errors.push({ keyword: 'exclusiveMaximum', message: `Must be less than ${c.exclusiveMaximum}` });
155
+ }
156
+ if (c.multipleOf !== undefined) {
157
+ const quotient = num / c.multipleOf;
158
+ if (Math.abs(quotient - Math.round(quotient)) >= 1e-6) {
159
+ errors.push({ keyword: 'multipleOf', message: `Must be a multiple of ${c.multipleOf}` });
160
+ }
161
+ }
162
+ return errors;
163
+ }
164
+
165
+ case 'boolean': {
166
+ if (typeof value !== 'boolean') {
167
+ errors.push({ keyword: 'type', message: 'Must be a boolean' });
168
+ }
169
+ return errors;
170
+ }
171
+
172
+ case 'array': {
173
+ if (!Array.isArray(value)) {
174
+ errors.push({ keyword: 'type', message: 'Must be an array' });
175
+ return errors;
176
+ }
177
+ if (c.minItems !== undefined && value.length < c.minItems) {
178
+ errors.push({
179
+ keyword: 'minItems',
180
+ message: `Must have at least ${c.minItems} item${c.minItems === 1 ? '' : 's'}`,
181
+ });
182
+ }
183
+ if (c.maxItems !== undefined && value.length > c.maxItems) {
184
+ errors.push({
185
+ keyword: 'maxItems',
186
+ message: `Must have at most ${c.maxItems} item${c.maxItems === 1 ? '' : 's'}`,
187
+ });
188
+ }
189
+ if (c.uniqueItems === true && !isUniqueDeepArray(value)) {
190
+ errors.push({ keyword: 'uniqueItems', message: 'Items must be unique' });
191
+ }
192
+ return errors;
193
+ }
194
+
195
+ case 'object': {
196
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
197
+ errors.push({ keyword: 'type', message: 'Must be an object' });
198
+ return errors;
199
+ }
200
+ const size = Object.keys(value).length;
201
+ if (c.minProperties !== undefined && size < c.minProperties) {
202
+ errors.push({ keyword: 'minProperties', message: `Must have at least ${c.minProperties} properties` });
203
+ }
204
+ if (c.maxProperties !== undefined && size > c.maxProperties) {
205
+ errors.push({ keyword: 'maxProperties', message: `Must have at most ${c.maxProperties} properties` });
206
+ }
207
+ return errors;
208
+ }
209
+
210
+ default:
211
+ return errors;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Validate every leaf field of a model against the current data.
217
+ * Returns a map of data-pointer -> FieldError[] for fields that fail.
218
+ * @param {import('./model.js').FormField} model - Root field from buildFormModel
219
+ * @param {any} data - Current form data
220
+ * @returns {Record<string, FieldError[]>}
221
+ */
222
+ export function validateAllFields(model, data) {
223
+ /** @type {Record<string, FieldError[]>} */
224
+ const result = {};
225
+ walkFields(model, data, '', result);
226
+ return result;
227
+ }
228
+
229
+ function walkFields(field, value, pointer, result) {
230
+ const errors = validateField(field, value);
231
+ if (errors.length > 0) result[pointer] = errors;
232
+
233
+ if (field.kind === 'object' && field.children && value != null && typeof value === 'object') {
234
+ for (const child of field.children) {
235
+ walkFields(child, value[child.key], `${pointer}/${child.key}`, result);
236
+ }
237
+ }
238
+ else if (field.kind === 'array' && Array.isArray(value)) {
239
+ for (let i = 0; i < value.length; i++) {
240
+ const itemField = field.tuple?.[i] ?? field.item;
241
+ if (itemField) walkFields(itemField, value[i], `${pointer}/${i}`, result);
242
+ }
243
+ }
244
+ }