UNPKG

@iarayan/ch-orm

Version:

A Developer-First ClickHouse ORM with Powerful CLI Tools

317 lines 12.6 kB
import * as fs from "fs"; import * as path from "path"; import { MigrationRunner } from "../../schema/MigrationRunner"; import { MigrationRecord } from "../../schema/models/MigrationRecord"; /** * Command for running migrations via CLI */ export class MigrationRunnerCommand { constructor(connection, migrationsDir = "./migrations") { // Map of filename to migration instance this.migrationInstances = new Map(); this.migrationsDir = migrationsDir; this.runner = new MigrationRunner(connection); // Set connection for the MigrationRecord model MigrationRecord.setConnection(connection); } /** * Load a migration file by filename * @param filename Migration filename * @returns Migration instance */ async loadMigration(filename) { if (this.migrationInstances.has(filename)) { return this.migrationInstances.get(filename); } const filePath = path.resolve(this.migrationsDir, filename); if (!fs.existsSync(filePath)) { throw new Error(`Migration file not found: ${filename}`); } // Try ESM import first try { const migration = await import(filePath); const MigrationClass = migration.default; if (MigrationClass) { const instance = new MigrationClass(this.runner.getConnection()); // Override the getName method to return the filename const originalGetName = instance.getName.bind(instance); instance.getName = () => filename; // Store for reuse this.migrationInstances.set(filename, instance); return instance; } } catch (error) { // If ESM import fails, try CommonJS require with ts-node try { // Register ts-node if not already registered if (!require.extensions[".ts"]) { require("ts-node").register({ transpileOnly: true, compilerOptions: { module: "CommonJS", esModuleInterop: true, }, }); } const migration = require(filePath); // For CommonJS, find the class in the exports let MigrationClass = migration.default; // If no default export, look for named exports if (!MigrationClass) { for (const key in migration) { if (typeof migration[key] === "function" && migration[key].prototype && typeof migration[key].prototype.up === "function" && typeof migration[key].prototype.down === "function") { MigrationClass = migration[key]; break; } } } if (MigrationClass) { const instance = new MigrationClass(this.runner.getConnection()); // Override the getName method to return the filename const originalGetName = instance.getName.bind(instance); instance.getName = () => filename; // Store for reuse this.migrationInstances.set(filename, instance); return instance; } } catch (requireError) { throw new Error(`Failed to load migration file ${filename}: ${(requireError === null || requireError === void 0 ? void 0 : requireError.message) || "Unknown error"}`); } } throw new Error(`No migration class found in ${filename}`); } /** * Get all completed migrations */ async getCompletedMigrations() { // Use the MigrationRecord model to get all completed migrations const records = await MigrationRecord.query().get(); return records.map((record) => record.name); } /** * Get migrations for rollback */ async getMigrationsForRollback(steps = 1) { // Use the MigrationRecord model to get migrations for rollback const records = await MigrationRecord.query() .orderBy("created_at", "DESC") .limit(steps) .get(); return records.map((record) => record.name); } /** * Run a specific migration */ async runMigration(filename) { // Load the migration const instance = await this.loadMigration(filename); // Add it to the runner this.runner.add(instance); // Run it await this.runner.run(); } /** * Rollback a specific migration */ async rollbackMigration(filename) { // Load the migration const instance = await this.loadMigration(filename); // Add it to the runner this.runner.add(instance); // Mark it as applied (so it can be rolled back) instance.setApplied(true); // Roll it back await instance.revert(); // Delete the migration record from the database await MigrationRecord.query().where("name", filename).delete(); } /** * Get migration status */ async getMigrationStatus() { const completedMigrationNames = await this.getCompletedMigrations(); const files = fs.readdirSync(this.migrationsDir); return files.map((file) => { return { migration: file, status: completedMigrationNames.includes(file) ? "Completed" : "Pending", }; }); } /** * Run all pending migrations */ async run() { const status = await this.getMigrationStatus(); const pending = status.filter((s) => s.status === "Pending"); for (const { migration } of pending) { await this.runMigration(migration); } } /** * Rollback migrations */ async rollback() { // Get the last batch number const lastBatchResult = await MigrationRecord.query() .select("batch") .orderBy("batch", "DESC") .first(); if (!lastBatchResult) { return; // No migrations to roll back } const lastBatch = lastBatchResult.batch; // Get all migrations from the last batch const migrationsToRollback = await MigrationRecord.query() .where("batch", lastBatch) .orderBy("created_at", "DESC") // Roll back in reverse order of creation .get(); if (migrationsToRollback.length === 0) { return; // No migrations to roll back } // Load and roll back each migration in the batch for (const migrationRecord of migrationsToRollback) { try { await this.rollbackMigration(migrationRecord.name); } catch (error) { throw new Error(`Failed to roll back migration ${migrationRecord.name}: ${error.message}`); } } } /** * Reset the database by rolling back all migrations */ async reset() { const completedMigrationFilenames = await this.getCompletedMigrations(); if (completedMigrationFilenames.length === 0) { return; // No migrations to reset } // For each completed migration, load and add it to the runner for (const filename of completedMigrationFilenames) { try { const instance = await this.loadMigration(filename); this.runner.add(instance); instance.setApplied(true); } catch (error) { throw new Error(`Failed to load migration ${filename}: ${error.message}`); } } // Execute reset to roll back all migrations await this.runner.reset(); } /** * Fresh install - drops all database objects and runs migrations from scratch */ async fresh() { var _a, _b; // Get the connection to execute raw queries const connection = this.runner.getConnection(); try { // 1. Get list of all tables in the current database const result = await connection.query(` SELECT name, database FROM system.tables WHERE database = currentDatabase() AND name != 'migrations' -- Keep the migrations table for now AND NOT startsWith(name, '.inner') -- Skip internal system tables `); const tables = result.data.map((row) => ({ name: row.name, database: row.database, })); // 2. Get list of all materialized views const viewsResult = await connection.query(` SELECT name, database FROM system.tables WHERE database = currentDatabase() AND engine = 'MaterializedView' `); const views = viewsResult.data.map((row) => ({ name: row.name, database: row.database, })); // Count of inner tables (for reporting only) const innerTablesResult = await connection.query(` SELECT count() as count FROM system.tables WHERE database = currentDatabase() AND startsWith(name, '.inner') `); const innerTablesCount = ((_a = innerTablesResult.data[0]) === null || _a === void 0 ? void 0 : _a.count) || 0; // 3. Drop all materialized views first (they depend on tables) for (const view of views) { // Properly escape view names with backticks await connection.query(`DROP VIEW IF EXISTS \`${view.database}\`.\`${view.name}\``); } // 4. Drop all tables for (const table of tables) { if (table.name !== "migrations") { // Extra safety check // Properly escape table names with backticks await connection.query(`DROP TABLE IF EXISTS \`${table.database}\`.\`${table.name}\``); } } // 5. Check if migrations table exists and delete all records const migrationTableExistsResult = await connection.query(` SELECT count() as count FROM system.tables WHERE database = currentDatabase() AND name = 'migrations' `); const migrationsTableExists = ((_b = migrationTableExistsResult.data[0]) === null || _b === void 0 ? void 0 : _b.count) > 0; if (migrationsTableExists) { // Using the model to delete all migration records await MigrationRecord.query().where("1", "=", "1").deleteQuery(); // Double-check that the records are gone const countCheck = await MigrationRecord.query().count(); if (countCheck > 0) { // If model-based deletion didn't work, try direct SQL await connection.query(`DELETE FROM \`migrations\` WHERE 1=1`); } } else { // Create migrations table if it doesn't exist await connection.query(` CREATE TABLE IF NOT EXISTS \`migrations\` ( name String, batch UInt32, execution_time Float64, created_at DateTime ) ENGINE = MergeTree() ORDER BY (name) `); } // Verify migrations are clear before running new ones const migrationRecords = await MigrationRecord.query().get(); if (migrationRecords.length > 0) { throw new Error("Migration records could not be cleared"); } // 6. Run all migrations from scratch await this.run(); // Final verification await this.getMigrationStatus(); } catch (error) { throw error; } } /** * Refresh the database by rolling back all migrations and then running them again */ async refresh() { // First reset all migrations await this.reset(); // Then run all migrations again await this.run(); } } //# sourceMappingURL=MigrationRunnerCommand.js.map