@compwright/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
312 lines (249 loc) • 9.77 kB
JavaScript
'use strict';
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.RollbackError = undefined;
exports.default = function (schema, opts) {
var options = (0, _lodash.merge)({}, defaultOptions, opts);
// get _id type from schema
options._idType = schema.tree._id.type;
// 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;
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);
});
// save new state and resolve with the resulting document
_this.set((0, _lodash.merge)(data, state)).save().then(resolve).catch(reject);
});
});
};
// 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()));
// 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) {
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 = Object.assign({}, this._conditions, this._update.$set || (0, _lodash.omitBy)(this._update, function (value, key) {
return key[0] === '$';
}));
this.model.findOne(conditions).then(function (doc) {
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 () {
var originals = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
_this4._originals = [].concat(originals).map(function (original) {
return original || new _this4.model({});
}).map(function (original) {
return toJSON(original.data());
});
}).then(function () {
return next();
}).catch(next);
}
function postUpdateMany(result, next) {
var _this5 = this;
if (result.nModified === 0) return;
var conditions = Object.assign({}, this._conditions, this._update.$set || (0, _lodash.omitBy)(this._update, function (value, key) {
return key[0] === '$';
}));
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: {},
removePatches: true,
transforms: [_humps.pascalize, _humps.decamelize],
trackOriginalValue: false
// 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));
};