sequelize-central-log
Version:
Maintain a central history of changes to tables ( models ) in sequelize.
362 lines • 15.8 kB
JavaScript
;
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