Mongoose Patch History is a mongoose plugin that saves a history of JSON Patch operations for all documents belonging to a schema in an associated "patches" collection.
$ npm install mongoose-patch-history
To use mongoose-patch-history for an existing mongoose schema you can simply plug it in. As an example, the following schema definition defines a Post
schema, and uses mongoose-patch-history with default options:
import mongoose, { Schema } from 'mongoose'
import patchHistory from 'mongoose-patch-history'
/* or the following if not running your app with babel:
const patchHistory = require('mongoose-patch-history').default;
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
*/
const PostSchema = new Schema({
title: { type: String, required: true },
comments: Array,
})
PostSchema.plugin(patchHistory, { mongoose, name: 'postPatches' })
const Post = mongoose.model('Post', PostSchema)
mongoose-patch-history will define a schema that has a ref
field containing the ObjectId
of the original document, a ops
array containing all json patch operations and a date
field storing the date where the patch was applied.
Continuing the previous example, a new patch is added to the associated patch collection whenever a new post is added to the posts collection:
Post.create({ title: 'JSON patches' })
.then(post => post.patches.findOne({ ref: post.id }))
.then(console.log)
// {
// _id: ObjectId('4edd40c86762e0fb12000003'),
// ref: ObjectId('4edd40c86762e0fb12000004'),
// ops: [
// { value: 'JSON patches', path: '/title', op: 'add' },
// { value: [], path: '/comments', op: 'add' }
// ],
// date: new Date(1462360838107),
// __v: 0
// }
mongoose-patch-history also adds a static field Patches
to the model that can be used to access the patch model associated with the model, for example to query all patches of a document. Whenever a post is edited, a new patch that reflects the update operation is added to the associated patch collection:
const data = {
title: 'JSON patches with mongoose',
comments: [{ message: 'Wow! Such Mongoose! Very NoSQL!' }],
}
Post.create({ title: 'JSON patches' })
.then(post => post.set(data).save())
.then(post => post.patches.find({ ref: post.id }))
.then(console.log)
// [{
// _id: ObjectId('4edd40c86762e0fb12000003'),
// ref: ObjectId('4edd40c86762e0fb12000004'),
// ops: [
// { value: 'JSON patches', path: '/title', op: 'add' },
// { value: [], path: '/comments', op: 'add' }
// ],
// date: new Date(1462360838107),
// __v: 0
// }, {
// _id: ObjectId('4edd40c86762e0fb12000005'),
// ref: ObjectId('4edd40c86762e0fb12000004'),
// ops: [
// { value: { message: 'Wow! Such Mongoose! Very NoSQL!' }, path: '/comments/0', op: 'add' },
// { value: 'JSON patches with mongoose', path: '/title', op: 'replace' }
// ],
// "date": new Date(1462361848742),
// "__v": 0
// }]
rollback(ObjectId, data, save)
Documents have a rollback
method that accepts the ObjectId of a patch doc and sets the document to the state of that patch, adding a new patch to the history.
Post.create({ title: 'First version' })
.then(post => post.set({ title: 'Second version' }).save())
.then(post => post.set({ title: 'Third version' }).save())
.then(post => {
return post.patches
.find({ ref: post.id })
.then(patches => post.rollback(patches[1].id))
})
.then(console.log)
// {
// _id: ObjectId('4edd40c86762e0fb12000006'),
// title: 'Second version',
// __v: 0
// }
Further the rollback
method accepts a data object which is injected into the document.
post.rollback(patches[1].id, { name: 'merged' })
// {
// _id: ObjectId('4edd40c86762e0fb12000006'),
// title: 'Second version',
// name: 'merged',
// __v: 0
// }
To rollback
the document to a specific patch but without saving it back to the database call the method with an empty data object and the save flag set to false.
post.rollback(patches[1].id, {}, false)
// Returns the document without saving it back to the db.
// {
// _id: ObjectId('4edd40c86762e0fb12000006'),
// title: 'Second version',
// __v: 0
// }
The rollback
method will throw an Error when invoked with an ObjectId that is
PostSchema.plugin(patchHistory, {
mongoose,
name: 'postPatches',
})
mongoose
:pushpin: required name
:pushpin: required removePatches
true
transforms
name
option. Default: An array of humps.pascalize and humps.decamelizeincludes
{}
excludes
[]
trackOriginalValue
originalValue
. Default: false
PostSchema.plugin(patchHistory, {
mongoose,
name: 'postPatches',
includes: {
title: { type: String, required: true },
},
})
This will add a title
property to the patch schema. All options that are available in mongoose's schema property definitions such as required
, default
or index
can be used.
Post.create({ title: 'Included in every patch' })
.then((post) => post.patches.findOne({ ref: post.id })
.then((patch) => {
console.log(patch.title) // 'Included in every patch'
})
The value of the patch documents properties is read from the versioned documents property of the same name.
There is an additional option that allows storing information in the patch documents that is not stored in the versioned documents. To do so, you can use a combination of virtual type setters on the versioned document and an additional from
property in the include options of mongoose-patch-history:
// save user as _user in versioned documents
PostSchema.virtual('user').set(function (user) {
this._user = user
})
// read user from _user in patch documents
PostSchema.plugin(patchHistory, {
mongoose,
name: 'postPatches',
includes: {
user: { type: Schema.Types.ObjectId, required: true, from: '_user' },
},
})
// create post, pass in user information
Post.create({
title: 'Why is hiring broken?',
user: mongoose.Types.ObjectId(),
})
.then(post => {
console.log(post.user) // undefined
return post.patches.findOne({ ref: post.id })
})
.then(patch => {
console.log(patch.user) // 4edd40c86762e0fb12000012
})
In case of a rollback in this scenario, the rollback
method accepts an object as its second parameter where additional data can be injected:
Post.create({ title: 'v1', user: mongoose.Types.ObjectId() })
.then(post =>
post
.set({
title: 'v2',
user: mongoose.Types.ObjectId(),
})
.save()
)
.then(post => {
return post.patches.find({ ref: post.id }).then(patches =>
post.rollback(patches[0].id, {
user: mongoose.Types.ObjectId(),
})
)
})
In situations where you are running Mongoose queries directly instead of via a document, you can specify the extra fields in the query options:
Post.findOneAndUpdate(
{ _id: '4edd40c86762e0fb12000012' },
{ title: 'Why is hiring broken? (updated)' },
{ _user: mongoose.Types.ObjectId() }
)
PostSchema.plugin(patchHistory, {
mongoose,
name: 'postPatches',
excludes: [
'/path/to/hidden/property',
'/path/into/array/*/property',
'/path/to/one/array/1/element',
],
})
// Properties
// /path/to/hidden: included
// /path/to/hidden/property: excluded
// /path/to/hidden/property/nesting: excluded
// Array element properties
// /path/into/array/0: included
// /path/into/array/345345/property: excluded
// /path/to/one/array/0/element: included
// /path/to/one/array/1/element: excluded
This will exclude the given properties and all nested paths. Excluding /
however will not work, since then you can just disable the plugin.
{}
or undefined
after processing all excludes statements, it will not be included in the patch.{}
or undefined
are still added to the patch. This brings support for later remove
or replace
operations to work as intended.ARRAY_WILDCARD
*
matches every array element.If there are any bugs experienced with the excludes
feature please write an issue so we can fix it!