UNPKG

db-migrate-cli

Version:

A CLI tool for managing database migrations

336 lines (274 loc) 10.2 kB
const fs = require("fs/promises"); const path = require("path"); const migrationDal = require("./dal/migration-dal"); const coreDal = require("./dal/core-dal"); const mysql = require("mysql2/promise"); const loadConfig = require("./config-loader"); const sqlFilePath = path.join(__dirname, "./sql/setup.sql"); const database = require("./database"); class Migration { constructor() { const config = loadConfig("database/config.js"); this.migrationsDir = path.join(process.cwd(), config.migrationsDir); this.database = config.database; } init = async () => { let connection; const chalk = (await import("chalk")).default; try { const sqlContent = await fs.readFile(sqlFilePath, "utf8"); connection = await mysql.createConnection({ host: this.database.host, user: this.database.user, password: this.database.password, database: this.database.database, multipleStatements: true, }); await connection.query(sqlContent); console.log(chalk.green("Migration initializated successfully.")); } catch (err) { console.error(chalk.red(`Error initializing migration: ${err.message}`)); } finally { await connection.end(); process.exit(); } }; // Get migration files sorted by name getMigrationFiles = async () => { const files = await fs.readdir(this.migrationsDir); return files.filter((file) => file.endsWith(".sql")).sort(); }; // Execute SQL and handle errors executeSql = async (sql) => { const status = await coreDal.executeQuery(sql); if (status !== "Success") { const chalk = (await import("chalk")).default; console.error(chalk.red(status)); process.exit(1); } }; processStatements = async (statements, chalk) => { const totalDigits = statements.length.toString().length; const shouldLog = statements.length > 1; for (let i = 0; i < statements.length; i++) { const { sql, startLine, endLine } = statements[i]; if (shouldLog) { const firstLine = sql.trim().split(/\r?\n/)[0] || ""; const preview = firstLine.length > 80 ? firstLine.substring(0, 80).replace(/\s+/g, " ") + "..." : firstLine.replace(/\s+/g, " "); const rangeText = startLine === endLine ? `line ${startLine.toString().padStart(2)}` : `lines ${startLine.toString().padStart(2)}-${endLine .toString() .padStart(2)}`; const statementIndex = `${(i + 1).toString().padStart(totalDigits)}/${ statements.length }`; console.log( chalk.cyan(`Statement ${statementIndex}`) + chalk.dim(` ${rangeText}: `) + chalk.gray(preview) ); } await this.executeSql(sql); } }; runMigration = async (file, batch) => { const chalk = (await import("chalk")).default; console.log(chalk.bold.blueBright("Migrating: ") + chalk.whiteBright(file)); const filePath = path.join(this.migrationsDir, file); const content = await fs.readFile(filePath, "utf8"); const { sql: upSql, startLine: upStartLine } = this.parseUpBlock(content); const statements = this.splitSqlStatements(upSql, upStartLine - 1); if (statements.length > 0) { await this.processStatements(statements, chalk); } await migrationDal.addMigration(file.replace(".sql", ""), batch); console.log(chalk.bold.greenBright("Migrated: ") + chalk.whiteBright(file)); }; rollbackMigration = async (migration) => { const chalk = (await import("chalk")).default; const file = `${migration.migration}.sql`; const filePath = path.join(this.migrationsDir, file); console.log( chalk.bold.blueBright("Rolling back: ") + chalk.whiteBright(file) ); try { await fs.access(filePath); } catch (err) { console.error(chalk.red(`Migration file not found: ${file}`)); return; } const content = await fs.readFile(filePath, "utf8"); const { sql: downSql, startLine: downStartLine } = this.parseDownBlock(content); const statements = this.splitSqlStatements(downSql, downStartLine - 1); if (statements.length === 0) { throw new Error(`No valid SQL found in DOWN for migration ${file}`); } await this.processStatements(statements, chalk); await migrationDal.deleteMigration(migration.id); console.log( chalk.bold.greenBright("Rolled back: ") + chalk.whiteBright(file) ); }; // Run all pending migrations runMigrations = async () => { const migrations = await migrationDal.getMigrations(); const migratedFiles = migrations.map( (migration) => `${migration.migration}.sql` ); const migrationFiles = await this.getMigrationFiles(); const pendingFiles = migrationFiles.filter( (file) => !migratedFiles.includes(file) ); if (pendingFiles.length === 0) { const chalk = (await import("chalk")).default; console.log(chalk.red("No new migrations to apply.")); return; } const maxBatch = migrations.length > 0 ? migrations[migrations.length - 1].batch : 0; const newBatch = maxBatch + 1; for (const file of pendingFiles) { try { await this.runMigration(file, newBatch); } catch (err) { console.error("Error applying migration:", err); break; } } }; // Rollback all migrations from the last batch rollbackMigrations = async () => { const chalk = (await import("chalk")).default; const migrations = await migrationDal.getLastBatchMigrations(); if (migrations.length === 0) { console.log(chalk.red("No migrations to rollback.")); return; } for (const migration of migrations) { try { await this.rollbackMigration(migration); } catch (err) { console.error("Error rolling back migration:", err); break; } } }; // Create a new migration file createMigrationFile = async (name) => { const chalk = (await import("chalk")).default; const now = new Date(); const timestamp = [ now.getFullYear(), String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0"), String(now.getHours()).padStart(2, "0"), String(now.getMinutes()).padStart(2, "0"), String(now.getSeconds()).padStart(2, "0"), ].join("_"); const trimmedName = name.trim().toLowerCase(); const formattedName = trimmedName.replace(/\s+/g, "_"); const fileName = `${timestamp}_${formattedName}.sql`; await fs.mkdir(this.migrationsDir, { recursive: true }); const filePath = path.join(this.migrationsDir, fileName); const baseName = `${formattedName}.sql`; const existingFiles = await fs.readdir(this.migrationsDir); const fileExists = existingFiles.some((file) => { const fileBaseName = file.replace(/^\d+_\d+_\d+_\d+_\d+_\d+_/, ""); return fileBaseName === baseName; }); if (fileExists) { console.error(chalk.red(`Migration file already exists: ${baseName}`)); return; } const content = ` -- UP -- Add your "up" migration SQL here -- DOWN -- Add your "down" migration SQL here `; await fs.writeFile(filePath, content.trim()); console.log(chalk.green(`Migration file created: ${fileName}`)); }; parseUpBlock = (content) => { const lines = content.split(/\r?\n/); const upIndex = lines.findIndex((line) => line.trim() === "-- UP"); if (upIndex === -1) { throw new Error("Missing '-- UP' section."); } const downIndex = lines.findIndex( (line, i) => line.trim() === "-- DOWN" && i > upIndex ); const upLines = downIndex !== -1 ? lines.slice(upIndex + 1, downIndex) : lines.slice(upIndex + 1); return { sql: upLines.join("\n").trim(), startLine: upIndex + 2, }; }; parseDownBlock = (content) => { const lines = content.split(/\r?\n/); const downIndex = lines.findIndex((line) => line.trim() === "-- DOWN"); if (downIndex === -1) { throw new Error("Missing '-- DOWN' section."); } const upIndex = lines.findIndex( (line, i) => line.trim() === "-- UP" && i > downIndex ); const downLines = upIndex !== -1 ? lines.slice(downIndex + 1, upIndex) : lines.slice(downIndex + 1); return { sql: downLines.join("\n").trim(), startLine: downIndex + 2, }; }; splitSqlStatements = (content, startLineOffset = 0) => { const lines = content.split(/\r?\n/); const statements = []; let current = []; let currentStartLine = 0; let delimiter = ";"; const delimiterRegex = /^DELIMITER\s+(.+)$/i; for (let i = 0; i < lines.length; i++) { const actualLine = lines[i]; const line = actualLine.trim(); const match = line.match(delimiterRegex); if (match) { delimiter = match[1]; continue; } if (current.length === 0 && line === "") { continue; // skip blank lines between statements } if (current.length === 0) { currentStartLine = i; // first non-blank line in this statement } current.push(actualLine); if (line.endsWith(delimiter)) { const sql = current.join("\n").trim(); if (sql) { statements.push({ sql: sql.slice(0, -delimiter.length).trim(), // remove delimiter startLine: currentStartLine + 1 + startLineOffset, endLine: i + 1 + startLineOffset, }); } current = []; currentStartLine = i + 1; } else if (current.length === 1) { currentStartLine = i; } } return statements; }; } module.exports = Migration;