@form-engine-ts/core 2.6.0 → 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
@@ -77,11 +77,13 @@ 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.
81
82
  Clone/delete operations accept `expectedRevision`; cloning rejects non-published sources, publish validation failures are
82
83
  returned as typed `validation_failed` issues, and successful publishing archives only a supplied actual published record,
83
84
  preserving its schema and metadata. `createPublishTransitionPlan` returns complete records plus expected/next revisions for
84
- storage adapters implementing `VersionedFormStorageAdapter` to commit atomically.
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.
85
87
  `createResponseAccumulator` incrementally counts choices, answered/unanswered values, and numeric summaries without retaining
86
88
  free-text bodies. In lenient mode, mismatched responses are skipped and exposed by `addMany()` and `getReport()` instead of
87
89
  being included silently. Independent accumulators for the same schema can be merged, and `finalize()` matches
@@ -94,6 +96,8 @@ Formula-injection neutralization applies to both default and custom columns.
94
96
 
95
97
  Adapters implementing `PagedSubmissionStorageAdapter` expose `listSubmissionPage(formId, options)`. The opaque Base64
96
98
  cursor combines `submittedAt` and response ID, so equal timestamps do not produce gaps or duplicates. `metadataFilters`
97
- 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.
98
102
 
99
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,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,6 +2162,46 @@ 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
2206
  if (record === void 0) return;
2097
2207
  if (record.formId !== state.formId || record.version !== state.publishedVersion || record.status !== "published" || record.schema.id !== record.formId || record.schema.version !== record.version) {
@@ -2099,9 +2209,7 @@ function validatePublishedRecord(state, record) {
2099
2209
  }
2100
2210
  }
2101
2211
  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;
2212
+ return requireTimestamp(options.publishedAt ?? options.timestamp, "publishedAt");
2105
2213
  }
2106
2214
  async function publishDraft(state, draftSchema, options = {}) {
2107
2215
  validateState(state);
@@ -2184,6 +2292,19 @@ async function createPublishTransitionPlan(state, draftRecord, options = {}) {
2184
2292
  draftToDeleteVersion: draftRecord.version,
2185
2293
  publishedRecordToSave,
2186
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,
2187
2308
  timestamp
2188
2309
  }
2189
2310
  }
@@ -2200,6 +2321,29 @@ function deleteDraft(state, options = {}) {
2200
2321
  value: { nextState: { ...stateWithoutDraft, revision: state.revision + 1 } }
2201
2322
  };
2202
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
+ }
2203
2347
  function assertVersionMutable(status) {
2204
2348
  if (status !== "draft") {
2205
2349
  const error = { type: "version_immutable", status };
@@ -2218,18 +2362,24 @@ function assertVersionMutable(status) {
2218
2362
  calculatePageVisibility,
2219
2363
  cloneVersionToDraft,
2220
2364
  collectSchemaLocales,
2365
+ createCloneTransitionPlan,
2366
+ createDeleteDraftTransitionPlan,
2221
2367
  createPublishTransitionPlan,
2222
2368
  createResponseAccumulator,
2223
2369
  createSubmission,
2224
2370
  decodeSubmissionCursor,
2371
+ decodeTextAnswerCursor,
2225
2372
  deleteDraft,
2226
2373
  dispatchWebhook,
2227
2374
  encodeSubmissionCursor,
2375
+ encodeTextAnswerCursor,
2228
2376
  escapeCsvCell,
2229
2377
  exportResponsesToCsv,
2230
2378
  exportResponsesToCsvStream,
2231
2379
  isDisplayConditionSatisfied,
2232
2380
  isQuestionVisible,
2381
+ jsonValuesEqual,
2382
+ matchesSubmissionFilter,
2233
2383
  matchesSubmissionPageFilters,
2234
2384
  normalizeSubmissionPageSize,
2235
2385
  pipeResponsesToCsvStream,
package/dist/index.d.cts CHANGED
@@ -24,6 +24,14 @@ 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;
@@ -50,6 +58,8 @@ type VersionTransitionError = {
50
58
  interface CloneVersionOptions {
51
59
  readonly maxVersions?: number;
52
60
  readonly expectedRevision?: number;
61
+ readonly clonedAt?: string;
62
+ readonly metadata?: Readonly<Record<string, JsonValue>>;
53
63
  /** Additional known published versions that may be used as a clone source. */
54
64
  readonly allowedSourceVersions?: readonly number[];
55
65
  }
@@ -63,6 +73,7 @@ interface PublishDraftOptions {
63
73
  }
64
74
  interface DeleteDraftOptions {
65
75
  readonly expectedRevision?: number;
76
+ readonly deletedAt?: string;
66
77
  }
67
78
  interface PublishDraftResult {
68
79
  readonly nextState: FormVersionState;
@@ -75,6 +86,10 @@ declare function cloneVersionToDraft(state: FormVersionState, sourceSchema: Form
75
86
  readonly nextState: FormVersionState;
76
87
  readonly draftSchema: FormSchema;
77
88
  }, VersionTransitionError>;
89
+ declare function createCloneTransitionPlan(state: FormVersionState, sourceRecord: FormVersionRecord, options?: CloneVersionOptions): Result<{
90
+ readonly nextState: FormVersionState;
91
+ readonly plan: VersionTransitionPlan;
92
+ }, VersionTransitionError>;
78
93
  declare function publishDraft(state: FormVersionState, draftSchema: FormSchema, options?: PublishDraftOptions): Promise<Result<PublishDraftResult, VersionTransitionError>>;
79
94
  declare function createPublishTransitionPlan(state: FormVersionState, draftRecord: FormVersionRecord, options?: PublishDraftOptions): Promise<Result<{
80
95
  readonly nextState: FormVersionState;
@@ -83,6 +98,10 @@ declare function createPublishTransitionPlan(state: FormVersionState, draftRecor
83
98
  declare function deleteDraft(state: FormVersionState, options?: DeleteDraftOptions): Result<{
84
99
  readonly nextState: FormVersionState;
85
100
  }, VersionTransitionError>;
101
+ declare function createDeleteDraftTransitionPlan(state: FormVersionState, draftRecord: FormVersionRecord, options?: DeleteDraftOptions): Result<{
102
+ readonly nextState: FormVersionState;
103
+ readonly plan: VersionTransitionPlan;
104
+ }, VersionTransitionError>;
86
105
  declare function assertVersionMutable(status: FormVersionStatus): void;
87
106
 
88
107
  type FieldType = "text" | "textarea" | "number" | "rating" | "select" | "multi-select" | "checkbox" | "radio";
@@ -257,9 +276,34 @@ interface SubmissionPageQueryOptions {
257
276
  readonly since?: string;
258
277
  readonly until?: string;
259
278
  readonly locale?: string;
260
- readonly filter?: (submission: FormSubmission) => boolean;
279
+ readonly filter?: SubmissionFilter | ((submission: FormSubmission) => boolean);
280
+ /** @deprecated Prefer the generic filter AST. */
261
281
  readonly metadataFilters?: Readonly<Record<string, JsonValue>>;
262
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
+ };
263
307
  interface SubmissionPage {
264
308
  readonly items: readonly FormSubmission[];
265
309
  readonly nextCursor?: string;
@@ -267,6 +311,22 @@ interface SubmissionPage {
267
311
  }
268
312
  interface PagedSubmissionStorageAdapter extends FormStorageAdapter {
269
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;
270
330
  }
271
331
  interface VersionTransitionPlan {
272
332
  readonly formId: string;
@@ -276,13 +336,32 @@ interface VersionTransitionPlan {
276
336
  readonly draftToDeleteVersion?: number;
277
337
  readonly publishedRecordToSave?: FormVersionRecord;
278
338
  readonly archivedRecordsToSave?: readonly FormVersionRecord[];
339
+ readonly events: readonly VersionTransitionEvent[];
340
+ /** The complete next state value used by persistent adapters. */
341
+ readonly nextVersion?: number;
279
342
  readonly timestamp: string;
280
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
+ };
281
358
  interface VersionedFormStorageAdapter extends FormStorageAdapter {
282
- commitVersionTransition(plan: VersionTransitionPlan): Promise<{
283
- readonly success: boolean;
284
- readonly error?: string;
285
- }>;
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>>;
286
365
  }
287
366
  interface BaseQuestionAggregate {
288
367
  readonly fieldId: string;
@@ -442,9 +521,17 @@ interface SubmissionCursorValue {
442
521
  readonly submittedAt: string;
443
522
  readonly responseId: string;
444
523
  }
524
+ interface TextAnswerCursorValue {
525
+ readonly responseId: string;
526
+ readonly fieldId: string;
527
+ }
445
528
  declare function encodeSubmissionCursor(value: SubmissionCursorValue): string;
446
529
  declare function decodeSubmissionCursor(cursor: string): SubmissionCursorValue;
530
+ declare function encodeTextAnswerCursor(value: TextAnswerCursorValue): string;
531
+ declare function decodeTextAnswerCursor(cursor: string): TextAnswerCursorValue;
447
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;
448
535
  declare function matchesSubmissionPageFilters(submission: FormSubmission, options: Pick<SubmissionPageQueryOptions, "filter" | "metadataFilters">): boolean;
449
536
 
450
537
  interface CollectedLocales {
@@ -520,4 +607,4 @@ declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Rea
520
607
  declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
521
608
  declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
522
609
 
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 };
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
@@ -24,6 +24,14 @@ 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;
@@ -50,6 +58,8 @@ type VersionTransitionError = {
50
58
  interface CloneVersionOptions {
51
59
  readonly maxVersions?: number;
52
60
  readonly expectedRevision?: number;
61
+ readonly clonedAt?: string;
62
+ readonly metadata?: Readonly<Record<string, JsonValue>>;
53
63
  /** Additional known published versions that may be used as a clone source. */
54
64
  readonly allowedSourceVersions?: readonly number[];
55
65
  }
@@ -63,6 +73,7 @@ interface PublishDraftOptions {
63
73
  }
64
74
  interface DeleteDraftOptions {
65
75
  readonly expectedRevision?: number;
76
+ readonly deletedAt?: string;
66
77
  }
67
78
  interface PublishDraftResult {
68
79
  readonly nextState: FormVersionState;
@@ -75,6 +86,10 @@ declare function cloneVersionToDraft(state: FormVersionState, sourceSchema: Form
75
86
  readonly nextState: FormVersionState;
76
87
  readonly draftSchema: FormSchema;
77
88
  }, VersionTransitionError>;
89
+ declare function createCloneTransitionPlan(state: FormVersionState, sourceRecord: FormVersionRecord, options?: CloneVersionOptions): Result<{
90
+ readonly nextState: FormVersionState;
91
+ readonly plan: VersionTransitionPlan;
92
+ }, VersionTransitionError>;
78
93
  declare function publishDraft(state: FormVersionState, draftSchema: FormSchema, options?: PublishDraftOptions): Promise<Result<PublishDraftResult, VersionTransitionError>>;
79
94
  declare function createPublishTransitionPlan(state: FormVersionState, draftRecord: FormVersionRecord, options?: PublishDraftOptions): Promise<Result<{
80
95
  readonly nextState: FormVersionState;
@@ -83,6 +98,10 @@ declare function createPublishTransitionPlan(state: FormVersionState, draftRecor
83
98
  declare function deleteDraft(state: FormVersionState, options?: DeleteDraftOptions): Result<{
84
99
  readonly nextState: FormVersionState;
85
100
  }, VersionTransitionError>;
101
+ declare function createDeleteDraftTransitionPlan(state: FormVersionState, draftRecord: FormVersionRecord, options?: DeleteDraftOptions): Result<{
102
+ readonly nextState: FormVersionState;
103
+ readonly plan: VersionTransitionPlan;
104
+ }, VersionTransitionError>;
86
105
  declare function assertVersionMutable(status: FormVersionStatus): void;
87
106
 
88
107
  type FieldType = "text" | "textarea" | "number" | "rating" | "select" | "multi-select" | "checkbox" | "radio";
@@ -257,9 +276,34 @@ interface SubmissionPageQueryOptions {
257
276
  readonly since?: string;
258
277
  readonly until?: string;
259
278
  readonly locale?: string;
260
- readonly filter?: (submission: FormSubmission) => boolean;
279
+ readonly filter?: SubmissionFilter | ((submission: FormSubmission) => boolean);
280
+ /** @deprecated Prefer the generic filter AST. */
261
281
  readonly metadataFilters?: Readonly<Record<string, JsonValue>>;
262
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
+ };
263
307
  interface SubmissionPage {
264
308
  readonly items: readonly FormSubmission[];
265
309
  readonly nextCursor?: string;
@@ -267,6 +311,22 @@ interface SubmissionPage {
267
311
  }
268
312
  interface PagedSubmissionStorageAdapter extends FormStorageAdapter {
269
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;
270
330
  }
271
331
  interface VersionTransitionPlan {
272
332
  readonly formId: string;
@@ -276,13 +336,32 @@ interface VersionTransitionPlan {
276
336
  readonly draftToDeleteVersion?: number;
277
337
  readonly publishedRecordToSave?: FormVersionRecord;
278
338
  readonly archivedRecordsToSave?: readonly FormVersionRecord[];
339
+ readonly events: readonly VersionTransitionEvent[];
340
+ /** The complete next state value used by persistent adapters. */
341
+ readonly nextVersion?: number;
279
342
  readonly timestamp: string;
280
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
+ };
281
358
  interface VersionedFormStorageAdapter extends FormStorageAdapter {
282
- commitVersionTransition(plan: VersionTransitionPlan): Promise<{
283
- readonly success: boolean;
284
- readonly error?: string;
285
- }>;
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>>;
286
365
  }
287
366
  interface BaseQuestionAggregate {
288
367
  readonly fieldId: string;
@@ -442,9 +521,17 @@ interface SubmissionCursorValue {
442
521
  readonly submittedAt: string;
443
522
  readonly responseId: string;
444
523
  }
524
+ interface TextAnswerCursorValue {
525
+ readonly responseId: string;
526
+ readonly fieldId: string;
527
+ }
445
528
  declare function encodeSubmissionCursor(value: SubmissionCursorValue): string;
446
529
  declare function decodeSubmissionCursor(cursor: string): SubmissionCursorValue;
530
+ declare function encodeTextAnswerCursor(value: TextAnswerCursorValue): string;
531
+ declare function decodeTextAnswerCursor(cursor: string): TextAnswerCursorValue;
447
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;
448
535
  declare function matchesSubmissionPageFilters(submission: FormSubmission, options: Pick<SubmissionPageQueryOptions, "filter" | "metadataFilters">): boolean;
449
536
 
450
537
  interface CollectedLocales {
@@ -520,4 +607,4 @@ declare function calculatePageVisibility(schema: FormSchema, currentAnswers: Rea
520
607
  declare function calculateFieldVisibility(schema: FormSchema, currentAnswers: Readonly<Record<string, unknown>>): Readonly<Record<string, boolean>>;
521
608
  declare function selectVisibleAnswers(schema: FormSchema, currentAnswers: FormValues): FormValues;
522
609
 
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 };
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
@@ -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,6 +2095,46 @@ 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
2139
  if (record === void 0) return;
2036
2140
  if (record.formId !== state.formId || record.version !== state.publishedVersion || record.status !== "published" || record.schema.id !== record.formId || record.schema.version !== record.version) {
@@ -2038,9 +2142,7 @@ function validatePublishedRecord(state, record) {
2038
2142
  }
2039
2143
  }
2040
2144
  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;
2145
+ return requireTimestamp(options.publishedAt ?? options.timestamp, "publishedAt");
2044
2146
  }
2045
2147
  async function publishDraft(state, draftSchema, options = {}) {
2046
2148
  validateState(state);
@@ -2123,6 +2225,19 @@ async function createPublishTransitionPlan(state, draftRecord, options = {}) {
2123
2225
  draftToDeleteVersion: draftRecord.version,
2124
2226
  publishedRecordToSave,
2125
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,
2126
2241
  timestamp
2127
2242
  }
2128
2243
  }
@@ -2139,6 +2254,29 @@ function deleteDraft(state, options = {}) {
2139
2254
  value: { nextState: { ...stateWithoutDraft, revision: state.revision + 1 } }
2140
2255
  };
2141
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
+ }
2142
2280
  function assertVersionMutable(status) {
2143
2281
  if (status !== "draft") {
2144
2282
  const error = { type: "version_immutable", status };
@@ -2156,18 +2294,24 @@ export {
2156
2294
  calculatePageVisibility,
2157
2295
  cloneVersionToDraft,
2158
2296
  collectSchemaLocales,
2297
+ createCloneTransitionPlan,
2298
+ createDeleteDraftTransitionPlan,
2159
2299
  createPublishTransitionPlan,
2160
2300
  createResponseAccumulator,
2161
2301
  createSubmission,
2162
2302
  decodeSubmissionCursor,
2303
+ decodeTextAnswerCursor,
2163
2304
  deleteDraft,
2164
2305
  dispatchWebhook,
2165
2306
  encodeSubmissionCursor,
2307
+ encodeTextAnswerCursor,
2166
2308
  escapeCsvCell,
2167
2309
  exportResponsesToCsv,
2168
2310
  exportResponsesToCsvStream,
2169
2311
  isDisplayConditionSatisfied,
2170
2312
  isQuestionVisible,
2313
+ jsonValuesEqual,
2314
+ matchesSubmissionFilter,
2171
2315
  matchesSubmissionPageFilters,
2172
2316
  normalizeSubmissionPageSize,
2173
2317
  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.7.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },