@jarenjs/forms 0.9.2 → 0.34.0

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 CHANGED
@@ -11,7 +11,9 @@
11
11
  * enabled - EBV query: should the field accept input?
12
12
  * assert - EBV query: cross-field preemptive validation
13
13
  * computed - query whose plain-JSON result is the field's derived value
14
- * message - string shown when `assert` fails
14
+ * message - MessageSpec shown when `assert` fails: an inline template
15
+ * string, or `{ "$msgid": ..., "message"?: ..., "params"?: ... }`
16
+ * resolving through a message catalog (see messages.js)
15
17
  *
16
18
  * Unknown members are ignored (forward compatibility). Every rule kind
17
19
  * shares one query context: the input document `$` is the WHOLE form data
@@ -51,10 +53,24 @@ import {
51
53
 
52
54
  import { escapePointerKey } from './model.js';
53
55
 
56
+ import { setValueAtPointer, changedPointers } from './data.js';
57
+
58
+ import {
59
+ queryDependencies, mergeDependencies, dependencyTouched, ALL_POINTERS,
60
+ } from './deps.js';
61
+
62
+ import {
63
+ compileMessageTemplate,
64
+ renderFormsMessage,
65
+ formsMessages,
66
+ } from './messages.js';
67
+
54
68
  /** The externals every rule query may reference, and no others. */
55
69
  const ALLOWED_EXTERNALS = ['value', 'pointer'];
56
70
 
57
- const DEFAULT_ASSERT_MESSAGE = 'Invalid value';
71
+ /** The msgid of the default assert failure text ('Invalid value'), in the
72
+ * built-in English catalog (messages.js). */
73
+ const DEFAULT_ASSERT_MSGID = 'x-form/assert';
58
74
 
59
75
  /**
60
76
  * @typedef {object} RuleResult
@@ -62,8 +78,9 @@ const DEFAULT_ASSERT_MESSAGE = 'Invalid value';
62
78
  * @property {boolean} [enabled] - EBV of the field's `enabled` rule
63
79
  * @property {any} [computed] - Plain-JSON result of the `computed` rule
64
80
  * @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)
81
+ * `[{ keyword: 'x-form/assert', params, msgid, message }]` when the
82
+ * `assert` rule fails (the validateField error shape, so error
83
+ * rendering works unchanged; `params` always carries the `pointer`)
67
84
  */
68
85
 
69
86
  /**
@@ -96,6 +113,17 @@ function compileRuleQuery(doc, fieldPointer, member, options) {
96
113
  return query;
97
114
  }
98
115
 
116
+ /**
117
+ * A compiled `x-form.message` MessageSpec: inline text compiles into a
118
+ * render closure; a `$msgid` form resolves through the active catalog at
119
+ * failure time (English fallback), with the inline `message` template as
120
+ * the catalog-miss fallback.
121
+ * @typedef {object} CompiledRuleMessage
122
+ * @property {string|null} msgid - Catalog key, or null for a plain inline message
123
+ * @property {((params: object) => string)|null} render - Compiled inline template
124
+ * @property {object|null} params - Author params, merged into the error's params
125
+ */
126
+
99
127
  /**
100
128
  * @typedef {object} CompiledFieldRules
101
129
  * @property {string} pointer - The field's data pointer (template pointers keep `-`)
@@ -106,9 +134,41 @@ function compileRuleQuery(doc, fieldPointer, member, options) {
106
134
  * @property {function|null} enabled
107
135
  * @property {function|null} assert
108
136
  * @property {function|null} computed
109
- * @property {string|null} message
137
+ * @property {CompiledRuleMessage|null} message
138
+ * @property {string[]} deps - Pointer prefixes this rule reads (deps.js);
139
+ * the memo re-runs it only when a change touches one of them
110
140
  */
111
141
 
142
+ /**
143
+ * Compile the `message` member of an `x-form` rule. A plain string stays
144
+ * valid (backward compatible: it is the inline-template MessageSpec);
145
+ * the object form carries `$msgid`/`message`/`params`.
146
+ * @param {unknown} raw - The rule's `message` value
147
+ * @param {string} fieldPointer - The field's data pointer, for compile errors
148
+ * @returns {CompiledRuleMessage|null} The compiled spec, or null when absent
149
+ */
150
+ function compileRuleMessageSpec(raw, fieldPointer) {
151
+ if (raw === undefined || raw === null) return null;
152
+ if (typeof raw === 'string')
153
+ return { msgid: null, render: compileMessageTemplate(raw), params: null };
154
+ if (typeof raw !== 'object' || Array.isArray(raw))
155
+ throw new Error(`${fieldPointer} x-form/message: must be a string or a MessageSpec object`);
156
+ const spec = /** @type {any} */ (raw);
157
+ if (spec.$msgid !== undefined && typeof spec.$msgid !== 'string')
158
+ throw new Error(`${fieldPointer} x-form/message: '$msgid' must be a string`);
159
+ if (spec.message !== undefined && typeof spec.message !== 'string')
160
+ throw new Error(`${fieldPointer} x-form/message: 'message' must be a string`);
161
+ if (spec.$msgid === undefined && spec.message === undefined)
162
+ throw new Error(`${fieldPointer} x-form/message: needs '$msgid' and/or 'message'`);
163
+ if (spec.params !== undefined && (spec.params === null || typeof spec.params !== 'object' || Array.isArray(spec.params)))
164
+ throw new Error(`${fieldPointer} x-form/message: 'params' must be an object`);
165
+ return {
166
+ msgid: spec.$msgid !== undefined ? spec.$msgid : null,
167
+ render: spec.message !== undefined ? compileMessageTemplate(spec.message) : null,
168
+ params: spec.params !== undefined ? spec.params : null,
169
+ };
170
+ }
171
+
112
172
  /**
113
173
  * @typedef {object} CompiledRules
114
174
  * @property {Array<CompiledFieldRules>} rules
@@ -138,6 +198,16 @@ function compileRuleQuery(doc, fieldPointer, member, options) {
138
198
  * results['/vatId']; // { visible: true, errors: [{ keyword: 'x-form/assert', ... }] }
139
199
  */
140
200
  export function compileFormRules(model, options = {}) {
201
+ // a root-level `visible` rule is a modeling error, rejected here so
202
+ // it can never fire: hiding the whole form would make the render
203
+ // tree AND the session summary vanish (`buildFormViewModel` → null),
204
+ // silently voiding the dirty/navigation authority. Whole-form
205
+ // visibility belongs to the host at the mount boundary.
206
+ if (model?.rules?.visible !== undefined) {
207
+ throw new TypeError(
208
+ 'compileFormRules: a root-level x-form "visible" rule is not allowed - '
209
+ + 'gate the whole form at the mount boundary instead');
210
+ }
141
211
  const queryOptions = options.compileTypeTest !== undefined
142
212
  ? { compileTypeTest: options.compileTypeTest }
143
213
  : {};
@@ -167,7 +237,8 @@ function walkField(field, parts, rules, queryOptions) {
167
237
  ? compileRuleQuery(raw.assert, pointer, 'assert', queryOptions) : null,
168
238
  computed: raw.computed !== undefined
169
239
  ? compileRuleQuery(raw.computed, pointer, 'computed', queryOptions) : null,
170
- message: typeof raw.message === 'string' ? raw.message : null,
240
+ message: compileRuleMessageSpec(raw.message, pointer),
241
+ deps: fieldDependencies(raw, parts),
171
242
  });
172
243
  }
173
244
 
@@ -183,13 +254,38 @@ function walkField(field, parts, rules, queryOptions) {
183
254
  walkField(field.item, [...parts, ITEM], rules, queryOptions);
184
255
  }
185
256
 
257
+ /**
258
+ * What one field's rules read: every root-anchored path in its four
259
+ * query documents, plus its own location — the `value` external binds
260
+ * from there, and for a templated field the whole array above the item
261
+ * slot, since adding or removing an element changes which pointers the
262
+ * rule even produces.
263
+ * @param {any} raw - The authored `x-form` object
264
+ * @param {Array<string|symbol>} parts - Decoded path segments; ITEM marks a slot
265
+ * @returns {string[]}
266
+ */
267
+ function fieldDependencies(raw, parts) {
268
+ let own = '';
269
+ for (const part of parts) {
270
+ if (part === ITEM) break;
271
+ own += `/${escapePointerKey(/** @type {string} */ (part))}`;
272
+ }
273
+ return mergeDependencies([
274
+ [own],
275
+ raw.visible !== undefined ? queryDependencies(raw.visible) : [],
276
+ raw.enabled !== undefined ? queryDependencies(raw.enabled) : [],
277
+ raw.assert !== undefined ? queryDependencies(raw.assert) : [],
278
+ raw.computed !== undefined ? queryDependencies(raw.computed) : [],
279
+ ]);
280
+ }
281
+
186
282
  /**
187
283
  * Evaluate one field's compiled rules against the data root.
188
284
  * The externals object is reused across rules: the compiled query copies
189
285
  * externals into its frame before evaluating (see the query engine), so
190
286
  * mutation between calls is safe and allocation-free.
191
287
  */
192
- function evaluateOne(rule, data, value, pointer, ext, results) {
288
+ function evaluateOne(rule, data, value, pointer, ext, results, catalog, written) {
193
289
  ext.value = value === undefined ? null : value;
194
290
  ext.pointer = pointer;
195
291
 
@@ -218,13 +314,34 @@ function evaluateOne(rule, data, value, pointer, ext, results) {
218
314
  ok = false; // fail closed: an uncomputable assertion is not satisfied
219
315
  }
220
316
  if (!ok) {
221
- result.errors = [{
222
- keyword: 'x-form/assert',
223
- message: rule.message !== null ? rule.message : DEFAULT_ASSERT_MESSAGE,
224
- }];
317
+ const spec = rule.message;
318
+ const msgid = spec !== null && spec.msgid !== null ? spec.msgid : DEFAULT_ASSERT_MSGID;
319
+ const params = spec !== null && spec.params !== null
320
+ ? { ...spec.params, pointer }
321
+ : { pointer };
322
+ let message;
323
+ if (spec !== null) {
324
+ if (spec.msgid !== null) {
325
+ // D-M5 chain: active catalog, then built-in English, then the
326
+ // spec's inline template, then the assert default text
327
+ let render = catalog !== undefined ? catalog[spec.msgid] : undefined;
328
+ if (render === undefined) render = formsMessages[spec.msgid];
329
+ if (render !== undefined) message = render(params);
330
+ else if (spec.render !== null) message = spec.render(params);
331
+ else message = renderFormsMessage(catalog, DEFAULT_ASSERT_MSGID, params);
332
+ }
333
+ else {
334
+ message = /** @type {(params: object) => string} */ (spec.render)(params);
335
+ }
336
+ }
337
+ else {
338
+ message = renderFormsMessage(catalog, DEFAULT_ASSERT_MSGID, params);
339
+ }
340
+ result.errors = [{ keyword: 'x-form/assert', params, msgid, message }];
225
341
  }
226
342
  }
227
343
  results[pointer] = result;
344
+ if (written !== null) written.push(pointer);
228
345
  }
229
346
 
230
347
  function ebvFailOpen(query, data, ext) {
@@ -247,14 +364,16 @@ function ebvFailOpen(query, data, ext) {
247
364
  * dispatcher deciding per node what it applies to. Keep the mechanism in
248
365
  * this function.
249
366
  */
250
- function expandItemRule(rule, data, node, partIndex, pointer, ext, results) {
367
+ function expandItemRule(rule, data, node, partIndex, pointer, ext, results, catalog, written) {
251
368
  const parts = rule.parts;
252
369
  for (let i = partIndex; i < parts.length; i++) {
253
370
  const part = parts[i];
254
371
  if (part === ITEM) {
255
372
  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);
373
+ for (let index = 0; index < node.length; index++) {
374
+ expandItemRule(rule, data, node[index], i + 1, `${pointer}/${index}`,
375
+ ext, results, catalog, written);
376
+ }
258
377
  return;
259
378
  }
260
379
  pointer = `${pointer}/${escapePointerKey(part)}`;
@@ -262,7 +381,7 @@ function expandItemRule(rule, data, node, partIndex, pointer, ext, results) {
262
381
  ? node[/** @type {string} */ (part)]
263
382
  : undefined;
264
383
  }
265
- evaluateOne(rule, data, node, pointer, ext, results);
384
+ evaluateOne(rule, data, node, pointer, ext, results, catalog, written);
266
385
  }
267
386
 
268
387
  /**
@@ -272,31 +391,219 @@ function expandItemRule(rule, data, node, partIndex, pointer, ext, results) {
272
391
  * field declares. Rules on array item templates are evaluated once per
273
392
  * element of the actual array, keyed by the expanded pointer.
274
393
  *
394
+ * Pass a `memo` from {@link createRuleMemo} to re-evaluate only the
395
+ * rules a change can have affected. The memo diffs the previous
396
+ * document against this one — reference-equal subtrees are skipped
397
+ * whole, so an immutable edit costs O(change) — and re-runs a rule only
398
+ * when a changed pointer touches one of its declared dependencies
399
+ * (deps.js). The result is identical to an unmemoized evaluation.
400
+ *
401
+ * The memo OWNS the map it returns and patches it on later calls: a
402
+ * caller must read it before evaluating again, and must not keep it as
403
+ * a snapshot (`buildFormViewModel` reads it synchronously, which is the
404
+ * intended shape). Handing back a fresh map instead would put a write
405
+ * per rule back on the hot path — on a wide form, the rules that did
406
+ * NOT change are the work worth skipping.
407
+ *
275
408
  * @param {CompiledRules} compiled - From compileFormRules
276
409
  * @param {any} data - The form data root (the query input `$`)
410
+ * @param {Readonly<Record<string, (params: object, error?: object) => string>>} [catalog] - Optional compiled message catalog (see messages.js), default English
411
+ * @param {RuleMemo} [memo] - Reused across calls; mutated in place
277
412
  * @returns {Record<string, RuleResult>}
278
413
  * @example
279
414
  * const results = evaluateFormRules(compiled, { company: 'ACME', vatId: '' });
280
415
  * results['/vatId'].errors; // [{ keyword: 'x-form/assert', message: '...' }]
281
416
  */
282
- export function evaluateFormRules(compiled, data) {
417
+ export function evaluateFormRules(compiled, data, catalog = undefined, memo = undefined) {
418
+ const rules = compiled.rules;
419
+ // a catalog swap (a locale switch) invalidates every rendered message
420
+ const reuse = memo !== undefined && memo.results !== null && memo.catalog === catalog;
421
+ // A declared write is taken at its word; otherwise the documents are
422
+ // diffed. Diffing is the honest default — it needs nothing from the
423
+ // caller — but it must scan the members of every container that
424
+ // changed identity, which a host that just wrote `/lines/2/amount`
425
+ // can simply tell us instead.
426
+ const changed = reuse ? (memo.touched ?? changedPointers(memo.data, data)) : null;
427
+ if (memo !== undefined) memo.touched = null;
428
+ if (reuse && changed.length === 0) return memo.results;
429
+
430
+ const ext = { value: null, pointer: '' };
431
+ if (reuse) {
432
+ // Patch the previous map in place. Rebuilding it would put a write
433
+ // per rule back on the hot path, which is most of what there was to
434
+ // save: on a wide form the untouched rules ARE the work.
435
+ const results = memo.results;
436
+ for (let i = 0; i < rules.length; i++) {
437
+ const rule = rules[i];
438
+ if (!dependenciesAffected(rule.deps, changed)) continue;
439
+ if (!rule.templated) {
440
+ // it writes its own pointer and nothing else, every time — no
441
+ // key set to track and nothing that can go stale
442
+ const value = rule.getValue(data);
443
+ evaluateOne(rule, data, value === JSONPOINTER_NOTHING ? undefined : value,
444
+ rule.pointer, ext, results, catalog, null);
445
+ continue;
446
+ }
447
+ const written = [];
448
+ expandItemRule(rule, data, data, 0, '', ext, results, catalog, written);
449
+ // an item-template rule's key set follows the array's length, so
450
+ // an entry it no longer writes has to go
451
+ const previous = memo.keys[i];
452
+ for (let k = 0; k < previous.length; k++) {
453
+ if (!written.includes(previous[k])) delete results[previous[k]];
454
+ }
455
+ memo.keys[i] = written;
456
+ }
457
+ memo.data = data;
458
+ return results;
459
+ }
460
+
283
461
  /** @type {Record<string, RuleResult>} */
284
462
  const results = {};
285
- const ext = { value: null, pointer: '' };
286
- const rules = compiled.rules;
463
+ /** @type {string[][]|null} */
464
+ const keys = memo !== undefined ? new Array(rules.length) : null;
287
465
  for (let i = 0; i < rules.length; i++) {
288
466
  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
- }
467
+ // only a template rule's key set is data-dependent; every other
468
+ // rule writes exactly its own pointer
469
+ const written = keys !== null && rule.templated ? [] : null;
470
+ evaluateRule(rule, data, ext, results, catalog, written);
471
+ if (keys !== null) keys[i] = written ?? [rule.pointer];
472
+ }
473
+ if (memo !== undefined) {
474
+ memo.data = data;
475
+ memo.catalog = catalog;
476
+ memo.results = results;
477
+ memo.keys = keys;
296
478
  }
297
479
  return results;
298
480
  }
299
481
 
482
+ /** Evaluate one compiled rule into the results map. */
483
+ function evaluateRule(rule, data, ext, results, catalog, written) {
484
+ if (rule.templated) {
485
+ expandItemRule(rule, data, data, 0, '', ext, results, catalog, written);
486
+ return;
487
+ }
488
+ const value = rule.getValue(data);
489
+ evaluateOne(rule, data, value === JSONPOINTER_NOTHING ? undefined : value,
490
+ rule.pointer, ext, results, catalog, written);
491
+ }
492
+
493
+ /**
494
+ * The memo {@link evaluateFormRules} carries between keystrokes: the
495
+ * document it last saw, the results it produced, and which result keys
496
+ * each rule wrote (an item-template rule writes one per element, so the
497
+ * count is data-dependent and has to be recorded, not derived).
498
+ * @typedef {object} RuleMemo
499
+ * @property {any} data
500
+ * @property {any} catalog
501
+ * @property {Record<string, RuleResult>|null} results
502
+ * @property {string[][]|null} keys
503
+ * @property {string[]|null} touched - Pointers declared through
504
+ * {@link RuleMemo.touch}, consumed by the next evaluation
505
+ * @property {(pointer: string) => RuleMemo} touch
506
+ */
507
+
508
+ /**
509
+ * Create an empty rule memo. One per form session: it is bound to the
510
+ * document lineage it has seen, so sharing it between two forms would
511
+ * diff unrelated documents (correct, but pointlessly expensive).
512
+ *
513
+ * `memo.touch(pointer)` declares a write before the next evaluation.
514
+ * It is an optimization AND a promise: the evaluation then trusts the
515
+ * declaration instead of diffing, so a caller that touches one pointer
516
+ * while changing another gets stale results for the rules it did not
517
+ * name. Say nothing and the diff works it out.
518
+ * @returns {RuleMemo}
519
+ * @example
520
+ * const memo = createRuleMemo();
521
+ * data = setValueAtPointer(data, '/lines/2/amount', 9);
522
+ * const state = evaluateFormRules(compiled, data, catalog, memo.touch('/lines/2/amount'));
523
+ */
524
+ export function createRuleMemo() {
525
+ /** @type {RuleMemo} */
526
+ const memo = {
527
+ data: undefined,
528
+ catalog: undefined,
529
+ results: null,
530
+ keys: null,
531
+ touched: null,
532
+ touch(pointer) {
533
+ (memo.touched ??= []).push(pointer);
534
+ return memo;
535
+ },
536
+ };
537
+ return memo;
538
+ }
539
+
540
+ /** Whether any changed pointer touches any of a rule's dependencies. */
541
+ function dependenciesAffected(deps, changed) {
542
+ if (deps === ALL_POINTERS) return true;
543
+ for (let i = 0; i < deps.length; i++) {
544
+ for (let k = 0; k < changed.length; k++) {
545
+ if (dependencyTouched(deps[i], changed[k])) return true;
546
+ }
547
+ }
548
+ return false;
549
+ }
550
+
551
+ /**
552
+ * Drop the values of fields their `visible` rules currently hide, for a
553
+ * caller about to submit.
554
+ *
555
+ * The policy this settles: hidden values are KEPT while editing (a
556
+ * field that reappears must not have forgotten what the operator typed)
557
+ * and dropped only here, at the submit boundary, by a caller who asked.
558
+ * Nothing prunes implicitly — `buildFormViewModel` still never touches
559
+ * the data, and this returns a copy.
560
+ *
561
+ * Visibility is evaluated ONCE against the incoming document, so a
562
+ * `visible` rule that reads a value this call removes still sees it.
563
+ * Hidden array ELEMENTS are removed and their siblings renumber, which
564
+ * is right for a document being sent but means the returned pointers no
565
+ * longer match the ones the view model rendered.
566
+ *
567
+ * @param {CompiledRules} compiled - From compileFormRules
568
+ * @param {any} data - The form data root
569
+ * @param {Readonly<Record<string, (params: object, error?: object) => string>>} [catalog]
570
+ * @returns {any} A copy without the hidden values (`data` itself when
571
+ * nothing is hidden; untouched subtrees are shared)
572
+ * @example
573
+ * const submitted = pruneHiddenValues(compiled, session.data);
574
+ */
575
+ export function pruneHiddenValues(compiled, data, catalog = undefined) {
576
+ const results = evaluateFormRules(compiled, data, catalog);
577
+ const hidden = [];
578
+ for (const pointer of Object.keys(results)) {
579
+ if (results[pointer].visible === false && pointer !== '') hidden.push(pointer);
580
+ }
581
+ if (hidden.length === 0) return data;
582
+ // Deepest first, and higher array indexes before lower ones: removing
583
+ // an element renumbers its siblings, so every pointer still to be
584
+ // processed must address a location the removal cannot have moved.
585
+ hidden.sort(comparePointersDescending);
586
+ let out = data;
587
+ for (const pointer of hidden)
588
+ out = setValueAtPointer(out, pointer, undefined);
589
+ return out;
590
+ }
591
+
592
+ /** Order two pointers deepest-first, numeric segments by value. */
593
+ function comparePointersDescending(a, b) {
594
+ const left = a.split('/');
595
+ const right = b.split('/');
596
+ const shared = Math.min(left.length, right.length);
597
+ for (let i = 1; i < shared; i++) {
598
+ if (left[i] === right[i]) continue;
599
+ const na = Number(left[i]);
600
+ const nb = Number(right[i]);
601
+ if (Number.isInteger(na) && Number.isInteger(nb)) return nb - na;
602
+ return left[i] < right[i] ? 1 : -1;
603
+ }
604
+ return right.length - left.length;
605
+ }
606
+
300
607
  //#region $query synergy
301
608
 
302
609
  /**
@@ -324,23 +631,43 @@ function pathNameSelector(key) {
324
631
  * Pure schema-to-schema transform - no validator import; the output only
325
632
  * spells the keyword.
326
633
  *
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.
634
+ * The asserts land on the ROOT schema (where the query input `$` is the
635
+ * instance root, matching the rule context), each as its OWN `allOf`
636
+ * branch `{ "$query": <wrapped>, "errorMessage": { "$query": <spec> } }`
637
+ * so per-assert identity - and the rule's authored message - survives
638
+ * into submit validation. Each assert is wrapped to rebuild its bindings:
639
+ * `value` binds to the field's location, `pointer` to its pointer string.
640
+ * An assert on an array item template quantifies with `$every` over the
641
+ * actual elements (`pointer` then stays the template pointer - element
642
+ * indexes are a render-time notion). An existing root `$query` is left
643
+ * untouched on the root itself.
644
+ *
645
+ * The carried message spec is the rule's `x-form.message` - inline string
646
+ * as an inline `message`, `$msgid` form passed through - with `params`
647
+ * merged over `{ pointer: <field pointer> }`; a rule with no message gets
648
+ * `{ "$msgid": "x-form/assert", "params": { "pointer": ... } }`. EVERY
649
+ * submit-time `$query` failure therefore carries the owning field's
650
+ * pointer in `params`, which lets UIs map root-level `$query` errors onto
651
+ * fields.
335
652
  *
336
653
  * The transform follows the same structural spine as buildFormModel
337
654
  * (`properties`, `items`, `prefixItems`, `allOf`) but does not resolve
338
655
  * `$ref`s - a `$def`'s data location depends on its use site.
339
656
  *
657
+ * Absent fields bind exactly as they do per keystroke: `null`. A path
658
+ * that selects nothing is the empty sequence, which compares unequal to
659
+ * everything and would make `$ne`/`$eq` mean the opposite thing on the
660
+ * two sides of the same authored rule - so the binding is wrapped in a
661
+ * `$default` against `null`. For the same reason an item-template
662
+ * assert quantifies over the ELEMENTS rather than over the selected
663
+ * leaf values: quantifying over the leaves silently skips an element
664
+ * that lacks the member, where the keystroke path evaluates it with
665
+ * `null`.
666
+ *
340
667
  * @param {object|boolean} schema - The root JSON schema
341
668
  * @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
669
+ * untouched subtrees are shared) with the collected `$query` branches,
670
+ * or the input itself when there is nothing to copy
344
671
  * @example
345
672
  * const submitSchema = formRulesToQueryAssertions(schema);
346
673
  * const validate = new JarenValidator().compile(submitSchema); // caller-side
@@ -349,55 +676,127 @@ export function formRulesToQueryAssertions(schema) {
349
676
  if (schema == null || typeof schema !== 'object' || Array.isArray(schema))
350
677
  return schema;
351
678
 
352
- /** @type {any[]} */
679
+ /** @type {Array<{query: any, pointer: string, message: unknown}>} */
353
680
  const assertions = [];
354
- collectAsserts(schema, '', '$', 0, assertions);
681
+ collectAsserts(schema, '', ['$'], assertions);
355
682
  if (assertions.length === 0)
356
683
  return schema;
357
684
 
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 }] };
685
+ const branches = assertions.map(assert => ({
686
+ $query: assert.query,
687
+ errorMessage: { $query: assertMessageSpec(assert.message, assert.pointer) },
688
+ }));
689
+ const allOf = Array.isArray(schema.allOf) ? schema.allOf : [];
690
+ return { ...schema, allOf: [...allOf, ...branches] };
691
+ }
692
+
693
+ /**
694
+ * Build the `errorMessage.$query` MessageSpec carried into the submit
695
+ * schema for one assert: the rule's message with `params` merged over
696
+ * `{ pointer }`, or the `x-form/assert` catalog default.
697
+ * @param {unknown} message - The rule's raw `x-form.message`, if any
698
+ * @param {string} pointer - The owning field's data pointer
699
+ * @returns {object} The MessageSpec for the transformed schema
700
+ */
701
+ function assertMessageSpec(message, pointer) {
702
+ if (typeof message === 'string')
703
+ return { message, params: { pointer } };
704
+ if (message != null && typeof message === 'object' && !Array.isArray(message)) {
705
+ const spec = /** @type {any} */ (message);
706
+ return { ...spec, params: { pointer, ...(spec.params || {}) } };
362
707
  }
363
- return { ...schema, $query: queryDoc };
708
+ return { $msgid: 'x-form/assert', params: { pointer } };
709
+ }
710
+
711
+ /**
712
+ * The loop-variable prefix for element quantification. The leading
713
+ * underscore keeps it clear of an author's own `$let` names while
714
+ * staying inside the engine's variable grammar.
715
+ */
716
+ const ITEM_VAR = '_item';
717
+
718
+ /**
719
+ * Append one path selector to the last chunk of a chunk list, returning
720
+ * a new list (chunks are split at `[*]`; see {@link assertQuery}).
721
+ */
722
+ function extendChunks(chunks, selector) {
723
+ const next = chunks.slice();
724
+ next[next.length - 1] += selector;
725
+ return next;
726
+ }
727
+
728
+ /**
729
+ * Build the `$query` document for one assert from its data location,
730
+ * expressed as path chunks split at each array expansion: every chunk
731
+ * but the last ends with `[*]`, and the last is the tail after the
732
+ * final one (`''` when the assert sits on the element itself).
733
+ *
734
+ * Zero expansions is a plain binding; each expansion becomes an
735
+ * `$every` over the elements at that level, so the innermost binding
736
+ * reads its member off ONE element. Binding through `$default` means an
737
+ * absent location arrives as `null`, exactly as the keystroke path
738
+ * binds it.
739
+ *
740
+ * A field that also declares `visible` has its assert guarded by it:
741
+ * the assert holds vacuously while the field is hidden. That is what
742
+ * the keystroke path already does — `buildFormViewModel` drops hidden
743
+ * nodes, so their assert errors never render and never count — and an
744
+ * unguarded copy would let a field the operator cannot see or fix block
745
+ * submit forever.
746
+ * @param {string[]} chunks
747
+ * @param {any} assert - The authored rule document
748
+ * @param {string} pointer - The field's data pointer
749
+ * @param {any} [visible] - The field's `visible` rule, when it has one
750
+ * @returns {any} The wrapped query document
751
+ */
752
+ function assertQuery(chunks, assert, pointer, visible) {
753
+ const depth = chunks.length - 1;
754
+ const at = (k) => k === 0 ? chunks[0] : `$${ITEM_VAR}${k - 1}${chunks[k]}`;
755
+ const body = visible === undefined
756
+ ? assert
757
+ : { $or: [{ $not: visible }, assert] };
758
+ let query = {
759
+ $let: { value: { $default: [at(depth), { $const: null }] }, pointer: { $const: pointer } },
760
+ $return: body,
761
+ };
762
+ for (let k = depth - 1; k >= 0; k--)
763
+ query = { $every: { [`${ITEM_VAR}${k}`]: at(k) }, $satisfies: query };
764
+ return query;
364
765
  }
365
766
 
366
767
  /**
367
768
  * 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.
769
+ * and the path chunks of their data location.
371
770
  */
372
- function collectAsserts(schema, pointer, path, itemDepth, out) {
771
+ function collectAsserts(schema, pointer, chunks, out) {
373
772
  if (schema == null || typeof schema !== 'object' || Array.isArray(schema))
374
773
  return;
375
774
 
376
775
  const rules = schema['x-form'];
377
776
  if (rules != null && typeof rules === 'object' && !Array.isArray(rules)
378
777
  && 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 } });
778
+ out.push({
779
+ query: assertQuery(chunks, rules.assert, pointer, rules.visible),
780
+ pointer,
781
+ message: rules.message,
782
+ });
384
783
  }
385
784
 
386
785
  if (schema.properties != null && typeof schema.properties === 'object') {
387
786
  for (const [key, sub] of Object.entries(schema.properties)) {
388
787
  collectAsserts(sub, `${pointer}/${escapePointerKey(key)}`,
389
- path + pathNameSelector(key), itemDepth, out);
788
+ extendChunks(chunks, pathNameSelector(key)), out);
390
789
  }
391
790
  }
392
791
  if (Array.isArray(schema.prefixItems)) {
393
792
  for (let i = 0; i < schema.prefixItems.length; i++)
394
- collectAsserts(schema.prefixItems[i], `${pointer}/${i}`, `${path}[${i}]`, itemDepth, out);
793
+ collectAsserts(schema.prefixItems[i], `${pointer}/${i}`, extendChunks(chunks, `[${i}]`), out);
395
794
  }
396
795
  if (schema.items != null && typeof schema.items === 'object' && !Array.isArray(schema.items))
397
- collectAsserts(schema.items, `${pointer}/-`, `${path}[*]`, itemDepth + 1, out);
796
+ collectAsserts(schema.items, `${pointer}/-`, [...extendChunks(chunks, '[*]'), ''], out);
398
797
  if (Array.isArray(schema.allOf)) {
399
798
  for (const branch of schema.allOf)
400
- collectAsserts(branch, pointer, path, itemDepth, out);
799
+ collectAsserts(branch, pointer, chunks, out);
401
800
  }
402
801
  }
403
802