@form-engine-ts/storage-mongodb 2.5.1 → 2.7.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 +9 -1
- package/dist/index.cjs +300 -8
- package/dist/index.d.cts +7 -4
- package/dist/index.d.ts +7 -4
- package/dist/index.js +300 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -29,4 +29,12 @@ 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
|
-
|
|
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.
|
|
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
|
|
38
|
+
`revision_conflict` to losing concurrent publishers; on a real MongoDB client, the state and record changes are committed
|
|
39
|
+
in one transaction. Clone, publish, and draft-delete plans persist complete version state, affected records, and audit
|
|
40
|
+
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");
|
|
@@ -42,6 +43,70 @@ function parseSubmission(value, location) {
|
|
|
42
43
|
function schemaDocumentId(formId, formVersion) {
|
|
43
44
|
return `schema:${encodeURIComponent(formId)}:${formVersion}`;
|
|
44
45
|
}
|
|
46
|
+
function versionDocumentId(formId, formVersion) {
|
|
47
|
+
return `version:${encodeURIComponent(formId)}:${formVersion}`;
|
|
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
|
+
}
|
|
78
|
+
function assertVersionRecord(record, formId, expectedStatus) {
|
|
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) {
|
|
80
|
+
throw new TypeError("Version transition record is inconsistent.");
|
|
81
|
+
}
|
|
82
|
+
(0, import_core.assertValidFormSchema)(record.schema);
|
|
83
|
+
}
|
|
84
|
+
function assertTransitionPlan(plan) {
|
|
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))) {
|
|
86
|
+
throw new TypeError("Version transition plan is invalid.");
|
|
87
|
+
}
|
|
88
|
+
if (!Array.isArray(plan.events)) throw new TypeError("Version transition plan events are required.");
|
|
89
|
+
if (plan.draftToCreate !== void 0) assertVersionRecord(plan.draftToCreate, plan.formId, "draft");
|
|
90
|
+
if (plan.publishedRecordToSave !== void 0) {
|
|
91
|
+
assertVersionRecord(plan.publishedRecordToSave, plan.formId, "published");
|
|
92
|
+
}
|
|
93
|
+
for (const record of plan.archivedRecordsToSave ?? []) assertVersionRecord(record, plan.formId, "archived");
|
|
94
|
+
if (plan.draftToDeleteVersion !== void 0 && (!Number.isSafeInteger(plan.draftToDeleteVersion) || plan.draftToDeleteVersion < 1)) {
|
|
95
|
+
throw new TypeError("draftToDeleteVersion must be a positive safe integer.");
|
|
96
|
+
}
|
|
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
|
+
}
|
|
105
|
+
function isCasConflict(error) {
|
|
106
|
+
if (!isRecord(error)) return false;
|
|
107
|
+
if (error.code === 11e3 || error.code === 112 || error.code === 251) return true;
|
|
108
|
+
return typeof error.hasErrorLabel === "function" && error.hasErrorLabel("TransientTransactionError") === true;
|
|
109
|
+
}
|
|
45
110
|
function parseSchemaDocument(document) {
|
|
46
111
|
try {
|
|
47
112
|
(0, import_core.assertValidFormSchema)(document.schema);
|
|
@@ -73,8 +138,96 @@ function createMongoDbStorage(options) {
|
|
|
73
138
|
"form_responses",
|
|
74
139
|
"responsesCollectionName"
|
|
75
140
|
);
|
|
141
|
+
const versionsCollectionName = collectionName(
|
|
142
|
+
options.versionsCollectionName,
|
|
143
|
+
"form_versions",
|
|
144
|
+
"versionsCollectionName"
|
|
145
|
+
);
|
|
146
|
+
const versionStatesCollectionName = collectionName(
|
|
147
|
+
options.versionStatesCollectionName,
|
|
148
|
+
"form_version_states",
|
|
149
|
+
"versionStatesCollectionName"
|
|
150
|
+
);
|
|
151
|
+
const versionEventsCollectionName = "form_version_events";
|
|
76
152
|
const schemas = options.db.collection(schemasCollectionName);
|
|
77
153
|
const submissions = options.db.collection(responsesCollectionName);
|
|
154
|
+
const versions = options.db.collection(versionsCollectionName);
|
|
155
|
+
const versionStates = options.db.collection(versionStatesCollectionName);
|
|
156
|
+
const versionEvents = options.db.collection(versionEventsCollectionName);
|
|
157
|
+
const storageRevisionConflict = async (plan) => {
|
|
158
|
+
const actual = await versionStates.findOne({ _id: plan.formId });
|
|
159
|
+
return {
|
|
160
|
+
success: false,
|
|
161
|
+
error: {
|
|
162
|
+
type: "revision_conflict",
|
|
163
|
+
expectedRevision: plan.expectedRevision,
|
|
164
|
+
...actual === null ? {} : { actualRevision: actual.revision }
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
const upsertVersionRecord = async (record, session) => {
|
|
169
|
+
const stored = cloneJson(record);
|
|
170
|
+
await versions.updateOne(
|
|
171
|
+
{ _id: versionDocumentId(record.formId, record.version) },
|
|
172
|
+
{ $set: { formId: record.formId, version: record.version, status: record.status, record: stored } },
|
|
173
|
+
{ upsert: true, ...session === void 0 ? {} : { session } }
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
const applyVersionTransition = async (plan, session) => {
|
|
177
|
+
const inferredNextVersion = Math.max(
|
|
178
|
+
1,
|
|
179
|
+
(plan.draftToCreate?.version ?? 0) + 1,
|
|
180
|
+
(plan.draftToDeleteVersion ?? 0) + 1,
|
|
181
|
+
(plan.publishedRecordToSave?.version ?? 0) + 1
|
|
182
|
+
);
|
|
183
|
+
const stateSet = {
|
|
184
|
+
revision: plan.nextRevision,
|
|
185
|
+
nextVersion: plan.nextVersion ?? inferredNextVersion,
|
|
186
|
+
updatedAt: plan.timestamp
|
|
187
|
+
};
|
|
188
|
+
if (plan.draftToCreate !== void 0) stateSet.draftVersion = plan.draftToCreate.version;
|
|
189
|
+
if (plan.publishedRecordToSave !== void 0) stateSet.publishedVersion = plan.publishedRecordToSave.version;
|
|
190
|
+
const stateUpdate = {
|
|
191
|
+
$set: stateSet,
|
|
192
|
+
...plan.draftToDeleteVersion === void 0 ? {} : { $unset: { draftVersion: "" } }
|
|
193
|
+
};
|
|
194
|
+
const stateResult = await versionStates.updateOne(
|
|
195
|
+
{ _id: plan.formId, revision: plan.expectedRevision },
|
|
196
|
+
stateUpdate,
|
|
197
|
+
{
|
|
198
|
+
upsert: plan.expectedRevision === 0,
|
|
199
|
+
...session === void 0 ? {} : { session }
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
if (stateResult.matchedCount === 0 && stateResult.upsertedCount === 0) {
|
|
203
|
+
const actual = await versionStates.findOne({ _id: plan.formId }, session === void 0 ? {} : { session });
|
|
204
|
+
return {
|
|
205
|
+
success: false,
|
|
206
|
+
error: {
|
|
207
|
+
type: "revision_conflict",
|
|
208
|
+
expectedRevision: plan.expectedRevision,
|
|
209
|
+
...actual === null ? {} : { actualRevision: actual.revision }
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (plan.draftToDeleteVersion !== void 0) {
|
|
214
|
+
await versions.deleteOne(
|
|
215
|
+
{ _id: versionDocumentId(plan.formId, plan.draftToDeleteVersion) },
|
|
216
|
+
session === void 0 ? {} : { session }
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (plan.draftToCreate !== void 0) await upsertVersionRecord(plan.draftToCreate, session);
|
|
220
|
+
if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
|
|
221
|
+
for (const record of plan.archivedRecordsToSave ?? []) await upsertVersionRecord(record, session);
|
|
222
|
+
for (const [eventIndex, event] of plan.events.entries()) {
|
|
223
|
+
await versionEvents.updateOne(
|
|
224
|
+
{ _id: versionEventDocumentId(plan.formId, plan.expectedRevision, eventIndex) },
|
|
225
|
+
{ $set: { formId: plan.formId, fromRevision: plan.expectedRevision, eventIndex, event: cloneJson(event) } },
|
|
226
|
+
{ upsert: true, ...session === void 0 ? {} : { session } }
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return { success: true, value: { success: true } };
|
|
230
|
+
};
|
|
78
231
|
return {
|
|
79
232
|
async saveSchema(schema) {
|
|
80
233
|
(0, import_core.assertValidFormSchema)(schema);
|
|
@@ -125,7 +278,7 @@ function createMongoDbStorage(options) {
|
|
|
125
278
|
...options2.since === void 0 ? {} : { $gte: options2.since },
|
|
126
279
|
...options2.until === void 0 ? {} : { $lte: options2.until }
|
|
127
280
|
};
|
|
128
|
-
const
|
|
281
|
+
const baseFilter = {
|
|
129
282
|
formId,
|
|
130
283
|
...options2.version === void 0 ? {} : { formVersion: options2.version },
|
|
131
284
|
...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
|
|
@@ -137,8 +290,16 @@ function createMongoDbStorage(options) {
|
|
|
137
290
|
]
|
|
138
291
|
}
|
|
139
292
|
};
|
|
293
|
+
const serverFilters = [baseFilter];
|
|
294
|
+
if (options2.filter !== void 0 && typeof options2.filter !== "function") {
|
|
295
|
+
serverFilters.push(submissionFilterToMongo(options2.filter));
|
|
296
|
+
}
|
|
297
|
+
for (const [key, value] of Object.entries(options2.metadataFilters ?? {})) {
|
|
298
|
+
serverFilters.push({ [`submission.metadata.${key}`]: value });
|
|
299
|
+
}
|
|
300
|
+
const filter = serverFilters.length === 1 ? baseFilter : { $and: serverFilters };
|
|
140
301
|
const sorted = submissions.find(filter).sort({ submittedAt: 1, _id: 1 });
|
|
141
|
-
const requiresClientFiltering = options2.filter
|
|
302
|
+
const requiresClientFiltering = typeof options2.filter === "function";
|
|
142
303
|
const documents = requiresClientFiltering ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
|
|
143
304
|
const candidates = documents.map(parseSubmissionDocument).filter((item) => (0, import_core.matchesSubmissionPageFilters)(item, options2));
|
|
144
305
|
const hasMore = candidates.length > pageSize;
|
|
@@ -150,6 +311,60 @@ function createMongoDbStorage(options) {
|
|
|
150
311
|
...hasMore && last !== void 0 ? { nextCursor: (0, import_core.encodeSubmissionCursor)({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
|
|
151
312
|
};
|
|
152
313
|
},
|
|
314
|
+
async listTextAnswerPage(formId, fieldId, options2 = {}) {
|
|
315
|
+
if (fieldId.trim().length === 0) throw new TypeError("fieldId must not be empty.");
|
|
316
|
+
const pageSize = (0, import_core.normalizeSubmissionPageSize)(options2.pageSize);
|
|
317
|
+
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.");
|
|
320
|
+
}
|
|
321
|
+
const submittedAt = options2.since === void 0 && options2.until === void 0 ? void 0 : {
|
|
322
|
+
...options2.since === void 0 ? {} : { $gte: options2.since },
|
|
323
|
+
...options2.until === void 0 ? {} : { $lte: options2.until }
|
|
324
|
+
};
|
|
325
|
+
const baseFilter = {
|
|
326
|
+
formId,
|
|
327
|
+
...options2.version === void 0 ? {} : { formVersion: options2.version },
|
|
328
|
+
...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
|
|
329
|
+
...submittedAt === void 0 ? {} : { submittedAt },
|
|
330
|
+
...cursor === void 0 ? {} : { _id: { $gt: cursor.responseId } },
|
|
331
|
+
[`submission.values.${fieldId}`]: { $type: "string" }
|
|
332
|
+
};
|
|
333
|
+
const serverFilters = [baseFilter];
|
|
334
|
+
if (options2.filter !== void 0 && typeof options2.filter !== "function") {
|
|
335
|
+
serverFilters.push(submissionFilterToMongo(options2.filter));
|
|
336
|
+
}
|
|
337
|
+
for (const [key, value] of Object.entries(options2.metadataFilters ?? {})) {
|
|
338
|
+
serverFilters.push({ [`submission.metadata.${key}`]: value });
|
|
339
|
+
}
|
|
340
|
+
const filter = serverFilters.length === 1 ? baseFilter : { $and: serverFilters };
|
|
341
|
+
const sorted = submissions.find(filter).sort({ _id: 1 });
|
|
342
|
+
const documents = typeof options2.filter === "function" ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
|
|
343
|
+
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 }
|
|
356
|
+
}
|
|
357
|
+
];
|
|
358
|
+
});
|
|
359
|
+
const hasMore = candidates.length > pageSize;
|
|
360
|
+
const items = candidates.slice(0, pageSize);
|
|
361
|
+
const last = items.at(-1);
|
|
362
|
+
return {
|
|
363
|
+
items,
|
|
364
|
+
hasMore,
|
|
365
|
+
...hasMore && last !== void 0 ? { nextCursor: (0, import_core.encodeTextAnswerCursor)({ responseId: last.responseId, fieldId }) } : {}
|
|
366
|
+
};
|
|
367
|
+
},
|
|
153
368
|
async deleteSubmission(submissionId) {
|
|
154
369
|
await submissions.deleteOne({ _id: submissionId });
|
|
155
370
|
},
|
|
@@ -157,17 +372,94 @@ function createMongoDbStorage(options) {
|
|
|
157
372
|
await submissions.deleteMany({ formId });
|
|
158
373
|
},
|
|
159
374
|
async clear() {
|
|
160
|
-
await Promise.all([
|
|
375
|
+
await Promise.all([
|
|
376
|
+
schemas.deleteMany({}),
|
|
377
|
+
submissions.deleteMany({}),
|
|
378
|
+
versions.deleteMany({}),
|
|
379
|
+
versionStates.deleteMany({}),
|
|
380
|
+
versionEvents.deleteMany({})
|
|
381
|
+
]);
|
|
382
|
+
},
|
|
383
|
+
async getVersionState(formId) {
|
|
384
|
+
const document = await versionStates.findOne({ _id: formId });
|
|
385
|
+
if (document === null) return null;
|
|
386
|
+
if (!Number.isSafeInteger(document.revision) || document.revision < 0) {
|
|
387
|
+
throw new Error(`MongoDB version state "${formId}" is invalid.`);
|
|
388
|
+
}
|
|
389
|
+
const nextVersion = document.nextVersion ?? Math.max(1, (document.draftVersion ?? 0) + 1, (document.publishedVersion ?? 0) + 1);
|
|
390
|
+
return {
|
|
391
|
+
formId,
|
|
392
|
+
revision: document.revision,
|
|
393
|
+
nextVersion,
|
|
394
|
+
...document.draftVersion === void 0 ? {} : { draftVersion: document.draftVersion },
|
|
395
|
+
...document.publishedVersion === void 0 ? {} : { publishedVersion: document.publishedVersion }
|
|
396
|
+
};
|
|
397
|
+
},
|
|
398
|
+
async getVersionRecord(formId, version) {
|
|
399
|
+
const document = await versions.findOne({ _id: versionDocumentId(formId, version) });
|
|
400
|
+
return document === null ? null : parseVersionRecordDocument(document);
|
|
401
|
+
},
|
|
402
|
+
async listVersionRecords(formId) {
|
|
403
|
+
return (await versions.find({ formId }).sort({ version: 1 }).toArray()).map(parseVersionRecordDocument);
|
|
404
|
+
},
|
|
405
|
+
async commitVersionTransition(plan) {
|
|
406
|
+
try {
|
|
407
|
+
assertTransitionPlan(plan);
|
|
408
|
+
} catch (cause) {
|
|
409
|
+
return {
|
|
410
|
+
success: false,
|
|
411
|
+
error: { type: "invalid_transition", message: cause instanceof Error ? cause.message : String(cause) }
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
const client = options.db.client;
|
|
415
|
+
if (client === void 0 || typeof client.startSession !== "function") {
|
|
416
|
+
try {
|
|
417
|
+
return await applyVersionTransition(plan);
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (isCasConflict(error)) {
|
|
420
|
+
return storageRevisionConflict(plan);
|
|
421
|
+
}
|
|
422
|
+
return { success: false, error: { type: "storage_error", cause: error } };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const session = client.startSession();
|
|
426
|
+
try {
|
|
427
|
+
let result = {
|
|
428
|
+
success: false,
|
|
429
|
+
error: { type: "revision_conflict", expectedRevision: plan.expectedRevision }
|
|
430
|
+
};
|
|
431
|
+
await session.withTransaction(async () => {
|
|
432
|
+
result = await applyVersionTransition(plan, session);
|
|
433
|
+
});
|
|
434
|
+
return result;
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (isCasConflict(error)) {
|
|
437
|
+
return storageRevisionConflict(plan);
|
|
438
|
+
}
|
|
439
|
+
return { success: false, error: { type: "storage_error", cause: error } };
|
|
440
|
+
} finally {
|
|
441
|
+
await session.endSession();
|
|
442
|
+
}
|
|
161
443
|
},
|
|
162
444
|
async createIndexes() {
|
|
163
|
-
await
|
|
164
|
-
|
|
165
|
-
|
|
445
|
+
await Promise.all([
|
|
446
|
+
submissions.createIndexes([
|
|
447
|
+
{ key: { formId: 1, submittedAt: 1, _id: 1 }, name: "form_responses_form_submitted_at_id" },
|
|
448
|
+
{ key: { "submission.locale": 1 }, name: "form_responses_locale" }
|
|
449
|
+
]),
|
|
450
|
+
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" }
|
|
453
|
+
]),
|
|
454
|
+
versionEvents.createIndexes([
|
|
455
|
+
{ key: { formId: 1, fromRevision: 1, eventIndex: 1 }, name: "form_version_events_revision" }
|
|
456
|
+
])
|
|
166
457
|
]);
|
|
167
458
|
}
|
|
168
459
|
};
|
|
169
460
|
}
|
|
170
461
|
// Annotate the CommonJS export names for ESM import in node:
|
|
171
462
|
0 && (module.exports = {
|
|
172
|
-
createMongoDbStorage
|
|
463
|
+
createMongoDbStorage,
|
|
464
|
+
submissionFilterToMongo
|
|
173
465
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { PagedSubmissionStorageAdapter } 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;
|
|
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
|
}
|
|
14
|
+
declare function submissionFilterToMongo(filter: SubmissionFilter): Document;
|
|
12
15
|
declare function createMongoDbStorage(options: MongoDbStorageOptions): MongoDbStorageAdapter;
|
|
13
16
|
|
|
14
|
-
export { type MongoDbStorageAdapter, type MongoDbStorageOptions, createMongoDbStorage };
|
|
17
|
+
export { type MongoDbStorageAdapter, type MongoDbStorageOptions, createMongoDbStorage, submissionFilterToMongo };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { PagedSubmissionStorageAdapter } 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;
|
|
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
|
}
|
|
14
|
+
declare function submissionFilterToMongo(filter: SubmissionFilter): Document;
|
|
12
15
|
declare function createMongoDbStorage(options: MongoDbStorageOptions): MongoDbStorageAdapter;
|
|
13
16
|
|
|
14
|
-
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";
|
|
@@ -24,6 +26,70 @@ function parseSubmission(value, location) {
|
|
|
24
26
|
function schemaDocumentId(formId, formVersion) {
|
|
25
27
|
return `schema:${encodeURIComponent(formId)}:${formVersion}`;
|
|
26
28
|
}
|
|
29
|
+
function versionDocumentId(formId, formVersion) {
|
|
30
|
+
return `version:${encodeURIComponent(formId)}:${formVersion}`;
|
|
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
|
+
}
|
|
61
|
+
function assertVersionRecord(record, formId, expectedStatus) {
|
|
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) {
|
|
63
|
+
throw new TypeError("Version transition record is inconsistent.");
|
|
64
|
+
}
|
|
65
|
+
assertValidFormSchema(record.schema);
|
|
66
|
+
}
|
|
67
|
+
function assertTransitionPlan(plan) {
|
|
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))) {
|
|
69
|
+
throw new TypeError("Version transition plan is invalid.");
|
|
70
|
+
}
|
|
71
|
+
if (!Array.isArray(plan.events)) throw new TypeError("Version transition plan events are required.");
|
|
72
|
+
if (plan.draftToCreate !== void 0) assertVersionRecord(plan.draftToCreate, plan.formId, "draft");
|
|
73
|
+
if (plan.publishedRecordToSave !== void 0) {
|
|
74
|
+
assertVersionRecord(plan.publishedRecordToSave, plan.formId, "published");
|
|
75
|
+
}
|
|
76
|
+
for (const record of plan.archivedRecordsToSave ?? []) assertVersionRecord(record, plan.formId, "archived");
|
|
77
|
+
if (plan.draftToDeleteVersion !== void 0 && (!Number.isSafeInteger(plan.draftToDeleteVersion) || plan.draftToDeleteVersion < 1)) {
|
|
78
|
+
throw new TypeError("draftToDeleteVersion must be a positive safe integer.");
|
|
79
|
+
}
|
|
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
|
+
}
|
|
88
|
+
function isCasConflict(error) {
|
|
89
|
+
if (!isRecord(error)) return false;
|
|
90
|
+
if (error.code === 11e3 || error.code === 112 || error.code === 251) return true;
|
|
91
|
+
return typeof error.hasErrorLabel === "function" && error.hasErrorLabel("TransientTransactionError") === true;
|
|
92
|
+
}
|
|
27
93
|
function parseSchemaDocument(document) {
|
|
28
94
|
try {
|
|
29
95
|
assertValidFormSchema(document.schema);
|
|
@@ -55,8 +121,96 @@ function createMongoDbStorage(options) {
|
|
|
55
121
|
"form_responses",
|
|
56
122
|
"responsesCollectionName"
|
|
57
123
|
);
|
|
124
|
+
const versionsCollectionName = collectionName(
|
|
125
|
+
options.versionsCollectionName,
|
|
126
|
+
"form_versions",
|
|
127
|
+
"versionsCollectionName"
|
|
128
|
+
);
|
|
129
|
+
const versionStatesCollectionName = collectionName(
|
|
130
|
+
options.versionStatesCollectionName,
|
|
131
|
+
"form_version_states",
|
|
132
|
+
"versionStatesCollectionName"
|
|
133
|
+
);
|
|
134
|
+
const versionEventsCollectionName = "form_version_events";
|
|
58
135
|
const schemas = options.db.collection(schemasCollectionName);
|
|
59
136
|
const submissions = options.db.collection(responsesCollectionName);
|
|
137
|
+
const versions = options.db.collection(versionsCollectionName);
|
|
138
|
+
const versionStates = options.db.collection(versionStatesCollectionName);
|
|
139
|
+
const versionEvents = options.db.collection(versionEventsCollectionName);
|
|
140
|
+
const storageRevisionConflict = async (plan) => {
|
|
141
|
+
const actual = await versionStates.findOne({ _id: plan.formId });
|
|
142
|
+
return {
|
|
143
|
+
success: false,
|
|
144
|
+
error: {
|
|
145
|
+
type: "revision_conflict",
|
|
146
|
+
expectedRevision: plan.expectedRevision,
|
|
147
|
+
...actual === null ? {} : { actualRevision: actual.revision }
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
const upsertVersionRecord = async (record, session) => {
|
|
152
|
+
const stored = cloneJson(record);
|
|
153
|
+
await versions.updateOne(
|
|
154
|
+
{ _id: versionDocumentId(record.formId, record.version) },
|
|
155
|
+
{ $set: { formId: record.formId, version: record.version, status: record.status, record: stored } },
|
|
156
|
+
{ upsert: true, ...session === void 0 ? {} : { session } }
|
|
157
|
+
);
|
|
158
|
+
};
|
|
159
|
+
const applyVersionTransition = async (plan, session) => {
|
|
160
|
+
const inferredNextVersion = Math.max(
|
|
161
|
+
1,
|
|
162
|
+
(plan.draftToCreate?.version ?? 0) + 1,
|
|
163
|
+
(plan.draftToDeleteVersion ?? 0) + 1,
|
|
164
|
+
(plan.publishedRecordToSave?.version ?? 0) + 1
|
|
165
|
+
);
|
|
166
|
+
const stateSet = {
|
|
167
|
+
revision: plan.nextRevision,
|
|
168
|
+
nextVersion: plan.nextVersion ?? inferredNextVersion,
|
|
169
|
+
updatedAt: plan.timestamp
|
|
170
|
+
};
|
|
171
|
+
if (plan.draftToCreate !== void 0) stateSet.draftVersion = plan.draftToCreate.version;
|
|
172
|
+
if (plan.publishedRecordToSave !== void 0) stateSet.publishedVersion = plan.publishedRecordToSave.version;
|
|
173
|
+
const stateUpdate = {
|
|
174
|
+
$set: stateSet,
|
|
175
|
+
...plan.draftToDeleteVersion === void 0 ? {} : { $unset: { draftVersion: "" } }
|
|
176
|
+
};
|
|
177
|
+
const stateResult = await versionStates.updateOne(
|
|
178
|
+
{ _id: plan.formId, revision: plan.expectedRevision },
|
|
179
|
+
stateUpdate,
|
|
180
|
+
{
|
|
181
|
+
upsert: plan.expectedRevision === 0,
|
|
182
|
+
...session === void 0 ? {} : { session }
|
|
183
|
+
}
|
|
184
|
+
);
|
|
185
|
+
if (stateResult.matchedCount === 0 && stateResult.upsertedCount === 0) {
|
|
186
|
+
const actual = await versionStates.findOne({ _id: plan.formId }, session === void 0 ? {} : { session });
|
|
187
|
+
return {
|
|
188
|
+
success: false,
|
|
189
|
+
error: {
|
|
190
|
+
type: "revision_conflict",
|
|
191
|
+
expectedRevision: plan.expectedRevision,
|
|
192
|
+
...actual === null ? {} : { actualRevision: actual.revision }
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (plan.draftToDeleteVersion !== void 0) {
|
|
197
|
+
await versions.deleteOne(
|
|
198
|
+
{ _id: versionDocumentId(plan.formId, plan.draftToDeleteVersion) },
|
|
199
|
+
session === void 0 ? {} : { session }
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (plan.draftToCreate !== void 0) await upsertVersionRecord(plan.draftToCreate, session);
|
|
203
|
+
if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
|
|
204
|
+
for (const record of plan.archivedRecordsToSave ?? []) await upsertVersionRecord(record, session);
|
|
205
|
+
for (const [eventIndex, event] of plan.events.entries()) {
|
|
206
|
+
await versionEvents.updateOne(
|
|
207
|
+
{ _id: versionEventDocumentId(plan.formId, plan.expectedRevision, eventIndex) },
|
|
208
|
+
{ $set: { formId: plan.formId, fromRevision: plan.expectedRevision, eventIndex, event: cloneJson(event) } },
|
|
209
|
+
{ upsert: true, ...session === void 0 ? {} : { session } }
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return { success: true, value: { success: true } };
|
|
213
|
+
};
|
|
60
214
|
return {
|
|
61
215
|
async saveSchema(schema) {
|
|
62
216
|
assertValidFormSchema(schema);
|
|
@@ -107,7 +261,7 @@ function createMongoDbStorage(options) {
|
|
|
107
261
|
...options2.since === void 0 ? {} : { $gte: options2.since },
|
|
108
262
|
...options2.until === void 0 ? {} : { $lte: options2.until }
|
|
109
263
|
};
|
|
110
|
-
const
|
|
264
|
+
const baseFilter = {
|
|
111
265
|
formId,
|
|
112
266
|
...options2.version === void 0 ? {} : { formVersion: options2.version },
|
|
113
267
|
...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
|
|
@@ -119,8 +273,16 @@ function createMongoDbStorage(options) {
|
|
|
119
273
|
]
|
|
120
274
|
}
|
|
121
275
|
};
|
|
276
|
+
const serverFilters = [baseFilter];
|
|
277
|
+
if (options2.filter !== void 0 && typeof options2.filter !== "function") {
|
|
278
|
+
serverFilters.push(submissionFilterToMongo(options2.filter));
|
|
279
|
+
}
|
|
280
|
+
for (const [key, value] of Object.entries(options2.metadataFilters ?? {})) {
|
|
281
|
+
serverFilters.push({ [`submission.metadata.${key}`]: value });
|
|
282
|
+
}
|
|
283
|
+
const filter = serverFilters.length === 1 ? baseFilter : { $and: serverFilters };
|
|
122
284
|
const sorted = submissions.find(filter).sort({ submittedAt: 1, _id: 1 });
|
|
123
|
-
const requiresClientFiltering = options2.filter
|
|
285
|
+
const requiresClientFiltering = typeof options2.filter === "function";
|
|
124
286
|
const documents = requiresClientFiltering ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
|
|
125
287
|
const candidates = documents.map(parseSubmissionDocument).filter((item) => matchesSubmissionPageFilters(item, options2));
|
|
126
288
|
const hasMore = candidates.length > pageSize;
|
|
@@ -132,6 +294,60 @@ function createMongoDbStorage(options) {
|
|
|
132
294
|
...hasMore && last !== void 0 ? { nextCursor: encodeSubmissionCursor({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
|
|
133
295
|
};
|
|
134
296
|
},
|
|
297
|
+
async listTextAnswerPage(formId, fieldId, options2 = {}) {
|
|
298
|
+
if (fieldId.trim().length === 0) throw new TypeError("fieldId must not be empty.");
|
|
299
|
+
const pageSize = normalizeSubmissionPageSize(options2.pageSize);
|
|
300
|
+
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.");
|
|
303
|
+
}
|
|
304
|
+
const submittedAt = options2.since === void 0 && options2.until === void 0 ? void 0 : {
|
|
305
|
+
...options2.since === void 0 ? {} : { $gte: options2.since },
|
|
306
|
+
...options2.until === void 0 ? {} : { $lte: options2.until }
|
|
307
|
+
};
|
|
308
|
+
const baseFilter = {
|
|
309
|
+
formId,
|
|
310
|
+
...options2.version === void 0 ? {} : { formVersion: options2.version },
|
|
311
|
+
...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
|
|
312
|
+
...submittedAt === void 0 ? {} : { submittedAt },
|
|
313
|
+
...cursor === void 0 ? {} : { _id: { $gt: cursor.responseId } },
|
|
314
|
+
[`submission.values.${fieldId}`]: { $type: "string" }
|
|
315
|
+
};
|
|
316
|
+
const serverFilters = [baseFilter];
|
|
317
|
+
if (options2.filter !== void 0 && typeof options2.filter !== "function") {
|
|
318
|
+
serverFilters.push(submissionFilterToMongo(options2.filter));
|
|
319
|
+
}
|
|
320
|
+
for (const [key, value] of Object.entries(options2.metadataFilters ?? {})) {
|
|
321
|
+
serverFilters.push({ [`submission.metadata.${key}`]: value });
|
|
322
|
+
}
|
|
323
|
+
const filter = serverFilters.length === 1 ? baseFilter : { $and: serverFilters };
|
|
324
|
+
const sorted = submissions.find(filter).sort({ _id: 1 });
|
|
325
|
+
const documents = typeof options2.filter === "function" ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
|
|
326
|
+
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 }
|
|
339
|
+
}
|
|
340
|
+
];
|
|
341
|
+
});
|
|
342
|
+
const hasMore = candidates.length > pageSize;
|
|
343
|
+
const items = candidates.slice(0, pageSize);
|
|
344
|
+
const last = items.at(-1);
|
|
345
|
+
return {
|
|
346
|
+
items,
|
|
347
|
+
hasMore,
|
|
348
|
+
...hasMore && last !== void 0 ? { nextCursor: encodeTextAnswerCursor({ responseId: last.responseId, fieldId }) } : {}
|
|
349
|
+
};
|
|
350
|
+
},
|
|
135
351
|
async deleteSubmission(submissionId) {
|
|
136
352
|
await submissions.deleteOne({ _id: submissionId });
|
|
137
353
|
},
|
|
@@ -139,16 +355,93 @@ function createMongoDbStorage(options) {
|
|
|
139
355
|
await submissions.deleteMany({ formId });
|
|
140
356
|
},
|
|
141
357
|
async clear() {
|
|
142
|
-
await Promise.all([
|
|
358
|
+
await Promise.all([
|
|
359
|
+
schemas.deleteMany({}),
|
|
360
|
+
submissions.deleteMany({}),
|
|
361
|
+
versions.deleteMany({}),
|
|
362
|
+
versionStates.deleteMany({}),
|
|
363
|
+
versionEvents.deleteMany({})
|
|
364
|
+
]);
|
|
365
|
+
},
|
|
366
|
+
async getVersionState(formId) {
|
|
367
|
+
const document = await versionStates.findOne({ _id: formId });
|
|
368
|
+
if (document === null) return null;
|
|
369
|
+
if (!Number.isSafeInteger(document.revision) || document.revision < 0) {
|
|
370
|
+
throw new Error(`MongoDB version state "${formId}" is invalid.`);
|
|
371
|
+
}
|
|
372
|
+
const nextVersion = document.nextVersion ?? Math.max(1, (document.draftVersion ?? 0) + 1, (document.publishedVersion ?? 0) + 1);
|
|
373
|
+
return {
|
|
374
|
+
formId,
|
|
375
|
+
revision: document.revision,
|
|
376
|
+
nextVersion,
|
|
377
|
+
...document.draftVersion === void 0 ? {} : { draftVersion: document.draftVersion },
|
|
378
|
+
...document.publishedVersion === void 0 ? {} : { publishedVersion: document.publishedVersion }
|
|
379
|
+
};
|
|
380
|
+
},
|
|
381
|
+
async getVersionRecord(formId, version) {
|
|
382
|
+
const document = await versions.findOne({ _id: versionDocumentId(formId, version) });
|
|
383
|
+
return document === null ? null : parseVersionRecordDocument(document);
|
|
384
|
+
},
|
|
385
|
+
async listVersionRecords(formId) {
|
|
386
|
+
return (await versions.find({ formId }).sort({ version: 1 }).toArray()).map(parseVersionRecordDocument);
|
|
387
|
+
},
|
|
388
|
+
async commitVersionTransition(plan) {
|
|
389
|
+
try {
|
|
390
|
+
assertTransitionPlan(plan);
|
|
391
|
+
} catch (cause) {
|
|
392
|
+
return {
|
|
393
|
+
success: false,
|
|
394
|
+
error: { type: "invalid_transition", message: cause instanceof Error ? cause.message : String(cause) }
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
const client = options.db.client;
|
|
398
|
+
if (client === void 0 || typeof client.startSession !== "function") {
|
|
399
|
+
try {
|
|
400
|
+
return await applyVersionTransition(plan);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
if (isCasConflict(error)) {
|
|
403
|
+
return storageRevisionConflict(plan);
|
|
404
|
+
}
|
|
405
|
+
return { success: false, error: { type: "storage_error", cause: error } };
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const session = client.startSession();
|
|
409
|
+
try {
|
|
410
|
+
let result = {
|
|
411
|
+
success: false,
|
|
412
|
+
error: { type: "revision_conflict", expectedRevision: plan.expectedRevision }
|
|
413
|
+
};
|
|
414
|
+
await session.withTransaction(async () => {
|
|
415
|
+
result = await applyVersionTransition(plan, session);
|
|
416
|
+
});
|
|
417
|
+
return result;
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (isCasConflict(error)) {
|
|
420
|
+
return storageRevisionConflict(plan);
|
|
421
|
+
}
|
|
422
|
+
return { success: false, error: { type: "storage_error", cause: error } };
|
|
423
|
+
} finally {
|
|
424
|
+
await session.endSession();
|
|
425
|
+
}
|
|
143
426
|
},
|
|
144
427
|
async createIndexes() {
|
|
145
|
-
await
|
|
146
|
-
|
|
147
|
-
|
|
428
|
+
await Promise.all([
|
|
429
|
+
submissions.createIndexes([
|
|
430
|
+
{ key: { formId: 1, submittedAt: 1, _id: 1 }, name: "form_responses_form_submitted_at_id" },
|
|
431
|
+
{ key: { "submission.locale": 1 }, name: "form_responses_locale" }
|
|
432
|
+
]),
|
|
433
|
+
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" }
|
|
436
|
+
]),
|
|
437
|
+
versionEvents.createIndexes([
|
|
438
|
+
{ key: { formId: 1, fromRevision: 1, eventIndex: 1 }, name: "form_version_events_revision" }
|
|
439
|
+
])
|
|
148
440
|
]);
|
|
149
441
|
}
|
|
150
442
|
};
|
|
151
443
|
}
|
|
152
444
|
export {
|
|
153
|
-
createMongoDbStorage
|
|
445
|
+
createMongoDbStorage,
|
|
446
|
+
submissionFilterToMongo
|
|
154
447
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@form-engine-ts/storage-mongodb",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.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.7.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"mongodb": "^6.0.0"
|