@form-engine-ts/storage-mongodb 2.7.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
@@ -33,8 +33,9 @@ Metadata filters and the generic submission-filter AST are translated to MongoDB
33
33
  Legacy predicate filters remain supported and are applied client-side. `listTextAnswerPage` returns stable cursor pages
34
34
  of individual text/textarea answers without loading every answer body at once.
35
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
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
38
39
  `revision_conflict` to losing concurrent publishers; on a real MongoDB client, the state and record changes are committed
39
40
  in one transaction. Clone, publish, and draft-delete plans persist complete version state, affected records, and audit
40
41
  events. State/record/list reads and typed commit errors are exposed through the full `VersionedFormStorageAdapter` API.
package/dist/index.cjs CHANGED
@@ -104,7 +104,11 @@ function parseVersionRecordDocument(document) {
104
104
  }
105
105
  function isCasConflict(error) {
106
106
  if (!isRecord(error)) return false;
107
- 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;
108
112
  return typeof error.hasErrorLabel === "function" && error.hasErrorLabel("TransientTransactionError") === true;
109
113
  }
110
114
  function parseSchemaDocument(document) {
@@ -217,8 +221,8 @@ function createMongoDbStorage(options) {
217
221
  );
218
222
  }
219
223
  if (plan.draftToCreate !== void 0) await upsertVersionRecord(plan.draftToCreate, session);
220
- if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
221
224
  for (const record of plan.archivedRecordsToSave ?? []) await upsertVersionRecord(record, session);
225
+ if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
222
226
  for (const [eventIndex, event] of plan.events.entries()) {
223
227
  await versionEvents.updateOne(
224
228
  { _id: versionEventDocumentId(plan.formId, plan.expectedRevision, eventIndex) },
@@ -311,12 +315,16 @@ function createMongoDbStorage(options) {
311
315
  ...hasMore && last !== void 0 ? { nextCursor: (0, import_core.encodeSubmissionCursor)({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
312
316
  };
313
317
  },
314
- async listTextAnswerPage(formId, fieldId, options2 = {}) {
315
- if (fieldId.trim().length === 0) throw new TypeError("fieldId must not be empty.");
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
+ }
316
324
  const pageSize = (0, import_core.normalizeSubmissionPageSize)(options2.pageSize);
317
325
  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.");
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.");
320
328
  }
321
329
  const submittedAt = options2.since === void 0 && options2.until === void 0 ? void 0 : {
322
330
  ...options2.since === void 0 ? {} : { $gte: options2.since },
@@ -327,8 +335,8 @@ function createMongoDbStorage(options) {
327
335
  ...options2.version === void 0 ? {} : { formVersion: options2.version },
328
336
  ...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
329
337
  ...submittedAt === void 0 ? {} : { submittedAt },
330
- ...cursor === void 0 ? {} : { _id: { $gt: cursor.responseId } },
331
- [`submission.values.${fieldId}`]: { $type: "string" }
338
+ ...cursor === void 0 ? {} : { _id: { $gte: cursor.responseId } },
339
+ ...requestedFieldIds?.length === 1 ? { [`submission.values.${requestedFieldIds[0]}`]: { $type: "string" } } : {}
332
340
  };
333
341
  const serverFilters = [baseFilter];
334
342
  if (options2.filter !== void 0 && typeof options2.filter !== "function") {
@@ -339,22 +347,27 @@ function createMongoDbStorage(options) {
339
347
  }
340
348
  const filter = serverFilters.length === 1 ? baseFilter : { $and: serverFilters };
341
349
  const sorted = submissions.find(filter).sort({ _id: 1 });
342
- const documents = typeof options2.filter === "function" ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
350
+ const documents = await sorted.toArray();
343
351
  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 }
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 [];
356
357
  }
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
+ });
358
371
  });
359
372
  const hasMore = candidates.length > pageSize;
360
373
  const items = candidates.slice(0, pageSize);
@@ -362,7 +375,7 @@ function createMongoDbStorage(options) {
362
375
  return {
363
376
  items,
364
377
  hasMore,
365
- ...hasMore && last !== void 0 ? { nextCursor: (0, import_core.encodeTextAnswerCursor)({ responseId: last.responseId, fieldId }) } : {}
378
+ ...hasMore && last !== void 0 ? { nextCursor: (0, import_core.encodeTextAnswerCursor)({ responseId: last.responseId, fieldId: last.fieldId }) } : {}
366
379
  };
367
380
  },
368
381
  async deleteSubmission(submissionId) {
@@ -448,8 +461,19 @@ function createMongoDbStorage(options) {
448
461
  { key: { "submission.locale": 1 }, name: "form_responses_locale" }
449
462
  ]),
450
463
  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" }
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
+ }
453
477
  ]),
454
478
  versionEvents.createIndexes([
455
479
  { key: { formId: 1, fromRevision: 1, eventIndex: 1 }, name: "form_version_events_revision" }
package/dist/index.js CHANGED
@@ -87,7 +87,11 @@ function parseVersionRecordDocument(document) {
87
87
  }
88
88
  function isCasConflict(error) {
89
89
  if (!isRecord(error)) return false;
90
- 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;
91
95
  return typeof error.hasErrorLabel === "function" && error.hasErrorLabel("TransientTransactionError") === true;
92
96
  }
93
97
  function parseSchemaDocument(document) {
@@ -200,8 +204,8 @@ function createMongoDbStorage(options) {
200
204
  );
201
205
  }
202
206
  if (plan.draftToCreate !== void 0) await upsertVersionRecord(plan.draftToCreate, session);
203
- if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
204
207
  for (const record of plan.archivedRecordsToSave ?? []) await upsertVersionRecord(record, session);
208
+ if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
205
209
  for (const [eventIndex, event] of plan.events.entries()) {
206
210
  await versionEvents.updateOne(
207
211
  { _id: versionEventDocumentId(plan.formId, plan.expectedRevision, eventIndex) },
@@ -294,12 +298,16 @@ function createMongoDbStorage(options) {
294
298
  ...hasMore && last !== void 0 ? { nextCursor: encodeSubmissionCursor({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
295
299
  };
296
300
  },
297
- async listTextAnswerPage(formId, fieldId, options2 = {}) {
298
- if (fieldId.trim().length === 0) throw new TypeError("fieldId must not be empty.");
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
+ }
299
307
  const pageSize = normalizeSubmissionPageSize(options2.pageSize);
300
308
  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.");
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.");
303
311
  }
304
312
  const submittedAt = options2.since === void 0 && options2.until === void 0 ? void 0 : {
305
313
  ...options2.since === void 0 ? {} : { $gte: options2.since },
@@ -310,8 +318,8 @@ function createMongoDbStorage(options) {
310
318
  ...options2.version === void 0 ? {} : { formVersion: options2.version },
311
319
  ...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
312
320
  ...submittedAt === void 0 ? {} : { submittedAt },
313
- ...cursor === void 0 ? {} : { _id: { $gt: cursor.responseId } },
314
- [`submission.values.${fieldId}`]: { $type: "string" }
321
+ ...cursor === void 0 ? {} : { _id: { $gte: cursor.responseId } },
322
+ ...requestedFieldIds?.length === 1 ? { [`submission.values.${requestedFieldIds[0]}`]: { $type: "string" } } : {}
315
323
  };
316
324
  const serverFilters = [baseFilter];
317
325
  if (options2.filter !== void 0 && typeof options2.filter !== "function") {
@@ -322,22 +330,27 @@ function createMongoDbStorage(options) {
322
330
  }
323
331
  const filter = serverFilters.length === 1 ? baseFilter : { $and: serverFilters };
324
332
  const sorted = submissions.find(filter).sort({ _id: 1 });
325
- const documents = typeof options2.filter === "function" ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
333
+ const documents = await sorted.toArray();
326
334
  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 }
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 [];
339
340
  }
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
+ });
341
354
  });
342
355
  const hasMore = candidates.length > pageSize;
343
356
  const items = candidates.slice(0, pageSize);
@@ -345,7 +358,7 @@ function createMongoDbStorage(options) {
345
358
  return {
346
359
  items,
347
360
  hasMore,
348
- ...hasMore && last !== void 0 ? { nextCursor: encodeTextAnswerCursor({ responseId: last.responseId, fieldId }) } : {}
361
+ ...hasMore && last !== void 0 ? { nextCursor: encodeTextAnswerCursor({ responseId: last.responseId, fieldId: last.fieldId }) } : {}
349
362
  };
350
363
  },
351
364
  async deleteSubmission(submissionId) {
@@ -431,8 +444,19 @@ function createMongoDbStorage(options) {
431
444
  { key: { "submission.locale": 1 }, name: "form_responses_locale" }
432
445
  ]),
433
446
  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" }
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
+ }
436
460
  ]),
437
461
  versionEvents.createIndexes([
438
462
  { key: { formId: 1, fromRevision: 1, eventIndex: 1 }, name: "form_version_events_revision" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/storage-mongodb",
3
- "version": "2.7.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.7.0"
42
+ "@form-engine-ts/core": "2.8.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "mongodb": "^6.0.0"