@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/dist/index.js CHANGED
@@ -1043,17 +1043,38 @@ function aggregateResponses(schema, submissions) {
1043
1043
  };
1044
1044
  }
1045
1045
  function responseValues(submission) {
1046
- return "values" in submission ? submission.values : submission.answers;
1046
+ if (typeof submission !== "object" || submission === null) return void 0;
1047
+ if ("values" in submission) return submission.values;
1048
+ return "answers" in submission ? submission.answers : void 0;
1047
1049
  }
1048
1050
  function responseIdentifier(submission) {
1049
- return "id" in submission ? submission.id : submission.responseId;
1051
+ if (typeof submission !== "object" || submission === null) return "<unknown>";
1052
+ if ("id" in submission && typeof submission.id === "string" && submission.id.length > 0) return submission.id;
1053
+ if ("responseId" in submission && typeof submission.responseId === "string" && submission.responseId.length > 0) {
1054
+ return submission.responseId;
1055
+ }
1056
+ return "<unknown>";
1057
+ }
1058
+ function isAnswerRecord(value) {
1059
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1050
1060
  }
1051
- function responseMismatch(schema, submission) {
1061
+ function responseProblem(schema, submission) {
1062
+ if (typeof submission !== "object" || submission === null) {
1063
+ return { reason: "invalid_structure", error: "Submission structure is invalid." };
1064
+ }
1065
+ const identifier = responseIdentifier(submission);
1066
+ const values = responseValues(submission);
1067
+ if (typeof identifier !== "string" || identifier.length === 0 || typeof submission.formId !== "string" || typeof submission.submittedAt !== "string" || !isAnswerRecord(values)) {
1068
+ return { reason: "invalid_structure", error: `Submission ${String(identifier)} structure is invalid.` };
1069
+ }
1052
1070
  if (submission.formId !== schema.id) {
1053
- return `Submission ${responseIdentifier(submission)} does not match form ${schema.id}.`;
1071
+ return { reason: "form_id_mismatch", error: `Submission ${identifier} does not match form ${schema.id}.` };
1054
1072
  }
1055
- if ("formVersion" in submission && submission.formVersion !== schema.version) {
1056
- return `Submission ${responseIdentifier(submission)} does not match ${schema.id}@${schema.version}.`;
1073
+ if ("formVersion" in submission && submission.formVersion !== void 0 && submission.formVersion !== schema.version) {
1074
+ return {
1075
+ reason: "version_mismatch",
1076
+ error: `Submission ${identifier} does not match ${schema.id}@${schema.version}.`
1077
+ };
1057
1078
  }
1058
1079
  return void 0;
1059
1080
  }
@@ -1061,6 +1082,7 @@ var IncrementalResponseAccumulator = class _IncrementalResponseAccumulator {
1061
1082
  #schema;
1062
1083
  #mode;
1063
1084
  #fields;
1085
+ #skipReasons = [];
1064
1086
  #submissionCount = 0;
1065
1087
  constructor(schema, options) {
1066
1088
  assertValidFormSchema(schema);
@@ -1082,9 +1104,14 @@ var IncrementalResponseAccumulator = class _IncrementalResponseAccumulator {
1082
1104
  );
1083
1105
  }
1084
1106
  add(submission) {
1085
- const mismatch = responseMismatch(this.#schema, submission);
1086
- if (mismatch !== void 0 && this.#mode === "strict") return { success: false, error: mismatch };
1107
+ const problem = responseProblem(this.#schema, submission);
1108
+ if (problem !== void 0) {
1109
+ if (this.#mode === "strict") return { success: false, error: problem.error };
1110
+ this.#skipReasons.push({ responseId: responseIdentifier(submission), reason: problem.reason });
1111
+ return { success: true, skipped: true };
1112
+ }
1087
1113
  const values = responseValues(submission);
1114
+ if (!isAnswerRecord(values)) throw new Error("Validated response answers are unavailable.");
1088
1115
  const visibility = calculateFieldVisibility(this.#schema, values);
1089
1116
  for (const field of this.#schema.fields) {
1090
1117
  const accumulator = this.#fields.get(field.id);
@@ -1114,6 +1141,7 @@ var IncrementalResponseAccumulator = class _IncrementalResponseAccumulator {
1114
1141
  const result = this.add(submission);
1115
1142
  if (!result.success) throw new TypeError(result.error ?? "Submission could not be accumulated.");
1116
1143
  }
1144
+ return this.getReport();
1117
1145
  }
1118
1146
  merge(other) {
1119
1147
  if (!(other instanceof _IncrementalResponseAccumulator)) {
@@ -1123,6 +1151,7 @@ var IncrementalResponseAccumulator = class _IncrementalResponseAccumulator {
1123
1151
  throw new TypeError("Response accumulators must use the same schema.");
1124
1152
  }
1125
1153
  this.#submissionCount += other.#submissionCount;
1154
+ this.#skipReasons.push(...other.#skipReasons);
1126
1155
  for (const [fieldId, source] of other.#fields) {
1127
1156
  const target = this.#fields.get(fieldId);
1128
1157
  if (target === void 0) throw new Error(`Accumulator for ${fieldId} is unavailable.`);
@@ -1138,6 +1167,13 @@ var IncrementalResponseAccumulator = class _IncrementalResponseAccumulator {
1138
1167
  }
1139
1168
  return this;
1140
1169
  }
1170
+ getReport() {
1171
+ return {
1172
+ processedCount: this.#submissionCount,
1173
+ skippedCount: this.#skipReasons.length,
1174
+ skipReasons: this.#skipReasons.map((reason) => ({ ...reason }))
1175
+ };
1176
+ }
1141
1177
  finalize() {
1142
1178
  return {
1143
1179
  formId: this.#schema.id,
@@ -1209,6 +1245,7 @@ function asFormResponse(submission) {
1209
1245
  responseId: submission.id,
1210
1246
  formId: submission.formId,
1211
1247
  sourceLocale: submission.locale,
1248
+ formVersion: submission.formVersion,
1212
1249
  answers: submission.values,
1213
1250
  submittedAt: submission.submittedAt,
1214
1251
  ...submission.metadata === void 0 ? {} : { metadata: submission.metadata },
@@ -1232,8 +1269,8 @@ async function* exportResponsesToCsvStream(schema, submissions, options = {}) {
1232
1269
  const header = headers.map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",");
1233
1270
  yield `${options.withBom ?? true ? "\uFEFF" : ""}${header}`;
1234
1271
  for await (const submission of submissions) {
1235
- const mismatch = responseMismatch(schema, submission);
1236
- if (mismatch !== void 0) throw new TypeError(mismatch);
1272
+ const problem = responseProblem(schema, submission);
1273
+ if (problem !== void 0) throw new TypeError(problem.error);
1237
1274
  const response = asFormResponse(submission);
1238
1275
  const answers = response.answers;
1239
1276
  const visible = selectVisibleAnswers(schema, answers);
@@ -1243,11 +1280,64 @@ async function* exportResponsesToCsvStream(schema, submissions, options = {}) {
1243
1280
  response.sourceLocale ?? "",
1244
1281
  ...schema.fields.map((field) => serializeUnknown(visible[field.id]))
1245
1282
  ] : [];
1246
- const customCells = customColumns.map((column) => column.getValue(response));
1283
+ const context = {
1284
+ ...response,
1285
+ submission: response,
1286
+ formVersion: response.formVersion ?? schema.version,
1287
+ schema
1288
+ };
1289
+ const customCells = await Promise.all(customColumns.map((column) => column.getValue(context)));
1247
1290
  yield `\r
1248
1291
  ${[...defaultCells, ...customCells].map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",")}`;
1249
1292
  }
1250
1293
  }
1294
+ function isWebWritableStream(writable) {
1295
+ return "getWriter" in writable && typeof writable.getWriter === "function";
1296
+ }
1297
+ async function writeNodeChunk(writable, chunk, streamError) {
1298
+ if (writable.write(chunk)) return;
1299
+ let onDrain;
1300
+ const drain = new Promise((resolve) => {
1301
+ onDrain = resolve;
1302
+ writable.once("drain", resolve);
1303
+ });
1304
+ try {
1305
+ await Promise.race([drain, streamError]);
1306
+ } finally {
1307
+ if (onDrain !== void 0) writable.removeListener("drain", onDrain);
1308
+ }
1309
+ }
1310
+ async function pipeResponsesToCsvStream(schema, submissions, writable, options = {}) {
1311
+ const encoder = new TextEncoder();
1312
+ if (isWebWritableStream(writable)) {
1313
+ const writer = writable.getWriter();
1314
+ try {
1315
+ for await (const chunk of exportResponsesToCsvStream(schema, submissions, options)) {
1316
+ await writer.write(encoder.encode(chunk));
1317
+ }
1318
+ await writer.close();
1319
+ } catch (cause) {
1320
+ await writer.abort(cause);
1321
+ throw cause;
1322
+ } finally {
1323
+ writer.releaseLock();
1324
+ }
1325
+ return;
1326
+ }
1327
+ let onStreamError;
1328
+ const streamError = new Promise((_resolve, reject) => {
1329
+ onStreamError = reject;
1330
+ writable.once("error", reject);
1331
+ });
1332
+ try {
1333
+ for await (const chunk of exportResponsesToCsvStream(schema, submissions, options)) {
1334
+ await writeNodeChunk(writable, encoder.encode(chunk), streamError);
1335
+ }
1336
+ await Promise.race([new Promise((resolve) => writable.end(resolve)), streamError]);
1337
+ } finally {
1338
+ if (onStreamError !== void 0) writable.removeListener("error", onStreamError);
1339
+ }
1340
+ }
1251
1341
  function exportResponsesToCsv(schema, responses, options = {}) {
1252
1342
  assertValidFormSchema(schema);
1253
1343
  for (const response of responses) {
@@ -1419,6 +1509,32 @@ function normalizeSubmissionPageSize(pageSize, fallback = 100) {
1419
1509
  if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("pageSize must be a positive safe integer.");
1420
1510
  return value;
1421
1511
  }
1512
+ function isJsonArray(value) {
1513
+ return Array.isArray(value);
1514
+ }
1515
+ function isJsonObject(value) {
1516
+ return typeof value === "object" && value !== null && !isJsonArray(value);
1517
+ }
1518
+ function jsonValuesEqual(left, right) {
1519
+ if (left === right) return true;
1520
+ if (left === void 0 || left === null || right === void 0 || right === null || typeof left !== typeof right) {
1521
+ return false;
1522
+ }
1523
+ if (isJsonArray(left) || isJsonArray(right)) {
1524
+ return isJsonArray(left) && isJsonArray(right) && left.length === right.length && left.every((value, index) => jsonValuesEqual(value, right[index]));
1525
+ }
1526
+ if (!isJsonObject(left) || !isJsonObject(right)) return false;
1527
+ const leftKeys = Object.keys(left);
1528
+ const rightKeys = Object.keys(right);
1529
+ return leftKeys.length === rightKeys.length && leftKeys.every((key) => Object.hasOwn(right, key) && jsonValuesEqual(left[key], right[key]));
1530
+ }
1531
+ function matchesSubmissionPageFilters(submission, options) {
1532
+ if (options.filter !== void 0 && !options.filter(submission)) return false;
1533
+ if (options.metadataFilters === void 0) return true;
1534
+ return Object.entries(options.metadataFilters).every(
1535
+ ([key, value]) => jsonValuesEqual(submission.metadata?.[key], value)
1536
+ );
1537
+ }
1422
1538
 
1423
1539
  // src/validation.ts
1424
1540
  var DEFAULT_MESSAGES = {
@@ -1857,6 +1973,12 @@ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocal
1857
1973
  }
1858
1974
 
1859
1975
  // src/versioning.ts
1976
+ function revisionConflict(state, expectedRevision) {
1977
+ return expectedRevision === void 0 || expectedRevision === state.revision ? void 0 : {
1978
+ success: false,
1979
+ error: { type: "revision_conflict", expectedRevision, actualRevision: state.revision }
1980
+ };
1981
+ }
1860
1982
  function validateState(state) {
1861
1983
  if (state.formId.trim().length === 0) throw new TypeError("formId must not be empty.");
1862
1984
  if (!Number.isSafeInteger(state.nextVersion) || state.nextVersion < 1) {
@@ -1869,9 +1991,25 @@ function validateState(state) {
1869
1991
  function cloneVersionToDraft(state, sourceSchema, options = {}) {
1870
1992
  validateState(state);
1871
1993
  if (sourceSchema.id !== state.formId) throw new TypeError("sourceSchema.id must match state.formId.");
1994
+ const conflict = revisionConflict(state, options.expectedRevision);
1995
+ if (conflict !== void 0) return conflict;
1872
1996
  if (state.draftVersion !== void 0) {
1873
1997
  return { success: false, error: { type: "draft_already_exists", currentDraftVersion: state.draftVersion } };
1874
1998
  }
1999
+ const allowedSourceVersions = /* @__PURE__ */ new Set([
2000
+ ...state.publishedVersion === void 0 ? [] : [state.publishedVersion],
2001
+ ...options.allowedSourceVersions ?? []
2002
+ ]);
2003
+ if (!allowedSourceVersions.has(sourceSchema.version)) {
2004
+ return {
2005
+ success: false,
2006
+ error: {
2007
+ type: "invalid_source_version",
2008
+ requestedVersion: sourceSchema.version,
2009
+ ...state.publishedVersion === void 0 ? {} : { publishedVersion: state.publishedVersion }
2010
+ }
2011
+ };
2012
+ }
1875
2013
  const maxVersions = options.maxVersions ?? Number.MAX_SAFE_INTEGER;
1876
2014
  if (!Number.isSafeInteger(maxVersions) || maxVersions < 1) {
1877
2015
  throw new TypeError("maxVersions must be a positive safe integer.");
@@ -1893,25 +2031,42 @@ function cloneVersionToDraft(state, sourceSchema, options = {}) {
1893
2031
  }
1894
2032
  };
1895
2033
  }
1896
- function publishDraft(state, draftSchema, options = {}) {
2034
+ function validatePublishedRecord(state, record) {
2035
+ if (record === void 0) return;
2036
+ if (record.formId !== state.formId || record.version !== state.publishedVersion || record.status !== "published" || record.schema.id !== record.formId || record.schema.version !== record.version) {
2037
+ throw new TypeError("currentPublishedRecord must match the state's published version.");
2038
+ }
2039
+ }
2040
+ function transitionTimestamp(options) {
2041
+ const timestamp = options.publishedAt ?? options.timestamp ?? "1970-01-01T00:00:00.000Z";
2042
+ if (!Number.isFinite(Date.parse(timestamp))) throw new TypeError("publishedAt must be a valid date string.");
2043
+ return timestamp;
2044
+ }
2045
+ async function publishDraft(state, draftSchema, options = {}) {
1897
2046
  validateState(state);
1898
- if (options.expectedRevision !== void 0 && options.expectedRevision !== state.revision) {
2047
+ const conflict = revisionConflict(state, options.expectedRevision);
2048
+ if (conflict !== void 0) return conflict;
2049
+ if (state.draftVersion === void 0 || draftSchema.id !== state.formId || draftSchema.version !== state.draftVersion) {
2050
+ return { success: false, error: { type: "draft_not_found" } };
2051
+ }
2052
+ validatePublishedRecord(state, options.currentPublishedRecord);
2053
+ const validation = await options.validate?.(draftSchema);
2054
+ if (validation === false || Array.isArray(validation) && validation.length > 0) {
1899
2055
  return {
1900
2056
  success: false,
1901
- error: {
1902
- type: "revision_conflict",
1903
- expectedRevision: options.expectedRevision,
1904
- actualRevision: state.revision
1905
- }
2057
+ error: { type: "validation_failed", issues: Array.isArray(validation) ? validation : [] }
1906
2058
  };
1907
2059
  }
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.");
2060
+ const timestamp = transitionTimestamp(options);
1914
2061
  const archivedVersion = state.publishedVersion;
2062
+ const archivedRecords = options.currentPublishedRecord === void 0 ? [] : [
2063
+ {
2064
+ ...options.currentPublishedRecord,
2065
+ status: "archived",
2066
+ revision: options.currentPublishedRecord.revision + 1,
2067
+ archivedAt: timestamp
2068
+ }
2069
+ ];
1915
2070
  const { draftVersion: _draftVersion, ...stateWithoutDraft } = state;
1916
2071
  return {
1917
2072
  success: true,
@@ -1926,15 +2081,57 @@ function publishDraft(state, draftSchema, options = {}) {
1926
2081
  version: draftSchema.version,
1927
2082
  status: "published",
1928
2083
  schema: draftSchema,
2084
+ revision: 1,
2085
+ ...archivedVersion === void 0 ? {} : { createdFromVersion: archivedVersion },
1929
2086
  createdAt: timestamp,
1930
2087
  publishedAt: timestamp
1931
2088
  },
2089
+ archivedRecords,
1932
2090
  ...archivedVersion === void 0 ? {} : { archivedVersion }
1933
2091
  }
1934
2092
  };
1935
2093
  }
1936
- function deleteDraft(state) {
2094
+ async function createPublishTransitionPlan(state, draftRecord, options = {}) {
2095
+ if (draftRecord.formId !== state.formId || draftRecord.version !== state.draftVersion || draftRecord.schema.id !== draftRecord.formId || draftRecord.schema.version !== draftRecord.version) {
2096
+ return { success: false, error: { type: "draft_not_found" } };
2097
+ }
2098
+ if (draftRecord.status !== "draft") {
2099
+ return { success: false, error: { type: "version_immutable", status: draftRecord.status } };
2100
+ }
2101
+ const expectedRevision = options.expectedRevision ?? state.revision;
2102
+ const timestamp = transitionTimestamp(options);
2103
+ const result = await publishDraft(state, draftRecord.schema, {
2104
+ ...options,
2105
+ expectedRevision,
2106
+ publishedAt: timestamp
2107
+ });
2108
+ if (!result.success) return result;
2109
+ const publishedRecordToSave = {
2110
+ ...draftRecord,
2111
+ status: "published",
2112
+ revision: draftRecord.revision + 1,
2113
+ publishedAt: timestamp
2114
+ };
2115
+ return {
2116
+ success: true,
2117
+ value: {
2118
+ nextState: result.value.nextState,
2119
+ plan: {
2120
+ formId: state.formId,
2121
+ expectedRevision,
2122
+ nextRevision: result.value.nextState.revision,
2123
+ draftToDeleteVersion: draftRecord.version,
2124
+ publishedRecordToSave,
2125
+ archivedRecordsToSave: result.value.archivedRecords,
2126
+ timestamp
2127
+ }
2128
+ }
2129
+ };
2130
+ }
2131
+ function deleteDraft(state, options = {}) {
1937
2132
  validateState(state);
2133
+ const conflict = revisionConflict(state, options.expectedRevision);
2134
+ if (conflict !== void 0) return conflict;
1938
2135
  if (state.draftVersion === void 0) return { success: false, error: { type: "draft_not_found" } };
1939
2136
  const { draftVersion: _draftVersion, ...stateWithoutDraft } = state;
1940
2137
  return {
@@ -1959,6 +2156,7 @@ export {
1959
2156
  calculatePageVisibility,
1960
2157
  cloneVersionToDraft,
1961
2158
  collectSchemaLocales,
2159
+ createPublishTransitionPlan,
1962
2160
  createResponseAccumulator,
1963
2161
  createSubmission,
1964
2162
  decodeSubmissionCursor,
@@ -1970,7 +2168,9 @@ export {
1970
2168
  exportResponsesToCsvStream,
1971
2169
  isDisplayConditionSatisfied,
1972
2170
  isQuestionVisible,
2171
+ matchesSubmissionPageFilters,
1973
2172
  normalizeSubmissionPageSize,
2173
+ pipeResponsesToCsvStream,
1974
2174
  populateSchemaTranslations,
1975
2175
  publishDraft,
1976
2176
  resolveFormTranslation,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/core",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },