mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
Add db migration management infratructure
This commit is contained in:
parent
0344a63b48
commit
3f93b93d9e
676
package-lock.json
generated
676
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -47,10 +47,12 @@
|
||||
"p-throttle": "^4.1.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"semver": "^7.6.3",
|
||||
"sequelize": "^6.35.2",
|
||||
"socket.io": "^4.5.4",
|
||||
"sqlite3": "^5.1.6",
|
||||
"ssrf-req-filter": "^1.1.0",
|
||||
"umzug": "^3.8.1",
|
||||
"xml2js": "^0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -8,6 +8,8 @@ const Logger = require('./Logger')
|
||||
const dbMigration = require('./utils/migrations/dbMigration')
|
||||
const Auth = require('./Auth')
|
||||
|
||||
const MigrationManager = require('./managers/MigrationManager')
|
||||
|
||||
class Database {
|
||||
constructor() {
|
||||
this.sequelize = null
|
||||
@ -168,6 +170,16 @@ class Database {
|
||||
throw new Error('Database connection failed')
|
||||
}
|
||||
|
||||
if (!this.isNew) {
|
||||
try {
|
||||
const migrationManager = new MigrationManager(this.sequelize, global.ConfigPath)
|
||||
await migrationManager.runMigrations(packageJson.version)
|
||||
} catch (error) {
|
||||
Logger.error(`[Database] Failed to run migrations`, error)
|
||||
throw new Error('Database migration failed')
|
||||
}
|
||||
}
|
||||
|
||||
await this.buildModels(force)
|
||||
Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))
|
||||
|
||||
|
199
server/managers/MigrationManager.js
Normal file
199
server/managers/MigrationManager.js
Normal file
@ -0,0 +1,199 @@
|
||||
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
|
7
server/migrations/changelog.md
Normal file
7
server/migrations/changelog.md
Normal file
@ -0,0 +1,7 @@
|
||||
# Migrations Changelog
|
||||
|
||||
Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time.
|
||||
|
||||
| Server Version | Migration Script Name | Description |
|
||||
| -------------- | --------------------- | ----------- |
|
||||
| | | |
|
46
server/migrations/readme.md
Normal file
46
server/migrations/readme.md
Normal file
@ -0,0 +1,46 @@
|
||||
# Database Migrations
|
||||
|
||||
This directory contains all the database migration scripts for the server.
|
||||
|
||||
## What is a migration?
|
||||
|
||||
A migration is a script that changes the structure of the database. This can include creating tables, adding columns, or modifying existing columns. A migration script consists of two parts: an "up" script that applies the changes to the database, and a "down" script that undoes the changes.
|
||||
|
||||
## Guidelines for writing migrations
|
||||
|
||||
When writing a migration, keep the following guidelines in mind:
|
||||
|
||||
- You **_must_** name your migration script according to the following convention: `<server_version>-<migration_name>.js`. For example, `v2.14.0-create-users-table.js`.
|
||||
|
||||
- `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.
|
||||
|
||||
- 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:
|
||||
|
||||
```javascript
|
||||
async function up({context: queryInterface}) {
|
||||
// Upwards migration script
|
||||
...
|
||||
}
|
||||
|
||||
async function down({context: queryInterface}) {
|
||||
// Downward migration script
|
||||
...
|
||||
}
|
||||
|
||||
module.exports = {up, down}
|
||||
```
|
||||
|
||||
- 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).
|
||||
- It's your responsibility to make sure that the down migration undoes the changes made by the up migration.
|
||||
- Log detailed information on every step of the migration. Use `Logger.info()` and `Logger.error()`.
|
||||
- Test tour migrations thoroughly before committing them.
|
||||
- write unit tests for your migrations (see `test/server/migrations` for an example)
|
||||
- you can force a server version change by modifying the `version` field in `package.json` on your dev environment (but don't forget to revert it back before committing)
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
484
test/server/managers/MigrationManager.test.js
Normal file
484
test/server/managers/MigrationManager.test.js
Normal file
@ -0,0 +1,484 @@
|
||||
const { expect, config } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const { Sequelize } = require('sequelize')
|
||||
const fs = require('../../../server/libs/fsExtra')
|
||||
const Logger = require('../../../server/Logger')
|
||||
const MigrationManager = require('../../../server/managers/MigrationManager')
|
||||
const { Umzug, memoryStorage } = require('umzug')
|
||||
const path = require('path')
|
||||
|
||||
describe('MigrationManager', () => {
|
||||
let sequelizeStub
|
||||
let umzugStub
|
||||
let migrationManager
|
||||
let loggerInfoStub
|
||||
let loggerErrorStub
|
||||
let fsCopyStub
|
||||
let fsMoveStub
|
||||
let fsRemoveStub
|
||||
let fsEnsureDirStub
|
||||
let fsPathExistsStub
|
||||
let processExitStub
|
||||
let configPath = 'path/to/config'
|
||||
|
||||
const serverVersion = '1.2.0'
|
||||
|
||||
beforeEach(() => {
|
||||
sequelizeStub = sinon.createStubInstance(Sequelize)
|
||||
umzugStub = {
|
||||
migrations: sinon.stub(),
|
||||
executed: sinon.stub(),
|
||||
up: sinon.stub(),
|
||||
down: sinon.stub()
|
||||
}
|
||||
sequelizeStub.getQueryInterface.returns({})
|
||||
migrationManager = new MigrationManager(sequelizeStub, configPath)
|
||||
migrationManager.fetchVersionsFromDatabase = sinon.stub().resolves()
|
||||
migrationManager.copyMigrationsToConfigDir = sinon.stub().resolves()
|
||||
migrationManager.updateMaxVersion = sinon.stub().resolves()
|
||||
migrationManager.umzug = umzugStub
|
||||
loggerInfoStub = sinon.stub(Logger, 'info')
|
||||
loggerErrorStub = sinon.stub(Logger, 'error')
|
||||
fsCopyStub = sinon.stub(fs, 'copy').resolves()
|
||||
fsMoveStub = sinon.stub(fs, 'move').resolves()
|
||||
fsRemoveStub = sinon.stub(fs, 'remove').resolves()
|
||||
fsEnsureDirStub = sinon.stub(fs, 'ensureDir').resolves()
|
||||
fsPathExistsStub = sinon.stub(fs, 'pathExists').resolves(true)
|
||||
processExitStub = sinon.stub(process, 'exit')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe('runMigrations', () => {
|
||||
it('should run up migrations successfully', async () => {
|
||||
// Arrange
|
||||
migrationManager.databaseVersion = '1.1.0'
|
||||
migrationManager.maxVersion = '1.1.0'
|
||||
|
||||
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' }])
|
||||
|
||||
// Act
|
||||
await migrationManager.runMigrations('1.2.0')
|
||||
|
||||
// Assert
|
||||
expect(migrationManager.fetchVersionsFromDatabase.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.calledWith({ migrations: ['v1.1.1-migration.js', 'v1.2.0-migration.js'] })).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(fsRemoveStub.calledOnce).to.be.true
|
||||
expect(fsRemoveStub.calledWith(path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true
|
||||
expect(loggerInfoStub.calledWith(sinon.match('Migrations successfully applied'))).to.be.true
|
||||
})
|
||||
|
||||
it('should run down migrations successfully', async () => {
|
||||
// Arrange
|
||||
migrationManager.databaseVersion = '1.2.0'
|
||||
migrationManager.maxVersion = '1.2.0'
|
||||
|
||||
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' }])
|
||||
|
||||
// Act
|
||||
await migrationManager.runMigrations('1.1.0')
|
||||
|
||||
// Assert
|
||||
expect(migrationManager.fetchVersionsFromDatabase.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.calledWith({ migrations: ['v1.2.0-migration.js', 'v1.1.1-migration.js'] })).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(fsRemoveStub.calledOnce).to.be.true
|
||||
expect(fsRemoveStub.calledWith(path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true
|
||||
expect(loggerInfoStub.calledWith(sinon.match('Migrations successfully applied'))).to.be.true
|
||||
})
|
||||
|
||||
it('should log that no migrations are needed if serverVersion equals databaseVersion', async () => {
|
||||
// Arrange
|
||||
migrationManager.serverVersion = '1.2.0'
|
||||
migrationManager.databaseVersion = '1.2.0'
|
||||
migrationManager.maxVersion = '1.2.0'
|
||||
|
||||
// Act
|
||||
await migrationManager.runMigrations(serverVersion)
|
||||
|
||||
// Assert
|
||||
expect(umzugStub.up.called).to.be.false
|
||||
expect(loggerInfoStub.calledWith(sinon.match('Database is already up to date.'))).to.be.true
|
||||
})
|
||||
|
||||
it('should handle migration failure and restore the original database', async () => {
|
||||
// Arrange
|
||||
migrationManager.serverVersion = '1.2.0'
|
||||
migrationManager.databaseVersion = '1.1.0'
|
||||
migrationManager.maxVersion = '1.1.0'
|
||||
|
||||
umzugStub.migrations.resolves([{ name: 'v1.2.0-migration.js' }])
|
||||
umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }])
|
||||
umzugStub.up.rejects(new Error('Migration failed'))
|
||||
|
||||
const originalDbPath = path.join(configPath, 'absdatabase.sqlite')
|
||||
const backupDbPath = path.join(configPath, 'absdatabase.backup.sqlite')
|
||||
|
||||
// Act
|
||||
await migrationManager.runMigrations(serverVersion)
|
||||
|
||||
// Assert
|
||||
expect(umzugStub.up.calledOnce).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(backupDbPath, originalDbPath, { overwrite: true })).to.be.true
|
||||
expect(loggerInfoStub.calledWith(sinon.match('Restored the original database'))).to.be.true
|
||||
expect(processExitStub.calledOnce).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
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', () => {
|
||||
it('should fetch versions from a real database', async () => {
|
||||
// Arrange
|
||||
const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
const serverSettings = { version: 'v1.1.0', maxVersion: '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)
|
||||
|
||||
// Act
|
||||
await migrationManager.fetchVersionsFromDatabase()
|
||||
|
||||
// Assert
|
||||
expect(migrationManager.maxVersion).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 () => {
|
||||
// Arrange
|
||||
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)
|
||||
|
||||
// Act
|
||||
await migrationManager.fetchVersionsFromDatabase()
|
||||
|
||||
// Assert
|
||||
expect(migrationManager.maxVersion).to.equal('0.0.0')
|
||||
expect(migrationManager.databaseVersion).to.equal('1.1.0')
|
||||
})
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
// Arrange
|
||||
const sequelizeStub = sinon.createStubInstance(Sequelize)
|
||||
sequelizeStub.query.rejects(new Error('Database query failed'))
|
||||
const migrationManager = new MigrationManager(sequelizeStub, configPath)
|
||||
|
||||
// Act
|
||||
try {
|
||||
await migrationManager.fetchVersionsFromDatabase()
|
||||
expect.fail('Expected fetchVersionsFromDatabase to throw an error, but it did not.')
|
||||
} catch (error) {
|
||||
// Assert
|
||||
expect(error.message).to.equal('Database query failed')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateMaxVersion', () => {
|
||||
it('should update the maxVersion in the database', async () => {
|
||||
// Arrange
|
||||
const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
const serverSettings = { version: 'v1.1.0', maxVersion: '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)
|
||||
|
||||
// Act
|
||||
await migrationManager.updateMaxVersion('v1.2.0')
|
||||
|
||||
// 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 })
|
||||
expect(result.maxVersion).to.equal('v1.2.0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractVersionFromTag', () => {
|
||||
it('should return null if tag is not provided', () => {
|
||||
// Arrange
|
||||
const migrationManager = new MigrationManager(sequelizeStub, configPath)
|
||||
|
||||
// Act
|
||||
const result = migrationManager.extractVersionFromTag()
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.null
|
||||
})
|
||||
|
||||
it('should return null if tag does not match the version format', () => {
|
||||
// Arrange
|
||||
const migrationManager = new MigrationManager(sequelizeStub, configPath)
|
||||
const tag = 'invalid-tag'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.extractVersionFromTag(tag)
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.null
|
||||
})
|
||||
|
||||
it('should extract the version from the tag', () => {
|
||||
// Arrange
|
||||
const migrationManager = new MigrationManager(sequelizeStub, configPath)
|
||||
const tag = 'v1.2.3'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.extractVersionFromTag(tag)
|
||||
|
||||
// Assert
|
||||
expect(result).to.equal('1.2.3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyMigrationsToConfigDir', () => {
|
||||
it('should copy migrations to the config directory', async () => {
|
||||
// Arrange
|
||||
const migrationManager = new MigrationManager(sequelizeStub, configPath)
|
||||
migrationManager.migrationsDir = path.join(configPath, 'migrations')
|
||||
const migrationsSourceDir = path.join(__dirname, '..', '..', '..', 'server', 'migrations')
|
||||
const targetDir = migrationManager.migrationsDir
|
||||
const files = ['migration1.js', 'migration2.js', 'readme.md']
|
||||
|
||||
const readdirStub = sinon.stub(fs, 'readdir').resolves(files)
|
||||
|
||||
// Act
|
||||
await migrationManager.copyMigrationsToConfigDir()
|
||||
|
||||
// Assert
|
||||
expect(fsEnsureDirStub.calledOnce).to.be.true
|
||||
expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true
|
||||
expect(readdirStub.calledOnce).to.be.true
|
||||
expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true
|
||||
expect(fsCopyStub.calledTwice).to.be.true
|
||||
expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration1.js'), path.join(targetDir, 'migration1.js'))).to.be.true
|
||||
expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration2.js'), path.join(targetDir, 'migration2.js'))).to.be.true
|
||||
})
|
||||
|
||||
it('should throw an error if copying the migrations fails', async () => {
|
||||
// Arrange
|
||||
const migrationManager = new MigrationManager(sequelizeStub, configPath)
|
||||
migrationManager.migrationsDir = path.join(configPath, 'migrations')
|
||||
const migrationsSourceDir = path.join(__dirname, '..', '..', '..', 'server', 'migrations')
|
||||
const targetDir = migrationManager.migrationsDir
|
||||
const files = ['migration1.js', 'migration2.js', 'readme.md']
|
||||
|
||||
const readdirStub = sinon.stub(fs, 'readdir').resolves(files)
|
||||
fsCopyStub.restore()
|
||||
fsCopyStub = sinon.stub(fs, 'copy').rejects()
|
||||
|
||||
// Act
|
||||
try {
|
||||
// Act
|
||||
await migrationManager.copyMigrationsToConfigDir()
|
||||
expect.fail('Expected copyMigrationsToConfigDir to throw an error, but it did not.')
|
||||
} catch (error) {}
|
||||
|
||||
// Assert
|
||||
expect(fsEnsureDirStub.calledOnce).to.be.true
|
||||
expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true
|
||||
expect(readdirStub.calledOnce).to.be.true
|
||||
expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true
|
||||
expect(fsCopyStub.calledTwice).to.be.true
|
||||
expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration1.js'), path.join(targetDir, 'migration1.js'))).to.be.true
|
||||
expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration2.js'), path.join(targetDir, 'migration2.js'))).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('findMigrationsToRun', () => {
|
||||
it('should return migrations to run when direction is "up"', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = ['v1.0.0-migration.js']
|
||||
migrationManager.databaseVersion = '1.0.0'
|
||||
migrationManager.serverVersion = '1.2.0'
|
||||
const direction = 'up'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal(['v1.1.0-migration.js', 'v1.2.0-migration.js'])
|
||||
})
|
||||
|
||||
it('should return migrations to run when direction is "down"', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = ['v1.2.0-migration.js', 'v1.3.0-migration.js']
|
||||
migrationManager.databaseVersion = '1.3.0'
|
||||
migrationManager.serverVersion = '1.2.0'
|
||||
const direction = 'down'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal(['v1.3.0-migration.js'])
|
||||
})
|
||||
|
||||
it('should return empty array when no migrations to run up', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.3.0-migration.js']
|
||||
migrationManager.databaseVersion = '1.3.0'
|
||||
migrationManager.serverVersion = '1.4.0'
|
||||
const direction = 'up'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('should return empty array when no migrations to run down', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = []
|
||||
migrationManager.databaseVersion = '1.4.0'
|
||||
migrationManager.serverVersion = '1.3.0'
|
||||
const direction = 'down'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('should return down migrations to run when direction is "down" and up migration was not executed', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = []
|
||||
migrationManager.databaseVersion = '1.3.0'
|
||||
migrationManager.serverVersion = '1.0.0'
|
||||
const direction = 'down'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal(['v1.3.0-migration.js', 'v1.2.0-migration.js', 'v1.1.0-migration.js'])
|
||||
})
|
||||
|
||||
it('should return empty array when direction is "down" and server version is higher than database version', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.3.0-migration.js']
|
||||
migrationManager.databaseVersion = '1.0.0'
|
||||
migrationManager.serverVersion = '1.3.0'
|
||||
const direction = 'down'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('should return empty array when direction is "up" and server version is lower than database version', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.3.0-migration.js']
|
||||
migrationManager.databaseVersion = '1.3.0'
|
||||
migrationManager.serverVersion = '1.0.0'
|
||||
const direction = 'up'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('should return up migrations to run when server version is between migrations', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js']
|
||||
migrationManager.databaseVersion = '1.1.0'
|
||||
migrationManager.serverVersion = '1.2.3'
|
||||
const direction = 'up'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal(['v1.2.0-migration.js'])
|
||||
})
|
||||
|
||||
it('should return down migrations to run when server version is between migrations', () => {
|
||||
// Arrange
|
||||
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
|
||||
const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js']
|
||||
migrationManager.databaseVersion = '1.2.0'
|
||||
migrationManager.serverVersion = '1.1.3'
|
||||
const direction = 'down'
|
||||
|
||||
// Act
|
||||
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
|
||||
|
||||
// Assert
|
||||
expect(result).to.deep.equal(['v1.2.0-migration.js'])
|
||||
})
|
||||
})
|
||||
})
|
9
test/server/managers/migrations/v1.0.0-migration.js
Normal file
9
test/server/managers/migrations/v1.0.0-migration.js
Normal file
@ -0,0 +1,9 @@
|
||||
async function up() {
|
||||
console.log('v1.0.0 up')
|
||||
}
|
||||
|
||||
async function down() {
|
||||
console.log('v1.0.0 down')
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
9
test/server/managers/migrations/v1.1.0-migration.js
Normal file
9
test/server/managers/migrations/v1.1.0-migration.js
Normal file
@ -0,0 +1,9 @@
|
||||
async function up() {
|
||||
console.log('v1.1.0 up')
|
||||
}
|
||||
|
||||
async function down() {
|
||||
console.log('v1.1.0 down')
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
9
test/server/managers/migrations/v1.10.0-migration.js
Normal file
9
test/server/managers/migrations/v1.10.0-migration.js
Normal file
@ -0,0 +1,9 @@
|
||||
async function up() {
|
||||
console.log('v1.10.0 up')
|
||||
}
|
||||
|
||||
async function down() {
|
||||
console.log('v1.10.0 down')
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
9
test/server/managers/migrations/v1.2.0-migration.js
Normal file
9
test/server/managers/migrations/v1.2.0-migration.js
Normal file
@ -0,0 +1,9 @@
|
||||
async function up() {
|
||||
console.log('v1.2.0 up')
|
||||
}
|
||||
|
||||
async function down() {
|
||||
console.log('v1.2.0 down')
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
42
test/server/migrations/v0.0.1-migration_example.js
Normal file
42
test/server/migrations/v0.0.1-migration_example.js
Normal file
@ -0,0 +1,42 @@
|
||||
const { DataTypes } = require('sequelize')
|
||||
const Logger = require('../../../server/Logger')
|
||||
|
||||
/**
|
||||
* This is an example of an upward migration script.
|
||||
*
|
||||
* @param {import { QueryInterface } from "sequelize";} options.context.queryInterface - a suquelize QueryInterface object.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: queryInterface }) {
|
||||
Logger.info('Running migration_example up...')
|
||||
Logger.info('Creating example_table...')
|
||||
await queryInterface.createTable('example_table', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
}
|
||||
})
|
||||
Logger.info('example_table created.')
|
||||
Logger.info('migration_example up complete.')
|
||||
}
|
||||
|
||||
/**
|
||||
* This is an example of a downward migration script.
|
||||
*
|
||||
* @param {import { QueryInterface } from "sequelize";} options.context.queryInterface - a suquelize QueryInterface object.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: queryInterface }) {
|
||||
Logger.info('Running migration_example down...')
|
||||
Logger.info('Dropping example_table...')
|
||||
await queryInterface.dropTable('example_table')
|
||||
Logger.info('example_table dropped.')
|
||||
Logger.info('migration_example down complete.')
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
53
test/server/migrations/v0.0.1-migration_example.test.js
Normal file
53
test/server/migrations/v0.0.1-migration_example.test.js
Normal file
@ -0,0 +1,53 @@
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const { up, down } = require('./v0.0.1-migration_example')
|
||||
const { Sequelize } = require('sequelize')
|
||||
const Logger = require('../../../server/Logger')
|
||||
|
||||
describe('migration_example', () => {
|
||||
let sequelize
|
||||
let queryInterface
|
||||
let loggerInfoStub
|
||||
|
||||
beforeEach(() => {
|
||||
sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
queryInterface = sequelize.getQueryInterface()
|
||||
loggerInfoStub = sinon.stub(Logger, 'info')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe('up', () => {
|
||||
it('should create example_table', async () => {
|
||||
await up({ context: queryInterface })
|
||||
|
||||
expect(loggerInfoStub.callCount).to.equal(4)
|
||||
expect(loggerInfoStub.getCall(0).calledWith(sinon.match('Running migration_example up...'))).to.be.true
|
||||
expect(loggerInfoStub.getCall(1).calledWith(sinon.match('Creating example_table...'))).to.be.true
|
||||
expect(loggerInfoStub.getCall(2).calledWith(sinon.match('example_table created.'))).to.be.true
|
||||
expect(loggerInfoStub.getCall(3).calledWith(sinon.match('migration_example up complete.'))).to.be.true
|
||||
expect(await queryInterface.showAllTables()).to.include('example_table')
|
||||
const tableDescription = await queryInterface.describeTable('example_table')
|
||||
expect(tableDescription).to.deep.equal({
|
||||
id: { type: 'INTEGER', allowNull: true, defaultValue: undefined, primaryKey: true, unique: false },
|
||||
name: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('down', () => {
|
||||
it('should drop example_table', async () => {
|
||||
await up({ context: queryInterface })
|
||||
await down({ context: queryInterface })
|
||||
|
||||
expect(loggerInfoStub.callCount).to.equal(8)
|
||||
expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Running migration_example down...'))).to.be.true
|
||||
expect(loggerInfoStub.getCall(5).calledWith(sinon.match('Dropping example_table...'))).to.be.true
|
||||
expect(loggerInfoStub.getCall(6).calledWith(sinon.match('example_table dropped.'))).to.be.true
|
||||
expect(loggerInfoStub.getCall(7).calledWith(sinon.match('migration_example down complete.'))).to.be.true
|
||||
expect(await queryInterface.showAllTables()).not.to.include('example_table')
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue
Block a user