bank20baht-validator 0.5.0 → 0.5.1

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/README.md CHANGED
@@ -69,6 +69,7 @@ step with the core version or the validator's `tsc` build resolves a stale copy
69
69
 
70
70
  `options` is `{ strict?: boolean }` (default `true`, see above).
71
71
  | `parseSchema(json)` | structural gate for storing builder output (throws on bad input) |
72
+ | `verifyReferences(schema, data, registry?)` | async: resolves every `reference` field id through `registry.references[name].resolve` and returns issues for records that do not exist (call after `validateSubmission`) |
72
73
  | `isFormDefinition` / `isWorkflowDefinition` | schema kind guards |
73
74
 
74
75
  All results are `{ valid, issues, data }`; issues are `{ ref, message }` where `ref` is the field key (forms) or `form_name.key` (workflows).
package/dist/index.d.ts CHANGED
@@ -52,6 +52,21 @@ export declare function validateWorkflowData(workflow: WorkflowDefinition, input
52
52
  * const result = validateSubmission(schema, body.data);
53
53
  */
54
54
  export declare function validateSubmission(schema: SchemaDefinition, data: unknown, registry?: Registry, locale?: Locale, options?: ValidateOptions): ValidationResult;
55
+ /**
56
+ * Do the records picked in `reference` fields exist? `validateSubmission` only checks that a
57
+ * reference value is an id string — existence needs the lookup, which is async. Call this
58
+ * after it, with the same registry the client uses:
59
+ *
60
+ * const result = validateSubmission(schema, body.data, registry);
61
+ * const missing = await verifyReferences(schema, result.data, registry);
62
+ *
63
+ * Each distinct `lookup + id` is resolved once (in parallel). An id `resolve` returns `null`
64
+ * for is an issue on that field ("Record not found"); a lookup the schema names but the
65
+ * registry lacks is an issue too; a lookup without `resolve` is trusted. Workflow refs are
66
+ * `form_name.key`. Top-level fields only (reference fields inside repeater rows are not
67
+ * resolved).
68
+ */
69
+ export declare function verifyReferences(schema: SchemaDefinition, data: unknown, registry?: Registry): Promise<ValidationIssue[]>;
55
70
  /**
56
71
  * Structural gate for storing builder output — the same checks the builder's
57
72
  * Import applies, so the server only ever stores schemas the client could
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@
13
13
  // every value must have the shape the UI control could actually emit before
14
14
  // the authored validators run.
15
15
  // ---------------------------------------------------------------------------
16
- import { applyFlags, approvalFields, conditionRowRefs, createScriptContext, defaultValue, evaluate, hasScripts, runScripts, getCatalog, normalizeTagsValue, parseDisabledEntry, parseIsoDate, parseIsoDateTime, parseRowRef, resolveViewFields, setCatalog, setLocale, t, isBlank, isSafeUrl, isUnsafePattern, validateField, walkPath, normalizeLegacySchema, } from 'bank20baht-ui/engine';
16
+ import { applyFlags, approvalFields, conditionRowRefs, createScriptContext, defaultValue, evaluate, hasScripts, runScripts, getCatalog, normalizeTagsValue, parseDisabledEntry, parseIsoDate, parseIsoDateTime, parseRowRef, rangeBounds, ratingMax, resolveViewFields, setCatalog, setLocale, t, isBlank, isSafeUrl, isUnsafePattern, validateField, walkPath, normalizeLegacySchema, } from 'bank20baht-ui/engine';
17
17
  /**
18
18
  * Run `fn` with the catalog switched to `locale`, restoring the previous
19
19
  * catalog after. The engine catalog is module-global; validation is fully
@@ -34,6 +34,10 @@ function withLocale(locale, fn) {
34
34
  }
35
35
  /** Keys that write through Object.prototype — never valid as field/form names. */
36
36
  const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
37
+ /** Static blocks (`label`, `section`): no value, never in the payload, never validated. */
38
+ function isStatic(field) {
39
+ return field.type === 'label' || field.type === 'section';
40
+ }
37
41
  const DATE_MODES = new Set(['single', 'range', 'multiple']);
38
42
  const FIELD_TYPES = new Set([
39
43
  'text',
@@ -50,8 +54,12 @@ const FIELD_TYPES = new Set([
50
54
  'date',
51
55
  'file',
52
56
  'label',
57
+ 'section',
53
58
  'repeater',
54
59
  'tags',
60
+ 'rating',
61
+ 'range',
62
+ 'reference',
55
63
  ]);
56
64
  export function isWorkflowDefinition(schema) {
57
65
  return (isRecord(schema) &&
@@ -156,11 +164,13 @@ function typeIssue(field, value) {
156
164
  case 'textarea':
157
165
  case 'email':
158
166
  case 'password':
167
+ case 'reference':
159
168
  return shapeMessage(typeof value === 'string');
160
169
  case 'date':
161
170
  return dateIssue(field, value);
162
171
  case 'number':
163
172
  case 'currency':
173
+ case 'range':
164
174
  return shapeMessage(typeof value === 'number' && Number.isFinite(value));
165
175
  case 'checkbox':
166
176
  case 'toggle':
@@ -172,6 +182,11 @@ function typeIssue(field, value) {
172
182
  return multiChoiceIssue(value, optionKeys);
173
183
  case 'tags':
174
184
  return tagsShapeIssue(field, value, optionKeys);
185
+ case 'rating':
186
+ return shapeMessage(typeof value === 'number' &&
187
+ Number.isInteger(value) &&
188
+ value >= 1 &&
189
+ value <= ratingMax(field));
175
190
  case 'file':
176
191
  return fileIssue(field, value);
177
192
  case 'repeater':
@@ -198,7 +213,7 @@ function shapeIssues(field, value, ref) {
198
213
  return;
199
214
  }
200
215
  for (const sub of field.fields ?? []) {
201
- if (sub.type === 'label' || UNSAFE_KEYS.has(sub.key))
216
+ if (isStatic(sub) || UNSAFE_KEYS.has(sub.key))
202
217
  continue;
203
218
  const v = Object.hasOwn(row, sub.key) ? row[sub.key] : undefined;
204
219
  const m = typeIssue(sub, v);
@@ -323,7 +338,7 @@ function canonicalRow(field, row) {
323
338
  return row;
324
339
  const next = {};
325
340
  for (const sub of field.fields ?? []) {
326
- if (sub.type === 'label' || UNSAFE_KEYS.has(sub.key) || !Object.hasOwn(row, sub.key))
341
+ if (isStatic(sub) || UNSAFE_KEYS.has(sub.key) || !Object.hasOwn(row, sub.key))
327
342
  continue;
328
343
  next[sub.key] = normalizeCanonicalValue(sub, row[sub.key]);
329
344
  }
@@ -373,7 +388,7 @@ function formDataResult(form, input, registry) {
373
388
  const issues = [];
374
389
  const fields = [];
375
390
  for (const f of sorted(form.fields)) {
376
- if (f.type === 'label')
391
+ if (isStatic(f))
377
392
  continue;
378
393
  if (UNSAFE_KEYS.has(f.key)) {
379
394
  issues.push({ ref: f.key, message: 'unsafe field key' });
@@ -526,7 +541,7 @@ function rowStatesOf(form, field, derived) {
526
541
  function formFieldIssues(form, derived, registry) {
527
542
  const issues = [];
528
543
  for (const field of sorted([...form.fields, ...approvalFields(form)])) {
529
- if (field.type === 'label')
544
+ if (isStatic(field))
530
545
  continue;
531
546
  issues.push(...workflowFieldIssues(form, field, derived, registry));
532
547
  }
@@ -613,6 +628,67 @@ export function validateSubmission(schema, data, registry, locale, options) {
613
628
  data: {},
614
629
  };
615
630
  }
631
+ /**
632
+ * Do the records picked in `reference` fields exist? `validateSubmission` only checks that a
633
+ * reference value is an id string — existence needs the lookup, which is async. Call this
634
+ * after it, with the same registry the client uses:
635
+ *
636
+ * const result = validateSubmission(schema, body.data, registry);
637
+ * const missing = await verifyReferences(schema, result.data, registry);
638
+ *
639
+ * Each distinct `lookup + id` is resolved once (in parallel). An id `resolve` returns `null`
640
+ * for is an issue on that field ("Record not found"); a lookup the schema names but the
641
+ * registry lacks is an issue too; a lookup without `resolve` is trusted. Workflow refs are
642
+ * `form_name.key`. Top-level fields only (reference fields inside repeater rows are not
643
+ * resolved).
644
+ */
645
+ export async function verifyReferences(schema, data, registry) {
646
+ const checks = [];
647
+ const collect = (fields, values, prefix) => {
648
+ if (!isRecord(values))
649
+ return;
650
+ for (const field of fields) {
651
+ if (field.type !== 'reference' || !Object.hasOwn(values, field.key))
652
+ continue;
653
+ const id = values[field.key];
654
+ if (typeof id === 'string' && id !== '') {
655
+ checks.push({ ref: `${prefix}${field.key}`, lookup: field.reference ?? '', id });
656
+ }
657
+ }
658
+ };
659
+ if (isWorkflowDefinition(schema)) {
660
+ for (const form of schema.forms) {
661
+ collect(form.fields, isRecord(data) ? data[form.form_name] : undefined, `${form.form_name}.`);
662
+ }
663
+ }
664
+ else if (isFormDefinition(schema)) {
665
+ collect(schema.fields, data, '');
666
+ }
667
+ const lookups = registry?.references ?? {};
668
+ const found = new Map();
669
+ const exists = (lookup, id) => {
670
+ const resolve = Object.hasOwn(lookups, lookup) ? lookups[lookup]?.resolve : undefined;
671
+ if (!resolve)
672
+ return null;
673
+ const key = `${lookup}\u0000${id}`;
674
+ let pending = found.get(key);
675
+ if (!pending) {
676
+ pending = Promise.resolve(resolve(id)).then((record) => record !== null, () => false);
677
+ found.set(key, pending);
678
+ }
679
+ return pending;
680
+ };
681
+ const issues = await Promise.all(checks.map(async ({ ref, lookup, id }) => {
682
+ if (!Object.hasOwn(lookups, lookup)) {
683
+ return { ref, message: `unknown reference lookup ${JSON.stringify(lookup)}` };
684
+ }
685
+ const pending = exists(lookup, id);
686
+ if (pending === null)
687
+ return null; // no resolver: trusted
688
+ return (await pending) ? null : { ref, message: t('referenceNotFound') };
689
+ }));
690
+ return issues.filter((issue) => issue !== null);
691
+ }
616
692
  /**
617
693
  * Structural gate for storing builder output — the same checks the builder's
618
694
  * Import applies, so the server only ever stores schemas the client could
@@ -725,7 +801,7 @@ function fieldRefs(wf) {
725
801
  const refs = new Set();
726
802
  for (const form of wf.forms) {
727
803
  for (const field of [...form.fields, ...approvalFields(form)]) {
728
- if (field.type === 'label')
804
+ if (isStatic(field))
729
805
  continue;
730
806
  refs.add(`${form.form_name}.${field.key}`);
731
807
  for (const ref of rowRefsOf(form.form_name, field))
@@ -773,9 +849,9 @@ function assertValidFields(form) {
773
849
  }
774
850
  }
775
851
  function assertFieldKey(field, form_name) {
776
- // labels are static blocks and may omit the key; everything else needs one
852
+ // labels/sections are static blocks and may omit the key; everything else needs one
777
853
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: value is untrusted JSON
778
- if (field.type === 'label' && field.key === undefined)
854
+ if (isStatic(field) && field.key === undefined)
779
855
  return;
780
856
  if (typeof field.key !== 'string' || field.key === '') {
781
857
  throw new Error(`field on ${form_name} needs a non-empty string key`);
@@ -822,6 +898,13 @@ function assertFieldShape(field, form_name) {
822
898
  assertFieldKey(field, form_name);
823
899
  if (field.type === 'date')
824
900
  assertDateProps(field, form_name);
901
+ if (field.type === 'rating')
902
+ assertRatingProps(field, form_name);
903
+ if (field.type === 'range')
904
+ assertRangeProps(field, form_name);
905
+ if (field.type === 'reference' && (typeof field.reference !== 'string' || !field.reference)) {
906
+ throw new Error(`reference on ${form_name}.${field.key} must name a registry lookup (registry.references)`);
907
+ }
825
908
  if (field.options !== undefined &&
826
909
  (!Array.isArray(field.options) ||
827
910
  field.options.some((o) => !isRecord(o) || typeof o.key !== 'string'))) {
@@ -830,6 +913,27 @@ function assertFieldShape(field, form_name) {
830
913
  assertFieldValidators(field, form_name);
831
914
  }
832
915
  /** Calendar constraints must be well-formed or the picker silently ignores them. */
916
+ function assertRatingProps(field, form_name) {
917
+ const max = field.max;
918
+ if (max === undefined)
919
+ return;
920
+ if (typeof max !== 'number' || !Number.isInteger(max) || max < 1 || max > 10) {
921
+ throw new Error(`max on ${form_name}.${field.key} must be a whole number from 1 to 10 (star count)`);
922
+ }
923
+ }
924
+ function assertRangeProps(field, form_name) {
925
+ const where = `${form_name}.${field.key}`;
926
+ const { min, max, step } = field;
927
+ const notNumber = (v) => v !== undefined && (typeof v !== 'number' || !Number.isFinite(v));
928
+ if (notNumber(min) || notNumber(max))
929
+ throw new Error(`min and max on ${where} must be numbers`);
930
+ const bounds = rangeBounds(field);
931
+ if (bounds.min >= bounds.max)
932
+ throw new Error(`min on ${where} must be below max`);
933
+ if (step !== undefined && (typeof step !== 'number' || !(step > 0))) {
934
+ throw new Error(`step on ${where} must be a positive number`);
935
+ }
936
+ }
833
937
  function assertDateProps(field, form_name) {
834
938
  const where = `${form_name}.${field.key}`;
835
939
  if (field.date_mode !== undefined && !DATE_MODES.has(field.date_mode)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bank20baht-validator",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "Runtime validation for bahtui form/workflow JSON \u2014 the exact engine rules the client runs, on the server.",
6
6
  "license": "MIT",
@@ -41,12 +41,12 @@
41
41
  "test:coverage": "vitest run --coverage"
42
42
  },
43
43
  "devDependencies": {
44
- "bank20baht-ui": "^0.13.0",
44
+ "bank20baht-ui": "^0.13.3",
45
45
  "typescript": "^5.8.3",
46
46
  "vitest": "^3.2.4"
47
47
  },
48
48
  "peerDependencies": {
49
- "bank20baht-ui": ">=0.13.0 <0.14.0"
49
+ "bank20baht-ui": ">=0.13.3 <0.14.0"
50
50
  },
51
51
  "main": "./dist/index.js",
52
52
  "types": "./dist/index.d.ts",