From d430743d9b998d0a6497e4ea51a84022db2bb87c Mon Sep 17 00:00:00 2001 From: Warisu Date: Thu, 25 Jun 2026 13:40:58 +0100 Subject: [PATCH] feat(migration-service): initialize database schema management microservice (#362) --- microservices/migration-service/Dockerfile | 14 ++++ src/entities/migration-record.entity.ts | 39 +++++++++ src/runner/migration-runner.service.ts | 92 ++++++++++++++++++++++ src/runner/migration.controller.ts | 28 +++++++ 4 files changed, 173 insertions(+) create mode 100644 microservices/migration-service/Dockerfile create mode 100644 src/entities/migration-record.entity.ts create mode 100644 src/runner/migration-runner.service.ts create mode 100644 src/runner/migration.controller.ts diff --git a/microservices/migration-service/Dockerfile b/microservices/migration-service/Dockerfile new file mode 100644 index 0000000..7f2b527 --- /dev/null +++ b/microservices/migration-service/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine AS builder +WORKDIR /usr/src/app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:20-alpine +WORKDIR /usr/src/app +COPY package*.json ./ +RUN npm ci --only=production +COPY --from=builder /usr/src/app/dist ./dist +EXPOSE 3000 +CMD ["node", "dist/main"] \ No newline at end of file diff --git a/src/entities/migration-record.entity.ts b/src/entities/migration-record.entity.ts new file mode 100644 index 0000000..02b7d97 --- /dev/null +++ b/src/entities/migration-record.entity.ts @@ -0,0 +1,39 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +export enum MigrationStatus { + PENDING = 'pending', + RUNNING = 'running', + COMPLETED = 'completed', + FAILED = 'failed', + ROLLED_BACK = 'rolled_back', +} + +@Entity('global_migration_records') +export class MigrationRecord { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + version: string; // e.g., "20260625140000" + + @Column() + name: string; // e.g., "AddVaultYieldTrackingColumns" + + @Column({ type: 'enum', enum: MigrationStatus, default: MigrationStatus.PENDING }) + status: MigrationStatus; + + @Column({ type: 'text', nullable: true }) + executionLog: string; + + @Column({ type: 'text', nullable: true }) + rollbackScript: string; // Dynamic backup fallback SQL string + + @Column({ type: 'int', default: 0 }) + executionTimeMs: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/src/runner/migration-runner.service.ts b/src/runner/migration-runner.service.ts new file mode 100644 index 0000000..8a5079b --- /dev/null +++ b/src/runner/migration-runner.service.ts @@ -0,0 +1,92 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { MigrationRecord, MigrationStatus } from '../entities/migration-record.entity'; + +@Injectable() +export class MigrationRunnerService { + private readonly logger = new Logger(MigrationRunnerService.name); + + constructor( + @InjectRepository(MigrationRecord) private readonly recordRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + /** + * Executes a database schema modification safely using transactional isolations + */ + async runSafeMigration(version: string, forwardSql: string, rollbackSql: string): Promise { + let record = await this.recordRepo.findOneBy({ version }); + + if (!record) { + record = this.recordRepo.create({ version, name: `Migration_${version}`, status: MigrationStatus.PENDING, rollbackScript: rollbackSql }); + await this.recordRepo.save(record); + } + + if (record.status === MigrationStatus.COMPLETED) { + throw new BadRequestException(`Migration version ${version} has already been applied.`); + } + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + record.status = MigrationStatus.RUNNING; + await this.recordRepo.save(record); + + const startTime = Date.now(); + try { + // Execute the structural update forward statement + await queryRunner.query(forwardSql); + + await queryRunner.commitTransaction(); + + record.status = MigrationStatus.COMPLETED; + record.executionTimeMs = Date.now() - startTime; + record.executionLog = 'Migration executed successfully without data exceptions.'; + } catch (error) { + this.logger.error(`Migration ${version} failed. Rollback triggered automatically. Error: ${error.message}`); + await queryRunner.rollbackTransaction(); + + record.status = MigrationStatus.FAILED; + record.executionLog = `Failure Reason: ${error.message}`; + } finally { + await queryRunner.release(); + await this.recordRepo.save(record); + } + + return record; + } + + /** + * Safely reverts schema configurations to a specific target checkpoint + */ + async revertMigration(version: string): Promise { + const record = await this.recordRepo.findOneBy({ version, status: MigrationStatus.COMPLETED }); + if (!record) { + throw new BadRequestException(`No completed migration found matching version tag: ${version}`); + } + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + if (record.rollbackScript) { + await queryRunner.query(record.rollbackScript); + } + await queryRunner.commitTransaction(); + + record.status = MigrationStatus.ROLLED_BACK; + record.executionLog = `Reverted smoothly on request checkpoint initialization loop.`; + } catch (error) { + await queryRunner.rollbackTransaction(); + throw new Error(`Critical rollback failure: ${error.message}`); + } finally { + await queryRunner.release(); + await this.recordRepo.save(record); + } + + return record; + } +} \ No newline at end of file diff --git a/src/runner/migration.controller.ts b/src/runner/migration.controller.ts new file mode 100644 index 0000000..e3f6598 --- /dev/null +++ b/src/runner/migration.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, Post, Body, Param, Put } from '@nestjs/common'; +import { MigrationRunnerService } from './migration-runner.service'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { MigrationRecord } from '../entities/migration-record.entity'; + +@Controller('migrations') +export class MigrationController { + constructor( + private readonly runnerService: MigrationRunnerService, + @InjectRepository(MigrationRecord) private readonly recordRepo: Repository, + ) {} + + @Get('status') + async getStatusSummary() { + return this.recordRepo.find({ order: { version: 'DESC' } }); + } + + @Post('apply') + async applySchemaChange(@Body() body: { version: string; forwardSql: string; rollbackSql: string }) { + return this.runnerService.runSafeMigration(body.version, body.forwardSql, body.rollbackSql); + } + + @Put('revert/:version') + async triggerRollback(@Param('version') version: string) { + return this.runnerService.revertMigration(version); + } +} \ No newline at end of file