diff --git a/.changeset/sweet-dolphins-wonder.md b/.changeset/sweet-dolphins-wonder.md new file mode 100644 index 00000000000..2f78bacbd3a --- /dev/null +++ b/.changeset/sweet-dolphins-wonder.md @@ -0,0 +1,6 @@ +--- +"@reactioncommerce/api-core": minor +"@reactioncommerce/file-collections-sa-gridfs": minor +--- + +feat: upgrade mongodb to 4.4 diff --git a/packages/api-core/package.json b/packages/api-core/package.json index dd2628c8734..c15ed79eea5 100644 --- a/packages/api-core/package.json +++ b/packages/api-core/package.json @@ -67,7 +67,7 @@ "express": "^4.17.1", "graphql-iso-date": "^3.6.1", "lodash": "^4.17.15", - "mongodb": "3.6.2", + "mongodb": "4.4.1", "promise-retry": "^1.1.1", "simpl-schema": "^1.7.0" }, diff --git a/packages/api-core/src/ReactionAPICore.js b/packages/api-core/src/ReactionAPICore.js index 8c6a6b7cc28..395645d2424 100644 --- a/packages/api-core/src/ReactionAPICore.js +++ b/packages/api-core/src/ReactionAPICore.js @@ -1,9 +1,10 @@ +/* eslint-disable no-extra-bind */ import { createServer } from "http"; import { createRequire } from "module"; import diehard from "diehard"; import express from "express"; import _ from "lodash"; -import mongodb from "mongodb"; +import * as mongodb from "mongodb"; import SimpleSchema from "simpl-schema"; import collectionIndex from "@reactioncommerce/api-utils/collectionIndex.js"; import getAbsoluteUrl from "@reactioncommerce/api-utils/getAbsoluteUrl.js"; @@ -276,43 +277,9 @@ export default class ReactionAPICore { // Add the collection instance to `context.collections`. // If the collection already exists, we need to modify it instead of calling // `createCollection`, in order to add validation options. - const getCollectionPromise = new Promise((resolve, reject) => { - this.db.collection( - collectionConfig.name, - { strict: true }, - (error, collection) => { - if (error) { - // Collection with this name doesn't yet exist - this.db - .createCollection( - collectionConfig.name, - collectionOptions - ) - .then((newCollection) => { - resolve(newCollection); - return null; - }) - .catch(reject); - } else { - // Collection with this name exists, so modify before resolving - this.db - .command({ - collMod: collectionConfig.name, - ...collectionOptions - }) - .then(() => { - resolve(collection); - return null; - }) - .catch(reject); - } - } - ); - }); - - /* eslint-enable promise/no-promise-in-callback */ - - this.collections[collectionKey] = await getCollectionPromise; // eslint-disable-line no-await-in-loop + // eslint-disable-next-line no-await-in-loop + this.collections[collectionKey] = await this.getCollection(collectionConfig, collectionOptions); + this.applyMongoV3BackwardCompatible(this.collections[collectionKey]); // If the collection config has `indexes` key, define all requested indexes if (Array.isArray(collectionConfig.indexes)) { @@ -330,6 +297,111 @@ export default class ReactionAPICore { } } + /** + * @summary Get legacy collection object + * @param {Object} collectionConfig - The collection config + * @param {Object} collectionOptions - The collection options + * @returns {Object} - legacy collection + */ + async getCollection(collectionConfig, collectionOptions) { + try { + return this.db.collection(collectionConfig.name, collectionOptions); + } catch { + return this.db + .command({ collMod: collectionConfig.name, ...collectionOptions }); + } + } + + /** + * @summary Apply MongoV3 backward compatible + * @param {Object} collection - The legacy collection object + * @returns {undefined} Nothing + */ + applyMongoV3BackwardCompatible(collection) { + const prevFind = collection.find.bind(collection); + collection.find = ((...args) => { + const result = prevFind(...args); + result.cmd = { query: result[Reflect.ownKeys(result).find((symbol) => String(symbol) === "Symbol(filter)")] }; + result.options = { db: collection.s.db }; + result.ns = `${collection.s.namespace.db}.${collection.s.namespace.collection}`; + return result; + }).bind(collection); + + const acknowledgedToOk = (acknowledged) => (acknowledged ? 1 : 0); + + const prevDeleteOne = collection.deleteOne.bind(collection); + collection.deleteOne = (async (...args) => { + const response = await prevDeleteOne(...args); + // eslint-disable-next-line id-length + return { ...response, result: { n: response.deletedCount, ok: acknowledgedToOk(response.acknowledged) } }; + }).bind(collection); + + collection.removeOne = collection.deleteOne.bind(collection); + + const prevUpdateMany = collection.updateMany.bind(collection); + collection.updateMany = (async (...args) => { + const response = await prevUpdateMany(...args); + // eslint-disable-next-line id-length + return { ...response, result: { n: response.modifiedCount, ok: acknowledgedToOk(response.acknowledged) } }; + }).bind(collection); + + const prevInsertOne = collection.insertOne.bind(collection); + collection.insertOne = (async (...args) => { + const response = await prevInsertOne(...args); + // eslint-disable-next-line id-length + return { ...response, result: { n: response.acknowledged ? 1 : 0, ok: acknowledgedToOk(response.acknowledged) } }; + }).bind(collection); + + const prevFindOneAndUpdate = collection.findOneAndUpdate.bind(collection); + collection.findOneAndUpdate = (async (...args) => { + const options = args[2]; + if (options && typeof options.returnOriginal !== "undefined") { + args[2].returnDocument = options.returnOriginal ? mongodb.ReturnDocument.BEFORE : mongodb.ReturnDocument.AFTER; + } + const response = await prevFindOneAndUpdate(...args); + return { ...response, modifiedCount: response.lastErrorObject.n }; + }).bind(collection); + + const prevReplaceOne = collection.replaceOne.bind(collection); + collection.replaceOne = (async (...args) => { + const response = await prevReplaceOne(...args); + // eslint-disable-next-line id-length + return { ...response, result: { n: response.modifiedCount, ok: acknowledgedToOk(response.acknowledged) } }; + }).bind(collection); + + const prevUpdateOne = collection.updateOne.bind(collection); + collection.updateOne = (async (...args) => { + const response = await prevUpdateOne(...args); + // eslint-disable-next-line id-length + return { ...response, result: { n: response.modifiedCount, ok: acknowledgedToOk(response.acknowledged) } }; + }).bind(collection); + + const prevBulkWrite = collection.bulkWrite.bind(collection); + collection.bulkWrite = (async (...args) => { + const response = await prevBulkWrite(...args); + const { + nInserted, + nUpserted, + nMatched, + nModified, + nRemoved + } = response.result; + return { + ...response, + nInserted, + nUpserted, + nMatched, + nModified, + nRemoved, + insertedCount: nInserted, + matchedCount: nMatched, + modifiedCount: nModified, + deletedCount: nRemoved, + upsertedCount: nUpserted + }; + }).bind(collection); + } + /** * @summary Given a MongoDB URL, creates a connection to it, sets `this.mongoClient`, * calls `this.setMongoDatabase` with the database instance, and then diff --git a/packages/api-core/src/ReactionAPICore.test.js b/packages/api-core/src/ReactionAPICore.test.js index 8524536ab23..830e1a6c7eb 100644 --- a/packages/api-core/src/ReactionAPICore.test.js +++ b/packages/api-core/src/ReactionAPICore.test.js @@ -1,3 +1,5 @@ +/* eslint-disable id-length */ + import importAsString from "@reactioncommerce/api-utils/importAsString.js"; import ReactionAPICore from "./ReactionAPICore.js"; import appEvents from "./util/appEvents.js"; @@ -57,3 +59,89 @@ test("throws error if appEvents is missing any props", () => { expect(error.message).toBe("appEvents is missing the following required function properties: emit, on, resume, stop"); } }); + +test("getCollection should return correct values", async () => { + const collectionConfig = { name: "Test" }; + const mockCollection = { + find: jest.fn().mockReturnValue({}), + deleteOne: jest.fn().mockReturnValue({ deletedCount: 1, acknowledged: true }), + updateMany: jest.fn().mockReturnValue({ modifiedCount: 2, acknowledged: true }), + insertOne: jest.fn().mockReturnValue({ acknowledged: true }), + findOneAndUpdate: jest.fn().mockReturnValue({ ok: 1, lastErrorObject: { n: 1 } }), + replaceOne: jest.fn().mockReturnValue({ modifiedCount: 1, acknowledged: true }), + updateOne: jest.fn().mockReturnValue({ modifiedCount: 1, acknowledged: true }), + bulkWrite: jest.fn().mockReturnValue({ + result: { + ok: 1, + writeErrors: [], + writeConcernErrors: [], + nInserted: 1, + nUpserted: 2, + nMatched: 3, + nModified: 4, + nRemoved: 5 + } + }), + // eslint-disable-next-line id-length + s: { + db: jest.fn(), + namespace: { + db: "test_db", + collection: "test_collection" + } + } + }; + + const api = new ReactionAPICore(); + + api.db = { + collection: () => ({ ...mockCollection }), + command: jest.fn() + }; + + const collection = await api.getCollection(collectionConfig, {}); + api.applyMongoV3BackwardCompatible(collection); + + collection.find({}); + expect(mockCollection.find).toBeCalled(); + + const deleteOneResult = await collection.deleteOne({}); + expect(mockCollection.deleteOne).toBeCalled(); + expect(deleteOneResult).toEqual({ ...deleteOneResult, result: { n: 1, ok: 1 } }); + + const updateManyResult = await collection.updateMany({}); + expect(mockCollection.updateMany).toBeCalled(); + expect(updateManyResult).toEqual({ ...updateManyResult, result: { n: 2, ok: 1 } }); + + const insertOneResult = await collection.insertOne({}); + expect(mockCollection.insertOne).toBeCalled(); + expect(insertOneResult).toEqual({ ...insertOneResult, result: { n: 1, ok: 1 } }); + + const findOneAndUpdateResult = await collection.findOneAndUpdate({}); + expect(mockCollection.findOneAndUpdate).toBeCalled(); + expect(findOneAndUpdateResult).toEqual({ ...findOneAndUpdateResult, modifiedCount: 1 }); + + const replaceOneResult = await collection.replaceOne({}); + expect(mockCollection.replaceOne).toBeCalled(); + expect(replaceOneResult).toEqual({ ...replaceOneResult, result: { n: 1, ok: 1 } }); + + const updateOneResult = await collection.updateOne({}); + expect(mockCollection.updateOne).toBeCalled(); + expect(updateOneResult).toEqual({ ...updateOneResult, result: { n: 1, ok: 1 } }); + + const bulkWriteResult = await collection.bulkWrite({}); + expect(mockCollection.updateOne).toBeCalled(); + expect(bulkWriteResult).toEqual({ + ...bulkWriteResult, + nInserted: 1, + nUpserted: 2, + nMatched: 3, + nModified: 4, + nRemoved: 5, + insertedCount: 1, + upsertedCount: 2, + matchedCount: 3, + modifiedCount: 4, + deletedCount: 5 + }); +}); diff --git a/packages/api-core/src/util/mongoConnectWithRetry.js b/packages/api-core/src/util/mongoConnectWithRetry.js index e5b877b51e3..2ad0d504ccf 100644 --- a/packages/api-core/src/util/mongoConnectWithRetry.js +++ b/packages/api-core/src/util/mongoConnectWithRetry.js @@ -1,5 +1,5 @@ import Logger from "@reactioncommerce/logger"; -import mongodb from "mongodb"; +import * as mongodb from "mongodb"; import promiseRetry from "promise-retry"; import config from "../config.js"; diff --git a/packages/file-collections-sa-gridfs/package.json b/packages/file-collections-sa-gridfs/package.json index 1da8eca5e22..36182869694 100644 --- a/packages/file-collections-sa-gridfs/package.json +++ b/packages/file-collections-sa-gridfs/package.json @@ -68,8 +68,7 @@ "dependencies": { "@babel/runtime-corejs2": "^7.14.8", "@reactioncommerce/file-collections-sa-base": "^0.2.2", - "debug": "^4.3.2", - "gridfs-stream": "^1.1.1" + "debug": "^4.3.2" }, "devDependencies": { "@babel/cli": "^7.10.5", @@ -87,6 +86,9 @@ "@babel/preset-env": "^7.10.4", "babel-core": "^7.0.0-bridge.0" }, + "peerDependencies": { + "mongodb": ">= 4.4.1 < 5" + }, "publishConfig": { "access": "public" } diff --git a/packages/file-collections-sa-gridfs/src/GridFSStore.js b/packages/file-collections-sa-gridfs/src/GridFSStore.js index f10f3df3516..913143d16b5 100644 --- a/packages/file-collections-sa-gridfs/src/GridFSStore.js +++ b/packages/file-collections-sa-gridfs/src/GridFSStore.js @@ -1,4 +1,3 @@ -import Grid from "gridfs-stream"; import StorageAdapter from "@reactioncommerce/file-collections-sa-base"; import debug from "./debug"; @@ -25,7 +24,7 @@ export default class GridFSStore extends StorageAdapter { this.chunkSize = chunkSize; this.collectionName = `${collectionPrefix}${name}`.trim(); - this.grid = Grid(db, mongodb); + this.grid = new mongodb.GridFSBucket(db); this.mongodb = mongodb; } @@ -42,37 +41,34 @@ export default class GridFSStore extends StorageAdapter { } _getReadStream(fileKey, { start: startPos, end: endPos } = {}) { - const opts = { _id: fileKey._id, root: this.collectionName }; + const opts = {}; // Add range if this should be a partial read if (typeof startPos === "number" && typeof endPos === "number") { - opts.range = { startPos, endPos }; + opts.start = startPos; + opts.end = endPos; } debug("GridFSStore _getReadStream opts:", opts); - return this.grid.createReadStream(opts); + const _id = new this.mongodb.ObjectId(fileKey._id); + return this.grid.openDownloadStream(_id, opts); } _getWriteStream(fileKey, options = {}) { const opts = { - chunk_size: this.chunkSize, // eslint-disable-line camelcase - content_type: "application/octet-stream", // eslint-disable-line camelcase - filename: fileKey.filename, - mode: "w", // overwrite any existing data - root: this.collectionName, + chunkSizeBytes: this.chunkSize, + contentType: "application/octet-stream", ...options }; - if (fileKey._id) opts._id = fileKey._id; - debug("GridFSStore _getWriteStream opts:", opts); - const writeStream = this.grid.createWriteStream(opts); + const writeStream = this.grid.openUploadStream(fileKey.filename, opts); - writeStream.on("close", (file) => { + writeStream.on("finish", (file) => { if (!file) { - // gridfs-stream will emit "close" without passing a file + // gridfs will emit "finish" without passing a file // if there is an error. We can simply exit here because // the "error" listener will also be called in this case. return; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c19e3ff727..a027b390098 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -291,7 +291,7 @@ importers: graphql: 14.7.0 graphql-iso-date: ^3.6.1 lodash: ^4.17.15 - mongodb: 3.6.2 + mongodb: 4.4.1 promise-retry: ^1.1.1 simpl-schema: ^1.7.0 dependencies: @@ -315,7 +315,7 @@ importers: express: 4.18.1 graphql-iso-date: 3.6.1_graphql@14.7.0 lodash: 4.17.21 - mongodb: 3.6.2 + mongodb: 4.4.1 promise-retry: 1.1.1 simpl-schema: 1.12.3 devDependencies: @@ -1464,12 +1464,12 @@ importers: '@reactioncommerce/file-collections-sa-base': ^0.2.2 babel-core: ^7.0.0-bridge.0 debug: ^4.3.2 - gridfs-stream: ^1.1.1 + mongodb: '>= 4.4.1 < 5' dependencies: '@babel/runtime-corejs2': 7.19.0 '@reactioncommerce/file-collections-sa-base': link:../file-collections-sa-base debug: 4.3.4 - gridfs-stream: 1.1.1 + mongodb: 4.9.1 devDependencies: '@babel/cli': 7.18.10_@babel+core@7.19.0 '@babel/core': 7.19.0 @@ -11343,19 +11343,6 @@ packages: whatwg-url: 11.0.0 dev: false - /mongodb/3.6.2: - resolution: {integrity: sha512-sSZOb04w3HcnrrXC82NEh/YGCmBuRgR+C1hZgmmv4L6dBz4BkRse6Y8/q/neXer9i95fKUBbFi4KgeceXmbsOA==} - engines: {node: '>=4'} - dependencies: - bl: 2.2.1 - bson: 1.1.6 - denque: 1.5.1 - require_optional: 1.0.1 - safe-buffer: 5.2.1 - optionalDependencies: - saslprep: 1.0.3 - dev: false - /mongodb/3.7.3: resolution: {integrity: sha512-Psm+g3/wHXhjBEktkxXsFMZvd3nemI0r3IPsE0bU+4//PnvNWKkzhZcEsbPcYiWqe8XqXJJEg4Tgtr7Raw67Yw==} engines: {node: '>=4'} @@ -11389,6 +11376,18 @@ packages: saslprep: 1.0.3 dev: false + /mongodb/4.4.1: + resolution: {integrity: sha512-IAD3nFtCR4s22vi5qjqkCBnuyDDrOW8WVSSmgHquOvGaP1iTD+XpC5tr8wAUbZ2EeZkaswwBKQFHDvl4qYcKqQ==} + engines: {node: '>=12.9.0'} + dependencies: + bson: 4.7.0 + denque: 2.1.0 + mongodb-connection-string-url: 2.5.3 + socks: 2.7.0 + optionalDependencies: + saslprep: 1.0.3 + dev: false + /mongodb/4.9.1: resolution: {integrity: sha512-ZhgI/qBf84fD7sI4waZBoLBNJYPQN5IOC++SBCiPiyhzpNKOxN/fi0tBHvH2dEC42HXtNEbFB0zmNz4+oVtorQ==} engines: {node: '>=12.9.0'} @@ -12670,13 +12669,6 @@ packages: /require-main-filename/2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} - /require_optional/1.0.1: - resolution: {integrity: sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==} - dependencies: - resolve-from: 2.0.0 - semver: 5.7.1 - dev: false - /requires-port/1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} dev: false @@ -12688,11 +12680,6 @@ packages: resolve-from: 5.0.0 dev: true - /resolve-from/2.0.0: - resolution: {integrity: sha512-qpFcKaXsq8+oRoLilkwyc7zHGF5i9Q2/25NIgLQQ/+VVv9rU4qvr6nXVAw1DsnXJyQkZsR4Ytfbtg5ehfcUssQ==} - engines: {node: '>=0.10.0'} - dev: false - /resolve-from/3.0.0: resolution: {integrity: sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==} engines: {node: '>=4'}