bank20baht-validator 0.1.3 → 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
@@ -44,9 +44,16 @@ POST /api/submissions {"id":"todo-form","data":{"title":"ab"}}
44
44
  → 422 {"errors":[{"ref":"title","message":"Must be at least 3 characters"}]}
45
45
  ```
46
46
 
47
+ ## Compatibility
48
+
49
+ Peers on `bank20baht-ui` **0.2.x** (the `FieldType` union — including `password` since 0.2.2 —
50
+ comes from there). In a workspace that also builds `bank20baht-ui`, keep the peer range in
51
+ step with the core version or the validator's `tsc` build resolves a stale copy from npm.
52
+
47
53
  ## What it enforces
48
54
 
49
- - 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
+ - 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".
50
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.
51
58
  - Approval steps: a `decision` must be present, and the comment policy (`required` / `requireOnReject`) is honored.
52
59
 
@@ -60,4 +67,4 @@ POST /api/submissions {"id":"todo-form","data":{"title":"ab"}}
60
67
  | `parseSchema(json)` | structural gate for storing builder output (throws on bad input) |
61
68
  | `isFormDefinition` / `isWorkflowDefinition` | schema kind guards |
62
69
 
63
- 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, 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
@@ -34,6 +34,7 @@ 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
+ const DATE_MODES = new Set(['single', 'range', 'multiple']);
37
38
  const FIELD_TYPES = new Set([
38
39
  'text',
39
40
  'textarea',
@@ -52,16 +53,16 @@ const FIELD_TYPES = new Set([
52
53
  ]);
53
54
  export function isWorkflowDefinition(schema) {
54
55
  return (isRecord(schema) &&
55
- typeof schema['workflowId'] === 'string' &&
56
+ typeof schema['workflow_id'] === 'string' &&
56
57
  Array.isArray(schema['forms']) &&
57
58
  Array.isArray(schema['rules']));
58
59
  }
59
60
  export function isFormDefinition(schema) {
60
61
  return (isRecord(schema) &&
61
62
  !isWorkflowDefinition(schema) &&
62
- typeof schema['formId'] === 'string' &&
63
- typeof schema['formName'] === 'string' &&
64
- Array.isArray(schema['field_data']));
63
+ typeof schema['form_id'] === 'string' &&
64
+ typeof schema['form_name'] === 'string' &&
65
+ Array.isArray(schema['fields']));
65
66
  }
66
67
  function isRecord(v) {
67
68
  return typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -82,15 +83,28 @@ function sorted(fields) {
82
83
  function typeIssue(field, value) {
83
84
  if (value === null || value === undefined)
84
85
  return null;
85
- // dynamic options (optionsFrom) can't be checked without the registry fetch
86
- 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);
87
88
  switch (field.type) {
88
89
  case 'text':
89
90
  case 'textarea':
90
91
  case 'email':
91
92
  case 'password':
92
- case 'date':
93
93
  return typeof value === 'string' ? null : t('pattern');
94
+ case 'date': {
95
+ // value shape follows date_mode; every day must be a real ISO calendar day
96
+ const isDay = (v) => parseIsoDate(v) !== null;
97
+ switch (field.date_mode ?? 'single') {
98
+ case 'range':
99
+ return Array.isArray(value) && value.length === 2 && value.every(isDay)
100
+ ? null
101
+ : t('pattern');
102
+ case 'multiple':
103
+ return Array.isArray(value) && value.every(isDay) ? null : t('pattern');
104
+ default:
105
+ return isDay(value) ? null : t('pattern');
106
+ }
107
+ }
94
108
  case 'number':
95
109
  return typeof value === 'number' && Number.isFinite(value) ? null : t('pattern');
96
110
  case 'checkbox':
@@ -163,7 +177,7 @@ function formDataResult(form, input, registry) {
163
177
  }
164
178
  const issues = [];
165
179
  const fields = [];
166
- for (const f of sorted(form.field_data)) {
180
+ for (const f of sorted(form.fields)) {
167
181
  if (f.type === 'label')
168
182
  continue;
169
183
  if (UNSAFE_KEYS.has(f.key)) {
@@ -197,7 +211,7 @@ function formDataResult(form, input, registry) {
197
211
  * enforces across its steps: rules are evaluated first (`evaluate`), invisible
198
212
  * forms/fields are excluded, rule-derived `required` overrides the descriptor,
199
213
  * approval steps demand a decision plus their comment policy, and a rejected
200
- * approval with `onReject: end` (the default) stops validation there — the
214
+ * approval with `on_reject: end` (the default) stops validation there — the
201
215
  * client fires `baht-complete { status: 'rejected' }` immediately, so later
202
216
  * forms are never visited.
203
217
  */
@@ -239,19 +253,19 @@ function workflowDataResult(workflow, input, registry) {
239
253
  }
240
254
  // Forms in the order the client visits them: path order in graph mode,
241
255
  // ascending `order` among visible forms in linear mode.
242
- const byName = new Map(workflow.forms.map((f) => [f.formName, f]));
256
+ const byName = new Map(workflow.forms.map((f) => [f.form_name, f]));
243
257
  const ordered = pathList
244
258
  ? pathList.map((n) => byName.get(n)).filter((f) => f !== undefined)
245
259
  : [...workflow.forms]
246
260
  .sort((a, b) => a.order - b.order)
247
- .filter((f) => derived.forms[f.formName]?.visible !== false);
261
+ .filter((f) => derived.forms[f.form_name]?.visible !== false);
248
262
  // Rejection cut: everything after a rejected end-on-reject approval is
249
263
  // unvisited on the client and must not be demanded here.
250
264
  const cut = ordered.findIndex((form) => {
251
265
  if (form.kind !== 'approval')
252
266
  return false;
253
- const onReject = form.approval?.onReject ?? { action: 'end' };
254
- 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';
255
269
  });
256
270
  const visited = cut >= 0 ? ordered.slice(0, cut + 1) : ordered;
257
271
  for (const form of visited) {
@@ -259,10 +273,10 @@ function workflowDataResult(workflow, input, registry) {
259
273
  issues.push(...approvalIssues(form, derived.data));
260
274
  continue;
261
275
  }
262
- for (const field of sorted(form.field_data)) {
276
+ for (const field of sorted(form.fields)) {
263
277
  if (field.type === 'label')
264
278
  continue;
265
- const ref = `${form.formName}.${field.key}`;
279
+ const ref = `${form.form_name}.${field.key}`;
266
280
  if (UNSAFE_KEYS.has(field.key)) {
267
281
  issues.push({ ref, message: 'unsafe field key' });
268
282
  continue;
@@ -270,7 +284,7 @@ function workflowDataResult(workflow, input, registry) {
270
284
  const state = derived.fields[ref];
271
285
  if (state && !state.visible)
272
286
  continue; // hidden fields excluded (spec 02 §5)
273
- const formData = derived.data[form.formName] ?? {};
287
+ const formData = derived.data[form.form_name] ?? {};
274
288
  const shape = shapeIssues(field, formData[field.key], ref);
275
289
  if (shape.length > 0) {
276
290
  issues.push(...shape);
@@ -289,15 +303,15 @@ function workflowDataResult(workflow, input, registry) {
289
303
  /** Approval step checks — same gate `<baht-workflow>.decide()` applies. */
290
304
  function approvalIssues(form, data) {
291
305
  const issues = [];
292
- const values = data[form.formName] ?? {};
306
+ const values = data[form.form_name] ?? {};
293
307
  const decision = values['decision'];
294
308
  if (decision !== 'approved' && decision !== 'rejected') {
295
- issues.push({ ref: `${form.formName}.decision`, message: t('decisionRequired') });
309
+ issues.push({ ref: `${form.form_name}.decision`, message: t('decisionRequired') });
296
310
  }
297
311
  const mode = form.approval?.comment ?? 'optional';
298
312
  const commentRequired = mode === 'required' || (mode === 'requireOnReject' && decision === 'rejected');
299
313
  if (commentRequired && String(values['comment'] ?? '').trim() === '') {
300
- issues.push({ ref: `${form.formName}.comment`, message: t('commentRequired') });
314
+ issues.push({ ref: `${form.form_name}.comment`, message: t('commentRequired') });
301
315
  }
302
316
  return issues;
303
317
  }
@@ -325,38 +339,44 @@ export function validateSubmission(schema, data, registry, locale) {
325
339
  * render. Throws with a human-readable message on bad input.
326
340
  *
327
341
  * Beyond shape it validates referential integrity (rule targets, condition
328
- * refs, transition endpoints, onReject.goto) and compiles every `pattern`
342
+ * refs, transition endpoints, on_reject.goto) and compiles every `pattern`
329
343
  * validator, so authoring defects surface at store time — not as a 500 or a
330
344
  * submission that can never pass.
331
345
  */
332
346
  export function parseSchema(json) {
333
- const parsed = typeof json === 'string' ? JSON.parse(json) : json;
334
- if (!isRecord(parsed))
347
+ const raw = typeof json === 'string' ? JSON.parse(json) : json;
348
+ if (!isRecord(raw))
335
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
+ }
336
356
  if (isWorkflowDefinition(parsed)) {
337
357
  for (const f of parsed.forms) {
338
358
  if (!isRecord(f))
339
359
  throw new Error('invalid form: expected an object');
340
- if (typeof f.formName !== 'string' || f.formName === '' || !Array.isArray(f.field_data)) {
341
- 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)}`);
342
362
  }
343
- if (UNSAFE_KEYS.has(f.formName) || f.formName.includes('.')) {
344
- 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)}`);
345
365
  }
346
366
  if (typeof f.order !== 'number' || !Number.isFinite(f.order)) {
347
- throw new Error(`form ${f.formName} needs a numeric order`);
367
+ throw new Error(`form ${f.form_name} needs a numeric order`);
348
368
  }
349
369
  if (f.kind !== undefined && f.kind !== 'form' && f.kind !== 'approval') {
350
- 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}`);
351
371
  }
352
372
  assertValidFields(f);
353
373
  assertValidViews(f);
354
374
  assertValidRepeaters(f);
355
375
  if (f.view !== undefined && !f.views?.some((v) => v.name === f.view)) {
356
- 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`);
357
377
  }
358
378
  }
359
- const names = parsed.forms.map((f) => f.formName);
379
+ const names = parsed.forms.map((f) => f.form_name);
360
380
  if (new Set(names).size !== names.length) {
361
381
  throw new Error('formName must be unique in a workflow');
362
382
  }
@@ -367,16 +387,16 @@ export function parseSchema(json) {
367
387
  if (approval === undefined)
368
388
  continue;
369
389
  if (!isRecord(approval))
370
- throw new Error(`invalid approval config on ${f.formName}`);
371
- const onReject = approval.onReject;
372
- 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)
373
393
  continue;
374
- if (!isRecord(onReject) || (onReject.action !== 'end' && onReject.action !== 'goto')) {
375
- 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}`);
376
396
  }
377
- if (onReject.action === 'goto' &&
378
- (typeof onReject.form !== 'string' || !nameSet.has(onReject.form))) {
379
- 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}`);
380
400
  }
381
401
  }
382
402
  assertValidRules(parsed, nameSet, refs);
@@ -404,43 +424,43 @@ export function parseSchema(json) {
404
424
  assertValidRepeaters(parsed);
405
425
  return parsed;
406
426
  }
407
- 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)');
408
428
  }
409
429
  // ---- parseSchema internals ----------------------------------------------------
410
430
  /** Every addressable "formName.key" a rule/condition/expression may reference. */
411
431
  function fieldRefs(wf) {
412
432
  const refs = new Set();
413
433
  for (const form of wf.forms) {
414
- for (const field of [...form.field_data, ...approvalFields(form)]) {
434
+ for (const field of [...form.fields, ...approvalFields(form)]) {
415
435
  if (field.type === 'label')
416
436
  continue;
417
- refs.add(`${form.formName}.${field.key}`);
437
+ refs.add(`${form.form_name}.${field.key}`);
418
438
  }
419
439
  }
420
440
  return refs;
421
441
  }
422
442
  function assertValidFields(form) {
423
443
  const seen = new Set();
424
- for (const raw of form.field_data) {
444
+ for (const raw of form.fields) {
425
445
  if (!isRecord(raw))
426
- throw new Error(`invalid field on ${form.formName}: expected an object`);
446
+ throw new Error(`invalid field on ${form.form_name}: expected an object`);
427
447
  const field = raw;
428
- assertFieldShape(field, form.formName);
448
+ assertFieldShape(field, form.form_name);
429
449
  if (field.key !== undefined) {
430
450
  if (seen.has(field.key)) {
431
- 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}`);
432
452
  }
433
453
  seen.add(field.key);
434
454
  }
435
455
  if (field.fields !== undefined) {
436
456
  if (!Array.isArray(field.fields)) {
437
- 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`);
438
458
  }
439
459
  for (const sub of field.fields) {
440
460
  if (!isRecord(sub)) {
441
- 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`);
442
462
  }
443
- assertFieldShape(sub, form.formName);
463
+ assertFieldShape(sub, form.form_name);
444
464
  }
445
465
  }
446
466
  }
@@ -464,32 +484,34 @@ function hasNestedUnboundedQuantifier(pattern) {
464
484
  const unbounded = '(?:[*+]|\\{\\d+,\\})';
465
485
  return new RegExp(`\\((?:\\?:)?[^()|]${unbounded}\\??\\)${unbounded}`).test(stripped);
466
486
  }
467
- function assertFieldShape(field, formName) {
487
+ function assertFieldShape(field, form_name) {
468
488
  if (typeof field.type !== 'string' || !FIELD_TYPES.has(field.type)) {
469
- 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}`);
470
490
  }
471
491
  // labels are static blocks and may omit the key; everything else needs one
472
492
  if (field.type !== 'label' || field.key !== undefined) {
473
493
  if (typeof field.key !== 'string' || field.key === '') {
474
- 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`);
475
495
  }
476
496
  if (UNSAFE_KEYS.has(field.key) || field.key.includes('.')) {
477
- 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}`);
478
498
  }
479
499
  }
500
+ if (field.type === 'date')
501
+ assertDateProps(field, form_name);
480
502
  if (field.options !== undefined) {
481
503
  if (!Array.isArray(field.options) ||
482
504
  field.options.some((o) => !isRecord(o) || typeof o.key !== 'string')) {
483
- 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`);
484
506
  }
485
507
  }
486
508
  if (field.validators !== undefined) {
487
509
  if (!Array.isArray(field.validators)) {
488
- 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`);
489
511
  }
490
512
  for (const v of field.validators) {
491
513
  if (!isRecord(v) || typeof v.type !== 'string') {
492
- throw new Error(`invalid validator on ${formName}.${field.key}`);
514
+ throw new Error(`invalid validator on ${form_name}.${field.key}`);
493
515
  }
494
516
  if (v.type === 'pattern') {
495
517
  // compile now — an invalid regex would otherwise throw (or hang, if
@@ -499,10 +521,10 @@ function assertFieldShape(field, formName) {
499
521
  new RegExp(pattern);
500
522
  }
501
523
  catch {
502
- 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}`);
503
525
  }
504
526
  if (hasNestedUnboundedQuantifier(pattern)) {
505
- throw new Error(`pattern on ${formName}.${field.key} nests unbounded quantifiers ` +
527
+ throw new Error(`pattern on ${form_name}.${field.key} nests unbounded quantifiers ` +
506
528
  `(e.g. "(a+)+") — this backtracks catastrophically on hostile input; ` +
507
529
  `an equivalent single quantifier matches the same strings`);
508
530
  }
@@ -510,6 +532,32 @@ function assertFieldShape(field, formName) {
510
532
  }
511
533
  }
512
534
  }
535
+ /** Calendar constraints must be well-formed or the picker silently ignores them. */
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}`);
540
+ }
541
+ for (const bound of ['min', 'max']) {
542
+ const v = field[bound];
543
+ if (v !== undefined && parseIsoDate(v) === null) {
544
+ throw new Error(`${bound} on ${where} must be an ISO date (yyyy-mm-dd)`);
545
+ }
546
+ }
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);
550
+ if (bad) {
551
+ throw new Error(`disabled_dates on ${where} must be ISO days ("2026-04-13") or intervals ("2026-04-13/2026-04-15")`);
552
+ }
553
+ }
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);
557
+ if (bad)
558
+ throw new Error(`disabled_weekdays on ${where} must be integers 0 (Sun) … 6 (Sat)`);
559
+ }
560
+ }
513
561
  function assertValidRules(wf, formNames, refs) {
514
562
  wf.rules.forEach((raw, i) => {
515
563
  if (!isRecord(raw))
@@ -573,9 +621,9 @@ function assertValidCondition(cond, refs, where) {
573
621
  if (typeof cond['field'] !== 'string' || !refs.has(cond['field'])) {
574
622
  throw new Error(`${where} references unknown field ${JSON.stringify(cond['field'])}`);
575
623
  }
576
- const valueField = cond['valueField'];
577
- if (valueField !== undefined && (typeof valueField !== 'string' || !refs.has(valueField))) {
578
- 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)}`);
579
627
  }
580
628
  }
581
629
  function assertValidExpr(expr, refs, where) {
@@ -612,7 +660,7 @@ function assertValidExpr(expr, refs, where) {
612
660
  /**
613
661
  * View integrity — views are client-side display projections, but a stored
614
662
  * one must always resolve on the client: names unique and shaped right, every
615
- * listed key present in field_data. Catches builder typos at store time
663
+ * listed key present in fields. Catches builder typos at store time
616
664
  * instead of as a silently empty form.
617
665
  */
618
666
  /**
@@ -620,18 +668,18 @@ function assertValidExpr(expr, refs, where) {
620
668
  * unique keys, and repeaters never nest.
621
669
  */
622
670
  function assertValidRepeaters(form) {
623
- for (const field of form.field_data) {
671
+ for (const field of form.fields) {
624
672
  if (field.type !== 'repeater')
625
673
  continue;
626
674
  if (!Array.isArray(field.fields) || field.fields.length === 0) {
627
- 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[]`);
628
676
  }
629
677
  const keys = field.fields.map((f) => f.key);
630
678
  if (new Set(keys).size !== keys.length) {
631
- 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`);
632
680
  }
633
681
  if (field.fields.some((f) => f.type === 'repeater')) {
634
- 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`);
635
683
  }
636
684
  }
637
685
  }
@@ -639,20 +687,20 @@ function assertValidViews(form) {
639
687
  if (form.views === undefined)
640
688
  return;
641
689
  if (!Array.isArray(form.views))
642
- throw new Error(`views must be an array on ${form.formName}`);
643
- 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));
644
692
  const names = new Set();
645
693
  for (const view of form.views) {
646
694
  if (!isRecord(view) || typeof view.name !== 'string' || !Array.isArray(view.fields)) {
647
- throw new Error(`invalid view on ${form.formName}: expected { name, fields[] }`);
695
+ throw new Error(`invalid view on ${form.form_name}: expected { name, fields[] }`);
648
696
  }
649
697
  if (names.has(view.name)) {
650
- 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)}`);
651
699
  }
652
700
  names.add(view.name);
653
701
  for (const key of view.fields) {
654
702
  if (typeof key !== 'string' || !keys.has(key)) {
655
- 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)}`);
656
704
  }
657
705
  }
658
706
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bank20baht-validator",
3
- "version": "0.1.3",
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.2 <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",