UNPKG

@codebit-programando-solucoes/sequelize-paper-trail

Version:

Track changes to your Sequelize models data. Perfect for auditing or versioning.

658 lines (550 loc) 21.9 kB
"use strict"; var _sequelize = _interopRequireDefault(require("sequelize")); var _continuationLocalStorage = _interopRequireDefault(require("continuation-local-storage")); var jsdiff = _interopRequireWildcard(require("diff")); var _lodash = _interopRequireDefault(require("lodash")); var _helpers = _interopRequireDefault(require("./helpers")); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { var desc = Object.defineProperty && Object.getOwnPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : {}; if (desc.get || desc.set) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } } newObj.default = obj; return newObj; } } function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } let failHard = false; exports.init = (sequelize, optionsArg) => { // In case that options is being parsed as a readonly attribute. // Or it is not passed at all const optsArg = _lodash.default.cloneDeep(optionsArg || {}); const defaultOptions = { debug: false, log: null, exclude: ['id', 'createdAt', 'updatedAt', 'deletedAt', 'created_at', 'updated_at', 'deleted_at', 'revision'], revisionAttribute: 'revision', revisionModel: 'Revision', revisionChangeModel: 'RevisionChange', enableRevisionChangeModel: false, UUID: false, underscored: false, underscoredAttributes: false, defaultAttributes: { documentId: 'documentId', revisionId: 'revisionId' }, userModel: false, userModelAttribute: 'userId', enableCompression: false, enableMigration: false, enableStrictDiff: true, enablePreviousDocument: false, continuationNamespace: null, continuationKey: 'userId', metaDataFields: null, metaDataContinuationKey: 'metaData', documentFieldType: 'postgres' }; let ns = null; if (optsArg.continuationNamespace) { ns = _continuationLocalStorage.default.getNamespace(optsArg.continuationNamespace); if (!ns) { ns = _continuationLocalStorage.default.createNamespace(optsArg.continuationNamespace); } } if (optsArg.underscoredAttributes) { _helpers.default.toUnderscored(defaultOptions.defaultAttributes); } // keep the compatibility with old project if (optsArg.mysql && !optsArg.documentFieldType) { optsArg.documentFieldType = 'legacy'; } const options = _lodash.default.defaults(optsArg, defaultOptions); // if (optionsArg.defaultAttributes) { // if (optionsArg.defaultAttributes.documentId) { // defaultAttributes.documentId = // optionsArg.defaultAttributes.documentId; // } // if (optionsArg.defaultAttributes.revisionId) { // defaultAttributes.revisionId = // optionsArg.defaultAttributes.revisionId; // } // } // // if no options are passed the function // if (!options) options = {}; // enable debug logging // let debug = false; // const { debug } = options; // TODO: implement logging option const log = options.log || console.log; // show the current sequelize and options objects // if (options.debug) { // // log('sequelize object:'); // // log(sequelize); // log('options object:'); // log(options); // } // // attribute name for revision number in the models // if (!options.revisionAttribute) { // options.revisionAttribute = 'revision'; // } // // fields we want to exclude from audit trails // if (!options.exclude) { // options.exclude = [ // 'id', // 'createdAt', // 'updatedAt', // 'deletedAt', // if the model is paranoid // 'created_at', // 'updated_at', // 'deleted_at', // options.revisionAttribute, // ]; // } // // model name for revision table // if (!options.revisionModel) { // options.revisionModel = 'Revision'; // } // // model name for revision changes tables // if (!options.revisionChangeModel) { // options.revisionChangeModel = 'RevisionChange'; // } // if (!options.enableRevisionChangeModel) { // options.enableRevisionChangeModel = false; // } // // support UUID for postgresql // if (options.UUID === undefined) { // options.UUID = false; // } // // underscored created and updated attributes // if (!options.underscored) { // options.underscored = false; // } // if (!options.underscoredAttributes) { // options.underscoredAttributes = false; // options.defaultAttributes = defaultAttributes; // } else { // options.defaultAttributes = helpers.toUnderscored(defaultAttributes); // } // // To track the user that made the changes // if (!options.userModel) { // options.userModel = false; // } // // full revisions or compressed revisions (track only the difference in models) // // default: full revisions // if (!options.enableCompression) { // options.enableCompression = false; // } // // add the column to the database if it doesn't exist // if (!options.enableMigration) { // options.enableMigration = false; // } // // enable strict diff // // when true: 10 !== '10' // // when false: 10 == '10' // // default: true // if (!options.enableStrictDiff) { // options.enableStrictDiff = true; // } // let ns; // if (options.continuationNamespace) { // ns = cls.getNamespace(options.continuationNamespace); // if (!ns) { // ns = cls.createNamespace(options.continuationNamespace); // } // if (!options.continuationKey) { // options.continuationKey = 'userId'; // } // } // if (options.debug) { // log('parsed options:'); // log(options); // } function createBeforeHook(operation) { const beforeHook = function beforeHook(instance, opt) { if (options.debug) { log('beforeHook called'); log('instance:', instance); log('opt:', opt); } if (opt.noPaperTrail) { if (options.debug) { log('noPaperTrail opt: is true, not logging'); } return; } const destroyOperation = operation === 'destroy'; let previousVersion = {}; let currentVersion = {}; if (!destroyOperation && options.enableCompression) { _lodash.default.forEach(opt.defaultFields, a => { previousVersion[a] = instance._previousDataValues[a]; currentVersion[a] = instance.dataValues[a]; }); } else { previousVersion = instance._previousDataValues; currentVersion = instance.dataValues; } // Supported nested models. previousVersion = _lodash.default.omitBy(previousVersion, i => i != null && typeof i === 'object' && !(i instanceof Date)); previousVersion = _lodash.default.omit(previousVersion, options.exclude); currentVersion = _lodash.default.omitBy(currentVersion, i => i != null && typeof i === 'object' && !(i instanceof Date)); currentVersion = _lodash.default.omit(currentVersion, options.exclude); // Disallow change of revision instance.set(options.revisionAttribute, instance._previousDataValues[options.revisionAttribute]); // Get diffs const delta = _helpers.default.calcDelta(previousVersion, currentVersion, options.exclude, options.enableStrictDiff); const currentRevisionId = instance.get(options.revisionAttribute); if (failHard && !currentRevisionId && opt.type === 'UPDATE') { throw new Error('Revision Id was undefined'); } if (options.debug) { log('delta:', delta); log('revisionId', currentRevisionId); } // Check if all required fields have been provided to the opts / CLS if (options.metaDataFields) { // get all required field keys as an array const requiredFields = _lodash.default.keys(_lodash.default.pickBy(options.metaDataFields, function isMetaDataFieldRequired(required) { return required; })); if (requiredFields && requiredFields.length) { const metaData = ns && ns.get(options.metaDataContinuationKey) || opt.metaData; const requiredFieldsProvided = _lodash.default.filter(requiredFields, function isMetaDataFieldNonUndefined(field) { return metaData[field] !== undefined; }); if (requiredFieldsProvided.length !== requiredFields.length) { log('Required fields: ', options.metaDataFields, requiredFields); log('Required fields provided: ', metaData, requiredFieldsProvided); throw new Error('Not all required fields are provided to paper trail!'); } } } if (destroyOperation || delta && delta.length > 0) { const revisionId = (currentRevisionId || 0) + 1; instance.set(options.revisionAttribute, revisionId); if (!instance.context) { instance.context = {}; } instance.context.delta = delta; } if (options.debug) { log('end of beforeHook'); } }; return beforeHook; } function createAfterHook(operation) { const afterHook = function afterHook(instance, opt) { if (options.debug) { log('afterHook called'); log('instance:', instance); log('opt:', opt); if (ns) { log(`CLS ${options.continuationKey}:`, ns.get(options.continuationKey)); } } const destroyOperation = operation === 'destroy'; if (instance.context && (instance.context.delta && instance.context.delta.length > 0 || destroyOperation)) { const Revision = sequelize.model(options.revisionModel); let RevisionChange; if (options.enableRevisionChangeModel) { RevisionChange = sequelize.model(options.revisionChangeModel); } const { delta } = instance.context; let previousVersion = {}; let currentVersion = {}; if (!destroyOperation && options.enableCompression) { _lodash.default.forEach(opt.defaultFields, a => { previousVersion[a] = instance._previousDataValues[a]; currentVersion[a] = instance.dataValues[a]; }); } else { previousVersion = instance._previousDataValues; currentVersion = instance.dataValues; } // Supported nested models. previousVersion = _lodash.default.omitBy(previousVersion, i => i != null && typeof i === 'object' && !(i instanceof Date)); previousVersion = _lodash.default.omit(previousVersion, options.exclude); currentVersion = _lodash.default.omitBy(currentVersion, i => i != null && typeof i === 'object' && !(i instanceof Date)); currentVersion = _lodash.default.omit(currentVersion, options.exclude); if (failHard && ns && !ns.get(options.continuationKey)) { throw new Error(`The CLS continuationKey ${options.continuationKey} was not defined.`); } let document = currentVersion; let previousDocument = previousVersion; if (options.documentFieldType === 'legacy') { document = JSON.stringify(document); } // Build revision const query = { model: this.name, document, operation }; // add previous version if enable if (options.enablePreviousDocument) { const keys = Object.keys(previousDocument); let preventNulls = {}; if (operation == 'update') { keys.forEach(key => { if (previousDocument[key] != null) preventNulls[key] = previousDocument[key]; }); if (options.documentFieldType === 'legacy') { previousDocument = JSON.stringify(preventNulls); } } else { preventNulls = null; } query.previousDocument = preventNulls; } // Add all extra data fields to the query object if (options.metaDataFields) { const metaData = ns && ns.get(options.metaDataContinuationKey) || opt.metaData; if (metaData) { _lodash.default.forEach(options.metaDataFields, function getMetaDataValues(required, field) { const value = metaData[field]; if (options.debug) { log(`Adding metaData field to Revision - ${field} => ${value}`); } if (!(field in query)) { query[field] = value; } else if (options.debug) { log(`Revision object already has a value at ${field} => ${query[field]}`); log('Not overwriting the original value'); } }); } } // in case of custom user models that are not 'userId' query[options.userModelAttribute] = ns && ns.get(options.continuationKey) || opt.userId; query[options.defaultAttributes.documentId] = instance.id; const revision = Revision.build(query); revision[options.revisionAttribute] = instance.get(options.revisionAttribute); // Save revision return revision.save({ transaction: opt.transaction }).then(objectRevision => { // Loop diffs and create a revision-diff for each if (options.enableRevisionChangeModel) { _lodash.default.forEach(delta, difference => { const o = _helpers.default.diffToString(difference.item ? difference.item.lhs : difference.lhs); const n = _helpers.default.diffToString(difference.item ? difference.item.rhs : difference.rhs); // let document = difference; document = difference; let diff = o || n ? jsdiff.diffChars(o, n) : []; if (options.documentFieldType === 'legacy') { document = JSON.stringify(document); diff = JSON.stringify(diff); } const d = RevisionChange.build({ path: difference.path[0], document, diff, revisionId: objectRevision.id }); d.save({ transaction: opt.transaction }).then(savedD => { // Add diff to revision objectRevision[`add${_helpers.default.capitalizeFirstLetter(options.revisionChangeModel)}`](savedD); return null; }).catch(err => { log('RevisionChange save error', err); throw err; }); }); } return null; }).catch(err => { log('Revision save error', err); throw err; }); } if (options.debug) { log('end of afterHook'); } return null; }; return afterHook; } // order in which sequelize processes the hooks // (1) // beforeBulkCreate(instances, options, fn) // beforeBulkDestroy(instances, options, fn) // beforeBulkUpdate(instances, options, fn) // (2) // beforeValidate(instance, options, fn) // (-) // validate // (3) // afterValidate(instance, options, fn) // - or - // validationFailed(instance, options, error, fn) // (4) // beforeCreate(instance, options, fn) // beforeDestroy(instance, options, fn) // beforeUpdate(instance, options, fn) // (-) // create // destroy // update // (5) // afterCreate(instance, options, fn) // afterDestroy(instance, options, fn) // afterUpdate(instance, options, fn) // (6) // afterBulkCreate(instances, options, fn) // afterBulkDestroy(instances, options, fn) // afterBulkUpdate(instances, options, fn) // Extend model prototype with "hasPaperTrail" function // Call model.hasPaperTrail() to enable revisions for model _lodash.default.assignIn(_sequelize.default.Model, { hasPaperTrail: function hasPaperTrail() { if (options.debug) { log('Enabling paper trail on', this.name); } this.rawAttributes[options.revisionAttribute] = { type: _sequelize.default.INTEGER, defaultValue: 0 }; this.revisionable = true; // not sure if we need this this.refreshAttributes(); if (options.enableMigration) { const tableName = this.getTableName(); const queryInterface = sequelize.getQueryInterface(); queryInterface.describeTable(tableName).then(attributes => { if (!attributes[options.revisionAttribute]) { if (options.debug) { log('adding revision attribute to the database'); } queryInterface.addColumn(tableName, options.revisionAttribute, { type: _sequelize.default.INTEGER, defaultValue: 0 }).then(() => null).catch(err => { log('something went really wrong..', err); return null; }); } return null; }); } this.addHook('beforeCreate', createBeforeHook('create')); this.addHook('beforeDestroy', createBeforeHook('destroy')); this.addHook('beforeUpdate', createBeforeHook('update')); this.addHook('afterCreate', createAfterHook('create')); this.addHook('afterDestroy', createAfterHook('destroy')); this.addHook('afterUpdate', createAfterHook('update')); // create association return this.hasMany(sequelize.models[options.revisionModel], { foreignKey: options.defaultAttributes.documentId, constraints: false, scope: { model: this.name } }); } }); return { // Return defineModels() defineModels: function defineModels(db) { // Attributes for RevisionModel let attributes = { model: { type: _sequelize.default.TEXT, allowNull: false }, document: { type: _sequelize.default.JSONB, allowNull: false }, operation: _sequelize.default.STRING(7) }; if (options.enablePreviousDocument) { attributes.previousDocument = { type: _sequelize.default.JSONB, allowNull: true }; } if (options.documentFieldType === 'legacy') { attributes.document.type = _sequelize.default.TEXT('MEDIUMTEXT'); } else if (options.documentFieldType === 'mysql') { attributes.document.type = _sequelize.default.JSON; if (options.enablePreviousDocument) { attributes.previousDocument.type = _sequelize.default.JSON; } } attributes[options.defaultAttributes.documentId] = { type: _sequelize.default.INTEGER, allowNull: false }; attributes[options.revisionAttribute] = { type: _sequelize.default.INTEGER, allowNull: false }; if (options.UUID) { attributes.id = { primaryKey: true, type: _sequelize.default.UUID, defaultValue: _sequelize.default.UUIDV4 }; attributes[options.defaultAttributes.documentId].type = _sequelize.default.UUID; } if (options.debug) { log('attributes', attributes); } // Revision model const Revision = sequelize.define(options.revisionModel, attributes, { underscored: options.underscored, tableName: options.tableName }); Revision.associate = function associate(models) { if (options.debug) { log('models', models); } Revision.belongsTo(sequelize.model(options.userModel), options.belongsToUserOptions); }; if (options.enableRevisionChangeModel) { // Attributes for RevisionChangeModel attributes = { path: { type: _sequelize.default.TEXT, allowNull: false }, document: { type: _sequelize.default.JSONB, allowNull: false }, diff: { type: _sequelize.default.JSONB, allowNull: false } }; if (options.documentFieldType === 'legacy') { attributes.document.type = _sequelize.default.TEXT('MEDIUMTEXT'); attributes.diff.type = _sequelize.default.TEXT('MEDIUMTEXT'); } else if (options.documentFieldType === 'mysql') { attributes.document.type = _sequelize.default.JSON; attributes.diff.type = _sequelize.default.JSON; } if (options.UUID) { attributes.id = { primaryKey: true, type: _sequelize.default.UUID, defaultValue: _sequelize.default.UUIDV4 }; } // RevisionChange model const RevisionChange = sequelize.define(options.revisionChangeModel, attributes, { underscored: options.underscored }); // Set associations Revision.hasMany(RevisionChange, { foreignKey: options.defaultAttributes.revisionId, constraints: false }); // https://github.com/nielsgl/sequelize-paper-trail/issues/10 // RevisionChange.belongsTo(Revision, { // foreignKey: options.defaultAttributes.revisionId, // }); RevisionChange.belongsTo(Revision); if (db) db[RevisionChange.name] = RevisionChange; } if (db) db[Revision.name] = Revision; /* * We could extract this to a separate function so that having a * user model doesn't require different loading * * or perhaps we could omit this because we are creating the * association through the associate call above. */ if (options.userModel) { Revision.belongsTo(sequelize.model(options.userModel), options.belongsToUserOptions); } return Revision; } }; }; /** * Throw exceptions when the user identifier from CLS is not set or if the * revisionAttribute was not loaded on the model. */ exports.enableFailHard = () => { failHard = true; }; module.exports = exports;