2024-09-08 21:33:32 +03:00
const { Umzug , SequelizeStorage } = require ( '../libs/umzug' )
2024-09-07 22:24:19 +03:00
const { Sequelize , DataTypes } = require ( 'sequelize' )
2024-09-04 12:48:10 +03:00
const semver = require ( 'semver' )
const path = require ( 'path' )
2024-09-07 22:24:19 +03:00
const Module = require ( 'module' )
2024-09-04 12:48:10 +03:00
const fs = require ( '../libs/fsExtra' )
const Logger = require ( '../Logger' )
class MigrationManager {
2024-09-07 22:24:19 +03:00
static MIGRATIONS _META _TABLE = 'migrationsMeta'
2024-09-10 15:57:07 -05:00
/ * *
* @ param { import ( '../Database' ) . sequelize } sequelize
2024-09-14 08:01:32 +03:00
* @ param { boolean } isDatabaseNew
2024-09-10 15:57:07 -05:00
* @ param { string } [ configPath ]
* /
2024-09-14 08:01:32 +03:00
constructor ( sequelize , isDatabaseNew , configPath = global . configPath ) {
2024-09-07 22:24:19 +03:00
if ( ! sequelize || ! ( sequelize instanceof Sequelize ) ) throw new Error ( 'Sequelize instance is required for MigrationManager.' )
2024-09-04 12:48:10 +03:00
this . sequelize = sequelize
2024-09-14 08:01:32 +03:00
this . isDatabaseNew = isDatabaseNew
2024-09-07 22:24:19 +03:00
if ( ! configPath ) throw new Error ( 'Config path is required for MigrationManager.' )
2024-09-04 12:48:10 +03:00
this . configPath = configPath
2024-09-07 22:24:19 +03:00
this . migrationsSourceDir = path . join ( _ _dirname , '..' , 'migrations' )
this . initialized = false
2024-09-04 12:48:10 +03:00
this . migrationsDir = null
this . maxVersion = null
this . databaseVersion = null
this . serverVersion = null
this . umzug = null
}
2024-09-10 15:57:07 -05:00
/ * *
* Init version vars and copy migration files to config dir if necessary
*
* @ param { string } serverVersion
* /
2024-09-07 22:24:19 +03:00
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' )
2025-12-01 18:00:34 +02:00
try {
await fs . ensureDir ( this . migrationsDir )
} catch ( error ) {
Logger . error ( ` [MigrationManager] Failed to create migrations directory at " ${ this . migrationsDir } ": ${ error . message } ` )
throw new Error ( ` [MigrationManager] Failed to create migrations directory at " ${ this . migrationsDir } " ` , { cause : error } )
}
2024-09-07 22:24:19 +03:00
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.' )
2024-09-14 08:01:32 +03:00
Logger . debug ( ` [MigrationManager] Database version: ${ this . databaseVersion } , Max version: ${ this . maxVersion } , Server version: ${ this . serverVersion } ` )
2024-09-07 22:24:19 +03:00
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.' )
2024-09-04 12:48:10 +03:00
2024-09-14 08:01:32 +03:00
if ( this . isDatabaseNew ) {
Logger . info ( '[MigrationManager] Database is new. Skipping migrations.' )
return
}
2024-09-04 12:48:10 +03:00
const versionCompare = semver . compare ( this . serverVersion , this . databaseVersion )
if ( versionCompare == 0 ) {
Logger . info ( '[MigrationManager] Database is already up to date.' )
return
}
2024-09-08 21:33:32 +03:00
await this . initUmzug ( )
2024-09-04 12:48:10 +03:00
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 ) {
2026-03-02 16:52:24 -05:00
const dialect = typeof this . sequelize . getDialect === 'function' ? this . sequelize . getDialect ( ) : 'sqlite'
const isSqlite = ! dialect || dialect === 'sqlite'
2024-09-04 12:48:10 +03:00
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 ( ', ' ) } ` )
2026-03-02 16:52:24 -05:00
if ( isSqlite ) {
// 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.' )
}
2024-09-04 12:48:10 +03:00
// Run migrations
2024-09-07 22:24:19 +03:00
await this . umzug [ migrationDirection ] ( { migrations : migrationsToRun , rerun : 'ALLOW' } )
2024-09-04 12:48:10 +03:00
2026-03-02 16:52:24 -05:00
if ( isSqlite ) {
// Clean up the backup
await fs . remove ( backupDbPath )
}
2024-09-04 12:48:10 +03:00
Logger . info ( '[MigrationManager] Migrations successfully applied to the original database.' )
} catch ( error ) {
Logger . error ( '[MigrationManager] Migration failed:' , error )
2024-09-07 22:24:19 +03:00
await this . sequelize . close ( )
2024-09-04 12:48:10 +03:00
2026-03-02 16:52:24 -05:00
if ( isSqlite ) {
// 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.' )
2024-09-04 12:48:10 +03:00
2026-03-02 16:52:24 -05:00
await fs . move ( backupDbPath , originalDbPath , { overwrite : true } )
Logger . info ( '[MigrationManager] Restored the original database from the backup.' )
}
2024-09-04 12:48:10 +03:00
2024-09-08 21:33:32 +03:00
Logger . info ( '[MigrationManager] Migration failed. Exiting Audiobookshelf with code 1.' )
2024-09-04 12:48:10 +03:00
process . exit ( 1 )
}
} else {
Logger . info ( '[MigrationManager] No migrations to run.' )
}
2024-09-07 22:24:19 +03:00
await this . updateDatabaseVersion ( )
}
2024-09-04 12:48:10 +03:00
2024-09-08 21:33:32 +03:00
async initUmzug ( umzugStorage = new SequelizeStorage ( { sequelize : this . sequelize } ) ) {
// This check is for dependency injection in tests
2025-03-06 17:24:33 -06:00
const files = ( await fs . readdir ( this . migrationsDir ) )
. filter ( ( file ) => {
// Only include .js files and exclude dot files
return ! file . startsWith ( '.' ) && path . extname ( file ) . toLowerCase ( ) === '.js'
} )
. map ( ( file ) => path . join ( this . migrationsDir , file ) )
// Validate migration names
for ( const file of files ) {
const migrationName = path . basename ( file , path . extname ( file ) )
const migrationVersion = this . extractVersionFromTag ( migrationName )
if ( ! migrationVersion ) {
throw new Error ( ` Invalid migration file: " ${ migrationName } ". Unable to extract version from filename. ` )
}
}
2024-09-08 21:33:32 +03:00
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
2024-09-07 22:24:19 +03:00
}
2024-09-08 21:33:32 +03:00
}
} ,
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 )
} )
} )
2024-09-04 12:48:10 +03:00
}
async fetchVersionsFromDatabase ( ) {
2026-03-02 16:52:24 -05:00
const migrationsMetaTable = ` " ${ MigrationManager . MIGRATIONS _META _TABLE } " `
2024-09-07 22:24:19 +03:00
await this . checkOrCreateMigrationsMetaTable ( )
2026-03-02 17:03:03 -05:00
const [ versionRow ] = await this . sequelize . query ( ` SELECT value as version FROM ${ migrationsMetaTable } WHERE key = 'version' ` , {
2024-09-04 12:48:10 +03:00
type : Sequelize . QueryTypes . SELECT
} )
2026-03-02 17:03:03 -05:00
this . databaseVersion = versionRow ? . version
2024-09-04 12:48:10 +03:00
2026-03-02 17:03:03 -05:00
const [ maxVersionRow ] = await this . sequelize . query ( ` SELECT value as maxVersion FROM ${ migrationsMetaTable } WHERE key = 'maxVersion' ` , {
2024-09-07 22:24:19 +03:00
type : Sequelize . QueryTypes . SELECT
} )
2026-03-02 17:03:03 -05:00
this . maxVersion = maxVersionRow ? . maxVersion || maxVersionRow ? . maxversion
2024-09-07 22:24:19 +03:00
}
async checkOrCreateMigrationsMetaTable ( ) {
const queryInterface = this . sequelize . getQueryInterface ( )
2026-03-02 16:58:07 -05:00
let migrationsMetaTableExists = await this . tableExists ( MigrationManager . MIGRATIONS _META _TABLE )
2024-09-14 08:01:32 +03:00
2024-11-06 22:06:58 -07:00
// If the table exists, check that the `version` and `maxVersion` rows exist
if ( migrationsMetaTableExists ) {
2026-03-02 16:52:24 -05:00
const [ { count } ] = await this . sequelize . query ( ` SELECT COUNT(*) as count FROM " ${ MigrationManager . MIGRATIONS _META _TABLE } " WHERE key IN ('version', 'maxVersion') ` , {
2024-11-06 22:06:58 -07:00
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
}
}
2024-09-14 08:01:32 +03:00
if ( this . isDatabaseNew && migrationsMetaTableExists ) {
2024-11-06 22:06:58 -07:00
Logger . warn ( ` [MigrationManager] migrationsMeta table already exists. Dropping it... ` )
2024-09-14 08:01:32 +03:00
// This can happen if database was initialized with force: true
await queryInterface . dropTable ( MigrationManager . MIGRATIONS _META _TABLE )
migrationsMetaTableExists = false
}
if ( ! migrationsMetaTableExists ) {
2024-09-07 22:24:19 +03:00
await queryInterface . createTable ( MigrationManager . MIGRATIONS _META _TABLE , {
key : {
type : DataTypes . STRING ,
allowNull : false
} ,
value : {
type : DataTypes . STRING ,
allowNull : false
}
} )
2026-03-02 16:52:24 -05:00
await this . sequelize . query ( ` INSERT INTO " ${ MigrationManager . MIGRATIONS _META _TABLE } " (key, value) VALUES ('version', :version), ('maxVersion', '0.0.0') ` , {
replacements : { version : this . isDatabaseNew ? this . serverVersion : '0.0.0' } ,
2024-09-07 22:24:19 +03:00
type : Sequelize . QueryTypes . INSERT
} )
2024-09-14 08:01:32 +03:00
Logger . debug ( ` [MigrationManager] Created migrationsMeta table: " ${ MigrationManager . MIGRATIONS _META _TABLE } " ` )
2024-09-04 12:48:10 +03:00
}
}
2026-03-02 16:58:07 -05:00
async tableExists ( tableName ) {
const queryInterface = this . sequelize . getQueryInterface ( )
if ( typeof queryInterface . tableExists === 'function' ) {
return queryInterface . tableExists ( tableName )
}
const tables = await queryInterface . showAllTables ( )
return tables . some ( ( table ) => {
if ( typeof table === 'string' ) return table === tableName
if ( table ? . tableName ) return table . tableName === tableName
return false
} )
}
2024-09-04 12:48:10 +03:00
extractVersionFromTag ( tag ) {
if ( ! tag ) return null
const versionMatch = tag . match ( /^v?(\d+\.\d+\.\d+)/ )
return versionMatch ? versionMatch [ 1 ] : null
}
async copyMigrationsToConfigDir ( ) {
2024-09-07 22:24:19 +03:00
if ( ! ( await fs . pathExists ( this . migrationsSourceDir ) ) ) return
2024-09-04 23:55:16 +03:00
2024-09-07 22:24:19 +03:00
const files = await fs . readdir ( this . migrationsSourceDir )
2024-09-04 12:48:10 +03:00
await Promise . all (
files
. filter ( ( file ) => path . extname ( file ) === '.js' )
. map ( async ( file ) => {
2024-09-07 22:24:19 +03:00
const sourceFile = path . join ( this . migrationsSourceDir , file )
2024-09-04 12:48:10 +03:00
const targetFile = path . join ( this . migrationsDir , file )
await fs . copy ( sourceFile , targetFile ) // Asynchronously copy the files
} )
)
2024-09-14 08:01:32 +03:00
Logger . debug ( ` [MigrationManager] Copied migrations to the config directory: " ${ this . migrationsDir } " ` )
2024-09-04 12:48:10 +03:00
}
2024-09-10 15:57:07 -05:00
/ * *
*
* @ param { { name : string } [ ] } migrations
* @ param { string [ ] } executedMigrations - names of executed migrations
* @ param { string } direction - 'up' or 'down'
* @ returns { string [ ] } - names of migrations to run
* /
2024-09-04 12:48:10 +03:00
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
}
}
2024-09-07 22:24:19 +03:00
async updateMaxVersion ( ) {
try {
2026-03-02 16:52:24 -05:00
await this . sequelize . query ( ` UPDATE " ${ MigrationManager . MIGRATIONS _META _TABLE } " SET value = :maxVersion WHERE key = 'maxVersion' ` , {
replacements : { maxVersion : this . serverVersion } ,
2024-09-07 22:24:19 +03:00
type : Sequelize . QueryTypes . UPDATE
} )
} catch ( error ) {
throw new Error ( 'Failed to update maxVersion in the migrationsMeta table.' , { cause : error } )
}
2024-09-04 12:48:10 +03:00
this . maxVersion = this . serverVersion
}
2024-09-07 22:24:19 +03:00
async updateDatabaseVersion ( ) {
try {
2026-03-02 16:52:24 -05:00
await this . sequelize . query ( ` UPDATE " ${ MigrationManager . MIGRATIONS _META _TABLE } " SET value = :version WHERE key = 'version' ` , {
replacements : { version : this . serverVersion } ,
2024-09-07 22:24:19 +03:00
type : Sequelize . QueryTypes . UPDATE
} )
} catch ( error ) {
throw new Error ( 'Failed to update version in the migrationsMeta table.' , { cause : error } )
}
this . databaseVersion = this . serverVersion
}
2024-09-04 12:48:10 +03:00
}
module . exports = MigrationManager