@form-engine-ts/storage-mongodb 2.3.0 → 2.6.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
@@ -26,3 +26,12 @@ const submissions = await storage.listSubmissions(schema.id, schema.version, {
26
26
  ```
27
27
 
28
28
  The caller owns the MongoDB connection lifecycle. Collection names can be customized in the factory options. `createIndexes()` creates named indexes for form/timestamp range scans and locale queries. Range boundaries are inclusive, and MongoDB sorts by submission time and then ID. `clearResponses(formId)` preserves schemas and other forms; `clear()` removes documents only from the configured collections and does not drop collections or indexes.
29
+
30
+ `listSubmissionPage(formId, { version, pageSize, cursor, since, until, locale })` performs bounded reads using the
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.
33
+
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
+ `revision_conflict` to losing concurrent publishers; on a real MongoDB client, the state and record changes are committed
37
+ in one transaction.
package/dist/index.cjs CHANGED
@@ -42,6 +42,33 @@ function parseSubmission(value, location) {
42
42
  function schemaDocumentId(formId, formVersion) {
43
43
  return `schema:${encodeURIComponent(formId)}:${formVersion}`;
44
44
  }
45
+ function versionDocumentId(formId, formVersion) {
46
+ return `version:${encodeURIComponent(formId)}:${formVersion}`;
47
+ }
48
+ function assertVersionRecord(record, formId, expectedStatus) {
49
+ 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
+ throw new TypeError("Version transition record is inconsistent.");
51
+ }
52
+ (0, import_core.assertValidFormSchema)(record.schema);
53
+ }
54
+ function assertTransitionPlan(plan) {
55
+ 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
+ throw new TypeError("Version transition plan is invalid.");
57
+ }
58
+ if (plan.draftToCreate !== void 0) assertVersionRecord(plan.draftToCreate, plan.formId, "draft");
59
+ if (plan.publishedRecordToSave !== void 0) {
60
+ assertVersionRecord(plan.publishedRecordToSave, plan.formId, "published");
61
+ }
62
+ for (const record of plan.archivedRecordsToSave ?? []) assertVersionRecord(record, plan.formId, "archived");
63
+ if (plan.draftToDeleteVersion !== void 0 && (!Number.isSafeInteger(plan.draftToDeleteVersion) || plan.draftToDeleteVersion < 1)) {
64
+ throw new TypeError("draftToDeleteVersion must be a positive safe integer.");
65
+ }
66
+ }
67
+ function isCasConflict(error) {
68
+ if (!isRecord(error)) return false;
69
+ if (error.code === 11e3 || error.code === 112 || error.code === 251) return true;
70
+ return typeof error.hasErrorLabel === "function" && error.hasErrorLabel("TransientTransactionError") === true;
71
+ }
45
72
  function parseSchemaDocument(document) {
46
73
  try {
47
74
  (0, import_core.assertValidFormSchema)(document.schema);
@@ -73,8 +100,58 @@ function createMongoDbStorage(options) {
73
100
  "form_responses",
74
101
  "responsesCollectionName"
75
102
  );
103
+ const versionsCollectionName = collectionName(
104
+ options.versionsCollectionName,
105
+ "form_versions",
106
+ "versionsCollectionName"
107
+ );
108
+ const versionStatesCollectionName = collectionName(
109
+ options.versionStatesCollectionName,
110
+ "form_version_states",
111
+ "versionStatesCollectionName"
112
+ );
76
113
  const schemas = options.db.collection(schemasCollectionName);
77
114
  const submissions = options.db.collection(responsesCollectionName);
115
+ const versions = options.db.collection(versionsCollectionName);
116
+ const versionStates = options.db.collection(versionStatesCollectionName);
117
+ const upsertVersionRecord = async (record, session) => {
118
+ const stored = cloneJson(record);
119
+ await versions.updateOne(
120
+ { _id: versionDocumentId(record.formId, record.version) },
121
+ { $set: { formId: record.formId, version: record.version, status: record.status, record: stored } },
122
+ { upsert: true, ...session === void 0 ? {} : { session } }
123
+ );
124
+ };
125
+ const applyVersionTransition = async (plan, session) => {
126
+ const stateSet = { revision: plan.nextRevision, updatedAt: plan.timestamp };
127
+ if (plan.draftToCreate !== void 0) stateSet.draftVersion = plan.draftToCreate.version;
128
+ if (plan.publishedRecordToSave !== void 0) stateSet.publishedVersion = plan.publishedRecordToSave.version;
129
+ const stateUpdate = {
130
+ $set: stateSet,
131
+ ...plan.draftToDeleteVersion === void 0 ? {} : { $unset: { draftVersion: "" } }
132
+ };
133
+ const stateResult = await versionStates.updateOne(
134
+ { _id: plan.formId, revision: plan.expectedRevision },
135
+ stateUpdate,
136
+ {
137
+ upsert: plan.expectedRevision === 0,
138
+ ...session === void 0 ? {} : { session }
139
+ }
140
+ );
141
+ if (stateResult.matchedCount === 0 && stateResult.upsertedCount === 0) {
142
+ return { success: false, error: "revision_conflict" };
143
+ }
144
+ if (plan.draftToDeleteVersion !== void 0) {
145
+ await versions.deleteOne(
146
+ { _id: versionDocumentId(plan.formId, plan.draftToDeleteVersion) },
147
+ session === void 0 ? {} : { session }
148
+ );
149
+ }
150
+ if (plan.draftToCreate !== void 0) await upsertVersionRecord(plan.draftToCreate, session);
151
+ if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
152
+ for (const record of plan.archivedRecordsToSave ?? []) await upsertVersionRecord(record, session);
153
+ return { success: true };
154
+ };
78
155
  return {
79
156
  async saveSchema(schema) {
80
157
  (0, import_core.assertValidFormSchema)(schema);
@@ -118,6 +195,38 @@ function createMongoDbStorage(options) {
118
195
  }).sort({ submittedAt: 1, _id: 1 }).toArray();
119
196
  return documents.map(parseSubmissionDocument);
120
197
  },
198
+ async listSubmissionPage(formId, options2 = {}) {
199
+ const pageSize = (0, import_core.normalizeSubmissionPageSize)(options2.pageSize);
200
+ const cursor = options2.cursor === void 0 ? void 0 : (0, import_core.decodeSubmissionCursor)(options2.cursor);
201
+ const submittedAt = options2.since === void 0 && options2.until === void 0 ? void 0 : {
202
+ ...options2.since === void 0 ? {} : { $gte: options2.since },
203
+ ...options2.until === void 0 ? {} : { $lte: options2.until }
204
+ };
205
+ const filter = {
206
+ formId,
207
+ ...options2.version === void 0 ? {} : { formVersion: options2.version },
208
+ ...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
209
+ ...submittedAt === void 0 ? {} : { submittedAt },
210
+ ...cursor === void 0 ? {} : {
211
+ $or: [
212
+ { submittedAt: { $gt: cursor.submittedAt } },
213
+ { submittedAt: cursor.submittedAt, _id: { $gt: cursor.responseId } }
214
+ ]
215
+ }
216
+ };
217
+ const sorted = submissions.find(filter).sort({ submittedAt: 1, _id: 1 });
218
+ const requiresClientFiltering = options2.filter !== void 0 || options2.metadataFilters !== void 0;
219
+ const documents = requiresClientFiltering ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
220
+ const candidates = documents.map(parseSubmissionDocument).filter((item) => (0, import_core.matchesSubmissionPageFilters)(item, options2));
221
+ const hasMore = candidates.length > pageSize;
222
+ const items = candidates.slice(0, pageSize);
223
+ const last = items.at(-1);
224
+ return {
225
+ items,
226
+ hasMore,
227
+ ...hasMore && last !== void 0 ? { nextCursor: (0, import_core.encodeSubmissionCursor)({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
228
+ };
229
+ },
121
230
  async deleteSubmission(submissionId) {
122
231
  await submissions.deleteOne({ _id: submissionId });
123
232
  },
@@ -125,12 +234,51 @@ function createMongoDbStorage(options) {
125
234
  await submissions.deleteMany({ formId });
126
235
  },
127
236
  async clear() {
128
- await Promise.all([schemas.deleteMany({}), submissions.deleteMany({})]);
237
+ await Promise.all([
238
+ schemas.deleteMany({}),
239
+ submissions.deleteMany({}),
240
+ versions.deleteMany({}),
241
+ versionStates.deleteMany({})
242
+ ]);
243
+ },
244
+ async commitVersionTransition(plan) {
245
+ assertTransitionPlan(plan);
246
+ const client = options.db.client;
247
+ if (client === void 0 || typeof client.startSession !== "function") {
248
+ try {
249
+ return await applyVersionTransition(plan);
250
+ } catch (error) {
251
+ if (isCasConflict(error)) return { success: false, error: "revision_conflict" };
252
+ throw error;
253
+ }
254
+ }
255
+ const session = client.startSession();
256
+ try {
257
+ let result = {
258
+ success: false,
259
+ error: "revision_conflict"
260
+ };
261
+ await session.withTransaction(async () => {
262
+ result = await applyVersionTransition(plan, session);
263
+ });
264
+ return result;
265
+ } catch (error) {
266
+ if (isCasConflict(error)) return { success: false, error: "revision_conflict" };
267
+ throw error;
268
+ } finally {
269
+ await session.endSession();
270
+ }
129
271
  },
130
272
  async createIndexes() {
131
- await submissions.createIndexes([
132
- { key: { formId: 1, submittedAt: 1 }, name: "form_responses_form_submitted_at" },
133
- { key: { "submission.locale": 1 }, name: "form_responses_locale" }
273
+ await Promise.all([
274
+ submissions.createIndexes([
275
+ { key: { formId: 1, submittedAt: 1, _id: 1 }, name: "form_responses_form_submitted_at_id" },
276
+ { key: { "submission.locale": 1 }, name: "form_responses_locale" }
277
+ ]),
278
+ 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" }
281
+ ])
134
282
  ]);
135
283
  }
136
284
  };
package/dist/index.d.cts CHANGED
@@ -1,12 +1,14 @@
1
- import { FormStorageAdapter } from '@form-engine-ts/core';
1
+ import { PagedSubmissionStorageAdapter, VersionedFormStorageAdapter } from '@form-engine-ts/core';
2
2
  import { Db } from 'mongodb';
3
3
 
4
4
  interface MongoDbStorageOptions {
5
5
  readonly db: Db;
6
6
  readonly schemasCollectionName?: string;
7
7
  readonly responsesCollectionName?: string;
8
+ readonly versionsCollectionName?: string;
9
+ readonly versionStatesCollectionName?: string;
8
10
  }
9
- interface MongoDbStorageAdapter extends FormStorageAdapter {
11
+ interface MongoDbStorageAdapter extends PagedSubmissionStorageAdapter, VersionedFormStorageAdapter {
10
12
  createIndexes(): Promise<void>;
11
13
  }
12
14
  declare function createMongoDbStorage(options: MongoDbStorageOptions): MongoDbStorageAdapter;
package/dist/index.d.ts CHANGED
@@ -1,12 +1,14 @@
1
- import { FormStorageAdapter } from '@form-engine-ts/core';
1
+ import { PagedSubmissionStorageAdapter, VersionedFormStorageAdapter } from '@form-engine-ts/core';
2
2
  import { Db } from 'mongodb';
3
3
 
4
4
  interface MongoDbStorageOptions {
5
5
  readonly db: Db;
6
6
  readonly schemasCollectionName?: string;
7
7
  readonly responsesCollectionName?: string;
8
+ readonly versionsCollectionName?: string;
9
+ readonly versionStatesCollectionName?: string;
8
10
  }
9
- interface MongoDbStorageAdapter extends FormStorageAdapter {
11
+ interface MongoDbStorageAdapter extends PagedSubmissionStorageAdapter, VersionedFormStorageAdapter {
10
12
  createIndexes(): Promise<void>;
11
13
  }
12
14
  declare function createMongoDbStorage(options: MongoDbStorageOptions): MongoDbStorageAdapter;
package/dist/index.js CHANGED
@@ -1,5 +1,11 @@
1
1
  // src/index.ts
2
- import { assertValidFormSchema } from "@form-engine-ts/core";
2
+ import {
3
+ assertValidFormSchema,
4
+ decodeSubmissionCursor,
5
+ encodeSubmissionCursor,
6
+ matchesSubmissionPageFilters,
7
+ normalizeSubmissionPageSize
8
+ } from "@form-engine-ts/core";
3
9
  function cloneJson(value) {
4
10
  return JSON.parse(JSON.stringify(value));
5
11
  }
@@ -18,6 +24,33 @@ function parseSubmission(value, location) {
18
24
  function schemaDocumentId(formId, formVersion) {
19
25
  return `schema:${encodeURIComponent(formId)}:${formVersion}`;
20
26
  }
27
+ function versionDocumentId(formId, formVersion) {
28
+ return `version:${encodeURIComponent(formId)}:${formVersion}`;
29
+ }
30
+ function assertVersionRecord(record, formId, expectedStatus) {
31
+ 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
+ throw new TypeError("Version transition record is inconsistent.");
33
+ }
34
+ assertValidFormSchema(record.schema);
35
+ }
36
+ function assertTransitionPlan(plan) {
37
+ 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
+ throw new TypeError("Version transition plan is invalid.");
39
+ }
40
+ if (plan.draftToCreate !== void 0) assertVersionRecord(plan.draftToCreate, plan.formId, "draft");
41
+ if (plan.publishedRecordToSave !== void 0) {
42
+ assertVersionRecord(plan.publishedRecordToSave, plan.formId, "published");
43
+ }
44
+ for (const record of plan.archivedRecordsToSave ?? []) assertVersionRecord(record, plan.formId, "archived");
45
+ if (plan.draftToDeleteVersion !== void 0 && (!Number.isSafeInteger(plan.draftToDeleteVersion) || plan.draftToDeleteVersion < 1)) {
46
+ throw new TypeError("draftToDeleteVersion must be a positive safe integer.");
47
+ }
48
+ }
49
+ function isCasConflict(error) {
50
+ if (!isRecord(error)) return false;
51
+ if (error.code === 11e3 || error.code === 112 || error.code === 251) return true;
52
+ return typeof error.hasErrorLabel === "function" && error.hasErrorLabel("TransientTransactionError") === true;
53
+ }
21
54
  function parseSchemaDocument(document) {
22
55
  try {
23
56
  assertValidFormSchema(document.schema);
@@ -49,8 +82,58 @@ function createMongoDbStorage(options) {
49
82
  "form_responses",
50
83
  "responsesCollectionName"
51
84
  );
85
+ const versionsCollectionName = collectionName(
86
+ options.versionsCollectionName,
87
+ "form_versions",
88
+ "versionsCollectionName"
89
+ );
90
+ const versionStatesCollectionName = collectionName(
91
+ options.versionStatesCollectionName,
92
+ "form_version_states",
93
+ "versionStatesCollectionName"
94
+ );
52
95
  const schemas = options.db.collection(schemasCollectionName);
53
96
  const submissions = options.db.collection(responsesCollectionName);
97
+ const versions = options.db.collection(versionsCollectionName);
98
+ const versionStates = options.db.collection(versionStatesCollectionName);
99
+ const upsertVersionRecord = async (record, session) => {
100
+ const stored = cloneJson(record);
101
+ await versions.updateOne(
102
+ { _id: versionDocumentId(record.formId, record.version) },
103
+ { $set: { formId: record.formId, version: record.version, status: record.status, record: stored } },
104
+ { upsert: true, ...session === void 0 ? {} : { session } }
105
+ );
106
+ };
107
+ const applyVersionTransition = async (plan, session) => {
108
+ const stateSet = { revision: plan.nextRevision, updatedAt: plan.timestamp };
109
+ if (plan.draftToCreate !== void 0) stateSet.draftVersion = plan.draftToCreate.version;
110
+ if (plan.publishedRecordToSave !== void 0) stateSet.publishedVersion = plan.publishedRecordToSave.version;
111
+ const stateUpdate = {
112
+ $set: stateSet,
113
+ ...plan.draftToDeleteVersion === void 0 ? {} : { $unset: { draftVersion: "" } }
114
+ };
115
+ const stateResult = await versionStates.updateOne(
116
+ { _id: plan.formId, revision: plan.expectedRevision },
117
+ stateUpdate,
118
+ {
119
+ upsert: plan.expectedRevision === 0,
120
+ ...session === void 0 ? {} : { session }
121
+ }
122
+ );
123
+ if (stateResult.matchedCount === 0 && stateResult.upsertedCount === 0) {
124
+ return { success: false, error: "revision_conflict" };
125
+ }
126
+ if (plan.draftToDeleteVersion !== void 0) {
127
+ await versions.deleteOne(
128
+ { _id: versionDocumentId(plan.formId, plan.draftToDeleteVersion) },
129
+ session === void 0 ? {} : { session }
130
+ );
131
+ }
132
+ if (plan.draftToCreate !== void 0) await upsertVersionRecord(plan.draftToCreate, session);
133
+ if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
134
+ for (const record of plan.archivedRecordsToSave ?? []) await upsertVersionRecord(record, session);
135
+ return { success: true };
136
+ };
54
137
  return {
55
138
  async saveSchema(schema) {
56
139
  assertValidFormSchema(schema);
@@ -94,6 +177,38 @@ function createMongoDbStorage(options) {
94
177
  }).sort({ submittedAt: 1, _id: 1 }).toArray();
95
178
  return documents.map(parseSubmissionDocument);
96
179
  },
180
+ async listSubmissionPage(formId, options2 = {}) {
181
+ const pageSize = normalizeSubmissionPageSize(options2.pageSize);
182
+ const cursor = options2.cursor === void 0 ? void 0 : decodeSubmissionCursor(options2.cursor);
183
+ const submittedAt = options2.since === void 0 && options2.until === void 0 ? void 0 : {
184
+ ...options2.since === void 0 ? {} : { $gte: options2.since },
185
+ ...options2.until === void 0 ? {} : { $lte: options2.until }
186
+ };
187
+ const filter = {
188
+ formId,
189
+ ...options2.version === void 0 ? {} : { formVersion: options2.version },
190
+ ...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
191
+ ...submittedAt === void 0 ? {} : { submittedAt },
192
+ ...cursor === void 0 ? {} : {
193
+ $or: [
194
+ { submittedAt: { $gt: cursor.submittedAt } },
195
+ { submittedAt: cursor.submittedAt, _id: { $gt: cursor.responseId } }
196
+ ]
197
+ }
198
+ };
199
+ const sorted = submissions.find(filter).sort({ submittedAt: 1, _id: 1 });
200
+ const requiresClientFiltering = options2.filter !== void 0 || options2.metadataFilters !== void 0;
201
+ const documents = requiresClientFiltering ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
202
+ const candidates = documents.map(parseSubmissionDocument).filter((item) => matchesSubmissionPageFilters(item, options2));
203
+ const hasMore = candidates.length > pageSize;
204
+ const items = candidates.slice(0, pageSize);
205
+ const last = items.at(-1);
206
+ return {
207
+ items,
208
+ hasMore,
209
+ ...hasMore && last !== void 0 ? { nextCursor: encodeSubmissionCursor({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
210
+ };
211
+ },
97
212
  async deleteSubmission(submissionId) {
98
213
  await submissions.deleteOne({ _id: submissionId });
99
214
  },
@@ -101,12 +216,51 @@ function createMongoDbStorage(options) {
101
216
  await submissions.deleteMany({ formId });
102
217
  },
103
218
  async clear() {
104
- await Promise.all([schemas.deleteMany({}), submissions.deleteMany({})]);
219
+ await Promise.all([
220
+ schemas.deleteMany({}),
221
+ submissions.deleteMany({}),
222
+ versions.deleteMany({}),
223
+ versionStates.deleteMany({})
224
+ ]);
225
+ },
226
+ async commitVersionTransition(plan) {
227
+ assertTransitionPlan(plan);
228
+ const client = options.db.client;
229
+ if (client === void 0 || typeof client.startSession !== "function") {
230
+ try {
231
+ return await applyVersionTransition(plan);
232
+ } catch (error) {
233
+ if (isCasConflict(error)) return { success: false, error: "revision_conflict" };
234
+ throw error;
235
+ }
236
+ }
237
+ const session = client.startSession();
238
+ try {
239
+ let result = {
240
+ success: false,
241
+ error: "revision_conflict"
242
+ };
243
+ await session.withTransaction(async () => {
244
+ result = await applyVersionTransition(plan, session);
245
+ });
246
+ return result;
247
+ } catch (error) {
248
+ if (isCasConflict(error)) return { success: false, error: "revision_conflict" };
249
+ throw error;
250
+ } finally {
251
+ await session.endSession();
252
+ }
105
253
  },
106
254
  async createIndexes() {
107
- await submissions.createIndexes([
108
- { key: { formId: 1, submittedAt: 1 }, name: "form_responses_form_submitted_at" },
109
- { key: { "submission.locale": 1 }, name: "form_responses_locale" }
255
+ await Promise.all([
256
+ submissions.createIndexes([
257
+ { key: { formId: 1, submittedAt: 1, _id: 1 }, name: "form_responses_form_submitted_at_id" },
258
+ { key: { "submission.locale": 1 }, name: "form_responses_locale" }
259
+ ]),
260
+ 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" }
263
+ ])
110
264
  ]);
111
265
  }
112
266
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/storage-mongodb",
3
- "version": "2.3.0",
3
+ "version": "2.6.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.3.0"
42
+ "@form-engine-ts/core": "2.6.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "mongodb": "^6.0.0"