@form-engine-ts/core 2.3.0 → 2.5.1

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
@@ -74,4 +74,24 @@ const submissions = await storage.listSubmissions("contact", 1, range);
74
74
 
75
75
  Results are ordered by `submittedAt`, then submission ID. Both boundaries are inclusive.
76
76
 
77
+ ## Versioning, incremental analytics, and paged storage
78
+
79
+ `cloneVersionToDraft`, `publishDraft`, and `deleteDraft` implement revision-checked version transitions as pure functions.
80
+ Clone/delete operations accept `expectedRevision`; cloning rejects non-published sources, publish validation failures are
81
+ returned as typed `validation_failed` issues, and successful publishing reports every archived record. Storage adapters
82
+ that can commit the resulting changes atomically implement `VersionedFormStorageAdapter` and `VersionTransitionPlan`.
83
+ `createResponseAccumulator` incrementally counts choices, answered/unanswered values, and numeric summaries without retaining
84
+ free-text bodies. In lenient mode, mismatched responses are skipped and exposed by `addMany()` and `getReport()` instead of
85
+ being included silently. Independent accumulators for the same schema can be merged, and `finalize()` matches
86
+ `aggregateResponses`.
87
+
88
+ `exportResponsesToCsvStream` accepts an `AsyncIterable`, emits the BOM/header and one chunk per response, and supports
89
+ custom `CsvColumnDef` columns. Custom getters receive the submission, form version, and schema. Use
90
+ `pipeResponsesToCsvStream` to write to a Web `WritableStream` or Node-compatible writable while honoring backpressure.
91
+ Formula-injection neutralization applies to both default and custom columns.
92
+
93
+ Adapters implementing `PagedSubmissionStorageAdapter` expose `listSubmissionPage(formId, options)`. The opaque Base64
94
+ cursor combines `submittedAt` and response ID, so equal timestamps do not produce gaps or duplicates. `metadataFilters`
95
+ and `filter` are applied before page sizing.
96
+
77
97
  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,19 +22,30 @@ 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,
30
32
  collectSchemaLocales: () => collectSchemaLocales,
33
+ createResponseAccumulator: () => createResponseAccumulator,
31
34
  createSubmission: () => createSubmission,
35
+ decodeSubmissionCursor: () => decodeSubmissionCursor,
36
+ deleteDraft: () => deleteDraft,
32
37
  dispatchWebhook: () => dispatchWebhook,
38
+ encodeSubmissionCursor: () => encodeSubmissionCursor,
33
39
  escapeCsvCell: () => escapeCsvCell,
34
40
  exportResponsesToCsv: () => exportResponsesToCsv,
41
+ exportResponsesToCsvStream: () => exportResponsesToCsvStream,
35
42
  isDisplayConditionSatisfied: () => isDisplayConditionSatisfied,
36
43
  isQuestionVisible: () => isQuestionVisible,
44
+ matchesSubmissionPageFilters: () => matchesSubmissionPageFilters,
45
+ normalizeSubmissionPageSize: () => normalizeSubmissionPageSize,
46
+ pipeResponsesToCsvStream: () => pipeResponsesToCsvStream,
37
47
  populateSchemaTranslations: () => populateSchemaTranslations,
48
+ publishDraft: () => publishDraft,
38
49
  resolveFormTranslation: () => resolveFormTranslation,
39
50
  resolveLocalizedSchema: () => resolveLocalizedSchema,
40
51
  sanitizeSchema: () => sanitizeSchema,
@@ -1091,6 +1102,188 @@ function aggregateResponses(schema, submissions) {
1091
1102
  questions: schema.fields.map((field) => aggregateField(schema, field, submissions))
1092
1103
  };
1093
1104
  }
1105
+ function responseValues(submission) {
1106
+ if (typeof submission !== "object" || submission === null) return void 0;
1107
+ if ("values" in submission) return submission.values;
1108
+ return "answers" in submission ? submission.answers : void 0;
1109
+ }
1110
+ function responseIdentifier(submission) {
1111
+ if (typeof submission !== "object" || submission === null) return "<unknown>";
1112
+ if ("id" in submission && typeof submission.id === "string" && submission.id.length > 0) return submission.id;
1113
+ if ("responseId" in submission && typeof submission.responseId === "string" && submission.responseId.length > 0) {
1114
+ return submission.responseId;
1115
+ }
1116
+ return "<unknown>";
1117
+ }
1118
+ function isAnswerRecord(value) {
1119
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1120
+ }
1121
+ function responseProblem(schema, submission) {
1122
+ if (typeof submission !== "object" || submission === null) {
1123
+ return { reason: "invalid_structure", error: "Submission structure is invalid." };
1124
+ }
1125
+ const identifier = responseIdentifier(submission);
1126
+ const values = responseValues(submission);
1127
+ if (typeof identifier !== "string" || identifier.length === 0 || typeof submission.formId !== "string" || typeof submission.submittedAt !== "string" || !isAnswerRecord(values)) {
1128
+ return { reason: "invalid_structure", error: `Submission ${String(identifier)} structure is invalid.` };
1129
+ }
1130
+ if (submission.formId !== schema.id) {
1131
+ return { reason: "form_id_mismatch", error: `Submission ${identifier} does not match form ${schema.id}.` };
1132
+ }
1133
+ if ("formVersion" in submission && submission.formVersion !== void 0 && submission.formVersion !== schema.version) {
1134
+ return {
1135
+ reason: "version_mismatch",
1136
+ error: `Submission ${identifier} does not match ${schema.id}@${schema.version}.`
1137
+ };
1138
+ }
1139
+ return void 0;
1140
+ }
1141
+ var IncrementalResponseAccumulator = class _IncrementalResponseAccumulator {
1142
+ #schema;
1143
+ #mode;
1144
+ #fields;
1145
+ #skipReasons = [];
1146
+ #submissionCount = 0;
1147
+ constructor(schema, options) {
1148
+ assertValidFormSchema(schema);
1149
+ this.#schema = JSON.parse(JSON.stringify(schema));
1150
+ this.#mode = options.mode ?? "strict";
1151
+ this.#fields = new Map(
1152
+ schema.fields.map((field) => [
1153
+ field.id,
1154
+ {
1155
+ answeredCount: 0,
1156
+ total: 0,
1157
+ minimum: null,
1158
+ maximum: null,
1159
+ trueCount: 0,
1160
+ falseCount: 0,
1161
+ optionCounts: new Map("options" in field ? field.options.map((option) => [option.id, 0]) : [])
1162
+ }
1163
+ ])
1164
+ );
1165
+ }
1166
+ add(submission) {
1167
+ const problem = responseProblem(this.#schema, submission);
1168
+ if (problem !== void 0) {
1169
+ if (this.#mode === "strict") return { success: false, error: problem.error };
1170
+ this.#skipReasons.push({ responseId: responseIdentifier(submission), reason: problem.reason });
1171
+ return { success: true, skipped: true };
1172
+ }
1173
+ const values = responseValues(submission);
1174
+ if (!isAnswerRecord(values)) throw new Error("Validated response answers are unavailable.");
1175
+ const visibility = calculateFieldVisibility(this.#schema, values);
1176
+ for (const field of this.#schema.fields) {
1177
+ const accumulator = this.#fields.get(field.id);
1178
+ if (accumulator === void 0) throw new Error(`Accumulator for ${field.id} is unavailable.`);
1179
+ const candidate = values[field.id];
1180
+ if (visibility[field.id] !== true || !valueIsValid(field, candidate)) continue;
1181
+ accumulator.answeredCount += 1;
1182
+ if ((field.type === "number" || field.type === "rating") && typeof candidate === "number") {
1183
+ accumulator.total += candidate;
1184
+ accumulator.minimum = accumulator.minimum === null ? candidate : Math.min(accumulator.minimum, candidate);
1185
+ accumulator.maximum = accumulator.maximum === null ? candidate : Math.max(accumulator.maximum, candidate);
1186
+ } else if (field.type === "checkbox") {
1187
+ if (candidate === true) accumulator.trueCount += 1;
1188
+ if (candidate === false) accumulator.falseCount += 1;
1189
+ } else if ("options" in field) {
1190
+ const selections = Array.isArray(candidate) ? candidate : typeof candidate === "string" ? [candidate] : [];
1191
+ for (const selection of selections) {
1192
+ accumulator.optionCounts.set(selection, (accumulator.optionCounts.get(selection) ?? 0) + 1);
1193
+ }
1194
+ }
1195
+ }
1196
+ this.#submissionCount += 1;
1197
+ return { success: true };
1198
+ }
1199
+ addMany(submissions) {
1200
+ for (const submission of submissions) {
1201
+ const result = this.add(submission);
1202
+ if (!result.success) throw new TypeError(result.error ?? "Submission could not be accumulated.");
1203
+ }
1204
+ return this.getReport();
1205
+ }
1206
+ merge(other) {
1207
+ if (!(other instanceof _IncrementalResponseAccumulator)) {
1208
+ throw new TypeError("Only form-engine response accumulators can be merged.");
1209
+ }
1210
+ if (other.#schema.id !== this.#schema.id || other.#schema.version !== this.#schema.version || JSON.stringify(other.#schema.fields) !== JSON.stringify(this.#schema.fields)) {
1211
+ throw new TypeError("Response accumulators must use the same schema.");
1212
+ }
1213
+ this.#submissionCount += other.#submissionCount;
1214
+ this.#skipReasons.push(...other.#skipReasons);
1215
+ for (const [fieldId, source] of other.#fields) {
1216
+ const target = this.#fields.get(fieldId);
1217
+ if (target === void 0) throw new Error(`Accumulator for ${fieldId} is unavailable.`);
1218
+ target.answeredCount += source.answeredCount;
1219
+ target.total += source.total;
1220
+ target.minimum = target.minimum === null ? source.minimum : source.minimum === null ? target.minimum : Math.min(target.minimum, source.minimum);
1221
+ target.maximum = target.maximum === null ? source.maximum : source.maximum === null ? target.maximum : Math.max(target.maximum, source.maximum);
1222
+ target.trueCount += source.trueCount;
1223
+ target.falseCount += source.falseCount;
1224
+ for (const [optionId, count] of source.optionCounts) {
1225
+ target.optionCounts.set(optionId, (target.optionCounts.get(optionId) ?? 0) + count);
1226
+ }
1227
+ }
1228
+ return this;
1229
+ }
1230
+ getReport() {
1231
+ return {
1232
+ processedCount: this.#submissionCount,
1233
+ skippedCount: this.#skipReasons.length,
1234
+ skipReasons: this.#skipReasons.map((reason) => ({ ...reason }))
1235
+ };
1236
+ }
1237
+ finalize() {
1238
+ return {
1239
+ formId: this.#schema.id,
1240
+ formVersion: this.#schema.version,
1241
+ submissionCount: this.#submissionCount,
1242
+ questions: this.#schema.fields.map((field) => {
1243
+ const accumulator = this.#fields.get(field.id);
1244
+ if (accumulator === void 0) throw new Error(`Accumulator for ${field.id} is unavailable.`);
1245
+ const base = {
1246
+ fieldId: field.id,
1247
+ answeredCount: accumulator.answeredCount,
1248
+ unansweredCount: this.#submissionCount - accumulator.answeredCount
1249
+ };
1250
+ if (field.type === "text" || field.type === "textarea") return { ...base, kind: field.type };
1251
+ if (field.type === "number" || field.type === "rating") {
1252
+ return {
1253
+ ...base,
1254
+ kind: field.type,
1255
+ minimum: accumulator.minimum,
1256
+ maximum: accumulator.maximum,
1257
+ average: accumulator.answeredCount === 0 ? null : accumulator.total / accumulator.answeredCount,
1258
+ total: accumulator.total
1259
+ };
1260
+ }
1261
+ if (field.type === "checkbox") {
1262
+ return {
1263
+ ...base,
1264
+ kind: "checkbox",
1265
+ trueCount: accumulator.trueCount,
1266
+ falseCount: accumulator.falseCount,
1267
+ truePercentageOfSubmissions: percentage(accumulator.trueCount, this.#submissionCount),
1268
+ falsePercentageOfSubmissions: percentage(accumulator.falseCount, this.#submissionCount)
1269
+ };
1270
+ }
1271
+ if (!("options" in field)) throw new TypeError(`Field ${field.id} cannot be aggregated.`);
1272
+ return {
1273
+ ...base,
1274
+ kind: field.type,
1275
+ options: field.options.map((option) => {
1276
+ const count = accumulator.optionCounts.get(option.id) ?? 0;
1277
+ return { id: option.id, count, percentageOfSubmissions: percentage(count, this.#submissionCount) };
1278
+ })
1279
+ };
1280
+ })
1281
+ };
1282
+ }
1283
+ };
1284
+ function createResponseAccumulator(schema, options = {}) {
1285
+ return new IncrementalResponseAccumulator(schema, options);
1286
+ }
1094
1287
  function escapeCsvCell(value, neutralizeFormulas = true) {
1095
1288
  if (value === null || value === void 0) return "";
1096
1289
  let stringValue = String(value);
@@ -1106,6 +1299,105 @@ function serializeValue(value) {
1106
1299
  if (value === void 0) return "";
1107
1300
  return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value);
1108
1301
  }
1302
+ function asFormResponse(submission) {
1303
+ if (!("values" in submission)) return submission;
1304
+ return {
1305
+ responseId: submission.id,
1306
+ formId: submission.formId,
1307
+ sourceLocale: submission.locale,
1308
+ formVersion: submission.formVersion,
1309
+ answers: submission.values,
1310
+ submittedAt: submission.submittedAt,
1311
+ ...submission.metadata === void 0 ? {} : { metadata: submission.metadata },
1312
+ ...submission.translationMetadata === void 0 ? {} : { translationMetadata: submission.translationMetadata }
1313
+ };
1314
+ }
1315
+ function serializeUnknown(value) {
1316
+ if (value === null || value === void 0) return "";
1317
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
1318
+ return JSON.stringify(value);
1319
+ }
1320
+ async function* exportResponsesToCsvStream(schema, submissions, options = {}) {
1321
+ assertValidFormSchema(schema);
1322
+ const includeDefaultColumns = options.includeDefaultColumns ?? true;
1323
+ const customColumns = options.columns ?? [];
1324
+ const headers = [
1325
+ ...includeDefaultColumns ? ["submissionId", "submittedAt", "locale", ...schema.fields.map((field) => field.id)] : [],
1326
+ ...customColumns.map((column) => column.header)
1327
+ ];
1328
+ const neutralizeFormulas = options.neutralizeFormulas ?? true;
1329
+ const header = headers.map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",");
1330
+ yield `${options.withBom ?? true ? "\uFEFF" : ""}${header}`;
1331
+ for await (const submission of submissions) {
1332
+ const problem = responseProblem(schema, submission);
1333
+ if (problem !== void 0) throw new TypeError(problem.error);
1334
+ const response = asFormResponse(submission);
1335
+ const answers = response.answers;
1336
+ const visible = selectVisibleAnswers(schema, answers);
1337
+ const defaultCells = includeDefaultColumns ? [
1338
+ response.responseId,
1339
+ response.submittedAt,
1340
+ response.sourceLocale ?? "",
1341
+ ...schema.fields.map((field) => serializeUnknown(visible[field.id]))
1342
+ ] : [];
1343
+ const context = {
1344
+ ...response,
1345
+ submission: response,
1346
+ formVersion: response.formVersion ?? schema.version,
1347
+ schema
1348
+ };
1349
+ const customCells = customColumns.map((column) => column.getValue(context));
1350
+ yield `\r
1351
+ ${[...defaultCells, ...customCells].map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",")}`;
1352
+ }
1353
+ }
1354
+ function isWebWritableStream(writable) {
1355
+ return "getWriter" in writable && typeof writable.getWriter === "function";
1356
+ }
1357
+ async function writeNodeChunk(writable, chunk, streamError) {
1358
+ if (writable.write(chunk)) return;
1359
+ let onDrain;
1360
+ const drain = new Promise((resolve) => {
1361
+ onDrain = resolve;
1362
+ writable.once("drain", resolve);
1363
+ });
1364
+ try {
1365
+ await Promise.race([drain, streamError]);
1366
+ } finally {
1367
+ if (onDrain !== void 0) writable.removeListener("drain", onDrain);
1368
+ }
1369
+ }
1370
+ async function pipeResponsesToCsvStream(schema, submissions, writable, options = {}) {
1371
+ const encoder = new TextEncoder();
1372
+ if (isWebWritableStream(writable)) {
1373
+ const writer = writable.getWriter();
1374
+ try {
1375
+ for await (const chunk of exportResponsesToCsvStream(schema, submissions, options)) {
1376
+ await writer.write(encoder.encode(chunk));
1377
+ }
1378
+ await writer.close();
1379
+ } catch (cause) {
1380
+ await writer.abort(cause);
1381
+ throw cause;
1382
+ } finally {
1383
+ writer.releaseLock();
1384
+ }
1385
+ return;
1386
+ }
1387
+ let onStreamError;
1388
+ const streamError = new Promise((_resolve, reject) => {
1389
+ onStreamError = reject;
1390
+ writable.once("error", reject);
1391
+ });
1392
+ try {
1393
+ for await (const chunk of exportResponsesToCsvStream(schema, submissions, options)) {
1394
+ await writeNodeChunk(writable, encoder.encode(chunk), streamError);
1395
+ }
1396
+ await Promise.race([new Promise((resolve) => writable.end(resolve)), streamError]);
1397
+ } finally {
1398
+ if (onStreamError !== void 0) writable.removeListener("error", onStreamError);
1399
+ }
1400
+ }
1109
1401
  function exportResponsesToCsv(schema, responses, options = {}) {
1110
1402
  assertValidFormSchema(schema);
1111
1403
  for (const response of responses) {
@@ -1223,6 +1515,87 @@ function transformFieldType(field, nextType) {
1223
1515
  return { ...common, type: nextType, options };
1224
1516
  }
1225
1517
 
1518
+ // src/pagination.ts
1519
+ var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1520
+ function encodeBase64(bytes) {
1521
+ let result = "";
1522
+ for (let index = 0; index < bytes.length; index += 3) {
1523
+ const first = bytes[index] ?? 0;
1524
+ const second = bytes[index + 1] ?? 0;
1525
+ const third = bytes[index + 2] ?? 0;
1526
+ const combined = first << 16 | second << 8 | third;
1527
+ result += BASE64_ALPHABET[combined >> 18 & 63] ?? "";
1528
+ result += BASE64_ALPHABET[combined >> 12 & 63] ?? "";
1529
+ result += index + 1 < bytes.length ? BASE64_ALPHABET[combined >> 6 & 63] ?? "" : "=";
1530
+ result += index + 2 < bytes.length ? BASE64_ALPHABET[combined & 63] ?? "" : "=";
1531
+ }
1532
+ return result;
1533
+ }
1534
+ function decodeBase64(value) {
1535
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
1536
+ throw new TypeError("cursor must be a valid Base64 token.");
1537
+ }
1538
+ const bytes = [];
1539
+ for (let index = 0; index < value.length; index += 4) {
1540
+ const characters = value.slice(index, index + 4);
1541
+ const sextets = [...characters].map((character) => character === "=" ? 0 : BASE64_ALPHABET.indexOf(character));
1542
+ const combined = (sextets[0] ?? 0) << 18 | (sextets[1] ?? 0) << 12 | (sextets[2] ?? 0) << 6 | (sextets[3] ?? 0);
1543
+ bytes.push(combined >> 16 & 255);
1544
+ if (characters[2] !== "=") bytes.push(combined >> 8 & 255);
1545
+ if (characters[3] !== "=") bytes.push(combined & 255);
1546
+ }
1547
+ return new Uint8Array(bytes);
1548
+ }
1549
+ function encodeSubmissionCursor(value) {
1550
+ if (value.submittedAt.length === 0 || value.responseId.length === 0) {
1551
+ throw new TypeError("Cursor values must not be empty.");
1552
+ }
1553
+ return encodeBase64(new TextEncoder().encode(JSON.stringify(value)));
1554
+ }
1555
+ function decodeSubmissionCursor(cursor) {
1556
+ try {
1557
+ const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64(cursor)));
1558
+ 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) {
1559
+ throw new TypeError("cursor payload is invalid.");
1560
+ }
1561
+ return { submittedAt: parsed.submittedAt, responseId: parsed.responseId };
1562
+ } catch (cause) {
1563
+ if (cause instanceof TypeError && cause.message === "cursor payload is invalid.") throw cause;
1564
+ throw new TypeError("cursor must be a valid form-engine cursor.", { cause });
1565
+ }
1566
+ }
1567
+ function normalizeSubmissionPageSize(pageSize, fallback = 100) {
1568
+ const value = pageSize ?? fallback;
1569
+ if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("pageSize must be a positive safe integer.");
1570
+ return value;
1571
+ }
1572
+ function isJsonArray(value) {
1573
+ return Array.isArray(value);
1574
+ }
1575
+ function isJsonObject(value) {
1576
+ return typeof value === "object" && value !== null && !isJsonArray(value);
1577
+ }
1578
+ function jsonValuesEqual(left, right) {
1579
+ if (left === right) return true;
1580
+ if (left === void 0 || left === null || right === void 0 || right === null || typeof left !== typeof right) {
1581
+ return false;
1582
+ }
1583
+ if (isJsonArray(left) || isJsonArray(right)) {
1584
+ return isJsonArray(left) && isJsonArray(right) && left.length === right.length && left.every((value, index) => jsonValuesEqual(value, right[index]));
1585
+ }
1586
+ if (!isJsonObject(left) || !isJsonObject(right)) return false;
1587
+ const leftKeys = Object.keys(left);
1588
+ const rightKeys = Object.keys(right);
1589
+ return leftKeys.length === rightKeys.length && leftKeys.every((key) => Object.hasOwn(right, key) && jsonValuesEqual(left[key], right[key]));
1590
+ }
1591
+ function matchesSubmissionPageFilters(submission, options) {
1592
+ if (options.filter !== void 0 && !options.filter(submission)) return false;
1593
+ if (options.metadataFilters === void 0) return true;
1594
+ return Object.entries(options.metadataFilters).every(
1595
+ ([key, value]) => jsonValuesEqual(submission.metadata?.[key], value)
1596
+ );
1597
+ }
1598
+
1226
1599
  // src/validation.ts
1227
1600
  var DEFAULT_MESSAGES = {
1228
1601
  required: "validation.required",
@@ -1658,23 +2031,161 @@ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocal
1658
2031
  );
1659
2032
  return resolveLocalizedSchema(populated.schema, targetLocale);
1660
2033
  }
2034
+
2035
+ // src/versioning.ts
2036
+ function revisionConflict(state, expectedRevision) {
2037
+ return expectedRevision === void 0 || expectedRevision === state.revision ? void 0 : {
2038
+ success: false,
2039
+ error: { type: "revision_conflict", expectedRevision, actualRevision: state.revision }
2040
+ };
2041
+ }
2042
+ function validateState(state) {
2043
+ if (state.formId.trim().length === 0) throw new TypeError("formId must not be empty.");
2044
+ if (!Number.isSafeInteger(state.nextVersion) || state.nextVersion < 1) {
2045
+ throw new TypeError("nextVersion must be a positive safe integer.");
2046
+ }
2047
+ if (!Number.isSafeInteger(state.revision) || state.revision < 0) {
2048
+ throw new TypeError("revision must be a non-negative safe integer.");
2049
+ }
2050
+ }
2051
+ function cloneVersionToDraft(state, sourceSchema, options = {}) {
2052
+ validateState(state);
2053
+ if (sourceSchema.id !== state.formId) throw new TypeError("sourceSchema.id must match state.formId.");
2054
+ const conflict = revisionConflict(state, options.expectedRevision);
2055
+ if (conflict !== void 0) return conflict;
2056
+ if (state.draftVersion !== void 0) {
2057
+ return { success: false, error: { type: "draft_already_exists", currentDraftVersion: state.draftVersion } };
2058
+ }
2059
+ const allowedSourceVersions = /* @__PURE__ */ new Set([
2060
+ ...state.publishedVersion === void 0 ? [] : [state.publishedVersion],
2061
+ ...options.allowedSourceVersions ?? []
2062
+ ]);
2063
+ if (!allowedSourceVersions.has(sourceSchema.version)) {
2064
+ return {
2065
+ success: false,
2066
+ error: {
2067
+ type: "invalid_source_version",
2068
+ requestedVersion: sourceSchema.version,
2069
+ ...state.publishedVersion === void 0 ? {} : { publishedVersion: state.publishedVersion }
2070
+ }
2071
+ };
2072
+ }
2073
+ const maxVersions = options.maxVersions ?? Number.MAX_SAFE_INTEGER;
2074
+ if (!Number.isSafeInteger(maxVersions) || maxVersions < 1) {
2075
+ throw new TypeError("maxVersions must be a positive safe integer.");
2076
+ }
2077
+ if (state.nextVersion > maxVersions) {
2078
+ return { success: false, error: { type: "max_version_exceeded", max: maxVersions } };
2079
+ }
2080
+ const version = state.nextVersion;
2081
+ return {
2082
+ success: true,
2083
+ value: {
2084
+ nextState: {
2085
+ ...state,
2086
+ draftVersion: version,
2087
+ nextVersion: version + 1,
2088
+ revision: state.revision + 1
2089
+ },
2090
+ draftSchema: { ...sourceSchema, version }
2091
+ }
2092
+ };
2093
+ }
2094
+ function publishDraft(state, draftSchema, options = {}) {
2095
+ validateState(state);
2096
+ const conflict = revisionConflict(state, options.expectedRevision);
2097
+ if (conflict !== void 0) return conflict;
2098
+ if (state.draftVersion === void 0 || draftSchema.id !== state.formId || draftSchema.version !== state.draftVersion) {
2099
+ return { success: false, error: { type: "draft_not_found" } };
2100
+ }
2101
+ const validation = options.validate?.(draftSchema);
2102
+ if (validation === false || Array.isArray(validation) && validation.length > 0) {
2103
+ return {
2104
+ success: false,
2105
+ error: { type: "validation_failed", issues: Array.isArray(validation) ? validation : [] }
2106
+ };
2107
+ }
2108
+ const timestamp = options.timestamp ?? "1970-01-01T00:00:00.000Z";
2109
+ if (!Number.isFinite(Date.parse(timestamp))) throw new TypeError("timestamp must be a valid date string.");
2110
+ const archivedVersion = state.publishedVersion;
2111
+ const archivedRecords = archivedVersion === void 0 ? [] : [
2112
+ {
2113
+ formId: state.formId,
2114
+ version: archivedVersion,
2115
+ status: "archived",
2116
+ schema: { ...draftSchema, version: archivedVersion },
2117
+ createdAt: timestamp,
2118
+ publishedAt: timestamp,
2119
+ archivedAt: timestamp
2120
+ }
2121
+ ];
2122
+ const { draftVersion: _draftVersion, ...stateWithoutDraft } = state;
2123
+ return {
2124
+ success: true,
2125
+ value: {
2126
+ nextState: {
2127
+ ...stateWithoutDraft,
2128
+ publishedVersion: draftSchema.version,
2129
+ revision: state.revision + 1
2130
+ },
2131
+ publishedRecord: {
2132
+ formId: state.formId,
2133
+ version: draftSchema.version,
2134
+ status: "published",
2135
+ schema: draftSchema,
2136
+ createdAt: timestamp,
2137
+ publishedAt: timestamp
2138
+ },
2139
+ archivedRecords,
2140
+ ...archivedVersion === void 0 ? {} : { archivedVersion }
2141
+ }
2142
+ };
2143
+ }
2144
+ function deleteDraft(state, options = {}) {
2145
+ validateState(state);
2146
+ const conflict = revisionConflict(state, options.expectedRevision);
2147
+ if (conflict !== void 0) return conflict;
2148
+ if (state.draftVersion === void 0) return { success: false, error: { type: "draft_not_found" } };
2149
+ const { draftVersion: _draftVersion, ...stateWithoutDraft } = state;
2150
+ return {
2151
+ success: true,
2152
+ value: { nextState: { ...stateWithoutDraft, revision: state.revision + 1 } }
2153
+ };
2154
+ }
2155
+ function assertVersionMutable(status) {
2156
+ if (status !== "draft") {
2157
+ const error = { type: "version_immutable", status };
2158
+ throw new TypeError(`A ${status} form version is immutable.`, { cause: error });
2159
+ }
2160
+ }
1661
2161
  // Annotate the CommonJS export names for ESM import in node:
1662
2162
  0 && (module.exports = {
1663
2163
  aggregateResponses,
1664
2164
  assertValidFormSchema,
2165
+ assertVersionMutable,
1665
2166
  calculateChoiceDistribution,
1666
2167
  calculateCrossTabulation,
1667
2168
  calculateFieldVisibility,
1668
2169
  calculateNumericSummary,
1669
2170
  calculatePageVisibility,
2171
+ cloneVersionToDraft,
1670
2172
  collectSchemaLocales,
2173
+ createResponseAccumulator,
1671
2174
  createSubmission,
2175
+ decodeSubmissionCursor,
2176
+ deleteDraft,
1672
2177
  dispatchWebhook,
2178
+ encodeSubmissionCursor,
1673
2179
  escapeCsvCell,
1674
2180
  exportResponsesToCsv,
2181
+ exportResponsesToCsvStream,
1675
2182
  isDisplayConditionSatisfied,
1676
2183
  isQuestionVisible,
2184
+ matchesSubmissionPageFilters,
2185
+ normalizeSubmissionPageSize,
2186
+ pipeResponsesToCsvStream,
1677
2187
  populateSchemaTranslations,
2188
+ publishDraft,
1678
2189
  resolveFormTranslation,
1679
2190
  resolveLocalizedSchema,
1680
2191
  sanitizeSchema,