sequelize-central-log
Version:
Maintain a central history of changes to tables ( models ) in sequelize.
512 lines (463 loc) • 14.8 kB
text/typescript
import {
DataType,
DataTypes,
Model,
ModelAttributes,
Op,
Sequelize,
} from 'sequelize';
import { createNamespace, getNamespace, Namespace } from 'cls-hooked';
import {
ModelDefined,
Logging as OriginalLogging,
} from 'sequelize/types/model';
interface ConfigOptions {
attributeModelId: string;
attributeModelId2: string;
attributeModelId3: string;
attributeRevision: string;
attributeRevisionModel: string;
attributeRevisionModelTableName: string;
attributeUserId: string;
continuationKey: string;
continuationNamespace: string | null;
debug: boolean;
enableMigration: boolean;
enableRevisionAttributeMigration: boolean;
exclude: string[];
failHard: boolean;
freezeTableName: boolean;
log: any;
mysql: boolean;
primaryKeyType: DataType;
trackFullModel: boolean;
useCompositeKeys: boolean;
underscored: boolean;
underscoredAttributes: boolean;
userModel: ModelDefined<any, any> | null;
}
// Add Typings for noHistory and userId on options.
declare module 'sequelize' {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
export interface Logging extends OriginalLogging {
/**
* Don't Track revision for this instance. defaults false.
*/
noHistory?: boolean;
/**
* Foreign Primary Key for user id INT
*/
userId?: number;
/**
* Should Skip Logging User, useful for Tables that require Anonymous users.
*/
skipLoggingUser?: boolean;
}
}
export type CentralLogOptions = Partial<ConfigOptions>;
export class SequelizeCentralLog {
private configuration: ConfigOptions;
private log: any;
private ns: Namespace | undefined;
private settings: ConfigOptions = {
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: DataTypes.INTEGER,
trackFullModel: false,
useCompositeKeys: false,
underscored: false,
underscoredAttributes: false,
userModel: null,
};
private revisionModel: ModelDefined<any, any>;
constructor(
private sequelizeDB: Sequelize,
private centralLogOptions?: CentralLogOptions,
) {
this.settings.mysql = this.sequelizeDB.getDialect() === 'mysql';
this.configuration = <ConfigOptions>{
...this.settings,
...this.centralLogOptions,
};
this.log = this.configuration.log;
if (this.configuration.continuationNamespace) {
this.ns = getNamespace(this.configuration.continuationNamespace);
if (!this.ns) {
this.ns = createNamespace(this.configuration.continuationNamespace);
}
}
}
/**
* Setup Revision Model and returns Revision Model.
* @returns ModelDefined<any, any> Revision Model for querying change data
*/
public async defineModels(): Promise<ModelDefined<any, any>> {
// set revision Model in sequelize.
const attributes: ModelAttributes = {
model: {
type: 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: DataTypes.INTEGER,
allowNull: true,
defaultValue: null,
},
operation: DataTypes.STRING(7),
[this.configuration.attributeRevision]: {
type: DataTypes.INTEGER,
allowNull: false,
},
current: {
type: DataTypes.JSON,
allowNull: false,
},
diff: {
type: 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
*/
public async addHistory(
model: ModelDefined<any, any>,
options?: {
exclude?: string[];
hasCompositeKey?: boolean;
thirdCompositeKey?: boolean;
disableHistoryAutoHook?: boolean;
},
): Promise<void> {
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: 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: 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: { [key: string]: string | number | { [Op.col]: any } } = {
model: Model.name,
};
if (options?.disableHistoryAutoHook) {
model['disableAutoHistoryIndividualHook'] = true;
}
if (options?.hasCompositeKey) {
if (primaryKeys.length < 2) {
throw new Error(
`Model ${Model.name}: Only has one primary Key, please check Model definition or don't pass hasCompositeKey: true`,
);
}
scope[this.configuration.attributeModelId2] = {
[Op.col]: `${Model.name}.${primaryKeys[1]}`,
};
model['usesCompositeKeys'] = true;
if (options?.thirdCompositeKey) {
if (primaryKeys.length < 3) {
throw new Error(
`Model ${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] = {
[Op.col]: `${Model.name}.${primaryKeys[2]}`,
};
}
}
// Add association to revision.
model.hasMany(
this.sequelizeDB.models[this.configuration.attributeRevisionModel],
{
foreignKey: this.configuration.attributeModelId,
constraints: false,
scope,
},
);
}
private getPrimaryKeys(instance: any): string[] {
return Object.getPrototypeOf(instance).constructor.primaryKeyAttributes;
}
private removeKeys(obj: any, instance: any): void {
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];
}
}
}
private readOnlyHook() {
throw new Error(
`This is a read-only revision table. You cannot update/destroy records.`,
);
}
private bulkCreateHook(instances, options) {
if (
!options.individualHooks &&
!instances.some(
(instance) => instance.constructor.disableAutoHistoryIndividualHook,
)
) {
options.individualHooks = true;
}
}
private bulkUpdateDestroyHook(options) {
if (
!options.individualHooks &&
!options.model.disableAutoHistoryIndividualHook
) {
options.individualHooks = true;
}
}
private createBeforeHook(operation: string) {
return (instance: any, opt: any) => {
// 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: string[] = 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: string) => {
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');
}
};
}
private createAfterHook(operation: string) {
return async (instance: any, opt: any) => {
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;
};
}
}