@form-engine-ts/core 4.1.0 → 4.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
@@ -41,13 +41,15 @@ round-trips. `completionMessage` is localized with the rest of the form text.
41
41
 
42
42
  `transformFieldType` changes a question's type without discarding source text, translations, conditions, or extension
43
43
  metadata. `validateFormSchema(schema, { policy })` applies the framework-independent `FormPolicy`, including field,
44
- option, text, serialized-byte, allowed-type, and locale constraints. `allowedLocales` constrains the default and supported
44
+ option, text, serialized-byte, allowed-type, locale, and per-field `fieldConstraints` rules. Rating bounds can be fixed
45
+ or range-limited, text lengths can have a policy maximum, and required state can be fixed. `allowedLocales` constrains the default and supported
45
46
  locales, `maxLocales` limits their unique total, and contradictory required/allowed locale policies are reported.
46
47
  Required locales cover every source text that exists on the form, its fields, options, and pages.
47
48
 
48
49
  `collectSchemaLocales(schema)` scans registrations plus every form/page/field/option `translations` and
49
50
  `translationMetadata` key. Validation reports unregistered translation locales and applies `allowedLocales` and
50
- `maxLocales` to the complete collected set. `sanitizeSchema` purges unregistered locale content. Pass
51
+ `maxLocales` to the complete collected set. `sanitizeSchema(schema, { policy })` also applies fixed field values and
52
+ safe maximum-length corrections while purging unregistered locale content. Pass
51
53
  `{ policy: { allowedLocales, maxLocales } }` to `populateSchemaTranslations` to reject inadmissible targets before the
52
54
  translation adapter runs.
53
55
 
package/dist/index.cjs CHANGED
@@ -156,6 +156,27 @@ function sanitizePageLocales(page, registeredLocales) {
156
156
  const translations = registeredEntries(page.translations, registeredLocales);
157
157
  return { ...base, ...translations === void 0 ? {} : { translations } };
158
158
  }
159
+ function sanitizeFieldConstraints(field, policy) {
160
+ const constraint = policy?.fieldConstraints?.[field.type];
161
+ if (constraint === void 0) return field;
162
+ const required = constraint.fixedRequired ?? field.required;
163
+ if (field.type === "rating") {
164
+ const ratingConstraint = "fixedMin" in constraint || "fixedMax" in constraint ? constraint : void 0;
165
+ const min = ratingConstraint !== void 0 && "fixedMin" in ratingConstraint ? ratingConstraint.fixedMin : field.min;
166
+ const max = ratingConstraint !== void 0 && "fixedMax" in ratingConstraint ? ratingConstraint.fixedMax : field.max;
167
+ return {
168
+ ...field,
169
+ required,
170
+ ...min === void 0 ? {} : { min },
171
+ ...max === void 0 ? {} : { max }
172
+ };
173
+ }
174
+ if ((field.type === "text" || field.type === "textarea") && "maxMaxLength" in constraint) {
175
+ const maxLength = field.maxLength === void 0 || constraint.maxMaxLength === void 0 ? field.maxLength : Math.min(field.maxLength, constraint.maxMaxLength);
176
+ return { ...field, required, ...maxLength === void 0 ? {} : { maxLength } };
177
+ }
178
+ return { ...field, required };
179
+ }
159
180
  function cyclicQuestionIds(fields) {
160
181
  const firstById = /* @__PURE__ */ new Map();
161
182
  for (const field of fields) {
@@ -240,7 +261,7 @@ function validateSchemaStructure(schema) {
240
261
  }
241
262
  return issues;
242
263
  }
243
- function sanitizeSchema(schema) {
264
+ function sanitizeSchema(schema, options = {}) {
244
265
  const existingQuestionIds = new Set(schema.fields.map((field) => field.id));
245
266
  const registeredLocales = /* @__PURE__ */ new Set([
246
267
  ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
@@ -248,7 +269,7 @@ function sanitizeSchema(schema) {
248
269
  ]);
249
270
  const cyclic = cyclicQuestionIds(schema.fields);
250
271
  const sanitizedFields = schema.fields.map((sourceField) => {
251
- const field = sanitizeFieldLocales(sourceField, registeredLocales);
272
+ const field = sanitizeFieldConstraints(sanitizeFieldLocales(sourceField, registeredLocales), options.policy);
252
273
  const sourceId = field.displayCondition?.questionId;
253
274
  if (sourceId === void 0 || existingQuestionIds.has(sourceId) && sourceId !== field.id && !cyclic.has(field.id)) {
254
275
  return field;
@@ -306,6 +327,17 @@ function isNonEmptyString(value) {
306
327
  function issue(issues, path, code, message) {
307
328
  issues.push({ path, code, message });
308
329
  }
330
+ function fieldConstraintIssue(issues, field, fieldIndex, property, expected, message) {
331
+ issues.push({
332
+ path: `fields[${fieldIndex}].${property}`,
333
+ code: "field_constraint_violation",
334
+ type: "field_constraint_violation",
335
+ message,
336
+ fieldId: field.id,
337
+ property,
338
+ expected
339
+ });
340
+ }
309
341
  function validateJsonValue(value, path, issues, ancestors = /* @__PURE__ */ new Set()) {
310
342
  if (value === null || typeof value === "string" || typeof value === "boolean") return;
311
343
  if (typeof value === "number") {
@@ -659,6 +691,93 @@ function validatePolicy(schema, policy, issues) {
659
691
  `At most ${policy.maxOptionsPerField} options are allowed.`
660
692
  );
661
693
  }
694
+ const constraint = policy.fieldConstraints?.[field.type];
695
+ if (constraint === void 0) return;
696
+ if (constraint.fixedRequired !== void 0 && field.required !== constraint.fixedRequired) {
697
+ fieldConstraintIssue(
698
+ issues,
699
+ field,
700
+ fieldIndex,
701
+ "required",
702
+ constraint.fixedRequired,
703
+ `Field required must be ${String(constraint.fixedRequired)} for field type ${field.type}.`
704
+ );
705
+ }
706
+ if (field.type === "rating") {
707
+ const ratingConstraint = "fixedMin" in constraint || "fixedMax" in constraint || "allowedMinRange" in constraint || "allowedMaxRange" in constraint ? constraint : void 0;
708
+ if (ratingConstraint?.fixedMin !== void 0 && field.min !== ratingConstraint.fixedMin) {
709
+ fieldConstraintIssue(
710
+ issues,
711
+ field,
712
+ fieldIndex,
713
+ "min",
714
+ ratingConstraint.fixedMin,
715
+ `Rating minimum must be ${ratingConstraint.fixedMin}.`
716
+ );
717
+ }
718
+ if (ratingConstraint !== void 0 && "fixedMax" in ratingConstraint && ratingConstraint.fixedMax !== void 0 && field.max !== ratingConstraint.fixedMax) {
719
+ fieldConstraintIssue(
720
+ issues,
721
+ field,
722
+ fieldIndex,
723
+ "max",
724
+ ratingConstraint.fixedMax,
725
+ `Rating maximum must be ${ratingConstraint.fixedMax}.`
726
+ );
727
+ }
728
+ if (ratingConstraint !== void 0 && "allowedMinRange" in ratingConstraint && ratingConstraint.allowedMinRange !== void 0 && typeof field.min === "number" && (field.min < ratingConstraint.allowedMinRange[0] || field.min > ratingConstraint.allowedMinRange[1])) {
729
+ fieldConstraintIssue(
730
+ issues,
731
+ field,
732
+ fieldIndex,
733
+ "min",
734
+ ratingConstraint.allowedMinRange,
735
+ `Rating minimum must be between ${ratingConstraint.allowedMinRange[0]} and ${ratingConstraint.allowedMinRange[1]}.`
736
+ );
737
+ }
738
+ if (ratingConstraint !== void 0 && "allowedMaxRange" in ratingConstraint && ratingConstraint.allowedMaxRange !== void 0 && typeof field.max === "number" && (field.max < ratingConstraint.allowedMaxRange[0] || field.max > ratingConstraint.allowedMaxRange[1])) {
739
+ fieldConstraintIssue(
740
+ issues,
741
+ field,
742
+ fieldIndex,
743
+ "max",
744
+ ratingConstraint.allowedMaxRange,
745
+ `Rating maximum must be between ${ratingConstraint.allowedMaxRange[0]} and ${ratingConstraint.allowedMaxRange[1]}.`
746
+ );
747
+ }
748
+ }
749
+ if ((field.type === "text" || field.type === "textarea") && "maxMaxLength" in constraint && constraint.maxMaxLength !== void 0) {
750
+ if (field.maxLength !== void 0 && field.maxLength > constraint.maxMaxLength) {
751
+ fieldConstraintIssue(
752
+ issues,
753
+ field,
754
+ fieldIndex,
755
+ "maxLength",
756
+ constraint.maxMaxLength,
757
+ `Maximum text length must be at most ${constraint.maxMaxLength}.`
758
+ );
759
+ }
760
+ }
761
+ if ((field.type === "select" || field.type === "radio" || field.type === "multi-select") && "minOptions" in constraint && constraint.minOptions !== void 0 && field.options.length < constraint.minOptions) {
762
+ fieldConstraintIssue(
763
+ issues,
764
+ field,
765
+ fieldIndex,
766
+ "options",
767
+ constraint.minOptions,
768
+ `At least ${constraint.minOptions} options are required.`
769
+ );
770
+ }
771
+ if ((field.type === "select" || field.type === "radio" || field.type === "multi-select") && "maxOptions" in constraint && constraint.maxOptions !== void 0 && field.options.length > constraint.maxOptions) {
772
+ fieldConstraintIssue(
773
+ issues,
774
+ field,
775
+ fieldIndex,
776
+ "options",
777
+ constraint.maxOptions,
778
+ `At most ${constraint.maxOptions} options are allowed.`
779
+ );
780
+ }
662
781
  });
663
782
  if (policy.maxTextLength !== void 0) {
664
783
  for (const entry of collectSchemaText(schema)) {
package/dist/index.d.cts CHANGED
@@ -114,6 +114,28 @@ declare function createDeleteDraftTransitionPlan(state: FormVersionState, draftR
114
114
  declare function assertVersionMutable(status: FormVersionStatus): void;
115
115
 
116
116
  type FieldType = "text" | "textarea" | "number" | "rating" | "select" | "multi-select" | "checkbox" | "radio";
117
+ type QuestionType = FieldType;
118
+ interface BaseFieldConstraintRule {
119
+ readonly defaultRequired?: boolean;
120
+ readonly fixedRequired?: boolean;
121
+ }
122
+ interface RatingFieldConstraintRule extends BaseFieldConstraintRule {
123
+ readonly defaultMin?: number;
124
+ readonly defaultMax?: number;
125
+ readonly fixedMin?: number;
126
+ readonly fixedMax?: number;
127
+ readonly allowedMinRange?: readonly [number, number];
128
+ readonly allowedMaxRange?: readonly [number, number];
129
+ }
130
+ interface TextFieldConstraintRule extends BaseFieldConstraintRule {
131
+ readonly defaultMaxLength?: number;
132
+ readonly maxMaxLength?: number;
133
+ }
134
+ interface ChoiceFieldConstraintRule extends BaseFieldConstraintRule {
135
+ readonly minOptions?: number;
136
+ readonly maxOptions?: number;
137
+ }
138
+ type FieldConstraintRule = RatingFieldConstraintRule | TextFieldConstraintRule | ChoiceFieldConstraintRule | BaseFieldConstraintRule;
117
139
  interface FormPolicy {
118
140
  readonly allowedFieldTypes?: readonly FieldType[];
119
141
  readonly maxFields?: number;
@@ -123,6 +145,8 @@ interface FormPolicy {
123
145
  readonly maxLocales?: number;
124
146
  readonly maxTextLength?: number;
125
147
  readonly maxSchemaBytes?: number;
148
+ /** Per-question-type defaults and immutable or bounded field constraints. */
149
+ readonly fieldConstraints?: Partial<Record<QuestionType, FieldConstraintRule>>;
126
150
  }
127
151
  type ConditionOperator = "equals" | "not_equals" | "contains" | "not_empty";
128
152
  type ConditionValue = string | number | boolean;
@@ -223,6 +247,12 @@ interface SchemaIssue {
223
247
  readonly path: string;
224
248
  readonly code: string;
225
249
  readonly message: string;
250
+ /** Compatibility discriminator for structured policy issues. */
251
+ readonly type?: string;
252
+ /** Present for policy issues that identify a field property and its expected value. */
253
+ readonly fieldId?: string;
254
+ readonly property?: string;
255
+ readonly expected?: boolean | number | readonly [number, number];
226
256
  }
227
257
  type SchemaValidationResult = {
228
258
  readonly valid: true;
@@ -417,7 +447,6 @@ interface FormAnalytics {
417
447
  readonly questions: readonly QuestionAggregate[];
418
448
  }
419
449
  type Question = FormField;
420
- type QuestionType = FieldType;
421
450
  type ChoiceOption = FieldOption;
422
451
  /** Translation keys reserved for the form builder UI. */
423
452
  type BuilderTranslationKey = `builder.${string}`;
@@ -577,6 +606,9 @@ interface CollectedLocales {
577
606
  /** Collects locale registrations and every locale key used by translations or translation metadata. */
578
607
  declare function collectSchemaLocales(schema: FormSchema): CollectedLocales;
579
608
 
609
+ interface SanitizeSchemaOptions {
610
+ readonly policy?: FormPolicy;
611
+ }
580
612
  type SchemaStructureIssueType = "dangling_condition_reference" | "duplicate_question_id" | "duplicate_choice_id" | "self_condition_reference" | "cyclic_condition_reference";
581
613
  interface SchemaStructureIssue {
582
614
  readonly type: SchemaStructureIssueType;
@@ -585,7 +617,7 @@ interface SchemaStructureIssue {
585
617
  readonly message: string;
586
618
  }
587
619
  declare function validateSchemaStructure(schema: FormSchema): SchemaStructureIssue[];
588
- declare function sanitizeSchema(schema: FormSchema): FormSchema;
620
+ declare function sanitizeSchema(schema: FormSchema, options?: SanitizeSchemaOptions): FormSchema;
589
621
 
590
622
  interface ValidateFormSchemaOptions {
591
623
  readonly policy?: FormPolicy;
@@ -639,4 +671,4 @@ declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Rea
639
671
  declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
640
672
  declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
641
673
 
642
- export { type AccumulatorReport, type AccumulatorResponse, type AccumulatorSkipReason, type AnswerValidationResult, type AsyncTranslationAdapter, type BaseField, type BuilderTranslationKey, type CheckboxField, type CheckboxQuestionAggregate, type ChoiceDistributionEntry, type ChoiceOption, type ChoiceQuestionAggregate, type CloneVersionOptions, type CollectedLocales, type ConditionOperator, type ConditionValue, type CreateSubmissionOptions, type CrossTabulationResult, type CsvColumnContext, type CsvColumnDef, type CsvExportOptions, DEFAULT_FIELD_TYPE_DEFINITIONS, type DeleteDraftOptions, type DisplayCondition, type ExtensibleNode, type FieldOption, type FieldType, type FieldTypeDefinition, type FormAnalytics, type FormEvent, type FormEventType, type FormField, type FormPage, type FormPolicy, type FormResponse, type FormSchema, type FormStorageAdapter, type FormSubmission, type FormValue, type FormValues, type FormVersionRecord, type FormVersionState, type FormVersionStatus, type JsonValue, type LocalizedText, type MultiSelectField, type NodeWritableStream, type NumberField, type NumberQuestionAggregate, type NumericSummary, type OptionAggregate, type PagedSubmissionStorageAdapter, type PaginationIteratorOptions, type PopulateTranslationOptions, type PublishDraftOptions, type PublishDraftResult, type Question, type QuestionAggregate, type QuestionType, type RatingField, type ResponseAccumulator, type ResponseAccumulatorOptions, type Result, type SchemaIssue, type SchemaStructureIssue, type SchemaStructureIssueType, type SchemaTranslations, type SchemaValidationResult, type SelectField, type StorageAdapter, type StorageCommitError, type StreamCsvOptions, type SubmissionCursorValue, type SubmissionFilter, type SubmissionPage, type SubmissionPageQueryOptions, type SubmissionQueryOptions, type TextAnswerCursorValue, type TextAnswerItem, type TextAnswerPage, type TextAnswerPageQueryOptions, type TextField, type TextQuestionAggregate, type TranslationAdapter, type TranslationReport, type TranslationSlot, type ValidateFormSchemaOptions, type ValidationCode, type ValidationError, type ValidationIssue, type VersionTransitionError, type VersionTransitionEvent, type VersionTransitionPlan, type VersionedFormStorageAdapter, type WebhookConfig, type WebhookDispatchResult, aggregateResponses, assertValidFormSchema, assertVersionMutable, calculateChoiceDistribution, calculateCrossTabulation, calculateFieldVisibility, calculateNumericSummary, calculatePageVisibility, cloneVersionToDraft, collectSchemaLocales, createCloneTransitionPlan, createDeleteDraftTransitionPlan, createPublishTransitionPlan, createResponseAccumulator, createSubmission, decodeSubmissionCursor, decodeTextAnswerCursor, deleteDraft, dispatchWebhook, encodeSubmissionCursor, encodeTextAnswerCursor, escapeCsvCell, exportResponsesToCsv, exportResponsesToCsvStream, isDisplayConditionSatisfied, isQuestionVisible, iterateSubmissionPages, jsonValuesEqual, matchesSubmissionFilter, matchesSubmissionPageFilters, normalizeSubmissionPageSize, pipeResponsesToCsvStream, populateSchemaTranslations, publishDraft, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, transformFieldType, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
674
+ export { type AccumulatorReport, type AccumulatorResponse, type AccumulatorSkipReason, type AnswerValidationResult, type AsyncTranslationAdapter, type BaseField, type BaseFieldConstraintRule, type BuilderTranslationKey, type CheckboxField, type CheckboxQuestionAggregate, type ChoiceDistributionEntry, type ChoiceFieldConstraintRule, type ChoiceOption, type ChoiceQuestionAggregate, type CloneVersionOptions, type CollectedLocales, type ConditionOperator, type ConditionValue, type CreateSubmissionOptions, type CrossTabulationResult, type CsvColumnContext, type CsvColumnDef, type CsvExportOptions, DEFAULT_FIELD_TYPE_DEFINITIONS, type DeleteDraftOptions, type DisplayCondition, type ExtensibleNode, type FieldConstraintRule, type FieldOption, type FieldType, type FieldTypeDefinition, type FormAnalytics, type FormEvent, type FormEventType, type FormField, type FormPage, type FormPolicy, type FormResponse, type FormSchema, type FormStorageAdapter, type FormSubmission, type FormValue, type FormValues, type FormVersionRecord, type FormVersionState, type FormVersionStatus, type JsonValue, type LocalizedText, type MultiSelectField, type NodeWritableStream, type NumberField, type NumberQuestionAggregate, type NumericSummary, type OptionAggregate, type PagedSubmissionStorageAdapter, type PaginationIteratorOptions, type PopulateTranslationOptions, type PublishDraftOptions, type PublishDraftResult, type Question, type QuestionAggregate, type QuestionType, type RatingField, type RatingFieldConstraintRule, type ResponseAccumulator, type ResponseAccumulatorOptions, type Result, type SanitizeSchemaOptions, type SchemaIssue, type SchemaStructureIssue, type SchemaStructureIssueType, type SchemaTranslations, type SchemaValidationResult, type SelectField, type StorageAdapter, type StorageCommitError, type StreamCsvOptions, type SubmissionCursorValue, type SubmissionFilter, type SubmissionPage, type SubmissionPageQueryOptions, type SubmissionQueryOptions, type TextAnswerCursorValue, type TextAnswerItem, type TextAnswerPage, type TextAnswerPageQueryOptions, type TextField, type TextFieldConstraintRule, type TextQuestionAggregate, type TranslationAdapter, type TranslationReport, type TranslationSlot, type ValidateFormSchemaOptions, type ValidationCode, type ValidationError, type ValidationIssue, type VersionTransitionError, type VersionTransitionEvent, type VersionTransitionPlan, type VersionedFormStorageAdapter, type WebhookConfig, type WebhookDispatchResult, aggregateResponses, assertValidFormSchema, assertVersionMutable, calculateChoiceDistribution, calculateCrossTabulation, calculateFieldVisibility, calculateNumericSummary, calculatePageVisibility, cloneVersionToDraft, collectSchemaLocales, createCloneTransitionPlan, createDeleteDraftTransitionPlan, createPublishTransitionPlan, createResponseAccumulator, createSubmission, decodeSubmissionCursor, decodeTextAnswerCursor, deleteDraft, dispatchWebhook, encodeSubmissionCursor, encodeTextAnswerCursor, escapeCsvCell, exportResponsesToCsv, exportResponsesToCsvStream, isDisplayConditionSatisfied, isQuestionVisible, iterateSubmissionPages, jsonValuesEqual, matchesSubmissionFilter, matchesSubmissionPageFilters, normalizeSubmissionPageSize, pipeResponsesToCsvStream, populateSchemaTranslations, publishDraft, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, transformFieldType, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
package/dist/index.d.ts CHANGED
@@ -114,6 +114,28 @@ declare function createDeleteDraftTransitionPlan(state: FormVersionState, draftR
114
114
  declare function assertVersionMutable(status: FormVersionStatus): void;
115
115
 
116
116
  type FieldType = "text" | "textarea" | "number" | "rating" | "select" | "multi-select" | "checkbox" | "radio";
117
+ type QuestionType = FieldType;
118
+ interface BaseFieldConstraintRule {
119
+ readonly defaultRequired?: boolean;
120
+ readonly fixedRequired?: boolean;
121
+ }
122
+ interface RatingFieldConstraintRule extends BaseFieldConstraintRule {
123
+ readonly defaultMin?: number;
124
+ readonly defaultMax?: number;
125
+ readonly fixedMin?: number;
126
+ readonly fixedMax?: number;
127
+ readonly allowedMinRange?: readonly [number, number];
128
+ readonly allowedMaxRange?: readonly [number, number];
129
+ }
130
+ interface TextFieldConstraintRule extends BaseFieldConstraintRule {
131
+ readonly defaultMaxLength?: number;
132
+ readonly maxMaxLength?: number;
133
+ }
134
+ interface ChoiceFieldConstraintRule extends BaseFieldConstraintRule {
135
+ readonly minOptions?: number;
136
+ readonly maxOptions?: number;
137
+ }
138
+ type FieldConstraintRule = RatingFieldConstraintRule | TextFieldConstraintRule | ChoiceFieldConstraintRule | BaseFieldConstraintRule;
117
139
  interface FormPolicy {
118
140
  readonly allowedFieldTypes?: readonly FieldType[];
119
141
  readonly maxFields?: number;
@@ -123,6 +145,8 @@ interface FormPolicy {
123
145
  readonly maxLocales?: number;
124
146
  readonly maxTextLength?: number;
125
147
  readonly maxSchemaBytes?: number;
148
+ /** Per-question-type defaults and immutable or bounded field constraints. */
149
+ readonly fieldConstraints?: Partial<Record<QuestionType, FieldConstraintRule>>;
126
150
  }
127
151
  type ConditionOperator = "equals" | "not_equals" | "contains" | "not_empty";
128
152
  type ConditionValue = string | number | boolean;
@@ -223,6 +247,12 @@ interface SchemaIssue {
223
247
  readonly path: string;
224
248
  readonly code: string;
225
249
  readonly message: string;
250
+ /** Compatibility discriminator for structured policy issues. */
251
+ readonly type?: string;
252
+ /** Present for policy issues that identify a field property and its expected value. */
253
+ readonly fieldId?: string;
254
+ readonly property?: string;
255
+ readonly expected?: boolean | number | readonly [number, number];
226
256
  }
227
257
  type SchemaValidationResult = {
228
258
  readonly valid: true;
@@ -417,7 +447,6 @@ interface FormAnalytics {
417
447
  readonly questions: readonly QuestionAggregate[];
418
448
  }
419
449
  type Question = FormField;
420
- type QuestionType = FieldType;
421
450
  type ChoiceOption = FieldOption;
422
451
  /** Translation keys reserved for the form builder UI. */
423
452
  type BuilderTranslationKey = `builder.${string}`;
@@ -577,6 +606,9 @@ interface CollectedLocales {
577
606
  /** Collects locale registrations and every locale key used by translations or translation metadata. */
578
607
  declare function collectSchemaLocales(schema: FormSchema): CollectedLocales;
579
608
 
609
+ interface SanitizeSchemaOptions {
610
+ readonly policy?: FormPolicy;
611
+ }
580
612
  type SchemaStructureIssueType = "dangling_condition_reference" | "duplicate_question_id" | "duplicate_choice_id" | "self_condition_reference" | "cyclic_condition_reference";
581
613
  interface SchemaStructureIssue {
582
614
  readonly type: SchemaStructureIssueType;
@@ -585,7 +617,7 @@ interface SchemaStructureIssue {
585
617
  readonly message: string;
586
618
  }
587
619
  declare function validateSchemaStructure(schema: FormSchema): SchemaStructureIssue[];
588
- declare function sanitizeSchema(schema: FormSchema): FormSchema;
620
+ declare function sanitizeSchema(schema: FormSchema, options?: SanitizeSchemaOptions): FormSchema;
589
621
 
590
622
  interface ValidateFormSchemaOptions {
591
623
  readonly policy?: FormPolicy;
@@ -639,4 +671,4 @@ declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Rea
639
671
  declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
640
672
  declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
641
673
 
642
- export { type AccumulatorReport, type AccumulatorResponse, type AccumulatorSkipReason, type AnswerValidationResult, type AsyncTranslationAdapter, type BaseField, type BuilderTranslationKey, type CheckboxField, type CheckboxQuestionAggregate, type ChoiceDistributionEntry, type ChoiceOption, type ChoiceQuestionAggregate, type CloneVersionOptions, type CollectedLocales, type ConditionOperator, type ConditionValue, type CreateSubmissionOptions, type CrossTabulationResult, type CsvColumnContext, type CsvColumnDef, type CsvExportOptions, DEFAULT_FIELD_TYPE_DEFINITIONS, type DeleteDraftOptions, type DisplayCondition, type ExtensibleNode, type FieldOption, type FieldType, type FieldTypeDefinition, type FormAnalytics, type FormEvent, type FormEventType, type FormField, type FormPage, type FormPolicy, type FormResponse, type FormSchema, type FormStorageAdapter, type FormSubmission, type FormValue, type FormValues, type FormVersionRecord, type FormVersionState, type FormVersionStatus, type JsonValue, type LocalizedText, type MultiSelectField, type NodeWritableStream, type NumberField, type NumberQuestionAggregate, type NumericSummary, type OptionAggregate, type PagedSubmissionStorageAdapter, type PaginationIteratorOptions, type PopulateTranslationOptions, type PublishDraftOptions, type PublishDraftResult, type Question, type QuestionAggregate, type QuestionType, type RatingField, type ResponseAccumulator, type ResponseAccumulatorOptions, type Result, type SchemaIssue, type SchemaStructureIssue, type SchemaStructureIssueType, type SchemaTranslations, type SchemaValidationResult, type SelectField, type StorageAdapter, type StorageCommitError, type StreamCsvOptions, type SubmissionCursorValue, type SubmissionFilter, type SubmissionPage, type SubmissionPageQueryOptions, type SubmissionQueryOptions, type TextAnswerCursorValue, type TextAnswerItem, type TextAnswerPage, type TextAnswerPageQueryOptions, type TextField, type TextQuestionAggregate, type TranslationAdapter, type TranslationReport, type TranslationSlot, type ValidateFormSchemaOptions, type ValidationCode, type ValidationError, type ValidationIssue, type VersionTransitionError, type VersionTransitionEvent, type VersionTransitionPlan, type VersionedFormStorageAdapter, type WebhookConfig, type WebhookDispatchResult, aggregateResponses, assertValidFormSchema, assertVersionMutable, calculateChoiceDistribution, calculateCrossTabulation, calculateFieldVisibility, calculateNumericSummary, calculatePageVisibility, cloneVersionToDraft, collectSchemaLocales, createCloneTransitionPlan, createDeleteDraftTransitionPlan, createPublishTransitionPlan, createResponseAccumulator, createSubmission, decodeSubmissionCursor, decodeTextAnswerCursor, deleteDraft, dispatchWebhook, encodeSubmissionCursor, encodeTextAnswerCursor, escapeCsvCell, exportResponsesToCsv, exportResponsesToCsvStream, isDisplayConditionSatisfied, isQuestionVisible, iterateSubmissionPages, jsonValuesEqual, matchesSubmissionFilter, matchesSubmissionPageFilters, normalizeSubmissionPageSize, pipeResponsesToCsvStream, populateSchemaTranslations, publishDraft, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, transformFieldType, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
674
+ export { type AccumulatorReport, type AccumulatorResponse, type AccumulatorSkipReason, type AnswerValidationResult, type AsyncTranslationAdapter, type BaseField, type BaseFieldConstraintRule, type BuilderTranslationKey, type CheckboxField, type CheckboxQuestionAggregate, type ChoiceDistributionEntry, type ChoiceFieldConstraintRule, type ChoiceOption, type ChoiceQuestionAggregate, type CloneVersionOptions, type CollectedLocales, type ConditionOperator, type ConditionValue, type CreateSubmissionOptions, type CrossTabulationResult, type CsvColumnContext, type CsvColumnDef, type CsvExportOptions, DEFAULT_FIELD_TYPE_DEFINITIONS, type DeleteDraftOptions, type DisplayCondition, type ExtensibleNode, type FieldConstraintRule, type FieldOption, type FieldType, type FieldTypeDefinition, type FormAnalytics, type FormEvent, type FormEventType, type FormField, type FormPage, type FormPolicy, type FormResponse, type FormSchema, type FormStorageAdapter, type FormSubmission, type FormValue, type FormValues, type FormVersionRecord, type FormVersionState, type FormVersionStatus, type JsonValue, type LocalizedText, type MultiSelectField, type NodeWritableStream, type NumberField, type NumberQuestionAggregate, type NumericSummary, type OptionAggregate, type PagedSubmissionStorageAdapter, type PaginationIteratorOptions, type PopulateTranslationOptions, type PublishDraftOptions, type PublishDraftResult, type Question, type QuestionAggregate, type QuestionType, type RatingField, type RatingFieldConstraintRule, type ResponseAccumulator, type ResponseAccumulatorOptions, type Result, type SanitizeSchemaOptions, type SchemaIssue, type SchemaStructureIssue, type SchemaStructureIssueType, type SchemaTranslations, type SchemaValidationResult, type SelectField, type StorageAdapter, type StorageCommitError, type StreamCsvOptions, type SubmissionCursorValue, type SubmissionFilter, type SubmissionPage, type SubmissionPageQueryOptions, type SubmissionQueryOptions, type TextAnswerCursorValue, type TextAnswerItem, type TextAnswerPage, type TextAnswerPageQueryOptions, type TextField, type TextFieldConstraintRule, type TextQuestionAggregate, type TranslationAdapter, type TranslationReport, type TranslationSlot, type ValidateFormSchemaOptions, type ValidationCode, type ValidationError, type ValidationIssue, type VersionTransitionError, type VersionTransitionEvent, type VersionTransitionPlan, type VersionedFormStorageAdapter, type WebhookConfig, type WebhookDispatchResult, aggregateResponses, assertValidFormSchema, assertVersionMutable, calculateChoiceDistribution, calculateCrossTabulation, calculateFieldVisibility, calculateNumericSummary, calculatePageVisibility, cloneVersionToDraft, collectSchemaLocales, createCloneTransitionPlan, createDeleteDraftTransitionPlan, createPublishTransitionPlan, createResponseAccumulator, createSubmission, decodeSubmissionCursor, decodeTextAnswerCursor, deleteDraft, dispatchWebhook, encodeSubmissionCursor, encodeTextAnswerCursor, escapeCsvCell, exportResponsesToCsv, exportResponsesToCsvStream, isDisplayConditionSatisfied, isQuestionVisible, iterateSubmissionPages, jsonValuesEqual, matchesSubmissionFilter, matchesSubmissionPageFilters, normalizeSubmissionPageSize, pipeResponsesToCsvStream, populateSchemaTranslations, publishDraft, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, transformFieldType, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
package/dist/index.js CHANGED
@@ -87,6 +87,27 @@ function sanitizePageLocales(page, registeredLocales) {
87
87
  const translations = registeredEntries(page.translations, registeredLocales);
88
88
  return { ...base, ...translations === void 0 ? {} : { translations } };
89
89
  }
90
+ function sanitizeFieldConstraints(field, policy) {
91
+ const constraint = policy?.fieldConstraints?.[field.type];
92
+ if (constraint === void 0) return field;
93
+ const required = constraint.fixedRequired ?? field.required;
94
+ if (field.type === "rating") {
95
+ const ratingConstraint = "fixedMin" in constraint || "fixedMax" in constraint ? constraint : void 0;
96
+ const min = ratingConstraint !== void 0 && "fixedMin" in ratingConstraint ? ratingConstraint.fixedMin : field.min;
97
+ const max = ratingConstraint !== void 0 && "fixedMax" in ratingConstraint ? ratingConstraint.fixedMax : field.max;
98
+ return {
99
+ ...field,
100
+ required,
101
+ ...min === void 0 ? {} : { min },
102
+ ...max === void 0 ? {} : { max }
103
+ };
104
+ }
105
+ if ((field.type === "text" || field.type === "textarea") && "maxMaxLength" in constraint) {
106
+ const maxLength = field.maxLength === void 0 || constraint.maxMaxLength === void 0 ? field.maxLength : Math.min(field.maxLength, constraint.maxMaxLength);
107
+ return { ...field, required, ...maxLength === void 0 ? {} : { maxLength } };
108
+ }
109
+ return { ...field, required };
110
+ }
90
111
  function cyclicQuestionIds(fields) {
91
112
  const firstById = /* @__PURE__ */ new Map();
92
113
  for (const field of fields) {
@@ -171,7 +192,7 @@ function validateSchemaStructure(schema) {
171
192
  }
172
193
  return issues;
173
194
  }
174
- function sanitizeSchema(schema) {
195
+ function sanitizeSchema(schema, options = {}) {
175
196
  const existingQuestionIds = new Set(schema.fields.map((field) => field.id));
176
197
  const registeredLocales = /* @__PURE__ */ new Set([
177
198
  ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
@@ -179,7 +200,7 @@ function sanitizeSchema(schema) {
179
200
  ]);
180
201
  const cyclic = cyclicQuestionIds(schema.fields);
181
202
  const sanitizedFields = schema.fields.map((sourceField) => {
182
- const field = sanitizeFieldLocales(sourceField, registeredLocales);
203
+ const field = sanitizeFieldConstraints(sanitizeFieldLocales(sourceField, registeredLocales), options.policy);
183
204
  const sourceId = field.displayCondition?.questionId;
184
205
  if (sourceId === void 0 || existingQuestionIds.has(sourceId) && sourceId !== field.id && !cyclic.has(field.id)) {
185
206
  return field;
@@ -237,6 +258,17 @@ function isNonEmptyString(value) {
237
258
  function issue(issues, path, code, message) {
238
259
  issues.push({ path, code, message });
239
260
  }
261
+ function fieldConstraintIssue(issues, field, fieldIndex, property, expected, message) {
262
+ issues.push({
263
+ path: `fields[${fieldIndex}].${property}`,
264
+ code: "field_constraint_violation",
265
+ type: "field_constraint_violation",
266
+ message,
267
+ fieldId: field.id,
268
+ property,
269
+ expected
270
+ });
271
+ }
240
272
  function validateJsonValue(value, path, issues, ancestors = /* @__PURE__ */ new Set()) {
241
273
  if (value === null || typeof value === "string" || typeof value === "boolean") return;
242
274
  if (typeof value === "number") {
@@ -590,6 +622,93 @@ function validatePolicy(schema, policy, issues) {
590
622
  `At most ${policy.maxOptionsPerField} options are allowed.`
591
623
  );
592
624
  }
625
+ const constraint = policy.fieldConstraints?.[field.type];
626
+ if (constraint === void 0) return;
627
+ if (constraint.fixedRequired !== void 0 && field.required !== constraint.fixedRequired) {
628
+ fieldConstraintIssue(
629
+ issues,
630
+ field,
631
+ fieldIndex,
632
+ "required",
633
+ constraint.fixedRequired,
634
+ `Field required must be ${String(constraint.fixedRequired)} for field type ${field.type}.`
635
+ );
636
+ }
637
+ if (field.type === "rating") {
638
+ const ratingConstraint = "fixedMin" in constraint || "fixedMax" in constraint || "allowedMinRange" in constraint || "allowedMaxRange" in constraint ? constraint : void 0;
639
+ if (ratingConstraint?.fixedMin !== void 0 && field.min !== ratingConstraint.fixedMin) {
640
+ fieldConstraintIssue(
641
+ issues,
642
+ field,
643
+ fieldIndex,
644
+ "min",
645
+ ratingConstraint.fixedMin,
646
+ `Rating minimum must be ${ratingConstraint.fixedMin}.`
647
+ );
648
+ }
649
+ if (ratingConstraint !== void 0 && "fixedMax" in ratingConstraint && ratingConstraint.fixedMax !== void 0 && field.max !== ratingConstraint.fixedMax) {
650
+ fieldConstraintIssue(
651
+ issues,
652
+ field,
653
+ fieldIndex,
654
+ "max",
655
+ ratingConstraint.fixedMax,
656
+ `Rating maximum must be ${ratingConstraint.fixedMax}.`
657
+ );
658
+ }
659
+ if (ratingConstraint !== void 0 && "allowedMinRange" in ratingConstraint && ratingConstraint.allowedMinRange !== void 0 && typeof field.min === "number" && (field.min < ratingConstraint.allowedMinRange[0] || field.min > ratingConstraint.allowedMinRange[1])) {
660
+ fieldConstraintIssue(
661
+ issues,
662
+ field,
663
+ fieldIndex,
664
+ "min",
665
+ ratingConstraint.allowedMinRange,
666
+ `Rating minimum must be between ${ratingConstraint.allowedMinRange[0]} and ${ratingConstraint.allowedMinRange[1]}.`
667
+ );
668
+ }
669
+ if (ratingConstraint !== void 0 && "allowedMaxRange" in ratingConstraint && ratingConstraint.allowedMaxRange !== void 0 && typeof field.max === "number" && (field.max < ratingConstraint.allowedMaxRange[0] || field.max > ratingConstraint.allowedMaxRange[1])) {
670
+ fieldConstraintIssue(
671
+ issues,
672
+ field,
673
+ fieldIndex,
674
+ "max",
675
+ ratingConstraint.allowedMaxRange,
676
+ `Rating maximum must be between ${ratingConstraint.allowedMaxRange[0]} and ${ratingConstraint.allowedMaxRange[1]}.`
677
+ );
678
+ }
679
+ }
680
+ if ((field.type === "text" || field.type === "textarea") && "maxMaxLength" in constraint && constraint.maxMaxLength !== void 0) {
681
+ if (field.maxLength !== void 0 && field.maxLength > constraint.maxMaxLength) {
682
+ fieldConstraintIssue(
683
+ issues,
684
+ field,
685
+ fieldIndex,
686
+ "maxLength",
687
+ constraint.maxMaxLength,
688
+ `Maximum text length must be at most ${constraint.maxMaxLength}.`
689
+ );
690
+ }
691
+ }
692
+ if ((field.type === "select" || field.type === "radio" || field.type === "multi-select") && "minOptions" in constraint && constraint.minOptions !== void 0 && field.options.length < constraint.minOptions) {
693
+ fieldConstraintIssue(
694
+ issues,
695
+ field,
696
+ fieldIndex,
697
+ "options",
698
+ constraint.minOptions,
699
+ `At least ${constraint.minOptions} options are required.`
700
+ );
701
+ }
702
+ if ((field.type === "select" || field.type === "radio" || field.type === "multi-select") && "maxOptions" in constraint && constraint.maxOptions !== void 0 && field.options.length > constraint.maxOptions) {
703
+ fieldConstraintIssue(
704
+ issues,
705
+ field,
706
+ fieldIndex,
707
+ "options",
708
+ constraint.maxOptions,
709
+ `At most ${constraint.maxOptions} options are allowed.`
710
+ );
711
+ }
593
712
  });
594
713
  if (policy.maxTextLength !== void 0) {
595
714
  for (const entry of collectSchemaText(schema)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/core",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },