@form-engine-ts/core 2.5.1 → 2.7.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,22 +76,28 @@ 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. `createCloneTransitionPlan`, `createPublishTransitionPlan`, and `createDeleteDraftTransitionPlan` produce
81
+ complete persistence plans with the next state, affected records, and immutable audit events.
80
82
  Clone/delete operations accept `expectedRevision`; cloning rejects non-published sources, publish validation failures are
81
- returned as typed `validation_failed` issues, and successful publishing reports every archived record. Storage adapters
82
- that can commit the resulting changes atomically implement `VersionedFormStorageAdapter` and `VersionTransitionPlan`.
83
+ returned as typed `validation_failed` issues, and successful publishing archives only a supplied actual published record,
84
+ preserving its schema and metadata. `createPublishTransitionPlan` returns complete records plus expected/next revisions for
85
+ storage adapters implementing `VersionedFormStorageAdapter` to commit atomically. Versioned adapters expose state/record
86
+ reads and return a typed `Result` from `commitVersionTransition`, including the actual revision on concurrency conflicts.
83
87
  `createResponseAccumulator` incrementally counts choices, answered/unanswered values, and numeric summaries without retaining
84
88
  free-text bodies. In lenient mode, mismatched responses are skipped and exposed by `addMany()` and `getReport()` instead of
85
89
  being included silently. Independent accumulators for the same schema can be merged, and `finalize()` matches
86
90
  `aggregateResponses`.
87
91
 
88
92
  `exportResponsesToCsvStream` accepts an `AsyncIterable`, emits the BOM/header and one chunk per response, and supports
89
- custom `CsvColumnDef` columns. Custom getters receive the submission, form version, and schema. Use
93
+ custom `CsvColumnDef` columns. Custom getters may be asynchronous and receive the submission, form version, and schema. Use
90
94
  `pipeResponsesToCsvStream` to write to a Web `WritableStream` or Node-compatible writable while honoring backpressure.
91
95
  Formula-injection neutralization applies to both default and custom columns.
92
96
 
93
97
  Adapters implementing `PagedSubmissionStorageAdapter` expose `listSubmissionPage(formId, options)`. The opaque Base64
94
98
  cursor combines `submittedAt` and response ID, so equal timestamps do not produce gaps or duplicates. `metadataFilters`
95
- and `filter` are applied before page sizing.
99
+ and `filter` are applied before page sizing. `filter` accepts a composable `eq`/`in`/`range`/`exists` and/or AST; adapters
100
+ may push supported nodes to their native query language while preserving identical client-side semantics. Adapters that
101
+ implement `listTextAnswerPage` expose stable cursor pagination over individual text answers.
96
102
 
97
103
  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,17 +30,24 @@ __export(index_exports, {
30
30
  calculatePageVisibility: () => calculatePageVisibility,
31
31
  cloneVersionToDraft: () => cloneVersionToDraft,
32
32
  collectSchemaLocales: () => collectSchemaLocales,
33
+ createCloneTransitionPlan: () => createCloneTransitionPlan,
34
+ createDeleteDraftTransitionPlan: () => createDeleteDraftTransitionPlan,
35
+ createPublishTransitionPlan: () => createPublishTransitionPlan,
33
36
  createResponseAccumulator: () => createResponseAccumulator,
34
37
  createSubmission: () => createSubmission,
35
38
  decodeSubmissionCursor: () => decodeSubmissionCursor,
39
+ decodeTextAnswerCursor: () => decodeTextAnswerCursor,
36
40
  deleteDraft: () => deleteDraft,
37
41
  dispatchWebhook: () => dispatchWebhook,
38
42
  encodeSubmissionCursor: () => encodeSubmissionCursor,
43
+ encodeTextAnswerCursor: () => encodeTextAnswerCursor,
39
44
  escapeCsvCell: () => escapeCsvCell,
40
45
  exportResponsesToCsv: () => exportResponsesToCsv,
41
46
  exportResponsesToCsvStream: () => exportResponsesToCsvStream,
42
47
  isDisplayConditionSatisfied: () => isDisplayConditionSatisfied,
43
48
  isQuestionVisible: () => isQuestionVisible,
49
+ jsonValuesEqual: () => jsonValuesEqual,
50
+ matchesSubmissionFilter: () => matchesSubmissionFilter,
44
51
  matchesSubmissionPageFilters: () => matchesSubmissionPageFilters,
45
52
  normalizeSubmissionPageSize: () => normalizeSubmissionPageSize,
46
53
  pipeResponsesToCsvStream: () => pipeResponsesToCsvStream,
@@ -1346,7 +1353,7 @@ async function* exportResponsesToCsvStream(schema, submissions, options = {}) {
1346
1353
  formVersion: response.formVersion ?? schema.version,
1347
1354
  schema
1348
1355
  };
1349
- const customCells = customColumns.map((column) => column.getValue(context));
1356
+ const customCells = await Promise.all(customColumns.map((column) => column.getValue(context)));
1350
1357
  yield `\r
1351
1358
  ${[...defaultCells, ...customCells].map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",")}`;
1352
1359
  }
@@ -1564,6 +1571,24 @@ function decodeSubmissionCursor(cursor) {
1564
1571
  throw new TypeError("cursor must be a valid form-engine cursor.", { cause });
1565
1572
  }
1566
1573
  }
1574
+ function encodeTextAnswerCursor(value) {
1575
+ if (value.responseId.length === 0 || value.fieldId.length === 0) {
1576
+ throw new TypeError("Text answer cursor values must not be empty.");
1577
+ }
1578
+ return encodeBase64(new TextEncoder().encode(JSON.stringify(value)));
1579
+ }
1580
+ function decodeTextAnswerCursor(cursor) {
1581
+ try {
1582
+ const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64(cursor)));
1583
+ if (typeof parsed !== "object" || parsed === null || !("responseId" in parsed) || typeof parsed.responseId !== "string" || parsed.responseId.length === 0 || !("fieldId" in parsed) || typeof parsed.fieldId !== "string" || parsed.fieldId.length === 0) {
1584
+ throw new TypeError("text answer cursor payload is invalid.");
1585
+ }
1586
+ return { responseId: parsed.responseId, fieldId: parsed.fieldId };
1587
+ } catch (cause) {
1588
+ if (cause instanceof TypeError && cause.message === "text answer cursor payload is invalid.") throw cause;
1589
+ throw new TypeError("cursor must be a valid text answer cursor.", { cause });
1590
+ }
1591
+ }
1567
1592
  function normalizeSubmissionPageSize(pageSize, fallback = 100) {
1568
1593
  const value = pageSize ?? fallback;
1569
1594
  if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("pageSize must be a positive safe integer.");
@@ -1588,8 +1613,39 @@ function jsonValuesEqual(left, right) {
1588
1613
  const rightKeys = Object.keys(right);
1589
1614
  return leftKeys.length === rightKeys.length && leftKeys.every((key) => Object.hasOwn(right, key) && jsonValuesEqual(left[key], right[key]));
1590
1615
  }
1616
+ function readSubmissionPath(submission, path) {
1617
+ if (path.length === 0 || path.split(".").some((part) => part.length === 0 || part === "__proto__" || part === "constructor")) {
1618
+ return void 0;
1619
+ }
1620
+ let current = submission;
1621
+ for (const part of path.split(".")) {
1622
+ if (typeof current !== "object" || current === null || !Object.hasOwn(current, part)) return void 0;
1623
+ current = current[part];
1624
+ }
1625
+ return current;
1626
+ }
1627
+ function compareRangeValue(value, boundary, direction) {
1628
+ if (typeof value === "number" && typeof boundary === "number") {
1629
+ return direction === "from" ? value >= boundary : value <= boundary;
1630
+ }
1631
+ if (typeof value === "string" && typeof boundary === "string") {
1632
+ return direction === "from" ? value >= boundary : value <= boundary;
1633
+ }
1634
+ return false;
1635
+ }
1636
+ function matchesSubmissionFilter(submission, filter) {
1637
+ if (filter.op === "and") return filter.filters.every((item) => matchesSubmissionFilter(submission, item));
1638
+ if (filter.op === "or") return filter.filters.some((item) => matchesSubmissionFilter(submission, item));
1639
+ const value = readSubmissionPath(submission, filter.path);
1640
+ if (filter.op === "eq") return jsonValuesEqual(value, filter.value);
1641
+ if (filter.op === "in") return filter.values.some((candidate) => jsonValuesEqual(value, candidate));
1642
+ if (filter.op === "exists") return filter.value ? value !== void 0 : value === void 0;
1643
+ return (filter.from === void 0 || compareRangeValue(value, filter.from, "from")) && (filter.to === void 0 || compareRangeValue(value, filter.to, "to"));
1644
+ }
1591
1645
  function matchesSubmissionPageFilters(submission, options) {
1592
- if (options.filter !== void 0 && !options.filter(submission)) return false;
1646
+ if (options.filter !== void 0 && !(typeof options.filter === "function" ? options.filter(submission) : matchesSubmissionFilter(submission, options.filter))) {
1647
+ return false;
1648
+ }
1593
1649
  if (options.metadataFilters === void 0) return true;
1594
1650
  return Object.entries(options.metadataFilters).every(
1595
1651
  ([key, value]) => jsonValuesEqual(submission.metadata?.[key], value)
@@ -2048,6 +2104,21 @@ function validateState(state) {
2048
2104
  throw new TypeError("revision must be a non-negative safe integer.");
2049
2105
  }
2050
2106
  }
2107
+ function requireTimestamp(value, name) {
2108
+ const timestamp = value ?? "1970-01-01T00:00:00.000Z";
2109
+ if (!Number.isFinite(Date.parse(timestamp))) throw new TypeError(`${name} must be a valid date string.`);
2110
+ return timestamp;
2111
+ }
2112
+ function transitionEvent(type, state, nextState, affectedVersions, occurredAt) {
2113
+ return {
2114
+ type,
2115
+ formId: state.formId,
2116
+ fromRevision: state.revision,
2117
+ toRevision: nextState.revision,
2118
+ affectedVersions,
2119
+ occurredAt
2120
+ };
2121
+ }
2051
2122
  function cloneVersionToDraft(state, sourceSchema, options = {}) {
2052
2123
  validateState(state);
2053
2124
  if (sourceSchema.id !== state.formId) throw new TypeError("sourceSchema.id must match state.formId.");
@@ -2091,31 +2162,77 @@ function cloneVersionToDraft(state, sourceSchema, options = {}) {
2091
2162
  }
2092
2163
  };
2093
2164
  }
2094
- function publishDraft(state, draftSchema, options = {}) {
2165
+ function createCloneTransitionPlan(state, sourceRecord, options = {}) {
2166
+ if (sourceRecord.formId !== state.formId || sourceRecord.schema.id !== sourceRecord.formId || sourceRecord.schema.version !== sourceRecord.version || sourceRecord.status !== "published" && !options.allowedSourceVersions?.includes(sourceRecord.version)) {
2167
+ return {
2168
+ success: false,
2169
+ error: {
2170
+ type: "invalid_source_version",
2171
+ requestedVersion: sourceRecord.version,
2172
+ ...state.publishedVersion === void 0 ? {} : { publishedVersion: state.publishedVersion }
2173
+ }
2174
+ };
2175
+ }
2176
+ const result = cloneVersionToDraft(state, sourceRecord.schema, options);
2177
+ if (!result.success) return result;
2178
+ const timestamp = requireTimestamp(options.clonedAt, "clonedAt");
2179
+ const draftRecord = {
2180
+ formId: state.formId,
2181
+ version: result.value.draftSchema.version,
2182
+ status: "draft",
2183
+ schema: result.value.draftSchema,
2184
+ revision: 1,
2185
+ createdFromVersion: sourceRecord.version,
2186
+ createdAt: timestamp,
2187
+ ...options.metadata === void 0 ? {} : { metadata: options.metadata }
2188
+ };
2189
+ return {
2190
+ success: true,
2191
+ value: {
2192
+ nextState: result.value.nextState,
2193
+ plan: {
2194
+ formId: state.formId,
2195
+ expectedRevision: options.expectedRevision ?? state.revision,
2196
+ nextRevision: result.value.nextState.revision,
2197
+ draftToCreate: draftRecord,
2198
+ events: [transitionEvent("draft.created", state, result.value.nextState, [draftRecord.version], timestamp)],
2199
+ nextVersion: result.value.nextState.nextVersion,
2200
+ timestamp
2201
+ }
2202
+ }
2203
+ };
2204
+ }
2205
+ function validatePublishedRecord(state, record) {
2206
+ if (record === void 0) return;
2207
+ if (record.formId !== state.formId || record.version !== state.publishedVersion || record.status !== "published" || record.schema.id !== record.formId || record.schema.version !== record.version) {
2208
+ throw new TypeError("currentPublishedRecord must match the state's published version.");
2209
+ }
2210
+ }
2211
+ function transitionTimestamp(options) {
2212
+ return requireTimestamp(options.publishedAt ?? options.timestamp, "publishedAt");
2213
+ }
2214
+ async function publishDraft(state, draftSchema, options = {}) {
2095
2215
  validateState(state);
2096
2216
  const conflict = revisionConflict(state, options.expectedRevision);
2097
2217
  if (conflict !== void 0) return conflict;
2098
2218
  if (state.draftVersion === void 0 || draftSchema.id !== state.formId || draftSchema.version !== state.draftVersion) {
2099
2219
  return { success: false, error: { type: "draft_not_found" } };
2100
2220
  }
2101
- const validation = options.validate?.(draftSchema);
2221
+ validatePublishedRecord(state, options.currentPublishedRecord);
2222
+ const validation = await options.validate?.(draftSchema);
2102
2223
  if (validation === false || Array.isArray(validation) && validation.length > 0) {
2103
2224
  return {
2104
2225
  success: false,
2105
2226
  error: { type: "validation_failed", issues: Array.isArray(validation) ? validation : [] }
2106
2227
  };
2107
2228
  }
2108
- const timestamp = options.timestamp ?? "1970-01-01T00:00:00.000Z";
2109
- if (!Number.isFinite(Date.parse(timestamp))) throw new TypeError("timestamp must be a valid date string.");
2229
+ const timestamp = transitionTimestamp(options);
2110
2230
  const archivedVersion = state.publishedVersion;
2111
- const archivedRecords = archivedVersion === void 0 ? [] : [
2231
+ const archivedRecords = options.currentPublishedRecord === void 0 ? [] : [
2112
2232
  {
2113
- formId: state.formId,
2114
- version: archivedVersion,
2233
+ ...options.currentPublishedRecord,
2115
2234
  status: "archived",
2116
- schema: { ...draftSchema, version: archivedVersion },
2117
- createdAt: timestamp,
2118
- publishedAt: timestamp,
2235
+ revision: options.currentPublishedRecord.revision + 1,
2119
2236
  archivedAt: timestamp
2120
2237
  }
2121
2238
  ];
@@ -2133,6 +2250,8 @@ function publishDraft(state, draftSchema, options = {}) {
2133
2250
  version: draftSchema.version,
2134
2251
  status: "published",
2135
2252
  schema: draftSchema,
2253
+ revision: 1,
2254
+ ...archivedVersion === void 0 ? {} : { createdFromVersion: archivedVersion },
2136
2255
  createdAt: timestamp,
2137
2256
  publishedAt: timestamp
2138
2257
  },
@@ -2141,6 +2260,56 @@ function publishDraft(state, draftSchema, options = {}) {
2141
2260
  }
2142
2261
  };
2143
2262
  }
2263
+ async function createPublishTransitionPlan(state, draftRecord, options = {}) {
2264
+ if (draftRecord.formId !== state.formId || draftRecord.version !== state.draftVersion || draftRecord.schema.id !== draftRecord.formId || draftRecord.schema.version !== draftRecord.version) {
2265
+ return { success: false, error: { type: "draft_not_found" } };
2266
+ }
2267
+ if (draftRecord.status !== "draft") {
2268
+ return { success: false, error: { type: "version_immutable", status: draftRecord.status } };
2269
+ }
2270
+ const expectedRevision = options.expectedRevision ?? state.revision;
2271
+ const timestamp = transitionTimestamp(options);
2272
+ const result = await publishDraft(state, draftRecord.schema, {
2273
+ ...options,
2274
+ expectedRevision,
2275
+ publishedAt: timestamp
2276
+ });
2277
+ if (!result.success) return result;
2278
+ const publishedRecordToSave = {
2279
+ ...draftRecord,
2280
+ status: "published",
2281
+ revision: draftRecord.revision + 1,
2282
+ publishedAt: timestamp
2283
+ };
2284
+ return {
2285
+ success: true,
2286
+ value: {
2287
+ nextState: result.value.nextState,
2288
+ plan: {
2289
+ formId: state.formId,
2290
+ expectedRevision,
2291
+ nextRevision: result.value.nextState.revision,
2292
+ draftToDeleteVersion: draftRecord.version,
2293
+ publishedRecordToSave,
2294
+ archivedRecordsToSave: result.value.archivedRecords,
2295
+ events: [
2296
+ ...result.value.archivedRecords.length === 0 ? [] : [
2297
+ transitionEvent(
2298
+ "version.archived",
2299
+ state,
2300
+ result.value.nextState,
2301
+ result.value.archivedRecords.map((record) => record.version),
2302
+ timestamp
2303
+ )
2304
+ ],
2305
+ transitionEvent("version.published", state, result.value.nextState, [draftRecord.version], timestamp)
2306
+ ],
2307
+ nextVersion: result.value.nextState.nextVersion,
2308
+ timestamp
2309
+ }
2310
+ }
2311
+ };
2312
+ }
2144
2313
  function deleteDraft(state, options = {}) {
2145
2314
  validateState(state);
2146
2315
  const conflict = revisionConflict(state, options.expectedRevision);
@@ -2152,6 +2321,29 @@ function deleteDraft(state, options = {}) {
2152
2321
  value: { nextState: { ...stateWithoutDraft, revision: state.revision + 1 } }
2153
2322
  };
2154
2323
  }
2324
+ function createDeleteDraftTransitionPlan(state, draftRecord, options = {}) {
2325
+ if (draftRecord.formId !== state.formId || draftRecord.version !== state.draftVersion || draftRecord.status !== "draft" || draftRecord.schema.id !== state.formId || draftRecord.schema.version !== draftRecord.version) {
2326
+ return { success: false, error: { type: "draft_not_found" } };
2327
+ }
2328
+ const result = deleteDraft(state, options);
2329
+ if (!result.success) return result;
2330
+ const timestamp = requireTimestamp(options.deletedAt, "deletedAt");
2331
+ return {
2332
+ success: true,
2333
+ value: {
2334
+ nextState: result.value.nextState,
2335
+ plan: {
2336
+ formId: state.formId,
2337
+ expectedRevision: options.expectedRevision ?? state.revision,
2338
+ nextRevision: result.value.nextState.revision,
2339
+ draftToDeleteVersion: draftRecord.version,
2340
+ events: [transitionEvent("draft.deleted", state, result.value.nextState, [draftRecord.version], timestamp)],
2341
+ nextVersion: result.value.nextState.nextVersion,
2342
+ timestamp
2343
+ }
2344
+ }
2345
+ };
2346
+ }
2155
2347
  function assertVersionMutable(status) {
2156
2348
  if (status !== "draft") {
2157
2349
  const error = { type: "version_immutable", status };
@@ -2170,17 +2362,24 @@ function assertVersionMutable(status) {
2170
2362
  calculatePageVisibility,
2171
2363
  cloneVersionToDraft,
2172
2364
  collectSchemaLocales,
2365
+ createCloneTransitionPlan,
2366
+ createDeleteDraftTransitionPlan,
2367
+ createPublishTransitionPlan,
2173
2368
  createResponseAccumulator,
2174
2369
  createSubmission,
2175
2370
  decodeSubmissionCursor,
2371
+ decodeTextAnswerCursor,
2176
2372
  deleteDraft,
2177
2373
  dispatchWebhook,
2178
2374
  encodeSubmissionCursor,
2375
+ encodeTextAnswerCursor,
2179
2376
  escapeCsvCell,
2180
2377
  exportResponsesToCsv,
2181
2378
  exportResponsesToCsvStream,
2182
2379
  isDisplayConditionSatisfied,
2183
2380
  isQuestionVisible,
2381
+ jsonValuesEqual,
2382
+ matchesSubmissionFilter,
2184
2383
  matchesSubmissionPageFilters,
2185
2384
  normalizeSubmissionPageSize,
2186
2385
  pipeResponsesToCsvStream,
package/dist/index.d.cts CHANGED
@@ -6,11 +6,13 @@ type Result<T, E> = {
6
6
  readonly error: E;
7
7
  };
8
8
  type FormVersionStatus = "draft" | "published" | "archived";
9
- interface FormVersionRecord {
9
+ interface FormVersionRecord extends ExtensibleNode {
10
10
  readonly formId: string;
11
11
  readonly version: number;
12
12
  readonly status: FormVersionStatus;
13
13
  readonly schema: FormSchema;
14
+ readonly revision: number;
15
+ readonly createdFromVersion?: number;
14
16
  readonly createdAt: string;
15
17
  readonly publishedAt?: string;
16
18
  readonly archivedAt?: string;
@@ -22,6 +24,14 @@ interface FormVersionState {
22
24
  readonly nextVersion: number;
23
25
  readonly revision: number;
24
26
  }
27
+ interface VersionTransitionEvent {
28
+ readonly type: "draft.created" | "draft.deleted" | "version.published" | "version.archived";
29
+ readonly formId: string;
30
+ readonly fromRevision: number;
31
+ readonly toRevision: number;
32
+ readonly affectedVersions: readonly number[];
33
+ readonly occurredAt: string;
34
+ }
25
35
  type VersionTransitionError = {
26
36
  readonly type: "draft_already_exists";
27
37
  readonly currentDraftVersion: number;
@@ -48,17 +58,22 @@ type VersionTransitionError = {
48
58
  interface CloneVersionOptions {
49
59
  readonly maxVersions?: number;
50
60
  readonly expectedRevision?: number;
61
+ readonly clonedAt?: string;
62
+ readonly metadata?: Readonly<Record<string, JsonValue>>;
51
63
  /** Additional known published versions that may be used as a clone source. */
52
64
  readonly allowedSourceVersions?: readonly number[];
53
65
  }
54
66
  interface PublishDraftOptions {
55
67
  readonly expectedRevision?: number;
56
- readonly validate?: (schema: FormSchema) => boolean | readonly SchemaIssue[];
57
- /** Supplies deterministic record timestamps while keeping the transition pure. */
68
+ readonly currentPublishedRecord?: FormVersionRecord;
69
+ readonly validate?: (schema: FormSchema) => boolean | Promise<boolean> | readonly SchemaIssue[] | Promise<readonly SchemaIssue[]>;
70
+ readonly publishedAt?: string;
71
+ /** @deprecated Use publishedAt. */
58
72
  readonly timestamp?: string;
59
73
  }
60
74
  interface DeleteDraftOptions {
61
75
  readonly expectedRevision?: number;
76
+ readonly deletedAt?: string;
62
77
  }
63
78
  interface PublishDraftResult {
64
79
  readonly nextState: FormVersionState;
@@ -71,10 +86,22 @@ declare function cloneVersionToDraft(state: FormVersionState, sourceSchema: Form
71
86
  readonly nextState: FormVersionState;
72
87
  readonly draftSchema: FormSchema;
73
88
  }, VersionTransitionError>;
74
- declare function publishDraft(state: FormVersionState, draftSchema: FormSchema, options?: PublishDraftOptions): Result<PublishDraftResult, VersionTransitionError>;
89
+ declare function createCloneTransitionPlan(state: FormVersionState, sourceRecord: FormVersionRecord, options?: CloneVersionOptions): Result<{
90
+ readonly nextState: FormVersionState;
91
+ readonly plan: VersionTransitionPlan;
92
+ }, VersionTransitionError>;
93
+ declare function publishDraft(state: FormVersionState, draftSchema: FormSchema, options?: PublishDraftOptions): Promise<Result<PublishDraftResult, VersionTransitionError>>;
94
+ declare function createPublishTransitionPlan(state: FormVersionState, draftRecord: FormVersionRecord, options?: PublishDraftOptions): Promise<Result<{
95
+ readonly nextState: FormVersionState;
96
+ readonly plan: VersionTransitionPlan;
97
+ }, VersionTransitionError>>;
75
98
  declare function deleteDraft(state: FormVersionState, options?: DeleteDraftOptions): Result<{
76
99
  readonly nextState: FormVersionState;
77
100
  }, VersionTransitionError>;
101
+ declare function createDeleteDraftTransitionPlan(state: FormVersionState, draftRecord: FormVersionRecord, options?: DeleteDraftOptions): Result<{
102
+ readonly nextState: FormVersionState;
103
+ readonly plan: VersionTransitionPlan;
104
+ }, VersionTransitionError>;
78
105
  declare function assertVersionMutable(status: FormVersionStatus): void;
79
106
 
80
107
  type FieldType = "text" | "textarea" | "number" | "rating" | "select" | "multi-select" | "checkbox" | "radio";
@@ -249,9 +276,34 @@ interface SubmissionPageQueryOptions {
249
276
  readonly since?: string;
250
277
  readonly until?: string;
251
278
  readonly locale?: string;
252
- readonly filter?: (submission: FormSubmission) => boolean;
279
+ readonly filter?: SubmissionFilter | ((submission: FormSubmission) => boolean);
280
+ /** @deprecated Prefer the generic filter AST. */
253
281
  readonly metadataFilters?: Readonly<Record<string, JsonValue>>;
254
282
  }
283
+ type SubmissionFilter = {
284
+ readonly op: "eq";
285
+ readonly path: string;
286
+ readonly value: JsonValue;
287
+ } | {
288
+ readonly op: "in";
289
+ readonly path: string;
290
+ readonly values: readonly JsonValue[];
291
+ } | {
292
+ readonly op: "range";
293
+ readonly path: string;
294
+ readonly from?: JsonValue;
295
+ readonly to?: JsonValue;
296
+ } | {
297
+ readonly op: "exists";
298
+ readonly path: string;
299
+ readonly value: boolean;
300
+ } | {
301
+ readonly op: "and";
302
+ readonly filters: readonly SubmissionFilter[];
303
+ } | {
304
+ readonly op: "or";
305
+ readonly filters: readonly SubmissionFilter[];
306
+ };
255
307
  interface SubmissionPage {
256
308
  readonly items: readonly FormSubmission[];
257
309
  readonly nextCursor?: string;
@@ -259,19 +311,57 @@ interface SubmissionPage {
259
311
  }
260
312
  interface PagedSubmissionStorageAdapter extends FormStorageAdapter {
261
313
  listSubmissionPage(formId: string, options?: SubmissionPageQueryOptions): Promise<SubmissionPage>;
314
+ listTextAnswerPage?(formId: string, fieldId: string, options?: SubmissionPageQueryOptions): Promise<TextAnswerPage>;
315
+ }
316
+ interface TextAnswerItem {
317
+ readonly responseId: string;
318
+ readonly formId: string;
319
+ readonly formVersion: number;
320
+ readonly fieldId: string;
321
+ readonly text: string;
322
+ readonly locale?: string;
323
+ readonly submittedAt: string;
324
+ readonly metadata?: Readonly<Record<string, JsonValue>>;
325
+ }
326
+ interface TextAnswerPage {
327
+ readonly items: readonly TextAnswerItem[];
328
+ readonly nextCursor?: string;
329
+ readonly hasMore: boolean;
262
330
  }
263
331
  interface VersionTransitionPlan {
264
- readonly state: FormVersionState;
332
+ readonly formId: string;
265
333
  readonly expectedRevision: number;
266
- readonly draftToPublish?: FormSchema;
267
- readonly versionsToArchive: readonly number[];
268
- readonly versionsToDelete: readonly number[];
334
+ readonly nextRevision: number;
335
+ readonly draftToCreate?: FormVersionRecord;
336
+ readonly draftToDeleteVersion?: number;
337
+ readonly publishedRecordToSave?: FormVersionRecord;
338
+ readonly archivedRecordsToSave?: readonly FormVersionRecord[];
339
+ readonly events: readonly VersionTransitionEvent[];
340
+ /** The complete next state value used by persistent adapters. */
341
+ readonly nextVersion?: number;
342
+ readonly timestamp: string;
269
343
  }
344
+ type StorageCommitError = {
345
+ readonly type: "revision_conflict";
346
+ readonly expectedRevision: number;
347
+ readonly actualRevision?: number;
348
+ } | {
349
+ readonly type: "draft_already_exists";
350
+ readonly currentDraftVersion: number;
351
+ } | {
352
+ readonly type: "invalid_transition";
353
+ readonly message: string;
354
+ } | {
355
+ readonly type: "storage_error";
356
+ readonly cause: unknown;
357
+ };
270
358
  interface VersionedFormStorageAdapter extends FormStorageAdapter {
271
- commitVersionTransition(plan: VersionTransitionPlan): Promise<{
272
- readonly success: boolean;
273
- readonly error?: string;
274
- }>;
359
+ getVersionState(formId: string): Promise<FormVersionState | null>;
360
+ getVersionRecord(formId: string, version: number): Promise<FormVersionRecord | null>;
361
+ listVersionRecords(formId: string): Promise<readonly FormVersionRecord[]>;
362
+ commitVersionTransition(plan: VersionTransitionPlan): Promise<Result<{
363
+ readonly success: true;
364
+ }, StorageCommitError>>;
275
365
  }
276
366
  interface BaseQuestionAggregate {
277
367
  readonly fieldId: string;
@@ -377,7 +467,7 @@ interface CsvExportOptions {
377
467
  }
378
468
  interface CsvColumnDef {
379
469
  readonly header: string;
380
- readonly getValue: (context: CsvColumnContext) => string | number | boolean | null | undefined;
470
+ readonly getValue: (context: CsvColumnContext) => string | number | boolean | null | undefined | Promise<string | number | boolean | null | undefined>;
381
471
  }
382
472
  interface CsvColumnContext extends FormResponse {
383
473
  readonly submission: FormResponse;
@@ -431,9 +521,17 @@ interface SubmissionCursorValue {
431
521
  readonly submittedAt: string;
432
522
  readonly responseId: string;
433
523
  }
524
+ interface TextAnswerCursorValue {
525
+ readonly responseId: string;
526
+ readonly fieldId: string;
527
+ }
434
528
  declare function encodeSubmissionCursor(value: SubmissionCursorValue): string;
435
529
  declare function decodeSubmissionCursor(cursor: string): SubmissionCursorValue;
530
+ declare function encodeTextAnswerCursor(value: TextAnswerCursorValue): string;
531
+ declare function decodeTextAnswerCursor(cursor: string): TextAnswerCursorValue;
436
532
  declare function normalizeSubmissionPageSize(pageSize: number | undefined, fallback?: number): number;
533
+ declare function jsonValuesEqual(left: JsonValue | undefined, right: JsonValue | undefined): boolean;
534
+ declare function matchesSubmissionFilter(submission: FormSubmission, filter: SubmissionFilter): boolean;
437
535
  declare function matchesSubmissionPageFilters(submission: FormSubmission, options: Pick<SubmissionPageQueryOptions, "filter" | "metadataFilters">): boolean;
438
536
 
439
537
  interface CollectedLocales {
@@ -509,4 +607,4 @@ declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Rea
509
607
  declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
510
608
  declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
511
609
 
512
- export { type AccumulatorReport, type AccumulatorResponse, type AccumulatorSkipReason, 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 CsvColumnContext, type CsvColumnDef, type CsvExportOptions, type DeleteDraftOptions, 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 NodeWritableStream, type NumberField, type NumberQuestionAggregate, type NumericSummary, type OptionAggregate, type PagedSubmissionStorageAdapter, type PopulateTranslationOptions, type PublishDraftOptions, type PublishDraftResult, 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 VersionTransitionPlan, type VersionedFormStorageAdapter, 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, matchesSubmissionPageFilters, normalizeSubmissionPageSize, pipeResponsesToCsvStream, populateSchemaTranslations, publishDraft, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, transformFieldType, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
610
+ export { type AccumulatorReport, type AccumulatorResponse, type AccumulatorSkipReason, 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 CsvColumnContext, type CsvColumnDef, type CsvExportOptions, type DeleteDraftOptions, 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 NodeWritableStream, type NumberField, type NumberQuestionAggregate, type NumericSummary, type OptionAggregate, type PagedSubmissionStorageAdapter, type PopulateTranslationOptions, type PublishDraftOptions, type PublishDraftResult, 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 StorageCommitError, type StreamCsvOptions, type SubmissionCursorValue, type SubmissionFilter, type SubmissionPage, type SubmissionPageQueryOptions, type SubmissionQueryOptions, type TextAnswerCursorValue, type TextAnswerItem, type TextAnswerPage, type TextField, type TextQuestionAggregate, type TranslationAdapter, type TranslationReport, type TranslationSlot, type ValidateFormSchemaOptions, type ValidationCode, type ValidationError, type ValidationIssue, type VersionTransitionError, type VersionTransitionEvent, type VersionTransitionPlan, type VersionedFormStorageAdapter, type WebhookConfig, type WebhookDispatchResult, aggregateResponses, assertValidFormSchema, assertVersionMutable, calculateChoiceDistribution, calculateCrossTabulation, calculateFieldVisibility, calculateNumericSummary, calculatePageVisibility, cloneVersionToDraft, collectSchemaLocales, createCloneTransitionPlan, createDeleteDraftTransitionPlan, createPublishTransitionPlan, createResponseAccumulator, createSubmission, decodeSubmissionCursor, decodeTextAnswerCursor, deleteDraft, dispatchWebhook, encodeSubmissionCursor, encodeTextAnswerCursor, escapeCsvCell, exportResponsesToCsv, exportResponsesToCsvStream, isDisplayConditionSatisfied, isQuestionVisible, jsonValuesEqual, matchesSubmissionFilter, matchesSubmissionPageFilters, normalizeSubmissionPageSize, pipeResponsesToCsvStream, populateSchemaTranslations, publishDraft, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, transformFieldType, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
package/dist/index.d.ts CHANGED
@@ -6,11 +6,13 @@ type Result<T, E> = {
6
6
  readonly error: E;
7
7
  };
8
8
  type FormVersionStatus = "draft" | "published" | "archived";
9
- interface FormVersionRecord {
9
+ interface FormVersionRecord extends ExtensibleNode {
10
10
  readonly formId: string;
11
11
  readonly version: number;
12
12
  readonly status: FormVersionStatus;
13
13
  readonly schema: FormSchema;
14
+ readonly revision: number;
15
+ readonly createdFromVersion?: number;
14
16
  readonly createdAt: string;
15
17
  readonly publishedAt?: string;
16
18
  readonly archivedAt?: string;
@@ -22,6 +24,14 @@ interface FormVersionState {
22
24
  readonly nextVersion: number;
23
25
  readonly revision: number;
24
26
  }
27
+ interface VersionTransitionEvent {
28
+ readonly type: "draft.created" | "draft.deleted" | "version.published" | "version.archived";
29
+ readonly formId: string;
30
+ readonly fromRevision: number;
31
+ readonly toRevision: number;
32
+ readonly affectedVersions: readonly number[];
33
+ readonly occurredAt: string;
34
+ }
25
35
  type VersionTransitionError = {
26
36
  readonly type: "draft_already_exists";
27
37
  readonly currentDraftVersion: number;
@@ -48,17 +58,22 @@ type VersionTransitionError = {
48
58
  interface CloneVersionOptions {
49
59
  readonly maxVersions?: number;
50
60
  readonly expectedRevision?: number;
61
+ readonly clonedAt?: string;
62
+ readonly metadata?: Readonly<Record<string, JsonValue>>;
51
63
  /** Additional known published versions that may be used as a clone source. */
52
64
  readonly allowedSourceVersions?: readonly number[];
53
65
  }
54
66
  interface PublishDraftOptions {
55
67
  readonly expectedRevision?: number;
56
- readonly validate?: (schema: FormSchema) => boolean | readonly SchemaIssue[];
57
- /** Supplies deterministic record timestamps while keeping the transition pure. */
68
+ readonly currentPublishedRecord?: FormVersionRecord;
69
+ readonly validate?: (schema: FormSchema) => boolean | Promise<boolean> | readonly SchemaIssue[] | Promise<readonly SchemaIssue[]>;
70
+ readonly publishedAt?: string;
71
+ /** @deprecated Use publishedAt. */
58
72
  readonly timestamp?: string;
59
73
  }
60
74
  interface DeleteDraftOptions {
61
75
  readonly expectedRevision?: number;
76
+ readonly deletedAt?: string;
62
77
  }
63
78
  interface PublishDraftResult {
64
79
  readonly nextState: FormVersionState;
@@ -71,10 +86,22 @@ declare function cloneVersionToDraft(state: FormVersionState, sourceSchema: Form
71
86
  readonly nextState: FormVersionState;
72
87
  readonly draftSchema: FormSchema;
73
88
  }, VersionTransitionError>;
74
- declare function publishDraft(state: FormVersionState, draftSchema: FormSchema, options?: PublishDraftOptions): Result<PublishDraftResult, VersionTransitionError>;
89
+ declare function createCloneTransitionPlan(state: FormVersionState, sourceRecord: FormVersionRecord, options?: CloneVersionOptions): Result<{
90
+ readonly nextState: FormVersionState;
91
+ readonly plan: VersionTransitionPlan;
92
+ }, VersionTransitionError>;
93
+ declare function publishDraft(state: FormVersionState, draftSchema: FormSchema, options?: PublishDraftOptions): Promise<Result<PublishDraftResult, VersionTransitionError>>;
94
+ declare function createPublishTransitionPlan(state: FormVersionState, draftRecord: FormVersionRecord, options?: PublishDraftOptions): Promise<Result<{
95
+ readonly nextState: FormVersionState;
96
+ readonly plan: VersionTransitionPlan;
97
+ }, VersionTransitionError>>;
75
98
  declare function deleteDraft(state: FormVersionState, options?: DeleteDraftOptions): Result<{
76
99
  readonly nextState: FormVersionState;
77
100
  }, VersionTransitionError>;
101
+ declare function createDeleteDraftTransitionPlan(state: FormVersionState, draftRecord: FormVersionRecord, options?: DeleteDraftOptions): Result<{
102
+ readonly nextState: FormVersionState;
103
+ readonly plan: VersionTransitionPlan;
104
+ }, VersionTransitionError>;
78
105
  declare function assertVersionMutable(status: FormVersionStatus): void;
79
106
 
80
107
  type FieldType = "text" | "textarea" | "number" | "rating" | "select" | "multi-select" | "checkbox" | "radio";
@@ -249,9 +276,34 @@ interface SubmissionPageQueryOptions {
249
276
  readonly since?: string;
250
277
  readonly until?: string;
251
278
  readonly locale?: string;
252
- readonly filter?: (submission: FormSubmission) => boolean;
279
+ readonly filter?: SubmissionFilter | ((submission: FormSubmission) => boolean);
280
+ /** @deprecated Prefer the generic filter AST. */
253
281
  readonly metadataFilters?: Readonly<Record<string, JsonValue>>;
254
282
  }
283
+ type SubmissionFilter = {
284
+ readonly op: "eq";
285
+ readonly path: string;
286
+ readonly value: JsonValue;
287
+ } | {
288
+ readonly op: "in";
289
+ readonly path: string;
290
+ readonly values: readonly JsonValue[];
291
+ } | {
292
+ readonly op: "range";
293
+ readonly path: string;
294
+ readonly from?: JsonValue;
295
+ readonly to?: JsonValue;
296
+ } | {
297
+ readonly op: "exists";
298
+ readonly path: string;
299
+ readonly value: boolean;
300
+ } | {
301
+ readonly op: "and";
302
+ readonly filters: readonly SubmissionFilter[];
303
+ } | {
304
+ readonly op: "or";
305
+ readonly filters: readonly SubmissionFilter[];
306
+ };
255
307
  interface SubmissionPage {
256
308
  readonly items: readonly FormSubmission[];
257
309
  readonly nextCursor?: string;
@@ -259,19 +311,57 @@ interface SubmissionPage {
259
311
  }
260
312
  interface PagedSubmissionStorageAdapter extends FormStorageAdapter {
261
313
  listSubmissionPage(formId: string, options?: SubmissionPageQueryOptions): Promise<SubmissionPage>;
314
+ listTextAnswerPage?(formId: string, fieldId: string, options?: SubmissionPageQueryOptions): Promise<TextAnswerPage>;
315
+ }
316
+ interface TextAnswerItem {
317
+ readonly responseId: string;
318
+ readonly formId: string;
319
+ readonly formVersion: number;
320
+ readonly fieldId: string;
321
+ readonly text: string;
322
+ readonly locale?: string;
323
+ readonly submittedAt: string;
324
+ readonly metadata?: Readonly<Record<string, JsonValue>>;
325
+ }
326
+ interface TextAnswerPage {
327
+ readonly items: readonly TextAnswerItem[];
328
+ readonly nextCursor?: string;
329
+ readonly hasMore: boolean;
262
330
  }
263
331
  interface VersionTransitionPlan {
264
- readonly state: FormVersionState;
332
+ readonly formId: string;
265
333
  readonly expectedRevision: number;
266
- readonly draftToPublish?: FormSchema;
267
- readonly versionsToArchive: readonly number[];
268
- readonly versionsToDelete: readonly number[];
334
+ readonly nextRevision: number;
335
+ readonly draftToCreate?: FormVersionRecord;
336
+ readonly draftToDeleteVersion?: number;
337
+ readonly publishedRecordToSave?: FormVersionRecord;
338
+ readonly archivedRecordsToSave?: readonly FormVersionRecord[];
339
+ readonly events: readonly VersionTransitionEvent[];
340
+ /** The complete next state value used by persistent adapters. */
341
+ readonly nextVersion?: number;
342
+ readonly timestamp: string;
269
343
  }
344
+ type StorageCommitError = {
345
+ readonly type: "revision_conflict";
346
+ readonly expectedRevision: number;
347
+ readonly actualRevision?: number;
348
+ } | {
349
+ readonly type: "draft_already_exists";
350
+ readonly currentDraftVersion: number;
351
+ } | {
352
+ readonly type: "invalid_transition";
353
+ readonly message: string;
354
+ } | {
355
+ readonly type: "storage_error";
356
+ readonly cause: unknown;
357
+ };
270
358
  interface VersionedFormStorageAdapter extends FormStorageAdapter {
271
- commitVersionTransition(plan: VersionTransitionPlan): Promise<{
272
- readonly success: boolean;
273
- readonly error?: string;
274
- }>;
359
+ getVersionState(formId: string): Promise<FormVersionState | null>;
360
+ getVersionRecord(formId: string, version: number): Promise<FormVersionRecord | null>;
361
+ listVersionRecords(formId: string): Promise<readonly FormVersionRecord[]>;
362
+ commitVersionTransition(plan: VersionTransitionPlan): Promise<Result<{
363
+ readonly success: true;
364
+ }, StorageCommitError>>;
275
365
  }
276
366
  interface BaseQuestionAggregate {
277
367
  readonly fieldId: string;
@@ -377,7 +467,7 @@ interface CsvExportOptions {
377
467
  }
378
468
  interface CsvColumnDef {
379
469
  readonly header: string;
380
- readonly getValue: (context: CsvColumnContext) => string | number | boolean | null | undefined;
470
+ readonly getValue: (context: CsvColumnContext) => string | number | boolean | null | undefined | Promise<string | number | boolean | null | undefined>;
381
471
  }
382
472
  interface CsvColumnContext extends FormResponse {
383
473
  readonly submission: FormResponse;
@@ -431,9 +521,17 @@ interface SubmissionCursorValue {
431
521
  readonly submittedAt: string;
432
522
  readonly responseId: string;
433
523
  }
524
+ interface TextAnswerCursorValue {
525
+ readonly responseId: string;
526
+ readonly fieldId: string;
527
+ }
434
528
  declare function encodeSubmissionCursor(value: SubmissionCursorValue): string;
435
529
  declare function decodeSubmissionCursor(cursor: string): SubmissionCursorValue;
530
+ declare function encodeTextAnswerCursor(value: TextAnswerCursorValue): string;
531
+ declare function decodeTextAnswerCursor(cursor: string): TextAnswerCursorValue;
436
532
  declare function normalizeSubmissionPageSize(pageSize: number | undefined, fallback?: number): number;
533
+ declare function jsonValuesEqual(left: JsonValue | undefined, right: JsonValue | undefined): boolean;
534
+ declare function matchesSubmissionFilter(submission: FormSubmission, filter: SubmissionFilter): boolean;
437
535
  declare function matchesSubmissionPageFilters(submission: FormSubmission, options: Pick<SubmissionPageQueryOptions, "filter" | "metadataFilters">): boolean;
438
536
 
439
537
  interface CollectedLocales {
@@ -509,4 +607,4 @@ declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Rea
509
607
  declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
510
608
  declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
511
609
 
512
- export { type AccumulatorReport, type AccumulatorResponse, type AccumulatorSkipReason, 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 CsvColumnContext, type CsvColumnDef, type CsvExportOptions, type DeleteDraftOptions, 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 NodeWritableStream, type NumberField, type NumberQuestionAggregate, type NumericSummary, type OptionAggregate, type PagedSubmissionStorageAdapter, type PopulateTranslationOptions, type PublishDraftOptions, type PublishDraftResult, 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 VersionTransitionPlan, type VersionedFormStorageAdapter, 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, matchesSubmissionPageFilters, normalizeSubmissionPageSize, pipeResponsesToCsvStream, populateSchemaTranslations, publishDraft, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, transformFieldType, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
610
+ export { type AccumulatorReport, type AccumulatorResponse, type AccumulatorSkipReason, 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 CsvColumnContext, type CsvColumnDef, type CsvExportOptions, type DeleteDraftOptions, 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 NodeWritableStream, type NumberField, type NumberQuestionAggregate, type NumericSummary, type OptionAggregate, type PagedSubmissionStorageAdapter, type PopulateTranslationOptions, type PublishDraftOptions, type PublishDraftResult, 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 StorageCommitError, type StreamCsvOptions, type SubmissionCursorValue, type SubmissionFilter, type SubmissionPage, type SubmissionPageQueryOptions, type SubmissionQueryOptions, type TextAnswerCursorValue, type TextAnswerItem, type TextAnswerPage, type TextField, type TextQuestionAggregate, type TranslationAdapter, type TranslationReport, type TranslationSlot, type ValidateFormSchemaOptions, type ValidationCode, type ValidationError, type ValidationIssue, type VersionTransitionError, type VersionTransitionEvent, type VersionTransitionPlan, type VersionedFormStorageAdapter, type WebhookConfig, type WebhookDispatchResult, aggregateResponses, assertValidFormSchema, assertVersionMutable, calculateChoiceDistribution, calculateCrossTabulation, calculateFieldVisibility, calculateNumericSummary, calculatePageVisibility, cloneVersionToDraft, collectSchemaLocales, createCloneTransitionPlan, createDeleteDraftTransitionPlan, createPublishTransitionPlan, createResponseAccumulator, createSubmission, decodeSubmissionCursor, decodeTextAnswerCursor, deleteDraft, dispatchWebhook, encodeSubmissionCursor, encodeTextAnswerCursor, escapeCsvCell, exportResponsesToCsv, exportResponsesToCsvStream, isDisplayConditionSatisfied, isQuestionVisible, jsonValuesEqual, matchesSubmissionFilter, matchesSubmissionPageFilters, normalizeSubmissionPageSize, pipeResponsesToCsvStream, populateSchemaTranslations, publishDraft, resolveFormTranslation, resolveLocalizedSchema, sanitizeSchema, selectVisibleAnswers, transformFieldType, validateAnswers, validateFormSchema, validatePageAnswers, validateSchemaStructure };
package/dist/index.js CHANGED
@@ -1286,7 +1286,7 @@ async function* exportResponsesToCsvStream(schema, submissions, options = {}) {
1286
1286
  formVersion: response.formVersion ?? schema.version,
1287
1287
  schema
1288
1288
  };
1289
- const customCells = customColumns.map((column) => column.getValue(context));
1289
+ const customCells = await Promise.all(customColumns.map((column) => column.getValue(context)));
1290
1290
  yield `\r
1291
1291
  ${[...defaultCells, ...customCells].map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",")}`;
1292
1292
  }
@@ -1504,6 +1504,24 @@ function decodeSubmissionCursor(cursor) {
1504
1504
  throw new TypeError("cursor must be a valid form-engine cursor.", { cause });
1505
1505
  }
1506
1506
  }
1507
+ function encodeTextAnswerCursor(value) {
1508
+ if (value.responseId.length === 0 || value.fieldId.length === 0) {
1509
+ throw new TypeError("Text answer cursor values must not be empty.");
1510
+ }
1511
+ return encodeBase64(new TextEncoder().encode(JSON.stringify(value)));
1512
+ }
1513
+ function decodeTextAnswerCursor(cursor) {
1514
+ try {
1515
+ const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64(cursor)));
1516
+ if (typeof parsed !== "object" || parsed === null || !("responseId" in parsed) || typeof parsed.responseId !== "string" || parsed.responseId.length === 0 || !("fieldId" in parsed) || typeof parsed.fieldId !== "string" || parsed.fieldId.length === 0) {
1517
+ throw new TypeError("text answer cursor payload is invalid.");
1518
+ }
1519
+ return { responseId: parsed.responseId, fieldId: parsed.fieldId };
1520
+ } catch (cause) {
1521
+ if (cause instanceof TypeError && cause.message === "text answer cursor payload is invalid.") throw cause;
1522
+ throw new TypeError("cursor must be a valid text answer cursor.", { cause });
1523
+ }
1524
+ }
1507
1525
  function normalizeSubmissionPageSize(pageSize, fallback = 100) {
1508
1526
  const value = pageSize ?? fallback;
1509
1527
  if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("pageSize must be a positive safe integer.");
@@ -1528,8 +1546,39 @@ function jsonValuesEqual(left, right) {
1528
1546
  const rightKeys = Object.keys(right);
1529
1547
  return leftKeys.length === rightKeys.length && leftKeys.every((key) => Object.hasOwn(right, key) && jsonValuesEqual(left[key], right[key]));
1530
1548
  }
1549
+ function readSubmissionPath(submission, path) {
1550
+ if (path.length === 0 || path.split(".").some((part) => part.length === 0 || part === "__proto__" || part === "constructor")) {
1551
+ return void 0;
1552
+ }
1553
+ let current = submission;
1554
+ for (const part of path.split(".")) {
1555
+ if (typeof current !== "object" || current === null || !Object.hasOwn(current, part)) return void 0;
1556
+ current = current[part];
1557
+ }
1558
+ return current;
1559
+ }
1560
+ function compareRangeValue(value, boundary, direction) {
1561
+ if (typeof value === "number" && typeof boundary === "number") {
1562
+ return direction === "from" ? value >= boundary : value <= boundary;
1563
+ }
1564
+ if (typeof value === "string" && typeof boundary === "string") {
1565
+ return direction === "from" ? value >= boundary : value <= boundary;
1566
+ }
1567
+ return false;
1568
+ }
1569
+ function matchesSubmissionFilter(submission, filter) {
1570
+ if (filter.op === "and") return filter.filters.every((item) => matchesSubmissionFilter(submission, item));
1571
+ if (filter.op === "or") return filter.filters.some((item) => matchesSubmissionFilter(submission, item));
1572
+ const value = readSubmissionPath(submission, filter.path);
1573
+ if (filter.op === "eq") return jsonValuesEqual(value, filter.value);
1574
+ if (filter.op === "in") return filter.values.some((candidate) => jsonValuesEqual(value, candidate));
1575
+ if (filter.op === "exists") return filter.value ? value !== void 0 : value === void 0;
1576
+ return (filter.from === void 0 || compareRangeValue(value, filter.from, "from")) && (filter.to === void 0 || compareRangeValue(value, filter.to, "to"));
1577
+ }
1531
1578
  function matchesSubmissionPageFilters(submission, options) {
1532
- if (options.filter !== void 0 && !options.filter(submission)) return false;
1579
+ if (options.filter !== void 0 && !(typeof options.filter === "function" ? options.filter(submission) : matchesSubmissionFilter(submission, options.filter))) {
1580
+ return false;
1581
+ }
1533
1582
  if (options.metadataFilters === void 0) return true;
1534
1583
  return Object.entries(options.metadataFilters).every(
1535
1584
  ([key, value]) => jsonValuesEqual(submission.metadata?.[key], value)
@@ -1988,6 +2037,21 @@ function validateState(state) {
1988
2037
  throw new TypeError("revision must be a non-negative safe integer.");
1989
2038
  }
1990
2039
  }
2040
+ function requireTimestamp(value, name) {
2041
+ const timestamp = value ?? "1970-01-01T00:00:00.000Z";
2042
+ if (!Number.isFinite(Date.parse(timestamp))) throw new TypeError(`${name} must be a valid date string.`);
2043
+ return timestamp;
2044
+ }
2045
+ function transitionEvent(type, state, nextState, affectedVersions, occurredAt) {
2046
+ return {
2047
+ type,
2048
+ formId: state.formId,
2049
+ fromRevision: state.revision,
2050
+ toRevision: nextState.revision,
2051
+ affectedVersions,
2052
+ occurredAt
2053
+ };
2054
+ }
1991
2055
  function cloneVersionToDraft(state, sourceSchema, options = {}) {
1992
2056
  validateState(state);
1993
2057
  if (sourceSchema.id !== state.formId) throw new TypeError("sourceSchema.id must match state.formId.");
@@ -2031,31 +2095,77 @@ function cloneVersionToDraft(state, sourceSchema, options = {}) {
2031
2095
  }
2032
2096
  };
2033
2097
  }
2034
- function publishDraft(state, draftSchema, options = {}) {
2098
+ function createCloneTransitionPlan(state, sourceRecord, options = {}) {
2099
+ if (sourceRecord.formId !== state.formId || sourceRecord.schema.id !== sourceRecord.formId || sourceRecord.schema.version !== sourceRecord.version || sourceRecord.status !== "published" && !options.allowedSourceVersions?.includes(sourceRecord.version)) {
2100
+ return {
2101
+ success: false,
2102
+ error: {
2103
+ type: "invalid_source_version",
2104
+ requestedVersion: sourceRecord.version,
2105
+ ...state.publishedVersion === void 0 ? {} : { publishedVersion: state.publishedVersion }
2106
+ }
2107
+ };
2108
+ }
2109
+ const result = cloneVersionToDraft(state, sourceRecord.schema, options);
2110
+ if (!result.success) return result;
2111
+ const timestamp = requireTimestamp(options.clonedAt, "clonedAt");
2112
+ const draftRecord = {
2113
+ formId: state.formId,
2114
+ version: result.value.draftSchema.version,
2115
+ status: "draft",
2116
+ schema: result.value.draftSchema,
2117
+ revision: 1,
2118
+ createdFromVersion: sourceRecord.version,
2119
+ createdAt: timestamp,
2120
+ ...options.metadata === void 0 ? {} : { metadata: options.metadata }
2121
+ };
2122
+ return {
2123
+ success: true,
2124
+ value: {
2125
+ nextState: result.value.nextState,
2126
+ plan: {
2127
+ formId: state.formId,
2128
+ expectedRevision: options.expectedRevision ?? state.revision,
2129
+ nextRevision: result.value.nextState.revision,
2130
+ draftToCreate: draftRecord,
2131
+ events: [transitionEvent("draft.created", state, result.value.nextState, [draftRecord.version], timestamp)],
2132
+ nextVersion: result.value.nextState.nextVersion,
2133
+ timestamp
2134
+ }
2135
+ }
2136
+ };
2137
+ }
2138
+ function validatePublishedRecord(state, record) {
2139
+ if (record === void 0) return;
2140
+ if (record.formId !== state.formId || record.version !== state.publishedVersion || record.status !== "published" || record.schema.id !== record.formId || record.schema.version !== record.version) {
2141
+ throw new TypeError("currentPublishedRecord must match the state's published version.");
2142
+ }
2143
+ }
2144
+ function transitionTimestamp(options) {
2145
+ return requireTimestamp(options.publishedAt ?? options.timestamp, "publishedAt");
2146
+ }
2147
+ async function publishDraft(state, draftSchema, options = {}) {
2035
2148
  validateState(state);
2036
2149
  const conflict = revisionConflict(state, options.expectedRevision);
2037
2150
  if (conflict !== void 0) return conflict;
2038
2151
  if (state.draftVersion === void 0 || draftSchema.id !== state.formId || draftSchema.version !== state.draftVersion) {
2039
2152
  return { success: false, error: { type: "draft_not_found" } };
2040
2153
  }
2041
- const validation = options.validate?.(draftSchema);
2154
+ validatePublishedRecord(state, options.currentPublishedRecord);
2155
+ const validation = await options.validate?.(draftSchema);
2042
2156
  if (validation === false || Array.isArray(validation) && validation.length > 0) {
2043
2157
  return {
2044
2158
  success: false,
2045
2159
  error: { type: "validation_failed", issues: Array.isArray(validation) ? validation : [] }
2046
2160
  };
2047
2161
  }
2048
- const timestamp = options.timestamp ?? "1970-01-01T00:00:00.000Z";
2049
- if (!Number.isFinite(Date.parse(timestamp))) throw new TypeError("timestamp must be a valid date string.");
2162
+ const timestamp = transitionTimestamp(options);
2050
2163
  const archivedVersion = state.publishedVersion;
2051
- const archivedRecords = archivedVersion === void 0 ? [] : [
2164
+ const archivedRecords = options.currentPublishedRecord === void 0 ? [] : [
2052
2165
  {
2053
- formId: state.formId,
2054
- version: archivedVersion,
2166
+ ...options.currentPublishedRecord,
2055
2167
  status: "archived",
2056
- schema: { ...draftSchema, version: archivedVersion },
2057
- createdAt: timestamp,
2058
- publishedAt: timestamp,
2168
+ revision: options.currentPublishedRecord.revision + 1,
2059
2169
  archivedAt: timestamp
2060
2170
  }
2061
2171
  ];
@@ -2073,6 +2183,8 @@ function publishDraft(state, draftSchema, options = {}) {
2073
2183
  version: draftSchema.version,
2074
2184
  status: "published",
2075
2185
  schema: draftSchema,
2186
+ revision: 1,
2187
+ ...archivedVersion === void 0 ? {} : { createdFromVersion: archivedVersion },
2076
2188
  createdAt: timestamp,
2077
2189
  publishedAt: timestamp
2078
2190
  },
@@ -2081,6 +2193,56 @@ function publishDraft(state, draftSchema, options = {}) {
2081
2193
  }
2082
2194
  };
2083
2195
  }
2196
+ async function createPublishTransitionPlan(state, draftRecord, options = {}) {
2197
+ if (draftRecord.formId !== state.formId || draftRecord.version !== state.draftVersion || draftRecord.schema.id !== draftRecord.formId || draftRecord.schema.version !== draftRecord.version) {
2198
+ return { success: false, error: { type: "draft_not_found" } };
2199
+ }
2200
+ if (draftRecord.status !== "draft") {
2201
+ return { success: false, error: { type: "version_immutable", status: draftRecord.status } };
2202
+ }
2203
+ const expectedRevision = options.expectedRevision ?? state.revision;
2204
+ const timestamp = transitionTimestamp(options);
2205
+ const result = await publishDraft(state, draftRecord.schema, {
2206
+ ...options,
2207
+ expectedRevision,
2208
+ publishedAt: timestamp
2209
+ });
2210
+ if (!result.success) return result;
2211
+ const publishedRecordToSave = {
2212
+ ...draftRecord,
2213
+ status: "published",
2214
+ revision: draftRecord.revision + 1,
2215
+ publishedAt: timestamp
2216
+ };
2217
+ return {
2218
+ success: true,
2219
+ value: {
2220
+ nextState: result.value.nextState,
2221
+ plan: {
2222
+ formId: state.formId,
2223
+ expectedRevision,
2224
+ nextRevision: result.value.nextState.revision,
2225
+ draftToDeleteVersion: draftRecord.version,
2226
+ publishedRecordToSave,
2227
+ archivedRecordsToSave: result.value.archivedRecords,
2228
+ events: [
2229
+ ...result.value.archivedRecords.length === 0 ? [] : [
2230
+ transitionEvent(
2231
+ "version.archived",
2232
+ state,
2233
+ result.value.nextState,
2234
+ result.value.archivedRecords.map((record) => record.version),
2235
+ timestamp
2236
+ )
2237
+ ],
2238
+ transitionEvent("version.published", state, result.value.nextState, [draftRecord.version], timestamp)
2239
+ ],
2240
+ nextVersion: result.value.nextState.nextVersion,
2241
+ timestamp
2242
+ }
2243
+ }
2244
+ };
2245
+ }
2084
2246
  function deleteDraft(state, options = {}) {
2085
2247
  validateState(state);
2086
2248
  const conflict = revisionConflict(state, options.expectedRevision);
@@ -2092,6 +2254,29 @@ function deleteDraft(state, options = {}) {
2092
2254
  value: { nextState: { ...stateWithoutDraft, revision: state.revision + 1 } }
2093
2255
  };
2094
2256
  }
2257
+ function createDeleteDraftTransitionPlan(state, draftRecord, options = {}) {
2258
+ if (draftRecord.formId !== state.formId || draftRecord.version !== state.draftVersion || draftRecord.status !== "draft" || draftRecord.schema.id !== state.formId || draftRecord.schema.version !== draftRecord.version) {
2259
+ return { success: false, error: { type: "draft_not_found" } };
2260
+ }
2261
+ const result = deleteDraft(state, options);
2262
+ if (!result.success) return result;
2263
+ const timestamp = requireTimestamp(options.deletedAt, "deletedAt");
2264
+ return {
2265
+ success: true,
2266
+ value: {
2267
+ nextState: result.value.nextState,
2268
+ plan: {
2269
+ formId: state.formId,
2270
+ expectedRevision: options.expectedRevision ?? state.revision,
2271
+ nextRevision: result.value.nextState.revision,
2272
+ draftToDeleteVersion: draftRecord.version,
2273
+ events: [transitionEvent("draft.deleted", state, result.value.nextState, [draftRecord.version], timestamp)],
2274
+ nextVersion: result.value.nextState.nextVersion,
2275
+ timestamp
2276
+ }
2277
+ }
2278
+ };
2279
+ }
2095
2280
  function assertVersionMutable(status) {
2096
2281
  if (status !== "draft") {
2097
2282
  const error = { type: "version_immutable", status };
@@ -2109,17 +2294,24 @@ export {
2109
2294
  calculatePageVisibility,
2110
2295
  cloneVersionToDraft,
2111
2296
  collectSchemaLocales,
2297
+ createCloneTransitionPlan,
2298
+ createDeleteDraftTransitionPlan,
2299
+ createPublishTransitionPlan,
2112
2300
  createResponseAccumulator,
2113
2301
  createSubmission,
2114
2302
  decodeSubmissionCursor,
2303
+ decodeTextAnswerCursor,
2115
2304
  deleteDraft,
2116
2305
  dispatchWebhook,
2117
2306
  encodeSubmissionCursor,
2307
+ encodeTextAnswerCursor,
2118
2308
  escapeCsvCell,
2119
2309
  exportResponsesToCsv,
2120
2310
  exportResponsesToCsvStream,
2121
2311
  isDisplayConditionSatisfied,
2122
2312
  isQuestionVisible,
2313
+ jsonValuesEqual,
2314
+ matchesSubmissionFilter,
2123
2315
  matchesSubmissionPageFilters,
2124
2316
  normalizeSubmissionPageSize,
2125
2317
  pipeResponsesToCsvStream,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/core",
3
- "version": "2.5.1",
3
+ "version": "2.7.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },