Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions microservices/migration-service/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
39 changes: 39 additions & 0 deletions src/entities/migration-record.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
92 changes: 92 additions & 0 deletions src/runner/migration-runner.service.ts
Original file line number Diff line number Diff line change
@@ -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<MigrationRecord>,
private readonly dataSource: DataSource,
) {}

/**
* Executes a database schema modification safely using transactional isolations
*/
async runSafeMigration(version: string, forwardSql: string, rollbackSql: string): Promise<MigrationRecord> {
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<MigrationRecord> {
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;
}
}
28 changes: 28 additions & 0 deletions src/runner/migration.controller.ts
Original file line number Diff line number Diff line change
@@ -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<MigrationRecord>,
) {}

@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);
}
}
Loading