@form-engine-ts/storage-mongodb 2.5.1 → 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
@@ -30,3 +30,8 @@ The caller owns the MongoDB connection lifecycle. Collection names can be custom
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
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);
@@ -157,12 +234,51 @@ function createMongoDbStorage(options) {
157
234
  await submissions.deleteMany({ formId });
158
235
  },
159
236
  async clear() {
160
- 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
+ }
161
271
  },
162
272
  async createIndexes() {
163
- await submissions.createIndexes([
164
- { key: { formId: 1, submittedAt: 1, _id: 1 }, name: "form_responses_form_submitted_at_id" },
165
- { key: { "submission.locale": 1 }, name: "form_responses_locale" }
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
+ ])
166
282
  ]);
167
283
  }
168
284
  };
package/dist/index.d.cts CHANGED
@@ -1,12 +1,14 @@
1
- import { PagedSubmissionStorageAdapter } 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 PagedSubmissionStorageAdapter {
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 { PagedSubmissionStorageAdapter } 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 PagedSubmissionStorageAdapter {
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
@@ -24,6 +24,33 @@ function parseSubmission(value, location) {
24
24
  function schemaDocumentId(formId, formVersion) {
25
25
  return `schema:${encodeURIComponent(formId)}:${formVersion}`;
26
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
+ }
27
54
  function parseSchemaDocument(document) {
28
55
  try {
29
56
  assertValidFormSchema(document.schema);
@@ -55,8 +82,58 @@ function createMongoDbStorage(options) {
55
82
  "form_responses",
56
83
  "responsesCollectionName"
57
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
+ );
58
95
  const schemas = options.db.collection(schemasCollectionName);
59
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
+ };
60
137
  return {
61
138
  async saveSchema(schema) {
62
139
  assertValidFormSchema(schema);
@@ -139,12 +216,51 @@ function createMongoDbStorage(options) {
139
216
  await submissions.deleteMany({ formId });
140
217
  },
141
218
  async clear() {
142
- 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
+ }
143
253
  },
144
254
  async createIndexes() {
145
- await submissions.createIndexes([
146
- { key: { formId: 1, submittedAt: 1, _id: 1 }, name: "form_responses_form_submitted_at_id" },
147
- { key: { "submission.locale": 1 }, name: "form_responses_locale" }
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
+ ])
148
264
  ]);
149
265
  }
150
266
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/storage-mongodb",
3
- "version": "2.5.1",
3
+ "version": "2.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.5.1"
42
+ "@form-engine-ts/core": "2.6.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "mongodb": "^6.0.0"