UNPKG

mongoose-history-plugin

Version:

Mongoose plugin that saves history in JsonPatch format and SemVer format

400 lines (323 loc) 11.9 kB
let JsonDiffPatch = require('jsondiffpatch'), semver = require('semver'); let historyPlugin = (options = {}) => { let pluginOptions = { mongoose: false, // A mongoose instance modelName: '__histories', // Name of the collection for the histories embeddedDocument: false, // Is this a sub document embeddedModelName: '', // Name of model if used with embedded document userCollection: 'users', // Collection to ref when you pass an user id userCollectionIdType: false, // Type for user collection ref id, defaults to ObjectId accountCollection: 'accounts', // Collection to ref when you pass an account id or the item has an account property accountCollectionIdType: false, // Type for account collection ref id, defaults to ObjectId userFieldName: 'user', // Name of the property for the user accountFieldName: 'account', // Name of the property of the account if any timestampFieldName: 'timestamp', // Name of the property of the timestamp methodFieldName: 'method', // Name of the property of the method collectionIdType: false, // Cast type for _id (support for other binary types like uuid) ignore: [], // List of fields to ignore when compare changes noDiffSave: false, // Save event even if there are no changes noDiffSaveOnMethods: [], // Save event even if there are no changes if method matches noEventSave: true, // If false save only when __history property is passed startingVersion: '0.0.0', // Default starting version // If true save only the _id of the populated fields // If false save the whole object of the populated fields // If false and a populated field property changes it triggers a new history // You need to populate the field after a change is made on the original document or it will not catch the differences ignorePopulatedFields: true }; Object.assign(pluginOptions, options); if (pluginOptions.mongoose === false) { throw new Error('You need to pass a mongoose instance'); } let mongoose = pluginOptions.mongoose; const collectionIdType = options.collectionIdType || mongoose.Schema.Types.ObjectId; const userCollectionIdType = options.userCollectionIdType || mongoose.Schema.Types.ObjectId; const accountCollectionIdType = options.accountCollectionIdType || mongoose.Schema.Types.ObjectId; let Schema = new mongoose.Schema( { collectionName: String, collectionId: { type: collectionIdType }, diff: {}, event: String, reason: String, data: { type: mongoose.Schema.Types.Mixed }, [pluginOptions.userFieldName]: { type: userCollectionIdType, ref: pluginOptions.userCollection }, [pluginOptions.accountFieldName]: { type: accountCollectionIdType, ref: pluginOptions.accountCollection }, version: { type: String, default: pluginOptions.startingVersion }, [pluginOptions.timestampFieldName]: Date, [pluginOptions.methodFieldName]: String }, { collection: pluginOptions.modelName } ); Schema.set('minimize', false); Schema.set('versionKey', false); Schema.set('strict', true); Schema.pre('save', function (next) { this[pluginOptions.timestampFieldName] = new Date(); next(); }); let Model = mongoose.model(pluginOptions.modelName, Schema); let getModelName = (defaultName) => { return pluginOptions.embeddedDocument ? pluginOptions.embeddedModelName : defaultName; }; let jdf = JsonDiffPatch.create({ objectHash: function (obj, index) { if (obj !== undefined) { return ( (obj._id && obj._id.toString()) || obj.id || obj.key || '$$index:' + index ); } return '$$index:' + index; }, arrays: { detectMove: true } }); let query = (method = 'find', options = {}) => { let query = Model[method](options.find || {}); if (options.select !== undefined) { Object.assign(options.select, { _id: 0, collectionId: 0, collectionName: 0 }); query.select(options.select); } options.sort && query.sort(options.sort); options.populate && query.populate(options.populate); options.limit && query.limit(options.limit); return query.lean(); }; let getPreviousVersion = async (document) => { // get the oldest version from the history collection let versions = await document.getVersions(); return versions[versions.length - 1] ? versions[versions.length - 1].object : {}; }; let getPopulatedFields = (document) => { let populatedFields = []; // we only depopulate the first depth of fields for (let field in document) { if (document.populated(field)) { populatedFields.push(field); } } return populatedFields; }; let depopulate = (document, populatedFields) => { // we only depopulate the first depth of fields for (let field of populatedFields) { document.depopulate(field); } }; let repopulate = async (document, populatedFields) => { for (let field of populatedFields) { await document.populate(field).execPopulate(); } }; let cloneObjectByJson = (object) => object ? JSON.parse(JSON.stringify(object)) : {}; let cleanFields = (object) => { delete object.__history; delete object.__v; for (let i in pluginOptions.ignore) { delete object[pluginOptions.ignore[i]]; } return object; }; let getDiff = ({ prev, current, document, forceSave }) => { let diff = jdf.diff(prev, current); let saveWithoutDiff = false; if (document.__history && pluginOptions.noDiffSaveOnMethods.length) { let method = document.__history[pluginOptions.methodFieldName]; if (pluginOptions.noDiffSaveOnMethods.includes(method)) { saveWithoutDiff = true; if (forceSave) { diff = prev; } } } return { diff, saveWithoutDiff }; }; let saveHistory = async ({ document, diff }) => { let lastHistory = await Model.findOne({ collectionName: getModelName(document.constructor.modelName), collectionId: document._id }) .sort('-' + pluginOptions.timestampFieldName) .select({ version: 1 }); let obj = {}; obj.collectionName = getModelName(document.constructor.modelName); obj.collectionId = document._id; obj.diff = diff || {}; if (document.__history) { obj.event = document.__history.event; obj[pluginOptions.userFieldName] = document.__history[ pluginOptions.userFieldName ]; obj[pluginOptions.accountFieldName] = document[pluginOptions.accountFieldName] || document.__history[pluginOptions.accountFieldName]; obj.reason = document.__history.reason; obj.data = document.__history.data; obj[pluginOptions.methodFieldName] = document.__history[ pluginOptions.methodFieldName ]; } let version; if (lastHistory) { let type = document.__history && document.__history.type ? document.__history.type : 'major'; version = semver.inc(lastHistory.version, type); } obj.version = version || pluginOptions.startingVersion; for (let i in obj) { if (obj[i] === undefined) { delete obj[i]; } } let history = new Model(obj); document.__history = undefined; await history.save(); }; return function (schema) { schema.add({ __history: { type: mongoose.Schema.Types.Mixed } }); let preSave = function (forceSave) { return async function (next) { let currentDocument = this; if (currentDocument.__history !== undefined || pluginOptions.noEventSave) { try { let previousVersion = await getPreviousVersion(currentDocument); let populatedFields = getPopulatedFields(currentDocument); if (pluginOptions.ignorePopulatedFields) { depopulate(currentDocument, populatedFields); } let currentObject = cleanFields(cloneObjectByJson(currentDocument)); let previousObject = cleanFields(cloneObjectByJson(previousVersion)); if (pluginOptions.ignorePopulatedFields) { await repopulate(currentDocument, populatedFields); } let { diff, saveWithoutDiff } = getDiff({ current: currentObject, prev: previousObject, document: currentDocument, forceSave }); if (diff || pluginOptions.noDiffSave || saveWithoutDiff) { await saveHistory({ document: currentDocument, diff }); } return next(); } catch (error) { return next(error); } } next(); }; }; schema.pre('save', preSave(false)); schema.pre('remove', preSave(true)); // diff.find schema.methods.getDiffs = function (options = {}) { options.find = options.find || {}; Object.assign(options.find, { collectionName: getModelName(this.constructor.modelName), collectionId: this._id }); options.sort = options.sort || '-' + pluginOptions.timestampFieldName; return query('find', options); }; // diff.get schema.methods.getDiff = function (version, options = {}) { options.find = options.find || {}; Object.assign(options.find, { collectionName: getModelName(this.constructor.modelName), collectionId: this._id, version: version }); options.sort = options.sort || '-' + pluginOptions.timestampFieldName; return query('findOne', options); }; // versions.get schema.methods.getVersion = async function (version2get, includeObject = true) { let histories = await this.getDiffs({ sort: pluginOptions.timestampFieldName }); let lastVersion = histories[histories.length - 1], firstVersion = histories[0], history, version = {}; if (semver.gt(version2get, lastVersion.version)) { version2get = lastVersion.version; } if (semver.lt(version2get, firstVersion.version)) { version2get = firstVersion.version; } histories.map((item) => { if (item.version === version2get) { history = item; } }); if (!includeObject) { return history; } histories.map((item) => { if ( semver.lt(item.version, version2get) || item.version === version2get ) { version = jdf.patch(version, item.diff); } }); delete history.diff; history.object = version; return history; }; // versions.compare schema.methods.compareVersions = async function (versionLeft, versionRight) { let versionLeftDocument = await this.getVersion(versionLeft); let versionRightDocument = await this.getVersion(versionRight); return { diff: jdf.diff(versionLeftDocument.object, versionRightDocument.object), left: versionLeftDocument.object, right: versionRightDocument.object }; }; // versions.find schema.methods.getVersions = async function (options = {}, includeObject = true) { options.sort = options.sort || pluginOptions.timestampFieldName; let histories = await this.getDiffs(options); if (!includeObject) { return histories; } let version = {}; for (let i = 0; i < histories.length; i++) { version = jdf.patch(version, histories[i].diff); histories[i].object = jdf.clone(version); delete histories[i].diff; } return histories; }; }; }; module.exports = historyPlugin;