UNPKG

@freemework/sql.misc.migration

Version:

Hosting library of the Freemework Project.

371 lines (319 loc) 14.2 kB
import { EOL } from "os"; import * as path from "path"; import * as vm from "vm"; import { FExecutionContext, FLogger, FSqlConnection, FSqlConnectionFactory, FException, FLoggerLabelsExecutionContext, FLoggerLevel, FLoggerBase, FLoggerLabel, FLoggerLabelValue, } from "@freemework/common"; import { FSqlMigrationSources } from "./f_sql_migration_sources.js"; export class FSqlMigrationManagerLoggerLabel extends FLoggerLabel { public static readonly DIRECTION = new FSqlMigrationManagerLoggerLabel("migration.direction", "Describes a direction of migration process (install/rollback)"); public static readonly SCRIPT = new FSqlMigrationManagerLoggerLabel("migration.script", "Describes a name of migration script"); public static readonly VERSION = new FSqlMigrationManagerLoggerLabel("migration.version", "Describes a version of migration atom"); } export abstract class FSqlMigrationManager { private readonly _sqlConnectionFactory: FSqlConnectionFactory; private readonly _versionTableName: string; private readonly logger: FLogger; public constructor(opts: FSqlMigrationManager.Opts) { this._sqlConnectionFactory = opts.sqlConnectionFactory; this._versionTableName = opts.versionTableName !== undefined ? opts.versionTableName : "__migration"; this.logger = FLogger.create(this.constructor.name); } /** * Install versions (increment version) * @param executionContext * @param targetVersion Optional target version. Will use latest version if omitted. */ public async install(executionContext: FExecutionContext, migrationSources: FSqlMigrationSources, targetVersion?: string): Promise<void> { executionContext = new FLoggerLabelsExecutionContext(executionContext, FSqlMigrationManagerLoggerLabel.DIRECTION.value("install"), ); const currentVersion: string | null = await this.getCurrentVersion(executionContext); const availableVersions: Array<string> = [...migrationSources.versionNames].sort(); // from old version to new version let scheduleVersions: Array<string> = availableVersions; if (currentVersion !== null) { scheduleVersions = scheduleVersions.reduce<Array<string>>( (p, c) => { if (c > currentVersion) { p.push(c); } return p; }, [] ); } if (targetVersion !== undefined) { scheduleVersions = scheduleVersions.reduceRight<Array<string>>(function (p, c) { if (c <= targetVersion) { p.unshift(c); } return p; }, []); } await this.sqlConnectionFactory.usingConnection( executionContext, async (usingExecutionContext: FExecutionContext, sqlConnection: FSqlConnection) => { if (!(await this._isVersionTableExist(usingExecutionContext, sqlConnection))) { await this._createVersionTable(usingExecutionContext, sqlConnection); } } ); for (const versionName of scheduleVersions) { await this.sqlConnectionFactory.usingConnectionWithTransaction( new FLoggerLabelsExecutionContext(executionContext, FSqlMigrationManagerLoggerLabel.VERSION.value(versionName), ), async (usingExecutionContext: FExecutionContext, sqlConnection: FSqlConnection) => { const migrationLogger = new FSqlMigrationManager.MigrationLogger(this.logger); const versionBundle: FSqlMigrationSources.VersionBundle = migrationSources.getVersionBundle(versionName); const installScriptNames: Array<string> = [...versionBundle.installScriptNames].sort(); for (const scriptName of installScriptNames) { const scriptExecutionContext = new FLoggerLabelsExecutionContext(usingExecutionContext, FSqlMigrationManagerLoggerLabel.SCRIPT.value(scriptName), ); const script: FSqlMigrationSources.Script = versionBundle.getInstallScript(scriptName); switch (script.kind) { case FSqlMigrationSources.Script.Kind.SQL: { migrationLogger.info(scriptExecutionContext, `Execute SQL script: ${script.name}`); migrationLogger.trace(scriptExecutionContext, EOL + script.content); await this._executeMigrationSql(scriptExecutionContext, sqlConnection, migrationLogger, script.content); break; } case FSqlMigrationSources.Script.Kind.JAVASCRIPT: { migrationLogger.info(scriptExecutionContext, `Execute JS script: ${script.name}`); migrationLogger.trace(scriptExecutionContext, EOL + script.content); await this._executeMigrationJavaScript( scriptExecutionContext, sqlConnection, migrationLogger, { content: script.content, file: script.file } ); break; } default: migrationLogger.warn(scriptExecutionContext, `Skip script '${versionName}:${script.name}' due unknown kind of script`); } } const logText: string = migrationLogger.flush(); await this._insertVersionLog(usingExecutionContext, sqlConnection, versionName, logText); const rollbackScripts: Array<FSqlMigrationSources.Script> = versionBundle.rollbackScriptNames.map(scriptName => versionBundle.getRollbackScript(scriptName)); await this._insertRollbackScripts(usingExecutionContext, sqlConnection, versionName, rollbackScripts); } ); } } /** * Rollback versions (increment version) * @param cancellationToken A cancellation token that can be used to cancel the action. * @param targetVersion Optional target version. Will rollback all versions if omitted. */ public async rollback(executionContext: FExecutionContext, targetVersion?: string): Promise<void> { executionContext = new FLoggerLabelsExecutionContext(executionContext, FSqlMigrationManagerLoggerLabel.DIRECTION.value("rollback"), ); const currentVersion: string | null = await this.getCurrentVersion(executionContext); if (currentVersion === null) { this.logger.warn(executionContext, `Skip rollback due to no any installed versions`); return; } const versionNames: Array<string> = await this.sqlConnectionFactory.usingConnection( executionContext, (usingExecutionContext: FExecutionContext, sqlConnection: FSqlConnection) => this._listVersions(usingExecutionContext, sqlConnection) ); const availableVersions: Array<string> = [...versionNames].sort().reverse(); // from new version to old version let scheduleVersionNames: Array<string> = availableVersions; scheduleVersionNames = scheduleVersionNames.reduce<Array<string>>( (p, c) => { if (c <= currentVersion) { p.push(c); } return p; }, [] ); if (targetVersion !== undefined) { scheduleVersionNames = scheduleVersionNames.reduceRight<Array<string>>( (p, c) => { if (c > targetVersion) { p.unshift(c); } return p; }, [] ); } for (const versionName of scheduleVersionNames) { await this.sqlConnectionFactory.usingConnectionWithTransaction( new FLoggerLabelsExecutionContext(executionContext, FSqlMigrationManagerLoggerLabel.VERSION.value(versionName), ), async (usingExecutionContext: FExecutionContext, sqlConnection: FSqlConnection) => { if (! await this._isVersionLogExist(usingExecutionContext, sqlConnection, versionName)) { this.logger.warn(executionContext, `Skip rollback for version '${versionName}' due this does not present inside database.`); return; } const scripts: Array<FSqlMigrationSources.Script> = await this._getRollbackScripts(usingExecutionContext, sqlConnection, versionName); //const versionBundle: FSqlMigrationSources.VersionBundle = this._migrationSources.getVersionBundle(versionName); const rollbackScriptNames: Array<string> = [...scripts.map(s => s.name)].sort().reverse(); const scriptsMap: Map<string, FSqlMigrationSources.Script> = scripts.reduce((acc, curr) => { acc.set(curr.name, curr); return acc }, new Map<string, FSqlMigrationSources.Script>()); for (const scriptName of rollbackScriptNames) { const scriptExecutionContext = new FLoggerLabelsExecutionContext(usingExecutionContext, FSqlMigrationManagerLoggerLabel.SCRIPT.value(scriptName), ); const script: FSqlMigrationSources.Script = scriptsMap.get(scriptName)!; switch (script.kind) { case FSqlMigrationSources.Script.Kind.SQL: { this.logger.info(scriptExecutionContext, `Execute SQL script: ${script.name}`); this.logger.trace(scriptExecutionContext, EOL + script.content); await this._executeMigrationSql(scriptExecutionContext, sqlConnection, this.logger, script.content); break; } case FSqlMigrationSources.Script.Kind.JAVASCRIPT: { this.logger.info(scriptExecutionContext, `Execute JS script: ${script.name}`); this.logger.trace(scriptExecutionContext, EOL + script.content); await this._executeMigrationJavaScript( scriptExecutionContext, sqlConnection, this.logger, { content: script.content, file: script.file } ); break; } default: this.logger.warn(scriptExecutionContext, `Skip script '${versionName}:${script.name}' due unknown kind of script`); } } await this._removeVersionLog(usingExecutionContext, sqlConnection, versionName); } ); } } /** * Gets current version of the database or `null` if version table is not presented. * @param cancellationToken Allows to request cancel of the operation. */ public abstract getCurrentVersion(executionContext: FExecutionContext): Promise<string | null>; /** * Gets list of installed versions of the database. * @param cancellationToken Allows to request cancel of the operation. */ public abstract listVersions(executionContext: FExecutionContext): Promise<Array<string>>; protected get sqlConnectionFactory(): FSqlConnectionFactory { return this._sqlConnectionFactory; } protected get versionTableName(): string { return this._versionTableName; } protected abstract _createVersionTable( executionContext: FExecutionContext, sqlConnection: FSqlConnection ): Promise<void>; protected async _executeMigrationJavaScript( executionContext: FExecutionContext, sqlConnection: FSqlConnection, migrationLogger: FLogger, migrationJavaScript: { readonly content: string; readonly file: string; } ): Promise<void> { await new Promise<void>((resolve, reject) => { const sandbox = { __private: { executionContext, log: migrationLogger, resolve, reject, sqlConnection: sqlConnection }, __dirname: path.dirname(migrationJavaScript.file), __filename: migrationJavaScript.file }; const script = new vm.Script(`${migrationJavaScript.content} Promise.resolve().then(() => migration(__private.executionContext, __private.sqlConnection, __private.log)).then(__private.resolve).catch(__private.reject);`, { filename: migrationJavaScript.file } ); script.runInNewContext(sandbox, { displayErrors: false }); }); } protected async _executeMigrationSql( executionContext: FExecutionContext, sqlConnection: FSqlConnection, migrationLogger: FLogger, sqlText: string ): Promise<void> { migrationLogger.trace(executionContext, EOL + sqlText); await sqlConnection.statement(sqlText).execute(executionContext); } protected async _getRollbackScripts( executionContext: FExecutionContext, _sqlConnection: FSqlConnection, _version: string ): Promise<Array<FSqlMigrationSources.Script>> { // TODO Make abstract method this.logger.fatal(executionContext, "_getRollbackScripts: Not implemented yet"); return []; } protected async _insertRollbackScripts( executionContext: FExecutionContext, _sqlConnection: FSqlConnection, _version: string, _scripts: ReadonlyArray<FSqlMigrationSources.Script> ): Promise<void> { // TODO Make abstract method this.logger.fatal(executionContext, "_insertRollbackScripts: Not implemented yet"); } protected abstract _insertVersionLog( executionContext: FExecutionContext, sqlConnection: FSqlConnection, version: string, logText: string ): Promise<void>; protected abstract _isVersionLogExist( executionContext: FExecutionContext, sqlConnection: FSqlConnection, version: string ): Promise<boolean>; protected abstract _isVersionTableExist( executionContext: FExecutionContext, sqlConnection: FSqlConnection ): Promise<boolean>; protected async _listVersions( executionContext: FExecutionContext, _sqlConnection: FSqlConnection ): Promise<Array<string>> { // TODO Make abstract method this.logger.fatal(executionContext, "_listVersions: Not implemented yet"); return []; } protected abstract _removeVersionLog( executionContext: FExecutionContext, sqlConnection: FSqlConnection, version: string ): Promise<void>; protected abstract _verifyVersionTableStructure( executionContext: FExecutionContext, sqlConnection: FSqlConnection ): Promise<void>; } export namespace FSqlMigrationManager { export interface Opts { readonly sqlConnectionFactory: FSqlConnectionFactory; /** * Name of version table. Default `__migration`. */ readonly versionTableName?: string; } export class MigrationException extends FException { } export class WrongMigrationDataException extends MigrationException { } export class MigrationLogger extends FLoggerBase { private readonly _wrap: FLogger; private readonly _lines: Array<string>; public constructor(wrap: FLogger) { super(wrap.name!); this._lines = []; this._wrap = wrap; } public flush(): string { // Join and empty _lines return this._lines.splice(0).join(EOL); } public override writeToOutput( level: FLoggerLevel, labelValues: ReadonlyArray<FLoggerLabelValue>, message: string, ex?: FException, ): void { let levelTxt: string; switch (level) { case FLoggerLevel.DEBUG: levelTxt = "[DEBUG]"; break; case FLoggerLevel.INFO: levelTxt = "[INFO]"; break; case FLoggerLevel.WARN: levelTxt = "[WARN]"; break; case FLoggerLevel.ERROR: levelTxt = "[ERROR]"; break; case FLoggerLevel.FATAL: levelTxt = "[FATAL]"; break; default: levelTxt = "[TRACE]"; break; } this._wrap.log(labelValues, level, message, ex); this._lines.push(`${levelTxt} ${message}`); } protected override isLevelEnabled(_level: FLoggerLevel): boolean { return true; // this produce [TRACE] logs } } }