@iarayan/ch-orm
Version:
A Developer-First ClickHouse ORM with Powerful CLI Tools
317 lines • 12.6 kB
JavaScript
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