UNPKG

mongoose-patch-history

Version:

Mongoose plugin that saves a history of JSON patch operations for all documents belonging to a schema in an associated 'patches' collection

475 lines (388 loc) 15.8 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.RollbackError = undefined; var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; exports.default = function (schema, opts) { var options = (0, _lodash.merge)({}, defaultOptions, opts); // get _id type from schema options._idType = schema.tree._id.type; // transform excludes option options.excludes = options.excludes.map(getArrayFromPath); // validate parameters (0, _assert2.default)(options.mongoose, '`mongoose` option must be defined'); (0, _assert2.default)(options.name, '`name` option must be defined'); (0, _assert2.default)(!schema.methods.data, 'conflicting instance method: `data`'); (0, _assert2.default)(options._idType, 'schema is missing an `_id` property'); // used to compare instance data snapshots. depopulates instance, // removes version key and object id schema.methods.data = function () { return this.toObject({ depopulate: true, versionKey: false, transform: function transform(doc, ret, options) { delete ret._id; // if timestamps option is set on schema, ignore timestamp fields if (schema.options.timestamps) { delete ret[schema.options.timestamps.createdAt || 'createdAt']; delete ret[schema.options.timestamps.updatedAt || 'updatedAt']; } } }); }; // roll the document back to the state of a given patch id() schema.methods.rollback = function (patchId, data) { var _this = this; var save = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : true; return this.patches.find({ ref: this.id }).sort({ date: 1 }).exec().then(function (patches) { return new _bluebird2.default(function (resolve, reject) { // patch doesn't exist if (!~(0, _lodash.map)(patches, 'id').indexOf(patchId)) { return reject(new RollbackError("patch doesn't exist")); } // get all patches that should be applied var apply = (0, _lodash.dropRightWhile)(patches, function (patch) { return patch.id !== patchId; }); // if the patches that are going to be applied are all existing patches, // the rollback attempts to rollback to the latest patch if (patches.length === apply.length) { return reject(new RollbackError('rollback to latest patch')); } // apply patches to `state` var state = {}; apply.forEach(function (patch) { _fastJsonPatch2.default.applyPatch(state, patch.ops, true); }); // set new state _this.set((0, _lodash.merge)(data, state)); // in case of save, save it back to the db and resolve if (save) { _this.save().then(resolve).catch(reject); } else resolve(_this); }); }); }; // create patch model, enable static model access via `Patches` and // instance method access through an instances `patches` property var Patches = createPatchModel(options); schema.statics.Patches = Patches; schema.virtual('patches').get(function () { return Patches; }); // after a document is initialized or saved, fresh snapshots of the // documents data are created var snapshot = function snapshot() { this._original = toJSON(this.data()); }; schema.post('init', snapshot); schema.post('save', snapshot); // when a document is removed and `removePatches` is not set to false , // all patch documents from the associated patch collection are also removed function deletePatches(document) { var ref = document._id; return document.patches.find({ ref: document._id }).then(function (patches) { return (0, _bluebird.join)(patches.map(function (patch) { return patch.remove(); })); }); } schema.pre('remove', function (next) { if (!options.removePatches) { return next(); } deletePatches(this).then(function () { return next(); }).catch(next); }); // when a document is saved, the json patch that reflects the changes is // computed. if the patch consists of one or more operations (meaning the // document has changed), a new patch document reflecting the changes is // added to the associated patch collection function createPatch(document) { var queryOptions = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; var ref = document._id; var ops = _fastJsonPatch2.default.compare(document.isNew ? {} : document._original || {}, toJSON(document.data())); if (options.excludes.length > 0) { ops = ops.filter(function (op) { var pathArray = getArrayFromPath(op.path); return !options.excludes.some(function (exclude) { return isPathContained(exclude, pathArray); }) && options.excludes.every(function (exclude) { return deepRemovePath(op, exclude); }); }); } // don't save a patch when there are no changes to save if (!ops.length) { return _bluebird2.default.resolve(); } // track original values if enabled if (options.trackOriginalValue) { ops.map(function (entry) { var path = (0, _lodash.tail)(entry.path.split('/')).join('.'); entry.originalValue = (0, _lodash.get)(document.isNew ? {} : document._original, path); }); } // assemble patch data var data = { ops: ops, ref: ref }; (0, _lodash.each)(options.includes, function (type, name) { data[name] = document[type.from || name] || queryOptions[type.from || name]; }); return document.patches.create(data); } schema.pre('save', function (next) { createPatch(this).then(function () { return next(); }).catch(next); }); schema.pre('findOneAndRemove', function (next) { if (!options.removePatches) { return next(); } this.model.findOne(this._conditions).then(function (original) { return deletePatches(original); }).then(function () { return next(); }).catch(next); }); schema.pre('findOneAndUpdate', preUpdateOne); function preUpdateOne(next) { var _this2 = this; this.model.findOne(this._conditions).then(function (original) { if (original) _this2._originalId = original._id; original = original || new _this2.model({}); _this2._original = toJSON(original.data()); }).then(function () { return next(); }).catch(next); } schema.post('findOneAndUpdate', function (doc, next) { if (!this.options.new) { return postUpdateOne.call(this, {}, next); } doc._original = this._original; createPatch(doc, this.options).then(function () { return next(); }).catch(next); }); function postUpdateOne(result, next) { var _this3 = this; if (result.nModified === 0) return; var conditions = void 0; if (this._originalId) conditions = { _id: { $eq: this._originalId } };else conditions = mergeQueryConditionsWithUpdate(this._conditions, this._update); this.model.findOne(conditions).then(function (doc) { if (!doc) return next(); doc._original = _this3._original; return createPatch(doc, _this3.options); }).then(function () { return next(); }).catch(next); } schema.pre('updateOne', preUpdateOne); schema.post('updateOne', postUpdateOne); function preUpdateMany(next) { var _this4 = this; this.model.find(this._conditions).then(function (originals) { var originalIds = []; var originalData = []; var _iteratorNormalCompletion = true; var _didIteratorError = false; var _iteratorError = undefined; try { for (var _iterator = originals[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { var original = _step.value; originalIds.push(original._id); originalData.push(toJSON(original.data())); } } catch (err) { _didIteratorError = true; _iteratorError = err; } finally { try { if (!_iteratorNormalCompletion && _iterator.return) { _iterator.return(); } } finally { if (_didIteratorError) { throw _iteratorError; } } } _this4._originalIds = originalIds; _this4._originals = originalData; }).then(function () { return next(); }).catch(next); } function postUpdateMany(result, next) { var _this5 = this; if (result.nModified === 0) return; var conditions = void 0; if (this._originalIds.length === 0) conditions = mergeQueryConditionsWithUpdate(this._conditions, this._update);else conditions = { _id: { $in: this._originalIds } }; this.model.find(conditions).then(function (docs) { return _bluebird2.default.all(docs.map(function (doc, i) { doc._original = _this5._originals[i]; return createPatch(doc, _this5.options); })); }).then(function () { return next(); }).catch(next); } schema.pre('updateMany', preUpdateMany); schema.post('updateMany', postUpdateMany); schema.pre('update', function (next) { if (this.options.multi) { preUpdateMany.call(this, next); } else { preUpdateOne.call(this, next); } }); schema.post('update', function (result, next) { if (this.options.many) { postUpdateMany.call(this, result, next); } else { postUpdateOne.call(this, result, next); } }); }; var _assert = require('assert'); var _assert2 = _interopRequireDefault(_assert); var _mongoose = require('mongoose'); var _bluebird = require('bluebird'); var _bluebird2 = _interopRequireDefault(_bluebird); var _fastJsonPatch = require('fast-json-patch'); var _fastJsonPatch2 = _interopRequireDefault(_fastJsonPatch); var _humps = require('humps'); var _lodash = require('lodash'); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } var RollbackError = exports.RollbackError = function RollbackError(message, extra) { Error.captureStackTrace(this, this.constructor); this.name = 'RollbackError'; this.message = message; }; require('util').inherits(RollbackError, Error); var createPatchModel = function createPatchModel(options) { var def = { date: { type: Date, required: true, default: Date.now }, ops: { type: [], required: true }, ref: { type: options._idType, required: true, index: true } }; (0, _lodash.each)(options.includes, function (type, name) { def[name] = (0, _lodash.omit)(type, 'from'); }); var PatchSchema = new _mongoose.Schema(def); return options.mongoose.model(options.transforms[0]('' + options.name), PatchSchema, options.transforms[1]('' + options.name)); }; var defaultOptions = { includes: {}, excludes: [], removePatches: true, transforms: [_humps.pascalize, _humps.decamelize], trackOriginalValue: false }; var ARRAY_INDEX_WILDCARD = '*'; /** * Splits a json-patch-path of form `/path/to/object` to an array `['path', 'to', 'object']`. * Note: `/` is returned as `[]` * * @param {string} path Path to split */ var getArrayFromPath = function getArrayFromPath(path) { return path.replace(/^\//, '').split('/'); }; /** * Checks the provided `json-patch-operation` on `excludePath`. This check joins the `path` and `value` property of the `operation` and removes any hit. * * @param {import('fast-json-patch').Operation} patch operation to check with `excludePath` * @param {String[]} excludePath Path to property to remove from value of `operation` * * @return `false` if `patch.value` is `{}` or `undefined` after remove, `true` in any other case */ var deepRemovePath = function deepRemovePath(patch, excludePath) { var operationPath = sanitizeEmptyPath(getArrayFromPath(patch.path)); if (isPathContained(operationPath, excludePath)) { var value = patch.value; // because the paths overlap start at patchPath.length // e.g.: patch: { path:'/object', value:{ property: 'test' } } // pathToExclude: '/object/property' // need to start at array idx 1, because value starts at idx 0 var _loop = function _loop(i) { if (excludePath[i] === ARRAY_INDEX_WILDCARD && Array.isArray(value)) { // start over with each array element and make a fresh check // Note: it can happen that array elements are rendered to: {} // we need to keep them to keep the order of array elements consistent value.forEach(function (elem) { deepRemovePath({ path: '/', value: elem }, excludePath.slice(i + 1)); }); // If the patch value has turned to {} return false so this patch can be filtered out if (Object.keys(patch.value).length === 0) return { v: false }; return { v: true }; } value = value[excludePath[i]]; if (typeof value === 'undefined') return { v: true }; }; for (var i = operationPath.length; i < excludePath.length - 1; i++) { var _ret = _loop(i); if ((typeof _ret === 'undefined' ? 'undefined' : _typeof(_ret)) === "object") return _ret.v; } if (typeof value[excludePath[excludePath.length - 1]] === 'undefined') return true;else { delete value[excludePath[excludePath.length - 1]]; // If the patch value has turned to {} return false so this patch can be filtered out if (Object.keys(patch.value).length === 0) return false; } } return true; }; /** * Sanitizes a path `['']` to be used with `isPathContained()` * @param {String[]} path */ var sanitizeEmptyPath = function sanitizeEmptyPath(path) { return path.length === 1 && path[0] === '' ? [] : path; }; // Checks if 'fractionPath' is contained in fullPath // Exp. 1: fractionPath '/path/to', fullPath '/path/to/object' => true // Exp. 2: fractionPath '/arrayPath/*/property', fullPath '/arrayPath/1/property' => true var isPathContained = function isPathContained(fractionPath, fullPath) { return fractionPath.every(function (entry, idx) { return entryIsIdentical(entry, fullPath[idx]) || matchesArrayWildcard(entry, fullPath[idx]); }); }; var entryIsIdentical = function entryIsIdentical(entry1, entry2) { return entry1 === entry2; }; var matchesArrayWildcard = function matchesArrayWildcard(entry1, entry2) { return isArrayIndexWildcard(entry1) && isIntegerGreaterEqual0(entry2); }; var isArrayIndexWildcard = function isArrayIndexWildcard(entry) { return entry === ARRAY_INDEX_WILDCARD; }; var isIntegerGreaterEqual0 = function isIntegerGreaterEqual0(entry) { return Number.isInteger(Number(entry)) && Number(entry) >= 0; }; // used to convert bson to json - especially ObjectID references need // to be converted to hex strings so that the jsonpatch `compare` method // works correctly var toJSON = function toJSON(obj) { return JSON.parse(JSON.stringify(obj)); }; // helper function to merge query conditions after an update has happened // usefull if a property which was initially defined in _conditions got overwritten // with the update var mergeQueryConditionsWithUpdate = function mergeQueryConditionsWithUpdate(_conditions, _update) { var update = _update ? _update.$set || _update : _update; var conditions = Object.assign({}, conditions, update); // excluding updates other than $set Object.keys(conditions).forEach(function (key) { if (key.includes('$')) delete conditions[key]; }); return conditions; };