@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 +5 -0
- package/dist/index.cjs +120 -4
- package/dist/index.d.cts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +120 -4
- package/package.json +2 -2
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([
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
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([
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
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.
|
|
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.
|
|
42
|
+
"@form-engine-ts/core": "2.6.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"mongodb": "^6.0.0"
|