@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/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,
|