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