@form-engine-ts/storage-mongodb 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
@@ -29,9 +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.
33
35
 
34
36
  Version records are stored in `form_versions`, with unique `(formId, version)` and `(formId, status)` indexes. Transition
35
37
  state lives in `form_version_states`. `commitVersionTransition(plan)` compares `expectedRevision` atomically and returns
36
38
  `revision_conflict` to losing concurrent publishers; on a real MongoDB client, the state and record changes are committed
37
- in one transaction.
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");
@@ -45,6 +46,35 @@ function schemaDocumentId(formId, formVersion) {
45
46
  function versionDocumentId(formId, formVersion) {
46
47
  return `version:${encodeURIComponent(formId)}:${formVersion}`;
47
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
+ }
48
78
  function assertVersionRecord(record, formId, expectedStatus) {
49
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) {
50
80
  throw new TypeError("Version transition record is inconsistent.");
@@ -55,6 +85,7 @@ function assertTransitionPlan(plan) {
55
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))) {
56
86
  throw new TypeError("Version transition plan is invalid.");
57
87
  }
88
+ if (!Array.isArray(plan.events)) throw new TypeError("Version transition plan events are required.");
58
89
  if (plan.draftToCreate !== void 0) assertVersionRecord(plan.draftToCreate, plan.formId, "draft");
59
90
  if (plan.publishedRecordToSave !== void 0) {
60
91
  assertVersionRecord(plan.publishedRecordToSave, plan.formId, "published");
@@ -64,6 +95,13 @@ function assertTransitionPlan(plan) {
64
95
  throw new TypeError("draftToDeleteVersion must be a positive safe integer.");
65
96
  }
66
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
+ }
67
105
  function isCasConflict(error) {
68
106
  if (!isRecord(error)) return false;
69
107
  if (error.code === 11e3 || error.code === 112 || error.code === 251) return true;
@@ -110,10 +148,23 @@ function createMongoDbStorage(options) {
110
148
  "form_version_states",
111
149
  "versionStatesCollectionName"
112
150
  );
151
+ const versionEventsCollectionName = "form_version_events";
113
152
  const schemas = options.db.collection(schemasCollectionName);
114
153
  const submissions = options.db.collection(responsesCollectionName);
115
154
  const versions = options.db.collection(versionsCollectionName);
116
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
+ };
117
168
  const upsertVersionRecord = async (record, session) => {
118
169
  const stored = cloneJson(record);
119
170
  await versions.updateOne(
@@ -123,7 +174,17 @@ function createMongoDbStorage(options) {
123
174
  );
124
175
  };
125
176
  const applyVersionTransition = async (plan, session) => {
126
- const stateSet = { revision: plan.nextRevision, updatedAt: plan.timestamp };
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
+ };
127
188
  if (plan.draftToCreate !== void 0) stateSet.draftVersion = plan.draftToCreate.version;
128
189
  if (plan.publishedRecordToSave !== void 0) stateSet.publishedVersion = plan.publishedRecordToSave.version;
129
190
  const stateUpdate = {
@@ -139,7 +200,15 @@ function createMongoDbStorage(options) {
139
200
  }
140
201
  );
141
202
  if (stateResult.matchedCount === 0 && stateResult.upsertedCount === 0) {
142
- return { success: false, error: "revision_conflict" };
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
+ };
143
212
  }
144
213
  if (plan.draftToDeleteVersion !== void 0) {
145
214
  await versions.deleteOne(
@@ -150,7 +219,14 @@ function createMongoDbStorage(options) {
150
219
  if (plan.draftToCreate !== void 0) await upsertVersionRecord(plan.draftToCreate, session);
151
220
  if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
152
221
  for (const record of plan.archivedRecordsToSave ?? []) await upsertVersionRecord(record, session);
153
- return { success: true };
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 } };
154
230
  };
155
231
  return {
156
232
  async saveSchema(schema) {
@@ -202,7 +278,7 @@ function createMongoDbStorage(options) {
202
278
  ...options2.since === void 0 ? {} : { $gte: options2.since },
203
279
  ...options2.until === void 0 ? {} : { $lte: options2.until }
204
280
  };
205
- const filter = {
281
+ const baseFilter = {
206
282
  formId,
207
283
  ...options2.version === void 0 ? {} : { formVersion: options2.version },
208
284
  ...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
@@ -214,8 +290,16 @@ function createMongoDbStorage(options) {
214
290
  ]
215
291
  }
216
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 };
217
301
  const sorted = submissions.find(filter).sort({ submittedAt: 1, _id: 1 });
218
- const requiresClientFiltering = options2.filter !== void 0 || options2.metadataFilters !== void 0;
302
+ const requiresClientFiltering = typeof options2.filter === "function";
219
303
  const documents = requiresClientFiltering ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
220
304
  const candidates = documents.map(parseSubmissionDocument).filter((item) => (0, import_core.matchesSubmissionPageFilters)(item, options2));
221
305
  const hasMore = candidates.length > pageSize;
@@ -227,6 +311,60 @@ function createMongoDbStorage(options) {
227
311
  ...hasMore && last !== void 0 ? { nextCursor: (0, import_core.encodeSubmissionCursor)({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
228
312
  };
229
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
+ },
230
368
  async deleteSubmission(submissionId) {
231
369
  await submissions.deleteOne({ _id: submissionId });
232
370
  },
@@ -238,33 +376,67 @@ function createMongoDbStorage(options) {
238
376
  schemas.deleteMany({}),
239
377
  submissions.deleteMany({}),
240
378
  versions.deleteMany({}),
241
- versionStates.deleteMany({})
379
+ versionStates.deleteMany({}),
380
+ versionEvents.deleteMany({})
242
381
  ]);
243
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
+ },
244
405
  async commitVersionTransition(plan) {
245
- assertTransitionPlan(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
+ }
246
414
  const client = options.db.client;
247
415
  if (client === void 0 || typeof client.startSession !== "function") {
248
416
  try {
249
417
  return await applyVersionTransition(plan);
250
418
  } catch (error) {
251
- if (isCasConflict(error)) return { success: false, error: "revision_conflict" };
252
- throw error;
419
+ if (isCasConflict(error)) {
420
+ return storageRevisionConflict(plan);
421
+ }
422
+ return { success: false, error: { type: "storage_error", cause: error } };
253
423
  }
254
424
  }
255
425
  const session = client.startSession();
256
426
  try {
257
427
  let result = {
258
428
  success: false,
259
- error: "revision_conflict"
429
+ error: { type: "revision_conflict", expectedRevision: plan.expectedRevision }
260
430
  };
261
431
  await session.withTransaction(async () => {
262
432
  result = await applyVersionTransition(plan, session);
263
433
  });
264
434
  return result;
265
435
  } catch (error) {
266
- if (isCasConflict(error)) return { success: false, error: "revision_conflict" };
267
- throw error;
436
+ if (isCasConflict(error)) {
437
+ return storageRevisionConflict(plan);
438
+ }
439
+ return { success: false, error: { type: "storage_error", cause: error } };
268
440
  } finally {
269
441
  await session.endSession();
270
442
  }
@@ -278,6 +450,9 @@ function createMongoDbStorage(options) {
278
450
  versions.createIndexes([
279
451
  { key: { formId: 1, version: 1 }, name: "form_versions_form_version", unique: true },
280
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" }
281
456
  ])
282
457
  ]);
283
458
  }
@@ -285,5 +460,6 @@ function createMongoDbStorage(options) {
285
460
  }
286
461
  // Annotate the CommonJS export names for ESM import in node:
287
462
  0 && (module.exports = {
288
- createMongoDbStorage
463
+ createMongoDbStorage,
464
+ submissionFilterToMongo
289
465
  });
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { PagedSubmissionStorageAdapter, VersionedFormStorageAdapter } 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;
@@ -11,6 +11,7 @@ interface MongoDbStorageOptions {
11
11
  interface MongoDbStorageAdapter extends PagedSubmissionStorageAdapter, VersionedFormStorageAdapter {
12
12
  createIndexes(): Promise<void>;
13
13
  }
14
+ declare function submissionFilterToMongo(filter: SubmissionFilter): Document;
14
15
  declare function createMongoDbStorage(options: MongoDbStorageOptions): MongoDbStorageAdapter;
15
16
 
16
- export { type MongoDbStorageAdapter, type MongoDbStorageOptions, createMongoDbStorage };
17
+ export { type MongoDbStorageAdapter, type MongoDbStorageOptions, createMongoDbStorage, submissionFilterToMongo };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { PagedSubmissionStorageAdapter, VersionedFormStorageAdapter } 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;
@@ -11,6 +11,7 @@ interface MongoDbStorageOptions {
11
11
  interface MongoDbStorageAdapter extends PagedSubmissionStorageAdapter, VersionedFormStorageAdapter {
12
12
  createIndexes(): Promise<void>;
13
13
  }
14
+ declare function submissionFilterToMongo(filter: SubmissionFilter): Document;
14
15
  declare function createMongoDbStorage(options: MongoDbStorageOptions): MongoDbStorageAdapter;
15
16
 
16
- 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";
@@ -27,6 +29,35 @@ function schemaDocumentId(formId, formVersion) {
27
29
  function versionDocumentId(formId, formVersion) {
28
30
  return `version:${encodeURIComponent(formId)}:${formVersion}`;
29
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
+ }
30
61
  function assertVersionRecord(record, formId, expectedStatus) {
31
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) {
32
63
  throw new TypeError("Version transition record is inconsistent.");
@@ -37,6 +68,7 @@ function assertTransitionPlan(plan) {
37
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))) {
38
69
  throw new TypeError("Version transition plan is invalid.");
39
70
  }
71
+ if (!Array.isArray(plan.events)) throw new TypeError("Version transition plan events are required.");
40
72
  if (plan.draftToCreate !== void 0) assertVersionRecord(plan.draftToCreate, plan.formId, "draft");
41
73
  if (plan.publishedRecordToSave !== void 0) {
42
74
  assertVersionRecord(plan.publishedRecordToSave, plan.formId, "published");
@@ -46,6 +78,13 @@ function assertTransitionPlan(plan) {
46
78
  throw new TypeError("draftToDeleteVersion must be a positive safe integer.");
47
79
  }
48
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
+ }
49
88
  function isCasConflict(error) {
50
89
  if (!isRecord(error)) return false;
51
90
  if (error.code === 11e3 || error.code === 112 || error.code === 251) return true;
@@ -92,10 +131,23 @@ function createMongoDbStorage(options) {
92
131
  "form_version_states",
93
132
  "versionStatesCollectionName"
94
133
  );
134
+ const versionEventsCollectionName = "form_version_events";
95
135
  const schemas = options.db.collection(schemasCollectionName);
96
136
  const submissions = options.db.collection(responsesCollectionName);
97
137
  const versions = options.db.collection(versionsCollectionName);
98
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
+ };
99
151
  const upsertVersionRecord = async (record, session) => {
100
152
  const stored = cloneJson(record);
101
153
  await versions.updateOne(
@@ -105,7 +157,17 @@ function createMongoDbStorage(options) {
105
157
  );
106
158
  };
107
159
  const applyVersionTransition = async (plan, session) => {
108
- const stateSet = { revision: plan.nextRevision, updatedAt: plan.timestamp };
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
+ };
109
171
  if (plan.draftToCreate !== void 0) stateSet.draftVersion = plan.draftToCreate.version;
110
172
  if (plan.publishedRecordToSave !== void 0) stateSet.publishedVersion = plan.publishedRecordToSave.version;
111
173
  const stateUpdate = {
@@ -121,7 +183,15 @@ function createMongoDbStorage(options) {
121
183
  }
122
184
  );
123
185
  if (stateResult.matchedCount === 0 && stateResult.upsertedCount === 0) {
124
- return { success: false, error: "revision_conflict" };
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
+ };
125
195
  }
126
196
  if (plan.draftToDeleteVersion !== void 0) {
127
197
  await versions.deleteOne(
@@ -132,7 +202,14 @@ function createMongoDbStorage(options) {
132
202
  if (plan.draftToCreate !== void 0) await upsertVersionRecord(plan.draftToCreate, session);
133
203
  if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
134
204
  for (const record of plan.archivedRecordsToSave ?? []) await upsertVersionRecord(record, session);
135
- return { success: true };
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 } };
136
213
  };
137
214
  return {
138
215
  async saveSchema(schema) {
@@ -184,7 +261,7 @@ function createMongoDbStorage(options) {
184
261
  ...options2.since === void 0 ? {} : { $gte: options2.since },
185
262
  ...options2.until === void 0 ? {} : { $lte: options2.until }
186
263
  };
187
- const filter = {
264
+ const baseFilter = {
188
265
  formId,
189
266
  ...options2.version === void 0 ? {} : { formVersion: options2.version },
190
267
  ...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
@@ -196,8 +273,16 @@ function createMongoDbStorage(options) {
196
273
  ]
197
274
  }
198
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 };
199
284
  const sorted = submissions.find(filter).sort({ submittedAt: 1, _id: 1 });
200
- const requiresClientFiltering = options2.filter !== void 0 || options2.metadataFilters !== void 0;
285
+ const requiresClientFiltering = typeof options2.filter === "function";
201
286
  const documents = requiresClientFiltering ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
202
287
  const candidates = documents.map(parseSubmissionDocument).filter((item) => matchesSubmissionPageFilters(item, options2));
203
288
  const hasMore = candidates.length > pageSize;
@@ -209,6 +294,60 @@ function createMongoDbStorage(options) {
209
294
  ...hasMore && last !== void 0 ? { nextCursor: encodeSubmissionCursor({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
210
295
  };
211
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
+ },
212
351
  async deleteSubmission(submissionId) {
213
352
  await submissions.deleteOne({ _id: submissionId });
214
353
  },
@@ -220,33 +359,67 @@ function createMongoDbStorage(options) {
220
359
  schemas.deleteMany({}),
221
360
  submissions.deleteMany({}),
222
361
  versions.deleteMany({}),
223
- versionStates.deleteMany({})
362
+ versionStates.deleteMany({}),
363
+ versionEvents.deleteMany({})
224
364
  ]);
225
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
+ },
226
388
  async commitVersionTransition(plan) {
227
- assertTransitionPlan(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
+ }
228
397
  const client = options.db.client;
229
398
  if (client === void 0 || typeof client.startSession !== "function") {
230
399
  try {
231
400
  return await applyVersionTransition(plan);
232
401
  } catch (error) {
233
- if (isCasConflict(error)) return { success: false, error: "revision_conflict" };
234
- throw error;
402
+ if (isCasConflict(error)) {
403
+ return storageRevisionConflict(plan);
404
+ }
405
+ return { success: false, error: { type: "storage_error", cause: error } };
235
406
  }
236
407
  }
237
408
  const session = client.startSession();
238
409
  try {
239
410
  let result = {
240
411
  success: false,
241
- error: "revision_conflict"
412
+ error: { type: "revision_conflict", expectedRevision: plan.expectedRevision }
242
413
  };
243
414
  await session.withTransaction(async () => {
244
415
  result = await applyVersionTransition(plan, session);
245
416
  });
246
417
  return result;
247
418
  } catch (error) {
248
- if (isCasConflict(error)) return { success: false, error: "revision_conflict" };
249
- throw error;
419
+ if (isCasConflict(error)) {
420
+ return storageRevisionConflict(plan);
421
+ }
422
+ return { success: false, error: { type: "storage_error", cause: error } };
250
423
  } finally {
251
424
  await session.endSession();
252
425
  }
@@ -260,11 +433,15 @@ function createMongoDbStorage(options) {
260
433
  versions.createIndexes([
261
434
  { key: { formId: 1, version: 1 }, name: "form_versions_form_version", unique: true },
262
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" }
263
439
  ])
264
440
  ]);
265
441
  }
266
442
  };
267
443
  }
268
444
  export {
269
- createMongoDbStorage
445
+ createMongoDbStorage,
446
+ submissionFilterToMongo
270
447
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/storage-mongodb",
3
- "version": "2.6.0",
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.6.0"
42
+ "@form-engine-ts/core": "2.7.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "mongodb": "^6.0.0"