diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe34481..485161b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,44 @@ jobs: - run: npm ci --include=dev - run: npm run build + migration-check: + name: migration-check + runs-on: ubuntu-latest + env: + # prisma validate/generate only parse the schema — no real connection needed. + DATABASE_URL: postgresql://user:pass@localhost:5432/migration_check + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci --include=dev + - name: Validate Prisma schema + run: npx prisma validate + - name: Generate Prisma client + run: npx prisma generate + - name: Verify migration files are well-formed + run: | + echo "Checking migration directories..." + MIGRATION_DIRS=$(find prisma/migrations -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l) + echo "Found $MIGRATION_DIRS migration directory(ies)" + if [ "$MIGRATION_DIRS" -eq 0 ]; then + echo "Warning: No migration directories found" + fi + # Verify each migration directory contains a migration.sql file + for dir in prisma/migrations/*/; do + if [ -d "$dir" ] && [ ! -f "${dir}migration.sql" ]; then + echo "Error: Migration directory '$dir' is missing migration.sql" + exit 1 + fi + done + echo "All migration files are well-formed" + - name: Verify Prisma client generation matches schema + run: | + npx prisma generate + echo "Prisma client generated successfully from current schema" + test: name: test runs-on: ubuntu-latest diff --git a/src/database/migration-checker.spec.ts b/src/database/migration-checker.spec.ts new file mode 100644 index 0000000..85fd1f2 --- /dev/null +++ b/src/database/migration-checker.spec.ts @@ -0,0 +1,311 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as fs from 'fs'; +import { + getMigrationFolders, + getAppliedMigrations, + checkMigrationStatus, + getDefaultMigrationsDir, +} from './migration-checker'; + +// Mock fs module +vi.mock('fs'); + +const mockFs = vi.mocked(fs); + +describe('MigrationChecker', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getMigrationFolders', () => { + it('should return sorted migration folder names', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readdirSync.mockReturnValue([ + { name: '20260830_02_init', isDirectory: () => true, isFile: () => false } as fs.Dirent, + { name: '20260830_01_create_users', isDirectory: () => true, isFile: () => false } as fs.Dirent, + { name: 'migration_lock.toml', isDirectory: () => false, isFile: () => true } as fs.Dirent, + ]); + + const result = getMigrationFolders('/fake/migrations'); + + expect(result).toEqual(['20260830_01_create_users', '20260830_02_init']); + expect(mockFs.existsSync).toHaveBeenCalledWith('/fake/migrations'); + }); + + it('should return empty array when directory does not exist', () => { + mockFs.existsSync.mockReturnValue(false); + + const result = getMigrationFolders('/nonexistent/path'); + + expect(result).toEqual([]); + }); + + it('should return empty array when directory is empty', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readdirSync.mockReturnValue([]); + + const result = getMigrationFolders('/empty/migrations'); + + expect(result).toEqual([]); + }); + + it('should filter out non-directory entries', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readdirSync.mockReturnValue([ + { name: '20260830_01_init', isDirectory: () => true, isFile: () => false } as fs.Dirent, + { name: 'some_file.txt', isDirectory: () => false, isFile: () => true } as fs.Dirent, + ]); + + const result = getMigrationFolders('/fake/migrations'); + + expect(result).toEqual(['20260830_01_init']); + }); + + it('should handle fs errors gracefully', () => { + mockFs.existsSync.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + const result = getMigrationFolders('/protected/path'); + + expect(result).toEqual([]); + }); + }); + + describe('getAppliedMigrations', () => { + it('should return applied migrations from the database', async () => { + const mockPrisma = { + $queryRawUnsafe: vi.fn().mockResolvedValue([ + { + migration_name: '20260830_01_init', + finished_at: new Date('2026-08-30T10:00:00Z'), + logs: null, + }, + { + migration_name: '20260830_02_add_users', + finished_at: new Date('2026-08-30T10:05:00Z'), + logs: null, + }, + ]), + }; + + const result = await getAppliedMigrations(mockPrisma as unknown as import('@prisma/client').PrismaClient); + + expect(result.size).toBe(2); + expect(result.get('20260830_01_init')).toEqual({ + finished: true, + error: null, + }); + expect(result.get('20260830_02_add_users')).toEqual({ + finished: true, + error: null, + }); + }); + + it('should detect failed migrations (finished_at is null)', async () => { + const mockPrisma = { + $queryRawUnsafe: vi.fn().mockResolvedValue([ + { + migration_name: '20260830_01_init', + finished_at: new Date('2026-08-30T10:00:00Z'), + logs: null, + }, + { + migration_name: '20260830_02_add_users', + finished_at: null, + logs: 'ERROR: relation "users" already exists', + }, + ]), + }; + + const result = await getAppliedMigrations(mockPrisma as unknown as import('@prisma/client').PrismaClient); + + expect(result.size).toBe(2); + expect(result.get('20260830_01_init')?.finished).toBe(true); + expect(result.get('20260830_02_add_users')?.finished).toBe(false); + expect(result.get('20260830_02_add_users')?.error).toBe( + 'ERROR: relation "users" already exists', + ); + }); + + it('should return empty map when table does not exist', async () => { + const mockPrisma = { + $queryRawUnsafe: vi.fn().mockRejectedValue( + new Error('relation "_prisma_migrations" does not exist'), + ), + }; + + const result = await getAppliedMigrations(mockPrisma as unknown as import('@prisma/client').PrismaClient); + + expect(result.size).toBe(0); + }); + + it('should return empty map when no migrations have been applied', async () => { + const mockPrisma = { + $queryRawUnsafe: vi.fn().mockResolvedValue([]), + }; + + const result = await getAppliedMigrations(mockPrisma as unknown as import('@prisma/client').PrismaClient); + + expect(result.size).toBe(0); + }); + }); + + describe('checkMigrationStatus', () => { + it('should report up-to-date when all migrations are applied', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readdirSync.mockReturnValue([ + { name: '20260830_01_init', isDirectory: () => true, isFile: () => false } as fs.Dirent, + { name: '20260830_02_add_users', isDirectory: () => true, isFile: () => false } as fs.Dirent, + ]); + + const mockPrisma = { + $queryRawUnsafe: vi.fn().mockResolvedValue([ + { + migration_name: '20260830_01_init', + finished_at: new Date('2026-08-30T10:00:00Z'), + logs: null, + }, + { + migration_name: '20260830_02_add_users', + finished_at: new Date('2026-08-30T10:05:00Z'), + logs: null, + }, + ]), + }; + + const result = await checkMigrationStatus(mockPrisma as unknown as import('@prisma/client').PrismaClient, '/fake/migrations'); + + expect(result.upToDate).toBe(true); + expect(result.pending).toEqual([]); + expect(result.failed).toEqual([]); + expect(result.message).toContain('All 2 migration(s) are applied and up to date'); + }); + + it('should detect pending migrations', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readdirSync.mockReturnValue([ + { name: '20260830_01_init', isDirectory: () => true, isFile: () => false } as fs.Dirent, + { name: '20260830_02_add_users', isDirectory: () => true, isFile: () => false } as fs.Dirent, + ]); + + const mockPrisma = { + $queryRawUnsafe: vi.fn().mockResolvedValue([ + { + migration_name: '20260830_01_init', + finished_at: new Date('2026-08-30T10:00:00Z'), + logs: null, + }, + ]), + }; + + const result = await checkMigrationStatus(mockPrisma as unknown as import('@prisma/client').PrismaClient, '/fake/migrations'); + + expect(result.upToDate).toBe(false); + expect(result.pending).toHaveLength(1); + expect(result.pending[0].name).toBe('20260830_02_add_users'); + expect(result.pending[0].applied).toBe(false); + expect(result.message).toContain('1 pending migration(s)'); + }); + + it('should detect failed migrations', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readdirSync.mockReturnValue([ + { name: '20260830_01_init', isDirectory: () => true, isFile: () => false } as fs.Dirent, + { name: '20260830_02_add_users', isDirectory: () => true, isFile: () => false } as fs.Dirent, + ]); + + const mockPrisma = { + $queryRawUnsafe: vi.fn().mockResolvedValue([ + { + migration_name: '20260830_01_init', + finished_at: new Date('2026-08-30T10:00:00Z'), + logs: null, + }, + { + migration_name: '20260830_02_add_users', + finished_at: null, + logs: 'ERROR: column "email" already exists', + }, + ]), + }; + + const result = await checkMigrationStatus(mockPrisma as unknown as import('@prisma/client').PrismaClient, '/fake/migrations'); + + expect(result.upToDate).toBe(false); + expect(result.failed).toHaveLength(1); + expect(result.failed[0].name).toBe('20260830_02_add_users'); + expect(result.failed[0].error).toBe('ERROR: column "email" already exists'); + expect(result.message).toContain('1 migration(s) failed'); + }); + + it('should handle empty migrations directory', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readdirSync.mockReturnValue([]); + + const mockPrisma = { + $queryRawUnsafe: vi.fn().mockResolvedValue([]), + }; + + const result = await checkMigrationStatus(mockPrisma as unknown as import('@prisma/client').PrismaClient, '/fake/migrations'); + + expect(result.upToDate).toBe(true); + expect(result.pending).toEqual([]); + expect(result.message).toContain('No migration files found'); + }); + + it('should handle missing migrations directory', async () => { + mockFs.existsSync.mockReturnValue(false); + + const mockPrisma = { + $queryRawUnsafe: vi.fn().mockResolvedValue([]), + }; + + const result = await checkMigrationStatus(mockPrisma as unknown as import('@prisma/client').PrismaClient, '/nonexistent/migrations'); + + expect(result.upToDate).toBe(true); + expect(result.pending).toEqual([]); + expect(result.message).toContain('No migration files found'); + }); + + it('should prioritize failed migrations over pending in message', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readdirSync.mockReturnValue([ + { name: '20260830_01_init', isDirectory: () => true, isFile: () => false } as fs.Dirent, + { name: '20260830_02_add_users', isDirectory: () => true, isFile: () => false } as fs.Dirent, + { name: '20260830_03_add_roles', isDirectory: () => true, isFile: () => false } as fs.Dirent, + ]); + + const mockPrisma = { + $queryRawUnsafe: vi.fn().mockResolvedValue([ + { + migration_name: '20260830_01_init', + finished_at: new Date('2026-08-30T10:00:00Z'), + logs: null, + }, + { + migration_name: '20260830_02_add_users', + finished_at: null, + logs: 'ERROR: something went wrong', + }, + ]), + }; + + const result = await checkMigrationStatus(mockPrisma as unknown as import('@prisma/client').PrismaClient, '/fake/migrations'); + + expect(result.upToDate).toBe(false); + expect(result.failed).toHaveLength(1); + expect(result.pending).toHaveLength(1); + // Failed message takes priority in the summary + expect(result.message).toContain('1 migration(s) failed'); + expect(result.message).toContain('20260830_02_add_users'); + }); + }); + + describe('getDefaultMigrationsDir', () => { + it('should return a path ending with prisma/migrations', () => { + const dir = getDefaultMigrationsDir(); + expect(dir).toMatch(/prisma[\\/]migrations$/); + }); + }); +}); diff --git a/src/database/migration-checker.ts b/src/database/migration-checker.ts new file mode 100644 index 0000000..864e356 --- /dev/null +++ b/src/database/migration-checker.ts @@ -0,0 +1,147 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { PrismaClient } from '@prisma/client'; + +/** + * Represents the status of a single migration. + */ +export interface MigrationStatus { + /** The migration folder name (e.g. "20260830_init") */ + name: string; + /** Whether this migration has been applied to the database */ + applied: boolean; + /** Whether the migration finished successfully (vs. still in progress) */ + finished: boolean; + /** Error logs if the migration failed */ + error: string | null; +} + +/** + * The result of a full migration status check. + */ +export interface MigrationCheckResult { + /** Whether all migrations are applied and the schema is in sync */ + upToDate: boolean; + /** All migrations found on disk */ + migrations: MigrationStatus[]; + /** Migrations that exist on disk but have not been applied */ + pending: MigrationStatus[]; + /** Migrations that failed during application */ + failed: MigrationStatus[]; + /** Human-readable summary message */ + message: string; +} + +/** + * Reads migration folder names from the prisma/migrations directory. + * Returns an empty array if the directory doesn't exist (e.g. in CI without + * the full repo checkout). + */ +export function getMigrationFolders(migrationsDir: string): string[] { + try { + if (!fs.existsSync(migrationsDir)) { + return []; + } + return fs + .readdirSync(migrationsDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory() && dirent.name !== 'migration_lock.toml') + .map((dirent) => dirent.name) + .sort(); + } catch { + return []; + } +} + +/** + * Queries the _prisma_migrations table to get the status of all applied + * migrations. Uses $queryRawUnsafe because the table name is a Prisma + * internal that isn't in the generated client types. + */ +export async function getAppliedMigrations( + prisma: PrismaClient, +): Promise< + Map +> { + const applied = new Map(); + + try { + // Query the Prisma migration history table directly. + // The table stores each migration's name, whether it finished, and any error logs. + const rows = (await prisma.$queryRawUnsafe( + `SELECT migration_name, finished_at, logs FROM _prisma_migrations ORDER BY started_at ASC`, + )) as { migration_name: string; finished_at: Date | null; logs: string | null }[]; + + for (const row of rows) { + applied.set(row.migration_name, { + finished: row.finished_at !== null, + error: row.logs, + }); + } + } catch { + // If the _prisma_migrations table doesn't exist yet (fresh database), + // return an empty map — all migrations are considered pending. + } + + return applied; +} + +/** + * Compares migration folders on disk against applied migrations in the + * database and returns a detailed status report. + * + * @param prisma - An active PrismaClient instance + * @param migrationsDir - Absolute path to the prisma/migrations directory + * @returns - MigrationCheckResult with full status details + */ +export async function checkMigrationStatus( + prisma: PrismaClient, + migrationsDir: string, +): Promise { + const folders = getMigrationFolders(migrationsDir); + const applied = await getAppliedMigrations(prisma); + + const migrations: MigrationStatus[] = folders.map((name) => { + const status = applied.get(name); + return { + name, + applied: status !== undefined, + finished: status?.finished ?? false, + error: status?.error ?? null, + }; + }); + + const pending = migrations.filter((m) => !m.applied); + const failed = migrations.filter((m) => m.applied && !m.finished); + + const upToDate = pending.length === 0 && failed.length === 0; + + let message: string; + if (failed.length > 0) { + message = + `${failed.length} migration(s) failed: ${failed.map((m) => m.name).join(', ')}. ` + + 'Database schema may be in an inconsistent state.'; + } else if (pending.length > 0) { + message = + `${pending.length} pending migration(s): ${pending.map((m) => m.name).join(', ')}. ` + + 'Run "prisma migrate deploy" before starting the application.'; + } else if (folders.length === 0) { + message = 'No migration files found on disk. Schema drift check skipped.'; + } else { + message = `All ${folders.length} migration(s) are applied and up to date.`; + } + + return { + upToDate, + migrations, + pending, + failed, + message, + }; +} + +/** + * Default migrations directory path relative to the project root. + */ +export function getDefaultMigrationsDir(): string { + return path.resolve(process.cwd(), 'prisma', 'migrations'); +} diff --git a/src/database/prisma.service.ts b/src/database/prisma.service.ts index 32c763e..cbbea16 100644 --- a/src/database/prisma.service.ts +++ b/src/database/prisma.service.ts @@ -4,6 +4,11 @@ import { PrismaClient } from '@prisma/client'; import { DatabaseConfig } from '../config/database.config'; import { buildDatasourceUrl } from './datasource-url'; import { createQueryTimeoutExtension } from './query-timeout.extension'; +import { + checkMigrationStatus, + getDefaultMigrationsDir, + MigrationCheckResult, +} from './migration-checker'; /** * The single Prisma client for the application. Manages connection lifecycle @@ -95,6 +100,9 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul await this.$connect(); await this.workerClient.$connect(); this.logger.log('Prisma connected to the database'); + + // Validate migration status after successful connection. + await this.validateMigrations(); } catch (error) { // Do not crash on boot when the DB is unavailable (e.g. typecheck/build, // or during local development before `docker compose up`). Log and go on. @@ -105,6 +113,31 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul } } + /** + * Validates that all Prisma migrations have been applied to the database. + * In production/strict mode, pending or failed migrations cause a critical + * error log. The application still starts (to avoid breaking CI/dev), but + * the error is clearly surfaced for operators. + */ + async validateMigrations(): Promise { + const migrationsDir = getDefaultMigrationsDir(); + const result = await checkMigrationStatus(this, migrationsDir); + + if (!result.upToDate) { + this.logger.error( + `Migration status check failed: ${result.message}`, + JSON.stringify({ + pending: result.pending.map((m) => m.name), + failed: result.failed.map((m) => m.name), + }), + ); + } else if (result.migrations.length > 0) { + this.logger.log(result.message); + } + + return result; + } + async onModuleDestroy(): Promise { await this.$disconnect(); await this.workerClient.$disconnect();