mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			307 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			307 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| const { Umzug, SequelizeStorage } = require('../libs/umzug')
 | |
| 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'
 | |
| 
 | |
|   /**
 | |
|    * @param {import('../Database').sequelize} sequelize
 | |
|    * @param {boolean} isDatabaseNew
 | |
|    * @param {string} [configPath]
 | |
|    */
 | |
|   constructor(sequelize, isDatabaseNew, configPath = global.configPath) {
 | |
|     if (!sequelize || !(sequelize instanceof Sequelize)) throw new Error('Sequelize instance is required for MigrationManager.')
 | |
|     this.sequelize = sequelize
 | |
|     this.isDatabaseNew = isDatabaseNew
 | |
|     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
 | |
|     this.serverVersion = null
 | |
|     this.umzug = null
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Init version vars and copy migration files to config dir if necessary
 | |
|    *
 | |
|    * @param {string} 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')
 | |
|     await fs.ensureDir(this.migrationsDir)
 | |
| 
 | |
|     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.')
 | |
|     Logger.debug(`[MigrationManager] Database version: ${this.databaseVersion}, Max version: ${this.maxVersion}, Server version: ${this.serverVersion}`)
 | |
| 
 | |
|     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.')
 | |
| 
 | |
|     if (this.isDatabaseNew) {
 | |
|       Logger.info('[MigrationManager] Database is new. Skipping migrations.')
 | |
|       return
 | |
|     }
 | |
| 
 | |
|     const versionCompare = semver.compare(this.serverVersion, this.databaseVersion)
 | |
|     if (versionCompare == 0) {
 | |
|       Logger.info('[MigrationManager] Database is already up to date.')
 | |
|       return
 | |
|     }
 | |
| 
 | |
|     await this.initUmzug()
 | |
|     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, rerun: 'ALLOW' })
 | |
| 
 | |
|         // 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)
 | |
| 
 | |
|         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')
 | |
|         await fs.move(originalDbPath, failedDbPath, { overwrite: true })
 | |
|         Logger.info('[MigrationManager] Saved the failed database as absdatabase.failed.sqlite.')
 | |
| 
 | |
|         await fs.move(backupDbPath, originalDbPath, { overwrite: true })
 | |
|         Logger.info('[MigrationManager] Restored the original database from the backup.')
 | |
| 
 | |
|         Logger.info('[MigrationManager] Migration failed. Exiting Audiobookshelf with code 1.')
 | |
|         process.exit(1)
 | |
|       }
 | |
|     } else {
 | |
|       Logger.info('[MigrationManager] No migrations to run.')
 | |
|     }
 | |
| 
 | |
|     await this.updateDatabaseVersion()
 | |
|   }
 | |
| 
 | |
|   async initUmzug(umzugStorage = new SequelizeStorage({ sequelize: this.sequelize })) {
 | |
|     // This check is for dependency injection in tests
 | |
|     const files = (await fs.readdir(this.migrationsDir)).filter((file) => !file.startsWith('.')).map((file) => path.join(this.migrationsDir, file))
 | |
| 
 | |
|     const parent = new Umzug({
 | |
|       migrations: {
 | |
|         files,
 | |
|         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: { queryInterface: this.sequelize.getQueryInterface(), logger: Logger },
 | |
|       storage: umzugStorage,
 | |
|       logger: Logger
 | |
|     })
 | |
| 
 | |
|     // 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() {
 | |
|     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
 | |
| 
 | |
|     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()
 | |
|     let migrationsMetaTableExists = await queryInterface.tableExists(MigrationManager.MIGRATIONS_META_TABLE)
 | |
| 
 | |
|     // If the table exists, check that the `version` and `maxVersion` rows exist
 | |
|     if (migrationsMetaTableExists) {
 | |
|       const [{ count }] = await this.sequelize.query("SELECT COUNT(*) as count FROM :migrationsMeta WHERE key IN ('version', 'maxVersion')", {
 | |
|         replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
 | |
|         type: Sequelize.QueryTypes.SELECT
 | |
|       })
 | |
|       if (count < 2) {
 | |
|         Logger.warn(`[MigrationManager] migrationsMeta table exists but is missing 'version' or 'maxVersion' row. Dropping it...`)
 | |
|         await queryInterface.dropTable(MigrationManager.MIGRATIONS_META_TABLE)
 | |
|         migrationsMetaTableExists = false
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     if (this.isDatabaseNew && migrationsMetaTableExists) {
 | |
|       Logger.warn(`[MigrationManager] migrationsMeta table already exists. Dropping it...`)
 | |
|       // This can happen if database was initialized with force: true
 | |
|       await queryInterface.dropTable(MigrationManager.MIGRATIONS_META_TABLE)
 | |
|       migrationsMetaTableExists = false
 | |
|     }
 | |
| 
 | |
|     if (!migrationsMetaTableExists) {
 | |
|       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.isDatabaseNew ? this.serverVersion : '0.0.0', migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
 | |
|         type: Sequelize.QueryTypes.INSERT
 | |
|       })
 | |
|       Logger.debug(`[MigrationManager] Created migrationsMeta table: "${MigrationManager.MIGRATIONS_META_TABLE}"`)
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   extractVersionFromTag(tag) {
 | |
|     if (!tag) return null
 | |
|     const versionMatch = tag.match(/^v?(\d+\.\d+\.\d+)/)
 | |
|     return versionMatch ? versionMatch[1] : null
 | |
|   }
 | |
| 
 | |
|   async copyMigrationsToConfigDir() {
 | |
|     if (!(await fs.pathExists(this.migrationsSourceDir))) return
 | |
| 
 | |
|     const files = await fs.readdir(this.migrationsSourceDir)
 | |
|     await Promise.all(
 | |
|       files
 | |
|         .filter((file) => path.extname(file) === '.js')
 | |
|         .map(async (file) => {
 | |
|           const sourceFile = path.join(this.migrationsSourceDir, file)
 | |
|           const targetFile = path.join(this.migrationsDir, file)
 | |
|           await fs.copy(sourceFile, targetFile) // Asynchronously copy the files
 | |
|         })
 | |
|     )
 | |
|     Logger.debug(`[MigrationManager] Copied migrations to the config directory: "${this.migrationsDir}"`)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    *
 | |
|    * @param {{ name: string }[]} migrations
 | |
|    * @param {string[]} executedMigrations - names of executed migrations
 | |
|    * @param {string} direction - 'up' or 'down'
 | |
|    * @returns {string[]} - names of migrations to run
 | |
|    */
 | |
|   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() {
 | |
|     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
 |