@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 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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/core",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },