@form-engine-ts/storage-mongodb 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,9 +29,13 @@ 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
- Version records are stored in `form_versions`, with unique `(formId, version)` and `(formId, status)` indexes. Transition
35
- state lives in `form_version_states`. `commitVersionTransition(plan)` compares `expectedRevision` atomically and returns
36
+ Version records are stored in `form_versions`, with a unique `(formId, version)` index. Partial unique indexes allow at
37
+ most one Draft and one Published record per form, while any number of Archived records remain available. Transition state
38
+ lives in `form_version_states`. `commitVersionTransition(plan)` compares `expectedRevision` atomically and returns
36
39
  `revision_conflict` to losing concurrent publishers; on a real MongoDB client, the state and record changes are committed
37
- in one transaction.
40
+ in one transaction. Clone, publish, and draft-delete plans persist complete version state, affected records, and audit
41
+ 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,9 +95,20 @@ 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
- if (error.code === 11e3 || error.code === 112 || error.code === 251) return true;
107
+ if (error.code === 11e3) {
108
+ const message = error instanceof Error ? error.message : String(error.message ?? "");
109
+ return !/unique_(?:draft|published|version)_per_form/u.test(message);
110
+ }
111
+ if (error.code === 112 || error.code === 251) return true;
70
112
  return typeof error.hasErrorLabel === "function" && error.hasErrorLabel("TransientTransactionError") === true;
71
113
  }
72
114
  function parseSchemaDocument(document) {
@@ -110,10 +152,23 @@ function createMongoDbStorage(options) {
110
152
  "form_version_states",
111
153
  "versionStatesCollectionName"
112
154
  );
155
+ const versionEventsCollectionName = "form_version_events";
113
156
  const schemas = options.db.collection(schemasCollectionName);
114
157
  const submissions = options.db.collection(responsesCollectionName);
115
158
  const versions = options.db.collection(versionsCollectionName);
116
159
  const versionStates = options.db.collection(versionStatesCollectionName);
160
+ const versionEvents = options.db.collection(versionEventsCollectionName);
161
+ const storageRevisionConflict = async (plan) => {
162
+ const actual = await versionStates.findOne({ _id: plan.formId });
163
+ return {
164
+ success: false,
165
+ error: {
166
+ type: "revision_conflict",
167
+ expectedRevision: plan.expectedRevision,
168
+ ...actual === null ? {} : { actualRevision: actual.revision }
169
+ }
170
+ };
171
+ };
117
172
  const upsertVersionRecord = async (record, session) => {
118
173
  const stored = cloneJson(record);
119
174
  await versions.updateOne(
@@ -123,7 +178,17 @@ function createMongoDbStorage(options) {
123
178
  );
124
179
  };
125
180
  const applyVersionTransition = async (plan, session) => {
126
- const stateSet = { revision: plan.nextRevision, updatedAt: plan.timestamp };
181
+ const inferredNextVersion = Math.max(
182
+ 1,
183
+ (plan.draftToCreate?.version ?? 0) + 1,
184
+ (plan.draftToDeleteVersion ?? 0) + 1,
185
+ (plan.publishedRecordToSave?.version ?? 0) + 1
186
+ );
187
+ const stateSet = {
188
+ revision: plan.nextRevision,
189
+ nextVersion: plan.nextVersion ?? inferredNextVersion,
190
+ updatedAt: plan.timestamp
191
+ };
127
192
  if (plan.draftToCreate !== void 0) stateSet.draftVersion = plan.draftToCreate.version;
128
193
  if (plan.publishedRecordToSave !== void 0) stateSet.publishedVersion = plan.publishedRecordToSave.version;
129
194
  const stateUpdate = {
@@ -139,7 +204,15 @@ function createMongoDbStorage(options) {
139
204
  }
140
205
  );
141
206
  if (stateResult.matchedCount === 0 && stateResult.upsertedCount === 0) {
142
- return { success: false, error: "revision_conflict" };
207
+ const actual = await versionStates.findOne({ _id: plan.formId }, session === void 0 ? {} : { session });
208
+ return {
209
+ success: false,
210
+ error: {
211
+ type: "revision_conflict",
212
+ expectedRevision: plan.expectedRevision,
213
+ ...actual === null ? {} : { actualRevision: actual.revision }
214
+ }
215
+ };
143
216
  }
144
217
  if (plan.draftToDeleteVersion !== void 0) {
145
218
  await versions.deleteOne(
@@ -148,9 +221,16 @@ function createMongoDbStorage(options) {
148
221
  );
149
222
  }
150
223
  if (plan.draftToCreate !== void 0) await upsertVersionRecord(plan.draftToCreate, session);
151
- if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
152
224
  for (const record of plan.archivedRecordsToSave ?? []) await upsertVersionRecord(record, session);
153
- return { success: true };
225
+ if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
226
+ for (const [eventIndex, event] of plan.events.entries()) {
227
+ await versionEvents.updateOne(
228
+ { _id: versionEventDocumentId(plan.formId, plan.expectedRevision, eventIndex) },
229
+ { $set: { formId: plan.formId, fromRevision: plan.expectedRevision, eventIndex, event: cloneJson(event) } },
230
+ { upsert: true, ...session === void 0 ? {} : { session } }
231
+ );
232
+ }
233
+ return { success: true, value: { success: true } };
154
234
  };
155
235
  return {
156
236
  async saveSchema(schema) {
@@ -202,7 +282,7 @@ function createMongoDbStorage(options) {
202
282
  ...options2.since === void 0 ? {} : { $gte: options2.since },
203
283
  ...options2.until === void 0 ? {} : { $lte: options2.until }
204
284
  };
205
- const filter = {
285
+ const baseFilter = {
206
286
  formId,
207
287
  ...options2.version === void 0 ? {} : { formVersion: options2.version },
208
288
  ...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
@@ -214,8 +294,16 @@ function createMongoDbStorage(options) {
214
294
  ]
215
295
  }
216
296
  };
297
+ const serverFilters = [baseFilter];
298
+ if (options2.filter !== void 0 && typeof options2.filter !== "function") {
299
+ serverFilters.push(submissionFilterToMongo(options2.filter));
300
+ }
301
+ for (const [key, value] of Object.entries(options2.metadataFilters ?? {})) {
302
+ serverFilters.push({ [`submission.metadata.${key}`]: value });
303
+ }
304
+ const filter = serverFilters.length === 1 ? baseFilter : { $and: serverFilters };
217
305
  const sorted = submissions.find(filter).sort({ submittedAt: 1, _id: 1 });
218
- const requiresClientFiltering = options2.filter !== void 0 || options2.metadataFilters !== void 0;
306
+ const requiresClientFiltering = typeof options2.filter === "function";
219
307
  const documents = requiresClientFiltering ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
220
308
  const candidates = documents.map(parseSubmissionDocument).filter((item) => (0, import_core.matchesSubmissionPageFilters)(item, options2));
221
309
  const hasMore = candidates.length > pageSize;
@@ -227,6 +315,69 @@ function createMongoDbStorage(options) {
227
315
  ...hasMore && last !== void 0 ? { nextCursor: (0, import_core.encodeSubmissionCursor)({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
228
316
  };
229
317
  },
318
+ async listTextAnswerPage(formId, fieldIdOrOptions, providedOptions) {
319
+ const options2 = typeof fieldIdOrOptions === "string" ? providedOptions ?? {} : fieldIdOrOptions ?? {};
320
+ const requestedFieldIds = typeof fieldIdOrOptions === "string" ? [fieldIdOrOptions] : options2.fieldIds === void 0 ? void 0 : [...new Set(options2.fieldIds)];
321
+ if (requestedFieldIds?.some((fieldId) => fieldId.trim().length === 0)) {
322
+ throw new TypeError("fieldIds must not contain empty values.");
323
+ }
324
+ const pageSize = (0, import_core.normalizeSubmissionPageSize)(options2.pageSize);
325
+ const cursor = options2.cursor === void 0 ? void 0 : (0, import_core.decodeTextAnswerCursor)(options2.cursor);
326
+ if (cursor !== void 0 && requestedFieldIds !== void 0 && !requestedFieldIds.includes(cursor.fieldId)) {
327
+ throw new TypeError("Text answer cursor fieldId does not match the query fields.");
328
+ }
329
+ const submittedAt = options2.since === void 0 && options2.until === void 0 ? void 0 : {
330
+ ...options2.since === void 0 ? {} : { $gte: options2.since },
331
+ ...options2.until === void 0 ? {} : { $lte: options2.until }
332
+ };
333
+ const baseFilter = {
334
+ formId,
335
+ ...options2.version === void 0 ? {} : { formVersion: options2.version },
336
+ ...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
337
+ ...submittedAt === void 0 ? {} : { submittedAt },
338
+ ...cursor === void 0 ? {} : { _id: { $gte: cursor.responseId } },
339
+ ...requestedFieldIds?.length === 1 ? { [`submission.values.${requestedFieldIds[0]}`]: { $type: "string" } } : {}
340
+ };
341
+ const serverFilters = [baseFilter];
342
+ if (options2.filter !== void 0 && typeof options2.filter !== "function") {
343
+ serverFilters.push(submissionFilterToMongo(options2.filter));
344
+ }
345
+ for (const [key, value] of Object.entries(options2.metadataFilters ?? {})) {
346
+ serverFilters.push({ [`submission.metadata.${key}`]: value });
347
+ }
348
+ const filter = serverFilters.length === 1 ? baseFilter : { $and: serverFilters };
349
+ const sorted = submissions.find(filter).sort({ _id: 1 });
350
+ const documents = await sorted.toArray();
351
+ const candidates = documents.map(parseSubmissionDocument).filter((submission) => (0, import_core.matchesSubmissionPageFilters)(submission, options2)).flatMap((submission) => {
352
+ const entries = requestedFieldIds === void 0 ? Object.entries(submission.values) : requestedFieldIds.map((fieldId) => [fieldId, submission.values[fieldId]]);
353
+ return entries.flatMap(([fieldId, text]) => {
354
+ if (typeof text !== "string" || text.length === 0) return [];
355
+ if (cursor !== void 0 && (submission.id < cursor.responseId || submission.id === cursor.responseId && fieldId <= cursor.fieldId)) {
356
+ return [];
357
+ }
358
+ return [
359
+ {
360
+ responseId: submission.id,
361
+ formId: submission.formId,
362
+ formVersion: submission.formVersion,
363
+ fieldId,
364
+ text,
365
+ locale: submission.locale,
366
+ submittedAt: submission.submittedAt,
367
+ ...submission.metadata === void 0 ? {} : { metadata: submission.metadata }
368
+ }
369
+ ];
370
+ });
371
+ });
372
+ const hasMore = candidates.length > pageSize;
373
+ const items = candidates.slice(0, pageSize);
374
+ const last = items.at(-1);
375
+ return {
376
+ items,
377
+ hasMore,
378
+ ...hasMore && last !== void 0 ? { nextCursor: (0, import_core.encodeTextAnswerCursor)({ responseId: last.responseId, fieldId: last.fieldId }) } : {}
379
+ };
380
+ },
230
381
  async deleteSubmission(submissionId) {
231
382
  await submissions.deleteOne({ _id: submissionId });
232
383
  },
@@ -238,33 +389,67 @@ function createMongoDbStorage(options) {
238
389
  schemas.deleteMany({}),
239
390
  submissions.deleteMany({}),
240
391
  versions.deleteMany({}),
241
- versionStates.deleteMany({})
392
+ versionStates.deleteMany({}),
393
+ versionEvents.deleteMany({})
242
394
  ]);
243
395
  },
396
+ async getVersionState(formId) {
397
+ const document = await versionStates.findOne({ _id: formId });
398
+ if (document === null) return null;
399
+ if (!Number.isSafeInteger(document.revision) || document.revision < 0) {
400
+ throw new Error(`MongoDB version state "${formId}" is invalid.`);
401
+ }
402
+ const nextVersion = document.nextVersion ?? Math.max(1, (document.draftVersion ?? 0) + 1, (document.publishedVersion ?? 0) + 1);
403
+ return {
404
+ formId,
405
+ revision: document.revision,
406
+ nextVersion,
407
+ ...document.draftVersion === void 0 ? {} : { draftVersion: document.draftVersion },
408
+ ...document.publishedVersion === void 0 ? {} : { publishedVersion: document.publishedVersion }
409
+ };
410
+ },
411
+ async getVersionRecord(formId, version) {
412
+ const document = await versions.findOne({ _id: versionDocumentId(formId, version) });
413
+ return document === null ? null : parseVersionRecordDocument(document);
414
+ },
415
+ async listVersionRecords(formId) {
416
+ return (await versions.find({ formId }).sort({ version: 1 }).toArray()).map(parseVersionRecordDocument);
417
+ },
244
418
  async commitVersionTransition(plan) {
245
- assertTransitionPlan(plan);
419
+ try {
420
+ assertTransitionPlan(plan);
421
+ } catch (cause) {
422
+ return {
423
+ success: false,
424
+ error: { type: "invalid_transition", message: cause instanceof Error ? cause.message : String(cause) }
425
+ };
426
+ }
246
427
  const client = options.db.client;
247
428
  if (client === void 0 || typeof client.startSession !== "function") {
248
429
  try {
249
430
  return await applyVersionTransition(plan);
250
431
  } catch (error) {
251
- if (isCasConflict(error)) return { success: false, error: "revision_conflict" };
252
- throw error;
432
+ if (isCasConflict(error)) {
433
+ return storageRevisionConflict(plan);
434
+ }
435
+ return { success: false, error: { type: "storage_error", cause: error } };
253
436
  }
254
437
  }
255
438
  const session = client.startSession();
256
439
  try {
257
440
  let result = {
258
441
  success: false,
259
- error: "revision_conflict"
442
+ error: { type: "revision_conflict", expectedRevision: plan.expectedRevision }
260
443
  };
261
444
  await session.withTransaction(async () => {
262
445
  result = await applyVersionTransition(plan, session);
263
446
  });
264
447
  return result;
265
448
  } catch (error) {
266
- if (isCasConflict(error)) return { success: false, error: "revision_conflict" };
267
- throw error;
449
+ if (isCasConflict(error)) {
450
+ return storageRevisionConflict(plan);
451
+ }
452
+ return { success: false, error: { type: "storage_error", cause: error } };
268
453
  } finally {
269
454
  await session.endSession();
270
455
  }
@@ -276,8 +461,22 @@ function createMongoDbStorage(options) {
276
461
  { key: { "submission.locale": 1 }, name: "form_responses_locale" }
277
462
  ]),
278
463
  versions.createIndexes([
279
- { key: { formId: 1, version: 1 }, name: "form_versions_form_version", unique: true },
280
- { key: { formId: 1, status: 1 }, name: "form_versions_form_status" }
464
+ { key: { formId: 1, version: 1 }, name: "unique_version_per_form", unique: true },
465
+ {
466
+ key: { formId: 1 },
467
+ name: "unique_draft_per_form",
468
+ unique: true,
469
+ partialFilterExpression: { status: "draft" }
470
+ },
471
+ {
472
+ key: { formId: 1 },
473
+ name: "unique_published_per_form",
474
+ unique: true,
475
+ partialFilterExpression: { status: "published" }
476
+ }
477
+ ]),
478
+ versionEvents.createIndexes([
479
+ { key: { formId: 1, fromRevision: 1, eventIndex: 1 }, name: "form_version_events_revision" }
281
480
  ])
282
481
  ]);
283
482
  }
@@ -285,5 +484,6 @@ function createMongoDbStorage(options) {
285
484
  }
286
485
  // Annotate the CommonJS export names for ESM import in node:
287
486
  0 && (module.exports = {
288
- createMongoDbStorage
487
+ createMongoDbStorage,
488
+ submissionFilterToMongo
289
489
  });
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,9 +78,20 @@ 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
- if (error.code === 11e3 || error.code === 112 || error.code === 251) return true;
90
+ if (error.code === 11e3) {
91
+ const message = error instanceof Error ? error.message : String(error.message ?? "");
92
+ return !/unique_(?:draft|published|version)_per_form/u.test(message);
93
+ }
94
+ if (error.code === 112 || error.code === 251) return true;
52
95
  return typeof error.hasErrorLabel === "function" && error.hasErrorLabel("TransientTransactionError") === true;
53
96
  }
54
97
  function parseSchemaDocument(document) {
@@ -92,10 +135,23 @@ function createMongoDbStorage(options) {
92
135
  "form_version_states",
93
136
  "versionStatesCollectionName"
94
137
  );
138
+ const versionEventsCollectionName = "form_version_events";
95
139
  const schemas = options.db.collection(schemasCollectionName);
96
140
  const submissions = options.db.collection(responsesCollectionName);
97
141
  const versions = options.db.collection(versionsCollectionName);
98
142
  const versionStates = options.db.collection(versionStatesCollectionName);
143
+ const versionEvents = options.db.collection(versionEventsCollectionName);
144
+ const storageRevisionConflict = async (plan) => {
145
+ const actual = await versionStates.findOne({ _id: plan.formId });
146
+ return {
147
+ success: false,
148
+ error: {
149
+ type: "revision_conflict",
150
+ expectedRevision: plan.expectedRevision,
151
+ ...actual === null ? {} : { actualRevision: actual.revision }
152
+ }
153
+ };
154
+ };
99
155
  const upsertVersionRecord = async (record, session) => {
100
156
  const stored = cloneJson(record);
101
157
  await versions.updateOne(
@@ -105,7 +161,17 @@ function createMongoDbStorage(options) {
105
161
  );
106
162
  };
107
163
  const applyVersionTransition = async (plan, session) => {
108
- const stateSet = { revision: plan.nextRevision, updatedAt: plan.timestamp };
164
+ const inferredNextVersion = Math.max(
165
+ 1,
166
+ (plan.draftToCreate?.version ?? 0) + 1,
167
+ (plan.draftToDeleteVersion ?? 0) + 1,
168
+ (plan.publishedRecordToSave?.version ?? 0) + 1
169
+ );
170
+ const stateSet = {
171
+ revision: plan.nextRevision,
172
+ nextVersion: plan.nextVersion ?? inferredNextVersion,
173
+ updatedAt: plan.timestamp
174
+ };
109
175
  if (plan.draftToCreate !== void 0) stateSet.draftVersion = plan.draftToCreate.version;
110
176
  if (plan.publishedRecordToSave !== void 0) stateSet.publishedVersion = plan.publishedRecordToSave.version;
111
177
  const stateUpdate = {
@@ -121,7 +187,15 @@ function createMongoDbStorage(options) {
121
187
  }
122
188
  );
123
189
  if (stateResult.matchedCount === 0 && stateResult.upsertedCount === 0) {
124
- return { success: false, error: "revision_conflict" };
190
+ const actual = await versionStates.findOne({ _id: plan.formId }, session === void 0 ? {} : { session });
191
+ return {
192
+ success: false,
193
+ error: {
194
+ type: "revision_conflict",
195
+ expectedRevision: plan.expectedRevision,
196
+ ...actual === null ? {} : { actualRevision: actual.revision }
197
+ }
198
+ };
125
199
  }
126
200
  if (plan.draftToDeleteVersion !== void 0) {
127
201
  await versions.deleteOne(
@@ -130,9 +204,16 @@ function createMongoDbStorage(options) {
130
204
  );
131
205
  }
132
206
  if (plan.draftToCreate !== void 0) await upsertVersionRecord(plan.draftToCreate, session);
133
- if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
134
207
  for (const record of plan.archivedRecordsToSave ?? []) await upsertVersionRecord(record, session);
135
- return { success: true };
208
+ if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
209
+ for (const [eventIndex, event] of plan.events.entries()) {
210
+ await versionEvents.updateOne(
211
+ { _id: versionEventDocumentId(plan.formId, plan.expectedRevision, eventIndex) },
212
+ { $set: { formId: plan.formId, fromRevision: plan.expectedRevision, eventIndex, event: cloneJson(event) } },
213
+ { upsert: true, ...session === void 0 ? {} : { session } }
214
+ );
215
+ }
216
+ return { success: true, value: { success: true } };
136
217
  };
137
218
  return {
138
219
  async saveSchema(schema) {
@@ -184,7 +265,7 @@ function createMongoDbStorage(options) {
184
265
  ...options2.since === void 0 ? {} : { $gte: options2.since },
185
266
  ...options2.until === void 0 ? {} : { $lte: options2.until }
186
267
  };
187
- const filter = {
268
+ const baseFilter = {
188
269
  formId,
189
270
  ...options2.version === void 0 ? {} : { formVersion: options2.version },
190
271
  ...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
@@ -196,8 +277,16 @@ function createMongoDbStorage(options) {
196
277
  ]
197
278
  }
198
279
  };
280
+ const serverFilters = [baseFilter];
281
+ if (options2.filter !== void 0 && typeof options2.filter !== "function") {
282
+ serverFilters.push(submissionFilterToMongo(options2.filter));
283
+ }
284
+ for (const [key, value] of Object.entries(options2.metadataFilters ?? {})) {
285
+ serverFilters.push({ [`submission.metadata.${key}`]: value });
286
+ }
287
+ const filter = serverFilters.length === 1 ? baseFilter : { $and: serverFilters };
199
288
  const sorted = submissions.find(filter).sort({ submittedAt: 1, _id: 1 });
200
- const requiresClientFiltering = options2.filter !== void 0 || options2.metadataFilters !== void 0;
289
+ const requiresClientFiltering = typeof options2.filter === "function";
201
290
  const documents = requiresClientFiltering ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
202
291
  const candidates = documents.map(parseSubmissionDocument).filter((item) => matchesSubmissionPageFilters(item, options2));
203
292
  const hasMore = candidates.length > pageSize;
@@ -209,6 +298,69 @@ function createMongoDbStorage(options) {
209
298
  ...hasMore && last !== void 0 ? { nextCursor: encodeSubmissionCursor({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
210
299
  };
211
300
  },
301
+ async listTextAnswerPage(formId, fieldIdOrOptions, providedOptions) {
302
+ const options2 = typeof fieldIdOrOptions === "string" ? providedOptions ?? {} : fieldIdOrOptions ?? {};
303
+ const requestedFieldIds = typeof fieldIdOrOptions === "string" ? [fieldIdOrOptions] : options2.fieldIds === void 0 ? void 0 : [...new Set(options2.fieldIds)];
304
+ if (requestedFieldIds?.some((fieldId) => fieldId.trim().length === 0)) {
305
+ throw new TypeError("fieldIds must not contain empty values.");
306
+ }
307
+ const pageSize = normalizeSubmissionPageSize(options2.pageSize);
308
+ const cursor = options2.cursor === void 0 ? void 0 : decodeTextAnswerCursor(options2.cursor);
309
+ if (cursor !== void 0 && requestedFieldIds !== void 0 && !requestedFieldIds.includes(cursor.fieldId)) {
310
+ throw new TypeError("Text answer cursor fieldId does not match the query fields.");
311
+ }
312
+ const submittedAt = options2.since === void 0 && options2.until === void 0 ? void 0 : {
313
+ ...options2.since === void 0 ? {} : { $gte: options2.since },
314
+ ...options2.until === void 0 ? {} : { $lte: options2.until }
315
+ };
316
+ const baseFilter = {
317
+ formId,
318
+ ...options2.version === void 0 ? {} : { formVersion: options2.version },
319
+ ...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
320
+ ...submittedAt === void 0 ? {} : { submittedAt },
321
+ ...cursor === void 0 ? {} : { _id: { $gte: cursor.responseId } },
322
+ ...requestedFieldIds?.length === 1 ? { [`submission.values.${requestedFieldIds[0]}`]: { $type: "string" } } : {}
323
+ };
324
+ const serverFilters = [baseFilter];
325
+ if (options2.filter !== void 0 && typeof options2.filter !== "function") {
326
+ serverFilters.push(submissionFilterToMongo(options2.filter));
327
+ }
328
+ for (const [key, value] of Object.entries(options2.metadataFilters ?? {})) {
329
+ serverFilters.push({ [`submission.metadata.${key}`]: value });
330
+ }
331
+ const filter = serverFilters.length === 1 ? baseFilter : { $and: serverFilters };
332
+ const sorted = submissions.find(filter).sort({ _id: 1 });
333
+ const documents = await sorted.toArray();
334
+ const candidates = documents.map(parseSubmissionDocument).filter((submission) => matchesSubmissionPageFilters(submission, options2)).flatMap((submission) => {
335
+ const entries = requestedFieldIds === void 0 ? Object.entries(submission.values) : requestedFieldIds.map((fieldId) => [fieldId, submission.values[fieldId]]);
336
+ return entries.flatMap(([fieldId, text]) => {
337
+ if (typeof text !== "string" || text.length === 0) return [];
338
+ if (cursor !== void 0 && (submission.id < cursor.responseId || submission.id === cursor.responseId && fieldId <= cursor.fieldId)) {
339
+ return [];
340
+ }
341
+ return [
342
+ {
343
+ responseId: submission.id,
344
+ formId: submission.formId,
345
+ formVersion: submission.formVersion,
346
+ fieldId,
347
+ text,
348
+ locale: submission.locale,
349
+ submittedAt: submission.submittedAt,
350
+ ...submission.metadata === void 0 ? {} : { metadata: submission.metadata }
351
+ }
352
+ ];
353
+ });
354
+ });
355
+ const hasMore = candidates.length > pageSize;
356
+ const items = candidates.slice(0, pageSize);
357
+ const last = items.at(-1);
358
+ return {
359
+ items,
360
+ hasMore,
361
+ ...hasMore && last !== void 0 ? { nextCursor: encodeTextAnswerCursor({ responseId: last.responseId, fieldId: last.fieldId }) } : {}
362
+ };
363
+ },
212
364
  async deleteSubmission(submissionId) {
213
365
  await submissions.deleteOne({ _id: submissionId });
214
366
  },
@@ -220,33 +372,67 @@ function createMongoDbStorage(options) {
220
372
  schemas.deleteMany({}),
221
373
  submissions.deleteMany({}),
222
374
  versions.deleteMany({}),
223
- versionStates.deleteMany({})
375
+ versionStates.deleteMany({}),
376
+ versionEvents.deleteMany({})
224
377
  ]);
225
378
  },
379
+ async getVersionState(formId) {
380
+ const document = await versionStates.findOne({ _id: formId });
381
+ if (document === null) return null;
382
+ if (!Number.isSafeInteger(document.revision) || document.revision < 0) {
383
+ throw new Error(`MongoDB version state "${formId}" is invalid.`);
384
+ }
385
+ const nextVersion = document.nextVersion ?? Math.max(1, (document.draftVersion ?? 0) + 1, (document.publishedVersion ?? 0) + 1);
386
+ return {
387
+ formId,
388
+ revision: document.revision,
389
+ nextVersion,
390
+ ...document.draftVersion === void 0 ? {} : { draftVersion: document.draftVersion },
391
+ ...document.publishedVersion === void 0 ? {} : { publishedVersion: document.publishedVersion }
392
+ };
393
+ },
394
+ async getVersionRecord(formId, version) {
395
+ const document = await versions.findOne({ _id: versionDocumentId(formId, version) });
396
+ return document === null ? null : parseVersionRecordDocument(document);
397
+ },
398
+ async listVersionRecords(formId) {
399
+ return (await versions.find({ formId }).sort({ version: 1 }).toArray()).map(parseVersionRecordDocument);
400
+ },
226
401
  async commitVersionTransition(plan) {
227
- assertTransitionPlan(plan);
402
+ try {
403
+ assertTransitionPlan(plan);
404
+ } catch (cause) {
405
+ return {
406
+ success: false,
407
+ error: { type: "invalid_transition", message: cause instanceof Error ? cause.message : String(cause) }
408
+ };
409
+ }
228
410
  const client = options.db.client;
229
411
  if (client === void 0 || typeof client.startSession !== "function") {
230
412
  try {
231
413
  return await applyVersionTransition(plan);
232
414
  } catch (error) {
233
- if (isCasConflict(error)) return { success: false, error: "revision_conflict" };
234
- throw error;
415
+ if (isCasConflict(error)) {
416
+ return storageRevisionConflict(plan);
417
+ }
418
+ return { success: false, error: { type: "storage_error", cause: error } };
235
419
  }
236
420
  }
237
421
  const session = client.startSession();
238
422
  try {
239
423
  let result = {
240
424
  success: false,
241
- error: "revision_conflict"
425
+ error: { type: "revision_conflict", expectedRevision: plan.expectedRevision }
242
426
  };
243
427
  await session.withTransaction(async () => {
244
428
  result = await applyVersionTransition(plan, session);
245
429
  });
246
430
  return result;
247
431
  } catch (error) {
248
- if (isCasConflict(error)) return { success: false, error: "revision_conflict" };
249
- throw error;
432
+ if (isCasConflict(error)) {
433
+ return storageRevisionConflict(plan);
434
+ }
435
+ return { success: false, error: { type: "storage_error", cause: error } };
250
436
  } finally {
251
437
  await session.endSession();
252
438
  }
@@ -258,13 +444,28 @@ function createMongoDbStorage(options) {
258
444
  { key: { "submission.locale": 1 }, name: "form_responses_locale" }
259
445
  ]),
260
446
  versions.createIndexes([
261
- { key: { formId: 1, version: 1 }, name: "form_versions_form_version", unique: true },
262
- { key: { formId: 1, status: 1 }, name: "form_versions_form_status" }
447
+ { key: { formId: 1, version: 1 }, name: "unique_version_per_form", unique: true },
448
+ {
449
+ key: { formId: 1 },
450
+ name: "unique_draft_per_form",
451
+ unique: true,
452
+ partialFilterExpression: { status: "draft" }
453
+ },
454
+ {
455
+ key: { formId: 1 },
456
+ name: "unique_published_per_form",
457
+ unique: true,
458
+ partialFilterExpression: { status: "published" }
459
+ }
460
+ ]),
461
+ versionEvents.createIndexes([
462
+ { key: { formId: 1, fromRevision: 1, eventIndex: 1 }, name: "form_version_events_revision" }
263
463
  ])
264
464
  ]);
265
465
  }
266
466
  };
267
467
  }
268
468
  export {
269
- createMongoDbStorage
469
+ createMongoDbStorage,
470
+ submissionFilterToMongo
270
471
  };
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.8.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.8.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "mongodb": "^6.0.0"