bank20baht-validator 0.1.4 → 0.2.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 +2 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +91 -85
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ step with the core version or the validator's `tsc` build resolves a stale copy
|
|
|
52
52
|
|
|
53
53
|
## What it enforces
|
|
54
54
|
|
|
55
|
-
- Every declarative validator of the field descriptors: `required`, `
|
|
55
|
+
- Every declarative validator of the field descriptors: `required`, `min_length`, `max_length`, `min`, `max`, `pattern`, `email`, and `custom` (via a `Registry`).
|
|
56
56
|
- Value **shape** per field type before any validator runs — a `password`/`text` must be a string, a `number` a finite number, a `dropdown` one of its option keys — so `{"$gt":""}` from a raw API call is rejected, not treated as "non-empty".
|
|
57
57
|
- **Workflow rules first** (`evaluate` from `bank20baht-ui/engine`): fields hidden by rules are excluded and smuggled values are cleared; `required` derived from rules is enforced; `setValue` results are computed.
|
|
58
58
|
- Approval steps: a `decision` must be present, and the comment policy (`required` / `requireOnReject`) is honored.
|
|
@@ -67,4 +67,4 @@ step with the core version or the validator's `tsc` build resolves a stale copy
|
|
|
67
67
|
| `parseSchema(json)` | structural gate for storing builder output (throws on bad input) |
|
|
68
68
|
| `isFormDefinition` / `isWorkflowDefinition` | schema kind guards |
|
|
69
69
|
|
|
70
|
-
All results are `{ valid, issues, data }`; issues are `{ ref, message }` where `ref` is the field key (forms) or `
|
|
70
|
+
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { applyFlags, approvalFields, defaultValue, evaluate, resolveViewFields, setLocale, validateField, type FormDefinition, type Locale, type NestedData, type Registry, type WorkflowDefinition, type WorkflowForm } from 'bank20baht-ui/engine';
|
|
2
2
|
export type SchemaDefinition = FormDefinition | WorkflowDefinition;
|
|
3
|
-
/** One violation. `ref` is the field key for forms, `
|
|
3
|
+
/** One violation. `ref` is the field key for forms, `form_name.key` for workflows. */
|
|
4
4
|
export interface ValidationIssue {
|
|
5
5
|
ref: string;
|
|
6
6
|
message: string;
|
|
@@ -30,7 +30,7 @@ export declare function validateFormData(form: FormDefinition, input: unknown, r
|
|
|
30
30
|
* enforces across its steps: rules are evaluated first (`evaluate`), invisible
|
|
31
31
|
* forms/fields are excluded, rule-derived `required` overrides the descriptor,
|
|
32
32
|
* approval steps demand a decision plus their comment policy, and a rejected
|
|
33
|
-
* approval with `
|
|
33
|
+
* approval with `on_reject: end` (the default) stops validation there — the
|
|
34
34
|
* client fires `baht-complete { status: 'rejected' }` immediately, so later
|
|
35
35
|
* forms are never visited.
|
|
36
36
|
*/
|
|
@@ -49,7 +49,7 @@ export declare function validateSubmission(schema: SchemaDefinition, data: unkno
|
|
|
49
49
|
* render. Throws with a human-readable message on bad input.
|
|
50
50
|
*
|
|
51
51
|
* Beyond shape it validates referential integrity (rule targets, condition
|
|
52
|
-
* refs, transition endpoints,
|
|
52
|
+
* refs, transition endpoints, on_reject.goto) and compiles every `pattern`
|
|
53
53
|
* validator, so authoring defects surface at store time — not as a 500 or a
|
|
54
54
|
* submission that can never pass.
|
|
55
55
|
*/
|
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, defaultValue, evaluate, getCatalog, parseDisabledEntry, parseIsoDate, resolveViewFields, setCatalog, setLocale, t, validateField, walkPath, } from 'bank20baht-ui/engine';
|
|
16
|
+
import { applyFlags, approvalFields, defaultValue, evaluate, getCatalog, parseDisabledEntry, parseIsoDate, resolveViewFields, setCatalog, setLocale, t, 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
|
|
@@ -53,16 +53,16 @@ const FIELD_TYPES = new Set([
|
|
|
53
53
|
]);
|
|
54
54
|
export function isWorkflowDefinition(schema) {
|
|
55
55
|
return (isRecord(schema) &&
|
|
56
|
-
typeof schema['
|
|
56
|
+
typeof schema['workflow_id'] === 'string' &&
|
|
57
57
|
Array.isArray(schema['forms']) &&
|
|
58
58
|
Array.isArray(schema['rules']));
|
|
59
59
|
}
|
|
60
60
|
export function isFormDefinition(schema) {
|
|
61
61
|
return (isRecord(schema) &&
|
|
62
62
|
!isWorkflowDefinition(schema) &&
|
|
63
|
-
typeof schema['
|
|
64
|
-
typeof schema['
|
|
65
|
-
Array.isArray(schema['
|
|
63
|
+
typeof schema['form_id'] === 'string' &&
|
|
64
|
+
typeof schema['form_name'] === 'string' &&
|
|
65
|
+
Array.isArray(schema['fields']));
|
|
66
66
|
}
|
|
67
67
|
function isRecord(v) {
|
|
68
68
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
@@ -83,8 +83,8 @@ function sorted(fields) {
|
|
|
83
83
|
function typeIssue(field, value) {
|
|
84
84
|
if (value === null || value === undefined)
|
|
85
85
|
return null;
|
|
86
|
-
// dynamic options (
|
|
87
|
-
const optionKeys = field.
|
|
86
|
+
// dynamic options (options_from) can't be checked without the registry fetch
|
|
87
|
+
const optionKeys = field.options_from ? null : field.options?.map((o) => o.key);
|
|
88
88
|
switch (field.type) {
|
|
89
89
|
case 'text':
|
|
90
90
|
case 'textarea':
|
|
@@ -92,9 +92,9 @@ function typeIssue(field, value) {
|
|
|
92
92
|
case 'password':
|
|
93
93
|
return typeof value === 'string' ? null : t('pattern');
|
|
94
94
|
case 'date': {
|
|
95
|
-
// value shape follows
|
|
95
|
+
// value shape follows date_mode; every day must be a real ISO calendar day
|
|
96
96
|
const isDay = (v) => parseIsoDate(v) !== null;
|
|
97
|
-
switch (field.
|
|
97
|
+
switch (field.date_mode ?? 'single') {
|
|
98
98
|
case 'range':
|
|
99
99
|
return Array.isArray(value) && value.length === 2 && value.every(isDay)
|
|
100
100
|
? null
|
|
@@ -177,7 +177,7 @@ function formDataResult(form, input, registry) {
|
|
|
177
177
|
}
|
|
178
178
|
const issues = [];
|
|
179
179
|
const fields = [];
|
|
180
|
-
for (const f of sorted(form.
|
|
180
|
+
for (const f of sorted(form.fields)) {
|
|
181
181
|
if (f.type === 'label')
|
|
182
182
|
continue;
|
|
183
183
|
if (UNSAFE_KEYS.has(f.key)) {
|
|
@@ -211,7 +211,7 @@ function formDataResult(form, input, registry) {
|
|
|
211
211
|
* enforces across its steps: rules are evaluated first (`evaluate`), invisible
|
|
212
212
|
* forms/fields are excluded, rule-derived `required` overrides the descriptor,
|
|
213
213
|
* approval steps demand a decision plus their comment policy, and a rejected
|
|
214
|
-
* approval with `
|
|
214
|
+
* approval with `on_reject: end` (the default) stops validation there — the
|
|
215
215
|
* client fires `baht-complete { status: 'rejected' }` immediately, so later
|
|
216
216
|
* forms are never visited.
|
|
217
217
|
*/
|
|
@@ -253,19 +253,19 @@ function workflowDataResult(workflow, input, registry) {
|
|
|
253
253
|
}
|
|
254
254
|
// Forms in the order the client visits them: path order in graph mode,
|
|
255
255
|
// ascending `order` among visible forms in linear mode.
|
|
256
|
-
const byName = new Map(workflow.forms.map((f) => [f.
|
|
256
|
+
const byName = new Map(workflow.forms.map((f) => [f.form_name, f]));
|
|
257
257
|
const ordered = pathList
|
|
258
258
|
? pathList.map((n) => byName.get(n)).filter((f) => f !== undefined)
|
|
259
259
|
: [...workflow.forms]
|
|
260
260
|
.sort((a, b) => a.order - b.order)
|
|
261
|
-
.filter((f) => derived.forms[f.
|
|
261
|
+
.filter((f) => derived.forms[f.form_name]?.visible !== false);
|
|
262
262
|
// Rejection cut: everything after a rejected end-on-reject approval is
|
|
263
263
|
// unvisited on the client and must not be demanded here.
|
|
264
264
|
const cut = ordered.findIndex((form) => {
|
|
265
265
|
if (form.kind !== 'approval')
|
|
266
266
|
return false;
|
|
267
|
-
const
|
|
268
|
-
return
|
|
267
|
+
const on_reject = form.approval?.on_reject ?? { action: 'end' };
|
|
268
|
+
return on_reject.action === 'end' && derived.data[form.form_name]?.['decision'] === 'rejected';
|
|
269
269
|
});
|
|
270
270
|
const visited = cut >= 0 ? ordered.slice(0, cut + 1) : ordered;
|
|
271
271
|
for (const form of visited) {
|
|
@@ -273,10 +273,10 @@ function workflowDataResult(workflow, input, registry) {
|
|
|
273
273
|
issues.push(...approvalIssues(form, derived.data));
|
|
274
274
|
continue;
|
|
275
275
|
}
|
|
276
|
-
for (const field of sorted(form.
|
|
276
|
+
for (const field of sorted(form.fields)) {
|
|
277
277
|
if (field.type === 'label')
|
|
278
278
|
continue;
|
|
279
|
-
const ref = `${form.
|
|
279
|
+
const ref = `${form.form_name}.${field.key}`;
|
|
280
280
|
if (UNSAFE_KEYS.has(field.key)) {
|
|
281
281
|
issues.push({ ref, message: 'unsafe field key' });
|
|
282
282
|
continue;
|
|
@@ -284,7 +284,7 @@ function workflowDataResult(workflow, input, registry) {
|
|
|
284
284
|
const state = derived.fields[ref];
|
|
285
285
|
if (state && !state.visible)
|
|
286
286
|
continue; // hidden fields excluded (spec 02 §5)
|
|
287
|
-
const formData = derived.data[form.
|
|
287
|
+
const formData = derived.data[form.form_name] ?? {};
|
|
288
288
|
const shape = shapeIssues(field, formData[field.key], ref);
|
|
289
289
|
if (shape.length > 0) {
|
|
290
290
|
issues.push(...shape);
|
|
@@ -303,15 +303,15 @@ function workflowDataResult(workflow, input, registry) {
|
|
|
303
303
|
/** Approval step checks — same gate `<baht-workflow>.decide()` applies. */
|
|
304
304
|
function approvalIssues(form, data) {
|
|
305
305
|
const issues = [];
|
|
306
|
-
const values = data[form.
|
|
306
|
+
const values = data[form.form_name] ?? {};
|
|
307
307
|
const decision = values['decision'];
|
|
308
308
|
if (decision !== 'approved' && decision !== 'rejected') {
|
|
309
|
-
issues.push({ ref: `${form.
|
|
309
|
+
issues.push({ ref: `${form.form_name}.decision`, message: t('decisionRequired') });
|
|
310
310
|
}
|
|
311
311
|
const mode = form.approval?.comment ?? 'optional';
|
|
312
312
|
const commentRequired = mode === 'required' || (mode === 'requireOnReject' && decision === 'rejected');
|
|
313
313
|
if (commentRequired && String(values['comment'] ?? '').trim() === '') {
|
|
314
|
-
issues.push({ ref: `${form.
|
|
314
|
+
issues.push({ ref: `${form.form_name}.comment`, message: t('commentRequired') });
|
|
315
315
|
}
|
|
316
316
|
return issues;
|
|
317
317
|
}
|
|
@@ -339,38 +339,44 @@ export function validateSubmission(schema, data, registry, locale) {
|
|
|
339
339
|
* render. Throws with a human-readable message on bad input.
|
|
340
340
|
*
|
|
341
341
|
* Beyond shape it validates referential integrity (rule targets, condition
|
|
342
|
-
* refs, transition endpoints,
|
|
342
|
+
* refs, transition endpoints, on_reject.goto) and compiles every `pattern`
|
|
343
343
|
* validator, so authoring defects surface at store time — not as a 500 or a
|
|
344
344
|
* submission that can never pass.
|
|
345
345
|
*/
|
|
346
346
|
export function parseSchema(json) {
|
|
347
|
-
const
|
|
348
|
-
if (!isRecord(
|
|
347
|
+
const raw = typeof json === 'string' ? JSON.parse(json) : json;
|
|
348
|
+
if (!isRecord(raw))
|
|
349
349
|
throw new Error('not an object');
|
|
350
|
+
// pre-0.3 JSON (formId / field_data / maxLength …) still parses — keys are
|
|
351
|
+
// migrated forward; the returned schema is always the current spelling.
|
|
352
|
+
const { schema: parsed, renamed } = normalizeLegacySchema(raw);
|
|
353
|
+
if (renamed.length > 0) {
|
|
354
|
+
console.warn(`[bank20baht-validator] legacy camelCase schema keys migrated (${renamed.length}): ${renamed.slice(0, 5).join(', ')}${renamed.length > 5 ? ', …' : ''} — re-save the schema with snake_case keys.`);
|
|
355
|
+
}
|
|
350
356
|
if (isWorkflowDefinition(parsed)) {
|
|
351
357
|
for (const f of parsed.forms) {
|
|
352
358
|
if (!isRecord(f))
|
|
353
359
|
throw new Error('invalid form: expected an object');
|
|
354
|
-
if (typeof f.
|
|
355
|
-
throw new Error(`invalid form ${JSON.stringify(f.
|
|
360
|
+
if (typeof f.form_name !== 'string' || f.form_name === '' || !Array.isArray(f.fields)) {
|
|
361
|
+
throw new Error(`invalid form ${JSON.stringify(f.form_id)}`);
|
|
356
362
|
}
|
|
357
|
-
if (UNSAFE_KEYS.has(f.
|
|
358
|
-
throw new Error(`invalid
|
|
363
|
+
if (UNSAFE_KEYS.has(f.form_name) || f.form_name.includes('.')) {
|
|
364
|
+
throw new Error(`invalid form_name ${JSON.stringify(f.form_name)}`);
|
|
359
365
|
}
|
|
360
366
|
if (typeof f.order !== 'number' || !Number.isFinite(f.order)) {
|
|
361
|
-
throw new Error(`form ${f.
|
|
367
|
+
throw new Error(`form ${f.form_name} needs a numeric order`);
|
|
362
368
|
}
|
|
363
369
|
if (f.kind !== undefined && f.kind !== 'form' && f.kind !== 'approval') {
|
|
364
|
-
throw new Error(`invalid form kind ${JSON.stringify(f.kind)} on ${f.
|
|
370
|
+
throw new Error(`invalid form kind ${JSON.stringify(f.kind)} on ${f.form_name}`);
|
|
365
371
|
}
|
|
366
372
|
assertValidFields(f);
|
|
367
373
|
assertValidViews(f);
|
|
368
374
|
assertValidRepeaters(f);
|
|
369
375
|
if (f.view !== undefined && !f.views?.some((v) => v.name === f.view)) {
|
|
370
|
-
throw new Error(`step view ${JSON.stringify(f.view)} on ${f.
|
|
376
|
+
throw new Error(`step view ${JSON.stringify(f.view)} on ${f.form_name} matches no view`);
|
|
371
377
|
}
|
|
372
378
|
}
|
|
373
|
-
const names = parsed.forms.map((f) => f.
|
|
379
|
+
const names = parsed.forms.map((f) => f.form_name);
|
|
374
380
|
if (new Set(names).size !== names.length) {
|
|
375
381
|
throw new Error('formName must be unique in a workflow');
|
|
376
382
|
}
|
|
@@ -381,16 +387,16 @@ export function parseSchema(json) {
|
|
|
381
387
|
if (approval === undefined)
|
|
382
388
|
continue;
|
|
383
389
|
if (!isRecord(approval))
|
|
384
|
-
throw new Error(`invalid approval config on ${f.
|
|
385
|
-
const
|
|
386
|
-
if (
|
|
390
|
+
throw new Error(`invalid approval config on ${f.form_name}`);
|
|
391
|
+
const on_reject = approval.on_reject;
|
|
392
|
+
if (on_reject === undefined)
|
|
387
393
|
continue;
|
|
388
|
-
if (!isRecord(
|
|
389
|
-
throw new Error(`invalid
|
|
394
|
+
if (!isRecord(on_reject) || (on_reject.action !== 'end' && on_reject.action !== 'goto')) {
|
|
395
|
+
throw new Error(`invalid on_reject on ${f.form_name}`);
|
|
390
396
|
}
|
|
391
|
-
if (
|
|
392
|
-
(typeof
|
|
393
|
-
throw new Error(`
|
|
397
|
+
if (on_reject.action === 'goto' &&
|
|
398
|
+
(typeof on_reject.form !== 'string' || !nameSet.has(on_reject.form))) {
|
|
399
|
+
throw new Error(`on_reject.goto targets unknown form ${JSON.stringify(on_reject.form)} on ${f.form_name}`);
|
|
394
400
|
}
|
|
395
401
|
}
|
|
396
402
|
assertValidRules(parsed, nameSet, refs);
|
|
@@ -418,43 +424,43 @@ export function parseSchema(json) {
|
|
|
418
424
|
assertValidRepeaters(parsed);
|
|
419
425
|
return parsed;
|
|
420
426
|
}
|
|
421
|
-
throw new Error('missing
|
|
427
|
+
throw new Error('missing workflow_id/forms/rules (workflow) or form_id/form_name/fields (form)');
|
|
422
428
|
}
|
|
423
429
|
// ---- parseSchema internals ----------------------------------------------------
|
|
424
430
|
/** Every addressable "formName.key" a rule/condition/expression may reference. */
|
|
425
431
|
function fieldRefs(wf) {
|
|
426
432
|
const refs = new Set();
|
|
427
433
|
for (const form of wf.forms) {
|
|
428
|
-
for (const field of [...form.
|
|
434
|
+
for (const field of [...form.fields, ...approvalFields(form)]) {
|
|
429
435
|
if (field.type === 'label')
|
|
430
436
|
continue;
|
|
431
|
-
refs.add(`${form.
|
|
437
|
+
refs.add(`${form.form_name}.${field.key}`);
|
|
432
438
|
}
|
|
433
439
|
}
|
|
434
440
|
return refs;
|
|
435
441
|
}
|
|
436
442
|
function assertValidFields(form) {
|
|
437
443
|
const seen = new Set();
|
|
438
|
-
for (const raw of form.
|
|
444
|
+
for (const raw of form.fields) {
|
|
439
445
|
if (!isRecord(raw))
|
|
440
|
-
throw new Error(`invalid field on ${form.
|
|
446
|
+
throw new Error(`invalid field on ${form.form_name}: expected an object`);
|
|
441
447
|
const field = raw;
|
|
442
|
-
assertFieldShape(field, form.
|
|
448
|
+
assertFieldShape(field, form.form_name);
|
|
443
449
|
if (field.key !== undefined) {
|
|
444
450
|
if (seen.has(field.key)) {
|
|
445
|
-
throw new Error(`duplicate field key ${JSON.stringify(field.key)} on ${form.
|
|
451
|
+
throw new Error(`duplicate field key ${JSON.stringify(field.key)} on ${form.form_name}`);
|
|
446
452
|
}
|
|
447
453
|
seen.add(field.key);
|
|
448
454
|
}
|
|
449
455
|
if (field.fields !== undefined) {
|
|
450
456
|
if (!Array.isArray(field.fields)) {
|
|
451
|
-
throw new Error(`fields[] on ${form.
|
|
457
|
+
throw new Error(`fields[] on ${form.form_name}.${field.key} must be an array`);
|
|
452
458
|
}
|
|
453
459
|
for (const sub of field.fields) {
|
|
454
460
|
if (!isRecord(sub)) {
|
|
455
|
-
throw new Error(`invalid sub-field on ${form.
|
|
461
|
+
throw new Error(`invalid sub-field on ${form.form_name}.${field.key}: expected an object`);
|
|
456
462
|
}
|
|
457
|
-
assertFieldShape(sub, form.
|
|
463
|
+
assertFieldShape(sub, form.form_name);
|
|
458
464
|
}
|
|
459
465
|
}
|
|
460
466
|
}
|
|
@@ -478,34 +484,34 @@ function hasNestedUnboundedQuantifier(pattern) {
|
|
|
478
484
|
const unbounded = '(?:[*+]|\\{\\d+,\\})';
|
|
479
485
|
return new RegExp(`\\((?:\\?:)?[^()|]${unbounded}\\??\\)${unbounded}`).test(stripped);
|
|
480
486
|
}
|
|
481
|
-
function assertFieldShape(field,
|
|
487
|
+
function assertFieldShape(field, form_name) {
|
|
482
488
|
if (typeof field.type !== 'string' || !FIELD_TYPES.has(field.type)) {
|
|
483
|
-
throw new Error(`unknown field type ${JSON.stringify(field.type)} on ${
|
|
489
|
+
throw new Error(`unknown field type ${JSON.stringify(field.type)} on ${form_name}`);
|
|
484
490
|
}
|
|
485
491
|
// labels are static blocks and may omit the key; everything else needs one
|
|
486
492
|
if (field.type !== 'label' || field.key !== undefined) {
|
|
487
493
|
if (typeof field.key !== 'string' || field.key === '') {
|
|
488
|
-
throw new Error(`field on ${
|
|
494
|
+
throw new Error(`field on ${form_name} needs a non-empty string key`);
|
|
489
495
|
}
|
|
490
496
|
if (UNSAFE_KEYS.has(field.key) || field.key.includes('.')) {
|
|
491
|
-
throw new Error(`invalid field key ${JSON.stringify(field.key)} on ${
|
|
497
|
+
throw new Error(`invalid field key ${JSON.stringify(field.key)} on ${form_name}`);
|
|
492
498
|
}
|
|
493
499
|
}
|
|
494
500
|
if (field.type === 'date')
|
|
495
|
-
assertDateProps(field,
|
|
501
|
+
assertDateProps(field, form_name);
|
|
496
502
|
if (field.options !== undefined) {
|
|
497
503
|
if (!Array.isArray(field.options) ||
|
|
498
504
|
field.options.some((o) => !isRecord(o) || typeof o.key !== 'string')) {
|
|
499
|
-
throw new Error(`invalid options on ${
|
|
505
|
+
throw new Error(`invalid options on ${form_name}.${field.key}: expected { key, label } items`);
|
|
500
506
|
}
|
|
501
507
|
}
|
|
502
508
|
if (field.validators !== undefined) {
|
|
503
509
|
if (!Array.isArray(field.validators)) {
|
|
504
|
-
throw new Error(`validators on ${
|
|
510
|
+
throw new Error(`validators on ${form_name}.${field.key} must be an array`);
|
|
505
511
|
}
|
|
506
512
|
for (const v of field.validators) {
|
|
507
513
|
if (!isRecord(v) || typeof v.type !== 'string') {
|
|
508
|
-
throw new Error(`invalid validator on ${
|
|
514
|
+
throw new Error(`invalid validator on ${form_name}.${field.key}`);
|
|
509
515
|
}
|
|
510
516
|
if (v.type === 'pattern') {
|
|
511
517
|
// compile now — an invalid regex would otherwise throw (or hang, if
|
|
@@ -515,10 +521,10 @@ function assertFieldShape(field, formName) {
|
|
|
515
521
|
new RegExp(pattern);
|
|
516
522
|
}
|
|
517
523
|
catch {
|
|
518
|
-
throw new Error(`invalid pattern ${JSON.stringify(v.value)} on ${
|
|
524
|
+
throw new Error(`invalid pattern ${JSON.stringify(v.value)} on ${form_name}.${field.key}`);
|
|
519
525
|
}
|
|
520
526
|
if (hasNestedUnboundedQuantifier(pattern)) {
|
|
521
|
-
throw new Error(`pattern on ${
|
|
527
|
+
throw new Error(`pattern on ${form_name}.${field.key} nests unbounded quantifiers ` +
|
|
522
528
|
`(e.g. "(a+)+") — this backtracks catastrophically on hostile input; ` +
|
|
523
529
|
`an equivalent single quantifier matches the same strings`);
|
|
524
530
|
}
|
|
@@ -527,10 +533,10 @@ function assertFieldShape(field, formName) {
|
|
|
527
533
|
}
|
|
528
534
|
}
|
|
529
535
|
/** Calendar constraints must be well-formed or the picker silently ignores them. */
|
|
530
|
-
function assertDateProps(field,
|
|
531
|
-
const where = `${
|
|
532
|
-
if (field.
|
|
533
|
-
throw new Error(`invalid
|
|
536
|
+
function assertDateProps(field, form_name) {
|
|
537
|
+
const where = `${form_name}.${field.key}`;
|
|
538
|
+
if (field.date_mode !== undefined && !DATE_MODES.has(field.date_mode)) {
|
|
539
|
+
throw new Error(`invalid date_mode ${JSON.stringify(field.date_mode)} on ${where}`);
|
|
534
540
|
}
|
|
535
541
|
for (const bound of ['min', 'max']) {
|
|
536
542
|
const v = field[bound];
|
|
@@ -538,18 +544,18 @@ function assertDateProps(field, formName) {
|
|
|
538
544
|
throw new Error(`${bound} on ${where} must be an ISO date (yyyy-mm-dd)`);
|
|
539
545
|
}
|
|
540
546
|
}
|
|
541
|
-
if (field.
|
|
542
|
-
const bad = !Array.isArray(field.
|
|
543
|
-
field.
|
|
547
|
+
if (field.disabled_dates !== undefined) {
|
|
548
|
+
const bad = !Array.isArray(field.disabled_dates) ||
|
|
549
|
+
field.disabled_dates.some((d) => typeof d !== 'string' || parseDisabledEntry(d) === null);
|
|
544
550
|
if (bad) {
|
|
545
|
-
throw new Error(`
|
|
551
|
+
throw new Error(`disabled_dates on ${where} must be ISO days ("2026-04-13") or intervals ("2026-04-13/2026-04-15")`);
|
|
546
552
|
}
|
|
547
553
|
}
|
|
548
|
-
if (field.
|
|
549
|
-
const bad = !Array.isArray(field.
|
|
550
|
-
field.
|
|
554
|
+
if (field.disabled_weekdays !== undefined) {
|
|
555
|
+
const bad = !Array.isArray(field.disabled_weekdays) ||
|
|
556
|
+
field.disabled_weekdays.some((d) => !Number.isInteger(d) || d < 0 || d > 6);
|
|
551
557
|
if (bad)
|
|
552
|
-
throw new Error(`
|
|
558
|
+
throw new Error(`disabled_weekdays on ${where} must be integers 0 (Sun) … 6 (Sat)`);
|
|
553
559
|
}
|
|
554
560
|
}
|
|
555
561
|
function assertValidRules(wf, formNames, refs) {
|
|
@@ -615,9 +621,9 @@ function assertValidCondition(cond, refs, where) {
|
|
|
615
621
|
if (typeof cond['field'] !== 'string' || !refs.has(cond['field'])) {
|
|
616
622
|
throw new Error(`${where} references unknown field ${JSON.stringify(cond['field'])}`);
|
|
617
623
|
}
|
|
618
|
-
const
|
|
619
|
-
if (
|
|
620
|
-
throw new Error(`${where} references unknown field ${JSON.stringify(
|
|
624
|
+
const value_field = cond['value_field'];
|
|
625
|
+
if (value_field !== undefined && (typeof value_field !== 'string' || !refs.has(value_field))) {
|
|
626
|
+
throw new Error(`${where} references unknown field ${JSON.stringify(value_field)}`);
|
|
621
627
|
}
|
|
622
628
|
}
|
|
623
629
|
function assertValidExpr(expr, refs, where) {
|
|
@@ -654,7 +660,7 @@ function assertValidExpr(expr, refs, where) {
|
|
|
654
660
|
/**
|
|
655
661
|
* View integrity — views are client-side display projections, but a stored
|
|
656
662
|
* one must always resolve on the client: names unique and shaped right, every
|
|
657
|
-
* listed key present in
|
|
663
|
+
* listed key present in fields. Catches builder typos at store time
|
|
658
664
|
* instead of as a silently empty form.
|
|
659
665
|
*/
|
|
660
666
|
/**
|
|
@@ -662,18 +668,18 @@ function assertValidExpr(expr, refs, where) {
|
|
|
662
668
|
* unique keys, and repeaters never nest.
|
|
663
669
|
*/
|
|
664
670
|
function assertValidRepeaters(form) {
|
|
665
|
-
for (const field of form.
|
|
671
|
+
for (const field of form.fields) {
|
|
666
672
|
if (field.type !== 'repeater')
|
|
667
673
|
continue;
|
|
668
674
|
if (!Array.isArray(field.fields) || field.fields.length === 0) {
|
|
669
|
-
throw new Error(`repeater ${JSON.stringify(field.key)} on ${form.
|
|
675
|
+
throw new Error(`repeater ${JSON.stringify(field.key)} on ${form.form_name} needs fields[]`);
|
|
670
676
|
}
|
|
671
677
|
const keys = field.fields.map((f) => f.key);
|
|
672
678
|
if (new Set(keys).size !== keys.length) {
|
|
673
|
-
throw new Error(`repeater ${JSON.stringify(field.key)} on ${form.
|
|
679
|
+
throw new Error(`repeater ${JSON.stringify(field.key)} on ${form.form_name} has duplicate sub-keys`);
|
|
674
680
|
}
|
|
675
681
|
if (field.fields.some((f) => f.type === 'repeater')) {
|
|
676
|
-
throw new Error(`repeater ${JSON.stringify(field.key)} on ${form.
|
|
682
|
+
throw new Error(`repeater ${JSON.stringify(field.key)} on ${form.form_name} nests a repeater`);
|
|
677
683
|
}
|
|
678
684
|
}
|
|
679
685
|
}
|
|
@@ -681,20 +687,20 @@ function assertValidViews(form) {
|
|
|
681
687
|
if (form.views === undefined)
|
|
682
688
|
return;
|
|
683
689
|
if (!Array.isArray(form.views))
|
|
684
|
-
throw new Error(`views must be an array on ${form.
|
|
685
|
-
const keys = new Set(form.
|
|
690
|
+
throw new Error(`views must be an array on ${form.form_name}`);
|
|
691
|
+
const keys = new Set(form.fields.map((f) => f.key));
|
|
686
692
|
const names = new Set();
|
|
687
693
|
for (const view of form.views) {
|
|
688
694
|
if (!isRecord(view) || typeof view.name !== 'string' || !Array.isArray(view.fields)) {
|
|
689
|
-
throw new Error(`invalid view on ${form.
|
|
695
|
+
throw new Error(`invalid view on ${form.form_name}: expected { name, fields[] }`);
|
|
690
696
|
}
|
|
691
697
|
if (names.has(view.name)) {
|
|
692
|
-
throw new Error(`view names must be unique on ${form.
|
|
698
|
+
throw new Error(`view names must be unique on ${form.form_name}: ${JSON.stringify(view.name)}`);
|
|
693
699
|
}
|
|
694
700
|
names.add(view.name);
|
|
695
701
|
for (const key of view.fields) {
|
|
696
702
|
if (typeof key !== 'string' || !keys.has(key)) {
|
|
697
|
-
throw new Error(`view ${JSON.stringify(view.name)} on ${form.
|
|
703
|
+
throw new Error(`view ${JSON.stringify(view.name)} on ${form.form_name} references unknown field ${JSON.stringify(key)}`);
|
|
698
704
|
}
|
|
699
705
|
}
|
|
700
706
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bank20baht-validator",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
@@ -40,12 +40,12 @@
|
|
|
40
40
|
"test:watch": "vitest"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"bank20baht-ui": "^0.
|
|
43
|
+
"bank20baht-ui": "^0.4.0",
|
|
44
44
|
"typescript": "^5.8.3",
|
|
45
45
|
"vitest": "^3.2.4"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"bank20baht-ui": ">=0.
|
|
48
|
+
"bank20baht-ui": ">=0.3.0 <0.5.0"
|
|
49
49
|
},
|
|
50
50
|
"main": "./dist/index.js",
|
|
51
51
|
"types": "./dist/index.d.ts",
|