bank20baht-validator 0.1.4 → 0.2.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/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`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `email`, and `custom` (via a `Registry`).
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 `formName.key` (workflows).
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, `formName.key` for workflows. */
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 `onReject: end` (the default) stops validation there — the
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, onReject.goto) and compiles every `pattern`
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['workflowId'] === 'string' &&
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['formId'] === 'string' &&
64
- typeof schema['formName'] === 'string' &&
65
- Array.isArray(schema['field_data']));
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 (optionsFrom) can't be checked without the registry fetch
87
- const optionKeys = field.optionsFrom ? null : field.options?.map((o) => o.key);
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 dateMode; every day must be a real ISO calendar day
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.dateMode ?? 'single') {
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.field_data)) {
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 `onReject: end` (the default) stops validation there — the
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.formName, 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.formName]?.visible !== false);
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 onReject = form.approval?.onReject ?? { action: 'end' };
268
- return onReject.action === 'end' && derived.data[form.formName]?.['decision'] === 'rejected';
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.field_data)) {
276
+ for (const field of sorted(form.fields)) {
277
277
  if (field.type === 'label')
278
278
  continue;
279
- const ref = `${form.formName}.${field.key}`;
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.formName] ?? {};
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.formName] ?? {};
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.formName}.decision`, message: t('decisionRequired') });
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.formName}.comment`, message: t('commentRequired') });
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, onReject.goto) and compiles every `pattern`
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 parsed = typeof json === 'string' ? JSON.parse(json) : json;
348
- if (!isRecord(parsed))
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.formName !== 'string' || f.formName === '' || !Array.isArray(f.field_data)) {
355
- throw new Error(`invalid form ${JSON.stringify(f.formId)}`);
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.formName) || f.formName.includes('.')) {
358
- throw new Error(`invalid formName ${JSON.stringify(f.formName)}`);
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.formName} needs a numeric order`);
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.formName}`);
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.formName} matches no view`);
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.formName);
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.formName}`);
385
- const onReject = approval.onReject;
386
- if (onReject === undefined)
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(onReject) || (onReject.action !== 'end' && onReject.action !== 'goto')) {
389
- throw new Error(`invalid onReject on ${f.formName}`);
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 (onReject.action === 'goto' &&
392
- (typeof onReject.form !== 'string' || !nameSet.has(onReject.form))) {
393
- throw new Error(`onReject.goto targets unknown form ${JSON.stringify(onReject.form)} on ${f.formName}`);
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 workflowId/forms/rules (workflow) or formId/formName/field_data (form)');
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.field_data, ...approvalFields(form)]) {
434
+ for (const field of [...form.fields, ...approvalFields(form)]) {
429
435
  if (field.type === 'label')
430
436
  continue;
431
- refs.add(`${form.formName}.${field.key}`);
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.field_data) {
444
+ for (const raw of form.fields) {
439
445
  if (!isRecord(raw))
440
- throw new Error(`invalid field on ${form.formName}: expected an object`);
446
+ throw new Error(`invalid field on ${form.form_name}: expected an object`);
441
447
  const field = raw;
442
- assertFieldShape(field, form.formName);
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.formName}`);
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.formName}.${field.key} must be an array`);
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.formName}.${field.key}: expected an object`);
461
+ throw new Error(`invalid sub-field on ${form.form_name}.${field.key}: expected an object`);
456
462
  }
457
- assertFieldShape(sub, form.formName);
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, formName) {
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 ${formName}`);
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 ${formName} needs a non-empty string key`);
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 ${formName}`);
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, formName);
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 ${formName}.${field.key}: expected { key, label } items`);
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 ${formName}.${field.key} must be an array`);
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 ${formName}.${field.key}`);
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 ${formName}.${field.key}`);
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 ${formName}.${field.key} nests unbounded quantifiers ` +
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, formName) {
531
- const where = `${formName}.${field.key}`;
532
- if (field.dateMode !== undefined && !DATE_MODES.has(field.dateMode)) {
533
- throw new Error(`invalid dateMode ${JSON.stringify(field.dateMode)} on ${where}`);
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.disabledDates !== undefined) {
542
- const bad = !Array.isArray(field.disabledDates) ||
543
- field.disabledDates.some((d) => typeof d !== 'string' || parseDisabledEntry(d) === null);
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(`disabledDates on ${where} must be ISO days ("2026-04-13") or intervals ("2026-04-13/2026-04-15")`);
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.disabledWeekdays !== undefined) {
549
- const bad = !Array.isArray(field.disabledWeekdays) ||
550
- field.disabledWeekdays.some((d) => !Number.isInteger(d) || d < 0 || d > 6);
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(`disabledWeekdays on ${where} must be integers 0 (Sun) … 6 (Sat)`);
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 valueField = cond['valueField'];
619
- if (valueField !== undefined && (typeof valueField !== 'string' || !refs.has(valueField))) {
620
- throw new Error(`${where} references unknown field ${JSON.stringify(valueField)}`);
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 field_data. Catches builder typos at store time
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.field_data) {
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.formName} needs fields[]`);
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.formName} has duplicate sub-keys`);
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.formName} nests a repeater`);
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.formName}`);
685
- const keys = new Set(form.field_data.map((f) => f.key));
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.formName}: expected { name, fields[] }`);
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.formName}: ${JSON.stringify(view.name)}`);
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.formName} references unknown field ${JSON.stringify(key)}`);
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.4",
3
+ "version": "0.2.0",
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.2.2",
43
+ "bank20baht-ui": "^0.3.0",
44
44
  "typescript": "^5.8.3",
45
45
  "vitest": "^3.2.4"
46
46
  },
47
47
  "peerDependencies": {
48
- "bank20baht-ui": ">=0.2.6 <0.3.0"
48
+ "bank20baht-ui": ">=0.3.0 <0.4.0"
49
49
  },
50
50
  "main": "./dist/index.js",
51
51
  "types": "./dist/index.d.ts",