@form-engine-ts/core 2.6.0 → 2.8.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
@@ -77,11 +77,16 @@ Results are ordered by `submittedAt`, then submission ID. Both boundaries are in
77
77
  ## Versioning, incremental analytics, and paged storage
78
78
 
79
79
  `cloneVersionToDraft`, asynchronous `publishDraft`, and `deleteDraft` implement revision-checked version transitions as
80
- pure functions.
80
+ pure functions. `createCloneTransitionPlan`, `createPublishTransitionPlan`, and `createDeleteDraftTransitionPlan` produce
81
+ complete persistence plans with the next state, affected records, and immutable audit events.
82
+ Publishing when state already identifies a Published version requires its matching `currentPublishedRecord`; omission or
83
+ a version mismatch returns the typed `missing_published_record` error. The resulting archive preserves the original
84
+ schema, creation timestamp, and metadata.
81
85
  Clone/delete operations accept `expectedRevision`; cloning rejects non-published sources, publish validation failures are
82
86
  returned as typed `validation_failed` issues, and successful publishing archives only a supplied actual published record,
83
87
  preserving its schema and metadata. `createPublishTransitionPlan` returns complete records plus expected/next revisions for
84
- storage adapters implementing `VersionedFormStorageAdapter` to commit atomically.
88
+ storage adapters implementing `VersionedFormStorageAdapter` to commit atomically. Versioned adapters expose state/record
89
+ reads and return a typed `Result` from `commitVersionTransition`, including the actual revision on concurrency conflicts.
85
90
  `createResponseAccumulator` incrementally counts choices, answered/unanswered values, and numeric summaries without retaining
86
91
  free-text bodies. In lenient mode, mismatched responses are skipped and exposed by `addMany()` and `getReport()` instead of
87
92
  being included silently. Independent accumulators for the same schema can be merged, and `finalize()` matches
@@ -94,6 +99,9 @@ Formula-injection neutralization applies to both default and custom columns.
94
99
 
95
100
  Adapters implementing `PagedSubmissionStorageAdapter` expose `listSubmissionPage(formId, options)`. The opaque Base64
96
101
  cursor combines `submittedAt` and response ID, so equal timestamps do not produce gaps or duplicates. `metadataFilters`
97
- and `filter` are applied before page sizing.
102
+ and `filter` are applied before page sizing. `filter` accepts a composable `eq`/`in`/`range`/`exists` and/or AST; adapters
103
+ may push supported nodes to their native query language while preserving identical client-side semantics. Adapters that
104
+ implement `listTextAnswerPage` expose stable cursor pagination over individual text answers.
105
+ `TextAnswerPageQueryOptions.fieldIds` can select multiple free-text fields for item-level paging.
98
106
 
99
107
  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,18 +30,24 @@ __export(index_exports, {
30
30
  calculatePageVisibility: () => calculatePageVisibility,
31
31
  cloneVersionToDraft: () => cloneVersionToDraft,
32
32
  collectSchemaLocales: () => collectSchemaLocales,
33
+ createCloneTransitionPlan: () => createCloneTransitionPlan,
34
+ createDeleteDraftTransitionPlan: () => createDeleteDraftTransitionPlan,
33
35
  createPublishTransitionPlan: () => createPublishTransitionPlan,
34
36
  createResponseAccumulator: () => createResponseAccumulator,
35
37
  createSubmission: () => createSubmission,
36
38
  decodeSubmissionCursor: () => decodeSubmissionCursor,
39
+ decodeTextAnswerCursor: () => decodeTextAnswerCursor,
37
40
  deleteDraft: () => deleteDraft,
38
41
  dispatchWebhook: () => dispatchWebhook,
39
42
  encodeSubmissionCursor: () => encodeSubmissionCursor,
43
+ encodeTextAnswerCursor: () => encodeTextAnswerCursor,
40
44
  escapeCsvCell: () => escapeCsvCell,
41
45
  exportResponsesToCsv: () => exportResponsesToCsv,
42
46
  exportResponsesToCsvStream: () => exportResponsesToCsvStream,
43
47
  isDisplayConditionSatisfied: () => isDisplayConditionSatisfied,
44
48
  isQuestionVisible: () => isQuestionVisible,
49
+ jsonValuesEqual: () => jsonValuesEqual,
50
+ matchesSubmissionFilter: () => matchesSubmissionFilter,
45
51
  matchesSubmissionPageFilters: () => matchesSubmissionPageFilters,
46
52
  normalizeSubmissionPageSize: () => normalizeSubmissionPageSize,
47
53
  pipeResponsesToCsvStream: () => pipeResponsesToCsvStream,
@@ -1565,6 +1571,24 @@ function decodeSubmissionCursor(cursor) {
1565
1571
  throw new TypeError("cursor must be a valid form-engine cursor.", { cause });
1566
1572
  }
1567
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
+ }
1568
1592
  function normalizeSubmissionPageSize(pageSize, fallback = 100) {
1569
1593
  const value = pageSize ?? fallback;
1570
1594
  if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("pageSize must be a positive safe integer.");
@@ -1589,8 +1613,39 @@ function jsonValuesEqual(left, right) {
1589
1613
  const rightKeys = Object.keys(right);
1590
1614
  return leftKeys.length === rightKeys.length && leftKeys.every((key) => Object.hasOwn(right, key) && jsonValuesEqual(left[key], right[key]));
1591
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
+ }
1592
1645
  function matchesSubmissionPageFilters(submission, options) {
1593
- 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
+ }
1594
1649
  if (options.metadataFilters === void 0) return true;
1595
1650
  return Object.entries(options.metadataFilters).every(
1596
1651
  ([key, value]) => jsonValuesEqual(submission.metadata?.[key], value)
@@ -2049,6 +2104,21 @@ function validateState(state) {
2049
2104
  throw new TypeError("revision must be a non-negative safe integer.");
2050
2105
  }
2051
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
+ }
2052
2122
  function cloneVersionToDraft(state, sourceSchema, options = {}) {
2053
2123
  validateState(state);
2054
2124
  if (sourceSchema.id !== state.formId) throw new TypeError("sourceSchema.id must match state.formId.");
@@ -2092,16 +2162,61 @@ function cloneVersionToDraft(state, sourceSchema, options = {}) {
2092
2162
  }
2093
2163
  };
2094
2164
  }
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
+ }
2095
2205
  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) {
2206
+ if (state.publishedVersion !== void 0 && record?.version !== state.publishedVersion) {
2207
+ return {
2208
+ success: false,
2209
+ error: { type: "missing_published_record", expectedVersion: state.publishedVersion }
2210
+ };
2211
+ }
2212
+ if (record === void 0) return void 0;
2213
+ if (record.formId !== state.formId || record.status !== "published" || record.schema.id !== record.formId || record.schema.version !== record.version) {
2098
2214
  throw new TypeError("currentPublishedRecord must match the state's published version.");
2099
2215
  }
2216
+ return void 0;
2100
2217
  }
2101
2218
  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;
2219
+ return requireTimestamp(options.publishedAt ?? options.timestamp, "publishedAt");
2105
2220
  }
2106
2221
  async function publishDraft(state, draftSchema, options = {}) {
2107
2222
  validateState(state);
@@ -2110,7 +2225,8 @@ async function publishDraft(state, draftSchema, options = {}) {
2110
2225
  if (state.draftVersion === void 0 || draftSchema.id !== state.formId || draftSchema.version !== state.draftVersion) {
2111
2226
  return { success: false, error: { type: "draft_not_found" } };
2112
2227
  }
2113
- validatePublishedRecord(state, options.currentPublishedRecord);
2228
+ const publishedRecordError = validatePublishedRecord(state, options.currentPublishedRecord);
2229
+ if (publishedRecordError !== void 0) return publishedRecordError;
2114
2230
  const validation = await options.validate?.(draftSchema);
2115
2231
  if (validation === false || Array.isArray(validation) && validation.length > 0) {
2116
2232
  return {
@@ -2184,6 +2300,19 @@ async function createPublishTransitionPlan(state, draftRecord, options = {}) {
2184
2300
  draftToDeleteVersion: draftRecord.version,
2185
2301
  publishedRecordToSave,
2186
2302
  archivedRecordsToSave: result.value.archivedRecords,
2303
+ events: [
2304
+ ...result.value.archivedRecords.length === 0 ? [] : [
2305
+ transitionEvent(
2306
+ "version.archived",
2307
+ state,
2308
+ result.value.nextState,
2309
+ result.value.archivedRecords.map((record) => record.version),
2310
+ timestamp
2311
+ )
2312
+ ],
2313
+ transitionEvent("version.published", state, result.value.nextState, [draftRecord.version], timestamp)
2314
+ ],
2315
+ nextVersion: result.value.nextState.nextVersion,
2187
2316
  timestamp
2188
2317
  }
2189
2318
  }
@@ -2200,6 +2329,29 @@ function deleteDraft(state, options = {}) {
2200
2329
  value: { nextState: { ...stateWithoutDraft, revision: state.revision + 1 } }
2201
2330
  };
2202
2331
  }
2332
+ function createDeleteDraftTransitionPlan(state, draftRecord, options = {}) {
2333
+ if (draftRecord.formId !== state.formId || draftRecord.version !== state.draftVersion || draftRecord.status !== "draft" || draftRecord.schema.id !== state.formId || draftRecord.schema.version !== draftRecord.version) {
2334
+ return { success: false, error: { type: "draft_not_found" } };
2335
+ }
2336
+ const result = deleteDraft(state, options);
2337
+ if (!result.success) return result;
2338
+ const timestamp = requireTimestamp(options.deletedAt, "deletedAt");
2339
+ return {
2340
+ success: true,
2341
+ value: {
2342
+ nextState: result.value.nextState,
2343
+ plan: {
2344
+ formId: state.formId,
2345
+ expectedRevision: options.expectedRevision ?? state.revision,
2346
+ nextRevision: result.value.nextState.revision,
2347
+ draftToDeleteVersion: draftRecord.version,
2348
+ events: [transitionEvent("draft.deleted", state, result.value.nextState, [draftRecord.version], timestamp)],
2349
+ nextVersion: result.value.nextState.nextVersion,
2350
+ timestamp
2351
+ }
2352
+ }
2353
+ };
2354
+ }
2203
2355
  function assertVersionMutable(status) {
2204
2356
  if (status !== "draft") {
2205
2357
  const error = { type: "version_immutable", status };
@@ -2218,18 +2370,24 @@ function assertVersionMutable(status) {
2218
2370
  calculatePageVisibility,
2219
2371
  cloneVersionToDraft,
2220
2372
  collectSchemaLocales,
2373
+ createCloneTransitionPlan,
2374
+ createDeleteDraftTransitionPlan,
2221
2375
  createPublishTransitionPlan,
2222
2376
  createResponseAccumulator,
2223
2377
  createSubmission,
2224
2378
  decodeSubmissionCursor,
2379
+ decodeTextAnswerCursor,
2225
2380
  deleteDraft,
2226
2381
  dispatchWebhook,
2227
2382
  encodeSubmissionCursor,
2383
+ encodeTextAnswerCursor,
2228
2384
  escapeCsvCell,
2229
2385
  exportResponsesToCsv,
2230
2386
  exportResponsesToCsvStream,
2231
2387
  isDisplayConditionSatisfied,
2232
2388
  isQuestionVisible,
2389
+ jsonValuesEqual,
2390
+ matchesSubmissionFilter,
2233
2391
  matchesSubmissionPageFilters,
2234
2392
  normalizeSubmissionPageSize,
2235
2393
  pipeResponsesToCsvStream,
package/dist/index.d.cts CHANGED
@@ -24,11 +24,22 @@ interface FormVersionState {
24
24
  readonly nextVersion: number;
25
25
  readonly revision: number;
26
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
+ }
27
35
  type VersionTransitionError = {
28
36
  readonly type: "draft_already_exists";
29
37
  readonly currentDraftVersion: number;
30
38
  } | {
31
39
  readonly type: "draft_not_found";
40
+ } | {
41
+ readonly type: "missing_published_record";
42
+ readonly expectedVersion: number;
32
43
  } | {
33
44
  readonly type: "revision_conflict";
34
45
  readonly expectedRevision: number;
@@ -50,6 +61,8 @@ type VersionTransitionError = {
50
61
  interface CloneVersionOptions {
51
62
  readonly maxVersions?: number;
52
63
  readonly expectedRevision?: number;
64
+ readonly clonedAt?: string;
65
+ readonly metadata?: Readonly<Record<string, JsonValue>>;
53
66
  /** Additional known published versions that may be used as a clone source. */
54
67
  readonly allowedSourceVersions?: readonly number[];
55
68
  }
@@ -63,6 +76,7 @@ interface PublishDraftOptions {
63
76
  }
64
77
  interface DeleteDraftOptions {
65
78
  readonly expectedRevision?: number;
79
+ readonly deletedAt?: string;
66
80
  }
67
81
  interface PublishDraftResult {
68
82
  readonly nextState: FormVersionState;
@@ -75,6 +89,10 @@ declare function cloneVersionToDraft(state: FormVersionState, sourceSchema: Form
75
89
  readonly nextState: FormVersionState;
76
90
  readonly draftSchema: FormSchema;
77
91
  }, VersionTransitionError>;
92
+ declare function createCloneTransitionPlan(state: FormVersionState, sourceRecord: FormVersionRecord, options?: CloneVersionOptions): Result<{
93
+ readonly nextState: FormVersionState;
94
+ readonly plan: VersionTransitionPlan;
95
+ }, VersionTransitionError>;
78
96
  declare function publishDraft(state: FormVersionState, draftSchema: FormSchema, options?: PublishDraftOptions): Promise<Result<PublishDraftResult, VersionTransitionError>>;
79
97
  declare function createPublishTransitionPlan(state: FormVersionState, draftRecord: FormVersionRecord, options?: PublishDraftOptions): Promise<Result<{
80
98
  readonly nextState: FormVersionState;
@@ -83,6 +101,10 @@ declare function createPublishTransitionPlan(state: FormVersionState, draftRecor
83
101
  declare function deleteDraft(state: FormVersionState, options?: DeleteDraftOptions): Result<{
84
102
  readonly nextState: FormVersionState;
85
103
  }, VersionTransitionError>;
104
+ declare function createDeleteDraftTransitionPlan(state: FormVersionState, draftRecord: FormVersionRecord, options?: DeleteDraftOptions): Result<{
105
+ readonly nextState: FormVersionState;
106
+ readonly plan: VersionTransitionPlan;
107
+ }, VersionTransitionError>;
86
108
  declare function assertVersionMutable(status: FormVersionStatus): void;
87
109
 
88
110
  type FieldType = "text" | "textarea" | "number" | "rating" | "select" | "multi-select" | "checkbox" | "radio";
@@ -257,9 +279,37 @@ interface SubmissionPageQueryOptions {
257
279
  readonly since?: string;
258
280
  readonly until?: string;
259
281
  readonly locale?: string;
260
- readonly filter?: (submission: FormSubmission) => boolean;
282
+ readonly filter?: SubmissionFilter | ((submission: FormSubmission) => boolean);
283
+ /** @deprecated Prefer the generic filter AST. */
261
284
  readonly metadataFilters?: Readonly<Record<string, JsonValue>>;
262
285
  }
286
+ interface TextAnswerPageQueryOptions extends SubmissionPageQueryOptions {
287
+ readonly fieldIds?: readonly string[];
288
+ }
289
+ type SubmissionFilter = {
290
+ readonly op: "eq";
291
+ readonly path: string;
292
+ readonly value: JsonValue;
293
+ } | {
294
+ readonly op: "in";
295
+ readonly path: string;
296
+ readonly values: readonly JsonValue[];
297
+ } | {
298
+ readonly op: "range";
299
+ readonly path: string;
300
+ readonly from?: JsonValue;
301
+ readonly to?: JsonValue;
302
+ } | {
303
+ readonly op: "exists";
304
+ readonly path: string;
305
+ readonly value: boolean;
306
+ } | {
307
+ readonly op: "and";
308
+ readonly filters: readonly SubmissionFilter[];
309
+ } | {
310
+ readonly op: "or";
311
+ readonly filters: readonly SubmissionFilter[];
312
+ };
263
313
  interface SubmissionPage {
264
314
  readonly items: readonly FormSubmission[];
265
315
  readonly nextCursor?: string;
@@ -267,6 +317,22 @@ interface SubmissionPage {
267
317
  }
268
318
  interface PagedSubmissionStorageAdapter extends FormStorageAdapter {
269
319
  listSubmissionPage(formId: string, options?: SubmissionPageQueryOptions): Promise<SubmissionPage>;
320
+ listTextAnswerPage?(formId: string, fieldIdOrOptions?: string | TextAnswerPageQueryOptions, options?: TextAnswerPageQueryOptions): Promise<TextAnswerPage>;
321
+ }
322
+ interface TextAnswerItem {
323
+ readonly responseId: string;
324
+ readonly formId: string;
325
+ readonly formVersion: number;
326
+ readonly fieldId: string;
327
+ readonly text: string;
328
+ readonly locale?: string;
329
+ readonly submittedAt: string;
330
+ readonly metadata?: Readonly<Record<string, JsonValue>>;
331
+ }
332
+ interface TextAnswerPage {
333
+ readonly items: readonly TextAnswerItem[];
334
+ readonly nextCursor?: string;
335
+ readonly hasMore: boolean;
270
336
  }
271
337
  interface VersionTransitionPlan {
272
338
  readonly formId: string;
@@ -276,13 +342,32 @@ interface VersionTransitionPlan {
276
342
  readonly draftToDeleteVersion?: number;
277
343
  readonly publishedRecordToSave?: FormVersionRecord;
278
344
  readonly archivedRecordsToSave?: readonly FormVersionRecord[];
345
+ readonly events: readonly VersionTransitionEvent[];
346
+ /** The complete next state value used by persistent adapters. */
347
+ readonly nextVersion?: number;
279
348
  readonly timestamp: string;
280
349
  }
350
+ type StorageCommitError = {
351
+ readonly type: "revision_conflict";
352
+ readonly expectedRevision: number;
353
+ readonly actualRevision?: number;
354
+ } | {
355
+ readonly type: "draft_already_exists";
356
+ readonly currentDraftVersion: number;
357
+ } | {
358
+ readonly type: "invalid_transition";
359
+ readonly message: string;
360
+ } | {
361
+ readonly type: "storage_error";
362
+ readonly cause: unknown;
363
+ };
281
364
  interface VersionedFormStorageAdapter extends FormStorageAdapter {
282
- commitVersionTransition(plan: VersionTransitionPlan): Promise<{
283
- readonly success: boolean;
284
- readonly error?: string;
285
- }>;
365
+ getVersionState(formId: string): Promise<FormVersionState | null>;
366
+ getVersionRecord(formId: string, version: number): Promise<FormVersionRecord | null>;
367
+ listVersionRecords(formId: string): Promise<readonly FormVersionRecord[]>;
368
+ commitVersionTransition(plan: VersionTransitionPlan): Promise<Result<{
369
+ readonly success: true;
370
+ }, StorageCommitError>>;
286
371
  }
287
372
  interface BaseQuestionAggregate {
288
373
  readonly fieldId: string;
@@ -442,9 +527,17 @@ interface SubmissionCursorValue {
442
527
  readonly submittedAt: string;
443
528
  readonly responseId: string;
444
529
  }
530
+ interface TextAnswerCursorValue {
531
+ readonly responseId: string;
532
+ readonly fieldId: string;
533
+ }
445
534
  declare function encodeSubmissionCursor(value: SubmissionCursorValue): string;
446
535
  declare function decodeSubmissionCursor(cursor: string): SubmissionCursorValue;
536
+ declare function encodeTextAnswerCursor(value: TextAnswerCursorValue): string;
537
+ declare function decodeTextAnswerCursor(cursor: string): TextAnswerCursorValue;
447
538
  declare function normalizeSubmissionPageSize(pageSize: number | undefined, fallback?: number): number;
539
+ declare function jsonValuesEqual(left: JsonValue | undefined, right: JsonValue | undefined): boolean;
540
+ declare function matchesSubmissionFilter(submission: FormSubmission, filter: SubmissionFilter): boolean;
448
541
  declare function matchesSubmissionPageFilters(submission: FormSubmission, options: Pick<SubmissionPageQueryOptions, "filter" | "metadataFilters">): boolean;
449
542
 
450
543
  interface CollectedLocales {
@@ -520,4 +613,4 @@ declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Rea
520
613
  declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
521
614
  declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
522
615
 
523
- 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, createPublishTransitionPlan, 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 };
616
+ 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 TextAnswerPageQueryOptions, 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
@@ -24,11 +24,22 @@ interface FormVersionState {
24
24
  readonly nextVersion: number;
25
25
  readonly revision: number;
26
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
+ }
27
35
  type VersionTransitionError = {
28
36
  readonly type: "draft_already_exists";
29
37
  readonly currentDraftVersion: number;
30
38
  } | {
31
39
  readonly type: "draft_not_found";
40
+ } | {
41
+ readonly type: "missing_published_record";
42
+ readonly expectedVersion: number;
32
43
  } | {
33
44
  readonly type: "revision_conflict";
34
45
  readonly expectedRevision: number;
@@ -50,6 +61,8 @@ type VersionTransitionError = {
50
61
  interface CloneVersionOptions {
51
62
  readonly maxVersions?: number;
52
63
  readonly expectedRevision?: number;
64
+ readonly clonedAt?: string;
65
+ readonly metadata?: Readonly<Record<string, JsonValue>>;
53
66
  /** Additional known published versions that may be used as a clone source. */
54
67
  readonly allowedSourceVersions?: readonly number[];
55
68
  }
@@ -63,6 +76,7 @@ interface PublishDraftOptions {
63
76
  }
64
77
  interface DeleteDraftOptions {
65
78
  readonly expectedRevision?: number;
79
+ readonly deletedAt?: string;
66
80
  }
67
81
  interface PublishDraftResult {
68
82
  readonly nextState: FormVersionState;
@@ -75,6 +89,10 @@ declare function cloneVersionToDraft(state: FormVersionState, sourceSchema: Form
75
89
  readonly nextState: FormVersionState;
76
90
  readonly draftSchema: FormSchema;
77
91
  }, VersionTransitionError>;
92
+ declare function createCloneTransitionPlan(state: FormVersionState, sourceRecord: FormVersionRecord, options?: CloneVersionOptions): Result<{
93
+ readonly nextState: FormVersionState;
94
+ readonly plan: VersionTransitionPlan;
95
+ }, VersionTransitionError>;
78
96
  declare function publishDraft(state: FormVersionState, draftSchema: FormSchema, options?: PublishDraftOptions): Promise<Result<PublishDraftResult, VersionTransitionError>>;
79
97
  declare function createPublishTransitionPlan(state: FormVersionState, draftRecord: FormVersionRecord, options?: PublishDraftOptions): Promise<Result<{
80
98
  readonly nextState: FormVersionState;
@@ -83,6 +101,10 @@ declare function createPublishTransitionPlan(state: FormVersionState, draftRecor
83
101
  declare function deleteDraft(state: FormVersionState, options?: DeleteDraftOptions): Result<{
84
102
  readonly nextState: FormVersionState;
85
103
  }, VersionTransitionError>;
104
+ declare function createDeleteDraftTransitionPlan(state: FormVersionState, draftRecord: FormVersionRecord, options?: DeleteDraftOptions): Result<{
105
+ readonly nextState: FormVersionState;
106
+ readonly plan: VersionTransitionPlan;
107
+ }, VersionTransitionError>;
86
108
  declare function assertVersionMutable(status: FormVersionStatus): void;
87
109
 
88
110
  type FieldType = "text" | "textarea" | "number" | "rating" | "select" | "multi-select" | "checkbox" | "radio";
@@ -257,9 +279,37 @@ interface SubmissionPageQueryOptions {
257
279
  readonly since?: string;
258
280
  readonly until?: string;
259
281
  readonly locale?: string;
260
- readonly filter?: (submission: FormSubmission) => boolean;
282
+ readonly filter?: SubmissionFilter | ((submission: FormSubmission) => boolean);
283
+ /** @deprecated Prefer the generic filter AST. */
261
284
  readonly metadataFilters?: Readonly<Record<string, JsonValue>>;
262
285
  }
286
+ interface TextAnswerPageQueryOptions extends SubmissionPageQueryOptions {
287
+ readonly fieldIds?: readonly string[];
288
+ }
289
+ type SubmissionFilter = {
290
+ readonly op: "eq";
291
+ readonly path: string;
292
+ readonly value: JsonValue;
293
+ } | {
294
+ readonly op: "in";
295
+ readonly path: string;
296
+ readonly values: readonly JsonValue[];
297
+ } | {
298
+ readonly op: "range";
299
+ readonly path: string;
300
+ readonly from?: JsonValue;
301
+ readonly to?: JsonValue;
302
+ } | {
303
+ readonly op: "exists";
304
+ readonly path: string;
305
+ readonly value: boolean;
306
+ } | {
307
+ readonly op: "and";
308
+ readonly filters: readonly SubmissionFilter[];
309
+ } | {
310
+ readonly op: "or";
311
+ readonly filters: readonly SubmissionFilter[];
312
+ };
263
313
  interface SubmissionPage {
264
314
  readonly items: readonly FormSubmission[];
265
315
  readonly nextCursor?: string;
@@ -267,6 +317,22 @@ interface SubmissionPage {
267
317
  }
268
318
  interface PagedSubmissionStorageAdapter extends FormStorageAdapter {
269
319
  listSubmissionPage(formId: string, options?: SubmissionPageQueryOptions): Promise<SubmissionPage>;
320
+ listTextAnswerPage?(formId: string, fieldIdOrOptions?: string | TextAnswerPageQueryOptions, options?: TextAnswerPageQueryOptions): Promise<TextAnswerPage>;
321
+ }
322
+ interface TextAnswerItem {
323
+ readonly responseId: string;
324
+ readonly formId: string;
325
+ readonly formVersion: number;
326
+ readonly fieldId: string;
327
+ readonly text: string;
328
+ readonly locale?: string;
329
+ readonly submittedAt: string;
330
+ readonly metadata?: Readonly<Record<string, JsonValue>>;
331
+ }
332
+ interface TextAnswerPage {
333
+ readonly items: readonly TextAnswerItem[];
334
+ readonly nextCursor?: string;
335
+ readonly hasMore: boolean;
270
336
  }
271
337
  interface VersionTransitionPlan {
272
338
  readonly formId: string;
@@ -276,13 +342,32 @@ interface VersionTransitionPlan {
276
342
  readonly draftToDeleteVersion?: number;
277
343
  readonly publishedRecordToSave?: FormVersionRecord;
278
344
  readonly archivedRecordsToSave?: readonly FormVersionRecord[];
345
+ readonly events: readonly VersionTransitionEvent[];
346
+ /** The complete next state value used by persistent adapters. */
347
+ readonly nextVersion?: number;
279
348
  readonly timestamp: string;
280
349
  }
350
+ type StorageCommitError = {
351
+ readonly type: "revision_conflict";
352
+ readonly expectedRevision: number;
353
+ readonly actualRevision?: number;
354
+ } | {
355
+ readonly type: "draft_already_exists";
356
+ readonly currentDraftVersion: number;
357
+ } | {
358
+ readonly type: "invalid_transition";
359
+ readonly message: string;
360
+ } | {
361
+ readonly type: "storage_error";
362
+ readonly cause: unknown;
363
+ };
281
364
  interface VersionedFormStorageAdapter extends FormStorageAdapter {
282
- commitVersionTransition(plan: VersionTransitionPlan): Promise<{
283
- readonly success: boolean;
284
- readonly error?: string;
285
- }>;
365
+ getVersionState(formId: string): Promise<FormVersionState | null>;
366
+ getVersionRecord(formId: string, version: number): Promise<FormVersionRecord | null>;
367
+ listVersionRecords(formId: string): Promise<readonly FormVersionRecord[]>;
368
+ commitVersionTransition(plan: VersionTransitionPlan): Promise<Result<{
369
+ readonly success: true;
370
+ }, StorageCommitError>>;
286
371
  }
287
372
  interface BaseQuestionAggregate {
288
373
  readonly fieldId: string;
@@ -442,9 +527,17 @@ interface SubmissionCursorValue {
442
527
  readonly submittedAt: string;
443
528
  readonly responseId: string;
444
529
  }
530
+ interface TextAnswerCursorValue {
531
+ readonly responseId: string;
532
+ readonly fieldId: string;
533
+ }
445
534
  declare function encodeSubmissionCursor(value: SubmissionCursorValue): string;
446
535
  declare function decodeSubmissionCursor(cursor: string): SubmissionCursorValue;
536
+ declare function encodeTextAnswerCursor(value: TextAnswerCursorValue): string;
537
+ declare function decodeTextAnswerCursor(cursor: string): TextAnswerCursorValue;
447
538
  declare function normalizeSubmissionPageSize(pageSize: number | undefined, fallback?: number): number;
539
+ declare function jsonValuesEqual(left: JsonValue | undefined, right: JsonValue | undefined): boolean;
540
+ declare function matchesSubmissionFilter(submission: FormSubmission, filter: SubmissionFilter): boolean;
448
541
  declare function matchesSubmissionPageFilters(submission: FormSubmission, options: Pick<SubmissionPageQueryOptions, "filter" | "metadataFilters">): boolean;
449
542
 
450
543
  interface CollectedLocales {
@@ -520,4 +613,4 @@ declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Rea
520
613
  declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
521
614
  declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
522
615
 
523
- 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, createPublishTransitionPlan, 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 };
616
+ 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 TextAnswerPageQueryOptions, 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
@@ -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,16 +2095,61 @@ function cloneVersionToDraft(state, sourceSchema, options = {}) {
2031
2095
  }
2032
2096
  };
2033
2097
  }
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
+ }
2034
2138
  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) {
2139
+ if (state.publishedVersion !== void 0 && record?.version !== state.publishedVersion) {
2140
+ return {
2141
+ success: false,
2142
+ error: { type: "missing_published_record", expectedVersion: state.publishedVersion }
2143
+ };
2144
+ }
2145
+ if (record === void 0) return void 0;
2146
+ if (record.formId !== state.formId || record.status !== "published" || record.schema.id !== record.formId || record.schema.version !== record.version) {
2037
2147
  throw new TypeError("currentPublishedRecord must match the state's published version.");
2038
2148
  }
2149
+ return void 0;
2039
2150
  }
2040
2151
  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;
2152
+ return requireTimestamp(options.publishedAt ?? options.timestamp, "publishedAt");
2044
2153
  }
2045
2154
  async function publishDraft(state, draftSchema, options = {}) {
2046
2155
  validateState(state);
@@ -2049,7 +2158,8 @@ async function publishDraft(state, draftSchema, options = {}) {
2049
2158
  if (state.draftVersion === void 0 || draftSchema.id !== state.formId || draftSchema.version !== state.draftVersion) {
2050
2159
  return { success: false, error: { type: "draft_not_found" } };
2051
2160
  }
2052
- validatePublishedRecord(state, options.currentPublishedRecord);
2161
+ const publishedRecordError = validatePublishedRecord(state, options.currentPublishedRecord);
2162
+ if (publishedRecordError !== void 0) return publishedRecordError;
2053
2163
  const validation = await options.validate?.(draftSchema);
2054
2164
  if (validation === false || Array.isArray(validation) && validation.length > 0) {
2055
2165
  return {
@@ -2123,6 +2233,19 @@ async function createPublishTransitionPlan(state, draftRecord, options = {}) {
2123
2233
  draftToDeleteVersion: draftRecord.version,
2124
2234
  publishedRecordToSave,
2125
2235
  archivedRecordsToSave: result.value.archivedRecords,
2236
+ events: [
2237
+ ...result.value.archivedRecords.length === 0 ? [] : [
2238
+ transitionEvent(
2239
+ "version.archived",
2240
+ state,
2241
+ result.value.nextState,
2242
+ result.value.archivedRecords.map((record) => record.version),
2243
+ timestamp
2244
+ )
2245
+ ],
2246
+ transitionEvent("version.published", state, result.value.nextState, [draftRecord.version], timestamp)
2247
+ ],
2248
+ nextVersion: result.value.nextState.nextVersion,
2126
2249
  timestamp
2127
2250
  }
2128
2251
  }
@@ -2139,6 +2262,29 @@ function deleteDraft(state, options = {}) {
2139
2262
  value: { nextState: { ...stateWithoutDraft, revision: state.revision + 1 } }
2140
2263
  };
2141
2264
  }
2265
+ function createDeleteDraftTransitionPlan(state, draftRecord, options = {}) {
2266
+ if (draftRecord.formId !== state.formId || draftRecord.version !== state.draftVersion || draftRecord.status !== "draft" || draftRecord.schema.id !== state.formId || draftRecord.schema.version !== draftRecord.version) {
2267
+ return { success: false, error: { type: "draft_not_found" } };
2268
+ }
2269
+ const result = deleteDraft(state, options);
2270
+ if (!result.success) return result;
2271
+ const timestamp = requireTimestamp(options.deletedAt, "deletedAt");
2272
+ return {
2273
+ success: true,
2274
+ value: {
2275
+ nextState: result.value.nextState,
2276
+ plan: {
2277
+ formId: state.formId,
2278
+ expectedRevision: options.expectedRevision ?? state.revision,
2279
+ nextRevision: result.value.nextState.revision,
2280
+ draftToDeleteVersion: draftRecord.version,
2281
+ events: [transitionEvent("draft.deleted", state, result.value.nextState, [draftRecord.version], timestamp)],
2282
+ nextVersion: result.value.nextState.nextVersion,
2283
+ timestamp
2284
+ }
2285
+ }
2286
+ };
2287
+ }
2142
2288
  function assertVersionMutable(status) {
2143
2289
  if (status !== "draft") {
2144
2290
  const error = { type: "version_immutable", status };
@@ -2156,18 +2302,24 @@ export {
2156
2302
  calculatePageVisibility,
2157
2303
  cloneVersionToDraft,
2158
2304
  collectSchemaLocales,
2305
+ createCloneTransitionPlan,
2306
+ createDeleteDraftTransitionPlan,
2159
2307
  createPublishTransitionPlan,
2160
2308
  createResponseAccumulator,
2161
2309
  createSubmission,
2162
2310
  decodeSubmissionCursor,
2311
+ decodeTextAnswerCursor,
2163
2312
  deleteDraft,
2164
2313
  dispatchWebhook,
2165
2314
  encodeSubmissionCursor,
2315
+ encodeTextAnswerCursor,
2166
2316
  escapeCsvCell,
2167
2317
  exportResponsesToCsv,
2168
2318
  exportResponsesToCsvStream,
2169
2319
  isDisplayConditionSatisfied,
2170
2320
  isQuestionVisible,
2321
+ jsonValuesEqual,
2322
+ matchesSubmissionFilter,
2171
2323
  matchesSubmissionPageFilters,
2172
2324
  normalizeSubmissionPageSize,
2173
2325
  pipeResponsesToCsvStream,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/core",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },