UNPKG

prisma-migrations

Version:

A Node.js library to manage Prisma ORM migrations like other ORMs

1 lines 87.1 kB
{"version":3,"sources":["../src/cli.ts","../src/config.ts","../src/file-manager.ts","../src/database-adapter.ts","../src/version-manager.ts","../src/commit-manager.ts","../src/migration-manager.ts"],"sourcesContent":["import { Command } from \"commander\";\nimport { MigrationManager } from \"./migration-manager\";\n\nconst program = new Command();\n\n// Helper function to get manager instance when needed\nfunction getManager() {\n return new MigrationManager();\n}\n\nprogram.version(\"1.0.0\").description(\"Prisma Migrations CLI\");\n\nprogram\n .command(\"create <name>\")\n .description(\"Create a new migration\")\n .action(async (name: string) => {\n try {\n const manager = getManager();\n await manager.createMigration({ name });\n console.log(`Migration '${name}' created successfully.`);\n } catch (error) {\n console.error(\n `Error creating migration: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n });\n\nprogram\n .command(\"up\")\n .description(\"Run all pending migrations\")\n .option(\"-t, --to <timestamp>\", \"Run up to a specific migration\")\n .option(\n \"-s, --steps <number>\",\n \"Run a specific number of migrations\",\n parseInt,\n )\n .option(\"-d, --dry-run\", \"Preview migrations without applying\")\n .action(async ({ to, steps, dryRun }) => {\n try {\n const manager = getManager();\n const result = await manager.runMigrations({ to, steps, dryRun });\n console.log(`Migrations applied successfully: ${result.success}`);\n } catch (error) {\n console.error(\n `Error running migrations: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n });\n\nprogram\n .command(\"down\")\n .description(\"Rollback migrations\")\n .option(\"-t, --to <timestamp>\", \"Rollback to a specific migration\")\n .option(\n \"-s, --steps <number>\",\n \"Rollback a specific number of migrations\",\n parseInt,\n )\n .option(\"-d, --dry-run\", \"Preview rollback without applying\")\n .action(async ({ to, steps, dryRun }) => {\n try {\n const manager = getManager();\n const result = await manager.rollbackMigrations({ to, steps, dryRun });\n console.log(`Migrations rolled back successfully: ${result.success}`);\n } catch (error) {\n console.error(\n `Error rolling back migrations: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n });\n\nprogram\n .command(\"status\")\n .description(\"Get migration status\")\n .action(async () => {\n try {\n const manager = getManager();\n const status = await manager.getMigrationStatus();\n status.forEach(({ name, status, appliedAt }) => {\n console.log(\n `${name} [${status}] - ${appliedAt ? appliedAt : \"Pending\"}`,\n );\n });\n } catch (error) {\n console.error(\n `Error retrieving status: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n });\n\nprogram\n .command(\"test\")\n .description(\"Test database connection\")\n .action(async () => {\n try {\n const manager = getManager();\n const result = await manager.testConnection();\n console.log(`Database connection successful: ${result}`);\n } catch (error) {\n console.error(\n `Error testing connection: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n });\n\nprogram.parse(process.argv);\n","import { existsSync, readFileSync } from \"fs\";\nimport { join } from \"path\";\nimport { pathToFileURL } from \"url\";\nimport { MigrationConfig } from \"./types\";\n\nexport class ConfigManager {\n private config: MigrationConfig;\n private configPromise: Promise<MigrationConfig>;\n\n constructor(configPath?: string) {\n this.configPromise = this.loadConfig(configPath);\n // Initialize with default config synchronously for backwards compatibility\n this.config = this.getDefaultConfig();\n }\n\n private getDefaultConfig(): MigrationConfig {\n return {\n migrationsDir: \"./migrations\",\n schemaPath: \"./prisma/schema.prisma\",\n tableName: \"_prisma_migrations\",\n createTable: true,\n migrationFormat: \"ts\",\n extension: \".ts\",\n };\n }\n\n private async loadConfig(configPath?: string): Promise<MigrationConfig> {\n const defaultConfig: MigrationConfig = {\n migrationsDir: \"./migrations\",\n schemaPath: \"./prisma/schema.prisma\",\n tableName: \"_prisma_migrations\",\n createTable: true,\n migrationFormat: \"ts\",\n extension: \".ts\",\n };\n\n // Try to load from package.json\n const packageJsonPath = join(process.cwd(), \"package.json\");\n if (existsSync(packageJsonPath)) {\n try {\n const packageJson = JSON.parse(readFileSync(packageJsonPath, \"utf-8\"));\n if (packageJson.prismaMigrations) {\n return { ...defaultConfig, ...packageJson.prismaMigrations };\n }\n } catch {}\n }\n\n // Try to load from config file\n const configFile = configPath || this.findConfigFile();\n if (configFile && existsSync(configFile)) {\n try {\n if (configFile.endsWith(\".ts\")) {\n // For TypeScript config files, we'd need tsx or compilation\n console.warn(\n \"TypeScript config files require tsx. Please use .mjs config files or ensure tsx is available.\",\n );\n } else if (configFile.endsWith(\".mjs\") || configFile.endsWith(\".js\")) {\n // Use dynamic import for ESM files\n const configModule = await import(pathToFileURL(configFile).href);\n const config = configModule.default || configModule;\n return { ...defaultConfig, ...config };\n } else {\n // JSON files\n const configContent = readFileSync(configFile, \"utf-8\");\n const config = JSON.parse(configContent);\n return { ...defaultConfig, ...config };\n }\n } catch (error) {\n console.warn(`Failed to load config file ${configFile}:`, error);\n }\n }\n\n // Try to load from prisma directory\n const prismaDir = join(process.cwd(), \"prisma\");\n if (existsSync(prismaDir)) {\n const schemaPath = join(prismaDir, \"schema.prisma\");\n if (existsSync(schemaPath)) {\n defaultConfig.schemaPath = schemaPath;\n }\n }\n\n return defaultConfig;\n }\n\n private findConfigFile(): string | null {\n const possibleFiles = [\n join(process.cwd(), \"prisma-migrations.config.js\"),\n join(process.cwd(), \"prisma-migrations.config.ts\"),\n join(process.cwd(), \"prisma-migrations.config.mjs\"),\n join(process.cwd(), \"prisma-migrations.config.json\"),\n ];\n\n for (const file of possibleFiles) {\n if (existsSync(file)) {\n return file;\n }\n }\n\n return null;\n }\n\n public getConfig(): MigrationConfig {\n return this.config;\n }\n\n public async getConfigAsync(): Promise<MigrationConfig> {\n this.config = await this.configPromise;\n return this.config;\n }\n\n public updateConfig(updates: Partial<MigrationConfig>): void {\n this.config = { ...this.config, ...updates };\n }\n\n public getDatabaseUrl(): string {\n if (this.config.databaseUrl) {\n return this.config.databaseUrl;\n }\n\n // Try to get from environment\n const envUrl = process.env.DATABASE_URL;\n if (envUrl) {\n return envUrl;\n }\n\n // Try to parse from schema.prisma\n if (existsSync(this.config.schemaPath)) {\n try {\n const schema = readFileSync(this.config.schemaPath, \"utf-8\");\n const urlMatch = schema.match(/url\\s*=\\s*env\\(\"([^\"]+)\"\\)/);\n if (urlMatch) {\n const envVar = urlMatch[1];\n const envValue = process.env[envVar];\n if (envValue) {\n return envValue;\n }\n }\n } catch {}\n }\n\n throw new Error(\n \"Database URL not found. Please set DATABASE_URL environment variable or configure it in your config file.\",\n );\n }\n}\n","import {\n existsSync,\n mkdirSync,\n readFileSync,\n writeFileSync,\n readdirSync,\n unlinkSync,\n} from \"fs\";\nimport { join } from \"path\";\nimport {\n MigrationFile,\n MigrationTemplate,\n MigrationConfig,\n FunctionMigrationTemplate,\n} from \"./types\";\n\nexport class FileManager {\n private migrationsDir: string;\n private config: MigrationConfig;\n\n constructor(migrationsDir: string, config: MigrationConfig) {\n this.migrationsDir = migrationsDir;\n this.config = config;\n this.ensureDirectoryExists();\n }\n\n private ensureDirectoryExists(): void {\n if (!existsSync(this.migrationsDir)) {\n mkdirSync(this.migrationsDir, { recursive: true });\n }\n }\n\n public createMigrationFile(\n name: string,\n template?: MigrationTemplate | FunctionMigrationTemplate,\n ): MigrationFile {\n const timestamp = this.generateTimestamp();\n const format = this.config.migrationFormat || \"ts\";\n const extension = this.config.extension || `.${format}`;\n const filename = `${timestamp}_${name}${extension}`;\n const filePath = join(this.migrationsDir, filename);\n\n let content: string;\n\n if (format === \"sql\") {\n const defaultTemplate: MigrationTemplate = {\n up: `-- Migration: ${name}\\n-- Created at: ${new Date().toISOString()}\\n\\n-- Add your migration SQL here\\n`,\n down: `-- Rollback for: ${name}\\n-- Created at: ${new Date().toISOString()}\\n\\n-- Add your rollback SQL here\\n`,\n };\n content = this.formatSqlMigrationContent(\n (template as MigrationTemplate) || defaultTemplate,\n );\n } else {\n content = this.formatJsMigrationContent(\n name,\n format as \"js\" | \"ts\",\n template as FunctionMigrationTemplate,\n );\n }\n\n writeFileSync(filePath, content, \"utf-8\");\n\n return {\n path: filePath,\n content,\n timestamp,\n name,\n type: format,\n };\n }\n\n public readMigrationFiles(): MigrationFile[] {\n const files = readdirSync(this.migrationsDir)\n .filter(\n (file) =>\n file.endsWith(\".sql\") || file.endsWith(\".js\") || file.endsWith(\".ts\"),\n )\n .sort();\n\n return files.map((file) => {\n const filePath = join(this.migrationsDir, file);\n const content = readFileSync(filePath, \"utf-8\");\n const match = file.match(/^(\\d+)_(.+)\\.(sql|js|ts)$/);\n\n if (!match) {\n throw new Error(`Invalid migration file format: ${file}`);\n }\n\n const [, timestamp, name, type] = match;\n\n return {\n path: filePath,\n content,\n timestamp,\n name,\n type: type as \"sql\" | \"js\" | \"ts\",\n };\n });\n }\n\n public getMigrationFile(timestamp: string): MigrationFile | null {\n const files = this.readMigrationFiles();\n return files.find((file) => file.timestamp === timestamp) || null;\n }\n\n public parseMigrationContent(migrationFile: MigrationFile): {\n up: string;\n down: string;\n } {\n if (migrationFile.type === \"sql\") {\n return this.parseSqlMigrationContent(migrationFile.content);\n } else {\n // For JS/TS files, we'll need to execute them to get the SQL\n return this.parseJsMigrationContent(migrationFile);\n }\n }\n\n private parseSqlMigrationContent(content: string): {\n up: string;\n down: string;\n } {\n const upMatch = content.match(/-- UP\\s*\\n([\\s\\S]*?)(?=-- DOWN|$)/);\n const downMatch = content.match(/-- DOWN\\s*\\n([\\s\\S]*?)$/);\n\n return {\n up: upMatch ? upMatch[1].trim() : content.trim(),\n down: downMatch ? downMatch[1].trim() : \"\",\n };\n }\n\n private parseJsMigrationContent(_migrationFile: MigrationFile): {\n up: string;\n down: string;\n } {\n // For JS/TS migrations, we need to load the module and execute the functions\n // This will be handled by the DatabaseAdapter when it needs to execute migrations\n return {\n up: \"\",\n down: \"\",\n };\n }\n\n private formatSqlMigrationContent(template: MigrationTemplate): string {\n return `-- UP\\n${template.up}\\n\\n-- DOWN\\n${template.down}\\n`;\n }\n\n private formatJsMigrationContent(\n name: string,\n format: \"js\" | \"ts\",\n _template?: FunctionMigrationTemplate,\n ): string {\n // Always use the default template for now\n return this.generatePrismaMigrationTemplate(name, format);\n }\n\n private generatePrismaMigrationTemplate(\n name: string,\n format: \"js\" | \"ts\",\n ): string {\n const isTypeScript = format === \"ts\";\n\n if (isTypeScript) {\n return `import { PrismaClient } from '@prisma/client';\n\n/**\n * Migration: ${name}\n * Created at: ${new Date().toISOString()}\n */\n\nexport async function up(prisma: PrismaClient): Promise<void> {\n // Add your migration logic here\n // Example - Raw SQL:\n // await prisma.$executeRaw\\`\n // CREATE TABLE users (\n // id SERIAL PRIMARY KEY,\n // email VARCHAR(255) UNIQUE NOT NULL,\n // created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n // updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n // )\n // \\`;\n \n // Example - Using Prisma operations:\n // await prisma.user.createMany({\n // data: [\n // { email: 'admin@example.com' },\n // { email: 'user@example.com' }\n // ]\n // });\n}\n\nexport async function down(prisma: PrismaClient): Promise<void> {\n // Add your rollback logic here\n // Example:\n // await prisma.$executeRaw\\`DROP TABLE IF EXISTS users\\`;\n}\n`;\n } else {\n return `// @ts-check\n\n/**\n * Migration: ${name}\n * Created at: ${new Date().toISOString()}\n */\n\n/**\n * @param {import('@prisma/client').PrismaClient} prisma\n */\nexports.up = async function(prisma) {\n // Add your migration logic here\n // Example - Raw SQL:\n // await prisma.$executeRaw\\`\n // CREATE TABLE users (\n // id SERIAL PRIMARY KEY,\n // email VARCHAR(255) UNIQUE NOT NULL,\n // created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n // updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n // )\n // \\`;\n \n // Example - Using Prisma operations:\n // await prisma.user.createMany({\n // data: [\n // { email: 'admin@example.com' },\n // { email: 'user@example.com' }\n // ]\n // });\n};\n\n/**\n * @param {import('@prisma/client').PrismaClient} prisma\n */\nexports.down = async function(prisma) {\n // Add your rollback logic here\n // Example:\n // await prisma.$executeRaw\\`DROP TABLE IF EXISTS users\\`;\n};\n`;\n }\n }\n\n private generateTimestamp(): string {\n const now = new Date();\n const year = now.getFullYear();\n const month = String(now.getMonth() + 1).padStart(2, \"0\");\n const day = String(now.getDate()).padStart(2, \"0\");\n const hours = String(now.getHours()).padStart(2, \"0\");\n const minutes = String(now.getMinutes()).padStart(2, \"0\");\n const seconds = String(now.getSeconds()).padStart(2, \"0\");\n\n return `${year}${month}${day}${hours}${minutes}${seconds}`;\n }\n\n public getLatestMigration(): MigrationFile | null {\n const files = this.readMigrationFiles();\n return files.length > 0 ? files[files.length - 1] : null;\n }\n\n public getMigrationByName(name: string): MigrationFile | null {\n const files = this.readMigrationFiles();\n return files.find((file) => file.name === name) || null;\n }\n\n public deleteMigrationFile(timestamp: string): boolean {\n const file = this.getMigrationFile(timestamp);\n if (file && existsSync(file.path)) {\n unlinkSync(file.path);\n return true;\n }\n return false;\n }\n}\n","import { PrismaClient } from \"@prisma/client\";\nimport {\n Migration,\n MigrationStatus,\n MigrationFile,\n PrismaMigration,\n} from \"./types\";\nimport { resolve } from \"path\";\n\nexport class DatabaseAdapter {\n private prisma: PrismaClient;\n private tableName: string;\n private isPrismaTable: boolean | null = null;\n\n constructor(databaseUrl: string, tableName: string = \"_prisma_migrations\") {\n this.prisma = new PrismaClient({\n datasources: {\n db: {\n url: databaseUrl,\n },\n },\n });\n this.tableName = tableName;\n }\n\n public async connect(): Promise<void> {\n await this.prisma.$connect();\n }\n\n public async disconnect(): Promise<void> {\n await this.prisma.$disconnect();\n }\n\n private async detectPrismaTable(): Promise<boolean> {\n if (this.isPrismaTable !== null) {\n return this.isPrismaTable;\n }\n\n try {\n // Check if table exists and has Prisma's structure (migration_name column)\n const columnExists = (await this.prisma.$queryRawUnsafe(`\n SELECT COUNT(*) as count\n FROM information_schema.columns\n WHERE table_name = '${this.tableName}' AND column_name = 'migration_name'\n `)) as any[];\n\n this.isPrismaTable = columnExists[0].count > 0;\n return this.isPrismaTable;\n } catch {\n this.isPrismaTable = false;\n return false;\n }\n }\n\n public async ensureMigrationsTable(): Promise<void> {\n const isPrismaTable = await this.detectPrismaTable();\n\n if (isPrismaTable) {\n // This is Prisma's migrations table, don't create our own\n return;\n }\n\n // Create our custom table structure for non-Prisma usage\n const createTableQuery = `\n CREATE TABLE IF NOT EXISTS ${this.tableName} (\n id VARCHAR(255) PRIMARY KEY,\n name VARCHAR(255) NOT NULL,\n applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n checksum VARCHAR(255)\n )\n `;\n\n await this.prisma.$executeRawUnsafe(createTableQuery);\n }\n\n public async getAppliedMigrations(): Promise<Migration[]> {\n const isPrismaTable = await this.detectPrismaTable();\n\n if (isPrismaTable) {\n // Use Prisma's table structure\n const results = (await this.prisma.$queryRawUnsafe(`\n SELECT id, migration_name, started_at as appliedAt\n FROM ${this.tableName}\n ORDER BY started_at ASC\n `)) as any[];\n\n return results.map((row) => ({\n id: row.id,\n name: row.migration_name,\n filename: `${row.id}_${row.migration_name}.sql`,\n timestamp: new Date(row.id.substring(0, 8)),\n applied: true,\n appliedAt: row.appliedAt,\n }));\n } else {\n // Use custom table structure\n const results = (await this.prisma.$queryRawUnsafe(`\n SELECT id, name, applied_at as appliedAt\n FROM ${this.tableName}\n ORDER BY applied_at ASC\n `)) as any[];\n\n return results.map((row) => ({\n id: row.id,\n name: row.name,\n filename: `${row.id}_${row.name}.sql`,\n timestamp: new Date(row.id.substring(0, 8)),\n applied: true,\n appliedAt: row.appliedAt,\n }));\n }\n }\n\n public async isMigrationApplied(migrationId: string): Promise<boolean> {\n const result = (await this.prisma.$queryRawUnsafe(\n `\n SELECT COUNT(*) as count\n FROM ${this.tableName}\n WHERE id = ?\n `,\n migrationId,\n )) as any[];\n\n return result[0].count > 0;\n }\n\n public async recordMigration(\n migrationId: string,\n name: string,\n ): Promise<void> {\n const isPrismaTable = await this.detectPrismaTable();\n\n if (isPrismaTable) {\n // Don't insert into Prisma's migration table - it's managed by Prisma\n // This is read-only for compatibility\n return;\n } else {\n // Use custom table structure\n await this.prisma.$executeRawUnsafe(\n `\n INSERT INTO ${this.tableName} (id, name)\n VALUES (?, ?)\n `,\n migrationId,\n name,\n );\n }\n }\n\n public async removeMigration(migrationId: string): Promise<void> {\n const isPrismaTable = await this.detectPrismaTable();\n\n if (isPrismaTable) {\n // Don't remove from Prisma's migration table - it's managed by Prisma\n // This is read-only for compatibility\n return;\n } else {\n // Use custom table structure\n await this.prisma.$executeRawUnsafe(\n `\n DELETE FROM ${this.tableName}\n WHERE id = ?\n `,\n migrationId,\n );\n }\n }\n\n public async executeMigration(sql: string): Promise<void> {\n // Split SQL into individual statements\n const statements = sql\n .split(\";\")\n .map((stmt) => stmt.trim())\n .filter((stmt) => stmt.length > 0);\n\n for (const statement of statements) {\n await this.prisma.$executeRawUnsafe(statement);\n }\n }\n\n public async executeMigrationFile(\n migrationFile: MigrationFile,\n direction: \"up\" | \"down\",\n ): Promise<void> {\n if (migrationFile.type === \"sql\") {\n throw new Error(\"Use executeMigration() for SQL files\");\n }\n\n // Load and execute the JavaScript/TypeScript migration\n const migration = await this.loadMigrationModule(migrationFile.path);\n\n if (direction === \"up\") {\n await migration.up(this.prisma);\n } else {\n await migration.down(this.prisma);\n }\n }\n\n private async loadMigrationModule(\n filePath: string,\n ): Promise<PrismaMigration> {\n try {\n // Clear module cache to ensure fresh loads during development\n const resolvedPath = resolve(filePath);\n delete require.cache[resolvedPath];\n\n // For TypeScript files, we need tsx to be available\n if (filePath.endsWith(\".ts\")) {\n // Check if tsx is available\n try {\n require.resolve(\"tsx\");\n } catch {\n throw new Error(\n \"tsx is required to run TypeScript migrations. Install it with: npm install tsx\",\n );\n }\n\n // Import the TypeScript module directly\n // tsx should be configured to handle .ts files via require hooks or similar\n const module = await import(resolvedPath);\n\n if (!module.up || !module.down) {\n throw new Error(\n `TypeScript migration ${filePath} must export both 'up' and 'down' functions`,\n );\n }\n\n return {\n up: module.up,\n down: module.down,\n };\n } else {\n // JavaScript file\n const module = await import(resolvedPath);\n const up = module.up || module.default?.up || module.exports?.up;\n const down =\n module.down || module.default?.down || module.exports?.down;\n\n if (!up || !down) {\n throw new Error(\n `JavaScript migration ${filePath} must export both 'up' and 'down' functions`,\n );\n }\n\n return { up, down };\n }\n } catch (error) {\n if (\n error instanceof Error &&\n (error.message.includes(\"tsx is required\") ||\n error.message.includes(\"must export\"))\n ) {\n throw error;\n }\n\n const fileType = filePath.endsWith(\".ts\") ? \"TypeScript\" : \"JavaScript\";\n const suggestion = filePath.endsWith(\".ts\")\n ? \"Make sure tsx is installed and the file exports 'up' and 'down' functions.\"\n : \"Make sure the file exports 'up' and 'down' functions.\";\n\n throw new Error(\n `Failed to load ${fileType} migration ${filePath}. ${suggestion} Error: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n }\n\n public async executeInTransaction(\n callback: () => Promise<void>,\n ): Promise<void> {\n await this.prisma.$transaction(async (_tx: any) => {\n await callback();\n });\n }\n\n public async testConnection(): Promise<boolean> {\n try {\n await this.prisma.$queryRaw`SELECT 1`;\n return true;\n } catch {\n return false;\n }\n }\n\n public async getLastMigration(): Promise<Migration | null> {\n const isPrismaTable = await this.detectPrismaTable();\n\n if (isPrismaTable) {\n // Use Prisma's table structure\n const result = (await this.prisma.$queryRawUnsafe(`\n SELECT id, migration_name, started_at as appliedAt\n FROM ${this.tableName}\n ORDER BY started_at DESC\n LIMIT 1\n `)) as any[];\n\n if (result.length === 0) {\n return null;\n }\n\n const row = result[0];\n return {\n id: row.id,\n name: row.migration_name,\n filename: `${row.id}_${row.migration_name}.sql`,\n timestamp: new Date(row.id.substring(0, 8)),\n applied: true,\n appliedAt: row.appliedAt,\n };\n } else {\n // Use custom table structure\n const result = (await this.prisma.$queryRawUnsafe(`\n SELECT id, name, applied_at as appliedAt\n FROM ${this.tableName}\n ORDER BY applied_at DESC\n LIMIT 1\n `)) as any[];\n\n if (result.length === 0) {\n return null;\n }\n\n const row = result[0];\n return {\n id: row.id,\n name: row.name,\n filename: `${row.id}_${row.name}.sql`,\n timestamp: new Date(row.id.substring(0, 8)),\n applied: true,\n appliedAt: row.appliedAt,\n };\n }\n }\n\n public async getMigrationStatus(\n migrationId: string,\n ): Promise<MigrationStatus | null> {\n const isPrismaTable = await this.detectPrismaTable();\n\n if (isPrismaTable) {\n // Use Prisma's table structure\n const result = (await this.prisma.$queryRawUnsafe(\n `\n SELECT id, migration_name, started_at as appliedAt\n FROM ${this.tableName}\n WHERE id = ?\n `,\n migrationId,\n )) as any[];\n\n if (result.length === 0) {\n return null;\n }\n\n const row = result[0];\n return {\n id: row.id,\n name: row.migration_name,\n status: \"applied\",\n appliedAt: row.appliedAt,\n };\n } else {\n // Use custom table structure\n const result = (await this.prisma.$queryRawUnsafe(\n `\n SELECT id, name, applied_at as appliedAt\n FROM ${this.tableName}\n WHERE id = ?\n `,\n migrationId,\n )) as any[];\n\n if (result.length === 0) {\n return null;\n }\n\n const row = result[0];\n return {\n id: row.id,\n name: row.name,\n status: \"applied\",\n appliedAt: row.appliedAt,\n };\n }\n }\n\n public async clearMigrations(): Promise<void> {\n await this.prisma.$executeRawUnsafe(`DELETE FROM ${this.tableName}`);\n }\n\n public async dropMigrationsTable(): Promise<void> {\n await this.prisma.$executeRawUnsafe(\n `DROP TABLE IF EXISTS ${this.tableName}`,\n );\n }\n\n public getDatabaseProvider(): string {\n // This is a simplified way to detect the provider\n // In a real implementation, you might want to parse the connection string\n // or use Prisma's internal methods\n return \"postgresql\"; // Default assumption\n }\n}\n","import { existsSync, readFileSync, writeFileSync } from \"fs\";\nimport { join } from \"path\";\nimport { VersionMigrationMapping, MigrationManifest } from \"./types\";\nimport { CommitManager } from \"./commit-manager\";\n\nexport class VersionManager {\n private manifestPath: string;\n private manifest: MigrationManifest;\n\n private commitManager: CommitManager;\n\n constructor(migrationsDir: string) {\n this.manifestPath = join(migrationsDir, \"migration-manifest.json\");\n this.manifest = this.loadManifest();\n this.commitManager = new CommitManager();\n }\n\n private loadManifest(): MigrationManifest {\n if (existsSync(this.manifestPath)) {\n try {\n const content = readFileSync(this.manifestPath, \"utf-8\");\n const manifest = JSON.parse(content);\n // Convert date strings back to Date objects\n manifest.lastUpdated = new Date(manifest.lastUpdated);\n manifest.versions = manifest.versions.map((v: any) => ({\n ...v,\n createdAt: new Date(v.createdAt),\n }));\n return manifest;\n } catch {\n console.warn(\"Failed to load migration manifest, creating new one\");\n }\n }\n\n return {\n versions: [],\n lastUpdated: new Date(),\n };\n }\n\n private saveManifest(): void {\n writeFileSync(this.manifestPath, JSON.stringify(this.manifest, null, 2));\n }\n\n /**\n * Register a version with its associated migrations\n */\n public registerVersion(\n version: string,\n migrations: string[],\n description?: string,\n commit?: string,\n ): void {\n const existingIndex = this.manifest.versions.findIndex(\n (v) => v.version === version,\n );\n\n const versionMapping: VersionMigrationMapping = {\n version,\n commit,\n migrations,\n description,\n createdAt: new Date(),\n };\n\n if (existingIndex >= 0) {\n this.manifest.versions[existingIndex] = versionMapping;\n } else {\n this.manifest.versions.push(versionMapping);\n }\n\n // Sort versions (assuming semantic versioning)\n this.manifest.versions.sort((a, b) =>\n this.compareVersions(a.version, b.version),\n );\n this.manifest.lastUpdated = new Date();\n this.saveManifest();\n }\n\n /**\n * Get all migrations that need to be applied/rolled back between versions or commits\n */\n public getMigrationsBetween(\n from: string | undefined,\n to: string,\n isCommit: boolean = false,\n ): {\n migrationsToRun: string[];\n migrationsToRollback: string[];\n } {\n let fromMigrations: Set<string> = new Set();\n let toMigrations: Set<string> = new Set();\n\n if (isCommit) {\n fromMigrations = from\n ? new Set(this.getCommitMigrations(from))\n : new Set();\n toMigrations = new Set(this.getCommitMigrations(to));\n } else {\n const fromVersionData = from ? this.getVersionData(from) : null;\n const toVersionData = this.getVersionData(to);\n\n if (!toVersionData) {\n throw new Error(`Version ${to} not found in manifest`);\n }\n\n fromMigrations = new Set(fromVersionData?.migrations || []);\n toMigrations = new Set(toVersionData.migrations);\n }\n\n // Migrations to run: in target version/commit but not in current version/commit\n const migrationsToRun = Array.from(toMigrations).filter(\n (m) => !fromMigrations.has(m),\n );\n\n // Migrations to rollback: in current version/commit but not in target version/commit\n const migrationsToRollback = Array.from(fromMigrations).filter(\n (m) => !toMigrations.has(m),\n );\n\n return {\n migrationsToRun,\n migrationsToRollback,\n };\n }\n\n /**\n * Get migrations for a specific commit by finding the closest version\n */\n private getCommitMigrations(commit: string): string[] {\n const info = this.commitManager.getCommitInfo(commit);\n\n // Find the most recent version/tag reachable from this commit\n const version = info.branch\n ? this.commitManager.getLatestTag(commit)\n : null;\n if (!version) {\n throw new Error(`No version tag found for commit ${commit}`);\n }\n\n const versionData = this.getVersionData(version);\n if (!versionData) {\n throw new Error(`Version data not found for tag ${version}`);\n }\n\n return versionData.migrations;\n }\n\n /**\n * Get version data by version string\n */\n public getVersionData(version: string): VersionMigrationMapping | null {\n return this.manifest.versions.find((v) => v.version === version) || null;\n }\n\n /**\n * Get all registered versions\n */\n public getAllVersions(): VersionMigrationMapping[] {\n return [...this.manifest.versions];\n }\n\n /**\n * Get current version from manifest\n */\n public getCurrentVersion(): string | undefined {\n return this.manifest.currentVersion;\n }\n\n /**\n * Set current version\n */\n public setCurrentVersion(version: string): void {\n this.manifest.currentVersion = version;\n this.manifest.lastUpdated = new Date();\n this.saveManifest();\n }\n\n /**\n * Get the latest version based on semantic versioning\n */\n public getLatestVersion(): string | undefined {\n if (this.manifest.versions.length === 0) return undefined;\n return this.manifest.versions[this.manifest.versions.length - 1].version;\n }\n\n /**\n * Compare two semantic versions\n */\n private compareVersions(a: string, b: string): number {\n const parseVersion = (version: string) => {\n const parts = version.split(\".\").map(Number);\n return {\n major: parts[0] || 0,\n minor: parts[1] || 0,\n patch: parts[2] || 0,\n };\n };\n\n const versionA = parseVersion(a);\n const versionB = parseVersion(b);\n\n if (versionA.major !== versionB.major) {\n return versionA.major - versionB.major;\n }\n if (versionA.minor !== versionB.minor) {\n return versionA.minor - versionB.minor;\n }\n return versionA.patch - versionB.patch;\n }\n\n /**\n * Validate that all migrations for a version exist\n */\n public validateVersionMigrations(\n version: string,\n existingMigrations: string[],\n ): boolean {\n const versionData = this.getVersionData(version);\n if (!versionData) return false;\n\n const existingSet = new Set(existingMigrations);\n return versionData.migrations.every((migration) =>\n existingSet.has(migration),\n );\n }\n\n /**\n * Generate a deployment plan between versions\n */\n public generateDeploymentPlan(\n fromVersion: string | undefined,\n toVersion: string,\n ): {\n plan: Array<{\n action: \"run\" | \"rollback\";\n migration: string;\n order: number;\n }>;\n summary: string;\n } {\n const { migrationsToRun, migrationsToRollback } = this.getMigrationsBetween(\n fromVersion,\n toVersion,\n );\n\n const plan: Array<{\n action: \"run\" | \"rollback\";\n migration: string;\n order: number;\n }> = [];\n\n // Rollbacks first (in reverse order)\n migrationsToRollback.reverse().forEach((migration, index) => {\n plan.push({\n action: \"rollback\",\n migration,\n order: index + 1,\n });\n });\n\n // Then run new migrations (in forward order)\n migrationsToRun.forEach((migration, index) => {\n plan.push({\n action: \"run\",\n migration,\n order: migrationsToRollback.length + index + 1,\n });\n });\n\n const summary = `Deployment plan from ${fromVersion || \"initial\"} to ${toVersion}:\n- ${migrationsToRollback.length} migration(s) to rollback\n- ${migrationsToRun.length} migration(s) to run\n- Total steps: ${plan.length}`;\n\n return { plan, summary };\n }\n}\n","import { execSync } from \"child_process\";\n\nexport interface CommitInfo {\n hash: string;\n shortHash: string;\n message: string;\n author: string;\n date: Date;\n branch?: string;\n}\n\nexport class CommitManager {\n private gitDir: string;\n\n constructor(gitDir: string = process.cwd()) {\n this.gitDir = gitDir;\n }\n\n /**\n * Get current commit hash\n */\n public getCurrentCommit(): string {\n try {\n return this.execGitCommand(\"git rev-parse HEAD\").trim();\n } catch (error) {\n throw new Error(\n `Failed to get current commit: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n }\n\n /**\n * Get current short commit hash\n */\n public getCurrentShortCommit(): string {\n try {\n return this.execGitCommand(\"git rev-parse --short HEAD\").trim();\n } catch (error) {\n throw new Error(\n `Failed to get current short commit: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n }\n\n /**\n * Get current branch name\n */\n public getCurrentBranch(): string {\n try {\n return this.execGitCommand(\"git rev-parse --abbrev-ref HEAD\").trim();\n } catch (error) {\n throw new Error(\n `Failed to get current branch: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n }\n\n /**\n * Get detailed commit information\n */\n public getCommitInfo(commitHash?: string): CommitInfo {\n const commit = commitHash || \"HEAD\";\n\n try {\n const hash = this.execGitCommand(`git rev-parse ${commit}`).trim();\n const shortHash = this.execGitCommand(\n `git rev-parse --short ${commit}`,\n ).trim();\n const message = this.execGitCommand(\n `git log -1 --pretty=format:\"%s\" ${commit}`,\n ).trim();\n const author = this.execGitCommand(\n `git log -1 --pretty=format:\"%an <%ae>\" ${commit}`,\n ).trim();\n const dateStr = this.execGitCommand(\n `git log -1 --pretty=format:\"%ai\" ${commit}`,\n ).trim();\n const date = new Date(dateStr);\n\n let branch: string | undefined;\n try {\n // Try to get the branch containing this commit\n branch = this.execGitCommand(\n `git branch --contains ${hash} | grep -v \"detached\" | head -1`,\n )\n .trim()\n .replace(/^\\*?\\s*/, \"\");\n } catch {\n // Branch detection failed, not critical\n }\n\n return {\n hash,\n shortHash,\n message,\n author,\n date,\n branch,\n };\n } catch (error) {\n throw new Error(\n `Failed to get commit info: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n }\n\n /**\n * Check if the working directory is clean (no uncommitted changes)\n */\n public isWorkingDirectoryClean(): boolean {\n try {\n const status = this.execGitCommand(\"git status --porcelain\").trim();\n return status.length === 0;\n } catch {\n return false;\n }\n }\n\n /**\n * Get commits between two references\n */\n public getCommitsBetween(from: string, to: string): CommitInfo[] {\n try {\n const range = `${from}..${to}`;\n const hashes = this.execGitCommand(`git rev-list ${range}`)\n .trim()\n .split(\"\\n\")\n .filter(Boolean);\n\n return hashes.map((hash) => this.getCommitInfo(hash));\n } catch (error) {\n throw new Error(\n `Failed to get commits between ${from} and ${to}: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n }\n\n /**\n * Check if a commit exists\n */\n public commitExists(commitHash: string): boolean {\n try {\n this.execGitCommand(`git rev-parse --verify ${commitHash}`);\n return true;\n } catch {\n return false;\n }\n }\n\n /**\n * Get tags for a specific commit\n */\n public getTagsForCommit(commitHash: string): string[] {\n try {\n const tags = this.execGitCommand(\n `git tag --points-at ${commitHash}`,\n ).trim();\n return tags ? tags.split(\"\\n\").filter(Boolean) : [];\n } catch {\n return [];\n }\n }\n\n /**\n * Find the most recent tag reachable from a commit\n */\n public getLatestTag(commitHash?: string): string | null {\n try {\n const commit = commitHash || \"HEAD\";\n const tag = this.execGitCommand(\n `git describe --tags --abbrev=0 ${commit}`,\n ).trim();\n return tag || null;\n } catch {\n return null;\n }\n }\n\n /**\n * Check if Git repository exists\n */\n public isGitRepository(): boolean {\n try {\n this.execGitCommand(\"git rev-parse --git-dir\");\n return true;\n } catch {\n return false;\n }\n }\n\n /**\n * Generate a version string based on git state\n */\n public generateVersionFromGit(): string {\n try {\n const latestTag = this.getLatestTag();\n const shortCommit = this.getCurrentShortCommit();\n const branch = this.getCurrentBranch();\n const isClean = this.isWorkingDirectoryClean();\n\n if (latestTag) {\n // If we're exactly on a tag, use the tag\n const tagCommit = this.execGitCommand(\n `git rev-parse ${latestTag}`,\n ).trim();\n const currentCommit = this.getCurrentCommit();\n\n if (tagCommit === currentCommit && isClean) {\n return latestTag;\n }\n\n // Otherwise, create a version with tag + commits since tag\n const commitsSinceTag = this.execGitCommand(\n `git rev-list --count ${latestTag}..HEAD`,\n ).trim();\n const suffix = isClean ? \"\" : \"-dirty\";\n return `${latestTag}-${commitsSinceTag}-g${shortCommit}${suffix}`;\n }\n\n // No tags, use branch and commit\n const cleanBranch = branch.replace(/[^a-zA-Z0-9.-]/g, \"-\");\n const suffix = isClean ? \"\" : \"-dirty\";\n return `${cleanBranch}-${shortCommit}${suffix}`;\n } catch (error) {\n throw new Error(\n `Failed to generate version from git: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n }\n\n private execGitCommand(command: string): string {\n return execSync(command, {\n cwd: this.gitDir,\n encoding: \"utf8\",\n stdio: \"pipe\",\n });\n }\n}\n","import { ConfigManager } from \"./config\";\nimport { FileManager } from \"./file-manager\";\nimport { DatabaseAdapter } from \"./database-adapter\";\nimport { VersionManager } from \"./version-manager\";\nimport {\n Migration,\n MigrationResult,\n MigrationState,\n MigrationStatus,\n CreateMigrationOptions,\n RunMigrationOptions,\n RollbackMigrationOptions,\n MigrationFile,\n VersionMigrationOptions,\n VersionMigrationResult,\n VersionMigrationMapping,\n} from \"./types\";\n\nexport class MigrationManager {\n private config: ConfigManager;\n private fileManager: FileManager;\n private dbAdapter: DatabaseAdapter;\n private versionManager: VersionManager;\n\n constructor(configPath?: string) {\n this.config = new ConfigManager(configPath);\n // Initialize with default config, will be updated when needed\n const config = this.config.getConfig();\n const { migrationsDir, tableName } = config;\n this.fileManager = new FileManager(migrationsDir, config);\n this.versionManager = new VersionManager(migrationsDir);\n\n const databaseUrl = this.config.getDatabaseUrl();\n this.dbAdapter = new DatabaseAdapter(databaseUrl, tableName);\n }\n\n private async ensureConfigLoaded(): Promise<void> {\n const config = await this.config.getConfigAsync();\n const { migrationsDir, tableName } = config;\n this.fileManager = new FileManager(migrationsDir, config);\n this.versionManager = new VersionManager(migrationsDir);\n\n const databaseUrl = this.config.getDatabaseUrl();\n this.dbAdapter = new DatabaseAdapter(databaseUrl, tableName);\n }\n\n public async initialize(): Promise<void> {\n await this.dbAdapter.connect();\n await this.dbAdapter.ensureMigrationsTable();\n }\n\n public async destroy(): Promise<void> {\n await this.dbAdapter.disconnect();\n }\n\n public async createMigration(\n options: CreateMigrationOptions,\n ): Promise<MigrationFile> {\n await this.ensureConfigLoaded();\n\n const { name, template } = options;\n\n // Validate migration name\n if (!name || name.trim().length === 0) {\n throw new Error(\"Migration name cannot be empty\");\n }\n\n // Check if migration with same name exists\n const existingMigration = this.fileManager.getMigrationByName(name);\n if (existingMigration) {\n throw new Error(`Migration with name '${name}' already exists`);\n }\n\n return this.fileManager.createMigrationFile(name, template as any);\n }\n\n public async runMigrations(\n options: RunMigrationOptions = {},\n ): Promise<MigrationResult> {\n const { to, steps, dryRun = false, force = false } = options;\n\n try {\n await this.initialize();\n\n let migrationsToRun: MigrationFile[] = [];\n\n if (to) {\n // Run up to specific migration\n const targetMigration =\n this.fileManager.getMigrationFile(to) ||\n this.fileManager.getMigrationByName(to);\n if (!targetMigration) {\n throw new Error(`Migration '${to}' not found`);\n }\n migrationsToRun = this.getMigrationsUpTo(targetMigration.timestamp);\n } else if (steps) {\n // Run specific number of migrations\n migrationsToRun = this.getMigrationsToRun(steps);\n } else {\n // Run all pending migrations\n migrationsToRun = this.getAllPendingMigrations();\n }\n\n if (dryRun) {\n return {\n success: true,\n migrations: migrationsToRun.map((file) => ({\n id: file.timestamp,\n name: file.name,\n filename: file.path,\n timestamp: new Date(),\n applied: false,\n })),\n };\n }\n\n const appliedMigrations: Migration[] = [];\n\n for (const migrationFile of migrationsToRun) {\n const isApplied = await this.dbAdapter.isMigrationApplied(\n migrationFile.timestamp,\n );\n\n if (isApplied && !force) {\n continue;\n }\n\n await this.dbAdapter.executeInTransaction(async () => {\n if (migrationFile.type === \"sql\") {\n const { up } =\n this.fileManager.parseMigrationContent(migrationFile);\n await this.dbAdapter.executeMigration(up);\n } else {\n await this.dbAdapter.executeMigrationFile(migrationFile, \"up\");\n }\n await this.dbAdapter.recordMigration(\n migrationFile.timestamp,\n migrationFile.name,\n );\n });\n\n appliedMigrations.push({\n id: migrationFile.timestamp,\n name: migrationFile.name,\n filename: migrationFile.path,\n timestamp: new Date(),\n applied: true,\n appliedAt: new Date(),\n });\n }\n\n return {\n success: true,\n migrations: appliedMigrations,\n };\n } catch (error) {\n return {\n success: false,\n migrations: [],\n error: error instanceof Error ? error.message : String(error),\n };\n } finally {\n await this.destroy();\n }\n }\n\n public async rollbackMigrations(\n options: RollbackMigrationOptions = {},\n ): Promise<MigrationResult> {\n const { to, steps, dryRun = false, force = false } = options;\n\n try {\n await this.initialize();\n\n const appliedMigrations = await this.dbAdapter.getAppliedMigrations();\n let migrationsToRollback: Migration[] = [];\n\n if (to) {\n // Rollback to specific migration\n const targetMigration =\n this.fileManager.getMigrationFile(to) ||\n this.fileManager.getMigrationByName(to);\n if (!targetMigration) {\n throw new Error(`Migration '${to}' not found`);\n }\n migrationsToRollback = this.getMigrationsToRollbackTo(\n appliedMigrations,\n targetMigration.timestamp,\n );\n } else if (steps) {\n // Rollback specific number of migrations\n migrationsToRollback = appliedMigrations.slice(-steps).reverse();\n } else {\n // Rollback last migration\n const lastMigration = await this.dbAdapter.getLastMigration();\n if (lastMigration) {\n migrationsToRollback = [lastMigration];\n }\n }\n\n if (dryRun) {\n return {\n success: true,\n migrations: migrationsToRollback,\n };\n }\n\n const rolledBackMigrations: Migration[] = [];\n\n for (const migration of migrationsToRollback) {\n const migrationFile = this.fileManager.getMigrationFile(migration.id);\n if (!migrationFile) {\n throw new Error(`Migration file not found for ${migration.id}`);\n }\n\n await this.dbAdapter.executeInTransaction(async () => {\n if (migrationFile.type === \"sql\") {\n const { down } =\n this.fileManager.parseMigrationContent(migrationFile);\n\n if (!down || down.trim().length === 0) {\n if (!force) {\n throw new Error(\n `No rollback SQL found for migration ${migration.name}`,\n );\n }\n return;\n }\n\n await this.dbAdapter.executeMigration(down);\n } else {\n try {\n await this.dbAdapter.executeMigrationFile(migrationFile, \"down\");\n } catch (error) {\n if (!force) {\n throw new Error(\n `Failed to rollback migration ${migration.name}: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n return;\n }\n }\n await this.dbAdapter.removeMigration(migration.id);\n });\n\n rolledBackMigrations.push({\n ...migration,\n applied: false,\n });\n }\n\n return {\n success: true,\n migrations: rolledBackMigrations,\n };\n } catch (error) {\n return {\n success: false,\n migrations: [],\n error: error instanceof Error ? error.message : String(error),\n };\n } finally {\n await this.destroy();\n }\n }\n\n public async getMigrationState(): Promise<MigrationState> {\n await this.initialize();\n\n const allMigrations = this.fileManager.readMigrationFiles();\n const appliedMigrations = await this.dbAdapter.getA