@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 +20 -0
- package/dist/index.cjs +511 -0
- package/dist/index.d.cts +169 -1
- package/dist/index.d.ts +169 -1
- package/dist/index.js +500 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1042,6 +1042,188 @@ function aggregateResponses(schema, submissions) {
|
|
|
1042
1042
|
questions: schema.fields.map((field) => aggregateField(schema, field, submissions))
|
|
1043
1043
|
};
|
|
1044
1044
|
}
|
|
1045
|
+
function responseValues(submission) {
|
|
1046
|
+
if (typeof submission !== "object" || submission === null) return void 0;
|
|
1047
|
+
if ("values" in submission) return submission.values;
|
|
1048
|
+
return "answers" in submission ? submission.answers : void 0;
|
|
1049
|
+
}
|
|
1050
|
+
function responseIdentifier(submission) {
|
|
1051
|
+
if (typeof submission !== "object" || submission === null) return "<unknown>";
|
|
1052
|
+
if ("id" in submission && typeof submission.id === "string" && submission.id.length > 0) return submission.id;
|
|
1053
|
+
if ("responseId" in submission && typeof submission.responseId === "string" && submission.responseId.length > 0) {
|
|
1054
|
+
return submission.responseId;
|
|
1055
|
+
}
|
|
1056
|
+
return "<unknown>";
|
|
1057
|
+
}
|
|
1058
|
+
function isAnswerRecord(value) {
|
|
1059
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1060
|
+
}
|
|
1061
|
+
function responseProblem(schema, submission) {
|
|
1062
|
+
if (typeof submission !== "object" || submission === null) {
|
|
1063
|
+
return { reason: "invalid_structure", error: "Submission structure is invalid." };
|
|
1064
|
+
}
|
|
1065
|
+
const identifier = responseIdentifier(submission);
|
|
1066
|
+
const values = responseValues(submission);
|
|
1067
|
+
if (typeof identifier !== "string" || identifier.length === 0 || typeof submission.formId !== "string" || typeof submission.submittedAt !== "string" || !isAnswerRecord(values)) {
|
|
1068
|
+
return { reason: "invalid_structure", error: `Submission ${String(identifier)} structure is invalid.` };
|
|
1069
|
+
}
|
|
1070
|
+
if (submission.formId !== schema.id) {
|
|
1071
|
+
return { reason: "form_id_mismatch", error: `Submission ${identifier} does not match form ${schema.id}.` };
|
|
1072
|
+
}
|
|
1073
|
+
if ("formVersion" in submission && submission.formVersion !== void 0 && submission.formVersion !== schema.version) {
|
|
1074
|
+
return {
|
|
1075
|
+
reason: "version_mismatch",
|
|
1076
|
+
error: `Submission ${identifier} does not match ${schema.id}@${schema.version}.`
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
return void 0;
|
|
1080
|
+
}
|
|
1081
|
+
var IncrementalResponseAccumulator = class _IncrementalResponseAccumulator {
|
|
1082
|
+
#schema;
|
|
1083
|
+
#mode;
|
|
1084
|
+
#fields;
|
|
1085
|
+
#skipReasons = [];
|
|
1086
|
+
#submissionCount = 0;
|
|
1087
|
+
constructor(schema, options) {
|
|
1088
|
+
assertValidFormSchema(schema);
|
|
1089
|
+
this.#schema = JSON.parse(JSON.stringify(schema));
|
|
1090
|
+
this.#mode = options.mode ?? "strict";
|
|
1091
|
+
this.#fields = new Map(
|
|
1092
|
+
schema.fields.map((field) => [
|
|
1093
|
+
field.id,
|
|
1094
|
+
{
|
|
1095
|
+
answeredCount: 0,
|
|
1096
|
+
total: 0,
|
|
1097
|
+
minimum: null,
|
|
1098
|
+
maximum: null,
|
|
1099
|
+
trueCount: 0,
|
|
1100
|
+
falseCount: 0,
|
|
1101
|
+
optionCounts: new Map("options" in field ? field.options.map((option) => [option.id, 0]) : [])
|
|
1102
|
+
}
|
|
1103
|
+
])
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
add(submission) {
|
|
1107
|
+
const problem = responseProblem(this.#schema, submission);
|
|
1108
|
+
if (problem !== void 0) {
|
|
1109
|
+
if (this.#mode === "strict") return { success: false, error: problem.error };
|
|
1110
|
+
this.#skipReasons.push({ responseId: responseIdentifier(submission), reason: problem.reason });
|
|
1111
|
+
return { success: true, skipped: true };
|
|
1112
|
+
}
|
|
1113
|
+
const values = responseValues(submission);
|
|
1114
|
+
if (!isAnswerRecord(values)) throw new Error("Validated response answers are unavailable.");
|
|
1115
|
+
const visibility = calculateFieldVisibility(this.#schema, values);
|
|
1116
|
+
for (const field of this.#schema.fields) {
|
|
1117
|
+
const accumulator = this.#fields.get(field.id);
|
|
1118
|
+
if (accumulator === void 0) throw new Error(`Accumulator for ${field.id} is unavailable.`);
|
|
1119
|
+
const candidate = values[field.id];
|
|
1120
|
+
if (visibility[field.id] !== true || !valueIsValid(field, candidate)) continue;
|
|
1121
|
+
accumulator.answeredCount += 1;
|
|
1122
|
+
if ((field.type === "number" || field.type === "rating") && typeof candidate === "number") {
|
|
1123
|
+
accumulator.total += candidate;
|
|
1124
|
+
accumulator.minimum = accumulator.minimum === null ? candidate : Math.min(accumulator.minimum, candidate);
|
|
1125
|
+
accumulator.maximum = accumulator.maximum === null ? candidate : Math.max(accumulator.maximum, candidate);
|
|
1126
|
+
} else if (field.type === "checkbox") {
|
|
1127
|
+
if (candidate === true) accumulator.trueCount += 1;
|
|
1128
|
+
if (candidate === false) accumulator.falseCount += 1;
|
|
1129
|
+
} else if ("options" in field) {
|
|
1130
|
+
const selections = Array.isArray(candidate) ? candidate : typeof candidate === "string" ? [candidate] : [];
|
|
1131
|
+
for (const selection of selections) {
|
|
1132
|
+
accumulator.optionCounts.set(selection, (accumulator.optionCounts.get(selection) ?? 0) + 1);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
this.#submissionCount += 1;
|
|
1137
|
+
return { success: true };
|
|
1138
|
+
}
|
|
1139
|
+
addMany(submissions) {
|
|
1140
|
+
for (const submission of submissions) {
|
|
1141
|
+
const result = this.add(submission);
|
|
1142
|
+
if (!result.success) throw new TypeError(result.error ?? "Submission could not be accumulated.");
|
|
1143
|
+
}
|
|
1144
|
+
return this.getReport();
|
|
1145
|
+
}
|
|
1146
|
+
merge(other) {
|
|
1147
|
+
if (!(other instanceof _IncrementalResponseAccumulator)) {
|
|
1148
|
+
throw new TypeError("Only form-engine response accumulators can be merged.");
|
|
1149
|
+
}
|
|
1150
|
+
if (other.#schema.id !== this.#schema.id || other.#schema.version !== this.#schema.version || JSON.stringify(other.#schema.fields) !== JSON.stringify(this.#schema.fields)) {
|
|
1151
|
+
throw new TypeError("Response accumulators must use the same schema.");
|
|
1152
|
+
}
|
|
1153
|
+
this.#submissionCount += other.#submissionCount;
|
|
1154
|
+
this.#skipReasons.push(...other.#skipReasons);
|
|
1155
|
+
for (const [fieldId, source] of other.#fields) {
|
|
1156
|
+
const target = this.#fields.get(fieldId);
|
|
1157
|
+
if (target === void 0) throw new Error(`Accumulator for ${fieldId} is unavailable.`);
|
|
1158
|
+
target.answeredCount += source.answeredCount;
|
|
1159
|
+
target.total += source.total;
|
|
1160
|
+
target.minimum = target.minimum === null ? source.minimum : source.minimum === null ? target.minimum : Math.min(target.minimum, source.minimum);
|
|
1161
|
+
target.maximum = target.maximum === null ? source.maximum : source.maximum === null ? target.maximum : Math.max(target.maximum, source.maximum);
|
|
1162
|
+
target.trueCount += source.trueCount;
|
|
1163
|
+
target.falseCount += source.falseCount;
|
|
1164
|
+
for (const [optionId, count] of source.optionCounts) {
|
|
1165
|
+
target.optionCounts.set(optionId, (target.optionCounts.get(optionId) ?? 0) + count);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
return this;
|
|
1169
|
+
}
|
|
1170
|
+
getReport() {
|
|
1171
|
+
return {
|
|
1172
|
+
processedCount: this.#submissionCount,
|
|
1173
|
+
skippedCount: this.#skipReasons.length,
|
|
1174
|
+
skipReasons: this.#skipReasons.map((reason) => ({ ...reason }))
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
finalize() {
|
|
1178
|
+
return {
|
|
1179
|
+
formId: this.#schema.id,
|
|
1180
|
+
formVersion: this.#schema.version,
|
|
1181
|
+
submissionCount: this.#submissionCount,
|
|
1182
|
+
questions: this.#schema.fields.map((field) => {
|
|
1183
|
+
const accumulator = this.#fields.get(field.id);
|
|
1184
|
+
if (accumulator === void 0) throw new Error(`Accumulator for ${field.id} is unavailable.`);
|
|
1185
|
+
const base = {
|
|
1186
|
+
fieldId: field.id,
|
|
1187
|
+
answeredCount: accumulator.answeredCount,
|
|
1188
|
+
unansweredCount: this.#submissionCount - accumulator.answeredCount
|
|
1189
|
+
};
|
|
1190
|
+
if (field.type === "text" || field.type === "textarea") return { ...base, kind: field.type };
|
|
1191
|
+
if (field.type === "number" || field.type === "rating") {
|
|
1192
|
+
return {
|
|
1193
|
+
...base,
|
|
1194
|
+
kind: field.type,
|
|
1195
|
+
minimum: accumulator.minimum,
|
|
1196
|
+
maximum: accumulator.maximum,
|
|
1197
|
+
average: accumulator.answeredCount === 0 ? null : accumulator.total / accumulator.answeredCount,
|
|
1198
|
+
total: accumulator.total
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
if (field.type === "checkbox") {
|
|
1202
|
+
return {
|
|
1203
|
+
...base,
|
|
1204
|
+
kind: "checkbox",
|
|
1205
|
+
trueCount: accumulator.trueCount,
|
|
1206
|
+
falseCount: accumulator.falseCount,
|
|
1207
|
+
truePercentageOfSubmissions: percentage(accumulator.trueCount, this.#submissionCount),
|
|
1208
|
+
falsePercentageOfSubmissions: percentage(accumulator.falseCount, this.#submissionCount)
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
if (!("options" in field)) throw new TypeError(`Field ${field.id} cannot be aggregated.`);
|
|
1212
|
+
return {
|
|
1213
|
+
...base,
|
|
1214
|
+
kind: field.type,
|
|
1215
|
+
options: field.options.map((option) => {
|
|
1216
|
+
const count = accumulator.optionCounts.get(option.id) ?? 0;
|
|
1217
|
+
return { id: option.id, count, percentageOfSubmissions: percentage(count, this.#submissionCount) };
|
|
1218
|
+
})
|
|
1219
|
+
};
|
|
1220
|
+
})
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
function createResponseAccumulator(schema, options = {}) {
|
|
1225
|
+
return new IncrementalResponseAccumulator(schema, options);
|
|
1226
|
+
}
|
|
1045
1227
|
function escapeCsvCell(value, neutralizeFormulas = true) {
|
|
1046
1228
|
if (value === null || value === void 0) return "";
|
|
1047
1229
|
let stringValue = String(value);
|
|
@@ -1057,6 +1239,105 @@ function serializeValue(value) {
|
|
|
1057
1239
|
if (value === void 0) return "";
|
|
1058
1240
|
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value);
|
|
1059
1241
|
}
|
|
1242
|
+
function asFormResponse(submission) {
|
|
1243
|
+
if (!("values" in submission)) return submission;
|
|
1244
|
+
return {
|
|
1245
|
+
responseId: submission.id,
|
|
1246
|
+
formId: submission.formId,
|
|
1247
|
+
sourceLocale: submission.locale,
|
|
1248
|
+
formVersion: submission.formVersion,
|
|
1249
|
+
answers: submission.values,
|
|
1250
|
+
submittedAt: submission.submittedAt,
|
|
1251
|
+
...submission.metadata === void 0 ? {} : { metadata: submission.metadata },
|
|
1252
|
+
...submission.translationMetadata === void 0 ? {} : { translationMetadata: submission.translationMetadata }
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
function serializeUnknown(value) {
|
|
1256
|
+
if (value === null || value === void 0) return "";
|
|
1257
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
|
1258
|
+
return JSON.stringify(value);
|
|
1259
|
+
}
|
|
1260
|
+
async function* exportResponsesToCsvStream(schema, submissions, options = {}) {
|
|
1261
|
+
assertValidFormSchema(schema);
|
|
1262
|
+
const includeDefaultColumns = options.includeDefaultColumns ?? true;
|
|
1263
|
+
const customColumns = options.columns ?? [];
|
|
1264
|
+
const headers = [
|
|
1265
|
+
...includeDefaultColumns ? ["submissionId", "submittedAt", "locale", ...schema.fields.map((field) => field.id)] : [],
|
|
1266
|
+
...customColumns.map((column) => column.header)
|
|
1267
|
+
];
|
|
1268
|
+
const neutralizeFormulas = options.neutralizeFormulas ?? true;
|
|
1269
|
+
const header = headers.map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",");
|
|
1270
|
+
yield `${options.withBom ?? true ? "\uFEFF" : ""}${header}`;
|
|
1271
|
+
for await (const submission of submissions) {
|
|
1272
|
+
const problem = responseProblem(schema, submission);
|
|
1273
|
+
if (problem !== void 0) throw new TypeError(problem.error);
|
|
1274
|
+
const response = asFormResponse(submission);
|
|
1275
|
+
const answers = response.answers;
|
|
1276
|
+
const visible = selectVisibleAnswers(schema, answers);
|
|
1277
|
+
const defaultCells = includeDefaultColumns ? [
|
|
1278
|
+
response.responseId,
|
|
1279
|
+
response.submittedAt,
|
|
1280
|
+
response.sourceLocale ?? "",
|
|
1281
|
+
...schema.fields.map((field) => serializeUnknown(visible[field.id]))
|
|
1282
|
+
] : [];
|
|
1283
|
+
const context = {
|
|
1284
|
+
...response,
|
|
1285
|
+
submission: response,
|
|
1286
|
+
formVersion: response.formVersion ?? schema.version,
|
|
1287
|
+
schema
|
|
1288
|
+
};
|
|
1289
|
+
const customCells = customColumns.map((column) => column.getValue(context));
|
|
1290
|
+
yield `\r
|
|
1291
|
+
${[...defaultCells, ...customCells].map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",")}`;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
function isWebWritableStream(writable) {
|
|
1295
|
+
return "getWriter" in writable && typeof writable.getWriter === "function";
|
|
1296
|
+
}
|
|
1297
|
+
async function writeNodeChunk(writable, chunk, streamError) {
|
|
1298
|
+
if (writable.write(chunk)) return;
|
|
1299
|
+
let onDrain;
|
|
1300
|
+
const drain = new Promise((resolve) => {
|
|
1301
|
+
onDrain = resolve;
|
|
1302
|
+
writable.once("drain", resolve);
|
|
1303
|
+
});
|
|
1304
|
+
try {
|
|
1305
|
+
await Promise.race([drain, streamError]);
|
|
1306
|
+
} finally {
|
|
1307
|
+
if (onDrain !== void 0) writable.removeListener("drain", onDrain);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
async function pipeResponsesToCsvStream(schema, submissions, writable, options = {}) {
|
|
1311
|
+
const encoder = new TextEncoder();
|
|
1312
|
+
if (isWebWritableStream(writable)) {
|
|
1313
|
+
const writer = writable.getWriter();
|
|
1314
|
+
try {
|
|
1315
|
+
for await (const chunk of exportResponsesToCsvStream(schema, submissions, options)) {
|
|
1316
|
+
await writer.write(encoder.encode(chunk));
|
|
1317
|
+
}
|
|
1318
|
+
await writer.close();
|
|
1319
|
+
} catch (cause) {
|
|
1320
|
+
await writer.abort(cause);
|
|
1321
|
+
throw cause;
|
|
1322
|
+
} finally {
|
|
1323
|
+
writer.releaseLock();
|
|
1324
|
+
}
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
let onStreamError;
|
|
1328
|
+
const streamError = new Promise((_resolve, reject) => {
|
|
1329
|
+
onStreamError = reject;
|
|
1330
|
+
writable.once("error", reject);
|
|
1331
|
+
});
|
|
1332
|
+
try {
|
|
1333
|
+
for await (const chunk of exportResponsesToCsvStream(schema, submissions, options)) {
|
|
1334
|
+
await writeNodeChunk(writable, encoder.encode(chunk), streamError);
|
|
1335
|
+
}
|
|
1336
|
+
await Promise.race([new Promise((resolve) => writable.end(resolve)), streamError]);
|
|
1337
|
+
} finally {
|
|
1338
|
+
if (onStreamError !== void 0) writable.removeListener("error", onStreamError);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1060
1341
|
function exportResponsesToCsv(schema, responses, options = {}) {
|
|
1061
1342
|
assertValidFormSchema(schema);
|
|
1062
1343
|
for (const response of responses) {
|
|
@@ -1174,6 +1455,87 @@ function transformFieldType(field, nextType) {
|
|
|
1174
1455
|
return { ...common, type: nextType, options };
|
|
1175
1456
|
}
|
|
1176
1457
|
|
|
1458
|
+
// src/pagination.ts
|
|
1459
|
+
var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
1460
|
+
function encodeBase64(bytes) {
|
|
1461
|
+
let result = "";
|
|
1462
|
+
for (let index = 0; index < bytes.length; index += 3) {
|
|
1463
|
+
const first = bytes[index] ?? 0;
|
|
1464
|
+
const second = bytes[index + 1] ?? 0;
|
|
1465
|
+
const third = bytes[index + 2] ?? 0;
|
|
1466
|
+
const combined = first << 16 | second << 8 | third;
|
|
1467
|
+
result += BASE64_ALPHABET[combined >> 18 & 63] ?? "";
|
|
1468
|
+
result += BASE64_ALPHABET[combined >> 12 & 63] ?? "";
|
|
1469
|
+
result += index + 1 < bytes.length ? BASE64_ALPHABET[combined >> 6 & 63] ?? "" : "=";
|
|
1470
|
+
result += index + 2 < bytes.length ? BASE64_ALPHABET[combined & 63] ?? "" : "=";
|
|
1471
|
+
}
|
|
1472
|
+
return result;
|
|
1473
|
+
}
|
|
1474
|
+
function decodeBase64(value) {
|
|
1475
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
|
1476
|
+
throw new TypeError("cursor must be a valid Base64 token.");
|
|
1477
|
+
}
|
|
1478
|
+
const bytes = [];
|
|
1479
|
+
for (let index = 0; index < value.length; index += 4) {
|
|
1480
|
+
const characters = value.slice(index, index + 4);
|
|
1481
|
+
const sextets = [...characters].map((character) => character === "=" ? 0 : BASE64_ALPHABET.indexOf(character));
|
|
1482
|
+
const combined = (sextets[0] ?? 0) << 18 | (sextets[1] ?? 0) << 12 | (sextets[2] ?? 0) << 6 | (sextets[3] ?? 0);
|
|
1483
|
+
bytes.push(combined >> 16 & 255);
|
|
1484
|
+
if (characters[2] !== "=") bytes.push(combined >> 8 & 255);
|
|
1485
|
+
if (characters[3] !== "=") bytes.push(combined & 255);
|
|
1486
|
+
}
|
|
1487
|
+
return new Uint8Array(bytes);
|
|
1488
|
+
}
|
|
1489
|
+
function encodeSubmissionCursor(value) {
|
|
1490
|
+
if (value.submittedAt.length === 0 || value.responseId.length === 0) {
|
|
1491
|
+
throw new TypeError("Cursor values must not be empty.");
|
|
1492
|
+
}
|
|
1493
|
+
return encodeBase64(new TextEncoder().encode(JSON.stringify(value)));
|
|
1494
|
+
}
|
|
1495
|
+
function decodeSubmissionCursor(cursor) {
|
|
1496
|
+
try {
|
|
1497
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64(cursor)));
|
|
1498
|
+
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) {
|
|
1499
|
+
throw new TypeError("cursor payload is invalid.");
|
|
1500
|
+
}
|
|
1501
|
+
return { submittedAt: parsed.submittedAt, responseId: parsed.responseId };
|
|
1502
|
+
} catch (cause) {
|
|
1503
|
+
if (cause instanceof TypeError && cause.message === "cursor payload is invalid.") throw cause;
|
|
1504
|
+
throw new TypeError("cursor must be a valid form-engine cursor.", { cause });
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
function normalizeSubmissionPageSize(pageSize, fallback = 100) {
|
|
1508
|
+
const value = pageSize ?? fallback;
|
|
1509
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("pageSize must be a positive safe integer.");
|
|
1510
|
+
return value;
|
|
1511
|
+
}
|
|
1512
|
+
function isJsonArray(value) {
|
|
1513
|
+
return Array.isArray(value);
|
|
1514
|
+
}
|
|
1515
|
+
function isJsonObject(value) {
|
|
1516
|
+
return typeof value === "object" && value !== null && !isJsonArray(value);
|
|
1517
|
+
}
|
|
1518
|
+
function jsonValuesEqual(left, right) {
|
|
1519
|
+
if (left === right) return true;
|
|
1520
|
+
if (left === void 0 || left === null || right === void 0 || right === null || typeof left !== typeof right) {
|
|
1521
|
+
return false;
|
|
1522
|
+
}
|
|
1523
|
+
if (isJsonArray(left) || isJsonArray(right)) {
|
|
1524
|
+
return isJsonArray(left) && isJsonArray(right) && left.length === right.length && left.every((value, index) => jsonValuesEqual(value, right[index]));
|
|
1525
|
+
}
|
|
1526
|
+
if (!isJsonObject(left) || !isJsonObject(right)) return false;
|
|
1527
|
+
const leftKeys = Object.keys(left);
|
|
1528
|
+
const rightKeys = Object.keys(right);
|
|
1529
|
+
return leftKeys.length === rightKeys.length && leftKeys.every((key) => Object.hasOwn(right, key) && jsonValuesEqual(left[key], right[key]));
|
|
1530
|
+
}
|
|
1531
|
+
function matchesSubmissionPageFilters(submission, options) {
|
|
1532
|
+
if (options.filter !== void 0 && !options.filter(submission)) return false;
|
|
1533
|
+
if (options.metadataFilters === void 0) return true;
|
|
1534
|
+
return Object.entries(options.metadataFilters).every(
|
|
1535
|
+
([key, value]) => jsonValuesEqual(submission.metadata?.[key], value)
|
|
1536
|
+
);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1177
1539
|
// src/validation.ts
|
|
1178
1540
|
var DEFAULT_MESSAGES = {
|
|
1179
1541
|
required: "validation.required",
|
|
@@ -1609,22 +1971,160 @@ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocal
|
|
|
1609
1971
|
);
|
|
1610
1972
|
return resolveLocalizedSchema(populated.schema, targetLocale);
|
|
1611
1973
|
}
|
|
1974
|
+
|
|
1975
|
+
// src/versioning.ts
|
|
1976
|
+
function revisionConflict(state, expectedRevision) {
|
|
1977
|
+
return expectedRevision === void 0 || expectedRevision === state.revision ? void 0 : {
|
|
1978
|
+
success: false,
|
|
1979
|
+
error: { type: "revision_conflict", expectedRevision, actualRevision: state.revision }
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
function validateState(state) {
|
|
1983
|
+
if (state.formId.trim().length === 0) throw new TypeError("formId must not be empty.");
|
|
1984
|
+
if (!Number.isSafeInteger(state.nextVersion) || state.nextVersion < 1) {
|
|
1985
|
+
throw new TypeError("nextVersion must be a positive safe integer.");
|
|
1986
|
+
}
|
|
1987
|
+
if (!Number.isSafeInteger(state.revision) || state.revision < 0) {
|
|
1988
|
+
throw new TypeError("revision must be a non-negative safe integer.");
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
function cloneVersionToDraft(state, sourceSchema, options = {}) {
|
|
1992
|
+
validateState(state);
|
|
1993
|
+
if (sourceSchema.id !== state.formId) throw new TypeError("sourceSchema.id must match state.formId.");
|
|
1994
|
+
const conflict = revisionConflict(state, options.expectedRevision);
|
|
1995
|
+
if (conflict !== void 0) return conflict;
|
|
1996
|
+
if (state.draftVersion !== void 0) {
|
|
1997
|
+
return { success: false, error: { type: "draft_already_exists", currentDraftVersion: state.draftVersion } };
|
|
1998
|
+
}
|
|
1999
|
+
const allowedSourceVersions = /* @__PURE__ */ new Set([
|
|
2000
|
+
...state.publishedVersion === void 0 ? [] : [state.publishedVersion],
|
|
2001
|
+
...options.allowedSourceVersions ?? []
|
|
2002
|
+
]);
|
|
2003
|
+
if (!allowedSourceVersions.has(sourceSchema.version)) {
|
|
2004
|
+
return {
|
|
2005
|
+
success: false,
|
|
2006
|
+
error: {
|
|
2007
|
+
type: "invalid_source_version",
|
|
2008
|
+
requestedVersion: sourceSchema.version,
|
|
2009
|
+
...state.publishedVersion === void 0 ? {} : { publishedVersion: state.publishedVersion }
|
|
2010
|
+
}
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
const maxVersions = options.maxVersions ?? Number.MAX_SAFE_INTEGER;
|
|
2014
|
+
if (!Number.isSafeInteger(maxVersions) || maxVersions < 1) {
|
|
2015
|
+
throw new TypeError("maxVersions must be a positive safe integer.");
|
|
2016
|
+
}
|
|
2017
|
+
if (state.nextVersion > maxVersions) {
|
|
2018
|
+
return { success: false, error: { type: "max_version_exceeded", max: maxVersions } };
|
|
2019
|
+
}
|
|
2020
|
+
const version = state.nextVersion;
|
|
2021
|
+
return {
|
|
2022
|
+
success: true,
|
|
2023
|
+
value: {
|
|
2024
|
+
nextState: {
|
|
2025
|
+
...state,
|
|
2026
|
+
draftVersion: version,
|
|
2027
|
+
nextVersion: version + 1,
|
|
2028
|
+
revision: state.revision + 1
|
|
2029
|
+
},
|
|
2030
|
+
draftSchema: { ...sourceSchema, version }
|
|
2031
|
+
}
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
function publishDraft(state, draftSchema, options = {}) {
|
|
2035
|
+
validateState(state);
|
|
2036
|
+
const conflict = revisionConflict(state, options.expectedRevision);
|
|
2037
|
+
if (conflict !== void 0) return conflict;
|
|
2038
|
+
if (state.draftVersion === void 0 || draftSchema.id !== state.formId || draftSchema.version !== state.draftVersion) {
|
|
2039
|
+
return { success: false, error: { type: "draft_not_found" } };
|
|
2040
|
+
}
|
|
2041
|
+
const validation = options.validate?.(draftSchema);
|
|
2042
|
+
if (validation === false || Array.isArray(validation) && validation.length > 0) {
|
|
2043
|
+
return {
|
|
2044
|
+
success: false,
|
|
2045
|
+
error: { type: "validation_failed", issues: Array.isArray(validation) ? validation : [] }
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
2048
|
+
const timestamp = options.timestamp ?? "1970-01-01T00:00:00.000Z";
|
|
2049
|
+
if (!Number.isFinite(Date.parse(timestamp))) throw new TypeError("timestamp must be a valid date string.");
|
|
2050
|
+
const archivedVersion = state.publishedVersion;
|
|
2051
|
+
const archivedRecords = archivedVersion === void 0 ? [] : [
|
|
2052
|
+
{
|
|
2053
|
+
formId: state.formId,
|
|
2054
|
+
version: archivedVersion,
|
|
2055
|
+
status: "archived",
|
|
2056
|
+
schema: { ...draftSchema, version: archivedVersion },
|
|
2057
|
+
createdAt: timestamp,
|
|
2058
|
+
publishedAt: timestamp,
|
|
2059
|
+
archivedAt: timestamp
|
|
2060
|
+
}
|
|
2061
|
+
];
|
|
2062
|
+
const { draftVersion: _draftVersion, ...stateWithoutDraft } = state;
|
|
2063
|
+
return {
|
|
2064
|
+
success: true,
|
|
2065
|
+
value: {
|
|
2066
|
+
nextState: {
|
|
2067
|
+
...stateWithoutDraft,
|
|
2068
|
+
publishedVersion: draftSchema.version,
|
|
2069
|
+
revision: state.revision + 1
|
|
2070
|
+
},
|
|
2071
|
+
publishedRecord: {
|
|
2072
|
+
formId: state.formId,
|
|
2073
|
+
version: draftSchema.version,
|
|
2074
|
+
status: "published",
|
|
2075
|
+
schema: draftSchema,
|
|
2076
|
+
createdAt: timestamp,
|
|
2077
|
+
publishedAt: timestamp
|
|
2078
|
+
},
|
|
2079
|
+
archivedRecords,
|
|
2080
|
+
...archivedVersion === void 0 ? {} : { archivedVersion }
|
|
2081
|
+
}
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
2084
|
+
function deleteDraft(state, options = {}) {
|
|
2085
|
+
validateState(state);
|
|
2086
|
+
const conflict = revisionConflict(state, options.expectedRevision);
|
|
2087
|
+
if (conflict !== void 0) return conflict;
|
|
2088
|
+
if (state.draftVersion === void 0) return { success: false, error: { type: "draft_not_found" } };
|
|
2089
|
+
const { draftVersion: _draftVersion, ...stateWithoutDraft } = state;
|
|
2090
|
+
return {
|
|
2091
|
+
success: true,
|
|
2092
|
+
value: { nextState: { ...stateWithoutDraft, revision: state.revision + 1 } }
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
function assertVersionMutable(status) {
|
|
2096
|
+
if (status !== "draft") {
|
|
2097
|
+
const error = { type: "version_immutable", status };
|
|
2098
|
+
throw new TypeError(`A ${status} form version is immutable.`, { cause: error });
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
1612
2101
|
export {
|
|
1613
2102
|
aggregateResponses,
|
|
1614
2103
|
assertValidFormSchema,
|
|
2104
|
+
assertVersionMutable,
|
|
1615
2105
|
calculateChoiceDistribution,
|
|
1616
2106
|
calculateCrossTabulation,
|
|
1617
2107
|
calculateFieldVisibility,
|
|
1618
2108
|
calculateNumericSummary,
|
|
1619
2109
|
calculatePageVisibility,
|
|
2110
|
+
cloneVersionToDraft,
|
|
1620
2111
|
collectSchemaLocales,
|
|
2112
|
+
createResponseAccumulator,
|
|
1621
2113
|
createSubmission,
|
|
2114
|
+
decodeSubmissionCursor,
|
|
2115
|
+
deleteDraft,
|
|
1622
2116
|
dispatchWebhook,
|
|
2117
|
+
encodeSubmissionCursor,
|
|
1623
2118
|
escapeCsvCell,
|
|
1624
2119
|
exportResponsesToCsv,
|
|
2120
|
+
exportResponsesToCsvStream,
|
|
1625
2121
|
isDisplayConditionSatisfied,
|
|
1626
2122
|
isQuestionVisible,
|
|
2123
|
+
matchesSubmissionPageFilters,
|
|
2124
|
+
normalizeSubmissionPageSize,
|
|
2125
|
+
pipeResponsesToCsvStream,
|
|
1627
2126
|
populateSchemaTranslations,
|
|
2127
|
+
publishDraft,
|
|
1628
2128
|
resolveFormTranslation,
|
|
1629
2129
|
resolveLocalizedSchema,
|
|
1630
2130
|
sanitizeSchema,
|