clicksuite
Version:
A CLI tool for managing ClickHouse database migrations with environment-specific configurations
773 lines (768 loc) • 42.7 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Runner = void 0;
const db_1 = require("./db");
const fs = __importStar(require("fs/promises"));
const fsSync = __importStar(require("fs"));
const path = __importStar(require("path"));
const chalk_1 = __importDefault(require("chalk"));
const js_yaml_1 = __importDefault(require("js-yaml"));
const inquirer_1 = __importDefault(require("inquirer"));
const MIGRATION_FILE_REGEX = /^(\d{14})_([\w-]+)\.yml$/;
// Helper function to interpolate environment variables in SQL
function interpolateEnvVars(sql) {
return sql.replace(/\$\{([^}]+)\}/g, (_, envVarName) => {
const envValue = process.env[envVarName];
if (envValue === undefined) {
console.warn(chalk_1.default.yellow(`⚠️ Warning: Environment variable '${envVarName}' is not set. Using empty string.`));
return '';
}
return envValue;
});
}
// Helper function to format SQL with table and database names and environment variables
function formatSQL(sql, tableName, databaseName) {
if (!sql) {
return sql;
}
let formatted = sql;
// First, replace table and database placeholders
if (tableName) {
formatted = formatted.replace(/\{table\}/g, tableName);
}
if (databaseName) {
formatted = formatted.replace(/\{database\}/g, databaseName);
}
// Then, interpolate environment variables
formatted = interpolateEnvVars(formatted);
return formatted;
}
class Runner {
constructor(context) {
this.context = context;
this.db = new db_1.Db(context);
if (!path.isAbsolute(this.context.migrationsDir)) {
this.context.migrationsDir = path.resolve(process.cwd(), this.context.migrationsDir);
console.warn(chalk_1.default.yellow(`⚠️ Runner: migrationsDir was not absolute, resolved to ${this.context.migrationsDir}. This should be resolved in index.ts.`));
}
// If a "migrations" subdirectory exists inside the provided directory,
// prefer it to mirror the CLI behavior.
try {
const migrationsSubdir = path.join(this.context.migrationsDir, 'migrations');
if (fsSync.existsSync(migrationsSubdir) && fsSync.statSync(migrationsSubdir).isDirectory()) {
this.context.migrationsDir = migrationsSubdir;
}
}
catch (_) {
// noop: fall back to provided migrationsDir
}
}
async _getLocalMigrations() {
const migrationFiles = [];
try {
const files = await fs.readdir(this.context.migrationsDir);
for (const file of files) {
const match = file.match(MIGRATION_FILE_REGEX);
if (match) {
const version = match[1];
const name = match[2].replace(/-/g, ' ');
const filePath = path.join(this.context.migrationsDir, file);
try {
const fileContent = await fs.readFile(filePath, 'utf-8');
// js-yaml handles YAML anchors and aliases automatically during parsing.
const rawContent = js_yaml_1.default.load(fileContent);
const currentEnvConfig = rawContent[this.context.environment] || {};
const defaultEnvConfig = rawContent['development'] || {}; // Fallback or for alias base
// Resolve settings: current env specific, then development (if aliased), then empty
// js-yaml handles alias merging, so currentEnvConfig should already be merged if it used an alias.
const querySettings = currentEnvConfig.settings || defaultEnvConfig.settings || {};
// Resolve SQL: current env specific, then development (if aliased)
let upSQL = currentEnvConfig.up || defaultEnvConfig.up;
let downSQL = currentEnvConfig.down || defaultEnvConfig.down;
// Format SQL with table and database names if provided
const tableName = rawContent.table;
const databaseName = rawContent.database;
upSQL = formatSQL(upSQL, tableName, databaseName);
downSQL = formatSQL(downSQL, tableName, databaseName);
migrationFiles.push({
version,
name,
filePath,
table: tableName,
database: databaseName,
upSQL: upSQL,
downSQL: downSQL,
querySettings: querySettings,
});
}
catch (e) {
console.error(chalk_1.default.bold.red(`❌ Error reading or parsing migration file ${filePath}:`), e.message);
if (e.mark) { // js-yaml provides error location
console.error(chalk_1.default.bold.red(` at line ${e.mark.line + 1}, column ${e.mark.column + 1}`));
}
}
}
}
}
catch (e) {
if (e.code === 'ENOENT') {
console.log(chalk_1.default.yellow(`⚠️ Migrations directory ${this.context.migrationsDir} not found. Run 'clicksuite init' to create it.`));
}
else {
console.error(chalk_1.default.bold.red('❌ Error reading migrations directory:'), e.message);
}
return [];
}
return migrationFiles.sort((a, b) => a.version.localeCompare(b.version));
}
/**
* Initialize the project by creating the migrations directory and the migrations table
*/
async init() {
console.log(chalk_1.default.blue('⏳ Runner: Initializing Clicksuite environment...'));
try {
await this.db.initMigrationsTable();
const pingResult = await this.db.ping();
if (pingResult.success) {
console.log(chalk_1.default.green('✅ Successfully connected to ClickHouse.'));
}
else {
console.error(chalk_1.default.bold.red('❌ Failed to connect to ClickHouse.'), pingResult.error);
}
console.log(chalk_1.default.green('✅ Clicksuite initialized successfully. Migration table is ready.'));
}
catch (error) {
console.error(chalk_1.default.bold.red('❌ Runner init failed:'), error);
throw error;
}
}
/**
* Generate a new migration file
* @param name - The name of the migration
*/
async generate(migrationNameInput) {
const timestamp = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14);
// Sanitize the migration name for the file name part
const safeFileNamePart = migrationNameInput.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9_-]/g, '').toLowerCase();
const filename = `${timestamp}_${safeFileNamePart}.yml`;
const filePath = path.join(this.context.migrationsDir, filename);
const migrationContent = {
version: timestamp,
name: migrationNameInput, // Keep original name for display purposes inside YAML
table: "your_table_name", // Placeholder for the user
database: "your_database_name", // Placeholder for the user
development: {
// YAML anchor. Note: js-yaml stringifies with quotes, which is fine.
// For true anchors in output, manual string construction or more complex YAML lib might be needed.
// However, for parsing, js-yaml handles `<<: *anchor` correctly if typed manually by user.
// The goal here is a good starting template.
up: "CREATE TABLE {database}.{table}",
down: "DROP TABLE IF EXISTS {database}.{table}",
settings: {},
},
test: {
// Example of aliasing if user wants to type it, though js-yaml.dump won't create anchors/aliases by default.
// '<<': '*development_defaults', // This is how user would type it. Dump will expand it.
// For a new file, we can just pre-fill it as if expanded or keep it minimal.
up: "-- SQL to apply migration for test (defaults to development if not specified or using aliases)",
down: "-- SQL to roll back migration for test (defaults to development if not specified or using aliases)",
settings: {},
},
production: {
up: "-- SQL to apply migration for production",
down: "-- SQL to roll back migration for production",
settings: {},
}
};
// Constructing a YAML string that demonstrates aliases for user to understand the pattern:
const yamlString = `version: "${migrationContent.version}"
name: "${migrationContent.name}"
table: "${migrationContent.table}"
database: "${migrationContent.database}"
development: &development_defaults
up: |
${migrationContent.development.up.split('\n').join('\n ')}
down: |
${migrationContent.development.down.split('\n').join('\n ')}
settings: ${JSON.stringify(migrationContent.development.settings)}
test:
<<: *development_defaults
# up: |
# -- SQL for test up (override)
# down: |
# -- SQL for test down (override)
production:
<<: *development_defaults
# up: |
# -- SQL for production up (override)
# down: |
# -- SQL for production down (override)
`;
try {
await fs.mkdir(this.context.migrationsDir, { recursive: true });
await fs.writeFile(filePath, yamlString);
console.log(chalk_1.default.green(`✅ Generated new migration file: ${filePath}`));
console.log(chalk_1.default.yellow('ℹ️ Please edit this file to add your environment-specific migration SQL and update the `table` field.'));
}
catch (e) {
console.error(chalk_1.default.bold.red(`❌ Error generating migration file ${filePath}:`), e.message);
}
}
async status() {
console.log(chalk_1.default.blue('🔍 Fetching migration status...'));
const localMigrations = await this._getLocalMigrations();
const dbRecords = await this.db.getAllMigrationRecords();
const statusList = [];
const dbMap = new Map();
dbRecords.forEach(rec => dbMap.set(rec.version, rec));
for (const local of localMigrations) {
const dbRec = dbMap.get(local.version);
let state;
let appliedAt;
if (dbRec) {
state = dbRec.active === 1 ? 'APPLIED' : 'INACTIVE';
appliedAt = dbRec.created_at;
dbMap.delete(local.version);
}
else {
state = 'PENDING';
}
statusList.push({ ...local, state, appliedAt });
}
dbMap.forEach(dbRec => {
statusList.push({
version: dbRec.version,
name: 'N/A (DB only. Likely a legacy migration file)',
filePath: 'N/A',
state: dbRec.active === 1 ? 'APPLIED' : 'INACTIVE',
appliedAt: dbRec.created_at,
});
});
statusList.sort((a, b) => a.version.localeCompare(b.version));
if (statusList.length === 0) {
console.log(chalk_1.default.yellow('ℹ️ No migrations found locally or in the database.'));
return;
}
console.log(chalk_1.default.bold(`\nMigration Status (Env: ${this.context.environment}, Migrations DB: ${this.context.migrationsDatabase || 'default'}):`));
console.log(chalk_1.default.gray('-------------------------------------------------------------------------------------'));
statusList.forEach(s => {
let stateChalk = chalk_1.default.yellow;
if (s.state === 'APPLIED')
stateChalk = chalk_1.default.green;
if (s.state === 'INACTIVE')
stateChalk = chalk_1.default.gray;
const nameDisplay = s.name === 'N/A (DB only)' ? chalk_1.default.italic(s.name) : s.name;
const dateDisplay = s.appliedAt ? chalk_1.default.dim(`(Applied: ${new Date(s.appliedAt).toLocaleString()})`) : '';
console.log(`${stateChalk.bold(s.state.padEnd(10))}` +
`${chalk_1.default.cyan(s.version)} - ${nameDisplay} ${dateDisplay}`);
});
console.log(chalk_1.default.gray('-------------------------------------------------------------------------------------'));
}
async migrate() {
console.log(chalk_1.default.blue('⏳ Running pending migrations (migrate:up)...'));
await this.up();
}
async up(targetVersion) {
const actionWord = this.context.dryRun ? 'Previewing' : 'Executing';
console.log(chalk_1.default.blue(`⏳ ${actionWord} UP migrations for environment '${this.context.environment}'... ${targetVersion ? 'Target: ' + targetVersion : 'All pending'}`));
const localMigrations = await this._getLocalMigrations();
const dbAppliedMigrations = await this.db.getAppliedMigrations();
const appliedVersions = new Set(dbAppliedMigrations.map(m => m.version));
const pendingMigrations = localMigrations
.filter(lm => !appliedVersions.has(lm.version))
.sort((a, b) => a.version.localeCompare(b.version));
if (pendingMigrations.length === 0) {
const message = this.context.dryRun
? 'No pending migrations to preview. Database is up-to-date.'
: 'No pending migrations to apply. Database is up-to-date.';
console.log(chalk_1.default.green(`ℹ️ ${message}`));
return;
}
let migrationsToRun = pendingMigrations;
if (targetVersion) {
const targetIdx = migrationsToRun.findIndex(m => m.version === targetVersion);
if (targetIdx === -1) {
console.error(chalk_1.default.bold.red(`❌ Target version ${targetVersion} not found among pending or already applied (but not active).`));
return;
}
migrationsToRun = migrationsToRun.slice(0, targetIdx + 1);
if (migrationsToRun.length === 0) {
console.log(chalk_1.default.yellow(`ℹ️ Target version ${targetVersion} seems to be already applied or no prior pending migrations.`));
return;
}
}
if (this.context.dryRun) {
console.log(chalk_1.default.cyan(`🔍 DRY RUN: The following ${migrationsToRun.length} migration(s) would be applied:`));
migrationsToRun.forEach(m => {
console.log(chalk_1.default.cyan(` ✅ ${m.version} - ${m.name}`));
});
}
else {
console.log(chalk_1.default.yellow(`🔍 Found ${migrationsToRun.length} migration(s) to apply.`));
}
for (const migration of migrationsToRun) {
const migrationTitle = this.context.dryRun
? `DRY RUN: Migration ${migration.version} - ${migration.name}`
: `⏳ Applying migration: ${migration.version} - ${migration.name}`;
console.log(chalk_1.default.magenta(`\n${migrationTitle}`));
if (!migration.upSQL) {
const skipMessage = this.context.dryRun
? `Would skip ${migration.version}: No 'up' SQL found for environment '${this.context.environment}'.`
: `⏭️ Skipping ${migration.version}: No 'up' SQL found for environment '${this.context.environment}'.`;
console.warn(chalk_1.default.yellow(skipMessage));
continue;
}
try {
if (this.context.dryRun) {
console.log(chalk_1.default.cyan('┌─') + chalk_1.default.cyan(`─ DRY RUN: Migration ${migration.version} - ${migration.name} `).padEnd(70, '─') + chalk_1.default.cyan('─'));
console.log(chalk_1.default.cyan('│') + ` Environment: ${this.context.environment}`);
if (migration.database)
console.log(chalk_1.default.cyan('│') + ` Database: ${migration.database}`);
if (migration.table)
console.log(chalk_1.default.cyan('│') + ` Table: ${migration.table}`);
console.log(chalk_1.default.cyan('│') + ' ');
// Count queries for display
const queryCount = migration.upSQL.split(';').filter(q => q.trim().length > 0).length;
const queryLabel = queryCount === 1 ? 'query' : 'queries';
console.log(chalk_1.default.cyan('│') + ` SQL to execute (${queryCount} ${queryLabel}):`);
// Show each query indented
const queries = migration.upSQL.split(';').filter(q => q.trim().length > 0);
queries.forEach(query => {
console.log(chalk_1.default.cyan('│') + ` ${query.trim()};`);
if (queries.indexOf(query) < queries.length - 1) {
console.log(chalk_1.default.cyan('│') + ' ');
}
});
console.log(chalk_1.default.cyan('└') + chalk_1.default.cyan('─'.repeat(70)));
}
else {
if (this.context.verbose) {
console.log(chalk_1.default.gray('--- UP SQL (Env: ') + chalk_1.default.cyan(this.context.environment) + chalk_1.default.gray(') ---'));
console.log(chalk_1.default.gray(migration.upSQL.trim()));
console.log(chalk_1.default.gray('--------------'));
if (migration.table || migration.database) {
const details = [];
if (migration.database)
details.push(`database: ${migration.database}`);
if (migration.table)
details.push(`table: ${migration.table}`);
console.log(chalk_1.default.dim(`(Using ${details.join(', ')})`));
}
}
await this.db.executeMigration(migration.upSQL, migration.querySettings);
await this.db.markMigrationApplied(migration.version);
console.log(chalk_1.default.green(`✅ Successfully applied ${migration.version} - ${migration.name}`));
}
}
catch (error) {
if (!this.context.dryRun) {
console.error(chalk_1.default.bold.red(`❌ Error applying migration ${migration.version} - ${migration.name}:`), error.message);
console.error(chalk_1.default.bold.red('❌ Migration process halted due to error.'));
throw error;
}
}
}
if (this.context.dryRun) {
console.log(chalk_1.default.cyan(`\n🔍 DRY RUN COMPLETE: ${migrationsToRun.length} migration(s) would be applied (no changes made)`));
}
else {
console.log(chalk_1.default.greenBright('\n✅ All selected UP migrations applied successfully!'));
if (!this.context.skipSchemaUpdate) {
await this._updateSchemaFile();
}
}
}
async down(targetVersionToBecomeLatest) {
const localMigrations = await this._getLocalMigrations();
const localMigrationsMap = new Map(localMigrations.map(m => [m.version, m]));
// Get all active migrations, sorted by version ascending (oldest first)
const appliedDbMigrations = (await this.db.getAppliedMigrations())
.sort((a, b) => a.version.localeCompare(b.version));
if (appliedDbMigrations.length === 0) {
console.log(chalk_1.default.yellow('ℹ️ No active migrations in the database to roll back.'));
return;
}
let migrationsToEffectivelyRollback = [];
if (!targetVersionToBecomeLatest) {
// Case 1: No target version specified - roll back the single last applied migration
const lastAppliedDbRecord = appliedDbMigrations[appliedDbMigrations.length - 1];
const actionWord = this.context.dryRun ? 'Previewing rollback of' : 'Attempting to roll back';
console.log(chalk_1.default.blue(`🔍 No specific version provided. ${actionWord} the last applied migration: ${lastAppliedDbRecord.version}`));
const correspondingLocalFile = localMigrationsMap.get(lastAppliedDbRecord.version);
if (correspondingLocalFile) {
migrationsToEffectivelyRollback.push(correspondingLocalFile);
}
else {
console.error(chalk_1.default.bold.red(`❌ Local migration file for version ${lastAppliedDbRecord.version} not found. Cannot roll back.`));
return;
}
}
else {
// Case 2: Target version specified - roll back all migrations *after* this version
const actionWord = this.context.dryRun ? 'Previewing rollback of migrations' : 'Attempting to roll back migrations';
console.log(chalk_1.default.blue(`🔍 ${actionWord} until version ${targetVersionToBecomeLatest} is the latest applied (or only one if it's the target)...`));
const targetIndexInApplied = appliedDbMigrations.findIndex(m => m.version === targetVersionToBecomeLatest);
if (targetIndexInApplied === -1) {
console.error(chalk_1.default.bold.red(`❌ Target version ${targetVersionToBecomeLatest} is not currently applied. Cannot roll back to this state.`));
// Further check: does this version even exist locally?
if (!localMigrationsMap.has(targetVersionToBecomeLatest)) {
console.error(chalk_1.default.bold.red(`❌ Additionally, version ${targetVersionToBecomeLatest} does not exist in local migration files.`));
}
return;
}
// Migrations to roll back are those applied *after* the targetVersionToBecomeLatest
// These are from targetIndexInApplied + 1 to the end of the appliedDbMigrations array.
// We need to roll them back in reverse order of application (latest first).
const dbRecordsToRollback = appliedDbMigrations.slice(targetIndexInApplied + 1).reverse();
if (dbRecordsToRollback.length === 0) {
console.log(chalk_1.default.green(`✅ Version ${targetVersionToBecomeLatest} is already the latest applied migration or no migrations were applied after it. No rollback needed.`));
return;
}
for (const dbRec of dbRecordsToRollback) {
const localFile = localMigrationsMap.get(dbRec.version);
if (localFile) {
migrationsToEffectivelyRollback.push(localFile);
}
else {
console.warn(chalk_1.default.yellow(`⚠️ Local migration file for version ${dbRec.version} (which is applied in DB) not found. Cannot automatically roll it back.`));
}
}
}
if (migrationsToEffectivelyRollback.length === 0) {
console.log(chalk_1.default.yellow('ℹ️ No migrations selected for rollback operation.'));
return;
}
if (this.context.dryRun) {
console.log(chalk_1.default.cyan(`🔍 DRY RUN: The following ${migrationsToEffectivelyRollback.length} migration(s) would be rolled back (in order):`));
migrationsToEffectivelyRollback.forEach(m => console.log(chalk_1.default.cyan(` ✅ ${m.version} - ${m.name}`)));
}
else {
console.log(chalk_1.default.magenta(`⏳ The following ${migrationsToEffectivelyRollback.length} migration(s) will be rolled back (in order):`));
migrationsToEffectivelyRollback.forEach(m => console.log(chalk_1.default.magenta(` ⏳ ${m.version} - ${m.name}`)));
if (!this.context.nonInteractive) {
const answers = await inquirer_1.default.prompt([
{
type: 'confirm',
name: 'confirmation',
message: `Are you sure you want to roll back these ${migrationsToEffectivelyRollback.length} migration(s)?`,
default: false,
},
]);
if (!answers.confirmation) {
console.log(chalk_1.default.gray('ℹ️ Rollback cancelled by user.'));
return;
}
}
}
for (const migration of migrationsToEffectivelyRollback) {
const migrationTitle = this.context.dryRun
? `DRY RUN: Rolling back ${migration.version} - ${migration.name}`
: `⏳ Rolling back migration: ${migration.version} - ${migration.name}`;
console.log(chalk_1.default.magenta(`\n${migrationTitle}`));
if (!migration.downSQL) {
const skipMessage = this.context.dryRun
? `Would skip ${migration.version}: No 'down' SQL found for environment '${this.context.environment}'.`
: `⏭️ Skipping ${migration.version}: No 'down' SQL found for environment '${this.context.environment}'.`;
console.warn(chalk_1.default.yellow(skipMessage));
continue; // Or halt, depending on desired strictness
}
try {
if (this.context.dryRun) {
console.log(chalk_1.default.cyan('┌─') + chalk_1.default.cyan(`─ DRY RUN: Rollback ${migration.version} - ${migration.name} `).padEnd(70, '─') + chalk_1.default.cyan('─'));
console.log(chalk_1.default.cyan('│') + ` Environment: ${this.context.environment}`);
if (migration.database)
console.log(chalk_1.default.cyan('│') + ` Database: ${migration.database}`);
if (migration.table)
console.log(chalk_1.default.cyan('│') + ` Table: ${migration.table}`);
console.log(chalk_1.default.cyan('│') + ' ');
// Count queries for display
const queryCount = migration.downSQL.split(';').filter(q => q.trim().length > 0).length;
const queryLabel = queryCount === 1 ? 'query' : 'queries';
console.log(chalk_1.default.cyan('│') + ` SQL to execute (${queryCount} ${queryLabel}):`);
// Show each query indented
const queries = migration.downSQL.split(';').filter(q => q.trim().length > 0);
queries.forEach(query => {
console.log(chalk_1.default.cyan('│') + ` ${query.trim()};`);
if (queries.indexOf(query) < queries.length - 1) {
console.log(chalk_1.default.cyan('│') + ' ');
}
});
console.log(chalk_1.default.cyan('└') + chalk_1.default.cyan('─'.repeat(70)));
}
else {
if (this.context.verbose) {
console.log(chalk_1.default.gray('--- DOWN SQL (Env: ') + chalk_1.default.cyan(this.context.environment) + chalk_1.default.gray(') ---'));
console.log(chalk_1.default.gray(migration.downSQL.trim()));
console.log(chalk_1.default.gray('----------------'));
if (migration.table || migration.database) {
const details = [];
if (migration.database)
details.push(`database: ${migration.database}`);
if (migration.table)
details.push(`table: ${migration.table}`);
console.log(chalk_1.default.dim(`(Using ${details.join(', ')})`));
}
}
await this.db.executeMigration(migration.downSQL, migration.querySettings);
await this.db.markMigrationRolledBack(migration.version);
console.log(chalk_1.default.green(`✅ Successfully rolled back ${migration.version} - ${migration.name}`));
}
}
catch (error) {
if (!this.context.dryRun) {
console.error(chalk_1.default.bold.red(`❌ Error rolling back migration ${migration.version} - ${migration.name}:`), error.message);
console.error(chalk_1.default.bold.red('Rollback process halted due to error.'));
throw error; // Re-throw to stop further rollbacks on error
}
}
}
if (this.context.dryRun) {
console.log(chalk_1.default.cyan(`\n🔍 DRY RUN COMPLETE: ${migrationsToEffectivelyRollback.length} migration(s) would be rolled back (no changes made)`));
}
else {
console.log(chalk_1.default.greenBright('\n✅ Selected DOWN migrations completed successfully!'));
if (!this.context.skipSchemaUpdate) {
await this._updateSchemaFile();
}
}
}
async reset() {
console.warn(chalk_1.default.yellow.bold('⚠️ WARNING: This will roll back all applied migrations and clear the migrations table.'));
let proceed = this.context.nonInteractive;
if (!proceed) {
const answers = await inquirer_1.default.prompt([
{
type: 'confirm',
name: 'confirmation',
message: 'Are you sure you want to reset all migrations? This action cannot be undone.',
default: false,
},
]);
proceed = answers.confirmation;
}
if (!proceed) {
console.log(chalk_1.default.gray('ℹ️ Migration reset cancelled by user.'));
return;
}
console.log(chalk_1.default.blue('⏳ Starting migration reset...'));
const appliedDbMigrations = (await this.db.getAppliedMigrations()).sort((a, b) => b.version.localeCompare(a.version));
const localMigrations = await this._getLocalMigrations();
const localMigrationsMap = new Map(localMigrations.map(m => [m.version, m]));
if (appliedDbMigrations.length === 0) {
console.log(chalk_1.default.yellow('ℹ️ No applied migrations found in the database to roll back.'));
}
else {
console.log(chalk_1.default.magenta(`🔍 Found ${appliedDbMigrations.length} applied migration(s) to roll back.`));
for (const dbMigration of appliedDbMigrations) {
console.log(chalk_1.default.blue(`\n⏳ Rolling back: ${dbMigration.version}`));
const localFile = localMigrationsMap.get(dbMigration.version);
if (!localFile || !localFile.downSQL) {
console.warn(chalk_1.default.yellow(` ⏭️ Skipping rollback of ${dbMigration.version}: No local file or downSQL found for env '${this.context.environment}'.`));
continue;
}
try {
if (this.context.verbose) {
console.log(chalk_1.default.gray(' --- DOWN SQL (Env: ') + chalk_1.default.cyan(this.context.environment) + chalk_1.default.gray(') ---'));
console.log(chalk_1.default.gray(` ${localFile.downSQL.trim().split('\n').join('\n ')}`));
console.log(chalk_1.default.gray(' ----------------'));
if (localFile.table || localFile.database) {
const details = [];
if (localFile.database)
details.push(`database: ${localFile.database}`);
if (localFile.table)
details.push(`table: ${localFile.table}`);
console.log(chalk_1.default.dim(` (Using ${details.join(', ')})`));
}
}
await this.db.executeMigration(localFile.downSQL, localFile.querySettings);
}
catch (error) {
console.error(chalk_1.default.bold.red(` ❌ Error executing downSQL for migration ${dbMigration.version}:`), error.message);
console.error(chalk_1.default.bold.red(' Reset process halted due to error. Some migrations may remain in the database. Manual cleanup might be required.'));
throw error;
}
}
}
try {
console.log(chalk_1.default.blue('\n⏳ Clearing the __clicksuite_migrations table...'));
await this.db.clearMigrationsTable();
await this.db.optimizeMigrationTable();
console.log(chalk_1.default.greenBright('\n✅ Database migrations have been reset successfully!'));
if (!this.context.skipSchemaUpdate) {
await this._updateSchemaFile();
}
}
catch (error) {
console.error(chalk_1.default.bold.red('❌ Error clearing or optimizing migrations table during reset:'), error.message);
throw error;
}
}
async _updateSchemaFile() {
const schemaPath = path.join(this.context.migrationsDir, 'schema.sql');
try {
if (this.context.verbose) {
console.log(chalk_1.default.dim(`🔍 Updating schema file for all databases (excluding system databases)`));
}
// Use the existing getDatabaseSchema method which already handles all the logic
const schema = await this.db.getDatabaseSchema();
// Get all database objects to count them for verbose output
if (this.context.verbose) {
const allTables = await this.db.getDatabaseTables();
const allViews = await this.db.getDatabaseMaterializedViews();
const allDictionaries = await this.db.getDatabaseDictionaries();
console.log(chalk_1.default.dim(`🔍 Found ${allTables.length} tables, ${allViews.length} views, ${allDictionaries.length} dictionaries across all databases`));
}
// Get unique database names from schema keys
const uniqueDatabases = new Set();
Object.keys(schema).forEach(key => {
const match = key.match(/^(table|view|dictionary)\/(.+)\.(.+)$/);
if (match) {
uniqueDatabases.add(match[2]); // Extract database name
}
});
let schemaContent = `-- Auto-generated schema file
-- This file contains table definitions, materialized view definitions, and dictionary definitions
-- Generated on: ${new Date().toISOString()}
-- Environment: ${this.context.environment}
-- Databases: ${Array.from(uniqueDatabases).sort().join(', ')}
`;
// Group schema entries by type
const tables = Object.entries(schema).filter(([key]) => key.startsWith('table/'));
const views = Object.entries(schema).filter(([key]) => key.startsWith('view/'));
const dictionaries = Object.entries(schema).filter(([key]) => key.startsWith('dictionary/'));
// Add table definitions
if (tables.length > 0) {
schemaContent += '\n-- =====================================================\n';
schemaContent += '-- TABLES\n';
schemaContent += '-- =====================================================\n';
for (const [key, createStatement] of tables) {
const objectName = key.replace('table/', '');
schemaContent += `\n-- Table: ${objectName}\n`;
schemaContent += createStatement;
if (!createStatement.endsWith(';')) {
schemaContent += ';';
}
schemaContent += '\n\n';
}
}
// Add materialized view definitions
if (views.length > 0) {
schemaContent += '\n-- =====================================================\n';
schemaContent += '-- MATERIALIZED VIEWS\n';
schemaContent += '-- =====================================================\n';
for (const [key, createStatement] of views) {
const objectName = key.replace('view/', '');
schemaContent += `\n-- Materialized View: ${objectName}\n`;
schemaContent += createStatement;
if (!createStatement.endsWith(';')) {
schemaContent += ';';
}
schemaContent += '\n\n';
}
}
// Add dictionary definitions
if (dictionaries.length > 0) {
schemaContent += '\n-- =====================================================\n';
schemaContent += '-- DICTIONARIES\n';
schemaContent += '-- =====================================================\n';
for (const [key, createStatement] of dictionaries) {
const objectName = key.replace('dictionary/', '');
schemaContent += `\n-- Dictionary: ${objectName}\n`;
schemaContent += createStatement;
if (!createStatement.endsWith(';')) {
schemaContent += ';';
}
schemaContent += '\n\n';
}
}
await fs.writeFile(schemaPath, schemaContent);
if (this.context.verbose) {
console.log(chalk_1.default.dim(`✅ Schema file updated: ${schemaPath}`));
}
else {
console.log(chalk_1.default.green('✅ Schema file updated'));
}
}
catch (error) {
console.warn(chalk_1.default.yellow(`⚠️ Warning: Could not update schema file: ${error.message}`));
}
}
async schemaLoad() {
console.log(chalk_1.default.blue('⏳ Loading schema from local migration files into the database (marking as applied without running SQL)...'));
const localMigrations = await this._getLocalMigrations();
const dbRecords = await this.db.getAllMigrationRecords();
const dbMap = new Map();
dbRecords.forEach(rec => dbMap.set(rec.version, rec));
if (localMigrations.length === 0) {
console.log(chalk_1.default.yellow('ℹ️ No local migration files found to load.'));
return;
}
let loadedCount = 0;
let skippedCount = 0;
for (const migration of localMigrations) {
const existingRecord = dbMap.get(migration.version);
if (existingRecord && existingRecord.active === 1) {
console.log(chalk_1.default.gray(`⏭️ Skipping ${migration.version} - ${migration.name}: Already marked as active in DB.`));
skippedCount++;
continue;
}
try {
await this.db.markMigrationApplied(migration.version);
console.log(chalk_1.default.green(`✅ Loaded ${migration.version} - ${migration.name} into migrations table as APPLIED.`));
loadedCount++;
}
catch (error) {
console.error(chalk_1.default.bold.red(`❌ Error loading migration ${migration.version} - ${migration.name} into DB:`), error.message);
}
}
console.log(chalk_1.default.greenBright('\n✅ Schema loading process complete.'));
console.log(chalk_1.default.cyan(` ℹ️ ${loadedCount} migration(s) newly marked as APPLIED.`));
console.log(chalk_1.default.gray(` ℹ️ ${skippedCount} migration(s) were already APPLIED and skipped.`));
if (loadedCount > 0) {
try {
await this.db.optimizeMigrationTable();
if (!this.context.skipSchemaUpdate) {
await this._updateSchemaFile();
}
}
catch (e) { /* error already logged by optimizeMigrationTable */ }
}
}
}
exports.Runner = Runner;