@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/README.md CHANGED
@@ -44,6 +44,12 @@ option, text, serialized-byte, allowed-type, and locale constraints. `allowedLoc
44
44
  locales, `maxLocales` limits their unique total, and contradictory required/allowed locale policies are reported.
45
45
  Required locales cover every source text that exists on the form, its fields, options, and pages.
46
46
 
47
+ `collectSchemaLocales(schema)` scans registrations plus every form/page/field/option `translations` and
48
+ `translationMetadata` key. Validation reports unregistered translation locales and applies `allowedLocales` and
49
+ `maxLocales` to the complete collected set. `sanitizeSchema` purges unregistered locale content. Pass
50
+ `{ policy: { allowedLocales, maxLocales } }` to `populateSchemaTranslations` to reject inadmissible targets before the
51
+ translation adapter runs.
52
+
47
53
  Translation callbacks receive `nodeMetadata` and `existingTranslationMetadata` separately. The deprecated `metadata`
48
54
  slot property remains an alias for `nodeMetadata` during migration.
49
55
 
@@ -68,4 +74,16 @@ const submissions = await storage.listSubmissions("contact", 1, range);
68
74
 
69
75
  Results are ordered by `submittedAt`, then submission ID. Both boundaries are inclusive.
70
76
 
77
+ ## Versioning, incremental analytics, and paged storage
78
+
79
+ `cloneVersionToDraft`, `publishDraft`, and `deleteDraft` implement revision-checked version transitions as pure functions.
80
+ `createResponseAccumulator` incrementally counts choices, answered/unanswered values, and numeric summaries without retaining
81
+ free-text bodies. Independent accumulators for the same schema can be merged, and `finalize()` matches `aggregateResponses`.
82
+
83
+ `exportResponsesToCsvStream` accepts an `AsyncIterable`, emits the BOM/header and one chunk per response, and supports
84
+ custom `CsvColumnDef` columns. Formula-injection neutralization applies to both default and custom columns.
85
+
86
+ Adapters implementing `PagedSubmissionStorageAdapter` expose `listSubmissionPage(formId, options)`. The opaque Base64
87
+ cursor combines `submittedAt` and response ID, so equal timestamps do not produce gaps or duplicates.
88
+
71
89
  See the [project documentation](https://github.com/nitta-a/form-engine-ts#readme) for the complete schema and API guide.
package/dist/index.cjs CHANGED
@@ -22,18 +22,28 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  aggregateResponses: () => aggregateResponses,
24
24
  assertValidFormSchema: () => assertValidFormSchema,
25
+ assertVersionMutable: () => assertVersionMutable,
25
26
  calculateChoiceDistribution: () => calculateChoiceDistribution,
26
27
  calculateCrossTabulation: () => calculateCrossTabulation,
27
28
  calculateFieldVisibility: () => calculateFieldVisibility,
28
29
  calculateNumericSummary: () => calculateNumericSummary,
29
30
  calculatePageVisibility: () => calculatePageVisibility,
31
+ cloneVersionToDraft: () => cloneVersionToDraft,
32
+ collectSchemaLocales: () => collectSchemaLocales,
33
+ createResponseAccumulator: () => createResponseAccumulator,
30
34
  createSubmission: () => createSubmission,
35
+ decodeSubmissionCursor: () => decodeSubmissionCursor,
36
+ deleteDraft: () => deleteDraft,
31
37
  dispatchWebhook: () => dispatchWebhook,
38
+ encodeSubmissionCursor: () => encodeSubmissionCursor,
32
39
  escapeCsvCell: () => escapeCsvCell,
33
40
  exportResponsesToCsv: () => exportResponsesToCsv,
41
+ exportResponsesToCsvStream: () => exportResponsesToCsvStream,
34
42
  isDisplayConditionSatisfied: () => isDisplayConditionSatisfied,
35
43
  isQuestionVisible: () => isQuestionVisible,
44
+ normalizeSubmissionPageSize: () => normalizeSubmissionPageSize,
36
45
  populateSchemaTranslations: () => populateSchemaTranslations,
46
+ publishDraft: () => publishDraft,
37
47
  resolveFormTranslation: () => resolveFormTranslation,
38
48
  resolveLocalizedSchema: () => resolveLocalizedSchema,
39
49
  sanitizeSchema: () => sanitizeSchema,
@@ -46,7 +56,95 @@ __export(index_exports, {
46
56
  });
47
57
  module.exports = __toCommonJS(index_exports);
48
58
 
59
+ // src/policy.ts
60
+ function collectRecordKeys(value, path, pathsByLocale) {
61
+ for (const locale of Object.keys(value ?? {})) {
62
+ const paths = pathsByLocale.get(locale) ?? [];
63
+ paths.push(`${path}.${locale}`);
64
+ pathsByLocale.set(locale, paths);
65
+ }
66
+ }
67
+ function collectSchemaLocales(schema) {
68
+ const pathsByLocale = /* @__PURE__ */ new Map();
69
+ collectRecordKeys(schema.translations, "translations", pathsByLocale);
70
+ collectRecordKeys(schema.translationMetadata, "translationMetadata", pathsByLocale);
71
+ schema.fields.forEach((field, fieldIndex) => {
72
+ collectRecordKeys(field.translations, `fields[${fieldIndex}].translations`, pathsByLocale);
73
+ collectRecordKeys(field.translationMetadata, `fields[${fieldIndex}].translationMetadata`, pathsByLocale);
74
+ if (!("options" in field)) return;
75
+ field.options.forEach((option, optionIndex) => {
76
+ collectRecordKeys(
77
+ option.translations,
78
+ `fields[${fieldIndex}].options[${optionIndex}].translations`,
79
+ pathsByLocale
80
+ );
81
+ collectRecordKeys(
82
+ option.translationMetadata,
83
+ `fields[${fieldIndex}].options[${optionIndex}].translationMetadata`,
84
+ pathsByLocale
85
+ );
86
+ });
87
+ });
88
+ schema.pages?.forEach((page, pageIndex) => {
89
+ collectRecordKeys(page.translations, `pages[${pageIndex}].translations`, pathsByLocale);
90
+ collectRecordKeys(page.translationMetadata, `pages[${pageIndex}].translationMetadata`, pathsByLocale);
91
+ });
92
+ const translationLocales = new Set(pathsByLocale.keys());
93
+ const allUniqueLocales = /* @__PURE__ */ new Set([
94
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
95
+ ...schema.supportedLocales ?? [],
96
+ ...translationLocales
97
+ ]);
98
+ return {
99
+ ...schema.defaultLocale === void 0 ? {} : { defaultLocale: schema.defaultLocale },
100
+ supportedLocales: schema.supportedLocales ?? [],
101
+ translationLocales,
102
+ allUniqueLocales,
103
+ translationLocalePaths: pathsByLocale
104
+ };
105
+ }
106
+
49
107
  // src/sanitization.ts
108
+ function registeredEntries(value, registeredLocales) {
109
+ if (value === void 0) return void 0;
110
+ const entries = Object.entries(value).filter(([locale]) => registeredLocales.has(locale));
111
+ return entries.length === 0 ? void 0 : Object.fromEntries(entries);
112
+ }
113
+ function sanitizeNodeLocales(node, registeredLocales) {
114
+ const { translationMetadata: _translationMetadata, ...base } = node;
115
+ const translationMetadata = registeredEntries(node.translationMetadata, registeredLocales);
116
+ return {
117
+ ...base,
118
+ ...translationMetadata === void 0 ? {} : { translationMetadata }
119
+ };
120
+ }
121
+ function sanitizeOptionLocales(option, registeredLocales) {
122
+ const { translations: _translations, ...base } = sanitizeNodeLocales(option, registeredLocales);
123
+ const translations = registeredEntries(option.translations, registeredLocales);
124
+ return { ...base, ...translations === void 0 ? {} : { translations } };
125
+ }
126
+ function sanitizeFieldLocales(field, registeredLocales) {
127
+ const localizedNode = sanitizeNodeLocales(field, registeredLocales);
128
+ const { translations: _translations, ...base } = localizedNode;
129
+ const translations = registeredEntries(field.translations, registeredLocales);
130
+ const localized = {
131
+ ...base,
132
+ ...translations === void 0 ? {} : { translations }
133
+ };
134
+ if (!("options" in localizedNode)) return localized;
135
+ const { translations: _choiceTranslations, ...choiceBase } = localizedNode;
136
+ return {
137
+ ...choiceBase,
138
+ ...translations === void 0 ? {} : { translations },
139
+ options: localizedNode.options.map((option) => sanitizeOptionLocales(option, registeredLocales))
140
+ };
141
+ }
142
+ function sanitizePageLocales(page, registeredLocales) {
143
+ const localizedNode = sanitizeNodeLocales(page, registeredLocales);
144
+ const { translations: _translations, ...base } = localizedNode;
145
+ const translations = registeredEntries(page.translations, registeredLocales);
146
+ return { ...base, ...translations === void 0 ? {} : { translations } };
147
+ }
50
148
  function cyclicQuestionIds(fields) {
51
149
  const firstById = /* @__PURE__ */ new Map();
52
150
  for (const field of fields) {
@@ -133,8 +231,13 @@ function validateSchemaStructure(schema) {
133
231
  }
134
232
  function sanitizeSchema(schema) {
135
233
  const existingQuestionIds = new Set(schema.fields.map((field) => field.id));
234
+ const registeredLocales = /* @__PURE__ */ new Set([
235
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
236
+ ...schema.supportedLocales ?? []
237
+ ]);
136
238
  const cyclic = cyclicQuestionIds(schema.fields);
137
- const sanitizedFields = schema.fields.map((field) => {
239
+ const sanitizedFields = schema.fields.map((sourceField) => {
240
+ const field = sanitizeFieldLocales(sourceField, registeredLocales);
138
241
  const sourceId = field.displayCondition?.questionId;
139
242
  if (sourceId === void 0 || existingQuestionIds.has(sourceId) && sourceId !== field.id && !cyclic.has(field.id)) {
140
243
  return field;
@@ -142,15 +245,19 @@ function sanitizeSchema(schema) {
142
245
  const { displayCondition: _displayCondition, ...sanitized } = field;
143
246
  return sanitized;
144
247
  });
248
+ const localizedSchema = sanitizeNodeLocales(schema, registeredLocales);
249
+ const { translations: _translations, ...schemaWithoutLocaleContent } = localizedSchema;
250
+ const translations = registeredEntries(schema.translations, registeredLocales);
145
251
  const base = {
146
- ...schema,
252
+ ...schemaWithoutLocaleContent,
253
+ ...translations === void 0 ? {} : { translations },
147
254
  fields: sanitizedFields
148
255
  };
149
256
  if (schema.pages === void 0) return base;
150
257
  const assigned = /* @__PURE__ */ new Set();
151
- const pages = schema.pages.map((page) => ({
152
- ...page,
153
- questionIds: page.questionIds.filter((id) => {
258
+ const pages = schema.pages.map((sourcePage) => ({
259
+ ...sanitizePageLocales(sourcePage, registeredLocales),
260
+ questionIds: sourcePage.questionIds.filter((id) => {
154
261
  if (!existingQuestionIds.has(id) || assigned.has(id)) return false;
155
262
  assigned.add(id);
156
263
  return true;
@@ -554,31 +661,38 @@ function validatePolicy(schema, policy, issues) {
554
661
  }
555
662
  }
556
663
  }
557
- const registeredLocales = [
558
- .../* @__PURE__ */ new Set([
559
- ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
560
- ...schema.supportedLocales ?? []
561
- ])
562
- ];
563
- if (policy.allowedLocales !== void 0) {
564
- if (schema.defaultLocale !== void 0 && !policy.allowedLocales.includes(schema.defaultLocale)) {
664
+ const collectedLocales = collectSchemaLocales(schema);
665
+ const registeredLocales = /* @__PURE__ */ new Set([
666
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
667
+ ...schema.supportedLocales ?? []
668
+ ]);
669
+ for (const locale of collectedLocales.translationLocales) {
670
+ if (registeredLocales.has(locale)) continue;
671
+ for (const path of collectedLocales.translationLocalePaths.get(locale) ?? []) {
565
672
  issue(
566
673
  issues,
567
- "defaultLocale",
568
- "disallowed_locale",
569
- `Locale ${schema.defaultLocale} is not allowed by the form policy.`
674
+ path,
675
+ "unregistered_translation_locale",
676
+ `Translation locale ${locale} is not registered by defaultLocale or supportedLocales.`
570
677
  );
571
678
  }
679
+ }
680
+ if (policy.allowedLocales !== void 0) {
681
+ const pathsByLocale = /* @__PURE__ */ new Map();
682
+ if (schema.defaultLocale !== void 0) pathsByLocale.set(schema.defaultLocale, ["defaultLocale"]);
572
683
  schema.supportedLocales?.forEach((locale, index) => {
573
- if (!policy.allowedLocales?.includes(locale)) {
574
- issue(
575
- issues,
576
- `supportedLocales[${index}]`,
577
- "disallowed_locale",
578
- `Locale ${locale} is not allowed by the form policy.`
579
- );
580
- }
684
+ pathsByLocale.set(locale, [...pathsByLocale.get(locale) ?? [], `supportedLocales[${index}]`]);
581
685
  });
686
+ for (const [locale, paths] of collectedLocales.translationLocalePaths) {
687
+ pathsByLocale.set(locale, [...pathsByLocale.get(locale) ?? [], ...paths]);
688
+ }
689
+ for (const [locale, paths] of pathsByLocale) {
690
+ if (!policy.allowedLocales.includes(locale)) {
691
+ for (const path of paths) {
692
+ issue(issues, path, "disallowed_locale", `Locale ${locale} is not allowed by the form policy.`);
693
+ }
694
+ }
695
+ }
582
696
  for (const locale of policy.requiredLocales ?? []) {
583
697
  if (!policy.allowedLocales.includes(locale)) {
584
698
  issue(
@@ -590,7 +704,7 @@ function validatePolicy(schema, policy, issues) {
590
704
  }
591
705
  }
592
706
  }
593
- if (policy.maxLocales !== void 0 && registeredLocales.length > policy.maxLocales) {
707
+ if (policy.maxLocales !== void 0 && collectedLocales.allUniqueLocales.size > policy.maxLocales) {
594
708
  issue(issues, "supportedLocales", "max_locales_exceeded", `At most ${policy.maxLocales} locales are allowed.`);
595
709
  }
596
710
  for (const locale of policy.requiredLocales ?? []) addRequiredTranslationIssues(schema, locale, issues);
@@ -986,6 +1100,152 @@ function aggregateResponses(schema, submissions) {
986
1100
  questions: schema.fields.map((field) => aggregateField(schema, field, submissions))
987
1101
  };
988
1102
  }
1103
+ function responseValues(submission) {
1104
+ return "values" in submission ? submission.values : submission.answers;
1105
+ }
1106
+ function responseIdentifier(submission) {
1107
+ return "id" in submission ? submission.id : submission.responseId;
1108
+ }
1109
+ function responseMismatch(schema, submission) {
1110
+ if (submission.formId !== schema.id) {
1111
+ return `Submission ${responseIdentifier(submission)} does not match form ${schema.id}.`;
1112
+ }
1113
+ if ("formVersion" in submission && submission.formVersion !== schema.version) {
1114
+ return `Submission ${responseIdentifier(submission)} does not match ${schema.id}@${schema.version}.`;
1115
+ }
1116
+ return void 0;
1117
+ }
1118
+ var IncrementalResponseAccumulator = class _IncrementalResponseAccumulator {
1119
+ #schema;
1120
+ #mode;
1121
+ #fields;
1122
+ #submissionCount = 0;
1123
+ constructor(schema, options) {
1124
+ assertValidFormSchema(schema);
1125
+ this.#schema = JSON.parse(JSON.stringify(schema));
1126
+ this.#mode = options.mode ?? "strict";
1127
+ this.#fields = new Map(
1128
+ schema.fields.map((field) => [
1129
+ field.id,
1130
+ {
1131
+ answeredCount: 0,
1132
+ total: 0,
1133
+ minimum: null,
1134
+ maximum: null,
1135
+ trueCount: 0,
1136
+ falseCount: 0,
1137
+ optionCounts: new Map("options" in field ? field.options.map((option) => [option.id, 0]) : [])
1138
+ }
1139
+ ])
1140
+ );
1141
+ }
1142
+ add(submission) {
1143
+ const mismatch = responseMismatch(this.#schema, submission);
1144
+ if (mismatch !== void 0 && this.#mode === "strict") return { success: false, error: mismatch };
1145
+ const values = responseValues(submission);
1146
+ const visibility = calculateFieldVisibility(this.#schema, values);
1147
+ for (const field of this.#schema.fields) {
1148
+ const accumulator = this.#fields.get(field.id);
1149
+ if (accumulator === void 0) throw new Error(`Accumulator for ${field.id} is unavailable.`);
1150
+ const candidate = values[field.id];
1151
+ if (visibility[field.id] !== true || !valueIsValid(field, candidate)) continue;
1152
+ accumulator.answeredCount += 1;
1153
+ if ((field.type === "number" || field.type === "rating") && typeof candidate === "number") {
1154
+ accumulator.total += candidate;
1155
+ accumulator.minimum = accumulator.minimum === null ? candidate : Math.min(accumulator.minimum, candidate);
1156
+ accumulator.maximum = accumulator.maximum === null ? candidate : Math.max(accumulator.maximum, candidate);
1157
+ } else if (field.type === "checkbox") {
1158
+ if (candidate === true) accumulator.trueCount += 1;
1159
+ if (candidate === false) accumulator.falseCount += 1;
1160
+ } else if ("options" in field) {
1161
+ const selections = Array.isArray(candidate) ? candidate : typeof candidate === "string" ? [candidate] : [];
1162
+ for (const selection of selections) {
1163
+ accumulator.optionCounts.set(selection, (accumulator.optionCounts.get(selection) ?? 0) + 1);
1164
+ }
1165
+ }
1166
+ }
1167
+ this.#submissionCount += 1;
1168
+ return { success: true };
1169
+ }
1170
+ addMany(submissions) {
1171
+ for (const submission of submissions) {
1172
+ const result = this.add(submission);
1173
+ if (!result.success) throw new TypeError(result.error ?? "Submission could not be accumulated.");
1174
+ }
1175
+ }
1176
+ merge(other) {
1177
+ if (!(other instanceof _IncrementalResponseAccumulator)) {
1178
+ throw new TypeError("Only form-engine response accumulators can be merged.");
1179
+ }
1180
+ if (other.#schema.id !== this.#schema.id || other.#schema.version !== this.#schema.version || JSON.stringify(other.#schema.fields) !== JSON.stringify(this.#schema.fields)) {
1181
+ throw new TypeError("Response accumulators must use the same schema.");
1182
+ }
1183
+ this.#submissionCount += other.#submissionCount;
1184
+ for (const [fieldId, source] of other.#fields) {
1185
+ const target = this.#fields.get(fieldId);
1186
+ if (target === void 0) throw new Error(`Accumulator for ${fieldId} is unavailable.`);
1187
+ target.answeredCount += source.answeredCount;
1188
+ target.total += source.total;
1189
+ target.minimum = target.minimum === null ? source.minimum : source.minimum === null ? target.minimum : Math.min(target.minimum, source.minimum);
1190
+ target.maximum = target.maximum === null ? source.maximum : source.maximum === null ? target.maximum : Math.max(target.maximum, source.maximum);
1191
+ target.trueCount += source.trueCount;
1192
+ target.falseCount += source.falseCount;
1193
+ for (const [optionId, count] of source.optionCounts) {
1194
+ target.optionCounts.set(optionId, (target.optionCounts.get(optionId) ?? 0) + count);
1195
+ }
1196
+ }
1197
+ return this;
1198
+ }
1199
+ finalize() {
1200
+ return {
1201
+ formId: this.#schema.id,
1202
+ formVersion: this.#schema.version,
1203
+ submissionCount: this.#submissionCount,
1204
+ questions: this.#schema.fields.map((field) => {
1205
+ const accumulator = this.#fields.get(field.id);
1206
+ if (accumulator === void 0) throw new Error(`Accumulator for ${field.id} is unavailable.`);
1207
+ const base = {
1208
+ fieldId: field.id,
1209
+ answeredCount: accumulator.answeredCount,
1210
+ unansweredCount: this.#submissionCount - accumulator.answeredCount
1211
+ };
1212
+ if (field.type === "text" || field.type === "textarea") return { ...base, kind: field.type };
1213
+ if (field.type === "number" || field.type === "rating") {
1214
+ return {
1215
+ ...base,
1216
+ kind: field.type,
1217
+ minimum: accumulator.minimum,
1218
+ maximum: accumulator.maximum,
1219
+ average: accumulator.answeredCount === 0 ? null : accumulator.total / accumulator.answeredCount,
1220
+ total: accumulator.total
1221
+ };
1222
+ }
1223
+ if (field.type === "checkbox") {
1224
+ return {
1225
+ ...base,
1226
+ kind: "checkbox",
1227
+ trueCount: accumulator.trueCount,
1228
+ falseCount: accumulator.falseCount,
1229
+ truePercentageOfSubmissions: percentage(accumulator.trueCount, this.#submissionCount),
1230
+ falsePercentageOfSubmissions: percentage(accumulator.falseCount, this.#submissionCount)
1231
+ };
1232
+ }
1233
+ if (!("options" in field)) throw new TypeError(`Field ${field.id} cannot be aggregated.`);
1234
+ return {
1235
+ ...base,
1236
+ kind: field.type,
1237
+ options: field.options.map((option) => {
1238
+ const count = accumulator.optionCounts.get(option.id) ?? 0;
1239
+ return { id: option.id, count, percentageOfSubmissions: percentage(count, this.#submissionCount) };
1240
+ })
1241
+ };
1242
+ })
1243
+ };
1244
+ }
1245
+ };
1246
+ function createResponseAccumulator(schema, options = {}) {
1247
+ return new IncrementalResponseAccumulator(schema, options);
1248
+ }
989
1249
  function escapeCsvCell(value, neutralizeFormulas = true) {
990
1250
  if (value === null || value === void 0) return "";
991
1251
  let stringValue = String(value);
@@ -1001,6 +1261,51 @@ function serializeValue(value) {
1001
1261
  if (value === void 0) return "";
1002
1262
  return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value);
1003
1263
  }
1264
+ function asFormResponse(submission) {
1265
+ if (!("values" in submission)) return submission;
1266
+ return {
1267
+ responseId: submission.id,
1268
+ formId: submission.formId,
1269
+ sourceLocale: submission.locale,
1270
+ answers: submission.values,
1271
+ submittedAt: submission.submittedAt,
1272
+ ...submission.metadata === void 0 ? {} : { metadata: submission.metadata },
1273
+ ...submission.translationMetadata === void 0 ? {} : { translationMetadata: submission.translationMetadata }
1274
+ };
1275
+ }
1276
+ function serializeUnknown(value) {
1277
+ if (value === null || value === void 0) return "";
1278
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
1279
+ return JSON.stringify(value);
1280
+ }
1281
+ async function* exportResponsesToCsvStream(schema, submissions, options = {}) {
1282
+ assertValidFormSchema(schema);
1283
+ const includeDefaultColumns = options.includeDefaultColumns ?? true;
1284
+ const customColumns = options.columns ?? [];
1285
+ const headers = [
1286
+ ...includeDefaultColumns ? ["submissionId", "submittedAt", "locale", ...schema.fields.map((field) => field.id)] : [],
1287
+ ...customColumns.map((column) => column.header)
1288
+ ];
1289
+ const neutralizeFormulas = options.neutralizeFormulas ?? true;
1290
+ const header = headers.map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",");
1291
+ yield `${options.withBom ?? true ? "\uFEFF" : ""}${header}`;
1292
+ for await (const submission of submissions) {
1293
+ const mismatch = responseMismatch(schema, submission);
1294
+ if (mismatch !== void 0) throw new TypeError(mismatch);
1295
+ const response = asFormResponse(submission);
1296
+ const answers = response.answers;
1297
+ const visible = selectVisibleAnswers(schema, answers);
1298
+ const defaultCells = includeDefaultColumns ? [
1299
+ response.responseId,
1300
+ response.submittedAt,
1301
+ response.sourceLocale ?? "",
1302
+ ...schema.fields.map((field) => serializeUnknown(visible[field.id]))
1303
+ ] : [];
1304
+ const customCells = customColumns.map((column) => column.getValue(response));
1305
+ yield `\r
1306
+ ${[...defaultCells, ...customCells].map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",")}`;
1307
+ }
1308
+ }
1004
1309
  function exportResponsesToCsv(schema, responses, options = {}) {
1005
1310
  assertValidFormSchema(schema);
1006
1311
  for (const response of responses) {
@@ -1118,6 +1423,61 @@ function transformFieldType(field, nextType) {
1118
1423
  return { ...common, type: nextType, options };
1119
1424
  }
1120
1425
 
1426
+ // src/pagination.ts
1427
+ var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1428
+ function encodeBase64(bytes) {
1429
+ let result = "";
1430
+ for (let index = 0; index < bytes.length; index += 3) {
1431
+ const first = bytes[index] ?? 0;
1432
+ const second = bytes[index + 1] ?? 0;
1433
+ const third = bytes[index + 2] ?? 0;
1434
+ const combined = first << 16 | second << 8 | third;
1435
+ result += BASE64_ALPHABET[combined >> 18 & 63] ?? "";
1436
+ result += BASE64_ALPHABET[combined >> 12 & 63] ?? "";
1437
+ result += index + 1 < bytes.length ? BASE64_ALPHABET[combined >> 6 & 63] ?? "" : "=";
1438
+ result += index + 2 < bytes.length ? BASE64_ALPHABET[combined & 63] ?? "" : "=";
1439
+ }
1440
+ return result;
1441
+ }
1442
+ function decodeBase64(value) {
1443
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
1444
+ throw new TypeError("cursor must be a valid Base64 token.");
1445
+ }
1446
+ const bytes = [];
1447
+ for (let index = 0; index < value.length; index += 4) {
1448
+ const characters = value.slice(index, index + 4);
1449
+ const sextets = [...characters].map((character) => character === "=" ? 0 : BASE64_ALPHABET.indexOf(character));
1450
+ const combined = (sextets[0] ?? 0) << 18 | (sextets[1] ?? 0) << 12 | (sextets[2] ?? 0) << 6 | (sextets[3] ?? 0);
1451
+ bytes.push(combined >> 16 & 255);
1452
+ if (characters[2] !== "=") bytes.push(combined >> 8 & 255);
1453
+ if (characters[3] !== "=") bytes.push(combined & 255);
1454
+ }
1455
+ return new Uint8Array(bytes);
1456
+ }
1457
+ function encodeSubmissionCursor(value) {
1458
+ if (value.submittedAt.length === 0 || value.responseId.length === 0) {
1459
+ throw new TypeError("Cursor values must not be empty.");
1460
+ }
1461
+ return encodeBase64(new TextEncoder().encode(JSON.stringify(value)));
1462
+ }
1463
+ function decodeSubmissionCursor(cursor) {
1464
+ try {
1465
+ const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64(cursor)));
1466
+ 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) {
1467
+ throw new TypeError("cursor payload is invalid.");
1468
+ }
1469
+ return { submittedAt: parsed.submittedAt, responseId: parsed.responseId };
1470
+ } catch (cause) {
1471
+ if (cause instanceof TypeError && cause.message === "cursor payload is invalid.") throw cause;
1472
+ throw new TypeError("cursor must be a valid form-engine cursor.", { cause });
1473
+ }
1474
+ }
1475
+ function normalizeSubmissionPageSize(pageSize, fallback = 100) {
1476
+ const value = pageSize ?? fallback;
1477
+ if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("pageSize must be a positive safe integer.");
1478
+ return value;
1479
+ }
1480
+
1121
1481
  // src/validation.ts
1122
1482
  var DEFAULT_MESSAGES = {
1123
1483
  required: "validation.required",
@@ -1493,6 +1853,18 @@ function resolveLocalizedSchema(schema, targetLocale) {
1493
1853
  async function populateSchemaTranslations(schema, targetLocales, adapter, options = {}) {
1494
1854
  assertValidFormSchema(schema);
1495
1855
  const locales = [...new Set(targetLocales.filter((locale) => locale.length > 0 && locale !== schema.defaultLocale))];
1856
+ const allowedLocales = options.policy?.allowedLocales;
1857
+ const collectedLocales = collectSchemaLocales(schema);
1858
+ const disallowedLocale = [...collectedLocales.allUniqueLocales, ...locales].find(
1859
+ (locale) => allowedLocales !== void 0 && !allowedLocales.includes(locale)
1860
+ );
1861
+ if (disallowedLocale !== void 0) {
1862
+ throw new RangeError(`Translation locale ${disallowedLocale} is not allowed by the form policy.`);
1863
+ }
1864
+ const projectedLocales = /* @__PURE__ */ new Set([...collectedLocales.allUniqueLocales, ...locales]);
1865
+ if (options.policy?.maxLocales !== void 0 && projectedLocales.size > options.policy.maxLocales) {
1866
+ throw new RangeError(`At most ${options.policy.maxLocales} locales are allowed by the form policy.`);
1867
+ }
1496
1868
  const updatedSlots = [];
1497
1869
  const skippedSlots = [];
1498
1870
  let result = schema;
@@ -1541,22 +1913,125 @@ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocal
1541
1913
  );
1542
1914
  return resolveLocalizedSchema(populated.schema, targetLocale);
1543
1915
  }
1916
+
1917
+ // src/versioning.ts
1918
+ function validateState(state) {
1919
+ if (state.formId.trim().length === 0) throw new TypeError("formId must not be empty.");
1920
+ if (!Number.isSafeInteger(state.nextVersion) || state.nextVersion < 1) {
1921
+ throw new TypeError("nextVersion must be a positive safe integer.");
1922
+ }
1923
+ if (!Number.isSafeInteger(state.revision) || state.revision < 0) {
1924
+ throw new TypeError("revision must be a non-negative safe integer.");
1925
+ }
1926
+ }
1927
+ function cloneVersionToDraft(state, sourceSchema, options = {}) {
1928
+ validateState(state);
1929
+ if (sourceSchema.id !== state.formId) throw new TypeError("sourceSchema.id must match state.formId.");
1930
+ if (state.draftVersion !== void 0) {
1931
+ return { success: false, error: { type: "draft_already_exists", currentDraftVersion: state.draftVersion } };
1932
+ }
1933
+ const maxVersions = options.maxVersions ?? Number.MAX_SAFE_INTEGER;
1934
+ if (!Number.isSafeInteger(maxVersions) || maxVersions < 1) {
1935
+ throw new TypeError("maxVersions must be a positive safe integer.");
1936
+ }
1937
+ if (state.nextVersion > maxVersions) {
1938
+ return { success: false, error: { type: "max_version_exceeded", max: maxVersions } };
1939
+ }
1940
+ const version = state.nextVersion;
1941
+ return {
1942
+ success: true,
1943
+ value: {
1944
+ nextState: {
1945
+ ...state,
1946
+ draftVersion: version,
1947
+ nextVersion: version + 1,
1948
+ revision: state.revision + 1
1949
+ },
1950
+ draftSchema: { ...sourceSchema, version }
1951
+ }
1952
+ };
1953
+ }
1954
+ function publishDraft(state, draftSchema, options = {}) {
1955
+ validateState(state);
1956
+ if (options.expectedRevision !== void 0 && options.expectedRevision !== state.revision) {
1957
+ return {
1958
+ success: false,
1959
+ error: {
1960
+ type: "revision_conflict",
1961
+ expectedRevision: options.expectedRevision,
1962
+ actualRevision: state.revision
1963
+ }
1964
+ };
1965
+ }
1966
+ if (state.draftVersion === void 0 || draftSchema.id !== state.formId || draftSchema.version !== state.draftVersion) {
1967
+ return { success: false, error: { type: "draft_not_found" } };
1968
+ }
1969
+ if (options.validate?.(draftSchema) === false) throw new TypeError("Draft schema validation failed.");
1970
+ const timestamp = options.timestamp ?? "1970-01-01T00:00:00.000Z";
1971
+ if (!Number.isFinite(Date.parse(timestamp))) throw new TypeError("timestamp must be a valid date string.");
1972
+ const archivedVersion = state.publishedVersion;
1973
+ const { draftVersion: _draftVersion, ...stateWithoutDraft } = state;
1974
+ return {
1975
+ success: true,
1976
+ value: {
1977
+ nextState: {
1978
+ ...stateWithoutDraft,
1979
+ publishedVersion: draftSchema.version,
1980
+ revision: state.revision + 1
1981
+ },
1982
+ publishedRecord: {
1983
+ formId: state.formId,
1984
+ version: draftSchema.version,
1985
+ status: "published",
1986
+ schema: draftSchema,
1987
+ createdAt: timestamp,
1988
+ publishedAt: timestamp
1989
+ },
1990
+ ...archivedVersion === void 0 ? {} : { archivedVersion }
1991
+ }
1992
+ };
1993
+ }
1994
+ function deleteDraft(state) {
1995
+ validateState(state);
1996
+ if (state.draftVersion === void 0) return { success: false, error: { type: "draft_not_found" } };
1997
+ const { draftVersion: _draftVersion, ...stateWithoutDraft } = state;
1998
+ return {
1999
+ success: true,
2000
+ value: { nextState: { ...stateWithoutDraft, revision: state.revision + 1 } }
2001
+ };
2002
+ }
2003
+ function assertVersionMutable(status) {
2004
+ if (status !== "draft") {
2005
+ const error = { type: "version_immutable", status };
2006
+ throw new TypeError(`A ${status} form version is immutable.`, { cause: error });
2007
+ }
2008
+ }
1544
2009
  // Annotate the CommonJS export names for ESM import in node:
1545
2010
  0 && (module.exports = {
1546
2011
  aggregateResponses,
1547
2012
  assertValidFormSchema,
2013
+ assertVersionMutable,
1548
2014
  calculateChoiceDistribution,
1549
2015
  calculateCrossTabulation,
1550
2016
  calculateFieldVisibility,
1551
2017
  calculateNumericSummary,
1552
2018
  calculatePageVisibility,
2019
+ cloneVersionToDraft,
2020
+ collectSchemaLocales,
2021
+ createResponseAccumulator,
1553
2022
  createSubmission,
2023
+ decodeSubmissionCursor,
2024
+ deleteDraft,
1554
2025
  dispatchWebhook,
2026
+ encodeSubmissionCursor,
1555
2027
  escapeCsvCell,
1556
2028
  exportResponsesToCsv,
2029
+ exportResponsesToCsvStream,
1557
2030
  isDisplayConditionSatisfied,
1558
2031
  isQuestionVisible,
2032
+ normalizeSubmissionPageSize,
1559
2033
  populateSchemaTranslations,
2034
+ publishDraft,
1560
2035
  resolveFormTranslation,
1561
2036
  resolveLocalizedSchema,
1562
2037
  sanitizeSchema,