@form-engine-ts/core 2.5.0 → 2.6.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
@@ -76,14 +76,24 @@ Results are ordered by `submittedAt`, then submission ID. Both boundaries are in
76
76
 
77
77
  ## Versioning, incremental analytics, and paged storage
78
78
 
79
- `cloneVersionToDraft`, `publishDraft`, and `deleteDraft` implement revision-checked version transitions as pure functions.
79
+ `cloneVersionToDraft`, asynchronous `publishDraft`, and `deleteDraft` implement revision-checked version transitions as
80
+ pure functions.
81
+ Clone/delete operations accept `expectedRevision`; cloning rejects non-published sources, publish validation failures are
82
+ returned as typed `validation_failed` issues, and successful publishing archives only a supplied actual published record,
83
+ preserving its schema and metadata. `createPublishTransitionPlan` returns complete records plus expected/next revisions for
84
+ storage adapters implementing `VersionedFormStorageAdapter` to commit atomically.
80
85
  `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`.
86
+ free-text bodies. In lenient mode, mismatched responses are skipped and exposed by `addMany()` and `getReport()` instead of
87
+ being included silently. Independent accumulators for the same schema can be merged, and `finalize()` matches
88
+ `aggregateResponses`.
82
89
 
83
90
  `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.
91
+ custom `CsvColumnDef` columns. Custom getters may be asynchronous and receive the submission, form version, and schema. Use
92
+ `pipeResponsesToCsvStream` to write to a Web `WritableStream` or Node-compatible writable while honoring backpressure.
93
+ Formula-injection neutralization applies to both default and custom columns.
85
94
 
86
95
  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.
96
+ cursor combines `submittedAt` and response ID, so equal timestamps do not produce gaps or duplicates. `metadataFilters`
97
+ and `filter` are applied before page sizing.
88
98
 
89
99
  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
@@ -30,6 +30,7 @@ __export(index_exports, {
30
30
  calculatePageVisibility: () => calculatePageVisibility,
31
31
  cloneVersionToDraft: () => cloneVersionToDraft,
32
32
  collectSchemaLocales: () => collectSchemaLocales,
33
+ createPublishTransitionPlan: () => createPublishTransitionPlan,
33
34
  createResponseAccumulator: () => createResponseAccumulator,
34
35
  createSubmission: () => createSubmission,
35
36
  decodeSubmissionCursor: () => decodeSubmissionCursor,
@@ -41,7 +42,9 @@ __export(index_exports, {
41
42
  exportResponsesToCsvStream: () => exportResponsesToCsvStream,
42
43
  isDisplayConditionSatisfied: () => isDisplayConditionSatisfied,
43
44
  isQuestionVisible: () => isQuestionVisible,
45
+ matchesSubmissionPageFilters: () => matchesSubmissionPageFilters,
44
46
  normalizeSubmissionPageSize: () => normalizeSubmissionPageSize,
47
+ pipeResponsesToCsvStream: () => pipeResponsesToCsvStream,
45
48
  populateSchemaTranslations: () => populateSchemaTranslations,
46
49
  publishDraft: () => publishDraft,
47
50
  resolveFormTranslation: () => resolveFormTranslation,
@@ -1101,17 +1104,38 @@ function aggregateResponses(schema, submissions) {
1101
1104
  };
1102
1105
  }
1103
1106
  function responseValues(submission) {
1104
- return "values" in submission ? submission.values : submission.answers;
1107
+ if (typeof submission !== "object" || submission === null) return void 0;
1108
+ if ("values" in submission) return submission.values;
1109
+ return "answers" in submission ? submission.answers : void 0;
1105
1110
  }
1106
1111
  function responseIdentifier(submission) {
1107
- return "id" in submission ? submission.id : submission.responseId;
1112
+ if (typeof submission !== "object" || submission === null) return "<unknown>";
1113
+ if ("id" in submission && typeof submission.id === "string" && submission.id.length > 0) return submission.id;
1114
+ if ("responseId" in submission && typeof submission.responseId === "string" && submission.responseId.length > 0) {
1115
+ return submission.responseId;
1116
+ }
1117
+ return "<unknown>";
1118
+ }
1119
+ function isAnswerRecord(value) {
1120
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1108
1121
  }
1109
- function responseMismatch(schema, submission) {
1122
+ function responseProblem(schema, submission) {
1123
+ if (typeof submission !== "object" || submission === null) {
1124
+ return { reason: "invalid_structure", error: "Submission structure is invalid." };
1125
+ }
1126
+ const identifier = responseIdentifier(submission);
1127
+ const values = responseValues(submission);
1128
+ if (typeof identifier !== "string" || identifier.length === 0 || typeof submission.formId !== "string" || typeof submission.submittedAt !== "string" || !isAnswerRecord(values)) {
1129
+ return { reason: "invalid_structure", error: `Submission ${String(identifier)} structure is invalid.` };
1130
+ }
1110
1131
  if (submission.formId !== schema.id) {
1111
- return `Submission ${responseIdentifier(submission)} does not match form ${schema.id}.`;
1132
+ return { reason: "form_id_mismatch", error: `Submission ${identifier} does not match form ${schema.id}.` };
1112
1133
  }
1113
- if ("formVersion" in submission && submission.formVersion !== schema.version) {
1114
- return `Submission ${responseIdentifier(submission)} does not match ${schema.id}@${schema.version}.`;
1134
+ if ("formVersion" in submission && submission.formVersion !== void 0 && submission.formVersion !== schema.version) {
1135
+ return {
1136
+ reason: "version_mismatch",
1137
+ error: `Submission ${identifier} does not match ${schema.id}@${schema.version}.`
1138
+ };
1115
1139
  }
1116
1140
  return void 0;
1117
1141
  }
@@ -1119,6 +1143,7 @@ var IncrementalResponseAccumulator = class _IncrementalResponseAccumulator {
1119
1143
  #schema;
1120
1144
  #mode;
1121
1145
  #fields;
1146
+ #skipReasons = [];
1122
1147
  #submissionCount = 0;
1123
1148
  constructor(schema, options) {
1124
1149
  assertValidFormSchema(schema);
@@ -1140,9 +1165,14 @@ var IncrementalResponseAccumulator = class _IncrementalResponseAccumulator {
1140
1165
  );
1141
1166
  }
1142
1167
  add(submission) {
1143
- const mismatch = responseMismatch(this.#schema, submission);
1144
- if (mismatch !== void 0 && this.#mode === "strict") return { success: false, error: mismatch };
1168
+ const problem = responseProblem(this.#schema, submission);
1169
+ if (problem !== void 0) {
1170
+ if (this.#mode === "strict") return { success: false, error: problem.error };
1171
+ this.#skipReasons.push({ responseId: responseIdentifier(submission), reason: problem.reason });
1172
+ return { success: true, skipped: true };
1173
+ }
1145
1174
  const values = responseValues(submission);
1175
+ if (!isAnswerRecord(values)) throw new Error("Validated response answers are unavailable.");
1146
1176
  const visibility = calculateFieldVisibility(this.#schema, values);
1147
1177
  for (const field of this.#schema.fields) {
1148
1178
  const accumulator = this.#fields.get(field.id);
@@ -1172,6 +1202,7 @@ var IncrementalResponseAccumulator = class _IncrementalResponseAccumulator {
1172
1202
  const result = this.add(submission);
1173
1203
  if (!result.success) throw new TypeError(result.error ?? "Submission could not be accumulated.");
1174
1204
  }
1205
+ return this.getReport();
1175
1206
  }
1176
1207
  merge(other) {
1177
1208
  if (!(other instanceof _IncrementalResponseAccumulator)) {
@@ -1181,6 +1212,7 @@ var IncrementalResponseAccumulator = class _IncrementalResponseAccumulator {
1181
1212
  throw new TypeError("Response accumulators must use the same schema.");
1182
1213
  }
1183
1214
  this.#submissionCount += other.#submissionCount;
1215
+ this.#skipReasons.push(...other.#skipReasons);
1184
1216
  for (const [fieldId, source] of other.#fields) {
1185
1217
  const target = this.#fields.get(fieldId);
1186
1218
  if (target === void 0) throw new Error(`Accumulator for ${fieldId} is unavailable.`);
@@ -1196,6 +1228,13 @@ var IncrementalResponseAccumulator = class _IncrementalResponseAccumulator {
1196
1228
  }
1197
1229
  return this;
1198
1230
  }
1231
+ getReport() {
1232
+ return {
1233
+ processedCount: this.#submissionCount,
1234
+ skippedCount: this.#skipReasons.length,
1235
+ skipReasons: this.#skipReasons.map((reason) => ({ ...reason }))
1236
+ };
1237
+ }
1199
1238
  finalize() {
1200
1239
  return {
1201
1240
  formId: this.#schema.id,
@@ -1267,6 +1306,7 @@ function asFormResponse(submission) {
1267
1306
  responseId: submission.id,
1268
1307
  formId: submission.formId,
1269
1308
  sourceLocale: submission.locale,
1309
+ formVersion: submission.formVersion,
1270
1310
  answers: submission.values,
1271
1311
  submittedAt: submission.submittedAt,
1272
1312
  ...submission.metadata === void 0 ? {} : { metadata: submission.metadata },
@@ -1290,8 +1330,8 @@ async function* exportResponsesToCsvStream(schema, submissions, options = {}) {
1290
1330
  const header = headers.map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",");
1291
1331
  yield `${options.withBom ?? true ? "\uFEFF" : ""}${header}`;
1292
1332
  for await (const submission of submissions) {
1293
- const mismatch = responseMismatch(schema, submission);
1294
- if (mismatch !== void 0) throw new TypeError(mismatch);
1333
+ const problem = responseProblem(schema, submission);
1334
+ if (problem !== void 0) throw new TypeError(problem.error);
1295
1335
  const response = asFormResponse(submission);
1296
1336
  const answers = response.answers;
1297
1337
  const visible = selectVisibleAnswers(schema, answers);
@@ -1301,11 +1341,64 @@ async function* exportResponsesToCsvStream(schema, submissions, options = {}) {
1301
1341
  response.sourceLocale ?? "",
1302
1342
  ...schema.fields.map((field) => serializeUnknown(visible[field.id]))
1303
1343
  ] : [];
1304
- const customCells = customColumns.map((column) => column.getValue(response));
1344
+ const context = {
1345
+ ...response,
1346
+ submission: response,
1347
+ formVersion: response.formVersion ?? schema.version,
1348
+ schema
1349
+ };
1350
+ const customCells = await Promise.all(customColumns.map((column) => column.getValue(context)));
1305
1351
  yield `\r
1306
1352
  ${[...defaultCells, ...customCells].map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",")}`;
1307
1353
  }
1308
1354
  }
1355
+ function isWebWritableStream(writable) {
1356
+ return "getWriter" in writable && typeof writable.getWriter === "function";
1357
+ }
1358
+ async function writeNodeChunk(writable, chunk, streamError) {
1359
+ if (writable.write(chunk)) return;
1360
+ let onDrain;
1361
+ const drain = new Promise((resolve) => {
1362
+ onDrain = resolve;
1363
+ writable.once("drain", resolve);
1364
+ });
1365
+ try {
1366
+ await Promise.race([drain, streamError]);
1367
+ } finally {
1368
+ if (onDrain !== void 0) writable.removeListener("drain", onDrain);
1369
+ }
1370
+ }
1371
+ async function pipeResponsesToCsvStream(schema, submissions, writable, options = {}) {
1372
+ const encoder = new TextEncoder();
1373
+ if (isWebWritableStream(writable)) {
1374
+ const writer = writable.getWriter();
1375
+ try {
1376
+ for await (const chunk of exportResponsesToCsvStream(schema, submissions, options)) {
1377
+ await writer.write(encoder.encode(chunk));
1378
+ }
1379
+ await writer.close();
1380
+ } catch (cause) {
1381
+ await writer.abort(cause);
1382
+ throw cause;
1383
+ } finally {
1384
+ writer.releaseLock();
1385
+ }
1386
+ return;
1387
+ }
1388
+ let onStreamError;
1389
+ const streamError = new Promise((_resolve, reject) => {
1390
+ onStreamError = reject;
1391
+ writable.once("error", reject);
1392
+ });
1393
+ try {
1394
+ for await (const chunk of exportResponsesToCsvStream(schema, submissions, options)) {
1395
+ await writeNodeChunk(writable, encoder.encode(chunk), streamError);
1396
+ }
1397
+ await Promise.race([new Promise((resolve) => writable.end(resolve)), streamError]);
1398
+ } finally {
1399
+ if (onStreamError !== void 0) writable.removeListener("error", onStreamError);
1400
+ }
1401
+ }
1309
1402
  function exportResponsesToCsv(schema, responses, options = {}) {
1310
1403
  assertValidFormSchema(schema);
1311
1404
  for (const response of responses) {
@@ -1477,6 +1570,32 @@ function normalizeSubmissionPageSize(pageSize, fallback = 100) {
1477
1570
  if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("pageSize must be a positive safe integer.");
1478
1571
  return value;
1479
1572
  }
1573
+ function isJsonArray(value) {
1574
+ return Array.isArray(value);
1575
+ }
1576
+ function isJsonObject(value) {
1577
+ return typeof value === "object" && value !== null && !isJsonArray(value);
1578
+ }
1579
+ function jsonValuesEqual(left, right) {
1580
+ if (left === right) return true;
1581
+ if (left === void 0 || left === null || right === void 0 || right === null || typeof left !== typeof right) {
1582
+ return false;
1583
+ }
1584
+ if (isJsonArray(left) || isJsonArray(right)) {
1585
+ return isJsonArray(left) && isJsonArray(right) && left.length === right.length && left.every((value, index) => jsonValuesEqual(value, right[index]));
1586
+ }
1587
+ if (!isJsonObject(left) || !isJsonObject(right)) return false;
1588
+ const leftKeys = Object.keys(left);
1589
+ const rightKeys = Object.keys(right);
1590
+ return leftKeys.length === rightKeys.length && leftKeys.every((key) => Object.hasOwn(right, key) && jsonValuesEqual(left[key], right[key]));
1591
+ }
1592
+ function matchesSubmissionPageFilters(submission, options) {
1593
+ if (options.filter !== void 0 && !options.filter(submission)) return false;
1594
+ if (options.metadataFilters === void 0) return true;
1595
+ return Object.entries(options.metadataFilters).every(
1596
+ ([key, value]) => jsonValuesEqual(submission.metadata?.[key], value)
1597
+ );
1598
+ }
1480
1599
 
1481
1600
  // src/validation.ts
1482
1601
  var DEFAULT_MESSAGES = {
@@ -1915,6 +2034,12 @@ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocal
1915
2034
  }
1916
2035
 
1917
2036
  // src/versioning.ts
2037
+ function revisionConflict(state, expectedRevision) {
2038
+ return expectedRevision === void 0 || expectedRevision === state.revision ? void 0 : {
2039
+ success: false,
2040
+ error: { type: "revision_conflict", expectedRevision, actualRevision: state.revision }
2041
+ };
2042
+ }
1918
2043
  function validateState(state) {
1919
2044
  if (state.formId.trim().length === 0) throw new TypeError("formId must not be empty.");
1920
2045
  if (!Number.isSafeInteger(state.nextVersion) || state.nextVersion < 1) {
@@ -1927,9 +2052,25 @@ function validateState(state) {
1927
2052
  function cloneVersionToDraft(state, sourceSchema, options = {}) {
1928
2053
  validateState(state);
1929
2054
  if (sourceSchema.id !== state.formId) throw new TypeError("sourceSchema.id must match state.formId.");
2055
+ const conflict = revisionConflict(state, options.expectedRevision);
2056
+ if (conflict !== void 0) return conflict;
1930
2057
  if (state.draftVersion !== void 0) {
1931
2058
  return { success: false, error: { type: "draft_already_exists", currentDraftVersion: state.draftVersion } };
1932
2059
  }
2060
+ const allowedSourceVersions = /* @__PURE__ */ new Set([
2061
+ ...state.publishedVersion === void 0 ? [] : [state.publishedVersion],
2062
+ ...options.allowedSourceVersions ?? []
2063
+ ]);
2064
+ if (!allowedSourceVersions.has(sourceSchema.version)) {
2065
+ return {
2066
+ success: false,
2067
+ error: {
2068
+ type: "invalid_source_version",
2069
+ requestedVersion: sourceSchema.version,
2070
+ ...state.publishedVersion === void 0 ? {} : { publishedVersion: state.publishedVersion }
2071
+ }
2072
+ };
2073
+ }
1933
2074
  const maxVersions = options.maxVersions ?? Number.MAX_SAFE_INTEGER;
1934
2075
  if (!Number.isSafeInteger(maxVersions) || maxVersions < 1) {
1935
2076
  throw new TypeError("maxVersions must be a positive safe integer.");
@@ -1951,25 +2092,42 @@ function cloneVersionToDraft(state, sourceSchema, options = {}) {
1951
2092
  }
1952
2093
  };
1953
2094
  }
1954
- function publishDraft(state, draftSchema, options = {}) {
2095
+ function validatePublishedRecord(state, record) {
2096
+ if (record === void 0) return;
2097
+ if (record.formId !== state.formId || record.version !== state.publishedVersion || record.status !== "published" || record.schema.id !== record.formId || record.schema.version !== record.version) {
2098
+ throw new TypeError("currentPublishedRecord must match the state's published version.");
2099
+ }
2100
+ }
2101
+ function transitionTimestamp(options) {
2102
+ const timestamp = options.publishedAt ?? options.timestamp ?? "1970-01-01T00:00:00.000Z";
2103
+ if (!Number.isFinite(Date.parse(timestamp))) throw new TypeError("publishedAt must be a valid date string.");
2104
+ return timestamp;
2105
+ }
2106
+ async function publishDraft(state, draftSchema, options = {}) {
1955
2107
  validateState(state);
1956
- if (options.expectedRevision !== void 0 && options.expectedRevision !== state.revision) {
2108
+ const conflict = revisionConflict(state, options.expectedRevision);
2109
+ if (conflict !== void 0) return conflict;
2110
+ if (state.draftVersion === void 0 || draftSchema.id !== state.formId || draftSchema.version !== state.draftVersion) {
2111
+ return { success: false, error: { type: "draft_not_found" } };
2112
+ }
2113
+ validatePublishedRecord(state, options.currentPublishedRecord);
2114
+ const validation = await options.validate?.(draftSchema);
2115
+ if (validation === false || Array.isArray(validation) && validation.length > 0) {
1957
2116
  return {
1958
2117
  success: false,
1959
- error: {
1960
- type: "revision_conflict",
1961
- expectedRevision: options.expectedRevision,
1962
- actualRevision: state.revision
1963
- }
2118
+ error: { type: "validation_failed", issues: Array.isArray(validation) ? validation : [] }
1964
2119
  };
1965
2120
  }
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.");
2121
+ const timestamp = transitionTimestamp(options);
1972
2122
  const archivedVersion = state.publishedVersion;
2123
+ const archivedRecords = options.currentPublishedRecord === void 0 ? [] : [
2124
+ {
2125
+ ...options.currentPublishedRecord,
2126
+ status: "archived",
2127
+ revision: options.currentPublishedRecord.revision + 1,
2128
+ archivedAt: timestamp
2129
+ }
2130
+ ];
1973
2131
  const { draftVersion: _draftVersion, ...stateWithoutDraft } = state;
1974
2132
  return {
1975
2133
  success: true,
@@ -1984,15 +2142,57 @@ function publishDraft(state, draftSchema, options = {}) {
1984
2142
  version: draftSchema.version,
1985
2143
  status: "published",
1986
2144
  schema: draftSchema,
2145
+ revision: 1,
2146
+ ...archivedVersion === void 0 ? {} : { createdFromVersion: archivedVersion },
1987
2147
  createdAt: timestamp,
1988
2148
  publishedAt: timestamp
1989
2149
  },
2150
+ archivedRecords,
1990
2151
  ...archivedVersion === void 0 ? {} : { archivedVersion }
1991
2152
  }
1992
2153
  };
1993
2154
  }
1994
- function deleteDraft(state) {
2155
+ async function createPublishTransitionPlan(state, draftRecord, options = {}) {
2156
+ if (draftRecord.formId !== state.formId || draftRecord.version !== state.draftVersion || draftRecord.schema.id !== draftRecord.formId || draftRecord.schema.version !== draftRecord.version) {
2157
+ return { success: false, error: { type: "draft_not_found" } };
2158
+ }
2159
+ if (draftRecord.status !== "draft") {
2160
+ return { success: false, error: { type: "version_immutable", status: draftRecord.status } };
2161
+ }
2162
+ const expectedRevision = options.expectedRevision ?? state.revision;
2163
+ const timestamp = transitionTimestamp(options);
2164
+ const result = await publishDraft(state, draftRecord.schema, {
2165
+ ...options,
2166
+ expectedRevision,
2167
+ publishedAt: timestamp
2168
+ });
2169
+ if (!result.success) return result;
2170
+ const publishedRecordToSave = {
2171
+ ...draftRecord,
2172
+ status: "published",
2173
+ revision: draftRecord.revision + 1,
2174
+ publishedAt: timestamp
2175
+ };
2176
+ return {
2177
+ success: true,
2178
+ value: {
2179
+ nextState: result.value.nextState,
2180
+ plan: {
2181
+ formId: state.formId,
2182
+ expectedRevision,
2183
+ nextRevision: result.value.nextState.revision,
2184
+ draftToDeleteVersion: draftRecord.version,
2185
+ publishedRecordToSave,
2186
+ archivedRecordsToSave: result.value.archivedRecords,
2187
+ timestamp
2188
+ }
2189
+ }
2190
+ };
2191
+ }
2192
+ function deleteDraft(state, options = {}) {
1995
2193
  validateState(state);
2194
+ const conflict = revisionConflict(state, options.expectedRevision);
2195
+ if (conflict !== void 0) return conflict;
1996
2196
  if (state.draftVersion === void 0) return { success: false, error: { type: "draft_not_found" } };
1997
2197
  const { draftVersion: _draftVersion, ...stateWithoutDraft } = state;
1998
2198
  return {
@@ -2018,6 +2218,7 @@ function assertVersionMutable(status) {
2018
2218
  calculatePageVisibility,
2019
2219
  cloneVersionToDraft,
2020
2220
  collectSchemaLocales,
2221
+ createPublishTransitionPlan,
2021
2222
  createResponseAccumulator,
2022
2223
  createSubmission,
2023
2224
  decodeSubmissionCursor,
@@ -2029,7 +2230,9 @@ function assertVersionMutable(status) {
2029
2230
  exportResponsesToCsvStream,
2030
2231
  isDisplayConditionSatisfied,
2031
2232
  isQuestionVisible,
2233
+ matchesSubmissionPageFilters,
2032
2234
  normalizeSubmissionPageSize,
2235
+ pipeResponsesToCsvStream,
2033
2236
  populateSchemaTranslations,
2034
2237
  publishDraft,
2035
2238
  resolveFormTranslation,