@form-engine-ts/core 2.2.0 → 2.5.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/dist/index.js CHANGED
@@ -1,4 +1,92 @@
1
+ // src/policy.ts
2
+ function collectRecordKeys(value, path, pathsByLocale) {
3
+ for (const locale of Object.keys(value ?? {})) {
4
+ const paths = pathsByLocale.get(locale) ?? [];
5
+ paths.push(`${path}.${locale}`);
6
+ pathsByLocale.set(locale, paths);
7
+ }
8
+ }
9
+ function collectSchemaLocales(schema) {
10
+ const pathsByLocale = /* @__PURE__ */ new Map();
11
+ collectRecordKeys(schema.translations, "translations", pathsByLocale);
12
+ collectRecordKeys(schema.translationMetadata, "translationMetadata", pathsByLocale);
13
+ schema.fields.forEach((field, fieldIndex) => {
14
+ collectRecordKeys(field.translations, `fields[${fieldIndex}].translations`, pathsByLocale);
15
+ collectRecordKeys(field.translationMetadata, `fields[${fieldIndex}].translationMetadata`, pathsByLocale);
16
+ if (!("options" in field)) return;
17
+ field.options.forEach((option, optionIndex) => {
18
+ collectRecordKeys(
19
+ option.translations,
20
+ `fields[${fieldIndex}].options[${optionIndex}].translations`,
21
+ pathsByLocale
22
+ );
23
+ collectRecordKeys(
24
+ option.translationMetadata,
25
+ `fields[${fieldIndex}].options[${optionIndex}].translationMetadata`,
26
+ pathsByLocale
27
+ );
28
+ });
29
+ });
30
+ schema.pages?.forEach((page, pageIndex) => {
31
+ collectRecordKeys(page.translations, `pages[${pageIndex}].translations`, pathsByLocale);
32
+ collectRecordKeys(page.translationMetadata, `pages[${pageIndex}].translationMetadata`, pathsByLocale);
33
+ });
34
+ const translationLocales = new Set(pathsByLocale.keys());
35
+ const allUniqueLocales = /* @__PURE__ */ new Set([
36
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
37
+ ...schema.supportedLocales ?? [],
38
+ ...translationLocales
39
+ ]);
40
+ return {
41
+ ...schema.defaultLocale === void 0 ? {} : { defaultLocale: schema.defaultLocale },
42
+ supportedLocales: schema.supportedLocales ?? [],
43
+ translationLocales,
44
+ allUniqueLocales,
45
+ translationLocalePaths: pathsByLocale
46
+ };
47
+ }
48
+
1
49
  // src/sanitization.ts
50
+ function registeredEntries(value, registeredLocales) {
51
+ if (value === void 0) return void 0;
52
+ const entries = Object.entries(value).filter(([locale]) => registeredLocales.has(locale));
53
+ return entries.length === 0 ? void 0 : Object.fromEntries(entries);
54
+ }
55
+ function sanitizeNodeLocales(node, registeredLocales) {
56
+ const { translationMetadata: _translationMetadata, ...base } = node;
57
+ const translationMetadata = registeredEntries(node.translationMetadata, registeredLocales);
58
+ return {
59
+ ...base,
60
+ ...translationMetadata === void 0 ? {} : { translationMetadata }
61
+ };
62
+ }
63
+ function sanitizeOptionLocales(option, registeredLocales) {
64
+ const { translations: _translations, ...base } = sanitizeNodeLocales(option, registeredLocales);
65
+ const translations = registeredEntries(option.translations, registeredLocales);
66
+ return { ...base, ...translations === void 0 ? {} : { translations } };
67
+ }
68
+ function sanitizeFieldLocales(field, registeredLocales) {
69
+ const localizedNode = sanitizeNodeLocales(field, registeredLocales);
70
+ const { translations: _translations, ...base } = localizedNode;
71
+ const translations = registeredEntries(field.translations, registeredLocales);
72
+ const localized = {
73
+ ...base,
74
+ ...translations === void 0 ? {} : { translations }
75
+ };
76
+ if (!("options" in localizedNode)) return localized;
77
+ const { translations: _choiceTranslations, ...choiceBase } = localizedNode;
78
+ return {
79
+ ...choiceBase,
80
+ ...translations === void 0 ? {} : { translations },
81
+ options: localizedNode.options.map((option) => sanitizeOptionLocales(option, registeredLocales))
82
+ };
83
+ }
84
+ function sanitizePageLocales(page, registeredLocales) {
85
+ const localizedNode = sanitizeNodeLocales(page, registeredLocales);
86
+ const { translations: _translations, ...base } = localizedNode;
87
+ const translations = registeredEntries(page.translations, registeredLocales);
88
+ return { ...base, ...translations === void 0 ? {} : { translations } };
89
+ }
2
90
  function cyclicQuestionIds(fields) {
3
91
  const firstById = /* @__PURE__ */ new Map();
4
92
  for (const field of fields) {
@@ -85,8 +173,13 @@ function validateSchemaStructure(schema) {
85
173
  }
86
174
  function sanitizeSchema(schema) {
87
175
  const existingQuestionIds = new Set(schema.fields.map((field) => field.id));
176
+ const registeredLocales = /* @__PURE__ */ new Set([
177
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
178
+ ...schema.supportedLocales ?? []
179
+ ]);
88
180
  const cyclic = cyclicQuestionIds(schema.fields);
89
- const sanitizedFields = schema.fields.map((field) => {
181
+ const sanitizedFields = schema.fields.map((sourceField) => {
182
+ const field = sanitizeFieldLocales(sourceField, registeredLocales);
90
183
  const sourceId = field.displayCondition?.questionId;
91
184
  if (sourceId === void 0 || existingQuestionIds.has(sourceId) && sourceId !== field.id && !cyclic.has(field.id)) {
92
185
  return field;
@@ -94,15 +187,19 @@ function sanitizeSchema(schema) {
94
187
  const { displayCondition: _displayCondition, ...sanitized } = field;
95
188
  return sanitized;
96
189
  });
190
+ const localizedSchema = sanitizeNodeLocales(schema, registeredLocales);
191
+ const { translations: _translations, ...schemaWithoutLocaleContent } = localizedSchema;
192
+ const translations = registeredEntries(schema.translations, registeredLocales);
97
193
  const base = {
98
- ...schema,
194
+ ...schemaWithoutLocaleContent,
195
+ ...translations === void 0 ? {} : { translations },
99
196
  fields: sanitizedFields
100
197
  };
101
198
  if (schema.pages === void 0) return base;
102
199
  const assigned = /* @__PURE__ */ new Set();
103
- const pages = schema.pages.map((page) => ({
104
- ...page,
105
- questionIds: page.questionIds.filter((id) => {
200
+ const pages = schema.pages.map((sourcePage) => ({
201
+ ...sanitizePageLocales(sourcePage, registeredLocales),
202
+ questionIds: sourcePage.questionIds.filter((id) => {
106
203
  if (!existingQuestionIds.has(id) || assigned.has(id)) return false;
107
204
  assigned.add(id);
108
205
  return true;
@@ -506,31 +603,38 @@ function validatePolicy(schema, policy, issues) {
506
603
  }
507
604
  }
508
605
  }
509
- const registeredLocales = [
510
- .../* @__PURE__ */ new Set([
511
- ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
512
- ...schema.supportedLocales ?? []
513
- ])
514
- ];
515
- if (policy.allowedLocales !== void 0) {
516
- if (schema.defaultLocale !== void 0 && !policy.allowedLocales.includes(schema.defaultLocale)) {
606
+ const collectedLocales = collectSchemaLocales(schema);
607
+ const registeredLocales = /* @__PURE__ */ new Set([
608
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
609
+ ...schema.supportedLocales ?? []
610
+ ]);
611
+ for (const locale of collectedLocales.translationLocales) {
612
+ if (registeredLocales.has(locale)) continue;
613
+ for (const path of collectedLocales.translationLocalePaths.get(locale) ?? []) {
517
614
  issue(
518
615
  issues,
519
- "defaultLocale",
520
- "disallowed_locale",
521
- `Locale ${schema.defaultLocale} is not allowed by the form policy.`
616
+ path,
617
+ "unregistered_translation_locale",
618
+ `Translation locale ${locale} is not registered by defaultLocale or supportedLocales.`
522
619
  );
523
620
  }
621
+ }
622
+ if (policy.allowedLocales !== void 0) {
623
+ const pathsByLocale = /* @__PURE__ */ new Map();
624
+ if (schema.defaultLocale !== void 0) pathsByLocale.set(schema.defaultLocale, ["defaultLocale"]);
524
625
  schema.supportedLocales?.forEach((locale, index) => {
525
- if (!policy.allowedLocales?.includes(locale)) {
526
- issue(
527
- issues,
528
- `supportedLocales[${index}]`,
529
- "disallowed_locale",
530
- `Locale ${locale} is not allowed by the form policy.`
531
- );
532
- }
626
+ pathsByLocale.set(locale, [...pathsByLocale.get(locale) ?? [], `supportedLocales[${index}]`]);
533
627
  });
628
+ for (const [locale, paths] of collectedLocales.translationLocalePaths) {
629
+ pathsByLocale.set(locale, [...pathsByLocale.get(locale) ?? [], ...paths]);
630
+ }
631
+ for (const [locale, paths] of pathsByLocale) {
632
+ if (!policy.allowedLocales.includes(locale)) {
633
+ for (const path of paths) {
634
+ issue(issues, path, "disallowed_locale", `Locale ${locale} is not allowed by the form policy.`);
635
+ }
636
+ }
637
+ }
534
638
  for (const locale of policy.requiredLocales ?? []) {
535
639
  if (!policy.allowedLocales.includes(locale)) {
536
640
  issue(
@@ -542,7 +646,7 @@ function validatePolicy(schema, policy, issues) {
542
646
  }
543
647
  }
544
648
  }
545
- if (policy.maxLocales !== void 0 && registeredLocales.length > policy.maxLocales) {
649
+ if (policy.maxLocales !== void 0 && collectedLocales.allUniqueLocales.size > policy.maxLocales) {
546
650
  issue(issues, "supportedLocales", "max_locales_exceeded", `At most ${policy.maxLocales} locales are allowed.`);
547
651
  }
548
652
  for (const locale of policy.requiredLocales ?? []) addRequiredTranslationIssues(schema, locale, issues);
@@ -938,6 +1042,152 @@ function aggregateResponses(schema, submissions) {
938
1042
  questions: schema.fields.map((field) => aggregateField(schema, field, submissions))
939
1043
  };
940
1044
  }
1045
+ function responseValues(submission) {
1046
+ return "values" in submission ? submission.values : submission.answers;
1047
+ }
1048
+ function responseIdentifier(submission) {
1049
+ return "id" in submission ? submission.id : submission.responseId;
1050
+ }
1051
+ function responseMismatch(schema, submission) {
1052
+ if (submission.formId !== schema.id) {
1053
+ return `Submission ${responseIdentifier(submission)} does not match form ${schema.id}.`;
1054
+ }
1055
+ if ("formVersion" in submission && submission.formVersion !== schema.version) {
1056
+ return `Submission ${responseIdentifier(submission)} does not match ${schema.id}@${schema.version}.`;
1057
+ }
1058
+ return void 0;
1059
+ }
1060
+ var IncrementalResponseAccumulator = class _IncrementalResponseAccumulator {
1061
+ #schema;
1062
+ #mode;
1063
+ #fields;
1064
+ #submissionCount = 0;
1065
+ constructor(schema, options) {
1066
+ assertValidFormSchema(schema);
1067
+ this.#schema = JSON.parse(JSON.stringify(schema));
1068
+ this.#mode = options.mode ?? "strict";
1069
+ this.#fields = new Map(
1070
+ schema.fields.map((field) => [
1071
+ field.id,
1072
+ {
1073
+ answeredCount: 0,
1074
+ total: 0,
1075
+ minimum: null,
1076
+ maximum: null,
1077
+ trueCount: 0,
1078
+ falseCount: 0,
1079
+ optionCounts: new Map("options" in field ? field.options.map((option) => [option.id, 0]) : [])
1080
+ }
1081
+ ])
1082
+ );
1083
+ }
1084
+ add(submission) {
1085
+ const mismatch = responseMismatch(this.#schema, submission);
1086
+ if (mismatch !== void 0 && this.#mode === "strict") return { success: false, error: mismatch };
1087
+ const values = responseValues(submission);
1088
+ const visibility = calculateFieldVisibility(this.#schema, values);
1089
+ for (const field of this.#schema.fields) {
1090
+ const accumulator = this.#fields.get(field.id);
1091
+ if (accumulator === void 0) throw new Error(`Accumulator for ${field.id} is unavailable.`);
1092
+ const candidate = values[field.id];
1093
+ if (visibility[field.id] !== true || !valueIsValid(field, candidate)) continue;
1094
+ accumulator.answeredCount += 1;
1095
+ if ((field.type === "number" || field.type === "rating") && typeof candidate === "number") {
1096
+ accumulator.total += candidate;
1097
+ accumulator.minimum = accumulator.minimum === null ? candidate : Math.min(accumulator.minimum, candidate);
1098
+ accumulator.maximum = accumulator.maximum === null ? candidate : Math.max(accumulator.maximum, candidate);
1099
+ } else if (field.type === "checkbox") {
1100
+ if (candidate === true) accumulator.trueCount += 1;
1101
+ if (candidate === false) accumulator.falseCount += 1;
1102
+ } else if ("options" in field) {
1103
+ const selections = Array.isArray(candidate) ? candidate : typeof candidate === "string" ? [candidate] : [];
1104
+ for (const selection of selections) {
1105
+ accumulator.optionCounts.set(selection, (accumulator.optionCounts.get(selection) ?? 0) + 1);
1106
+ }
1107
+ }
1108
+ }
1109
+ this.#submissionCount += 1;
1110
+ return { success: true };
1111
+ }
1112
+ addMany(submissions) {
1113
+ for (const submission of submissions) {
1114
+ const result = this.add(submission);
1115
+ if (!result.success) throw new TypeError(result.error ?? "Submission could not be accumulated.");
1116
+ }
1117
+ }
1118
+ merge(other) {
1119
+ if (!(other instanceof _IncrementalResponseAccumulator)) {
1120
+ throw new TypeError("Only form-engine response accumulators can be merged.");
1121
+ }
1122
+ if (other.#schema.id !== this.#schema.id || other.#schema.version !== this.#schema.version || JSON.stringify(other.#schema.fields) !== JSON.stringify(this.#schema.fields)) {
1123
+ throw new TypeError("Response accumulators must use the same schema.");
1124
+ }
1125
+ this.#submissionCount += other.#submissionCount;
1126
+ for (const [fieldId, source] of other.#fields) {
1127
+ const target = this.#fields.get(fieldId);
1128
+ if (target === void 0) throw new Error(`Accumulator for ${fieldId} is unavailable.`);
1129
+ target.answeredCount += source.answeredCount;
1130
+ target.total += source.total;
1131
+ target.minimum = target.minimum === null ? source.minimum : source.minimum === null ? target.minimum : Math.min(target.minimum, source.minimum);
1132
+ target.maximum = target.maximum === null ? source.maximum : source.maximum === null ? target.maximum : Math.max(target.maximum, source.maximum);
1133
+ target.trueCount += source.trueCount;
1134
+ target.falseCount += source.falseCount;
1135
+ for (const [optionId, count] of source.optionCounts) {
1136
+ target.optionCounts.set(optionId, (target.optionCounts.get(optionId) ?? 0) + count);
1137
+ }
1138
+ }
1139
+ return this;
1140
+ }
1141
+ finalize() {
1142
+ return {
1143
+ formId: this.#schema.id,
1144
+ formVersion: this.#schema.version,
1145
+ submissionCount: this.#submissionCount,
1146
+ questions: this.#schema.fields.map((field) => {
1147
+ const accumulator = this.#fields.get(field.id);
1148
+ if (accumulator === void 0) throw new Error(`Accumulator for ${field.id} is unavailable.`);
1149
+ const base = {
1150
+ fieldId: field.id,
1151
+ answeredCount: accumulator.answeredCount,
1152
+ unansweredCount: this.#submissionCount - accumulator.answeredCount
1153
+ };
1154
+ if (field.type === "text" || field.type === "textarea") return { ...base, kind: field.type };
1155
+ if (field.type === "number" || field.type === "rating") {
1156
+ return {
1157
+ ...base,
1158
+ kind: field.type,
1159
+ minimum: accumulator.minimum,
1160
+ maximum: accumulator.maximum,
1161
+ average: accumulator.answeredCount === 0 ? null : accumulator.total / accumulator.answeredCount,
1162
+ total: accumulator.total
1163
+ };
1164
+ }
1165
+ if (field.type === "checkbox") {
1166
+ return {
1167
+ ...base,
1168
+ kind: "checkbox",
1169
+ trueCount: accumulator.trueCount,
1170
+ falseCount: accumulator.falseCount,
1171
+ truePercentageOfSubmissions: percentage(accumulator.trueCount, this.#submissionCount),
1172
+ falsePercentageOfSubmissions: percentage(accumulator.falseCount, this.#submissionCount)
1173
+ };
1174
+ }
1175
+ if (!("options" in field)) throw new TypeError(`Field ${field.id} cannot be aggregated.`);
1176
+ return {
1177
+ ...base,
1178
+ kind: field.type,
1179
+ options: field.options.map((option) => {
1180
+ const count = accumulator.optionCounts.get(option.id) ?? 0;
1181
+ return { id: option.id, count, percentageOfSubmissions: percentage(count, this.#submissionCount) };
1182
+ })
1183
+ };
1184
+ })
1185
+ };
1186
+ }
1187
+ };
1188
+ function createResponseAccumulator(schema, options = {}) {
1189
+ return new IncrementalResponseAccumulator(schema, options);
1190
+ }
941
1191
  function escapeCsvCell(value, neutralizeFormulas = true) {
942
1192
  if (value === null || value === void 0) return "";
943
1193
  let stringValue = String(value);
@@ -953,6 +1203,51 @@ function serializeValue(value) {
953
1203
  if (value === void 0) return "";
954
1204
  return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value);
955
1205
  }
1206
+ function asFormResponse(submission) {
1207
+ if (!("values" in submission)) return submission;
1208
+ return {
1209
+ responseId: submission.id,
1210
+ formId: submission.formId,
1211
+ sourceLocale: submission.locale,
1212
+ answers: submission.values,
1213
+ submittedAt: submission.submittedAt,
1214
+ ...submission.metadata === void 0 ? {} : { metadata: submission.metadata },
1215
+ ...submission.translationMetadata === void 0 ? {} : { translationMetadata: submission.translationMetadata }
1216
+ };
1217
+ }
1218
+ function serializeUnknown(value) {
1219
+ if (value === null || value === void 0) return "";
1220
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
1221
+ return JSON.stringify(value);
1222
+ }
1223
+ async function* exportResponsesToCsvStream(schema, submissions, options = {}) {
1224
+ assertValidFormSchema(schema);
1225
+ const includeDefaultColumns = options.includeDefaultColumns ?? true;
1226
+ const customColumns = options.columns ?? [];
1227
+ const headers = [
1228
+ ...includeDefaultColumns ? ["submissionId", "submittedAt", "locale", ...schema.fields.map((field) => field.id)] : [],
1229
+ ...customColumns.map((column) => column.header)
1230
+ ];
1231
+ const neutralizeFormulas = options.neutralizeFormulas ?? true;
1232
+ const header = headers.map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",");
1233
+ yield `${options.withBom ?? true ? "\uFEFF" : ""}${header}`;
1234
+ for await (const submission of submissions) {
1235
+ const mismatch = responseMismatch(schema, submission);
1236
+ if (mismatch !== void 0) throw new TypeError(mismatch);
1237
+ const response = asFormResponse(submission);
1238
+ const answers = response.answers;
1239
+ const visible = selectVisibleAnswers(schema, answers);
1240
+ const defaultCells = includeDefaultColumns ? [
1241
+ response.responseId,
1242
+ response.submittedAt,
1243
+ response.sourceLocale ?? "",
1244
+ ...schema.fields.map((field) => serializeUnknown(visible[field.id]))
1245
+ ] : [];
1246
+ const customCells = customColumns.map((column) => column.getValue(response));
1247
+ yield `\r
1248
+ ${[...defaultCells, ...customCells].map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",")}`;
1249
+ }
1250
+ }
956
1251
  function exportResponsesToCsv(schema, responses, options = {}) {
957
1252
  assertValidFormSchema(schema);
958
1253
  for (const response of responses) {
@@ -1070,6 +1365,61 @@ function transformFieldType(field, nextType) {
1070
1365
  return { ...common, type: nextType, options };
1071
1366
  }
1072
1367
 
1368
+ // src/pagination.ts
1369
+ var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1370
+ function encodeBase64(bytes) {
1371
+ let result = "";
1372
+ for (let index = 0; index < bytes.length; index += 3) {
1373
+ const first = bytes[index] ?? 0;
1374
+ const second = bytes[index + 1] ?? 0;
1375
+ const third = bytes[index + 2] ?? 0;
1376
+ const combined = first << 16 | second << 8 | third;
1377
+ result += BASE64_ALPHABET[combined >> 18 & 63] ?? "";
1378
+ result += BASE64_ALPHABET[combined >> 12 & 63] ?? "";
1379
+ result += index + 1 < bytes.length ? BASE64_ALPHABET[combined >> 6 & 63] ?? "" : "=";
1380
+ result += index + 2 < bytes.length ? BASE64_ALPHABET[combined & 63] ?? "" : "=";
1381
+ }
1382
+ return result;
1383
+ }
1384
+ function decodeBase64(value) {
1385
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
1386
+ throw new TypeError("cursor must be a valid Base64 token.");
1387
+ }
1388
+ const bytes = [];
1389
+ for (let index = 0; index < value.length; index += 4) {
1390
+ const characters = value.slice(index, index + 4);
1391
+ const sextets = [...characters].map((character) => character === "=" ? 0 : BASE64_ALPHABET.indexOf(character));
1392
+ const combined = (sextets[0] ?? 0) << 18 | (sextets[1] ?? 0) << 12 | (sextets[2] ?? 0) << 6 | (sextets[3] ?? 0);
1393
+ bytes.push(combined >> 16 & 255);
1394
+ if (characters[2] !== "=") bytes.push(combined >> 8 & 255);
1395
+ if (characters[3] !== "=") bytes.push(combined & 255);
1396
+ }
1397
+ return new Uint8Array(bytes);
1398
+ }
1399
+ function encodeSubmissionCursor(value) {
1400
+ if (value.submittedAt.length === 0 || value.responseId.length === 0) {
1401
+ throw new TypeError("Cursor values must not be empty.");
1402
+ }
1403
+ return encodeBase64(new TextEncoder().encode(JSON.stringify(value)));
1404
+ }
1405
+ function decodeSubmissionCursor(cursor) {
1406
+ try {
1407
+ const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64(cursor)));
1408
+ if (typeof parsed !== "object" || parsed === null || !("submittedAt" in parsed) || typeof parsed.submittedAt !== "string" || parsed.submittedAt.length === 0 || !("responseId" in parsed) || typeof parsed.responseId !== "string" || parsed.responseId.length === 0) {
1409
+ throw new TypeError("cursor payload is invalid.");
1410
+ }
1411
+ return { submittedAt: parsed.submittedAt, responseId: parsed.responseId };
1412
+ } catch (cause) {
1413
+ if (cause instanceof TypeError && cause.message === "cursor payload is invalid.") throw cause;
1414
+ throw new TypeError("cursor must be a valid form-engine cursor.", { cause });
1415
+ }
1416
+ }
1417
+ function normalizeSubmissionPageSize(pageSize, fallback = 100) {
1418
+ const value = pageSize ?? fallback;
1419
+ if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("pageSize must be a positive safe integer.");
1420
+ return value;
1421
+ }
1422
+
1073
1423
  // src/validation.ts
1074
1424
  var DEFAULT_MESSAGES = {
1075
1425
  required: "validation.required",
@@ -1445,6 +1795,18 @@ function resolveLocalizedSchema(schema, targetLocale) {
1445
1795
  async function populateSchemaTranslations(schema, targetLocales, adapter, options = {}) {
1446
1796
  assertValidFormSchema(schema);
1447
1797
  const locales = [...new Set(targetLocales.filter((locale) => locale.length > 0 && locale !== schema.defaultLocale))];
1798
+ const allowedLocales = options.policy?.allowedLocales;
1799
+ const collectedLocales = collectSchemaLocales(schema);
1800
+ const disallowedLocale = [...collectedLocales.allUniqueLocales, ...locales].find(
1801
+ (locale) => allowedLocales !== void 0 && !allowedLocales.includes(locale)
1802
+ );
1803
+ if (disallowedLocale !== void 0) {
1804
+ throw new RangeError(`Translation locale ${disallowedLocale} is not allowed by the form policy.`);
1805
+ }
1806
+ const projectedLocales = /* @__PURE__ */ new Set([...collectedLocales.allUniqueLocales, ...locales]);
1807
+ if (options.policy?.maxLocales !== void 0 && projectedLocales.size > options.policy.maxLocales) {
1808
+ throw new RangeError(`At most ${options.policy.maxLocales} locales are allowed by the form policy.`);
1809
+ }
1448
1810
  const updatedSlots = [];
1449
1811
  const skippedSlots = [];
1450
1812
  let result = schema;
@@ -1493,21 +1855,124 @@ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocal
1493
1855
  );
1494
1856
  return resolveLocalizedSchema(populated.schema, targetLocale);
1495
1857
  }
1858
+
1859
+ // src/versioning.ts
1860
+ function validateState(state) {
1861
+ if (state.formId.trim().length === 0) throw new TypeError("formId must not be empty.");
1862
+ if (!Number.isSafeInteger(state.nextVersion) || state.nextVersion < 1) {
1863
+ throw new TypeError("nextVersion must be a positive safe integer.");
1864
+ }
1865
+ if (!Number.isSafeInteger(state.revision) || state.revision < 0) {
1866
+ throw new TypeError("revision must be a non-negative safe integer.");
1867
+ }
1868
+ }
1869
+ function cloneVersionToDraft(state, sourceSchema, options = {}) {
1870
+ validateState(state);
1871
+ if (sourceSchema.id !== state.formId) throw new TypeError("sourceSchema.id must match state.formId.");
1872
+ if (state.draftVersion !== void 0) {
1873
+ return { success: false, error: { type: "draft_already_exists", currentDraftVersion: state.draftVersion } };
1874
+ }
1875
+ const maxVersions = options.maxVersions ?? Number.MAX_SAFE_INTEGER;
1876
+ if (!Number.isSafeInteger(maxVersions) || maxVersions < 1) {
1877
+ throw new TypeError("maxVersions must be a positive safe integer.");
1878
+ }
1879
+ if (state.nextVersion > maxVersions) {
1880
+ return { success: false, error: { type: "max_version_exceeded", max: maxVersions } };
1881
+ }
1882
+ const version = state.nextVersion;
1883
+ return {
1884
+ success: true,
1885
+ value: {
1886
+ nextState: {
1887
+ ...state,
1888
+ draftVersion: version,
1889
+ nextVersion: version + 1,
1890
+ revision: state.revision + 1
1891
+ },
1892
+ draftSchema: { ...sourceSchema, version }
1893
+ }
1894
+ };
1895
+ }
1896
+ function publishDraft(state, draftSchema, options = {}) {
1897
+ validateState(state);
1898
+ if (options.expectedRevision !== void 0 && options.expectedRevision !== state.revision) {
1899
+ return {
1900
+ success: false,
1901
+ error: {
1902
+ type: "revision_conflict",
1903
+ expectedRevision: options.expectedRevision,
1904
+ actualRevision: state.revision
1905
+ }
1906
+ };
1907
+ }
1908
+ if (state.draftVersion === void 0 || draftSchema.id !== state.formId || draftSchema.version !== state.draftVersion) {
1909
+ return { success: false, error: { type: "draft_not_found" } };
1910
+ }
1911
+ if (options.validate?.(draftSchema) === false) throw new TypeError("Draft schema validation failed.");
1912
+ const timestamp = options.timestamp ?? "1970-01-01T00:00:00.000Z";
1913
+ if (!Number.isFinite(Date.parse(timestamp))) throw new TypeError("timestamp must be a valid date string.");
1914
+ const archivedVersion = state.publishedVersion;
1915
+ const { draftVersion: _draftVersion, ...stateWithoutDraft } = state;
1916
+ return {
1917
+ success: true,
1918
+ value: {
1919
+ nextState: {
1920
+ ...stateWithoutDraft,
1921
+ publishedVersion: draftSchema.version,
1922
+ revision: state.revision + 1
1923
+ },
1924
+ publishedRecord: {
1925
+ formId: state.formId,
1926
+ version: draftSchema.version,
1927
+ status: "published",
1928
+ schema: draftSchema,
1929
+ createdAt: timestamp,
1930
+ publishedAt: timestamp
1931
+ },
1932
+ ...archivedVersion === void 0 ? {} : { archivedVersion }
1933
+ }
1934
+ };
1935
+ }
1936
+ function deleteDraft(state) {
1937
+ validateState(state);
1938
+ if (state.draftVersion === void 0) return { success: false, error: { type: "draft_not_found" } };
1939
+ const { draftVersion: _draftVersion, ...stateWithoutDraft } = state;
1940
+ return {
1941
+ success: true,
1942
+ value: { nextState: { ...stateWithoutDraft, revision: state.revision + 1 } }
1943
+ };
1944
+ }
1945
+ function assertVersionMutable(status) {
1946
+ if (status !== "draft") {
1947
+ const error = { type: "version_immutable", status };
1948
+ throw new TypeError(`A ${status} form version is immutable.`, { cause: error });
1949
+ }
1950
+ }
1496
1951
  export {
1497
1952
  aggregateResponses,
1498
1953
  assertValidFormSchema,
1954
+ assertVersionMutable,
1499
1955
  calculateChoiceDistribution,
1500
1956
  calculateCrossTabulation,
1501
1957
  calculateFieldVisibility,
1502
1958
  calculateNumericSummary,
1503
1959
  calculatePageVisibility,
1960
+ cloneVersionToDraft,
1961
+ collectSchemaLocales,
1962
+ createResponseAccumulator,
1504
1963
  createSubmission,
1964
+ decodeSubmissionCursor,
1965
+ deleteDraft,
1505
1966
  dispatchWebhook,
1967
+ encodeSubmissionCursor,
1506
1968
  escapeCsvCell,
1507
1969
  exportResponsesToCsv,
1970
+ exportResponsesToCsvStream,
1508
1971
  isDisplayConditionSatisfied,
1509
1972
  isQuestionVisible,
1973
+ normalizeSubmissionPageSize,
1510
1974
  populateSchemaTranslations,
1975
+ publishDraft,
1511
1976
  resolveFormTranslation,
1512
1977
  resolveLocalizedSchema,
1513
1978
  sanitizeSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/core",
3
- "version": "2.2.0",
3
+ "version": "2.5.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },