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

@ -170,14 +170,13 @@ class Database {
throw new Error('Database connection failed') throw new Error('Database connection failed')
} }
if (!this.isNew) { try {
try { const migrationManager = new MigrationManager(this.sequelize, global.ConfigPath)
const migrationManager = new MigrationManager(this.sequelize, global.ConfigPath) await migrationManager.init(packageJson.version)
await migrationManager.runMigrations(packageJson.version) if (!this.isNew) await migrationManager.runMigrations()
} catch (error) { } catch (error) {
Logger.error(`[Database] Failed to run migrations`, error) Logger.error(`[Database] Failed to run migrations`, error)
throw new Error('Database migration failed') throw new Error('Database migration failed')
}
} }
await this.buildModels(force) await this.buildModels(force)

View File

@ -1,20 +1,21 @@
const { Umzug, SequelizeStorage } = require('umzug') const { Umzug, SequelizeStorage } = require('umzug')
const { Sequelize } = require('sequelize') const { Sequelize, DataTypes } = require('sequelize')
const semver = require('semver') const semver = require('semver')
const path = require('path') const path = require('path')
const Module = require('module')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const Logger = require('../Logger') const Logger = require('../Logger')
class MigrationManager { class MigrationManager {
static MIGRATIONS_META_TABLE = 'migrationsMeta'
constructor(sequelize, configPath = global.configPath) { constructor(sequelize, configPath = global.configPath) {
if (!sequelize || !(sequelize instanceof Sequelize)) { if (!sequelize || !(sequelize instanceof Sequelize)) throw new Error('Sequelize instance is required for MigrationManager.')
throw new Error('Sequelize instance is required for MigrationManager.')
}
this.sequelize = sequelize this.sequelize = sequelize
if (!configPath) { if (!configPath) throw new Error('Config path is required for MigrationManager.')
throw new Error('Config path is required for MigrationManager.')
}
this.configPath = configPath this.configPath = configPath
this.migrationsSourceDir = path.join(__dirname, '..', 'migrations')
this.initialized = false
this.migrationsDir = null this.migrationsDir = null
this.maxVersion = null this.maxVersion = null
this.databaseVersion = null this.databaseVersion = null
@ -22,8 +23,36 @@ class MigrationManager {
this.umzug = null this.umzug = null
} }
async runMigrations(serverVersion) { async init(serverVersion) {
await this.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) const versionCompare = semver.compare(this.serverVersion, this.databaseVersion)
if (versionCompare == 0) { if (versionCompare == 0) {
@ -31,6 +60,7 @@ class MigrationManager {
return return
} }
this.initUmzug()
const migrations = await this.umzug.migrations() const migrations = await this.umzug.migrations()
const executedMigrations = (await this.umzug.executed()).map((m) => m.name) 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.') Logger.info('Created a backup of the original database.')
// Run migrations // Run migrations
await this.umzug[migrationDirection]({ migrations: migrationsToRun }) await this.umzug[migrationDirection]({ migrations: migrationsToRun, rerun: 'ALLOW' })
// Clean up the backup // Clean up the backup
await fs.remove(backupDbPath) await fs.remove(backupDbPath)
@ -60,7 +90,7 @@ class MigrationManager {
} catch (error) { } catch (error) {
Logger.error('[MigrationManager] Migration failed:', 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 // Step 3: If migration fails, save the failed original and restore the backup
const failedDbPath = path.join(this.configPath, 'absdatabase.failed.sqlite') const failedDbPath = path.join(this.configPath, 'absdatabase.failed.sqlite')
@ -75,45 +105,40 @@ class MigrationManager {
} else { } else {
Logger.info('[MigrationManager] No migrations to run.') Logger.info('[MigrationManager] No migrations to run.')
} }
await this.updateDatabaseVersion()
} }
async init(serverVersion, umzugStorage = new SequelizeStorage({ sequelize: this.sequelize })) { initUmzug(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
if (!this.umzug) { if (!this.umzug) {
// This check is for dependency injection in tests // This check is for dependency injection in tests
const cwd = this.migrationsDir const cwd = this.migrationsDir
const parent = new Umzug({ const parent = new Umzug({
migrations: { 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, storage: umzugStorage,
logger: Logger.info logger: Logger
}) })
// Sort migrations by version // Sort migrations by version
@ -130,18 +155,38 @@ class MigrationManager {
} }
async fetchVersionsFromDatabase() { 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", { await this.checkOrCreateMigrationsMetaTable()
replacements: { key: 'server-settings' },
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 type: Sequelize.QueryTypes.SELECT
}) })
this.databaseVersion = version
if (result) { const [{ maxVersion }] = await this.sequelize.query("SELECT value as maxVersion FROM :migrationsMeta WHERE key = 'maxVersion'", {
try { replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
this.maxVersion = this.extractVersionFromTag(result.maxVersion) || '0.0.0' type: Sequelize.QueryTypes.SELECT
this.databaseVersion = this.extractVersionFromTag(result.version) })
} catch (error) { this.maxVersion = maxVersion
Logger.error('[MigrationManager] Failed to parse server settings from the database.', error) }
}
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() { async copyMigrationsToConfigDir() {
const migrationsSourceDir = path.join(__dirname, '..', 'migrations')
await fs.ensureDir(this.migrationsDir) // Ensure the target directory exists 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( await Promise.all(
files files
.filter((file) => path.extname(file) === '.js') .filter((file) => path.extname(file) === '.js')
.map(async (file) => { .map(async (file) => {
const sourceFile = path.join(migrationsSourceDir, file) const sourceFile = path.join(this.migrationsSourceDir, file)
const targetFile = path.join(this.migrationsDir, file) const targetFile = path.join(this.migrationsDir, file)
await fs.copy(sourceFile, targetFile) // Asynchronously copy the files await fs.copy(sourceFile, targetFile) // Asynchronously copy the files
}) })
@ -189,13 +232,29 @@ class MigrationManager {
} }
} }
async updateMaxVersion(serverVersion) { async updateMaxVersion() {
await this.sequelize.query("UPDATE settings SET value = JSON_SET(value, '$.maxVersion', ?) WHERE key = 'server-settings'", { try {
replacements: [serverVersion], await this.sequelize.query("UPDATE :migrationsMeta SET value = :maxVersion WHERE key = 'maxVersion'", {
type: Sequelize.QueryTypes.UPDATE 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 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 module.exports = MigrationManager

View File

@ -15,16 +15,18 @@ When writing a migration, keep the following guidelines in mind:
- `server_version` should be the version of the server that the migration was created for (this should usually be the next server release). - `server_version` should be the version of the server that the migration was created for (this should usually be the next server release).
- `migration_name` should be a short description of the changes that the migration makes. - `migration_name` should be a short description of the changes that the migration makes.
- The script should export two async functions: `up` and `down`. The `up` function should contain the script that applies the changes to the database, and the `down` function should contain the script that undoes the changes. The `up` and `down` functions should accept a single object parameter with a `context` property that contains a reference to a Sequelize [`QueryInterface`](https://sequelize.org/docs/v6/other-topics/query-interface/) object. A typical migration script might look like this: - The script should export two async functions: `up` and `down`. The `up` function should contain the script that applies the changes to the database, and the `down` function should contain the script that undoes the changes. The `up` and `down` functions should accept a single object parameter with a `context` property that contains a reference to a Sequelize [`QueryInterface`](https://sequelize.org/docs/v6/other-topics/query-interface/) object, and a [Logger](https://github.com/advplyr/audiobookshelf/blob/423a2129d10c6d8aaac9e8c75941fa6283889602/server/Logger.js#L4) object for logging. A typical migration script might look like this:
```javascript ```javascript
async function up({context: queryInterface}) { async function up({ context: { queryInterface, logger } }) {
// Upwards migration script // Upwards migration script
logger.info('migrating ...');
... ...
} }
async function down({context: queryInterface}) { async function down({ context: { queryInterface, logger } }) {
// Downward migration script // Downward migration script
logger.info('reverting ...');
... ...
} }
@ -33,7 +35,8 @@ When writing a migration, keep the following guidelines in mind:
- Always implement both the `up` and `down` functions. - Always implement both the `up` and `down` functions.
- The `up` and `down` functions should be idempotent (i.e., they should be safe to run multiple times). - The `up` and `down` functions should be idempotent (i.e., they should be safe to run multiple times).
- It's your responsibility to make sure that the down migration undoes the changes made by the up migration. - Prefer using only `queryInterface` and `logger` parameters, the `sequelize` module, and node.js built-in modules in your migration scripts. You can require other modules, but be aware that they might not be available or change from they ones you tested with.
- It's your responsibility to make sure that the down migration reverts the changes made by the up migration.
- Log detailed information on every step of the migration. Use `Logger.info()` and `Logger.error()`. - Log detailed information on every step of the migration. Use `Logger.info()` and `Logger.error()`.
- Test tour migrations thoroughly before committing them. - Test tour migrations thoroughly before committing them.
- write unit tests for your migrations (see `test/server/migrations` for an example) - write unit tests for your migrations (see `test/server/migrations` for an example)
@ -41,6 +44,6 @@ When writing a migration, keep the following guidelines in mind:
## How migrations are run ## How migrations are run
Migrations are run automatically when the server starts, when the server detects that the server version has changed. Migrations are always run server version order (from oldest to newest up migrations if the server version increased, and from newest to oldest down migrations if the server version decreased). Only the relevant migrations are run, based on the new and old server versions. Migrations are run automatically when the server starts, when the server detects that the server version has changed. Migrations are always run in server version order (from oldest to newest up migrations if the server version increased, and from newest to oldest down migrations if the server version decreased). Only the relevant migrations are run, based on the new and old server versions.
This means that you can switch between server releases without having to worry about running migrations manually. The server will automatically apply the necessary migrations when it starts. This means that you can switch between server releases without having to worry about running migrations manually. The server will automatically apply the necessary migrations when it starts.

View File

@ -17,7 +17,6 @@ describe('MigrationManager', () => {
let fsMoveStub let fsMoveStub
let fsRemoveStub let fsRemoveStub
let fsEnsureDirStub let fsEnsureDirStub
let fsPathExistsStub
let processExitStub let processExitStub
let configPath = 'path/to/config' let configPath = 'path/to/config'
@ -36,6 +35,7 @@ describe('MigrationManager', () => {
migrationManager.fetchVersionsFromDatabase = sinon.stub().resolves() migrationManager.fetchVersionsFromDatabase = sinon.stub().resolves()
migrationManager.copyMigrationsToConfigDir = sinon.stub().resolves() migrationManager.copyMigrationsToConfigDir = sinon.stub().resolves()
migrationManager.updateMaxVersion = sinon.stub().resolves() migrationManager.updateMaxVersion = sinon.stub().resolves()
migrationManager.initUmzug = sinon.stub()
migrationManager.umzug = umzugStub migrationManager.umzug = umzugStub
loggerInfoStub = sinon.stub(Logger, 'info') loggerInfoStub = sinon.stub(Logger, 'info')
loggerErrorStub = sinon.stub(Logger, 'error') loggerErrorStub = sinon.stub(Logger, 'error')
@ -51,24 +51,59 @@ describe('MigrationManager', () => {
sinon.restore() sinon.restore()
}) })
describe('init', () => {
it('should initialize the MigrationManager', async () => {
// arrange
migrationManager.databaseVersion = '1.1.0'
migrationManager.maxVersion = '1.1.0'
migrationManager.umzug = null
migrationManager.configPath = __dirname
// Act
await migrationManager.init(serverVersion)
// Assert
expect(migrationManager.serverVersion).to.equal(serverVersion)
expect(migrationManager.sequelize).to.equal(sequelizeStub)
expect(migrationManager.migrationsDir).to.equal(path.join(__dirname, 'migrations'))
expect(migrationManager.copyMigrationsToConfigDir.calledOnce).to.be.true
expect(migrationManager.updateMaxVersion.calledOnce).to.be.true
expect(migrationManager.initialized).to.be.true
/*
expect(migrationManager.umzug).to.be.an.instanceOf(Umzug)
expect((await migrationManager.umzug.migrations()).map((m) => m.name)).to.deep.equal(['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.10.0-migration.js'])
*/
})
it('should throw error if serverVersion is not provided', async () => {
// Act
try {
const result = await migrationManager.init()
expect.fail('Expected init to throw an error, but it did not.')
} catch (error) {
expect(error.message).to.equal('Invalid server version: undefined. Expected a version tag like v1.2.3.')
}
})
})
describe('runMigrations', () => { describe('runMigrations', () => {
it('should run up migrations successfully', async () => { it('should run up migrations successfully', async () => {
// Arrange // Arrange
migrationManager.databaseVersion = '1.1.0' migrationManager.databaseVersion = '1.1.0'
migrationManager.maxVersion = '1.1.0' migrationManager.maxVersion = '1.1.0'
migrationManager.serverVersion = '1.2.0'
migrationManager.initialized = true
umzugStub.migrations.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }]) umzugStub.migrations.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }])
umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }]) umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }])
// Act // Act
await migrationManager.runMigrations('1.2.0') await migrationManager.runMigrations()
// Assert // Assert
expect(migrationManager.fetchVersionsFromDatabase.calledOnce).to.be.true expect(migrationManager.initUmzug.calledOnce).to.be.true
expect(migrationManager.copyMigrationsToConfigDir.calledOnce).to.be.true
expect(migrationManager.updateMaxVersion.calledOnce).to.be.true
expect(umzugStub.up.calledOnce).to.be.true expect(umzugStub.up.calledOnce).to.be.true
expect(umzugStub.up.calledWith({ migrations: ['v1.1.1-migration.js', 'v1.2.0-migration.js'] })).to.be.true expect(umzugStub.up.calledWith({ migrations: ['v1.1.1-migration.js', 'v1.2.0-migration.js'], rerun: 'ALLOW' })).to.be.true
expect(fsCopyStub.calledOnce).to.be.true expect(fsCopyStub.calledOnce).to.be.true
expect(fsCopyStub.calledWith(path.join(configPath, 'absdatabase.sqlite'), path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true expect(fsCopyStub.calledWith(path.join(configPath, 'absdatabase.sqlite'), path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true
expect(fsRemoveStub.calledOnce).to.be.true expect(fsRemoveStub.calledOnce).to.be.true
@ -80,19 +115,19 @@ describe('MigrationManager', () => {
// Arrange // Arrange
migrationManager.databaseVersion = '1.2.0' migrationManager.databaseVersion = '1.2.0'
migrationManager.maxVersion = '1.2.0' migrationManager.maxVersion = '1.2.0'
migrationManager.serverVersion = '1.1.0'
migrationManager.initialized = true
umzugStub.migrations.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }]) umzugStub.migrations.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }])
umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }]) umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }])
// Act // Act
await migrationManager.runMigrations('1.1.0') await migrationManager.runMigrations()
// Assert // Assert
expect(migrationManager.fetchVersionsFromDatabase.calledOnce).to.be.true expect(migrationManager.initUmzug.calledOnce).to.be.true
expect(migrationManager.copyMigrationsToConfigDir.called).to.be.false
expect(migrationManager.updateMaxVersion.called).to.be.false
expect(umzugStub.down.calledOnce).to.be.true expect(umzugStub.down.calledOnce).to.be.true
expect(umzugStub.down.calledWith({ migrations: ['v1.2.0-migration.js', 'v1.1.1-migration.js'] })).to.be.true expect(umzugStub.down.calledWith({ migrations: ['v1.2.0-migration.js', 'v1.1.1-migration.js'], rerun: 'ALLOW' })).to.be.true
expect(fsCopyStub.calledOnce).to.be.true expect(fsCopyStub.calledOnce).to.be.true
expect(fsCopyStub.calledWith(path.join(configPath, 'absdatabase.sqlite'), path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true expect(fsCopyStub.calledWith(path.join(configPath, 'absdatabase.sqlite'), path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true
expect(fsRemoveStub.calledOnce).to.be.true expect(fsRemoveStub.calledOnce).to.be.true
@ -105,9 +140,10 @@ describe('MigrationManager', () => {
migrationManager.serverVersion = '1.2.0' migrationManager.serverVersion = '1.2.0'
migrationManager.databaseVersion = '1.2.0' migrationManager.databaseVersion = '1.2.0'
migrationManager.maxVersion = '1.2.0' migrationManager.maxVersion = '1.2.0'
migrationManager.initialized = true
// Act // Act
await migrationManager.runMigrations(serverVersion) await migrationManager.runMigrations()
// Assert // Assert
expect(umzugStub.up.called).to.be.false expect(umzugStub.up.called).to.be.false
@ -119,6 +155,7 @@ describe('MigrationManager', () => {
migrationManager.serverVersion = '1.2.0' migrationManager.serverVersion = '1.2.0'
migrationManager.databaseVersion = '1.1.0' migrationManager.databaseVersion = '1.1.0'
migrationManager.maxVersion = '1.1.0' migrationManager.maxVersion = '1.1.0'
migrationManager.initialized = true
umzugStub.migrations.resolves([{ name: 'v1.2.0-migration.js' }]) umzugStub.migrations.resolves([{ name: 'v1.2.0-migration.js' }])
umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }]) umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }])
@ -128,9 +165,10 @@ describe('MigrationManager', () => {
const backupDbPath = path.join(configPath, 'absdatabase.backup.sqlite') const backupDbPath = path.join(configPath, 'absdatabase.backup.sqlite')
// Act // Act
await migrationManager.runMigrations(serverVersion) await migrationManager.runMigrations()
// Assert // Assert
expect(migrationManager.initUmzug.calledOnce).to.be.true
expect(umzugStub.up.calledOnce).to.be.true expect(umzugStub.up.calledOnce).to.be.true
expect(loggerErrorStub.calledWith(sinon.match('Migration failed'))).to.be.true expect(loggerErrorStub.calledWith(sinon.match('Migration failed'))).to.be.true
expect(fsMoveStub.calledWith(originalDbPath, sinon.match('absdatabase.failed.sqlite'), { overwrite: true })).to.be.true expect(fsMoveStub.calledWith(originalDbPath, sinon.match('absdatabase.failed.sqlite'), { overwrite: true })).to.be.true
@ -140,44 +178,15 @@ describe('MigrationManager', () => {
}) })
}) })
describe('init', () => {
it('should throw error if serverVersion is not provided', async () => {
// Act
try {
const result = await migrationManager.init()
expect.fail('Expected init to throw an error, but it did not.')
} catch (error) {
expect(error.message).to.equal('Invalid server version: undefined. Expected a version tag like v1.2.3.')
}
})
it('should initialize the MigrationManager', async () => {
// arrange
migrationManager.databaseVersion = '1.1.0'
migrationManager.maxVersion = '1.1.0'
migrationManager.umzug = null
migrationManager.configPath = __dirname
// Act
await migrationManager.init(serverVersion, memoryStorage())
// Assert
expect(migrationManager.serverVersion).to.equal('1.2.0')
expect(migrationManager.sequelize).to.equal(sequelizeStub)
expect(migrationManager.umzug).to.be.an.instanceOf(Umzug)
expect((await migrationManager.umzug.migrations()).map((m) => m.name)).to.deep.equal(['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.10.0-migration.js'])
})
})
describe('fetchVersionsFromDatabase', () => { describe('fetchVersionsFromDatabase', () => {
it('should fetch versions from a real database', async () => { it('should fetch versions from the migrationsMeta table', async () => {
// Arrange // Arrange
const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
const serverSettings = { version: 'v1.1.0', maxVersion: 'v1.1.0' } // Create a migrationsMeta table and populate it with version and maxVersion
// Create a settings table with a single row await sequelize.query('CREATE TABLE migrationsMeta (key VARCHAR(255), value VARCHAR(255))')
await sequelize.query('CREATE TABLE settings (key TEXT, value JSON)') await sequelize.query("INSERT INTO migrationsMeta (key, value) VALUES ('version', '1.1.0'), ('maxVersion', '1.1.0')")
await sequelize.query('INSERT INTO settings (key, value) VALUES (:key, :value)', { replacements: { key: 'server-settings', value: JSON.stringify(serverSettings) } })
const migrationManager = new MigrationManager(sequelize, configPath) const migrationManager = new MigrationManager(sequelize, configPath)
migrationManager.checkOrCreateMigrationsMetaTable = sinon.stub().resolves()
// Act // Act
await migrationManager.fetchVersionsFromDatabase() await migrationManager.fetchVersionsFromDatabase()
@ -187,35 +196,23 @@ describe('MigrationManager', () => {
expect(migrationManager.databaseVersion).to.equal('1.1.0') expect(migrationManager.databaseVersion).to.equal('1.1.0')
}) })
it('should set versions to null if no result is returned from the database', async () => { it('should create the migrationsMeta table if it does not exist and fetch versions from it', async () => {
// Arrange // Arrange
const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
await sequelize.query('CREATE TABLE settings (key TEXT, value JSON)')
const migrationManager = new MigrationManager(sequelize, configPath)
// Act
await migrationManager.fetchVersionsFromDatabase()
// Assert
expect(migrationManager.maxVersion).to.be.null
expect(migrationManager.databaseVersion).to.be.null
})
it('should return a default maxVersion if no maxVersion is set in the database', async () => {
// Arrange
const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
const serverSettings = { version: 'v1.1.0' }
// Create a settings table with a single row
await sequelize.query('CREATE TABLE settings (key TEXT, value JSON)')
await sequelize.query('INSERT INTO settings (key, value) VALUES (:key, :value)', { replacements: { key: 'server-settings', value: JSON.stringify(serverSettings) } })
const migrationManager = new MigrationManager(sequelize, configPath) const migrationManager = new MigrationManager(sequelize, configPath)
migrationManager.serverVersion = serverVersion
// Act // Act
await migrationManager.fetchVersionsFromDatabase() await migrationManager.fetchVersionsFromDatabase()
// Assert // Assert
const tableDescription = await sequelize.getQueryInterface().describeTable('migrationsMeta')
expect(tableDescription).to.deep.equal({
key: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false },
value: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false }
})
expect(migrationManager.maxVersion).to.equal('0.0.0') expect(migrationManager.maxVersion).to.equal('0.0.0')
expect(migrationManager.databaseVersion).to.equal('1.1.0') expect(migrationManager.databaseVersion).to.equal(serverVersion)
}) })
it('should throw an error if the database query fails', async () => { it('should throw an error if the database query fails', async () => {
@ -223,6 +220,7 @@ describe('MigrationManager', () => {
const sequelizeStub = sinon.createStubInstance(Sequelize) const sequelizeStub = sinon.createStubInstance(Sequelize)
sequelizeStub.query.rejects(new Error('Database query failed')) sequelizeStub.query.rejects(new Error('Database query failed'))
const migrationManager = new MigrationManager(sequelizeStub, configPath) const migrationManager = new MigrationManager(sequelizeStub, configPath)
migrationManager.checkOrCreateMigrationsMetaTable = sinon.stub().resolves()
// Act // Act
try { try {
@ -239,18 +237,20 @@ describe('MigrationManager', () => {
it('should update the maxVersion in the database', async () => { it('should update the maxVersion in the database', async () => {
// Arrange // Arrange
const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
const serverSettings = { version: 'v1.1.0', maxVersion: 'v1.1.0' } // Create a migrationsMeta table and populate it with version and maxVersion
// Create a settings table with a single row await sequelize.query('CREATE TABLE migrationsMeta (key VARCHAR(255), value VARCHAR(255))')
await sequelize.query('CREATE TABLE settings (key TEXT, value JSON)') await sequelize.query("INSERT INTO migrationsMeta (key, value) VALUES ('version', '1.1.0'), ('maxVersion', '1.1.0')")
await sequelize.query('INSERT INTO settings (key, value) VALUES (:key, :value)', { replacements: { key: 'server-settings', value: JSON.stringify(serverSettings) } })
const migrationManager = new MigrationManager(sequelize, configPath) const migrationManager = new MigrationManager(sequelize, configPath)
migrationManager.serverVersion = '1.2.0'
// Act // Act
await migrationManager.updateMaxVersion('v1.2.0') await migrationManager.updateMaxVersion()
// Assert // Assert
const [result] = await sequelize.query("SELECT json_extract(value, '$.maxVersion') AS maxVersion FROM settings WHERE key = :key", { replacements: { key: 'server-settings' }, type: Sequelize.QueryTypes.SELECT }) const [{ maxVersion }] = await sequelize.query("SELECT value AS maxVersion FROM migrationsMeta WHERE key = 'maxVersion'", {
expect(result.maxVersion).to.equal('v1.2.0') type: Sequelize.QueryTypes.SELECT
})
expect(maxVersion).to.equal('1.2.0')
}) })
}) })

View File

@ -1,15 +1,15 @@
const { DataTypes } = require('sequelize') const { DataTypes } = require('sequelize')
const Logger = require('../../../server/Logger')
/** /**
* This is an example of an upward migration script. * This is an example of an upward migration script.
* *
* @param {import { QueryInterface } from "sequelize";} options.context.queryInterface - a suquelize QueryInterface object. * @param {import { QueryInterface } from "sequelize";} options.context.queryInterface - a suquelize QueryInterface object.
* @param {import { Logger } from "../../../server/Logger";} options.context.logger - a Logger object.
* @returns {Promise<void>} - A promise that resolves when the migration is complete. * @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/ */
async function up({ context: queryInterface }) { async function up({ context: { queryInterface, logger } }) {
Logger.info('Running migration_example up...') logger.info('Running migration_example up...')
Logger.info('Creating example_table...') logger.info('Creating example_table...')
await queryInterface.createTable('example_table', { await queryInterface.createTable('example_table', {
id: { id: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
@ -21,22 +21,23 @@ async function up({ context: queryInterface }) {
allowNull: false allowNull: false
} }
}) })
Logger.info('example_table created.') logger.info('example_table created.')
Logger.info('migration_example up complete.') logger.info('migration_example up complete.')
} }
/** /**
* This is an example of a downward migration script. * This is an example of a downward migration script.
* *
* @param {import { QueryInterface } from "sequelize";} options.context.queryInterface - a suquelize QueryInterface object. * @param {import { QueryInterface } from "sequelize";} options.context.queryInterface - a suquelize QueryInterface object.
* @param {import { Logger } from "../../../server/Logger";} options.context.logger - a Logger object.
* @returns {Promise<void>} - A promise that resolves when the migration is complete. * @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/ */
async function down({ context: queryInterface }) { async function down({ context: { queryInterface, logger } }) {
Logger.info('Running migration_example down...') logger.info('Running migration_example down...')
Logger.info('Dropping example_table...') logger.info('Dropping example_table...')
await queryInterface.dropTable('example_table') await queryInterface.dropTable('example_table')
Logger.info('example_table dropped.') logger.info('example_table dropped.')
Logger.info('migration_example down complete.') logger.info('migration_example down complete.')
} }
module.exports = { up, down } module.exports = { up, down }

View File

@ -21,7 +21,7 @@ describe('migration_example', () => {
describe('up', () => { describe('up', () => {
it('should create example_table', async () => { it('should create example_table', async () => {
await up({ context: queryInterface }) await up({ context: { queryInterface, logger: Logger } })
expect(loggerInfoStub.callCount).to.equal(4) expect(loggerInfoStub.callCount).to.equal(4)
expect(loggerInfoStub.getCall(0).calledWith(sinon.match('Running migration_example up...'))).to.be.true expect(loggerInfoStub.getCall(0).calledWith(sinon.match('Running migration_example up...'))).to.be.true
@ -39,8 +39,8 @@ describe('migration_example', () => {
describe('down', () => { describe('down', () => {
it('should drop example_table', async () => { it('should drop example_table', async () => {
await up({ context: queryInterface }) await up({ context: { queryInterface, logger: Logger } })
await down({ context: queryInterface }) await down({ context: { queryInterface, logger: Logger } })
expect(loggerInfoStub.callCount).to.equal(8) expect(loggerInfoStub.callCount).to.equal(8)
expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Running migration_example down...'))).to.be.true expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Running migration_example down...'))).to.be.true