const moment = require('moment-timezone'); const Promise = require('bluebird'); const _ = require('lodash'); const errors = require('@tryghost/errors'); module.exports = function (Bookshelf) { const ParentModel = Bookshelf.Model; const Model = Bookshelf.Model.extend({ /** * Update collision protection. * * IMPORTANT NOTES: * The `sync` method is called for any query e.g. update, add, delete, fetch * * We had the option to override Bookshelf's `save` method, but hooking into the `sync` method gives us * the ability to access the `changed` object. Bookshelf already knows which attributes has changed. * * Bookshelf's timestamp function can't be overridden, as it's synchronous, there is no way to return an Error. * * If we want to enable the collision plugin for other tables, the queries might need to run in a transaction. * This depends on if we fetch the model before editing. Imagine two concurrent requests come in, both would fetch * the same current database values and both would succeed to update and override each other. */ sync: function timestamp(options) { const parentSync = ParentModel.prototype.sync.apply(this, arguments); const originalUpdateSync = parentSync.update; const self = this; // CASE: only enabled for posts table if (this.tableName !== 'posts' || !self.serverData || ((options.method !== 'update' && options.method !== 'patch') || !options.method) ) { return parentSync; } /** * Only hook into the update sync * * IMPORTANT NOTES: * Even if the client sends a different `id` property, it get's ignored by bookshelf. * Because you can't change the `id` of an existing post. * * HTML is always auto generated, ignore. */ parentSync.update = function update() { return originalUpdateSync.apply(this, arguments) .then((response) => { const changed = _.omit(self._changed, [ 'created_at', 'updated_at', 'author_id', 'id', 'published_by', 'updated_by', 'html', 'plaintext' ]); const clientUpdatedAt = moment(self.clientData.updated_at || self.serverData.updated_at || new Date()); const serverUpdatedAt = moment(self.serverData.updated_at || clientUpdatedAt); if (Object.keys(changed).length) { if (clientUpdatedAt.diff(serverUpdatedAt) !== 0) { // @NOTE: This will rollback the update. We cannot know if relations were updated before doing the update. return Promise.reject(new errors.UpdateCollisionError({ message: 'Saving failed! Someone else is editing this post.', code: 'UPDATE_COLLISION', level: 'critical', errorDetails: { clientUpdatedAt: self.clientData.updated_at, serverUpdatedAt: self.serverData.updated_at } })); } } return response; }); }; return parentSync; }, /** * We have to remember current server data and client data. * The `sync` method has no access to it. * `updated_at` is already set to "Date.now" when the overridden `sync.update` is called. * See https://github.com/tgriesser/bookshelf/blob/79c526870e618748caf94e7476a0bc796ee090a6/src/model.js#L955 */ save: function save(data) { this.clientData = _.cloneDeep(data) || {}; this.serverData = _.cloneDeep(this.attributes); return ParentModel.prototype.save.apply(this, arguments); } }); Bookshelf.Model = Model; };