mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-05-24 01:13:00 -04:00
200 lines
7.3 KiB
JavaScript
200 lines
7.3 KiB
JavaScript
const { Umzug, SequelizeStorage } = require('umzug')
|
|
const { Sequelize } = require('sequelize')
|
|
const semver = require('semver')
|
|
const path = require('path')
|
|
const fs = require('../libs/fsExtra')
|
|
const Logger = require('../Logger')
|
|
|
|
class MigrationManager {
|
|
constructor(sequelize, configPath = global.configPath) {
|
|
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.')
|
|
}
|
|
this.configPath = configPath
|
|
this.migrationsDir = null
|
|
this.maxVersion = null
|
|
this.databaseVersion = null
|
|
this.serverVersion = null
|
|
this.umzug = null
|
|
}
|
|
|
|
async runMigrations(serverVersion) {
|
|
await this.init(serverVersion)
|
|
|
|
const versionCompare = semver.compare(this.serverVersion, this.databaseVersion)
|
|
if (versionCompare == 0) {
|
|
Logger.info('[MigrationManager] Database is already up to date.')
|
|
return
|
|
}
|
|
|
|
const migrations = await this.umzug.migrations()
|
|
const executedMigrations = (await this.umzug.executed()).map((m) => m.name)
|
|
|
|
const migrationDirection = versionCompare == 1 ? 'up' : 'down'
|
|
|
|
let migrationsToRun = []
|
|
migrationsToRun = this.findMigrationsToRun(migrations, executedMigrations, migrationDirection)
|
|
|
|
// Only proceed with migration if there are migrations to run
|
|
if (migrationsToRun.length > 0) {
|
|
const originalDbPath = path.join(this.configPath, 'absdatabase.sqlite')
|
|
const backupDbPath = path.join(this.configPath, 'absdatabase.backup.sqlite')
|
|
try {
|
|
Logger.info(`[MigrationManager] Migrating database ${migrationDirection} to version ${this.serverVersion}`)
|
|
Logger.info(`[MigrationManager] Migrations to run: ${migrationsToRun.join(', ')}`)
|
|
// Create a backup copy of the SQLite database before starting migrations
|
|
await fs.copy(originalDbPath, backupDbPath)
|
|
Logger.info('Created a backup of the original database.')
|
|
|
|
// Run migrations
|
|
await this.umzug[migrationDirection]({ migrations: migrationsToRun })
|
|
|
|
// Clean up the backup
|
|
await fs.remove(backupDbPath)
|
|
|
|
Logger.info('[MigrationManager] Migrations successfully applied to the original database.')
|
|
} catch (error) {
|
|
Logger.error('[MigrationManager] Migration failed:', error)
|
|
|
|
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')
|
|
await fs.move(originalDbPath, failedDbPath, { overwrite: true })
|
|
await fs.move(backupDbPath, originalDbPath, { overwrite: true })
|
|
|
|
Logger.info('[MigrationManager] Restored the original database from the backup.')
|
|
Logger.info('[MigrationManager] Saved the failed database as absdatabase.failed.sqlite.')
|
|
|
|
process.exit(1)
|
|
}
|
|
} else {
|
|
Logger.info('[MigrationManager] No migrations to run.')
|
|
}
|
|
}
|
|
|
|
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.', error)
|
|
}
|
|
|
|
try {
|
|
await this.updateMaxVersion(serverVersion)
|
|
} catch (error) {
|
|
throw new Error('Failed to update max version in the database.', error)
|
|
}
|
|
}
|
|
|
|
// Step 4: Initialize the Umzug instance
|
|
if (!this.umzug) {
|
|
// This check is for dependency injection in tests
|
|
const cwd = this.migrationsDir
|
|
|
|
const parent = new Umzug({
|
|
migrations: {
|
|
glob: ['*.js', { cwd }]
|
|
},
|
|
context: this.sequelize.getQueryInterface(),
|
|
storage: umzugStorage,
|
|
logger: Logger.info
|
|
})
|
|
|
|
// Sort migrations by version
|
|
this.umzug = new Umzug({
|
|
...parent.options,
|
|
migrations: async () =>
|
|
(await parent.migrations()).sort((a, b) => {
|
|
const versionA = this.extractVersionFromTag(a.name)
|
|
const versionB = this.extractVersionFromTag(b.name)
|
|
return semver.compare(versionA, versionB)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
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' },
|
|
type: Sequelize.QueryTypes.SELECT
|
|
})
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
extractVersionFromTag(tag) {
|
|
if (!tag) return null
|
|
const versionMatch = tag.match(/^v?(\d+\.\d+\.\d+)/)
|
|
return versionMatch ? versionMatch[1] : null
|
|
}
|
|
|
|
async copyMigrationsToConfigDir() {
|
|
const migrationsSourceDir = path.join(__dirname, '..', 'migrations')
|
|
|
|
await fs.ensureDir(this.migrationsDir) // Ensure the target directory exists
|
|
|
|
const files = await fs.readdir(migrationsSourceDir)
|
|
await Promise.all(
|
|
files
|
|
.filter((file) => path.extname(file) === '.js')
|
|
.map(async (file) => {
|
|
const sourceFile = path.join(migrationsSourceDir, file)
|
|
const targetFile = path.join(this.migrationsDir, file)
|
|
await fs.copy(sourceFile, targetFile) // Asynchronously copy the files
|
|
})
|
|
)
|
|
}
|
|
|
|
findMigrationsToRun(migrations, executedMigrations, direction) {
|
|
const migrationsToRun = migrations
|
|
.filter((migration) => {
|
|
const migrationVersion = this.extractVersionFromTag(migration.name)
|
|
if (direction === 'up') {
|
|
return semver.gt(migrationVersion, this.databaseVersion) && semver.lte(migrationVersion, this.serverVersion) && !executedMigrations.includes(migration.name)
|
|
} else {
|
|
// A down migration should be run even if the associated up migration wasn't executed before
|
|
return semver.lte(migrationVersion, this.databaseVersion) && semver.gt(migrationVersion, this.serverVersion)
|
|
}
|
|
})
|
|
.map((migration) => migration.name)
|
|
if (direction === 'down') {
|
|
return migrationsToRun.reverse()
|
|
} else {
|
|
return migrationsToRun
|
|
}
|
|
}
|
|
|
|
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
|
|
})
|
|
this.maxVersion = this.serverVersion
|
|
}
|
|
}
|
|
|
|
module.exports = MigrationManager
|