From 2cefbf8ca3072412f594a8879d3c769957509e4b Mon Sep 17 00:00:00 2001 From: idubnori Date: Fri, 14 Nov 2025 01:12:59 +0900 Subject: [PATCH] chore: add "pnpm run migrations:revert" command (#23869) --- docs/docs/developer/database-migrations.md | 10 ++++ server/package.json | 1 + server/src/bin/migrations.ts | 58 +++++++++++++++++- .../src/repositories/database.repository.ts | 59 +++++++++++++++---- .../repositories/database.repository.mock.ts | 1 + 5 files changed, 116 insertions(+), 13 deletions(-) diff --git a/docs/docs/developer/database-migrations.md b/docs/docs/developer/database-migrations.md index f032048b7a..a73e7e747c 100644 --- a/docs/docs/developer/database-migrations.md +++ b/docs/docs/developer/database-migrations.md @@ -12,3 +12,13 @@ pnpm run migrations:generate 3. Move the migration file to folder `./server/src/schema/migrations` in your code editor. The server will automatically detect `*.ts` file changes and restart. Part of the server start-up process includes running any new migrations, so it will be applied immediately. + +## Reverting a Migration + +If you need to undo the most recently applied migration—for example, when developing or testing on schema changes—run: + +```bash +pnpm run migrations:revert +``` + +This command rolls back the latest migration and brings the database schema back to its previous state. diff --git a/server/package.json b/server/package.json index c94f1376a1..aa6ba671a5 100644 --- a/server/package.json +++ b/server/package.json @@ -26,6 +26,7 @@ "migrations:generate": "node ./dist/bin/migrations.js generate", "migrations:create": "node ./dist/bin/migrations.js create", "migrations:run": "node ./dist/bin/migrations.js run", + "migrations:revert": "node ./dist/bin/migrations.js revert", "schema:drop": "node ./dist/bin/migrations.js query 'DROP schema public cascade; CREATE schema public;'", "schema:reset": "npm run schema:drop && npm run migrations:run", "sync:open-api": "node ./dist/bin/sync-open-api.js", diff --git a/server/src/bin/migrations.ts b/server/src/bin/migrations.ts index ebb07af442..588f358023 100644 --- a/server/src/bin/migrations.ts +++ b/server/src/bin/migrations.ts @@ -2,7 +2,7 @@ process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich'; import { Kysely, sql } from 'kysely'; -import { mkdirSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { basename, dirname, extname, join } from 'node:path'; import postgres from 'postgres'; import { ConfigRepository } from 'src/repositories/config.repository'; @@ -27,6 +27,11 @@ const main = async () => { return; } + case 'revert': { + await revert(); + return; + } + case 'query': { const query = process.argv[3]; await runQuery(query); @@ -48,6 +53,7 @@ const main = async () => { node dist/bin/migrations.js create node dist/bin/migrations.js generate node dist/bin/migrations.js run + node dist/bin/migrations.js revert `); } } @@ -74,6 +80,25 @@ const runMigrations = async () => { await db.destroy(); }; +const revert = async () => { + const configRepository = new ConfigRepository(); + const logger = LoggingRepository.create(); + const db = getDatabaseClient(); + const databaseRepository = new DatabaseRepository(db, logger, configRepository); + + try { + const migrationName = await databaseRepository.revertLastMigration(); + if (!migrationName) { + console.log('No migrations to revert'); + return; + } + + markMigrationAsReverted(migrationName); + } finally { + await db.destroy(); + } +}; + const debug = async () => { const { up } = await compare(); const upSql = '-- UP\n' + up.asSql({ comments: true }).join('\n'); @@ -148,6 +173,37 @@ ${downSql} `; }; +const markMigrationAsReverted = (migrationName: string) => { + // eslint-disable-next-line unicorn/prefer-module + const distRoot = join(__dirname, '..'); + const projectRoot = join(distRoot, '..'); + const sourceFolder = join(projectRoot, 'src', 'schema', 'migrations'); + const distFolder = join(distRoot, 'schema', 'migrations'); + + const sourcePath = join(sourceFolder, `${migrationName}.ts`); + const revertedFolder = join(sourceFolder, 'reverted'); + const revertedPath = join(revertedFolder, `${migrationName}.ts`); + + if (existsSync(revertedPath)) { + console.log(`Migration ${migrationName} is already marked as reverted`); + } else if (existsSync(sourcePath)) { + mkdirSync(revertedFolder, { recursive: true }); + renameSync(sourcePath, revertedPath); + console.log(`Moved ${sourcePath} to ${revertedPath}`); + } else { + console.warn(`Source migration file not found for ${migrationName}`); + } + + const distBase = join(distFolder, migrationName); + for (const extension of ['.js', '.js.map', '.d.ts']) { + const filePath = `${distBase}${extension}`; + if (existsSync(filePath)) { + rmSync(filePath, { force: true }); + console.log(`Removed ${filePath}`); + } + } +}; + main() .then(() => { process.exit(0); diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 9c9245ea34..842576fafb 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -360,18 +360,7 @@ export class DatabaseRepository { async runMigrations(): Promise { this.logger.debug('Running migrations'); - const migrator = new Migrator({ - db: this.db, - migrationLockTableName: 'kysely_migrations_lock', - allowUnorderedMigrations: this.configRepository.isDev(), - migrationTableName: 'kysely_migrations', - provider: new FileMigrationProvider({ - fs: { readdir }, - path: { join }, - // eslint-disable-next-line unicorn/prefer-module - migrationFolder: join(__dirname, '..', 'schema/migrations'), - }), - }); + const migrator = this.createMigrator(); const { error, results } = await migrator.migrateToLatest(); @@ -477,4 +466,50 @@ export class DatabaseRepository { private async releaseLock(lock: DatabaseLock, connection: Kysely): Promise { await sql`SELECT pg_advisory_unlock(${lock})`.execute(connection); } + + async revertLastMigration(): Promise { + this.logger.debug('Reverting last migration'); + + const migrator = this.createMigrator(); + const { error, results } = await migrator.migrateDown(); + + for (const result of results ?? []) { + if (result.status === 'Success') { + this.logger.log(`Reverted migration "${result.migrationName}"`); + } + + if (result.status === 'Error') { + this.logger.warn(`Failed to revert migration "${result.migrationName}"`); + } + } + + if (error) { + this.logger.error(`Failed to revert migrations: ${error}`); + throw error; + } + + const reverted = results?.find((result) => result.direction === 'Down' && result.status === 'Success'); + if (!reverted) { + this.logger.debug('No migrations to revert'); + return undefined; + } + + this.logger.debug('Finished reverting migration'); + return reverted.migrationName; + } + + private createMigrator(): Migrator { + return new Migrator({ + db: this.db, + migrationLockTableName: 'kysely_migrations_lock', + allowUnorderedMigrations: this.configRepository.isDev(), + migrationTableName: 'kysely_migrations', + provider: new FileMigrationProvider({ + fs: { readdir }, + path: { join }, + // eslint-disable-next-line unicorn/prefer-module + migrationFolder: join(__dirname, '..', 'schema/migrations'), + }), + }); + } } diff --git a/server/test/repositories/database.repository.mock.ts b/server/test/repositories/database.repository.mock.ts index 3664730be2..0ff869ca28 100644 --- a/server/test/repositories/database.repository.mock.ts +++ b/server/test/repositories/database.repository.mock.ts @@ -19,6 +19,7 @@ export const newDatabaseRepositoryMock = (): Mocked() => Promise) => function_()), tryLock: vitest.fn(), isBusy: vitest.fn(),