bank20baht-validator 0.0.1 → 0.0.3

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/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { approvalFields, defaultValue, evaluate, validateField, type FormDefinition, type NestedData, type Registry, type WorkflowDefinition, type WorkflowForm } from 'bank20baht-ui/engine';
1
+ import { approvalFields, defaultValue, evaluate, resolveViewFields, setLocale, validateField, type FormDefinition, type NestedData, type Registry, type WorkflowDefinition, type WorkflowForm } from 'bank20baht-ui/engine';
2
2
  export type SchemaDefinition = FormDefinition | WorkflowDefinition;
3
3
  /** One violation. `ref` is the field key for forms, `formName.key` for workflows. */
4
4
  export interface ValidationIssue {
@@ -20,7 +20,9 @@ export declare function isFormDefinition(schema: unknown): schema is FormDefinit
20
20
  /**
21
21
  * Validate a standalone form submission — mirrors `<baht-form>.validate()`
22
22
  * with no derived field states: every non-label field, missing values fall
23
- * back to the field default before checking.
23
+ * back to the field default before checking. `form.views` are ignored here:
24
+ * views are client-side display only, the server always validates the full
25
+ * form (a view can never weaken a validation rule).
24
26
  */
25
27
  export declare function validateFormData(form: FormDefinition, input: unknown, registry?: Registry): ValidationResult;
26
28
  /**
@@ -44,5 +46,6 @@ export declare function validateSubmission(schema: SchemaDefinition, data: unkno
44
46
  * render. Throws with a human-readable message on bad input.
45
47
  */
46
48
  export declare function parseSchema(json: unknown): SchemaDefinition;
47
- export { approvalFields, defaultValue, evaluate, validateField };
49
+ export { approvalFields, defaultValue, evaluate, resolveViewFields, setLocale, validateField };
48
50
  export type { FormDefinition, NestedData, Registry, WorkflowDefinition, WorkflowForm };
51
+ export type { FormViewSpec, Locale, MessageCatalog } from 'bank20baht-ui/engine';
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // request that bypasses the frontend hits the exact same rules — no codegen,
8
8
  // no recompile: swap the stored JSON and validation follows.
9
9
  // ---------------------------------------------------------------------------
10
- import { approvalFields, defaultValue, evaluate, validateField, } from 'bank20baht-ui/engine';
10
+ import { approvalFields, defaultValue, evaluate, resolveViewFields, setLocale, t, validateField, } from 'bank20baht-ui/engine';
11
11
  export function isWorkflowDefinition(schema) {
12
12
  return (isRecord(schema) &&
13
13
  typeof schema['workflowId'] === 'string' &&
@@ -30,20 +30,23 @@ function sorted(fields) {
30
30
  /**
31
31
  * Validate a standalone form submission — mirrors `<baht-form>.validate()`
32
32
  * with no derived field states: every non-label field, missing values fall
33
- * back to the field default before checking.
33
+ * back to the field default before checking. `form.views` are ignored here:
34
+ * views are client-side display only, the server always validates the full
35
+ * form (a view can never weaken a validation rule).
34
36
  */
35
37
  export function validateFormData(form, input, registry) {
36
38
  if (!isRecord(input)) {
37
39
  return { valid: false, issues: [{ ref: '$', message: 'data must be an object' }], data: {} };
38
40
  }
39
41
  const issues = [];
42
+ const fields = sorted(form.field_data).filter((f) => f.type !== 'label');
43
+ // canonical data first, so compare validators see every sibling
40
44
  const data = {};
41
- for (const field of sorted(form.field_data)) {
42
- if (field.type === 'label')
43
- continue;
44
- const value = field.key in input ? input[field.key] : defaultValue(field);
45
- data[field.key] = value;
46
- for (const err of validateField(field, value, registry)) {
45
+ for (const field of fields) {
46
+ data[field.key] = field.key in input ? input[field.key] : defaultValue(field);
47
+ }
48
+ for (const field of fields) {
49
+ for (const err of validateField(field, data[field.key], registry, data)) {
47
50
  issues.push({ ref: err.key, message: err.message });
48
51
  }
49
52
  }
@@ -87,8 +90,8 @@ export function validateWorkflowData(workflow, input, registry) {
87
90
  continue; // hidden fields excluded (spec 02 §5)
88
91
  const required = state ? state.required : !!field.required;
89
92
  const descriptor = required === !!field.required ? field : { ...field, required };
90
- const value = derived.data[form.formName]?.[field.key];
91
- for (const err of validateField(descriptor, value, registry)) {
93
+ const formData = derived.data[form.formName] ?? {};
94
+ for (const err of validateField(descriptor, formData[field.key], registry, formData)) {
92
95
  issues.push({ ref, message: err.message });
93
96
  }
94
97
  }
@@ -101,12 +104,12 @@ function approvalIssues(form, data) {
101
104
  const values = data[form.formName] ?? {};
102
105
  const decision = values['decision'];
103
106
  if (decision !== 'approved' && decision !== 'rejected') {
104
- issues.push({ ref: `${form.formName}.decision`, message: 'A decision is required.' });
107
+ issues.push({ ref: `${form.formName}.decision`, message: t('decisionRequired') });
105
108
  }
106
109
  const mode = form.approval?.comment ?? 'optional';
107
110
  const commentRequired = mode === 'required' || (mode === 'requireOnReject' && decision === 'rejected');
108
111
  if (commentRequired && String(values['comment'] ?? '').trim() === '') {
109
- issues.push({ ref: `${form.formName}.comment`, message: 'A comment is required.' });
112
+ issues.push({ ref: `${form.formName}.comment`, message: t('commentRequired') });
110
113
  }
111
114
  return issues;
112
115
  }
@@ -145,6 +148,11 @@ export function parseSchema(json) {
145
148
  if (f.kind !== undefined && f.kind !== 'form' && f.kind !== 'approval') {
146
149
  throw new Error(`invalid form kind ${JSON.stringify(f.kind)} on ${f.formName}`);
147
150
  }
151
+ assertValidViews(f);
152
+ assertValidRepeaters(f);
153
+ if (f.view !== undefined && !f.views?.some((v) => v.name === f.view)) {
154
+ throw new Error(`step view ${JSON.stringify(f.view)} on ${f.formName} matches no view`);
155
+ }
148
156
  }
149
157
  const names = parsed.forms.map((f) => f.formName);
150
158
  if (new Set(names).size !== names.length) {
@@ -152,10 +160,62 @@ export function parseSchema(json) {
152
160
  }
153
161
  return parsed;
154
162
  }
155
- if (isFormDefinition(parsed))
163
+ if (isFormDefinition(parsed)) {
164
+ assertValidViews(parsed);
165
+ assertValidRepeaters(parsed);
156
166
  return parsed;
167
+ }
157
168
  throw new Error('missing workflowId/forms/rules (workflow) or formId/formName/field_data (form)');
158
169
  }
170
+ /**
171
+ * View integrity — views are client-side display projections, but a stored
172
+ * one must always resolve on the client: names unique and shaped right, every
173
+ * listed key present in field_data. Catches builder typos at store time
174
+ * instead of as a silently empty form.
175
+ */
176
+ /**
177
+ * Repeater integrity (spec 01 §10): a repeater must carry sub-fields with
178
+ * unique keys, and repeaters never nest.
179
+ */
180
+ function assertValidRepeaters(form) {
181
+ for (const field of form.field_data) {
182
+ if (field.type !== 'repeater')
183
+ continue;
184
+ if (!Array.isArray(field.fields) || field.fields.length === 0) {
185
+ throw new Error(`repeater ${JSON.stringify(field.key)} on ${form.formName} needs fields[]`);
186
+ }
187
+ const keys = field.fields.map((f) => f.key);
188
+ if (new Set(keys).size !== keys.length) {
189
+ throw new Error(`repeater ${JSON.stringify(field.key)} on ${form.formName} has duplicate sub-keys`);
190
+ }
191
+ if (field.fields.some((f) => f.type === 'repeater')) {
192
+ throw new Error(`repeater ${JSON.stringify(field.key)} on ${form.formName} nests a repeater`);
193
+ }
194
+ }
195
+ }
196
+ function assertValidViews(form) {
197
+ if (form.views === undefined)
198
+ return;
199
+ if (!Array.isArray(form.views))
200
+ throw new Error(`views must be an array on ${form.formName}`);
201
+ const keys = new Set(form.field_data.map((f) => f.key));
202
+ const names = new Set();
203
+ for (const view of form.views) {
204
+ if (!isRecord(view) || typeof view.name !== 'string' || !Array.isArray(view.fields)) {
205
+ throw new Error(`invalid view on ${form.formName}: expected { name, fields[] }`);
206
+ }
207
+ if (names.has(view.name)) {
208
+ throw new Error(`view names must be unique on ${form.formName}: ${JSON.stringify(view.name)}`);
209
+ }
210
+ names.add(view.name);
211
+ for (const key of view.fields) {
212
+ if (typeof key !== 'string' || !keys.has(key)) {
213
+ throw new Error(`view ${JSON.stringify(view.name)} on ${form.formName} references unknown field ${JSON.stringify(key)}`);
214
+ }
215
+ }
216
+ }
217
+ }
159
218
  // Re-exported so servers can type stores and registries without a direct
160
- // dependency on bank20baht-ui.
161
- export { approvalFields, defaultValue, evaluate, validateField };
219
+ // dependency on bank20baht-ui. `setLocale` localizes the issues this package
220
+ // emits (same catalog the client renders).
221
+ export { approvalFields, defaultValue, evaluate, resolveViewFields, setLocale, validateField };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bank20baht-validator",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "description": "Runtime validation for bahtui form/workflow JSON — the exact engine rules the client runs, on the server.",
6
6
  "license": "MIT",
@@ -39,7 +39,7 @@
39
39
  "test:watch": "vitest"
40
40
  },
41
41
  "dependencies": {
42
- "bank20baht-ui": "^0.0.6"
42
+ "bank20baht-ui": "^0.0.8"
43
43
  },
44
44
  "devDependencies": {
45
45
  "typescript": "^5.8.3",