UNPKG

sequelize-central-log

Version:

Maintain a central history of changes to tables ( models ) in sequelize.

362 lines 15.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SequelizeCentralLog = void 0; const sequelize_1 = require("sequelize"); const cls_hooked_1 = require("cls-hooked"); class SequelizeCentralLog { constructor(sequelizeDB, centralLogOptions) { this.sequelizeDB = sequelizeDB; this.centralLogOptions = centralLogOptions; this.settings = { attributeModelId: 'modelId', attributeModelId2: 'modelId2', attributeModelId3: 'modelId3', attributeRevision: 'revision', attributeRevisionModel: 'Revision', attributeRevisionModelTableName: 'Revision', attributeUserId: 'userId', continuationKey: 'userId', continuationNamespace: null, debug: false, enableMigration: false, enableRevisionAttributeMigration: false, exclude: [ 'id', 'createdAt', 'updatedAt', 'deletedAt', 'created_at', 'updated_at', 'deleted_at', 'revision', ], failHard: false, freezeTableName: false, log: console.log, mysql: false, primaryKeyType: sequelize_1.DataTypes.INTEGER, trackFullModel: false, useCompositeKeys: false, underscored: false, underscoredAttributes: false, userModel: null, }; this.settings.mysql = this.sequelizeDB.getDialect() === 'mysql'; this.configuration = { ...this.settings, ...this.centralLogOptions, }; this.log = this.configuration.log; if (this.configuration.continuationNamespace) { this.ns = (0, cls_hooked_1.getNamespace)(this.configuration.continuationNamespace); if (!this.ns) { this.ns = (0, cls_hooked_1.createNamespace)(this.configuration.continuationNamespace); } } } /** * Setup Revision Model and returns Revision Model. * @returns ModelDefined<any, any> Revision Model for querying change data */ async defineModels() { // set revision Model in sequelize. const attributes = { model: { type: sequelize_1.DataTypes.TEXT, allowNull: false, }, [this.configuration.attributeModelId]: { type: this.configuration.primaryKeyType, allowNull: false, }, [this.configuration.attributeModelId2]: { type: this.configuration.primaryKeyType, allowNull: true, }, [this.configuration.attributeModelId3]: { type: this.configuration.primaryKeyType, allowNull: true, }, [this.configuration.attributeUserId]: { type: sequelize_1.DataTypes.INTEGER, allowNull: true, defaultValue: null, }, operation: sequelize_1.DataTypes.STRING(7), [this.configuration.attributeRevision]: { type: sequelize_1.DataTypes.INTEGER, allowNull: false, }, current: { type: sequelize_1.DataTypes.JSON, allowNull: false, }, diff: { type: sequelize_1.DataTypes.JSON, allowNull: false, }, }; if (!this.configuration.userModel) { delete attributes[this.configuration.attributeUserId]; } if (!this.configuration.trackFullModel) { delete attributes.current; } if (!this.configuration.useCompositeKeys) { delete attributes[this.configuration.attributeModelId2]; delete attributes[this.configuration.attributeModelId3]; } const Revision = this.sequelizeDB.define(this.configuration.attributeRevisionModel, attributes, { freezeTableName: this.configuration.freezeTableName, underscored: this.configuration.underscored, tableName: this.configuration.attributeRevisionModelTableName, updatedAt: false, }); Revision.addHook('beforeUpdate', this.readOnlyHook); Revision.addHook('beforeDestroy', this.readOnlyHook); if (this.configuration.userModel) { Revision.belongsTo(this.configuration.userModel, { foreignKey: this.configuration.attributeUserId, }); } if (this.configuration.enableMigration) await Revision.sync(); this.revisionModel = Revision; return Revision; } /** * Enables and add history tracking to the passed in Model * @param model Sequelize Model to add history tracking to * @param options Model level Options exclude removes columns on model only, hasCompositeKey enables multi key tracking, thirdCompositeKeyCount if it has three keys */ async addHistory(model, options) { if (this.configuration.debug) { this.log(`Enabling Central Log on ${model.name}`); } model['modelLevelExclude'] = options?.exclude || []; const primaryKeys = model.primaryKeyAttributes; // Add the Revision column to the model model.rawAttributes['revision'] = { type: sequelize_1.DataTypes.INTEGER, defaultValue: 0, }; // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore model.refreshAttributes(); // add revision attribute to the model if (this.configuration.enableRevisionAttributeMigration) { const tableName = model.getTableName(); const queryInterface = this.sequelizeDB.getQueryInterface(); const revisionAttribute = this.configuration.attributeRevision; const tableAttributes = await queryInterface.describeTable(tableName); if (!tableAttributes[revisionAttribute]) { if (this.configuration.debug) { this.log(`Adding revision attribute to ${tableName}`); } try { await queryInterface.addColumn(tableName, revisionAttribute, { type: sequelize_1.DataTypes.INTEGER, defaultValue: 0, }); } catch (error) { this.log(`Error occured while adding revisionAttribute to ${tableName}.. ${error}`); } } } model.addHook('beforeCreate', this.createBeforeHook('create')); model.addHook('beforeUpdate', this.createBeforeHook('update')); model.addHook('beforeDestroy', this.createBeforeHook('destroy')); model.addHook('afterCreate', this.createAfterHook('create')); model.addHook('afterUpdate', this.createAfterHook('update')); model.addHook('afterDestroy', this.createAfterHook('destroy')); model.addHook('beforeBulkCreate', this.bulkCreateHook); model.addHook('beforeBulkUpdate', this.bulkUpdateDestroyHook); model.addHook('beforeBulkDestroy', this.bulkUpdateDestroyHook); const scope = { model: sequelize_1.Model.name, }; if (options?.disableHistoryAutoHook) { model['disableAutoHistoryIndividualHook'] = true; } if (options?.hasCompositeKey) { if (primaryKeys.length < 2) { throw new Error(`Model ${sequelize_1.Model.name}: Only has one primary Key, please check Model definition or don't pass hasCompositeKey: true`); } scope[this.configuration.attributeModelId2] = { [sequelize_1.Op.col]: `${sequelize_1.Model.name}.${primaryKeys[1]}`, }; model['usesCompositeKeys'] = true; if (options?.thirdCompositeKey) { if (primaryKeys.length < 3) { throw new Error(`Model ${sequelize_1.Model.name}: Was marked to have three keys, but has less or more than 3, please check model definition or don't pass thirdCompositeKey.`); } model['thirdCompositeKey'] = true; scope[this.configuration.attributeModelId3] = { [sequelize_1.Op.col]: `${sequelize_1.Model.name}.${primaryKeys[2]}`, }; } } // Add association to revision. model.hasMany(this.sequelizeDB.models[this.configuration.attributeRevisionModel], { foreignKey: this.configuration.attributeModelId, constraints: false, scope, }); } getPrimaryKeys(instance) { return Object.getPrototypeOf(instance).constructor.primaryKeyAttributes; } removeKeys(obj, instance) { const finalExclude = [ ...this.configuration.exclude, ...instance.constructor.modelLevelExclude, ...this.getPrimaryKeys(instance), ]; for (const k in obj) { if ((obj[k] instanceof Object && !(obj[k] instanceof Date)) || finalExclude.some((rm) => rm === k)) { delete obj[k]; } } } readOnlyHook() { throw new Error(`This is a read-only revision table. You cannot update/destroy records.`); } bulkCreateHook(instances, options) { if (!options.individualHooks && !instances.some((instance) => instance.constructor.disableAutoHistoryIndividualHook)) { options.individualHooks = true; } } bulkUpdateDestroyHook(options) { if (!options.individualHooks && !options.model.disableAutoHistoryIndividualHook) { options.individualHooks = true; } } createBeforeHook(operation) { return (instance, opt) => { // Allow disabling of history for a transaction // No reason to go further if (opt.noHistory) { if (this.configuration.debug) { this.log('Transaction set to ignore logging, opt.noHistory: true'); } return; } // Setup const destroyOperation = operation === 'destroy'; const modelName = instance.constructor.name; const previousVersion = { ...instance._previousDataValues }; const currentVersion = { ...instance.dataValues }; const changedValues = Array.from(instance._changed); // Filter columns from data that we don't care to track. const diffValuesToRead = changedValues.filter((value) => ![ ...this.configuration.exclude, ...instance.constructor.modelLevelExclude, ].some((filterValue) => filterValue === value)); this.removeKeys(currentVersion, instance); this.removeKeys(previousVersion, instance); // Don't allow revision to be modified. instance.set(this.configuration.attributeRevision, instance._previousDataValues['revision']); const currentRevision = instance.get(this.configuration.attributeRevision); if (this.configuration.debug) { this.log(`BeforeHook Called on instance: ${modelName}`); this.log(`PrefilterChanges: ${changedValues.toString()}`); } let diff; if (destroyOperation) { diff = previousVersion; } else { diff = diffValuesToRead.map((attribute) => { return { key: attribute, values: { old: previousVersion[attribute] || null, new: currentVersion[attribute], }, }; }); } if (destroyOperation || (diff && diff.length > 0)) { // Set current revision, starting at 0 for create and adding one for every revision. If record already exists, start incrementing. let revision = 0; if (operation !== 'create') { revision = (currentRevision || 0) + 1; } instance.set(this.configuration.attributeRevision, revision); if (!instance.context) { instance.context = {}; } instance.context.diff = diff; } if (this.configuration.debug) { this.log(`Diff: ${JSON.stringify(diff)}`); this.log('End of beforeHook'); } }; } createAfterHook(operation) { return async (instance, opt) => { const modelName = instance.constructor.name; const currenVersion = { ...instance.dataValues }; const destroyOperation = operation === 'destroy'; const primaryKeys = this.getPrimaryKeys(instance); if (this.configuration.debug) { this.log(`afterHook Called on instance: ${modelName}`); this.log(`Operation: ${operation}`); this.log('afterHook called'); // Log cls ns if (this.ns) { this.log(this.ns.get(this.configuration.continuationKey)); } } // filter values this.removeKeys(currenVersion, instance); if (instance.context && ((instance.context.diff && instance.context.diff.length > 0) || destroyOperation)) { const diff = instance.context.diff; const currentRevision = instance.get(this.configuration.attributeRevision); const revisionValues = { model: modelName, [this.configuration.attributeModelId]: instance.get(primaryKeys[0]), operation, [this.configuration.attributeRevision]: currentRevision, diff, }; if (this.configuration.trackFullModel) { revisionValues.current = currenVersion; } // Set User by Continuation Key, userId set on the Option for the transaction or null if it can't figure it out. // Opt params take precedent. if (this.configuration.userModel && !opt.skipLoggingUser) { revisionValues[this.configuration.attributeUserId] = opt.userId || (this.ns && this.ns.get(this.configuration.continuationKey)) || null; } if (this.configuration.useCompositeKeys && instance.constructor.usesCompositeKeys) { revisionValues[this.configuration.attributeModelId2] = instance.get(primaryKeys[1]); if (instance.constructor.thirdCompositeKey) { revisionValues[this.configuration.attributeModelId3] = instance.get(primaryKeys[2]); } } try { await this.revisionModel.create(revisionValues); } catch (error) { this.log(error); } } if (this.configuration.debug) { this.log('End of AfterHook'); } return; }; } } exports.SequelizeCentralLog = SequelizeCentralLog; //# sourceMappingURL=index.js.map