@form-engine-ts/storage-mongodb 2.6.0 → 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 +5 -2
- package/dist/index.cjs +190 -14
- package/dist/index.d.cts +4 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +190 -13
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -29,9 +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.
|
|
33
35
|
|
|
34
36
|
Version records are stored in `form_versions`, with unique `(formId, version)` and `(formId, status)` indexes. Transition
|
|
35
37
|
state lives in `form_version_states`. `commitVersionTransition(plan)` compares `expectedRevision` atomically and returns
|
|
36
38
|
`revision_conflict` to losing concurrent publishers; on a real MongoDB client, the state and record changes are committed
|
|
37
|
-
in one transaction.
|
|
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");
|
|
@@ -45,6 +46,35 @@ function schemaDocumentId(formId, formVersion) {
|
|
|
45
46
|
function versionDocumentId(formId, formVersion) {
|
|
46
47
|
return `version:${encodeURIComponent(formId)}:${formVersion}`;
|
|
47
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
|
+
}
|
|
48
78
|
function assertVersionRecord(record, formId, expectedStatus) {
|
|
49
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) {
|
|
50
80
|
throw new TypeError("Version transition record is inconsistent.");
|
|
@@ -55,6 +85,7 @@ function assertTransitionPlan(plan) {
|
|
|
55
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))) {
|
|
56
86
|
throw new TypeError("Version transition plan is invalid.");
|
|
57
87
|
}
|
|
88
|
+
if (!Array.isArray(plan.events)) throw new TypeError("Version transition plan events are required.");
|
|
58
89
|
if (plan.draftToCreate !== void 0) assertVersionRecord(plan.draftToCreate, plan.formId, "draft");
|
|
59
90
|
if (plan.publishedRecordToSave !== void 0) {
|
|
60
91
|
assertVersionRecord(plan.publishedRecordToSave, plan.formId, "published");
|
|
@@ -64,6 +95,13 @@ function assertTransitionPlan(plan) {
|
|
|
64
95
|
throw new TypeError("draftToDeleteVersion must be a positive safe integer.");
|
|
65
96
|
}
|
|
66
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
|
+
}
|
|
67
105
|
function isCasConflict(error) {
|
|
68
106
|
if (!isRecord(error)) return false;
|
|
69
107
|
if (error.code === 11e3 || error.code === 112 || error.code === 251) return true;
|
|
@@ -110,10 +148,23 @@ function createMongoDbStorage(options) {
|
|
|
110
148
|
"form_version_states",
|
|
111
149
|
"versionStatesCollectionName"
|
|
112
150
|
);
|
|
151
|
+
const versionEventsCollectionName = "form_version_events";
|
|
113
152
|
const schemas = options.db.collection(schemasCollectionName);
|
|
114
153
|
const submissions = options.db.collection(responsesCollectionName);
|
|
115
154
|
const versions = options.db.collection(versionsCollectionName);
|
|
116
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
|
+
};
|
|
117
168
|
const upsertVersionRecord = async (record, session) => {
|
|
118
169
|
const stored = cloneJson(record);
|
|
119
170
|
await versions.updateOne(
|
|
@@ -123,7 +174,17 @@ function createMongoDbStorage(options) {
|
|
|
123
174
|
);
|
|
124
175
|
};
|
|
125
176
|
const applyVersionTransition = async (plan, session) => {
|
|
126
|
-
const
|
|
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
|
+
};
|
|
127
188
|
if (plan.draftToCreate !== void 0) stateSet.draftVersion = plan.draftToCreate.version;
|
|
128
189
|
if (plan.publishedRecordToSave !== void 0) stateSet.publishedVersion = plan.publishedRecordToSave.version;
|
|
129
190
|
const stateUpdate = {
|
|
@@ -139,7 +200,15 @@ function createMongoDbStorage(options) {
|
|
|
139
200
|
}
|
|
140
201
|
);
|
|
141
202
|
if (stateResult.matchedCount === 0 && stateResult.upsertedCount === 0) {
|
|
142
|
-
|
|
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
|
+
};
|
|
143
212
|
}
|
|
144
213
|
if (plan.draftToDeleteVersion !== void 0) {
|
|
145
214
|
await versions.deleteOne(
|
|
@@ -150,7 +219,14 @@ function createMongoDbStorage(options) {
|
|
|
150
219
|
if (plan.draftToCreate !== void 0) await upsertVersionRecord(plan.draftToCreate, session);
|
|
151
220
|
if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
|
|
152
221
|
for (const record of plan.archivedRecordsToSave ?? []) await upsertVersionRecord(record, session);
|
|
153
|
-
|
|
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 } };
|
|
154
230
|
};
|
|
155
231
|
return {
|
|
156
232
|
async saveSchema(schema) {
|
|
@@ -202,7 +278,7 @@ function createMongoDbStorage(options) {
|
|
|
202
278
|
...options2.since === void 0 ? {} : { $gte: options2.since },
|
|
203
279
|
...options2.until === void 0 ? {} : { $lte: options2.until }
|
|
204
280
|
};
|
|
205
|
-
const
|
|
281
|
+
const baseFilter = {
|
|
206
282
|
formId,
|
|
207
283
|
...options2.version === void 0 ? {} : { formVersion: options2.version },
|
|
208
284
|
...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
|
|
@@ -214,8 +290,16 @@ function createMongoDbStorage(options) {
|
|
|
214
290
|
]
|
|
215
291
|
}
|
|
216
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 };
|
|
217
301
|
const sorted = submissions.find(filter).sort({ submittedAt: 1, _id: 1 });
|
|
218
|
-
const requiresClientFiltering = options2.filter
|
|
302
|
+
const requiresClientFiltering = typeof options2.filter === "function";
|
|
219
303
|
const documents = requiresClientFiltering ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
|
|
220
304
|
const candidates = documents.map(parseSubmissionDocument).filter((item) => (0, import_core.matchesSubmissionPageFilters)(item, options2));
|
|
221
305
|
const hasMore = candidates.length > pageSize;
|
|
@@ -227,6 +311,60 @@ function createMongoDbStorage(options) {
|
|
|
227
311
|
...hasMore && last !== void 0 ? { nextCursor: (0, import_core.encodeSubmissionCursor)({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
|
|
228
312
|
};
|
|
229
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
|
+
},
|
|
230
368
|
async deleteSubmission(submissionId) {
|
|
231
369
|
await submissions.deleteOne({ _id: submissionId });
|
|
232
370
|
},
|
|
@@ -238,33 +376,67 @@ function createMongoDbStorage(options) {
|
|
|
238
376
|
schemas.deleteMany({}),
|
|
239
377
|
submissions.deleteMany({}),
|
|
240
378
|
versions.deleteMany({}),
|
|
241
|
-
versionStates.deleteMany({})
|
|
379
|
+
versionStates.deleteMany({}),
|
|
380
|
+
versionEvents.deleteMany({})
|
|
242
381
|
]);
|
|
243
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
|
+
},
|
|
244
405
|
async commitVersionTransition(plan) {
|
|
245
|
-
|
|
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
|
+
}
|
|
246
414
|
const client = options.db.client;
|
|
247
415
|
if (client === void 0 || typeof client.startSession !== "function") {
|
|
248
416
|
try {
|
|
249
417
|
return await applyVersionTransition(plan);
|
|
250
418
|
} catch (error) {
|
|
251
|
-
if (isCasConflict(error))
|
|
252
|
-
|
|
419
|
+
if (isCasConflict(error)) {
|
|
420
|
+
return storageRevisionConflict(plan);
|
|
421
|
+
}
|
|
422
|
+
return { success: false, error: { type: "storage_error", cause: error } };
|
|
253
423
|
}
|
|
254
424
|
}
|
|
255
425
|
const session = client.startSession();
|
|
256
426
|
try {
|
|
257
427
|
let result = {
|
|
258
428
|
success: false,
|
|
259
|
-
error: "revision_conflict"
|
|
429
|
+
error: { type: "revision_conflict", expectedRevision: plan.expectedRevision }
|
|
260
430
|
};
|
|
261
431
|
await session.withTransaction(async () => {
|
|
262
432
|
result = await applyVersionTransition(plan, session);
|
|
263
433
|
});
|
|
264
434
|
return result;
|
|
265
435
|
} catch (error) {
|
|
266
|
-
if (isCasConflict(error))
|
|
267
|
-
|
|
436
|
+
if (isCasConflict(error)) {
|
|
437
|
+
return storageRevisionConflict(plan);
|
|
438
|
+
}
|
|
439
|
+
return { success: false, error: { type: "storage_error", cause: error } };
|
|
268
440
|
} finally {
|
|
269
441
|
await session.endSession();
|
|
270
442
|
}
|
|
@@ -278,6 +450,9 @@ function createMongoDbStorage(options) {
|
|
|
278
450
|
versions.createIndexes([
|
|
279
451
|
{ key: { formId: 1, version: 1 }, name: "form_versions_form_version", unique: true },
|
|
280
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" }
|
|
281
456
|
])
|
|
282
457
|
]);
|
|
283
458
|
}
|
|
@@ -285,5 +460,6 @@ function createMongoDbStorage(options) {
|
|
|
285
460
|
}
|
|
286
461
|
// Annotate the CommonJS export names for ESM import in node:
|
|
287
462
|
0 && (module.exports = {
|
|
288
|
-
createMongoDbStorage
|
|
463
|
+
createMongoDbStorage,
|
|
464
|
+
submissionFilterToMongo
|
|
289
465
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { PagedSubmissionStorageAdapter, VersionedFormStorageAdapter } 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;
|
|
@@ -11,6 +11,7 @@ interface MongoDbStorageOptions {
|
|
|
11
11
|
interface MongoDbStorageAdapter extends PagedSubmissionStorageAdapter, VersionedFormStorageAdapter {
|
|
12
12
|
createIndexes(): Promise<void>;
|
|
13
13
|
}
|
|
14
|
+
declare function submissionFilterToMongo(filter: SubmissionFilter): Document;
|
|
14
15
|
declare function createMongoDbStorage(options: MongoDbStorageOptions): MongoDbStorageAdapter;
|
|
15
16
|
|
|
16
|
-
export { type MongoDbStorageAdapter, type MongoDbStorageOptions, createMongoDbStorage };
|
|
17
|
+
export { type MongoDbStorageAdapter, type MongoDbStorageOptions, createMongoDbStorage, submissionFilterToMongo };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { PagedSubmissionStorageAdapter, VersionedFormStorageAdapter } 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;
|
|
@@ -11,6 +11,7 @@ interface MongoDbStorageOptions {
|
|
|
11
11
|
interface MongoDbStorageAdapter extends PagedSubmissionStorageAdapter, VersionedFormStorageAdapter {
|
|
12
12
|
createIndexes(): Promise<void>;
|
|
13
13
|
}
|
|
14
|
+
declare function submissionFilterToMongo(filter: SubmissionFilter): Document;
|
|
14
15
|
declare function createMongoDbStorage(options: MongoDbStorageOptions): MongoDbStorageAdapter;
|
|
15
16
|
|
|
16
|
-
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";
|
|
@@ -27,6 +29,35 @@ function schemaDocumentId(formId, formVersion) {
|
|
|
27
29
|
function versionDocumentId(formId, formVersion) {
|
|
28
30
|
return `version:${encodeURIComponent(formId)}:${formVersion}`;
|
|
29
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
|
+
}
|
|
30
61
|
function assertVersionRecord(record, formId, expectedStatus) {
|
|
31
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) {
|
|
32
63
|
throw new TypeError("Version transition record is inconsistent.");
|
|
@@ -37,6 +68,7 @@ function assertTransitionPlan(plan) {
|
|
|
37
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))) {
|
|
38
69
|
throw new TypeError("Version transition plan is invalid.");
|
|
39
70
|
}
|
|
71
|
+
if (!Array.isArray(plan.events)) throw new TypeError("Version transition plan events are required.");
|
|
40
72
|
if (plan.draftToCreate !== void 0) assertVersionRecord(plan.draftToCreate, plan.formId, "draft");
|
|
41
73
|
if (plan.publishedRecordToSave !== void 0) {
|
|
42
74
|
assertVersionRecord(plan.publishedRecordToSave, plan.formId, "published");
|
|
@@ -46,6 +78,13 @@ function assertTransitionPlan(plan) {
|
|
|
46
78
|
throw new TypeError("draftToDeleteVersion must be a positive safe integer.");
|
|
47
79
|
}
|
|
48
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
|
+
}
|
|
49
88
|
function isCasConflict(error) {
|
|
50
89
|
if (!isRecord(error)) return false;
|
|
51
90
|
if (error.code === 11e3 || error.code === 112 || error.code === 251) return true;
|
|
@@ -92,10 +131,23 @@ function createMongoDbStorage(options) {
|
|
|
92
131
|
"form_version_states",
|
|
93
132
|
"versionStatesCollectionName"
|
|
94
133
|
);
|
|
134
|
+
const versionEventsCollectionName = "form_version_events";
|
|
95
135
|
const schemas = options.db.collection(schemasCollectionName);
|
|
96
136
|
const submissions = options.db.collection(responsesCollectionName);
|
|
97
137
|
const versions = options.db.collection(versionsCollectionName);
|
|
98
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
|
+
};
|
|
99
151
|
const upsertVersionRecord = async (record, session) => {
|
|
100
152
|
const stored = cloneJson(record);
|
|
101
153
|
await versions.updateOne(
|
|
@@ -105,7 +157,17 @@ function createMongoDbStorage(options) {
|
|
|
105
157
|
);
|
|
106
158
|
};
|
|
107
159
|
const applyVersionTransition = async (plan, session) => {
|
|
108
|
-
const
|
|
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
|
+
};
|
|
109
171
|
if (plan.draftToCreate !== void 0) stateSet.draftVersion = plan.draftToCreate.version;
|
|
110
172
|
if (plan.publishedRecordToSave !== void 0) stateSet.publishedVersion = plan.publishedRecordToSave.version;
|
|
111
173
|
const stateUpdate = {
|
|
@@ -121,7 +183,15 @@ function createMongoDbStorage(options) {
|
|
|
121
183
|
}
|
|
122
184
|
);
|
|
123
185
|
if (stateResult.matchedCount === 0 && stateResult.upsertedCount === 0) {
|
|
124
|
-
|
|
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
|
+
};
|
|
125
195
|
}
|
|
126
196
|
if (plan.draftToDeleteVersion !== void 0) {
|
|
127
197
|
await versions.deleteOne(
|
|
@@ -132,7 +202,14 @@ function createMongoDbStorage(options) {
|
|
|
132
202
|
if (plan.draftToCreate !== void 0) await upsertVersionRecord(plan.draftToCreate, session);
|
|
133
203
|
if (plan.publishedRecordToSave !== void 0) await upsertVersionRecord(plan.publishedRecordToSave, session);
|
|
134
204
|
for (const record of plan.archivedRecordsToSave ?? []) await upsertVersionRecord(record, session);
|
|
135
|
-
|
|
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 } };
|
|
136
213
|
};
|
|
137
214
|
return {
|
|
138
215
|
async saveSchema(schema) {
|
|
@@ -184,7 +261,7 @@ function createMongoDbStorage(options) {
|
|
|
184
261
|
...options2.since === void 0 ? {} : { $gte: options2.since },
|
|
185
262
|
...options2.until === void 0 ? {} : { $lte: options2.until }
|
|
186
263
|
};
|
|
187
|
-
const
|
|
264
|
+
const baseFilter = {
|
|
188
265
|
formId,
|
|
189
266
|
...options2.version === void 0 ? {} : { formVersion: options2.version },
|
|
190
267
|
...options2.locale === void 0 ? {} : { "submission.locale": options2.locale },
|
|
@@ -196,8 +273,16 @@ function createMongoDbStorage(options) {
|
|
|
196
273
|
]
|
|
197
274
|
}
|
|
198
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 };
|
|
199
284
|
const sorted = submissions.find(filter).sort({ submittedAt: 1, _id: 1 });
|
|
200
|
-
const requiresClientFiltering = options2.filter
|
|
285
|
+
const requiresClientFiltering = typeof options2.filter === "function";
|
|
201
286
|
const documents = requiresClientFiltering ? await sorted.toArray() : await sorted.limit(pageSize + 1).toArray();
|
|
202
287
|
const candidates = documents.map(parseSubmissionDocument).filter((item) => matchesSubmissionPageFilters(item, options2));
|
|
203
288
|
const hasMore = candidates.length > pageSize;
|
|
@@ -209,6 +294,60 @@ function createMongoDbStorage(options) {
|
|
|
209
294
|
...hasMore && last !== void 0 ? { nextCursor: encodeSubmissionCursor({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
|
|
210
295
|
};
|
|
211
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
|
+
},
|
|
212
351
|
async deleteSubmission(submissionId) {
|
|
213
352
|
await submissions.deleteOne({ _id: submissionId });
|
|
214
353
|
},
|
|
@@ -220,33 +359,67 @@ function createMongoDbStorage(options) {
|
|
|
220
359
|
schemas.deleteMany({}),
|
|
221
360
|
submissions.deleteMany({}),
|
|
222
361
|
versions.deleteMany({}),
|
|
223
|
-
versionStates.deleteMany({})
|
|
362
|
+
versionStates.deleteMany({}),
|
|
363
|
+
versionEvents.deleteMany({})
|
|
224
364
|
]);
|
|
225
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
|
+
},
|
|
226
388
|
async commitVersionTransition(plan) {
|
|
227
|
-
|
|
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
|
+
}
|
|
228
397
|
const client = options.db.client;
|
|
229
398
|
if (client === void 0 || typeof client.startSession !== "function") {
|
|
230
399
|
try {
|
|
231
400
|
return await applyVersionTransition(plan);
|
|
232
401
|
} catch (error) {
|
|
233
|
-
if (isCasConflict(error))
|
|
234
|
-
|
|
402
|
+
if (isCasConflict(error)) {
|
|
403
|
+
return storageRevisionConflict(plan);
|
|
404
|
+
}
|
|
405
|
+
return { success: false, error: { type: "storage_error", cause: error } };
|
|
235
406
|
}
|
|
236
407
|
}
|
|
237
408
|
const session = client.startSession();
|
|
238
409
|
try {
|
|
239
410
|
let result = {
|
|
240
411
|
success: false,
|
|
241
|
-
error: "revision_conflict"
|
|
412
|
+
error: { type: "revision_conflict", expectedRevision: plan.expectedRevision }
|
|
242
413
|
};
|
|
243
414
|
await session.withTransaction(async () => {
|
|
244
415
|
result = await applyVersionTransition(plan, session);
|
|
245
416
|
});
|
|
246
417
|
return result;
|
|
247
418
|
} catch (error) {
|
|
248
|
-
if (isCasConflict(error))
|
|
249
|
-
|
|
419
|
+
if (isCasConflict(error)) {
|
|
420
|
+
return storageRevisionConflict(plan);
|
|
421
|
+
}
|
|
422
|
+
return { success: false, error: { type: "storage_error", cause: error } };
|
|
250
423
|
} finally {
|
|
251
424
|
await session.endSession();
|
|
252
425
|
}
|
|
@@ -260,11 +433,15 @@ function createMongoDbStorage(options) {
|
|
|
260
433
|
versions.createIndexes([
|
|
261
434
|
{ key: { formId: 1, version: 1 }, name: "form_versions_form_version", unique: true },
|
|
262
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" }
|
|
263
439
|
])
|
|
264
440
|
]);
|
|
265
441
|
}
|
|
266
442
|
};
|
|
267
443
|
}
|
|
268
444
|
export {
|
|
269
|
-
createMongoDbStorage
|
|
445
|
+
createMongoDbStorage,
|
|
446
|
+
submissionFilterToMongo
|
|
270
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"
|