@form-engine-ts/storage-mongodb 2.5.1 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,4 +29,12 @@ The caller owns the MongoDB connection lifecycle. Collection names can be custom
29
29
 
30
30
  `listSubmissionPage(formId, { version, pageSize, cursor, since, until, locale })` performs bounded reads using the
31
31
  compound `submittedAt`/response-ID cursor. Run `createIndexes()` after upgrading to create the matching compound index.
32
- `metadataFilters` and custom `filter` predicates are applied before page sizing.
32
+ Metadata filters and the generic submission-filter AST are translated to MongoDB query operators before page sizing.
33
+ Legacy predicate filters remain supported and are applied client-side. `listTextAnswerPage` returns stable cursor pages
34
+ of individual text/textarea answers without loading every answer body at once.
35
+
36
+ Version records are stored in `form_versions`, with unique `(formId, version)` and `(formId, status)` indexes. Transition
37
+ state lives in `form_version_states`. `commitVersionTransition(plan)` compares `expectedRevision` atomically and returns
38
+ `revision_conflict` to losing concurrent publishers; on a real MongoDB client, the state and record changes are committed
39
+ in one transaction. Clone, publish, and draft-delete plans persist complete version state, affected records, and audit
40
+ events. State/record/list reads and typed commit errors are exposed through the full `VersionedFormStorageAdapter` API.
package/dist/index.cjs CHANGED
@@ -20,7 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
- createMongoDbStorage: () => createMongoDbStorage
23
+ createMongoDbStorage: () => createMongoDbStorage,
24
+ submissionFilterToMongo: () => submissionFilterToMongo
24
25
  });
25
26
  module.exports = __toCommonJS(index_exports);
26
27
  var import_core = require("@form-engine-ts/core");
@@ -42,6 +43,70 @@ function parseSubmission(value, location) {
42
43
  function schemaDocumentId(formId, formVersion) {
43
44
  return `schema:${encodeURIComponent(formId)}:${formVersion}`;
44
45
  }
46
+ function versionDocumentId(formId, formVersion) {
47
+ return `version:${encodeURIComponent(formId)}:${formVersion}`;
48
+ }
49
+ function versionEventDocumentId(formId, fromRevision, eventIndex) {
50
+ return `event:${encodeURIComponent(formId)}:${fromRevision}:${eventIndex}`;
51
+ }
52
+ function mongoSubmissionPath(path) {
53
+ if (!/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(path) || path.split(".").some((part) => part.startsWith("$"))) {
54
+ throw new TypeError(`Invalid submission filter path: ${path}`);
55
+ }
56
+ const [root, ...rest] = path.split(".");
57
+ const suffix = rest.length === 0 ? "" : `.${rest.join(".")}`;
58
+ if (root === "id" || root === "responseId") return `_id${suffix}`;
59
+ if (root === "formId" || root === "formVersion" || root === "submittedAt") return `${root}${suffix}`;
60
+ if (root === "locale") return `submission.locale${suffix}`;
61
+ return `submission.${path}`;
62
+ }
63
+ function submissionFilterToMongo(filter) {
64
+ if (filter.op === "and" || filter.op === "or") {
65
+ return { [filter.op === "and" ? "$and" : "$or"]: filter.filters.map(submissionFilterToMongo) };
66
+ }
67
+ const path = mongoSubmissionPath(filter.path);
68
+ if (filter.op === "eq") return { [path]: filter.value };
69
+ if (filter.op === "in") return { [path]: { $in: [...filter.values] } };
70
+ if (filter.op === "exists") return { [path]: { $exists: filter.value } };
71
+ return {
72
+ [path]: {
73
+ ...filter.from === void 0 ? {} : { $gte: filter.from },
74
+ ...filter.to === void 0 ? {} : { $lte: filter.to }
75
+ }
76
+ };
77
+ }
78
+ function assertVersionRecord(record, formId, expectedStatus) {
79
+ if (record.formId !== formId || record.schema.id !== formId || record.schema.version !== record.version || !Number.isSafeInteger(record.version) || record.version < 1 || !Number.isSafeInteger(record.revision) || record.revision < 0 || expectedStatus !== void 0 && record.status !== expectedStatus) {
80
+ throw new TypeError("Version transition record is inconsistent.");
81
+ }
82
+ (0, import_core.assertValidFormSchema)(record.schema);
83
+ }
84
+ function assertTransitionPlan(plan) {
85
+ if (plan.formId.trim().length === 0 || !Number.isSafeInteger(plan.expectedRevision) || plan.expectedRevision < 0 || plan.nextRevision !== plan.expectedRevision + 1 || !Number.isFinite(Date.parse(plan.timestamp))) {
86
+ throw new TypeError("Version transition plan is invalid.");
87
+ }
88
+ if (!Array.isArray(plan.events)) throw new TypeError("Version transition plan events are required.");
89
+ if (plan.draftToCreate !== void 0) assertVersionRecord(plan.draftToCreate, plan.formId, "draft");
90
+ if (plan.publishedRecordToSave !== void 0) {
91
+ assertVersionRecord(plan.publishedRecordToSave, plan.formId, "published");
92
+ }
93
+ for (const record of plan.archivedRecordsToSave ?? []) assertVersionRecord(record, plan.formId, "archived");
94
+ if (plan.draftToDeleteVersion !== void 0 && (!Number.isSafeInteger(plan.draftToDeleteVersion) || plan.draftToDeleteVersion < 1)) {
95
+ throw new TypeError("draftToDeleteVersion must be a positive safe integer.");
96
+ }
97
+ }
98
+ function parseVersionRecordDocument(document) {
99
+ assertVersionRecord(document.record, document.formId);
100
+ if (document._id !== versionDocumentId(document.formId, document.version) || document.record.version !== document.version || document.record.status !== document.status) {
101
+ throw new Error(`MongoDB version document "${String(document._id)}" has inconsistent metadata.`);
102
+ }
103
+ return cloneJson(document.record);
104
+ }
105
+ function isCasConflict(error) {
106
+ if (!isRecord(error)) return false;
107
+ if (error.code === 11e3 || error.code === 112 || error.code === 251) return true;
108
+ return typeof error.hasErrorLabel === "function" && error.hasErrorLabel("TransientTransactionError") === true;
109
+ }
45
110
  function parseSchemaDocument(document) {
46
111
  try {
47
112
  (0, import_core.assertValidFormSchema)(document.schema);
@@ -73,8 +138,96 @@ function createMongoDbStorage(options) {
73
138
  "form_responses",
74
139
  "responsesCollectionName"
75
140
  );
141
+ const versionsCollectionName = collectionName(
142
+ options.versionsCollectionName,
143
+ "form_versions",
144
+ "versionsCollectionName"
145
+ );
146
+ const versionStatesCollectionName = collectionName(
147
+ options.versionStatesCollectionName,
148
+ "form_version_states",
149
+ "versionStatesCollectionName"
150
+ );
151
+ const versionEventsCollectionName = "form_version_events";
76
152
  const schemas = options.db.collection(schemasCollectionName);
77
153
  const submissions = options.db.collection(responsesCollectionName);
154
+ const versions = options.db.collection(versionsCollectionName);
155
+ const versionStates = options.db.collection(versionStatesCollectionName);
156
+ const versionEvents = options.db.collection(versionEventsCollectionName);
157
+ const storageRevisionConflict = async (plan) => {
158
+ const actual = await versionStates.findOne({ _id: plan.formId });
159
+ return {
160
+ success: false,
161
+ error: {
162
+ type: "revision_conflict",
163
+ expectedRevision: plan.expectedRevision,
164
+ ...actual === null ? {} : { actualRevision: actual.revision }
165
+ }
166
+ };
167
+ };
168
+ const upsertVersionRecord = async (record, session) => {
169
+ const stored = cloneJson(record);
170
+ await versions.updateOne(
171
+ { _id: versionDocumentId(record.formId, record.version) },
172
+ { $set: { formId: record.formId, version: record.version, status: record.status, record: stored } },
173
+ { upsert: true, ...session === void 0 ? {} : { session } }
174
+ );
175
+ };
176
+ const applyVersionTransition = async (plan, session) => {
177
+ const inferredNextVersion = Math.max(
178
+ 1,
179
+ (plan.draftToCreate?.version ?? 0) + 1,
180
+ (plan.draftToDeleteVersion ?? 0) + 1,
181
+ (plan.publishedRecordToSave?.version ?? 0) + 1
182
+ );
183
+ const stateSet = {
184
+ revision: plan.nextRevision,
185
+ nextVersion: plan.nextVersion ?? inferredNextVersion,
186
+ updatedAt: plan.timestamp
187
+ };
188
+ if (plan.draftToCreate !== void 0) stateSet.draftVersion = plan.draftToCreate.version;
189
+ if (plan.publishedRecordToSave !== void 0) stateSet.publishedVersion = plan.publishedRecordToSave.version;
190
+ const stateUpdate = {
191
+ $set: stateSet,
192
+ ...plan.draftToDeleteVersion === void 0 ? {} : { $unset: { draftVersion: "" } }
193
+ };
194
+ const stateResult = await versionStates.updateOne(
195
+ { _id: plan.formId, revision: plan.expectedRevision },
196
+ stateUpdate,
197
+ {
198
+ upsert: plan.expectedRevision === 0,
199
+ ...session === void 0 ? {} : { session }
200
+ }
201
+ );
202
+ if (stateResult.matchedCount === 0 && stateResult.upsertedCount === 0) {
203
+ const actual = await versionStates.findOne({ _id: plan.formId }, session === void 0 ? {} : { session });
204
+ return {
205
+ success: false,
206
+ error: {
207
+ type: "revision_conflict",
208
+ expectedRevision: plan.expectedRevision,
209
+ ...actual === null ? {} : { actualRevision: actual.revision }
210
+ }
211
+ };
212
+ }
213
+ if (plan.draftToDeleteVersion !== void 0) {
214
+ await versions.deleteOne(
215
+ { _id: versionDocumentId(plan.formId, plan.draftToDeleteVersion) },
216
+ session === void 0 ? {} : { session }
217
+ );
218
+ }
219
+ if (plan.draftToCreate !== void 0) await upsertVersionRecord(plan.draftToCreate, session);
220
+ if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
221
+ for (const record of plan.archivedRecordsToSave ?? []) await upsertVersionRecord(record, session);
222
+ for (const [eventIndex, event] of plan.events.entries()) {
223
+ await versionEvents.updateOne(
224
+ { _id: versionEventDocumentId(plan.formId, plan.expectedRevision, eventIndex) },
225
+ { $set: { formId: plan.formId, fromRevision: plan.expectedRevision, eventIndex, event: cloneJson(event) } },
226
+ { upsert: true, ...session === void 0 ? {} : { session } }
227
+ );
228
+ }
229
+ return { success: true, value: { success: true } };
230
+ };
78
231
  return {
79
232
  async saveSchema(schema) {
80
233
  (0, import_core.assertValidFormSchema)(schema);
@@ -125,7 +278,7 @@ function createMongoDbStorage(options) {
125
278
  ...options2.since === void 0 ? {} : { $gte: options2.since },
126
279
  ...options2.until === void 0 ? {} : { $lte: options2.until }
127
280
  };
128
- const filter = {
281
+ const baseFilter = {
129
282
  formId,
130
283
  ...options2.version === void 0 ? {} : { formVersion: options2.version },
131
284
  ...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
@@ -137,8 +290,16 @@ function createMongoDbStorage(options) {
137
290
  ]
138
291
  }
139
292
  };
293
+ const serverFilters = [baseFilter];
294
+ if (options2.filter !== void 0 && typeof options2.filter !== "function") {
295
+ serverFilters.push(submissionFilterToMongo(options2.filter));
296
+ }
297
+ for (const [key, value] of Object.entries(options2.metadataFilters ?? {})) {
298
+ serverFilters.push({ [`submission.metadata.${key}`]: value });
299
+ }
300
+ const filter = serverFilters.length === 1 ? baseFilter : { $and: serverFilters };
140
301
  const sorted = submissions.find(filter).sort({ submittedAt: 1, _id: 1 });
141
- const requiresClientFiltering = options2.filter !== void 0 || options2.metadataFilters !== void 0;
302
+ const requiresClientFiltering = typeof options2.filter === "function";
142
303
  const documents = requiresClientFiltering ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
143
304
  const candidates = documents.map(parseSubmissionDocument).filter((item) => (0, import_core.matchesSubmissionPageFilters)(item, options2));
144
305
  const hasMore = candidates.length > pageSize;
@@ -150,6 +311,60 @@ function createMongoDbStorage(options) {
150
311
  ...hasMore && last !== void 0 ? { nextCursor: (0, import_core.encodeSubmissionCursor)({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
151
312
  };
152
313
  },
314
+ async listTextAnswerPage(formId, fieldId, options2 = {}) {
315
+ if (fieldId.trim().length === 0) throw new TypeError("fieldId must not be empty.");
316
+ const pageSize = (0, import_core.normalizeSubmissionPageSize)(options2.pageSize);
317
+ const cursor = options2.cursor === void 0 ? void 0 : (0, import_core.decodeTextAnswerCursor)(options2.cursor);
318
+ if (cursor !== void 0 && cursor.fieldId !== fieldId) {
319
+ throw new TypeError("Text answer cursor fieldId does not match the query.");
320
+ }
321
+ const submittedAt = options2.since === void 0 && options2.until === void 0 ? void 0 : {
322
+ ...options2.since === void 0 ? {} : { $gte: options2.since },
323
+ ...options2.until === void 0 ? {} : { $lte: options2.until }
324
+ };
325
+ const baseFilter = {
326
+ formId,
327
+ ...options2.version === void 0 ? {} : { formVersion: options2.version },
328
+ ...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
329
+ ...submittedAt === void 0 ? {} : { submittedAt },
330
+ ...cursor === void 0 ? {} : { _id: { $gt: cursor.responseId } },
331
+ [`submission.values.${fieldId}`]: { $type: "string" }
332
+ };
333
+ const serverFilters = [baseFilter];
334
+ if (options2.filter !== void 0 && typeof options2.filter !== "function") {
335
+ serverFilters.push(submissionFilterToMongo(options2.filter));
336
+ }
337
+ for (const [key, value] of Object.entries(options2.metadataFilters ?? {})) {
338
+ serverFilters.push({ [`submission.metadata.${key}`]: value });
339
+ }
340
+ const filter = serverFilters.length === 1 ? baseFilter : { $and: serverFilters };
341
+ const sorted = submissions.find(filter).sort({ _id: 1 });
342
+ const documents = typeof options2.filter === "function" ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
343
+ const candidates = documents.map(parseSubmissionDocument).filter((submission) => (0, import_core.matchesSubmissionPageFilters)(submission, options2)).flatMap((submission) => {
344
+ const text = submission.values[fieldId];
345
+ if (typeof text !== "string") return [];
346
+ return [
347
+ {
348
+ responseId: submission.id,
349
+ formId: submission.formId,
350
+ formVersion: submission.formVersion,
351
+ fieldId,
352
+ text,
353
+ locale: submission.locale,
354
+ submittedAt: submission.submittedAt,
355
+ ...submission.metadata === void 0 ? {} : { metadata: submission.metadata }
356
+ }
357
+ ];
358
+ });
359
+ const hasMore = candidates.length > pageSize;
360
+ const items = candidates.slice(0, pageSize);
361
+ const last = items.at(-1);
362
+ return {
363
+ items,
364
+ hasMore,
365
+ ...hasMore && last !== void 0 ? { nextCursor: (0, import_core.encodeTextAnswerCursor)({ responseId: last.responseId, fieldId }) } : {}
366
+ };
367
+ },
153
368
  async deleteSubmission(submissionId) {
154
369
  await submissions.deleteOne({ _id: submissionId });
155
370
  },
@@ -157,17 +372,94 @@ function createMongoDbStorage(options) {
157
372
  await submissions.deleteMany({ formId });
158
373
  },
159
374
  async clear() {
160
- await Promise.all([schemas.deleteMany({}), submissions.deleteMany({})]);
375
+ await Promise.all([
376
+ schemas.deleteMany({}),
377
+ submissions.deleteMany({}),
378
+ versions.deleteMany({}),
379
+ versionStates.deleteMany({}),
380
+ versionEvents.deleteMany({})
381
+ ]);
382
+ },
383
+ async getVersionState(formId) {
384
+ const document = await versionStates.findOne({ _id: formId });
385
+ if (document === null) return null;
386
+ if (!Number.isSafeInteger(document.revision) || document.revision < 0) {
387
+ throw new Error(`MongoDB version state "${formId}" is invalid.`);
388
+ }
389
+ const nextVersion = document.nextVersion ?? Math.max(1, (document.draftVersion ?? 0) + 1, (document.publishedVersion ?? 0) + 1);
390
+ return {
391
+ formId,
392
+ revision: document.revision,
393
+ nextVersion,
394
+ ...document.draftVersion === void 0 ? {} : { draftVersion: document.draftVersion },
395
+ ...document.publishedVersion === void 0 ? {} : { publishedVersion: document.publishedVersion }
396
+ };
397
+ },
398
+ async getVersionRecord(formId, version) {
399
+ const document = await versions.findOne({ _id: versionDocumentId(formId, version) });
400
+ return document === null ? null : parseVersionRecordDocument(document);
401
+ },
402
+ async listVersionRecords(formId) {
403
+ return (await versions.find({ formId }).sort({ version: 1 }).toArray()).map(parseVersionRecordDocument);
404
+ },
405
+ async commitVersionTransition(plan) {
406
+ try {
407
+ assertTransitionPlan(plan);
408
+ } catch (cause) {
409
+ return {
410
+ success: false,
411
+ error: { type: "invalid_transition", message: cause instanceof Error ? cause.message : String(cause) }
412
+ };
413
+ }
414
+ const client = options.db.client;
415
+ if (client === void 0 || typeof client.startSession !== "function") {
416
+ try {
417
+ return await applyVersionTransition(plan);
418
+ } catch (error) {
419
+ if (isCasConflict(error)) {
420
+ return storageRevisionConflict(plan);
421
+ }
422
+ return { success: false, error: { type: "storage_error", cause: error } };
423
+ }
424
+ }
425
+ const session = client.startSession();
426
+ try {
427
+ let result = {
428
+ success: false,
429
+ error: { type: "revision_conflict", expectedRevision: plan.expectedRevision }
430
+ };
431
+ await session.withTransaction(async () => {
432
+ result = await applyVersionTransition(plan, session);
433
+ });
434
+ return result;
435
+ } catch (error) {
436
+ if (isCasConflict(error)) {
437
+ return storageRevisionConflict(plan);
438
+ }
439
+ return { success: false, error: { type: "storage_error", cause: error } };
440
+ } finally {
441
+ await session.endSession();
442
+ }
161
443
  },
162
444
  async createIndexes() {
163
- await submissions.createIndexes([
164
- { key: { formId: 1, submittedAt: 1, _id: 1 }, name: "form_responses_form_submitted_at_id" },
165
- { key: { "submission.locale": 1 }, name: "form_responses_locale" }
445
+ await Promise.all([
446
+ submissions.createIndexes([
447
+ { key: { formId: 1, submittedAt: 1, _id: 1 }, name: "form_responses_form_submitted_at_id" },
448
+ { key: { "submission.locale": 1 }, name: "form_responses_locale" }
449
+ ]),
450
+ versions.createIndexes([
451
+ { key: { formId: 1, version: 1 }, name: "form_versions_form_version", unique: true },
452
+ { key: { formId: 1, status: 1 }, name: "form_versions_form_status" }
453
+ ]),
454
+ versionEvents.createIndexes([
455
+ { key: { formId: 1, fromRevision: 1, eventIndex: 1 }, name: "form_version_events_revision" }
456
+ ])
166
457
  ]);
167
458
  }
168
459
  };
169
460
  }
170
461
  // Annotate the CommonJS export names for ESM import in node:
171
462
  0 && (module.exports = {
172
- createMongoDbStorage
463
+ createMongoDbStorage,
464
+ submissionFilterToMongo
173
465
  });
package/dist/index.d.cts CHANGED
@@ -1,14 +1,17 @@
1
- import { PagedSubmissionStorageAdapter } from '@form-engine-ts/core';
2
- import { Db } from 'mongodb';
1
+ import { PagedSubmissionStorageAdapter, VersionedFormStorageAdapter, SubmissionFilter } from '@form-engine-ts/core';
2
+ import { Db, Document } from 'mongodb';
3
3
 
4
4
  interface MongoDbStorageOptions {
5
5
  readonly db: Db;
6
6
  readonly schemasCollectionName?: string;
7
7
  readonly responsesCollectionName?: string;
8
+ readonly versionsCollectionName?: string;
9
+ readonly versionStatesCollectionName?: string;
8
10
  }
9
- interface MongoDbStorageAdapter extends PagedSubmissionStorageAdapter {
11
+ interface MongoDbStorageAdapter extends PagedSubmissionStorageAdapter, VersionedFormStorageAdapter {
10
12
  createIndexes(): Promise<void>;
11
13
  }
14
+ declare function submissionFilterToMongo(filter: SubmissionFilter): Document;
12
15
  declare function createMongoDbStorage(options: MongoDbStorageOptions): MongoDbStorageAdapter;
13
16
 
14
- export { type MongoDbStorageAdapter, type MongoDbStorageOptions, createMongoDbStorage };
17
+ export { type MongoDbStorageAdapter, type MongoDbStorageOptions, createMongoDbStorage, submissionFilterToMongo };
package/dist/index.d.ts CHANGED
@@ -1,14 +1,17 @@
1
- import { PagedSubmissionStorageAdapter } from '@form-engine-ts/core';
2
- import { Db } from 'mongodb';
1
+ import { PagedSubmissionStorageAdapter, VersionedFormStorageAdapter, SubmissionFilter } from '@form-engine-ts/core';
2
+ import { Db, Document } from 'mongodb';
3
3
 
4
4
  interface MongoDbStorageOptions {
5
5
  readonly db: Db;
6
6
  readonly schemasCollectionName?: string;
7
7
  readonly responsesCollectionName?: string;
8
+ readonly versionsCollectionName?: string;
9
+ readonly versionStatesCollectionName?: string;
8
10
  }
9
- interface MongoDbStorageAdapter extends PagedSubmissionStorageAdapter {
11
+ interface MongoDbStorageAdapter extends PagedSubmissionStorageAdapter, VersionedFormStorageAdapter {
10
12
  createIndexes(): Promise<void>;
11
13
  }
14
+ declare function submissionFilterToMongo(filter: SubmissionFilter): Document;
12
15
  declare function createMongoDbStorage(options: MongoDbStorageOptions): MongoDbStorageAdapter;
13
16
 
14
- export { type MongoDbStorageAdapter, type MongoDbStorageOptions, createMongoDbStorage };
17
+ export { type MongoDbStorageAdapter, type MongoDbStorageOptions, createMongoDbStorage, submissionFilterToMongo };
package/dist/index.js CHANGED
@@ -2,7 +2,9 @@
2
2
  import {
3
3
  assertValidFormSchema,
4
4
  decodeSubmissionCursor,
5
+ decodeTextAnswerCursor,
5
6
  encodeSubmissionCursor,
7
+ encodeTextAnswerCursor,
6
8
  matchesSubmissionPageFilters,
7
9
  normalizeSubmissionPageSize
8
10
  } from "@form-engine-ts/core";
@@ -24,6 +26,70 @@ function parseSubmission(value, location) {
24
26
  function schemaDocumentId(formId, formVersion) {
25
27
  return `schema:${encodeURIComponent(formId)}:${formVersion}`;
26
28
  }
29
+ function versionDocumentId(formId, formVersion) {
30
+ return `version:${encodeURIComponent(formId)}:${formVersion}`;
31
+ }
32
+ function versionEventDocumentId(formId, fromRevision, eventIndex) {
33
+ return `event:${encodeURIComponent(formId)}:${fromRevision}:${eventIndex}`;
34
+ }
35
+ function mongoSubmissionPath(path) {
36
+ if (!/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(path) || path.split(".").some((part) => part.startsWith("$"))) {
37
+ throw new TypeError(`Invalid submission filter path: ${path}`);
38
+ }
39
+ const [root, ...rest] = path.split(".");
40
+ const suffix = rest.length === 0 ? "" : `.${rest.join(".")}`;
41
+ if (root === "id" || root === "responseId") return `_id${suffix}`;
42
+ if (root === "formId" || root === "formVersion" || root === "submittedAt") return `${root}${suffix}`;
43
+ if (root === "locale") return `submission.locale${suffix}`;
44
+ return `submission.${path}`;
45
+ }
46
+ function submissionFilterToMongo(filter) {
47
+ if (filter.op === "and" || filter.op === "or") {
48
+ return { [filter.op === "and" ? "$and" : "$or"]: filter.filters.map(submissionFilterToMongo) };
49
+ }
50
+ const path = mongoSubmissionPath(filter.path);
51
+ if (filter.op === "eq") return { [path]: filter.value };
52
+ if (filter.op === "in") return { [path]: { $in: [...filter.values] } };
53
+ if (filter.op === "exists") return { [path]: { $exists: filter.value } };
54
+ return {
55
+ [path]: {
56
+ ...filter.from === void 0 ? {} : { $gte: filter.from },
57
+ ...filter.to === void 0 ? {} : { $lte: filter.to }
58
+ }
59
+ };
60
+ }
61
+ function assertVersionRecord(record, formId, expectedStatus) {
62
+ if (record.formId !== formId || record.schema.id !== formId || record.schema.version !== record.version || !Number.isSafeInteger(record.version) || record.version < 1 || !Number.isSafeInteger(record.revision) || record.revision < 0 || expectedStatus !== void 0 && record.status !== expectedStatus) {
63
+ throw new TypeError("Version transition record is inconsistent.");
64
+ }
65
+ assertValidFormSchema(record.schema);
66
+ }
67
+ function assertTransitionPlan(plan) {
68
+ if (plan.formId.trim().length === 0 || !Number.isSafeInteger(plan.expectedRevision) || plan.expectedRevision < 0 || plan.nextRevision !== plan.expectedRevision + 1 || !Number.isFinite(Date.parse(plan.timestamp))) {
69
+ throw new TypeError("Version transition plan is invalid.");
70
+ }
71
+ if (!Array.isArray(plan.events)) throw new TypeError("Version transition plan events are required.");
72
+ if (plan.draftToCreate !== void 0) assertVersionRecord(plan.draftToCreate, plan.formId, "draft");
73
+ if (plan.publishedRecordToSave !== void 0) {
74
+ assertVersionRecord(plan.publishedRecordToSave, plan.formId, "published");
75
+ }
76
+ for (const record of plan.archivedRecordsToSave ?? []) assertVersionRecord(record, plan.formId, "archived");
77
+ if (plan.draftToDeleteVersion !== void 0 && (!Number.isSafeInteger(plan.draftToDeleteVersion) || plan.draftToDeleteVersion < 1)) {
78
+ throw new TypeError("draftToDeleteVersion must be a positive safe integer.");
79
+ }
80
+ }
81
+ function parseVersionRecordDocument(document) {
82
+ assertVersionRecord(document.record, document.formId);
83
+ if (document._id !== versionDocumentId(document.formId, document.version) || document.record.version !== document.version || document.record.status !== document.status) {
84
+ throw new Error(`MongoDB version document "${String(document._id)}" has inconsistent metadata.`);
85
+ }
86
+ return cloneJson(document.record);
87
+ }
88
+ function isCasConflict(error) {
89
+ if (!isRecord(error)) return false;
90
+ if (error.code === 11e3 || error.code === 112 || error.code === 251) return true;
91
+ return typeof error.hasErrorLabel === "function" && error.hasErrorLabel("TransientTransactionError") === true;
92
+ }
27
93
  function parseSchemaDocument(document) {
28
94
  try {
29
95
  assertValidFormSchema(document.schema);
@@ -55,8 +121,96 @@ function createMongoDbStorage(options) {
55
121
  "form_responses",
56
122
  "responsesCollectionName"
57
123
  );
124
+ const versionsCollectionName = collectionName(
125
+ options.versionsCollectionName,
126
+ "form_versions",
127
+ "versionsCollectionName"
128
+ );
129
+ const versionStatesCollectionName = collectionName(
130
+ options.versionStatesCollectionName,
131
+ "form_version_states",
132
+ "versionStatesCollectionName"
133
+ );
134
+ const versionEventsCollectionName = "form_version_events";
58
135
  const schemas = options.db.collection(schemasCollectionName);
59
136
  const submissions = options.db.collection(responsesCollectionName);
137
+ const versions = options.db.collection(versionsCollectionName);
138
+ const versionStates = options.db.collection(versionStatesCollectionName);
139
+ const versionEvents = options.db.collection(versionEventsCollectionName);
140
+ const storageRevisionConflict = async (plan) => {
141
+ const actual = await versionStates.findOne({ _id: plan.formId });
142
+ return {
143
+ success: false,
144
+ error: {
145
+ type: "revision_conflict",
146
+ expectedRevision: plan.expectedRevision,
147
+ ...actual === null ? {} : { actualRevision: actual.revision }
148
+ }
149
+ };
150
+ };
151
+ const upsertVersionRecord = async (record, session) => {
152
+ const stored = cloneJson(record);
153
+ await versions.updateOne(
154
+ { _id: versionDocumentId(record.formId, record.version) },
155
+ { $set: { formId: record.formId, version: record.version, status: record.status, record: stored } },
156
+ { upsert: true, ...session === void 0 ? {} : { session } }
157
+ );
158
+ };
159
+ const applyVersionTransition = async (plan, session) => {
160
+ const inferredNextVersion = Math.max(
161
+ 1,
162
+ (plan.draftToCreate?.version ?? 0) + 1,
163
+ (plan.draftToDeleteVersion ?? 0) + 1,
164
+ (plan.publishedRecordToSave?.version ?? 0) + 1
165
+ );
166
+ const stateSet = {
167
+ revision: plan.nextRevision,
168
+ nextVersion: plan.nextVersion ?? inferredNextVersion,
169
+ updatedAt: plan.timestamp
170
+ };
171
+ if (plan.draftToCreate !== void 0) stateSet.draftVersion = plan.draftToCreate.version;
172
+ if (plan.publishedRecordToSave !== void 0) stateSet.publishedVersion = plan.publishedRecordToSave.version;
173
+ const stateUpdate = {
174
+ $set: stateSet,
175
+ ...plan.draftToDeleteVersion === void 0 ? {} : { $unset: { draftVersion: "" } }
176
+ };
177
+ const stateResult = await versionStates.updateOne(
178
+ { _id: plan.formId, revision: plan.expectedRevision },
179
+ stateUpdate,
180
+ {
181
+ upsert: plan.expectedRevision === 0,
182
+ ...session === void 0 ? {} : { session }
183
+ }
184
+ );
185
+ if (stateResult.matchedCount === 0 && stateResult.upsertedCount === 0) {
186
+ const actual = await versionStates.findOne({ _id: plan.formId }, session === void 0 ? {} : { session });
187
+ return {
188
+ success: false,
189
+ error: {
190
+ type: "revision_conflict",
191
+ expectedRevision: plan.expectedRevision,
192
+ ...actual === null ? {} : { actualRevision: actual.revision }
193
+ }
194
+ };
195
+ }
196
+ if (plan.draftToDeleteVersion !== void 0) {
197
+ await versions.deleteOne(
198
+ { _id: versionDocumentId(plan.formId, plan.draftToDeleteVersion) },
199
+ session === void 0 ? {} : { session }
200
+ );
201
+ }
202
+ if (plan.draftToCreate !== void 0) await upsertVersionRecord(plan.draftToCreate, session);
203
+ if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
204
+ for (const record of plan.archivedRecordsToSave ?? []) await upsertVersionRecord(record, session);
205
+ for (const [eventIndex, event] of plan.events.entries()) {
206
+ await versionEvents.updateOne(
207
+ { _id: versionEventDocumentId(plan.formId, plan.expectedRevision, eventIndex) },
208
+ { $set: { formId: plan.formId, fromRevision: plan.expectedRevision, eventIndex, event: cloneJson(event) } },
209
+ { upsert: true, ...session === void 0 ? {} : { session } }
210
+ );
211
+ }
212
+ return { success: true, value: { success: true } };
213
+ };
60
214
  return {
61
215
  async saveSchema(schema) {
62
216
  assertValidFormSchema(schema);
@@ -107,7 +261,7 @@ function createMongoDbStorage(options) {
107
261
  ...options2.since === void 0 ? {} : { $gte: options2.since },
108
262
  ...options2.until === void 0 ? {} : { $lte: options2.until }
109
263
  };
110
- const filter = {
264
+ const baseFilter = {
111
265
  formId,
112
266
  ...options2.version === void 0 ? {} : { formVersion: options2.version },
113
267
  ...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
@@ -119,8 +273,16 @@ function createMongoDbStorage(options) {
119
273
  ]
120
274
  }
121
275
  };
276
+ const serverFilters = [baseFilter];
277
+ if (options2.filter !== void 0 && typeof options2.filter !== "function") {
278
+ serverFilters.push(submissionFilterToMongo(options2.filter));
279
+ }
280
+ for (const [key, value] of Object.entries(options2.metadataFilters ?? {})) {
281
+ serverFilters.push({ [`submission.metadata.${key}`]: value });
282
+ }
283
+ const filter = serverFilters.length === 1 ? baseFilter : { $and: serverFilters };
122
284
  const sorted = submissions.find(filter).sort({ submittedAt: 1, _id: 1 });
123
- const requiresClientFiltering = options2.filter !== void 0 || options2.metadataFilters !== void 0;
285
+ const requiresClientFiltering = typeof options2.filter === "function";
124
286
  const documents = requiresClientFiltering ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
125
287
  const candidates = documents.map(parseSubmissionDocument).filter((item) => matchesSubmissionPageFilters(item, options2));
126
288
  const hasMore = candidates.length > pageSize;
@@ -132,6 +294,60 @@ function createMongoDbStorage(options) {
132
294
  ...hasMore && last !== void 0 ? { nextCursor: encodeSubmissionCursor({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
133
295
  };
134
296
  },
297
+ async listTextAnswerPage(formId, fieldId, options2 = {}) {
298
+ if (fieldId.trim().length === 0) throw new TypeError("fieldId must not be empty.");
299
+ const pageSize = normalizeSubmissionPageSize(options2.pageSize);
300
+ const cursor = options2.cursor === void 0 ? void 0 : decodeTextAnswerCursor(options2.cursor);
301
+ if (cursor !== void 0 && cursor.fieldId !== fieldId) {
302
+ throw new TypeError("Text answer cursor fieldId does not match the query.");
303
+ }
304
+ const submittedAt = options2.since === void 0 && options2.until === void 0 ? void 0 : {
305
+ ...options2.since === void 0 ? {} : { $gte: options2.since },
306
+ ...options2.until === void 0 ? {} : { $lte: options2.until }
307
+ };
308
+ const baseFilter = {
309
+ formId,
310
+ ...options2.version === void 0 ? {} : { formVersion: options2.version },
311
+ ...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
312
+ ...submittedAt === void 0 ? {} : { submittedAt },
313
+ ...cursor === void 0 ? {} : { _id: { $gt: cursor.responseId } },
314
+ [`submission.values.${fieldId}`]: { $type: "string" }
315
+ };
316
+ const serverFilters = [baseFilter];
317
+ if (options2.filter !== void 0 && typeof options2.filter !== "function") {
318
+ serverFilters.push(submissionFilterToMongo(options2.filter));
319
+ }
320
+ for (const [key, value] of Object.entries(options2.metadataFilters ?? {})) {
321
+ serverFilters.push({ [`submission.metadata.${key}`]: value });
322
+ }
323
+ const filter = serverFilters.length === 1 ? baseFilter : { $and: serverFilters };
324
+ const sorted = submissions.find(filter).sort({ _id: 1 });
325
+ const documents = typeof options2.filter === "function" ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
326
+ const candidates = documents.map(parseSubmissionDocument).filter((submission) => matchesSubmissionPageFilters(submission, options2)).flatMap((submission) => {
327
+ const text = submission.values[fieldId];
328
+ if (typeof text !== "string") return [];
329
+ return [
330
+ {
331
+ responseId: submission.id,
332
+ formId: submission.formId,
333
+ formVersion: submission.formVersion,
334
+ fieldId,
335
+ text,
336
+ locale: submission.locale,
337
+ submittedAt: submission.submittedAt,
338
+ ...submission.metadata === void 0 ? {} : { metadata: submission.metadata }
339
+ }
340
+ ];
341
+ });
342
+ const hasMore = candidates.length > pageSize;
343
+ const items = candidates.slice(0, pageSize);
344
+ const last = items.at(-1);
345
+ return {
346
+ items,
347
+ hasMore,
348
+ ...hasMore && last !== void 0 ? { nextCursor: encodeTextAnswerCursor({ responseId: last.responseId, fieldId }) } : {}
349
+ };
350
+ },
135
351
  async deleteSubmission(submissionId) {
136
352
  await submissions.deleteOne({ _id: submissionId });
137
353
  },
@@ -139,16 +355,93 @@ function createMongoDbStorage(options) {
139
355
  await submissions.deleteMany({ formId });
140
356
  },
141
357
  async clear() {
142
- await Promise.all([schemas.deleteMany({}), submissions.deleteMany({})]);
358
+ await Promise.all([
359
+ schemas.deleteMany({}),
360
+ submissions.deleteMany({}),
361
+ versions.deleteMany({}),
362
+ versionStates.deleteMany({}),
363
+ versionEvents.deleteMany({})
364
+ ]);
365
+ },
366
+ async getVersionState(formId) {
367
+ const document = await versionStates.findOne({ _id: formId });
368
+ if (document === null) return null;
369
+ if (!Number.isSafeInteger(document.revision) || document.revision < 0) {
370
+ throw new Error(`MongoDB version state "${formId}" is invalid.`);
371
+ }
372
+ const nextVersion = document.nextVersion ?? Math.max(1, (document.draftVersion ?? 0) + 1, (document.publishedVersion ?? 0) + 1);
373
+ return {
374
+ formId,
375
+ revision: document.revision,
376
+ nextVersion,
377
+ ...document.draftVersion === void 0 ? {} : { draftVersion: document.draftVersion },
378
+ ...document.publishedVersion === void 0 ? {} : { publishedVersion: document.publishedVersion }
379
+ };
380
+ },
381
+ async getVersionRecord(formId, version) {
382
+ const document = await versions.findOne({ _id: versionDocumentId(formId, version) });
383
+ return document === null ? null : parseVersionRecordDocument(document);
384
+ },
385
+ async listVersionRecords(formId) {
386
+ return (await versions.find({ formId }).sort({ version: 1 }).toArray()).map(parseVersionRecordDocument);
387
+ },
388
+ async commitVersionTransition(plan) {
389
+ try {
390
+ assertTransitionPlan(plan);
391
+ } catch (cause) {
392
+ return {
393
+ success: false,
394
+ error: { type: "invalid_transition", message: cause instanceof Error ? cause.message : String(cause) }
395
+ };
396
+ }
397
+ const client = options.db.client;
398
+ if (client === void 0 || typeof client.startSession !== "function") {
399
+ try {
400
+ return await applyVersionTransition(plan);
401
+ } catch (error) {
402
+ if (isCasConflict(error)) {
403
+ return storageRevisionConflict(plan);
404
+ }
405
+ return { success: false, error: { type: "storage_error", cause: error } };
406
+ }
407
+ }
408
+ const session = client.startSession();
409
+ try {
410
+ let result = {
411
+ success: false,
412
+ error: { type: "revision_conflict", expectedRevision: plan.expectedRevision }
413
+ };
414
+ await session.withTransaction(async () => {
415
+ result = await applyVersionTransition(plan, session);
416
+ });
417
+ return result;
418
+ } catch (error) {
419
+ if (isCasConflict(error)) {
420
+ return storageRevisionConflict(plan);
421
+ }
422
+ return { success: false, error: { type: "storage_error", cause: error } };
423
+ } finally {
424
+ await session.endSession();
425
+ }
143
426
  },
144
427
  async createIndexes() {
145
- await submissions.createIndexes([
146
- { key: { formId: 1, submittedAt: 1, _id: 1 }, name: "form_responses_form_submitted_at_id" },
147
- { key: { "submission.locale": 1 }, name: "form_responses_locale" }
428
+ await Promise.all([
429
+ submissions.createIndexes([
430
+ { key: { formId: 1, submittedAt: 1, _id: 1 }, name: "form_responses_form_submitted_at_id" },
431
+ { key: { "submission.locale": 1 }, name: "form_responses_locale" }
432
+ ]),
433
+ versions.createIndexes([
434
+ { key: { formId: 1, version: 1 }, name: "form_versions_form_version", unique: true },
435
+ { key: { formId: 1, status: 1 }, name: "form_versions_form_status" }
436
+ ]),
437
+ versionEvents.createIndexes([
438
+ { key: { formId: 1, fromRevision: 1, eventIndex: 1 }, name: "form_version_events_revision" }
439
+ ])
148
440
  ]);
149
441
  }
150
442
  };
151
443
  }
152
444
  export {
153
- createMongoDbStorage
445
+ createMongoDbStorage,
446
+ submissionFilterToMongo
154
447
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/storage-mongodb",
3
- "version": "2.5.1",
3
+ "version": "2.7.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -39,7 +39,7 @@
39
39
  "typescript"
40
40
  ],
41
41
  "dependencies": {
42
- "@form-engine-ts/core": "2.5.1"
42
+ "@form-engine-ts/core": "2.7.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "mongodb": "^6.0.0"