diff --git a/docs/migrations.md b/docs/migrations.md new file mode 100644 index 00000000..cd3b3f9d --- /dev/null +++ b/docs/migrations.md @@ -0,0 +1,198 @@ +# Database Migration Guide + +This document covers how to run, roll back, and manage database migrations in the teachLink backend. + +## Overview + +Migrations are managed by the custom `MigrationModule` built on top of TypeORM and NestJS. Every migration implements a `MigrationConfig` interface with two methods: + +- `up(connection)` — applies the schema change +- `down(connection)` — fully reverses the schema change + +All migrations are registered in `src/migrations/migration.registry.ts` and tracked in the `migrations` database table. + +--- + +## Migration Files + +Migration files live in `src/migrations/samples/` and follow the naming convention: + +``` +NNN-description-of-change.migration.ts +``` + +Where `NNN` is a zero-padded sequence number (e.g. `001`, `002`). This ensures a deterministic execution order. + +### Current Migrations + +| # | Name | Description | +|---|------|-------------| +| 006 | `006-create-migrations-tracking-table` | Creates the `migrations` tracking table | +| 001 | `001-create-users-table` | Creates the `users` table with roles, status, and indexes | +| 002 | `002-create-courses-table` | Creates the `course` table with FK to users | +| 003 | `003-create-course-modules-table` | Creates the `course_module` table | +| 004 | `004-create-lessons-table` | Creates the `lesson` table | +| 005 | `005-create-enrollments-table` | Creates the `enrollment` table | + +--- + +## Running Migrations + +### Via npm scripts (requires the app to be running) + +```bash +# Run all pending migrations +npm run migrate:run + +# Check status of all migrations +npm run migrate:status +``` + +### Via HTTP API directly + +```bash +# Run all pending migrations +curl -X POST http://localhost:3000/migrations/run + +# List all migrations and their status +curl http://localhost:3000/migrations +``` + +### Automatic on startup + +Set the environment variable to run migrations automatically when the app boots: + +```bash +AUTO_RUN_MIGRATIONS=true +``` + +--- + +## Rolling Back Migrations + +### Roll back the last migration + +```bash +npm run migrate:rollback +# or +curl -X POST http://localhost:3000/migrations/rollback +``` + +### Roll back the last N migrations + +```bash +# Roll back last 3 migrations +COUNT=3 npm run migrate:rollback:count +# or +curl -X POST http://localhost:3000/migrations/rollback/3 +``` + +### Roll back a specific named migration + +```bash +curl -X PUT http://localhost:3000/migrations/002-create-courses-table/rollback +``` + +> **Note:** This will fail if later migrations that depend on this one are still applied. Roll those back first. + +### Roll back to a specific version + +Rolls back all migrations applied *after* the named migration, leaving the named migration itself in place. + +```bash +MIGRATION_NAME=002-create-courses-table npm run migrate:rollback:to +# or +curl -X POST http://localhost:3000/migrations/rollback/to/002-create-courses-table +``` + +--- + +## Resetting All Migrations (Development Only) + +This rolls back every applied migration in reverse order and clears the tracking table. + +```bash +npm run migrate:reset +# or +curl -X DELETE http://localhost:3000/migrations/reset +``` + +> ⚠️ **Never run this in production.** It will drop all managed tables. + +--- + +## Creating a New Migration + +1. Create a new file in `src/migrations/samples/` following the naming convention: + +```typescript +// src/migrations/samples/007-add-bio-to-users.migration.ts +import { Injectable, Logger } from '@nestjs/common'; +import { MigrationConfig } from '../migration.service'; + +@Injectable() +export class AddBioToUsersMigration implements MigrationConfig { + name = '007-add-bio-to-users'; + version = '1.0.0'; + dependencies = ['001-create-users-table']; + + private readonly logger = new Logger(AddBioToUsersMigration.name); + + async up(connection: any): Promise { + await connection.query(`ALTER TABLE users ADD COLUMN bio TEXT;`); + } + + async down(connection: any): Promise { + await connection.query(`ALTER TABLE users DROP COLUMN IF EXISTS bio;`); + } +} +``` + +2. Register it in `src/migrations/migration.registry.ts`: + +```typescript +import { AddBioToUsersMigration } from './samples/007-add-bio-to-users.migration'; + +export const MIGRATION_REGISTRY: MigrationConfig[] = [ + // ... existing migrations ... + new AddBioToUsersMigration(), +]; +``` + +--- + +## Migration Best Practices + +- **Always implement `down()`** as the exact inverse of `up()` — same columns, same types, same constraints, in reverse order. +- **Declare dependencies** in the `dependencies` array. The runner validates them before executing. +- **Never modify an existing migration** that has already been applied to any environment. Create a new migration instead. +- **Test rollbacks locally** before merging. Run `up`, verify, then run `down` and verify the schema is restored. +- **Use `IF EXISTS` / `IF NOT EXISTS`** guards in SQL to make migrations idempotent where possible. +- **Back up your database** before running migrations in staging or production. + +--- + +## Environment-Specific Considerations + +| Environment | `AUTO_RUN_MIGRATIONS` | Notes | +|-------------|----------------------|-------| +| Development | `true` (recommended) | Migrations run on every app start | +| Test | `false` | Use `migrate:run` before test suites | +| Staging | `false` | Run manually after deployment | +| Production | `false` | Run manually with a backup in place | + +--- + +## Troubleshooting + +**Migration stuck in `pending` status** +The migration was registered but never executed. Run `npm run migrate:run`. + +**Migration stuck in `failed` status** +Check the `error_message` column in the `migrations` table. Fix the underlying issue, then either re-run or roll back. + +**`Dependency not met` error** +A migration's dependency hasn't been applied yet. Check the registry order and run the dependency first. + +**`Cannot roll back` error** +Later migrations that depend on this one are still applied. Roll those back first, then retry. diff --git a/package.json b/package.json index 96f1e39e..f07cf8f5 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,13 @@ "test:e2e": "jest --config ./test/jest-e2e.json --forceExit --runInBand", "test:ml-models": "jest src/ml-models --maxWorkers=1 --max-old-space-size=2048", "test:ml-models-basic": "jest src/ml-models/ml-models.service.basic.spec.ts --maxWorkers=1 --max-old-space-size=2048", - "test:optimized": "node --max-old-space-size=2048 node_modules/.bin/jest --maxWorkers=1 --config jest.config.js" + "test:optimized": "node --max-old-space-size=2048 node_modules/.bin/jest --maxWorkers=1 --config jest.config.js", + "migrate:run": "curl -s -X POST http://localhost:3000/migrations/run | npx json", + "migrate:status": "curl -s http://localhost:3000/migrations | npx json", + "migrate:rollback": "curl -s -X POST http://localhost:3000/migrations/rollback | npx json", + "migrate:rollback:count": "curl -s -X POST http://localhost:3000/migrations/rollback/${COUNT:-1} | npx json", + "migrate:rollback:to": "curl -s -X POST http://localhost:3000/migrations/rollback/to/${MIGRATION_NAME} | npx json", + "migrate:reset": "curl -s -X DELETE http://localhost:3000/migrations/reset | npx json" }, "dependencies": { "@apollo/server": "^4.13.0", diff --git a/src/migrations/migration.controller.ts b/src/migrations/migration.controller.ts index 2544357a..dea07b27 100644 --- a/src/migrations/migration.controller.ts +++ b/src/migrations/migration.controller.ts @@ -110,13 +110,44 @@ export class MigrationController { ) { this.logger.log(`Rolling back specific migration: ${migrationName}`); - // Note: In a real implementation, you'd need to map the migration name to the actual migration config - // For now, this is a placeholder + try { + await this.rollbackService.rollbackByName(migrationName); + return res.status(HttpStatus.OK).json({ + success: true, + message: `Successfully rolled back migration: ${migrationName}`, + }); + } catch (error) { + this.logger.error(`Error rolling back migration ${migrationName}`, error.stack); + return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + success: false, + message: `Failed to rollback migration: ${migrationName}`, + error: error.message, + }); + } + } + + @Post('rollback/to/:migrationName') + @HttpCode(HttpStatus.OK) + async rollbackToVersion( + @Param('migrationName') migrationName: string, + @Res() res: Response, + ) { + this.logger.log(`Rolling back to version: ${migrationName}`); - return res.status(HttpStatus.NOT_IMPLEMENTED).json({ - success: false, - message: 'Specific migration rollback not implemented in this example', - }); + try { + await this.rollbackService.rollbackToVersion(migrationName); + return res.status(HttpStatus.OK).json({ + success: true, + message: `Successfully rolled back to version: ${migrationName}`, + }); + } catch (error) { + this.logger.error(`Error rolling back to version ${migrationName}`, error.stack); + return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + success: false, + message: `Failed to rollback to version: ${migrationName}`, + error: error.message, + }); + } } @Get('conflicts') diff --git a/src/migrations/migration.registry.ts b/src/migrations/migration.registry.ts new file mode 100644 index 00000000..992a9c25 --- /dev/null +++ b/src/migrations/migration.registry.ts @@ -0,0 +1,25 @@ +import { MigrationConfig } from './migration.service'; +import { CreateUsersTableMigration } from './samples/001-create-users-table.migration'; +import { CreateCoursesTableMigration } from './samples/002-create-courses-table.migration'; +import { CreateCourseModulesTableMigration } from './samples/003-create-course-modules-table.migration'; +import { CreateLessonsTableMigration } from './samples/004-create-lessons-table.migration'; +import { CreateEnrollmentsTableMigration } from './samples/005-create-enrollments-table.migration'; +import { CreateMigrationsTrackingTableMigration } from './samples/006-create-migrations-tracking-table.migration'; + +/** + * Central registry of all migrations in execution order. + * + * Rules: + * - Migrations are executed in the order they appear in this array. + * - Each migration's `dependencies` array is validated before execution. + * - To add a new migration: create the file in `samples/`, then append it here. + * - Never remove or reorder existing entries — only append new ones. + */ +export const MIGRATION_REGISTRY: MigrationConfig[] = [ + new CreateMigrationsTrackingTableMigration(), + new CreateUsersTableMigration(), + new CreateCoursesTableMigration(), + new CreateCourseModulesTableMigration(), + new CreateLessonsTableMigration(), + new CreateEnrollmentsTableMigration(), +]; diff --git a/src/migrations/migration.service.ts b/src/migrations/migration.service.ts index 2e649467..d19ad9a6 100644 --- a/src/migrations/migration.service.ts +++ b/src/migrations/migration.service.ts @@ -6,6 +6,7 @@ import { ConflictResolutionService } from './conflicts/conflict-resolution.servi import { SchemaValidationService } from './validation/schema-validation.service'; import { RollbackService } from './rollback/rollback.service'; import { EnvironmentSyncService } from './environments/environment-sync.service'; +import { MIGRATION_REGISTRY } from './migration.registry'; export interface MigrationConfig { name: string; @@ -109,9 +110,7 @@ export class MigrationService { * Gets all registered migrations */ private getRegisteredMigrations(): MigrationConfig[] { - // In a real implementation, this would load migrations from files or configuration - // For now, returning an empty array - this would be populated based on your actual migrations - return []; + return MIGRATION_REGISTRY; } /** diff --git a/src/migrations/rollback/rollback.service.ts b/src/migrations/rollback/rollback.service.ts index 2c1d4f8c..34c2d5cb 100644 --- a/src/migrations/rollback/rollback.service.ts +++ b/src/migrations/rollback/rollback.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Raw } from 'typeorm'; import { Migration, MigrationStatus } from '../entities/migration.entity'; import { MigrationConfig } from '../migration.service'; +import { MIGRATION_REGISTRY } from '../migration.registry'; @Injectable() export class RollbackService { @@ -110,14 +111,72 @@ export class RollbackService { * Gets all registered migrations */ private getRegisteredMigrations(): MigrationConfig[] { - // In a real implementation, this would load migrations from files or configuration - // For now, returning an empty array - this would be populated based on your actual migrations - return []; + return MIGRATION_REGISTRY; } /** - * Checks if a migration can be safely rolled back + * Rolls back all migrations down to (but not including) the target version. + * Migrations are rolled back in reverse-applied order. */ + async rollbackToVersion(targetMigrationName: string): Promise { + this.logger.log(`Rolling back to version: ${targetMigrationName}`); + + const registeredMigrations = this.getRegisteredMigrations(); + + // Verify the target migration exists in the registry + const targetExists = registeredMigrations.some((m) => m.name === targetMigrationName); + if (!targetExists) { + throw new Error(`Target migration not found in registry: ${targetMigrationName}`); + } + + // Get all applied migrations that were applied AFTER the target, in reverse order + const targetRecord = await this.migrationRepository.findOne({ + where: { name: targetMigrationName }, + }); + + const whereClause = targetRecord?.appliedAt + ? { status: MigrationStatus.COMPLETED, appliedAt: Raw((alias) => `${alias} > :appliedAt`, { appliedAt: targetRecord.appliedAt }) } + : { status: MigrationStatus.COMPLETED }; + + const migrationsToRollback = await this.migrationRepository.find({ + where: whereClause, + order: { appliedAt: 'DESC' }, + }); + + for (const appliedMigration of migrationsToRollback) { + const migrationConfig = registeredMigrations.find((m) => m.name === appliedMigration.name); + if (migrationConfig) { + await this.rollbackMigration(migrationConfig); + } else { + this.logger.warn(`Could not find migration config for: ${appliedMigration.name}`); + } + } + + this.logger.log(`Rollback to version ${targetMigrationName} complete.`); + } + + /** + * Rolls back a specific named migration regardless of order. + */ + async rollbackByName(migrationName: string): Promise { + this.logger.log(`Rolling back specific migration by name: ${migrationName}`); + + const registeredMigrations = this.getRegisteredMigrations(); + const migrationConfig = registeredMigrations.find((m) => m.name === migrationName); + + if (!migrationConfig) { + throw new Error(`Migration not found in registry: ${migrationName}`); + } + + const canRollback = await this.canRollbackMigration(migrationName); + if (!canRollback) { + throw new Error( + `Cannot roll back migration ${migrationName}: it either hasn't been applied or has dependent migrations applied after it.`, + ); + } + + await this.rollbackMigration(migrationConfig); + } async canRollbackMigration(migrationName: string): Promise { // Check if the migration exists and is completed const migration = await this.migrationRepository.findOne({ diff --git a/src/migrations/samples/001-create-users-table.migration.ts b/src/migrations/samples/001-create-users-table.migration.ts new file mode 100644 index 00000000..478999b7 --- /dev/null +++ b/src/migrations/samples/001-create-users-table.migration.ts @@ -0,0 +1,76 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { MigrationConfig } from '../migration.service'; + +/** + * Migration 001 — Create users table + * + * up: Creates the `users` table with all columns, enums, and indexes. + * down: Drops the `users` table and its associated enum types. + */ +@Injectable() +export class CreateUsersTableMigration implements MigrationConfig { + name = '001-create-users-table'; + version = '1.0.0'; + dependencies: string[] = []; + + private readonly logger = new Logger(CreateUsersTableMigration.name); + + async up(connection: any): Promise { + this.logger.log('Applying migration: create users table'); + + await connection.query(` + DO $$ BEGIN + CREATE TYPE user_role AS ENUM ('student', 'teacher', 'admin'); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await connection.query(` + DO $$ BEGIN + CREATE TYPE user_status AS ENUM ('active', 'inactive', 'suspended'); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await connection.query(` + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL UNIQUE, + username VARCHAR(100), + password VARCHAR(255) NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + role user_role NOT NULL DEFAULT 'student', + status user_status NOT NULL DEFAULT 'active', + tenant_id VARCHAR(100), + profile_picture VARCHAR(500), + is_email_verified BOOLEAN NOT NULL DEFAULT FALSE, + email_verification_token VARCHAR(255), + email_verification_expires TIMESTAMP, + password_reset_token VARCHAR(255), + password_reset_expires TIMESTAMP, + refresh_token TEXT, + password_history TEXT[] NOT NULL DEFAULT '{}', + last_login_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + `); + + await connection.query(`CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);`); + await connection.query(`CREATE INDEX IF NOT EXISTS idx_users_username ON users (username);`); + await connection.query(`CREATE INDEX IF NOT EXISTS idx_users_tenant_id ON users (tenant_id);`); + + this.logger.log('Migration applied: create users table'); + } + + async down(connection: any): Promise { + this.logger.log('Rolling back migration: create users table'); + + await connection.query(`DROP TABLE IF EXISTS users CASCADE;`); + await connection.query(`DROP TYPE IF EXISTS user_role;`); + await connection.query(`DROP TYPE IF EXISTS user_status;`); + + this.logger.log('Migration rolled back: create users table'); + } +} diff --git a/src/migrations/samples/002-create-courses-table.migration.ts b/src/migrations/samples/002-create-courses-table.migration.ts new file mode 100644 index 00000000..235eb172 --- /dev/null +++ b/src/migrations/samples/002-create-courses-table.migration.ts @@ -0,0 +1,50 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { MigrationConfig } from '../migration.service'; + +/** + * Migration 002 — Create courses table + * + * up: Creates the `course` table with FK to users (instructor). + * down: Drops the `course` table. + * + * Depends on: 001-create-users-table + */ +@Injectable() +export class CreateCoursesTableMigration implements MigrationConfig { + name = '002-create-courses-table'; + version = '1.0.0'; + dependencies = ['001-create-users-table']; + + private readonly logger = new Logger(CreateCoursesTableMigration.name); + + async up(connection: any): Promise { + this.logger.log('Applying migration: create courses table'); + + await connection.query(` + CREATE TABLE IF NOT EXISTS course ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + price NUMERIC(10, 2) NOT NULL DEFAULT 0, + status VARCHAR(50) NOT NULL DEFAULT 'draft', + thumbnail_url VARCHAR(500), + instructor_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + `); + + await connection.query(`CREATE INDEX IF NOT EXISTS idx_course_status ON course (status);`); + await connection.query(`CREATE INDEX IF NOT EXISTS idx_course_instructor_id ON course (instructor_id);`); + + this.logger.log('Migration applied: create courses table'); + } + + async down(connection: any): Promise { + this.logger.log('Rolling back migration: create courses table'); + + await connection.query(`DROP TABLE IF EXISTS course CASCADE;`); + + this.logger.log('Migration rolled back: create courses table'); + } +} diff --git a/src/migrations/samples/003-create-course-modules-table.migration.ts b/src/migrations/samples/003-create-course-modules-table.migration.ts new file mode 100644 index 00000000..79f0f918 --- /dev/null +++ b/src/migrations/samples/003-create-course-modules-table.migration.ts @@ -0,0 +1,44 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { MigrationConfig } from '../migration.service'; + +/** + * Migration 003 — Create course_module table + * + * up: Creates the `course_module` table with FK to course. + * down: Drops the `course_module` table. + * + * Depends on: 002-create-courses-table + */ +@Injectable() +export class CreateCourseModulesTableMigration implements MigrationConfig { + name = '003-create-course-modules-table'; + version = '1.0.0'; + dependencies = ['002-create-courses-table']; + + private readonly logger = new Logger(CreateCourseModulesTableMigration.name); + + async up(connection: any): Promise { + this.logger.log('Applying migration: create course_module table'); + + await connection.query(` + CREATE TABLE IF NOT EXISTS course_module ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + "order" INTEGER NOT NULL DEFAULT 0, + course_id UUID NOT NULL REFERENCES course(id) ON DELETE CASCADE + ); + `); + + await connection.query(`CREATE INDEX IF NOT EXISTS idx_course_module_course_id ON course_module (course_id);`); + + this.logger.log('Migration applied: create course_module table'); + } + + async down(connection: any): Promise { + this.logger.log('Rolling back migration: create course_module table'); + + await connection.query(`DROP TABLE IF EXISTS course_module CASCADE;`); + + this.logger.log('Migration rolled back: create course_module table'); + } +} diff --git a/src/migrations/samples/004-create-lessons-table.migration.ts b/src/migrations/samples/004-create-lessons-table.migration.ts new file mode 100644 index 00000000..35515ca4 --- /dev/null +++ b/src/migrations/samples/004-create-lessons-table.migration.ts @@ -0,0 +1,47 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { MigrationConfig } from '../migration.service'; + +/** + * Migration 004 — Create lesson table + * + * up: Creates the `lesson` table with FK to course_module. + * down: Drops the `lesson` table. + * + * Depends on: 003-create-course-modules-table + */ +@Injectable() +export class CreateLessonsTableMigration implements MigrationConfig { + name = '004-create-lessons-table'; + version = '1.0.0'; + dependencies = ['003-create-course-modules-table']; + + private readonly logger = new Logger(CreateLessonsTableMigration.name); + + async up(connection: any): Promise { + this.logger.log('Applying migration: create lesson table'); + + await connection.query(` + CREATE TABLE IF NOT EXISTS lesson ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + content TEXT, + video_url VARCHAR(500), + "order" INTEGER NOT NULL DEFAULT 0, + duration_seconds INTEGER NOT NULL DEFAULT 0, + module_id UUID NOT NULL REFERENCES course_module(id) ON DELETE CASCADE + ); + `); + + await connection.query(`CREATE INDEX IF NOT EXISTS idx_lesson_module_id ON lesson (module_id);`); + + this.logger.log('Migration applied: create lesson table'); + } + + async down(connection: any): Promise { + this.logger.log('Rolling back migration: create lesson table'); + + await connection.query(`DROP TABLE IF EXISTS lesson CASCADE;`); + + this.logger.log('Migration rolled back: create lesson table'); + } +} diff --git a/src/migrations/samples/005-create-enrollments-table.migration.ts b/src/migrations/samples/005-create-enrollments-table.migration.ts new file mode 100644 index 00000000..8a9bb966 --- /dev/null +++ b/src/migrations/samples/005-create-enrollments-table.migration.ts @@ -0,0 +1,51 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { MigrationConfig } from '../migration.service'; + +/** + * Migration 005 — Create enrollment table + * + * up: Creates the `enrollment` table with FKs to users and course. + * down: Drops the `enrollment` table. + * + * Depends on: 001-create-users-table, 002-create-courses-table + */ +@Injectable() +export class CreateEnrollmentsTableMigration implements MigrationConfig { + name = '005-create-enrollments-table'; + version = '1.0.0'; + dependencies = ['001-create-users-table', '002-create-courses-table']; + + private readonly logger = new Logger(CreateEnrollmentsTableMigration.name); + + async up(connection: any): Promise { + this.logger.log('Applying migration: create enrollment table'); + + await connection.query(` + CREATE TABLE IF NOT EXISTS enrollment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES course(id) ON DELETE CASCADE, + progress FLOAT NOT NULL DEFAULT 0, + status VARCHAR(50) NOT NULL DEFAULT 'active', + enrolled_at TIMESTAMP NOT NULL DEFAULT NOW(), + last_accessed_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + `); + + await connection.query(`CREATE INDEX IF NOT EXISTS idx_enrollment_user_id ON enrollment (user_id);`); + await connection.query(`CREATE INDEX IF NOT EXISTS idx_enrollment_course_id ON enrollment (course_id);`); + await connection.query(`CREATE INDEX IF NOT EXISTS idx_enrollment_status ON enrollment (status);`); + await connection.query(`CREATE INDEX IF NOT EXISTS idx_enrollment_user_status ON enrollment (user_id, status);`); + await connection.query(`CREATE INDEX IF NOT EXISTS idx_enrollment_course_status ON enrollment (course_id, status);`); + + this.logger.log('Migration applied: create enrollment table'); + } + + async down(connection: any): Promise { + this.logger.log('Rolling back migration: create enrollment table'); + + await connection.query(`DROP TABLE IF EXISTS enrollment CASCADE;`); + + this.logger.log('Migration rolled back: create enrollment table'); + } +} diff --git a/src/migrations/samples/006-create-migrations-tracking-table.migration.ts b/src/migrations/samples/006-create-migrations-tracking-table.migration.ts new file mode 100644 index 00000000..a7cc898c --- /dev/null +++ b/src/migrations/samples/006-create-migrations-tracking-table.migration.ts @@ -0,0 +1,58 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { MigrationConfig } from '../migration.service'; + +/** + * Migration 006 — Create migrations tracking table + * + * up: Creates the `migrations` table used by MigrationService to track + * which migrations have been applied. + * down: Drops the `migrations` table. + * + * NOTE: This migration is intentionally self-referential — it creates the + * table that tracks itself. Run this first via a raw DB script or a + * bootstrap mechanism before the migration runner starts. + */ +@Injectable() +export class CreateMigrationsTrackingTableMigration implements MigrationConfig { + name = '006-create-migrations-tracking-table'; + version = '1.0.0'; + dependencies: string[] = []; + + private readonly logger = new Logger(CreateMigrationsTrackingTableMigration.name); + + async up(connection: any): Promise { + this.logger.log('Applying migration: create migrations tracking table'); + + await connection.query(` + DO $$ BEGIN + CREATE TYPE migration_status AS ENUM ('pending', 'completed', 'failed', 'rolled_back'); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await connection.query(` + CREATE TABLE IF NOT EXISTS migrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL UNIQUE, + version VARCHAR(50) NOT NULL, + status migration_status NOT NULL DEFAULT 'pending', + applied_at TIMESTAMP, + rolled_back_at TIMESTAMP, + error_message TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + `); + + this.logger.log('Migration applied: create migrations tracking table'); + } + + async down(connection: any): Promise { + this.logger.log('Rolling back migration: create migrations tracking table'); + + await connection.query(`DROP TABLE IF EXISTS migrations CASCADE;`); + await connection.query(`DROP TYPE IF EXISTS migration_status;`); + + this.logger.log('Migration rolled back: create migrations tracking table'); + } +}