Make migration management more robust

This commit is contained in:
mikiher
2024-09-07 22:24:19 +03:00
parent b3ce300d32
commit 8a28029809
6 changed files with 222 additions and 160 deletions

View File

@@ -1,20 +1,21 @@
const { Umzug, SequelizeStorage } = require('umzug')
const { Sequelize } = require('sequelize')
const { Sequelize, DataTypes } = require('sequelize')
const semver = require('semver')
const path = require('path')
const Module = require('module')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
class MigrationManager {
static MIGRATIONS_META_TABLE = 'migrationsMeta'
constructor(sequelize, configPath = global.configPath) {
if (!sequelize || !(sequelize instanceof Sequelize)) {
throw new Error('Sequelize instance is required for MigrationManager.')
}
if (!sequelize || !(sequelize instanceof Sequelize)) throw new Error('Sequelize instance is required for MigrationManager.')
this.sequelize = sequelize
if (!configPath) {
throw new Error('Config path is required for MigrationManager.')
}
if (!configPath) throw new Error('Config path is required for MigrationManager.')
this.configPath = configPath
this.migrationsSourceDir = path.join(__dirname, '..', 'migrations')
this.initialized = false
this.migrationsDir = null
this.maxVersion = null
this.databaseVersion = null
@@ -22,8 +23,36 @@ class MigrationManager {
this.umzug = null
}
async runMigrations(serverVersion) {
await this.init(serverVersion)
async init(serverVersion) {
if (!(await fs.pathExists(this.configPath))) throw new Error(`Config path does not exist: ${this.configPath}`)
this.migrationsDir = path.join(this.configPath, 'migrations')
this.serverVersion = this.extractVersionFromTag(serverVersion)
if (!this.serverVersion) throw new Error(`Invalid server version: ${serverVersion}. Expected a version tag like v1.2.3.`)
await this.fetchVersionsFromDatabase()
if (!this.maxVersion || !this.databaseVersion) throw new Error('Failed to fetch versions from the database.')
if (semver.gt(this.serverVersion, this.maxVersion)) {
try {
await this.copyMigrationsToConfigDir()
} catch (error) {
throw new Error('Failed to copy migrations to the config directory.', { cause: error })
}
try {
await this.updateMaxVersion()
} catch (error) {
throw new Error('Failed to update max version in the database.', { cause: error })
}
}
this.initialized = true
}
async runMigrations() {
if (!this.initialized) throw new Error('MigrationManager is not initialized. Call init() first.')
const versionCompare = semver.compare(this.serverVersion, this.databaseVersion)
if (versionCompare == 0) {
@@ -31,6 +60,7 @@ class MigrationManager {
return
}
this.initUmzug()
const migrations = await this.umzug.migrations()
const executedMigrations = (await this.umzug.executed()).map((m) => m.name)
@@ -51,7 +81,7 @@ class MigrationManager {
Logger.info('Created a backup of the original database.')
// Run migrations
await this.umzug[migrationDirection]({ migrations: migrationsToRun })
await this.umzug[migrationDirection]({ migrations: migrationsToRun, rerun: 'ALLOW' })
// Clean up the backup
await fs.remove(backupDbPath)
@@ -60,7 +90,7 @@ class MigrationManager {
} catch (error) {
Logger.error('[MigrationManager] Migration failed:', error)
this.sequelize.close()
await this.sequelize.close()
// Step 3: If migration fails, save the failed original and restore the backup
const failedDbPath = path.join(this.configPath, 'absdatabase.failed.sqlite')
@@ -75,45 +105,40 @@ class MigrationManager {
} else {
Logger.info('[MigrationManager] No migrations to run.')
}
await this.updateDatabaseVersion()
}
async init(serverVersion, umzugStorage = new SequelizeStorage({ sequelize: this.sequelize })) {
if (!(await fs.pathExists(this.configPath))) throw new Error(`Config path does not exist: ${this.configPath}`)
this.migrationsDir = path.join(this.configPath, 'migrations')
this.serverVersion = this.extractVersionFromTag(serverVersion)
if (!this.serverVersion) throw new Error(`Invalid server version: ${serverVersion}. Expected a version tag like v1.2.3.`)
await this.fetchVersionsFromDatabase()
if (!this.maxVersion || !this.databaseVersion) throw new Error('Failed to fetch versions from the database.')
if (semver.gt(this.serverVersion, this.maxVersion)) {
try {
await this.copyMigrationsToConfigDir()
} catch (error) {
throw new Error('Failed to copy migrations to the config directory.', { cause: error })
}
try {
await this.updateMaxVersion(serverVersion)
} catch (error) {
throw new Error('Failed to update max version in the database.', { cause: error })
}
}
// Step 4: Initialize the Umzug instance
initUmzug(umzugStorage = new SequelizeStorage({ sequelize: this.sequelize })) {
if (!this.umzug) {
// This check is for dependency injection in tests
const cwd = this.migrationsDir
const parent = new Umzug({
migrations: {
glob: ['*.js', { cwd }]
glob: ['*.js', { cwd }],
resolve: (params) => {
// make script think it's in migrationsSourceDir
const migrationPath = params.path
const migrationName = params.name
const contents = fs.readFileSync(migrationPath, 'utf8')
const fakePath = path.join(this.migrationsSourceDir, path.basename(migrationPath))
const module = new Module(fakePath)
module.filename = fakePath
module.paths = Module._nodeModulePaths(this.migrationsSourceDir)
module._compile(contents, fakePath)
const script = module.exports
return {
name: migrationName,
path: migrationPath,
up: script.up,
down: script.down
}
}
},
context: this.sequelize.getQueryInterface(),
context: { queryInterface: this.sequelize.getQueryInterface(), logger: Logger },
storage: umzugStorage,
logger: Logger.info
logger: Logger
})
// Sort migrations by version
@@ -130,18 +155,38 @@ class MigrationManager {
}
async fetchVersionsFromDatabase() {
const [result] = await this.sequelize.query("SELECT json_extract(value, '$.version') AS version, json_extract(value, '$.maxVersion') AS maxVersion FROM settings WHERE key = :key", {
replacements: { key: 'server-settings' },
await this.checkOrCreateMigrationsMetaTable()
const [{ version }] = await this.sequelize.query("SELECT value as version FROM :migrationsMeta WHERE key = 'version'", {
replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
type: Sequelize.QueryTypes.SELECT
})
this.databaseVersion = version
if (result) {
try {
this.maxVersion = this.extractVersionFromTag(result.maxVersion) || '0.0.0'
this.databaseVersion = this.extractVersionFromTag(result.version)
} catch (error) {
Logger.error('[MigrationManager] Failed to parse server settings from the database.', error)
}
const [{ maxVersion }] = await this.sequelize.query("SELECT value as maxVersion FROM :migrationsMeta WHERE key = 'maxVersion'", {
replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
type: Sequelize.QueryTypes.SELECT
})
this.maxVersion = maxVersion
}
async checkOrCreateMigrationsMetaTable() {
const queryInterface = this.sequelize.getQueryInterface()
if (!(await queryInterface.tableExists(MigrationManager.MIGRATIONS_META_TABLE))) {
await queryInterface.createTable(MigrationManager.MIGRATIONS_META_TABLE, {
key: {
type: DataTypes.STRING,
allowNull: false
},
value: {
type: DataTypes.STRING,
allowNull: false
}
})
await this.sequelize.query("INSERT INTO :migrationsMeta (key, value) VALUES ('version', :version), ('maxVersion', '0.0.0')", {
replacements: { version: this.serverVersion, migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
type: Sequelize.QueryTypes.INSERT
})
}
}
@@ -152,18 +197,16 @@ class MigrationManager {
}
async copyMigrationsToConfigDir() {
const migrationsSourceDir = path.join(__dirname, '..', 'migrations')
await fs.ensureDir(this.migrationsDir) // Ensure the target directory exists
if (!(await fs.pathExists(migrationsSourceDir))) return
if (!(await fs.pathExists(this.migrationsSourceDir))) return
const files = await fs.readdir(migrationsSourceDir)
const files = await fs.readdir(this.migrationsSourceDir)
await Promise.all(
files
.filter((file) => path.extname(file) === '.js')
.map(async (file) => {
const sourceFile = path.join(migrationsSourceDir, file)
const sourceFile = path.join(this.migrationsSourceDir, file)
const targetFile = path.join(this.migrationsDir, file)
await fs.copy(sourceFile, targetFile) // Asynchronously copy the files
})
@@ -189,13 +232,29 @@ class MigrationManager {
}
}
async updateMaxVersion(serverVersion) {
await this.sequelize.query("UPDATE settings SET value = JSON_SET(value, '$.maxVersion', ?) WHERE key = 'server-settings'", {
replacements: [serverVersion],
type: Sequelize.QueryTypes.UPDATE
})
async updateMaxVersion() {
try {
await this.sequelize.query("UPDATE :migrationsMeta SET value = :maxVersion WHERE key = 'maxVersion'", {
replacements: { maxVersion: this.serverVersion, migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
type: Sequelize.QueryTypes.UPDATE
})
} catch (error) {
throw new Error('Failed to update maxVersion in the migrationsMeta table.', { cause: error })
}
this.maxVersion = this.serverVersion
}
async updateDatabaseVersion() {
try {
await this.sequelize.query("UPDATE :migrationsMeta SET value = :version WHERE key = 'version'", {
replacements: { version: this.serverVersion, migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
type: Sequelize.QueryTypes.UPDATE
})
} catch (error) {
throw new Error('Failed to update version in the migrationsMeta table.', { cause: error })
}
this.databaseVersion = this.serverVersion
}
}
module.exports = MigrationManager