@form-engine-ts/core 2.3.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 +12 -0
- package/dist/index.cjs +357 -0
- package/dist/index.d.cts +111 -1
- package/dist/index.d.ts +111 -1
- package/dist/index.js +348 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,4 +74,16 @@ 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
|
+
`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
|
+
|
|
77
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,19 +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,
|
|
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
|
+
normalizeSubmissionPageSize: () => normalizeSubmissionPageSize,
|
|
37
45
|
populateSchemaTranslations: () => populateSchemaTranslations,
|
|
46
|
+
publishDraft: () => publishDraft,
|
|
38
47
|
resolveFormTranslation: () => resolveFormTranslation,
|
|
39
48
|
resolveLocalizedSchema: () => resolveLocalizedSchema,
|
|
40
49
|
sanitizeSchema: () => sanitizeSchema,
|
|
@@ -1091,6 +1100,152 @@ function aggregateResponses(schema, submissions) {
|
|
|
1091
1100
|
questions: schema.fields.map((field) => aggregateField(schema, field, submissions))
|
|
1092
1101
|
};
|
|
1093
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
|
+
}
|
|
1094
1249
|
function escapeCsvCell(value, neutralizeFormulas = true) {
|
|
1095
1250
|
if (value === null || value === void 0) return "";
|
|
1096
1251
|
let stringValue = String(value);
|
|
@@ -1106,6 +1261,51 @@ function serializeValue(value) {
|
|
|
1106
1261
|
if (value === void 0) return "";
|
|
1107
1262
|
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value);
|
|
1108
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
|
+
}
|
|
1109
1309
|
function exportResponsesToCsv(schema, responses, options = {}) {
|
|
1110
1310
|
assertValidFormSchema(schema);
|
|
1111
1311
|
for (const response of responses) {
|
|
@@ -1223,6 +1423,61 @@ function transformFieldType(field, nextType) {
|
|
|
1223
1423
|
return { ...common, type: nextType, options };
|
|
1224
1424
|
}
|
|
1225
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
|
+
|
|
1226
1481
|
// src/validation.ts
|
|
1227
1482
|
var DEFAULT_MESSAGES = {
|
|
1228
1483
|
required: "validation.required",
|
|
@@ -1658,23 +1913,125 @@ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocal
|
|
|
1658
1913
|
);
|
|
1659
1914
|
return resolveLocalizedSchema(populated.schema, targetLocale);
|
|
1660
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
|
+
}
|
|
1661
2009
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1662
2010
|
0 && (module.exports = {
|
|
1663
2011
|
aggregateResponses,
|
|
1664
2012
|
assertValidFormSchema,
|
|
2013
|
+
assertVersionMutable,
|
|
1665
2014
|
calculateChoiceDistribution,
|
|
1666
2015
|
calculateCrossTabulation,
|
|
1667
2016
|
calculateFieldVisibility,
|
|
1668
2017
|
calculateNumericSummary,
|
|
1669
2018
|
calculatePageVisibility,
|
|
2019
|
+
cloneVersionToDraft,
|
|
1670
2020
|
collectSchemaLocales,
|
|
2021
|
+
createResponseAccumulator,
|
|
1671
2022
|
createSubmission,
|
|
2023
|
+
decodeSubmissionCursor,
|
|
2024
|
+
deleteDraft,
|
|
1672
2025
|
dispatchWebhook,
|
|
2026
|
+
encodeSubmissionCursor,
|
|
1673
2027
|
escapeCsvCell,
|
|
1674
2028
|
exportResponsesToCsv,
|
|
2029
|
+
exportResponsesToCsvStream,
|
|
1675
2030
|
isDisplayConditionSatisfied,
|
|
1676
2031
|
isQuestionVisible,
|
|
2032
|
+
normalizeSubmissionPageSize,
|
|
1677
2033
|
populateSchemaTranslations,
|
|
2034
|
+
publishDraft,
|
|
1678
2035
|
resolveFormTranslation,
|
|
1679
2036
|
resolveLocalizedSchema,
|
|
1680
2037
|
sanitizeSchema,
|
package/dist/index.d.cts
CHANGED
|
@@ -163,6 +163,22 @@ interface FormStorageAdapter extends StorageAdapter {
|
|
|
163
163
|
deleteSchema(formId: string, formVersion: number): Promise<void>;
|
|
164
164
|
deleteSubmission(submissionId: string): Promise<void>;
|
|
165
165
|
}
|
|
166
|
+
interface SubmissionPageQueryOptions {
|
|
167
|
+
readonly version?: number;
|
|
168
|
+
readonly cursor?: string;
|
|
169
|
+
readonly pageSize?: number;
|
|
170
|
+
readonly since?: string;
|
|
171
|
+
readonly until?: string;
|
|
172
|
+
readonly locale?: string;
|
|
173
|
+
}
|
|
174
|
+
interface SubmissionPage {
|
|
175
|
+
readonly items: readonly FormSubmission[];
|
|
176
|
+
readonly nextCursor?: string;
|
|
177
|
+
readonly hasMore: boolean;
|
|
178
|
+
}
|
|
179
|
+
interface PagedSubmissionStorageAdapter extends FormStorageAdapter {
|
|
180
|
+
listSubmissionPage(formId: string, options?: SubmissionPageQueryOptions): Promise<SubmissionPage>;
|
|
181
|
+
}
|
|
166
182
|
interface BaseQuestionAggregate {
|
|
167
183
|
readonly fieldId: string;
|
|
168
184
|
readonly answeredCount: number;
|
|
@@ -234,11 +250,34 @@ declare function calculateChoiceDistribution(responses: readonly FormSubmission[
|
|
|
234
250
|
declare function calculateNumericSummary(responses: readonly FormSubmission[], questionId: string): NumericSummary;
|
|
235
251
|
declare function calculateCrossTabulation(responses: readonly FormSubmission[], rowQuestionId: string, colQuestionId: string): CrossTabulationResult;
|
|
236
252
|
declare function aggregateResponses(schema: FormSchema, submissions: readonly FormSubmission[]): FormAnalytics;
|
|
253
|
+
type AccumulatorResponse = FormSubmission | FormResponse;
|
|
254
|
+
interface ResponseAccumulator {
|
|
255
|
+
add(submission: AccumulatorResponse): {
|
|
256
|
+
readonly success: boolean;
|
|
257
|
+
readonly error?: string;
|
|
258
|
+
};
|
|
259
|
+
addMany(submissions: Iterable<AccumulatorResponse>): void;
|
|
260
|
+
merge(other: ResponseAccumulator): ResponseAccumulator;
|
|
261
|
+
finalize(): FormAnalytics;
|
|
262
|
+
}
|
|
263
|
+
interface ResponseAccumulatorOptions {
|
|
264
|
+
readonly mode?: "strict" | "lenient";
|
|
265
|
+
}
|
|
266
|
+
declare function createResponseAccumulator(schema: FormSchema, options?: ResponseAccumulatorOptions): ResponseAccumulator;
|
|
237
267
|
declare function escapeCsvCell(value: string | number | boolean | null | undefined, neutralizeFormulas?: boolean): string;
|
|
238
268
|
interface CsvExportOptions {
|
|
239
269
|
readonly withBom?: boolean;
|
|
240
270
|
readonly neutralizeFormulas?: boolean;
|
|
241
271
|
}
|
|
272
|
+
interface CsvColumnDef {
|
|
273
|
+
readonly header: string;
|
|
274
|
+
readonly getValue: (submission: FormResponse) => string | number | boolean | null | undefined;
|
|
275
|
+
}
|
|
276
|
+
interface StreamCsvOptions extends CsvExportOptions {
|
|
277
|
+
readonly columns?: readonly CsvColumnDef[];
|
|
278
|
+
readonly includeDefaultColumns?: boolean;
|
|
279
|
+
}
|
|
280
|
+
declare function exportResponsesToCsvStream(schema: FormSchema, submissions: AsyncIterable<AccumulatorResponse>, options?: StreamCsvOptions): AsyncIterable<string>;
|
|
242
281
|
declare function exportResponsesToCsv(schema: FormSchema, responses: readonly FormSubmission[], options?: CsvExportOptions): string;
|
|
243
282
|
|
|
244
283
|
type FormEventType = "response.submitted" | "schema.updated";
|
|
@@ -268,6 +307,14 @@ declare function dispatchWebhook<T>(event: FormEvent<T>, config: WebhookConfig,
|
|
|
268
307
|
*/
|
|
269
308
|
declare function transformFieldType(field: FormField, nextType: QuestionType): FormField;
|
|
270
309
|
|
|
310
|
+
interface SubmissionCursorValue {
|
|
311
|
+
readonly submittedAt: string;
|
|
312
|
+
readonly responseId: string;
|
|
313
|
+
}
|
|
314
|
+
declare function encodeSubmissionCursor(value: SubmissionCursorValue): string;
|
|
315
|
+
declare function decodeSubmissionCursor(cursor: string): SubmissionCursorValue;
|
|
316
|
+
declare function normalizeSubmissionPageSize(pageSize: number | undefined, fallback?: number): number;
|
|
317
|
+
|
|
271
318
|
interface CollectedLocales {
|
|
272
319
|
readonly defaultLocale?: string;
|
|
273
320
|
readonly supportedLocales: readonly string[];
|
|
@@ -335,10 +382,73 @@ declare function resolveFormTranslation(schema: FormSchema, adapter: AsyncTransl
|
|
|
335
382
|
declare function validateAnswers(schema: FormSchema, values: FormValues): AnswerValidationResult;
|
|
336
383
|
declare function validatePageAnswers(schema: FormSchema, pageIndex: number, values: FormValues): AnswerValidationResult;
|
|
337
384
|
|
|
385
|
+
type Result<T, E> = {
|
|
386
|
+
readonly success: true;
|
|
387
|
+
readonly value: T;
|
|
388
|
+
} | {
|
|
389
|
+
readonly success: false;
|
|
390
|
+
readonly error: E;
|
|
391
|
+
};
|
|
392
|
+
type FormVersionStatus = "draft" | "published" | "archived";
|
|
393
|
+
interface FormVersionRecord {
|
|
394
|
+
readonly formId: string;
|
|
395
|
+
readonly version: number;
|
|
396
|
+
readonly status: FormVersionStatus;
|
|
397
|
+
readonly schema: FormSchema;
|
|
398
|
+
readonly createdAt: string;
|
|
399
|
+
readonly publishedAt?: string;
|
|
400
|
+
readonly archivedAt?: string;
|
|
401
|
+
}
|
|
402
|
+
interface FormVersionState {
|
|
403
|
+
readonly formId: string;
|
|
404
|
+
readonly draftVersion?: number;
|
|
405
|
+
readonly publishedVersion?: number;
|
|
406
|
+
readonly nextVersion: number;
|
|
407
|
+
readonly revision: number;
|
|
408
|
+
}
|
|
409
|
+
type VersionTransitionError = {
|
|
410
|
+
readonly type: "draft_already_exists";
|
|
411
|
+
readonly currentDraftVersion: number;
|
|
412
|
+
} | {
|
|
413
|
+
readonly type: "draft_not_found";
|
|
414
|
+
} | {
|
|
415
|
+
readonly type: "revision_conflict";
|
|
416
|
+
readonly expectedRevision: number;
|
|
417
|
+
readonly actualRevision: number;
|
|
418
|
+
} | {
|
|
419
|
+
readonly type: "version_immutable";
|
|
420
|
+
readonly status: FormVersionStatus;
|
|
421
|
+
} | {
|
|
422
|
+
readonly type: "max_version_exceeded";
|
|
423
|
+
readonly max: number;
|
|
424
|
+
};
|
|
425
|
+
interface CloneVersionOptions {
|
|
426
|
+
readonly maxVersions?: number;
|
|
427
|
+
}
|
|
428
|
+
interface PublishDraftOptions {
|
|
429
|
+
readonly expectedRevision?: number;
|
|
430
|
+
readonly validate?: (schema: FormSchema) => boolean;
|
|
431
|
+
/** Supplies deterministic record timestamps while keeping the transition pure. */
|
|
432
|
+
readonly timestamp?: string;
|
|
433
|
+
}
|
|
434
|
+
declare function cloneVersionToDraft(state: FormVersionState, sourceSchema: FormSchema, options?: CloneVersionOptions): Result<{
|
|
435
|
+
readonly nextState: FormVersionState;
|
|
436
|
+
readonly draftSchema: FormSchema;
|
|
437
|
+
}, VersionTransitionError>;
|
|
438
|
+
declare function publishDraft(state: FormVersionState, draftSchema: FormSchema, options?: PublishDraftOptions): Result<{
|
|
439
|
+
readonly nextState: FormVersionState;
|
|
440
|
+
readonly publishedRecord: FormVersionRecord;
|
|
441
|
+
readonly archivedVersion?: number;
|
|
442
|
+
}, VersionTransitionError>;
|
|
443
|
+
declare function deleteDraft(state: FormVersionState): Result<{
|
|
444
|
+
readonly nextState: FormVersionState;
|
|
445
|
+
}, VersionTransitionError>;
|
|
446
|
+
declare function assertVersionMutable(status: FormVersionStatus): void;
|
|
447
|
+
|
|
338
448
|
declare function isQuestionVisible(question: FormField, currentAnswers: Readonly<Record<string, unknown>>): boolean;
|
|
339
449
|
declare function isDisplayConditionSatisfied(condition: DisplayCondition | undefined, currentAnswers: Readonly<Record<string, unknown>>): boolean;
|
|
340
450
|
declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
|
|
341
451
|
declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
|
|
342
452
|
declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
|
|
343
453
|
|
|
344
|
-
export { type AnswerValidationResult, type AsyncTranslationAdapter, type BaseField, type CheckboxField, type CheckboxQuestionAggregate, type ChoiceDistributionEntry, type ChoiceOption, type ChoiceQuestionAggregate, type CollectedLocales, type ConditionOperator, type ConditionValue, type CreateSubmissionOptions, type CrossTabulationResult, type CsvExportOptions, type DisplayCondition, type ExtensibleNode, type FieldOption, type FieldType, type FormAnalytics, type FormEvent, type FormEventType, type FormField, type FormPage, type FormPolicy, type FormResponse, type FormSchema, type FormStorageAdapter, type FormSubmission, type FormValue, type FormValues, type JsonValue, type LocalizedText, type MultiSelectField, type NumberField, type NumberQuestionAggregate, type NumericSummary, type OptionAggregate, type PopulateTranslationOptions, type Question, type QuestionAggregate, type QuestionType, type RatingField, type SchemaIssue, type SchemaStructureIssue, type SchemaStructureIssueType, type SchemaTranslations, type SchemaValidationResult, type SelectField, type StorageAdapter, type SubmissionQueryOptions, type TextField, type TextQuestionAggregate, type TranslationAdapter, type TranslationReport, type TranslationSlot, type ValidateFormSchemaOptions, type ValidationCode, type ValidationError, type ValidationIssue, type WebhookConfig, type WebhookDispatchResult, aggregateResponses, assertValidFormSchema, calculateChoiceDistribution, calculateCrossTabulation, calculateFieldVisibility, calculateNumericSummary, calculatePageVisibility, collectSchemaLocales, createSubmission, dispatchWebhook, escapeCsvCell, exportResponsesToCsv, isDisplayConditionSatisfied, isQuestionVisible, populateSchemaTranslations, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, transformFieldType, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
|
|
454
|
+
export { type AccumulatorResponse, type AnswerValidationResult, type AsyncTranslationAdapter, type BaseField, type CheckboxField, type CheckboxQuestionAggregate, type ChoiceDistributionEntry, type ChoiceOption, type ChoiceQuestionAggregate, type CloneVersionOptions, type CollectedLocales, type ConditionOperator, type ConditionValue, type CreateSubmissionOptions, type CrossTabulationResult, type CsvColumnDef, type CsvExportOptions, type DisplayCondition, type ExtensibleNode, type FieldOption, type FieldType, type FormAnalytics, type FormEvent, type FormEventType, type FormField, type FormPage, type FormPolicy, type FormResponse, type FormSchema, type FormStorageAdapter, type FormSubmission, type FormValue, type FormValues, type FormVersionRecord, type FormVersionState, type FormVersionStatus, type JsonValue, type LocalizedText, type MultiSelectField, type NumberField, type NumberQuestionAggregate, type NumericSummary, type OptionAggregate, type PagedSubmissionStorageAdapter, type PopulateTranslationOptions, type PublishDraftOptions, type Question, type QuestionAggregate, type QuestionType, type RatingField, type ResponseAccumulator, type ResponseAccumulatorOptions, type Result, type SchemaIssue, type SchemaStructureIssue, type SchemaStructureIssueType, type SchemaTranslations, type SchemaValidationResult, type SelectField, type StorageAdapter, type StreamCsvOptions, type SubmissionCursorValue, type SubmissionPage, type SubmissionPageQueryOptions, type SubmissionQueryOptions, type TextField, type TextQuestionAggregate, type TranslationAdapter, type TranslationReport, type TranslationSlot, type ValidateFormSchemaOptions, type ValidationCode, type ValidationError, type ValidationIssue, type VersionTransitionError, type WebhookConfig, type WebhookDispatchResult, aggregateResponses, assertValidFormSchema, assertVersionMutable, calculateChoiceDistribution, calculateCrossTabulation, calculateFieldVisibility, calculateNumericSummary, calculatePageVisibility, cloneVersionToDraft, collectSchemaLocales, createResponseAccumulator, createSubmission, decodeSubmissionCursor, deleteDraft, dispatchWebhook, encodeSubmissionCursor, escapeCsvCell, exportResponsesToCsv, exportResponsesToCsvStream, isDisplayConditionSatisfied, isQuestionVisible, normalizeSubmissionPageSize, populateSchemaTranslations, publishDraft, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, transformFieldType, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
|
package/dist/index.d.ts
CHANGED
|
@@ -163,6 +163,22 @@ interface FormStorageAdapter extends StorageAdapter {
|
|
|
163
163
|
deleteSchema(formId: string, formVersion: number): Promise<void>;
|
|
164
164
|
deleteSubmission(submissionId: string): Promise<void>;
|
|
165
165
|
}
|
|
166
|
+
interface SubmissionPageQueryOptions {
|
|
167
|
+
readonly version?: number;
|
|
168
|
+
readonly cursor?: string;
|
|
169
|
+
readonly pageSize?: number;
|
|
170
|
+
readonly since?: string;
|
|
171
|
+
readonly until?: string;
|
|
172
|
+
readonly locale?: string;
|
|
173
|
+
}
|
|
174
|
+
interface SubmissionPage {
|
|
175
|
+
readonly items: readonly FormSubmission[];
|
|
176
|
+
readonly nextCursor?: string;
|
|
177
|
+
readonly hasMore: boolean;
|
|
178
|
+
}
|
|
179
|
+
interface PagedSubmissionStorageAdapter extends FormStorageAdapter {
|
|
180
|
+
listSubmissionPage(formId: string, options?: SubmissionPageQueryOptions): Promise<SubmissionPage>;
|
|
181
|
+
}
|
|
166
182
|
interface BaseQuestionAggregate {
|
|
167
183
|
readonly fieldId: string;
|
|
168
184
|
readonly answeredCount: number;
|
|
@@ -234,11 +250,34 @@ declare function calculateChoiceDistribution(responses: readonly FormSubmission[
|
|
|
234
250
|
declare function calculateNumericSummary(responses: readonly FormSubmission[], questionId: string): NumericSummary;
|
|
235
251
|
declare function calculateCrossTabulation(responses: readonly FormSubmission[], rowQuestionId: string, colQuestionId: string): CrossTabulationResult;
|
|
236
252
|
declare function aggregateResponses(schema: FormSchema, submissions: readonly FormSubmission[]): FormAnalytics;
|
|
253
|
+
type AccumulatorResponse = FormSubmission | FormResponse;
|
|
254
|
+
interface ResponseAccumulator {
|
|
255
|
+
add(submission: AccumulatorResponse): {
|
|
256
|
+
readonly success: boolean;
|
|
257
|
+
readonly error?: string;
|
|
258
|
+
};
|
|
259
|
+
addMany(submissions: Iterable<AccumulatorResponse>): void;
|
|
260
|
+
merge(other: ResponseAccumulator): ResponseAccumulator;
|
|
261
|
+
finalize(): FormAnalytics;
|
|
262
|
+
}
|
|
263
|
+
interface ResponseAccumulatorOptions {
|
|
264
|
+
readonly mode?: "strict" | "lenient";
|
|
265
|
+
}
|
|
266
|
+
declare function createResponseAccumulator(schema: FormSchema, options?: ResponseAccumulatorOptions): ResponseAccumulator;
|
|
237
267
|
declare function escapeCsvCell(value: string | number | boolean | null | undefined, neutralizeFormulas?: boolean): string;
|
|
238
268
|
interface CsvExportOptions {
|
|
239
269
|
readonly withBom?: boolean;
|
|
240
270
|
readonly neutralizeFormulas?: boolean;
|
|
241
271
|
}
|
|
272
|
+
interface CsvColumnDef {
|
|
273
|
+
readonly header: string;
|
|
274
|
+
readonly getValue: (submission: FormResponse) => string | number | boolean | null | undefined;
|
|
275
|
+
}
|
|
276
|
+
interface StreamCsvOptions extends CsvExportOptions {
|
|
277
|
+
readonly columns?: readonly CsvColumnDef[];
|
|
278
|
+
readonly includeDefaultColumns?: boolean;
|
|
279
|
+
}
|
|
280
|
+
declare function exportResponsesToCsvStream(schema: FormSchema, submissions: AsyncIterable<AccumulatorResponse>, options?: StreamCsvOptions): AsyncIterable<string>;
|
|
242
281
|
declare function exportResponsesToCsv(schema: FormSchema, responses: readonly FormSubmission[], options?: CsvExportOptions): string;
|
|
243
282
|
|
|
244
283
|
type FormEventType = "response.submitted" | "schema.updated";
|
|
@@ -268,6 +307,14 @@ declare function dispatchWebhook<T>(event: FormEvent<T>, config: WebhookConfig,
|
|
|
268
307
|
*/
|
|
269
308
|
declare function transformFieldType(field: FormField, nextType: QuestionType): FormField;
|
|
270
309
|
|
|
310
|
+
interface SubmissionCursorValue {
|
|
311
|
+
readonly submittedAt: string;
|
|
312
|
+
readonly responseId: string;
|
|
313
|
+
}
|
|
314
|
+
declare function encodeSubmissionCursor(value: SubmissionCursorValue): string;
|
|
315
|
+
declare function decodeSubmissionCursor(cursor: string): SubmissionCursorValue;
|
|
316
|
+
declare function normalizeSubmissionPageSize(pageSize: number | undefined, fallback?: number): number;
|
|
317
|
+
|
|
271
318
|
interface CollectedLocales {
|
|
272
319
|
readonly defaultLocale?: string;
|
|
273
320
|
readonly supportedLocales: readonly string[];
|
|
@@ -335,10 +382,73 @@ declare function resolveFormTranslation(schema: FormSchema, adapter: AsyncTransl
|
|
|
335
382
|
declare function validateAnswers(schema: FormSchema, values: FormValues): AnswerValidationResult;
|
|
336
383
|
declare function validatePageAnswers(schema: FormSchema, pageIndex: number, values: FormValues): AnswerValidationResult;
|
|
337
384
|
|
|
385
|
+
type Result<T, E> = {
|
|
386
|
+
readonly success: true;
|
|
387
|
+
readonly value: T;
|
|
388
|
+
} | {
|
|
389
|
+
readonly success: false;
|
|
390
|
+
readonly error: E;
|
|
391
|
+
};
|
|
392
|
+
type FormVersionStatus = "draft" | "published" | "archived";
|
|
393
|
+
interface FormVersionRecord {
|
|
394
|
+
readonly formId: string;
|
|
395
|
+
readonly version: number;
|
|
396
|
+
readonly status: FormVersionStatus;
|
|
397
|
+
readonly schema: FormSchema;
|
|
398
|
+
readonly createdAt: string;
|
|
399
|
+
readonly publishedAt?: string;
|
|
400
|
+
readonly archivedAt?: string;
|
|
401
|
+
}
|
|
402
|
+
interface FormVersionState {
|
|
403
|
+
readonly formId: string;
|
|
404
|
+
readonly draftVersion?: number;
|
|
405
|
+
readonly publishedVersion?: number;
|
|
406
|
+
readonly nextVersion: number;
|
|
407
|
+
readonly revision: number;
|
|
408
|
+
}
|
|
409
|
+
type VersionTransitionError = {
|
|
410
|
+
readonly type: "draft_already_exists";
|
|
411
|
+
readonly currentDraftVersion: number;
|
|
412
|
+
} | {
|
|
413
|
+
readonly type: "draft_not_found";
|
|
414
|
+
} | {
|
|
415
|
+
readonly type: "revision_conflict";
|
|
416
|
+
readonly expectedRevision: number;
|
|
417
|
+
readonly actualRevision: number;
|
|
418
|
+
} | {
|
|
419
|
+
readonly type: "version_immutable";
|
|
420
|
+
readonly status: FormVersionStatus;
|
|
421
|
+
} | {
|
|
422
|
+
readonly type: "max_version_exceeded";
|
|
423
|
+
readonly max: number;
|
|
424
|
+
};
|
|
425
|
+
interface CloneVersionOptions {
|
|
426
|
+
readonly maxVersions?: number;
|
|
427
|
+
}
|
|
428
|
+
interface PublishDraftOptions {
|
|
429
|
+
readonly expectedRevision?: number;
|
|
430
|
+
readonly validate?: (schema: FormSchema) => boolean;
|
|
431
|
+
/** Supplies deterministic record timestamps while keeping the transition pure. */
|
|
432
|
+
readonly timestamp?: string;
|
|
433
|
+
}
|
|
434
|
+
declare function cloneVersionToDraft(state: FormVersionState, sourceSchema: FormSchema, options?: CloneVersionOptions): Result<{
|
|
435
|
+
readonly nextState: FormVersionState;
|
|
436
|
+
readonly draftSchema: FormSchema;
|
|
437
|
+
}, VersionTransitionError>;
|
|
438
|
+
declare function publishDraft(state: FormVersionState, draftSchema: FormSchema, options?: PublishDraftOptions): Result<{
|
|
439
|
+
readonly nextState: FormVersionState;
|
|
440
|
+
readonly publishedRecord: FormVersionRecord;
|
|
441
|
+
readonly archivedVersion?: number;
|
|
442
|
+
}, VersionTransitionError>;
|
|
443
|
+
declare function deleteDraft(state: FormVersionState): Result<{
|
|
444
|
+
readonly nextState: FormVersionState;
|
|
445
|
+
}, VersionTransitionError>;
|
|
446
|
+
declare function assertVersionMutable(status: FormVersionStatus): void;
|
|
447
|
+
|
|
338
448
|
declare function isQuestionVisible(question: FormField, currentAnswers: Readonly<Record<string, unknown>>): boolean;
|
|
339
449
|
declare function isDisplayConditionSatisfied(condition: DisplayCondition | undefined, currentAnswers: Readonly<Record<string, unknown>>): boolean;
|
|
340
450
|
declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
|
|
341
451
|
declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
|
|
342
452
|
declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
|
|
343
453
|
|
|
344
|
-
export { type AnswerValidationResult, type AsyncTranslationAdapter, type BaseField, type CheckboxField, type CheckboxQuestionAggregate, type ChoiceDistributionEntry, type ChoiceOption, type ChoiceQuestionAggregate, type CollectedLocales, type ConditionOperator, type ConditionValue, type CreateSubmissionOptions, type CrossTabulationResult, type CsvExportOptions, type DisplayCondition, type ExtensibleNode, type FieldOption, type FieldType, type FormAnalytics, type FormEvent, type FormEventType, type FormField, type FormPage, type FormPolicy, type FormResponse, type FormSchema, type FormStorageAdapter, type FormSubmission, type FormValue, type FormValues, type JsonValue, type LocalizedText, type MultiSelectField, type NumberField, type NumberQuestionAggregate, type NumericSummary, type OptionAggregate, type PopulateTranslationOptions, type Question, type QuestionAggregate, type QuestionType, type RatingField, type SchemaIssue, type SchemaStructureIssue, type SchemaStructureIssueType, type SchemaTranslations, type SchemaValidationResult, type SelectField, type StorageAdapter, type SubmissionQueryOptions, type TextField, type TextQuestionAggregate, type TranslationAdapter, type TranslationReport, type TranslationSlot, type ValidateFormSchemaOptions, type ValidationCode, type ValidationError, type ValidationIssue, type WebhookConfig, type WebhookDispatchResult, aggregateResponses, assertValidFormSchema, calculateChoiceDistribution, calculateCrossTabulation, calculateFieldVisibility, calculateNumericSummary, calculatePageVisibility, collectSchemaLocales, createSubmission, dispatchWebhook, escapeCsvCell, exportResponsesToCsv, isDisplayConditionSatisfied, isQuestionVisible, populateSchemaTranslations, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, transformFieldType, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
|
|
454
|
+
export { type AccumulatorResponse, type AnswerValidationResult, type AsyncTranslationAdapter, type BaseField, type CheckboxField, type CheckboxQuestionAggregate, type ChoiceDistributionEntry, type ChoiceOption, type ChoiceQuestionAggregate, type CloneVersionOptions, type CollectedLocales, type ConditionOperator, type ConditionValue, type CreateSubmissionOptions, type CrossTabulationResult, type CsvColumnDef, type CsvExportOptions, type DisplayCondition, type ExtensibleNode, type FieldOption, type FieldType, type FormAnalytics, type FormEvent, type FormEventType, type FormField, type FormPage, type FormPolicy, type FormResponse, type FormSchema, type FormStorageAdapter, type FormSubmission, type FormValue, type FormValues, type FormVersionRecord, type FormVersionState, type FormVersionStatus, type JsonValue, type LocalizedText, type MultiSelectField, type NumberField, type NumberQuestionAggregate, type NumericSummary, type OptionAggregate, type PagedSubmissionStorageAdapter, type PopulateTranslationOptions, type PublishDraftOptions, type Question, type QuestionAggregate, type QuestionType, type RatingField, type ResponseAccumulator, type ResponseAccumulatorOptions, type Result, type SchemaIssue, type SchemaStructureIssue, type SchemaStructureIssueType, type SchemaTranslations, type SchemaValidationResult, type SelectField, type StorageAdapter, type StreamCsvOptions, type SubmissionCursorValue, type SubmissionPage, type SubmissionPageQueryOptions, type SubmissionQueryOptions, type TextField, type TextQuestionAggregate, type TranslationAdapter, type TranslationReport, type TranslationSlot, type ValidateFormSchemaOptions, type ValidationCode, type ValidationError, type ValidationIssue, type VersionTransitionError, type WebhookConfig, type WebhookDispatchResult, aggregateResponses, assertValidFormSchema, assertVersionMutable, calculateChoiceDistribution, calculateCrossTabulation, calculateFieldVisibility, calculateNumericSummary, calculatePageVisibility, cloneVersionToDraft, collectSchemaLocales, createResponseAccumulator, createSubmission, decodeSubmissionCursor, deleteDraft, dispatchWebhook, encodeSubmissionCursor, escapeCsvCell, exportResponsesToCsv, exportResponsesToCsvStream, isDisplayConditionSatisfied, isQuestionVisible, normalizeSubmissionPageSize, populateSchemaTranslations, publishDraft, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, transformFieldType, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
|
package/dist/index.js
CHANGED
|
@@ -1042,6 +1042,152 @@ function aggregateResponses(schema, submissions) {
|
|
|
1042
1042
|
questions: schema.fields.map((field) => aggregateField(schema, field, submissions))
|
|
1043
1043
|
};
|
|
1044
1044
|
}
|
|
1045
|
+
function responseValues(submission) {
|
|
1046
|
+
return "values" in submission ? submission.values : submission.answers;
|
|
1047
|
+
}
|
|
1048
|
+
function responseIdentifier(submission) {
|
|
1049
|
+
return "id" in submission ? submission.id : submission.responseId;
|
|
1050
|
+
}
|
|
1051
|
+
function responseMismatch(schema, submission) {
|
|
1052
|
+
if (submission.formId !== schema.id) {
|
|
1053
|
+
return `Submission ${responseIdentifier(submission)} does not match form ${schema.id}.`;
|
|
1054
|
+
}
|
|
1055
|
+
if ("formVersion" in submission && submission.formVersion !== schema.version) {
|
|
1056
|
+
return `Submission ${responseIdentifier(submission)} does not match ${schema.id}@${schema.version}.`;
|
|
1057
|
+
}
|
|
1058
|
+
return void 0;
|
|
1059
|
+
}
|
|
1060
|
+
var IncrementalResponseAccumulator = class _IncrementalResponseAccumulator {
|
|
1061
|
+
#schema;
|
|
1062
|
+
#mode;
|
|
1063
|
+
#fields;
|
|
1064
|
+
#submissionCount = 0;
|
|
1065
|
+
constructor(schema, options) {
|
|
1066
|
+
assertValidFormSchema(schema);
|
|
1067
|
+
this.#schema = JSON.parse(JSON.stringify(schema));
|
|
1068
|
+
this.#mode = options.mode ?? "strict";
|
|
1069
|
+
this.#fields = new Map(
|
|
1070
|
+
schema.fields.map((field) => [
|
|
1071
|
+
field.id,
|
|
1072
|
+
{
|
|
1073
|
+
answeredCount: 0,
|
|
1074
|
+
total: 0,
|
|
1075
|
+
minimum: null,
|
|
1076
|
+
maximum: null,
|
|
1077
|
+
trueCount: 0,
|
|
1078
|
+
falseCount: 0,
|
|
1079
|
+
optionCounts: new Map("options" in field ? field.options.map((option) => [option.id, 0]) : [])
|
|
1080
|
+
}
|
|
1081
|
+
])
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
add(submission) {
|
|
1085
|
+
const mismatch = responseMismatch(this.#schema, submission);
|
|
1086
|
+
if (mismatch !== void 0 && this.#mode === "strict") return { success: false, error: mismatch };
|
|
1087
|
+
const values = responseValues(submission);
|
|
1088
|
+
const visibility = calculateFieldVisibility(this.#schema, values);
|
|
1089
|
+
for (const field of this.#schema.fields) {
|
|
1090
|
+
const accumulator = this.#fields.get(field.id);
|
|
1091
|
+
if (accumulator === void 0) throw new Error(`Accumulator for ${field.id} is unavailable.`);
|
|
1092
|
+
const candidate = values[field.id];
|
|
1093
|
+
if (visibility[field.id] !== true || !valueIsValid(field, candidate)) continue;
|
|
1094
|
+
accumulator.answeredCount += 1;
|
|
1095
|
+
if ((field.type === "number" || field.type === "rating") && typeof candidate === "number") {
|
|
1096
|
+
accumulator.total += candidate;
|
|
1097
|
+
accumulator.minimum = accumulator.minimum === null ? candidate : Math.min(accumulator.minimum, candidate);
|
|
1098
|
+
accumulator.maximum = accumulator.maximum === null ? candidate : Math.max(accumulator.maximum, candidate);
|
|
1099
|
+
} else if (field.type === "checkbox") {
|
|
1100
|
+
if (candidate === true) accumulator.trueCount += 1;
|
|
1101
|
+
if (candidate === false) accumulator.falseCount += 1;
|
|
1102
|
+
} else if ("options" in field) {
|
|
1103
|
+
const selections = Array.isArray(candidate) ? candidate : typeof candidate === "string" ? [candidate] : [];
|
|
1104
|
+
for (const selection of selections) {
|
|
1105
|
+
accumulator.optionCounts.set(selection, (accumulator.optionCounts.get(selection) ?? 0) + 1);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
this.#submissionCount += 1;
|
|
1110
|
+
return { success: true };
|
|
1111
|
+
}
|
|
1112
|
+
addMany(submissions) {
|
|
1113
|
+
for (const submission of submissions) {
|
|
1114
|
+
const result = this.add(submission);
|
|
1115
|
+
if (!result.success) throw new TypeError(result.error ?? "Submission could not be accumulated.");
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
merge(other) {
|
|
1119
|
+
if (!(other instanceof _IncrementalResponseAccumulator)) {
|
|
1120
|
+
throw new TypeError("Only form-engine response accumulators can be merged.");
|
|
1121
|
+
}
|
|
1122
|
+
if (other.#schema.id !== this.#schema.id || other.#schema.version !== this.#schema.version || JSON.stringify(other.#schema.fields) !== JSON.stringify(this.#schema.fields)) {
|
|
1123
|
+
throw new TypeError("Response accumulators must use the same schema.");
|
|
1124
|
+
}
|
|
1125
|
+
this.#submissionCount += other.#submissionCount;
|
|
1126
|
+
for (const [fieldId, source] of other.#fields) {
|
|
1127
|
+
const target = this.#fields.get(fieldId);
|
|
1128
|
+
if (target === void 0) throw new Error(`Accumulator for ${fieldId} is unavailable.`);
|
|
1129
|
+
target.answeredCount += source.answeredCount;
|
|
1130
|
+
target.total += source.total;
|
|
1131
|
+
target.minimum = target.minimum === null ? source.minimum : source.minimum === null ? target.minimum : Math.min(target.minimum, source.minimum);
|
|
1132
|
+
target.maximum = target.maximum === null ? source.maximum : source.maximum === null ? target.maximum : Math.max(target.maximum, source.maximum);
|
|
1133
|
+
target.trueCount += source.trueCount;
|
|
1134
|
+
target.falseCount += source.falseCount;
|
|
1135
|
+
for (const [optionId, count] of source.optionCounts) {
|
|
1136
|
+
target.optionCounts.set(optionId, (target.optionCounts.get(optionId) ?? 0) + count);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return this;
|
|
1140
|
+
}
|
|
1141
|
+
finalize() {
|
|
1142
|
+
return {
|
|
1143
|
+
formId: this.#schema.id,
|
|
1144
|
+
formVersion: this.#schema.version,
|
|
1145
|
+
submissionCount: this.#submissionCount,
|
|
1146
|
+
questions: this.#schema.fields.map((field) => {
|
|
1147
|
+
const accumulator = this.#fields.get(field.id);
|
|
1148
|
+
if (accumulator === void 0) throw new Error(`Accumulator for ${field.id} is unavailable.`);
|
|
1149
|
+
const base = {
|
|
1150
|
+
fieldId: field.id,
|
|
1151
|
+
answeredCount: accumulator.answeredCount,
|
|
1152
|
+
unansweredCount: this.#submissionCount - accumulator.answeredCount
|
|
1153
|
+
};
|
|
1154
|
+
if (field.type === "text" || field.type === "textarea") return { ...base, kind: field.type };
|
|
1155
|
+
if (field.type === "number" || field.type === "rating") {
|
|
1156
|
+
return {
|
|
1157
|
+
...base,
|
|
1158
|
+
kind: field.type,
|
|
1159
|
+
minimum: accumulator.minimum,
|
|
1160
|
+
maximum: accumulator.maximum,
|
|
1161
|
+
average: accumulator.answeredCount === 0 ? null : accumulator.total / accumulator.answeredCount,
|
|
1162
|
+
total: accumulator.total
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
if (field.type === "checkbox") {
|
|
1166
|
+
return {
|
|
1167
|
+
...base,
|
|
1168
|
+
kind: "checkbox",
|
|
1169
|
+
trueCount: accumulator.trueCount,
|
|
1170
|
+
falseCount: accumulator.falseCount,
|
|
1171
|
+
truePercentageOfSubmissions: percentage(accumulator.trueCount, this.#submissionCount),
|
|
1172
|
+
falsePercentageOfSubmissions: percentage(accumulator.falseCount, this.#submissionCount)
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
if (!("options" in field)) throw new TypeError(`Field ${field.id} cannot be aggregated.`);
|
|
1176
|
+
return {
|
|
1177
|
+
...base,
|
|
1178
|
+
kind: field.type,
|
|
1179
|
+
options: field.options.map((option) => {
|
|
1180
|
+
const count = accumulator.optionCounts.get(option.id) ?? 0;
|
|
1181
|
+
return { id: option.id, count, percentageOfSubmissions: percentage(count, this.#submissionCount) };
|
|
1182
|
+
})
|
|
1183
|
+
};
|
|
1184
|
+
})
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
};
|
|
1188
|
+
function createResponseAccumulator(schema, options = {}) {
|
|
1189
|
+
return new IncrementalResponseAccumulator(schema, options);
|
|
1190
|
+
}
|
|
1045
1191
|
function escapeCsvCell(value, neutralizeFormulas = true) {
|
|
1046
1192
|
if (value === null || value === void 0) return "";
|
|
1047
1193
|
let stringValue = String(value);
|
|
@@ -1057,6 +1203,51 @@ function serializeValue(value) {
|
|
|
1057
1203
|
if (value === void 0) return "";
|
|
1058
1204
|
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value);
|
|
1059
1205
|
}
|
|
1206
|
+
function asFormResponse(submission) {
|
|
1207
|
+
if (!("values" in submission)) return submission;
|
|
1208
|
+
return {
|
|
1209
|
+
responseId: submission.id,
|
|
1210
|
+
formId: submission.formId,
|
|
1211
|
+
sourceLocale: submission.locale,
|
|
1212
|
+
answers: submission.values,
|
|
1213
|
+
submittedAt: submission.submittedAt,
|
|
1214
|
+
...submission.metadata === void 0 ? {} : { metadata: submission.metadata },
|
|
1215
|
+
...submission.translationMetadata === void 0 ? {} : { translationMetadata: submission.translationMetadata }
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
function serializeUnknown(value) {
|
|
1219
|
+
if (value === null || value === void 0) return "";
|
|
1220
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
|
1221
|
+
return JSON.stringify(value);
|
|
1222
|
+
}
|
|
1223
|
+
async function* exportResponsesToCsvStream(schema, submissions, options = {}) {
|
|
1224
|
+
assertValidFormSchema(schema);
|
|
1225
|
+
const includeDefaultColumns = options.includeDefaultColumns ?? true;
|
|
1226
|
+
const customColumns = options.columns ?? [];
|
|
1227
|
+
const headers = [
|
|
1228
|
+
...includeDefaultColumns ? ["submissionId", "submittedAt", "locale", ...schema.fields.map((field) => field.id)] : [],
|
|
1229
|
+
...customColumns.map((column) => column.header)
|
|
1230
|
+
];
|
|
1231
|
+
const neutralizeFormulas = options.neutralizeFormulas ?? true;
|
|
1232
|
+
const header = headers.map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",");
|
|
1233
|
+
yield `${options.withBom ?? true ? "\uFEFF" : ""}${header}`;
|
|
1234
|
+
for await (const submission of submissions) {
|
|
1235
|
+
const mismatch = responseMismatch(schema, submission);
|
|
1236
|
+
if (mismatch !== void 0) throw new TypeError(mismatch);
|
|
1237
|
+
const response = asFormResponse(submission);
|
|
1238
|
+
const answers = response.answers;
|
|
1239
|
+
const visible = selectVisibleAnswers(schema, answers);
|
|
1240
|
+
const defaultCells = includeDefaultColumns ? [
|
|
1241
|
+
response.responseId,
|
|
1242
|
+
response.submittedAt,
|
|
1243
|
+
response.sourceLocale ?? "",
|
|
1244
|
+
...schema.fields.map((field) => serializeUnknown(visible[field.id]))
|
|
1245
|
+
] : [];
|
|
1246
|
+
const customCells = customColumns.map((column) => column.getValue(response));
|
|
1247
|
+
yield `\r
|
|
1248
|
+
${[...defaultCells, ...customCells].map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",")}`;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1060
1251
|
function exportResponsesToCsv(schema, responses, options = {}) {
|
|
1061
1252
|
assertValidFormSchema(schema);
|
|
1062
1253
|
for (const response of responses) {
|
|
@@ -1174,6 +1365,61 @@ function transformFieldType(field, nextType) {
|
|
|
1174
1365
|
return { ...common, type: nextType, options };
|
|
1175
1366
|
}
|
|
1176
1367
|
|
|
1368
|
+
// src/pagination.ts
|
|
1369
|
+
var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
1370
|
+
function encodeBase64(bytes) {
|
|
1371
|
+
let result = "";
|
|
1372
|
+
for (let index = 0; index < bytes.length; index += 3) {
|
|
1373
|
+
const first = bytes[index] ?? 0;
|
|
1374
|
+
const second = bytes[index + 1] ?? 0;
|
|
1375
|
+
const third = bytes[index + 2] ?? 0;
|
|
1376
|
+
const combined = first << 16 | second << 8 | third;
|
|
1377
|
+
result += BASE64_ALPHABET[combined >> 18 & 63] ?? "";
|
|
1378
|
+
result += BASE64_ALPHABET[combined >> 12 & 63] ?? "";
|
|
1379
|
+
result += index + 1 < bytes.length ? BASE64_ALPHABET[combined >> 6 & 63] ?? "" : "=";
|
|
1380
|
+
result += index + 2 < bytes.length ? BASE64_ALPHABET[combined & 63] ?? "" : "=";
|
|
1381
|
+
}
|
|
1382
|
+
return result;
|
|
1383
|
+
}
|
|
1384
|
+
function decodeBase64(value) {
|
|
1385
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
|
1386
|
+
throw new TypeError("cursor must be a valid Base64 token.");
|
|
1387
|
+
}
|
|
1388
|
+
const bytes = [];
|
|
1389
|
+
for (let index = 0; index < value.length; index += 4) {
|
|
1390
|
+
const characters = value.slice(index, index + 4);
|
|
1391
|
+
const sextets = [...characters].map((character) => character === "=" ? 0 : BASE64_ALPHABET.indexOf(character));
|
|
1392
|
+
const combined = (sextets[0] ?? 0) << 18 | (sextets[1] ?? 0) << 12 | (sextets[2] ?? 0) << 6 | (sextets[3] ?? 0);
|
|
1393
|
+
bytes.push(combined >> 16 & 255);
|
|
1394
|
+
if (characters[2] !== "=") bytes.push(combined >> 8 & 255);
|
|
1395
|
+
if (characters[3] !== "=") bytes.push(combined & 255);
|
|
1396
|
+
}
|
|
1397
|
+
return new Uint8Array(bytes);
|
|
1398
|
+
}
|
|
1399
|
+
function encodeSubmissionCursor(value) {
|
|
1400
|
+
if (value.submittedAt.length === 0 || value.responseId.length === 0) {
|
|
1401
|
+
throw new TypeError("Cursor values must not be empty.");
|
|
1402
|
+
}
|
|
1403
|
+
return encodeBase64(new TextEncoder().encode(JSON.stringify(value)));
|
|
1404
|
+
}
|
|
1405
|
+
function decodeSubmissionCursor(cursor) {
|
|
1406
|
+
try {
|
|
1407
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64(cursor)));
|
|
1408
|
+
if (typeof parsed !== "object" || parsed === null || !("submittedAt" in parsed) || typeof parsed.submittedAt !== "string" || parsed.submittedAt.length === 0 || !("responseId" in parsed) || typeof parsed.responseId !== "string" || parsed.responseId.length === 0) {
|
|
1409
|
+
throw new TypeError("cursor payload is invalid.");
|
|
1410
|
+
}
|
|
1411
|
+
return { submittedAt: parsed.submittedAt, responseId: parsed.responseId };
|
|
1412
|
+
} catch (cause) {
|
|
1413
|
+
if (cause instanceof TypeError && cause.message === "cursor payload is invalid.") throw cause;
|
|
1414
|
+
throw new TypeError("cursor must be a valid form-engine cursor.", { cause });
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
function normalizeSubmissionPageSize(pageSize, fallback = 100) {
|
|
1418
|
+
const value = pageSize ?? fallback;
|
|
1419
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("pageSize must be a positive safe integer.");
|
|
1420
|
+
return value;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1177
1423
|
// src/validation.ts
|
|
1178
1424
|
var DEFAULT_MESSAGES = {
|
|
1179
1425
|
required: "validation.required",
|
|
@@ -1609,22 +1855,124 @@ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocal
|
|
|
1609
1855
|
);
|
|
1610
1856
|
return resolveLocalizedSchema(populated.schema, targetLocale);
|
|
1611
1857
|
}
|
|
1858
|
+
|
|
1859
|
+
// src/versioning.ts
|
|
1860
|
+
function validateState(state) {
|
|
1861
|
+
if (state.formId.trim().length === 0) throw new TypeError("formId must not be empty.");
|
|
1862
|
+
if (!Number.isSafeInteger(state.nextVersion) || state.nextVersion < 1) {
|
|
1863
|
+
throw new TypeError("nextVersion must be a positive safe integer.");
|
|
1864
|
+
}
|
|
1865
|
+
if (!Number.isSafeInteger(state.revision) || state.revision < 0) {
|
|
1866
|
+
throw new TypeError("revision must be a non-negative safe integer.");
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
function cloneVersionToDraft(state, sourceSchema, options = {}) {
|
|
1870
|
+
validateState(state);
|
|
1871
|
+
if (sourceSchema.id !== state.formId) throw new TypeError("sourceSchema.id must match state.formId.");
|
|
1872
|
+
if (state.draftVersion !== void 0) {
|
|
1873
|
+
return { success: false, error: { type: "draft_already_exists", currentDraftVersion: state.draftVersion } };
|
|
1874
|
+
}
|
|
1875
|
+
const maxVersions = options.maxVersions ?? Number.MAX_SAFE_INTEGER;
|
|
1876
|
+
if (!Number.isSafeInteger(maxVersions) || maxVersions < 1) {
|
|
1877
|
+
throw new TypeError("maxVersions must be a positive safe integer.");
|
|
1878
|
+
}
|
|
1879
|
+
if (state.nextVersion > maxVersions) {
|
|
1880
|
+
return { success: false, error: { type: "max_version_exceeded", max: maxVersions } };
|
|
1881
|
+
}
|
|
1882
|
+
const version = state.nextVersion;
|
|
1883
|
+
return {
|
|
1884
|
+
success: true,
|
|
1885
|
+
value: {
|
|
1886
|
+
nextState: {
|
|
1887
|
+
...state,
|
|
1888
|
+
draftVersion: version,
|
|
1889
|
+
nextVersion: version + 1,
|
|
1890
|
+
revision: state.revision + 1
|
|
1891
|
+
},
|
|
1892
|
+
draftSchema: { ...sourceSchema, version }
|
|
1893
|
+
}
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
function publishDraft(state, draftSchema, options = {}) {
|
|
1897
|
+
validateState(state);
|
|
1898
|
+
if (options.expectedRevision !== void 0 && options.expectedRevision !== state.revision) {
|
|
1899
|
+
return {
|
|
1900
|
+
success: false,
|
|
1901
|
+
error: {
|
|
1902
|
+
type: "revision_conflict",
|
|
1903
|
+
expectedRevision: options.expectedRevision,
|
|
1904
|
+
actualRevision: state.revision
|
|
1905
|
+
}
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
1908
|
+
if (state.draftVersion === void 0 || draftSchema.id !== state.formId || draftSchema.version !== state.draftVersion) {
|
|
1909
|
+
return { success: false, error: { type: "draft_not_found" } };
|
|
1910
|
+
}
|
|
1911
|
+
if (options.validate?.(draftSchema) === false) throw new TypeError("Draft schema validation failed.");
|
|
1912
|
+
const timestamp = options.timestamp ?? "1970-01-01T00:00:00.000Z";
|
|
1913
|
+
if (!Number.isFinite(Date.parse(timestamp))) throw new TypeError("timestamp must be a valid date string.");
|
|
1914
|
+
const archivedVersion = state.publishedVersion;
|
|
1915
|
+
const { draftVersion: _draftVersion, ...stateWithoutDraft } = state;
|
|
1916
|
+
return {
|
|
1917
|
+
success: true,
|
|
1918
|
+
value: {
|
|
1919
|
+
nextState: {
|
|
1920
|
+
...stateWithoutDraft,
|
|
1921
|
+
publishedVersion: draftSchema.version,
|
|
1922
|
+
revision: state.revision + 1
|
|
1923
|
+
},
|
|
1924
|
+
publishedRecord: {
|
|
1925
|
+
formId: state.formId,
|
|
1926
|
+
version: draftSchema.version,
|
|
1927
|
+
status: "published",
|
|
1928
|
+
schema: draftSchema,
|
|
1929
|
+
createdAt: timestamp,
|
|
1930
|
+
publishedAt: timestamp
|
|
1931
|
+
},
|
|
1932
|
+
...archivedVersion === void 0 ? {} : { archivedVersion }
|
|
1933
|
+
}
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
function deleteDraft(state) {
|
|
1937
|
+
validateState(state);
|
|
1938
|
+
if (state.draftVersion === void 0) return { success: false, error: { type: "draft_not_found" } };
|
|
1939
|
+
const { draftVersion: _draftVersion, ...stateWithoutDraft } = state;
|
|
1940
|
+
return {
|
|
1941
|
+
success: true,
|
|
1942
|
+
value: { nextState: { ...stateWithoutDraft, revision: state.revision + 1 } }
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
function assertVersionMutable(status) {
|
|
1946
|
+
if (status !== "draft") {
|
|
1947
|
+
const error = { type: "version_immutable", status };
|
|
1948
|
+
throw new TypeError(`A ${status} form version is immutable.`, { cause: error });
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1612
1951
|
export {
|
|
1613
1952
|
aggregateResponses,
|
|
1614
1953
|
assertValidFormSchema,
|
|
1954
|
+
assertVersionMutable,
|
|
1615
1955
|
calculateChoiceDistribution,
|
|
1616
1956
|
calculateCrossTabulation,
|
|
1617
1957
|
calculateFieldVisibility,
|
|
1618
1958
|
calculateNumericSummary,
|
|
1619
1959
|
calculatePageVisibility,
|
|
1960
|
+
cloneVersionToDraft,
|
|
1620
1961
|
collectSchemaLocales,
|
|
1962
|
+
createResponseAccumulator,
|
|
1621
1963
|
createSubmission,
|
|
1964
|
+
decodeSubmissionCursor,
|
|
1965
|
+
deleteDraft,
|
|
1622
1966
|
dispatchWebhook,
|
|
1967
|
+
encodeSubmissionCursor,
|
|
1623
1968
|
escapeCsvCell,
|
|
1624
1969
|
exportResponsesToCsv,
|
|
1970
|
+
exportResponsesToCsvStream,
|
|
1625
1971
|
isDisplayConditionSatisfied,
|
|
1626
1972
|
isQuestionVisible,
|
|
1973
|
+
normalizeSubmissionPageSize,
|
|
1627
1974
|
populateSchemaTranslations,
|
|
1975
|
+
publishDraft,
|
|
1628
1976
|
resolveFormTranslation,
|
|
1629
1977
|
resolveLocalizedSchema,
|
|
1630
1978
|
sanitizeSchema,
|