UNPKG

@launchql/core

Version:

LaunchQL Package and Migration Tools

542 lines (541 loc) 23.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.LaunchQLMigrate = void 0; const logger_1 = require("@launchql/logger"); const fs_1 = require("fs"); const path_1 = require("path"); const pg_cache_1 = require("pg-cache"); const types_1 = require("@launchql/types"); const files_1 = require("../files"); const deps_1 = require("../resolution/deps"); const resolve_1 = require("../resolution/resolve"); const clean_1 = require("./clean"); const event_logger_1 = require("./utils/event-logger"); const hash_1 = require("./utils/hash"); const transaction_1 = require("./utils/transaction"); // Helper function to get changes in order function getChangesInOrder(planPath, reverse = false) { const plan = (0, files_1.parsePlanFileSimple)(planPath); return reverse ? [...plan.changes].reverse() : plan.changes; } const log = new logger_1.Logger('migrate'); class LaunchQLMigrate { pool; pgConfig; hashMethod; eventLogger; initialized = false; constructor(config, options = {}) { this.pgConfig = config; // Use environment variable DEPLOYMENT_HASH_METHOD if available, otherwise use options or default to 'content' const envHashMethod = process.env.DEPLOYMENT_HASH_METHOD; this.hashMethod = options.hashMethod || envHashMethod || 'content'; this.pool = (0, pg_cache_1.getPgPool)(this.pgConfig); this.eventLogger = new event_logger_1.EventLogger(this.pgConfig); } /** * Calculate script hash using the configured method */ async calculateScriptHash(filePath) { if (this.hashMethod === 'ast') { return await (0, hash_1.hashSqlFile)(filePath); } else { return await (0, hash_1.hashFile)(filePath); } } /** * Initialize the migration schema */ async initialize() { if (this.initialized) return; try { log.info('Checking LaunchQL migration schema...'); // Check if launchql_migrate schema exists const result = await this.pool.query(` SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'launchql_migrate' `); if (result.rows.length === 0) { log.info('Schema not found, creating migration schema...'); // Read and execute schema SQL to create schema and tables const schemaPath = (0, path_1.join)(__dirname, 'sql', 'schema.sql'); const proceduresPath = (0, path_1.join)(__dirname, 'sql', 'procedures.sql'); const schemaSql = (0, fs_1.readFileSync)(schemaPath, 'utf-8'); const proceduresSql = (0, fs_1.readFileSync)(proceduresPath, 'utf-8'); await this.pool.query(schemaSql); await this.pool.query(proceduresSql); log.success('Migration schema created successfully'); } else { log.success('Migration schema found and ready'); } this.initialized = true; } catch (error) { log.error('Failed to initialize migration schema:', error); throw error; } } /** * Resolve toChange parameter, handling tag resolution if needed */ resolveToChange(toChange, planPath, packageName) { return toChange && toChange.includes('@') ? (0, resolve_1.resolveTagToChangeName)(planPath, toChange, packageName) : toChange; } /** * Deploy changes according to plan file */ async deploy(options) { await this.initialize(); const { modulePath, toChange, useTransaction = true, debug = false, logOnly = false } = options; const planPath = (0, path_1.join)(modulePath, 'launchql.plan'); const plan = (0, files_1.parsePlanFileSimple)(planPath); const resolvedToChange = this.resolveToChange(toChange, planPath, plan.package); const changes = getChangesInOrder(planPath); const fullPlanResult = (0, files_1.parsePlanFile)(planPath); const packageDir = (0, path_1.dirname)(planPath); const resolvedDeps = (0, deps_1.resolveDependencies)(packageDir, fullPlanResult.data?.package || plan.package, { tagResolution: 'resolve', loadPlanFiles: true, source: options.usePlan ? 'plan' : 'sql' }); const deployed = []; const skipped = []; let failed; // Use a separate pool for the target database const targetPool = (0, pg_cache_1.getPgPool)(this.pgConfig); // Execute deployment with or without transaction await (0, transaction_1.withTransaction)(targetPool, { useTransaction }, async (context) => { for (const change of changes) { // Stop if we've reached the target change if (resolvedToChange && deployed.includes(resolvedToChange)) { break; } const isDeployed = await this.isDeployed(plan.package, change.name); if (isDeployed) { log.info(`Skipping already deployed change: ${change.name}`); skipped.push(change.name); continue; } // Read deploy script const deployScript = (0, files_1.readScript)((0, path_1.dirname)(planPath), 'deploy', change.name); if (!deployScript) { log.error(`Deploy script not found for change: ${change.name}`); failed = change.name; break; } const cleanDeploySql = await (0, clean_1.cleanSql)(deployScript, false, '$EOFCODE$'); // Calculate script hash const scriptHash = await this.calculateScriptHash((0, path_1.join)((0, path_1.dirname)(planPath), 'deploy', `${change.name}.sql`)); const changeKey = `/deploy/${change.name}.sql`; const resolvedFromDeps = resolvedDeps?.deps[changeKey]; const resolvedChangeDeps = (resolvedFromDeps && resolvedFromDeps.length > 0) ? resolvedFromDeps : change.dependencies; try { // Call the deploy stored procedure await (0, transaction_1.executeQuery)(context, 'CALL launchql_migrate.deploy($1::TEXT, $2::TEXT, $3::TEXT, $4::TEXT[], $5::TEXT, $6::BOOLEAN)', [ plan.package, change.name, scriptHash, resolvedChangeDeps.length > 0 ? resolvedChangeDeps : null, cleanDeploySql, logOnly ]); deployed.push(change.name); log.success(`Successfully ${logOnly ? 'logged' : 'deployed'}: ${change.name}`); } catch (error) { // Log failure event outside of transaction await this.eventLogger.logEvent({ eventType: 'deploy', changeName: change.name, package: plan.package, errorMessage: error.message || 'Unknown error', errorCode: error.code || null }); // Build comprehensive error message const errorLines = []; errorLines.push(`Failed to deploy ${change.name}:`); errorLines.push(` Change: ${change.name}`); errorLines.push(` Package: ${plan.package}`); errorLines.push(` Script Hash: ${scriptHash}`); errorLines.push(` Dependencies: ${resolvedChangeDeps.length > 0 ? resolvedChangeDeps.join(', ') : 'none'}`); errorLines.push(` Error Code: ${error.code || 'N/A'}`); errorLines.push(` Error Message: ${error.message || 'N/A'}`); // Show SQL script preview for debugging if (cleanDeploySql) { const sqlLines = cleanDeploySql.split('\n'); const previewLines = debug ? sqlLines : sqlLines.slice(0, 10); if (debug) { errorLines.push(` Full SQL Script (${sqlLines.length} lines):`); previewLines.forEach((line, index) => { errorLines.push(` ${index + 1}: ${line}`); }); } else { errorLines.push(` SQL Preview (first 10 lines):`); previewLines.forEach((line, index) => { errorLines.push(` ${index + 1}: ${line}`); }); if (sqlLines.length > 10) { errorLines.push(` ... and ${sqlLines.length - 10} more lines`); errorLines.push(` 💡 Use debug mode to see full SQL script`); } } } // Provide debugging hints based on error code if (error.code === '25P02') { errorLines.push(`🔍 Debug Info: This error means a previous command in the transaction failed.`); errorLines.push(` The SQL script above may contain the failing command.`); errorLines.push(` Check the transaction query history for more details.`); } else if (error.code === '42P01') { errorLines.push(`💡 Hint: A table or view referenced in the SQL script does not exist.`); errorLines.push(` Check if dependencies are applied in the correct order.`); } else if (error.code === '42883') { errorLines.push(`💡 Hint: A function referenced in the SQL script does not exist.`); errorLines.push(` Check if required extensions or previous migrations are applied.`); } // Log the consolidated error message log.error(errorLines.join('\n')); failed = change.name; throw error; // Re-throw to trigger rollback if in transaction } // Stop if this was the target change if (toChange && change.name === toChange) { break; } } }); return { deployed, skipped, failed }; } /** * Revert changes according to plan file */ async revert(options) { await this.initialize(); const { modulePath, toChange, useTransaction = true } = options; const planPath = (0, path_1.join)(modulePath, 'launchql.plan'); const plan = (0, files_1.parsePlanFileSimple)(planPath); const resolvedToChange = this.resolveToChange(toChange, planPath, plan.package); const changes = getChangesInOrder(planPath, true); // Reverse order for revert const reverted = []; const skipped = []; let failed; // Use a separate pool for the target database const targetPool = (0, pg_cache_1.getPgPool)(this.pgConfig); // Execute revert with or without transaction await (0, transaction_1.withTransaction)(targetPool, { useTransaction }, async (context) => { for (const change of changes) { // Stop if we've reached the target change if (resolvedToChange && change.name === resolvedToChange) { break; } // Check if deployed const isDeployed = await this.isDeployed(plan.package, change.name); if (!isDeployed) { log.info(`Skipping not deployed change: ${change.name}`); skipped.push(change.name); continue; } // Read revert script const revertScript = (0, files_1.readScript)((0, path_1.dirname)(planPath), 'revert', change.name); if (!revertScript) { log.error(`Revert script not found for change: ${change.name}`); failed = change.name; break; } const cleanRevertSql = await (0, clean_1.cleanSql)(revertScript, false, '$EOFCODE$'); try { // Call the revert stored procedure await (0, transaction_1.executeQuery)(context, 'CALL launchql_migrate.revert($1, $2, $3)', [plan.package, change.name, cleanRevertSql]); reverted.push(change.name); log.success(`Successfully reverted: ${change.name}`); } catch (error) { // Log failure event outside of transaction await this.eventLogger.logEvent({ eventType: 'revert', changeName: change.name, package: plan.package, errorMessage: error.message || 'Unknown error', errorCode: error.code || null }); log.error(`Failed to revert ${change.name}:`, error); failed = change.name; throw error; // Re-throw to trigger rollback if in transaction } } }); return { reverted, skipped, failed }; } /** * Verify deployed changes */ async verify(options) { await this.initialize(); const { modulePath, toChange } = options; const planPath = (0, path_1.join)(modulePath, 'launchql.plan'); const plan = (0, files_1.parsePlanFileSimple)(planPath); const resolvedToChange = this.resolveToChange(toChange, planPath, plan.package); const changes = getChangesInOrder(planPath); const verified = []; const failed = []; // Use a separate pool for the target database const targetPool = (0, pg_cache_1.getPgPool)(this.pgConfig); try { for (const change of changes) { // Stop if we've reached the target change if (resolvedToChange && change.name === resolvedToChange) { break; } // Check if deployed const isDeployed = await this.isDeployed(plan.package, change.name); if (!isDeployed) { continue; } // Read verify script const verifyScript = (0, files_1.readScript)((0, path_1.dirname)(planPath), 'verify', change.name); if (!verifyScript) { log.warn(`Verify script not found for change: ${change.name}`); continue; } const cleanVerifySql = await (0, clean_1.cleanSql)(verifyScript, false, '$EOFCODE$'); try { // Call the verify function const result = await targetPool.query('SELECT launchql_migrate.verify($1, $2, $3) as verified', [plan.package, change.name, cleanVerifySql]); if (result.rows[0].verified) { verified.push(change.name); log.success(`Successfully verified: ${change.name}`); } else { const verificationError = new Error(`Verification failed for ${change.name}`); verificationError.code = 'VERIFICATION_FAILED'; throw verificationError; } } catch (error) { // Log failure event with rich error information await this.eventLogger.logEvent({ eventType: 'verify', changeName: change.name, package: plan.package, errorMessage: error.message || 'Unknown error', errorCode: error.code || null }); log.error(`Failed to verify ${change.name}:`, error); failed.push(change.name); } } } finally { } if (failed.length > 0) { throw types_1.errors.OPERATION_FAILED({ operation: 'Verification', reason: `${failed.length} change(s): ${failed.join(', ')}` }); } return { verified, failed }; } /** * Get deployment status */ async status(packageName) { await this.initialize(); const result = await this.pool.query('SELECT * FROM launchql_migrate.status($1)', [packageName]); return result.rows.map(row => ({ package: row.package, totalDeployed: row.total_deployed, lastChange: row.last_change, lastDeployed: new Date(row.last_deployed) })); } /** * Check if a change is deployed */ async isDeployed(packageName, changeName) { const result = await this.pool.query('SELECT launchql_migrate.is_deployed($1::TEXT, $2::TEXT) as is_deployed', [packageName, changeName]); return result.rows[0].is_deployed; } /** * Check if Sqitch tables exist in the database */ async hasSqitchTables() { const result = await this.pool.query(` SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'sqitch' AND table_name IN ('projects', 'changes', 'tags', 'events') ) `); return result.rows[0].exists; } /** * Import from existing Sqitch deployment */ async importFromSqitch() { await this.initialize(); try { log.info('Checking for existing Sqitch tables...'); // Check if sqitch schema exists const schemaResult = await this.pool.query("SELECT EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'sqitch')"); if (!schemaResult.rows[0].exists) { log.info('No Sqitch schema found, nothing to import'); return; } // Import packages log.info('Importing Sqitch packages...'); await this.pool.query(` INSERT INTO launchql_migrate.packages (package, created_at) SELECT DISTINCT project, now() FROM sqitch.projects ON CONFLICT (package) DO NOTHING `); // Import changes with dependencies log.info('Importing Sqitch changes...'); await this.pool.query(` WITH change_data AS ( SELECT c.project, c.change, c.change_id, c.committed_at FROM sqitch.changes c WHERE c.change_id IN ( SELECT change_id FROM sqitch.tags ) ) INSERT INTO launchql_migrate.changes (change_id, change_name, package, script_hash, deployed_at) SELECT encode(sha256((cd.project || cd.change || cd.change_id)::bytea), 'hex'), cd.change, cd.project, cd.change_id, cd.committed_at FROM change_data cd ON CONFLICT (package, change_name) DO NOTHING `); // Import dependencies log.info('Importing Sqitch dependencies...'); await this.pool.query(` INSERT INTO launchql_migrate.dependencies (change_id, requires) SELECT c.change_id, d.dependency FROM launchql_migrate.changes c JOIN sqitch.dependencies d ON d.change_id = c.script_hash ON CONFLICT DO NOTHING `); log.success('Successfully imported Sqitch deployment history'); } catch (error) { log.error('Failed to import from Sqitch:', error); throw error; } } /** * Get recent changes */ async getRecentChanges(targetDatabase, limit = 10) { const targetPool = (0, pg_cache_1.getPgPool)({ ...this.pgConfig, database: targetDatabase }); try { const result = await targetPool.query(` SELECT c.change_name, c.deployed_at, c.package FROM launchql_migrate.changes c ORDER BY c.deployed_at DESC NULLS LAST LIMIT $1 `, [limit]); return result.rows; } catch (error) { log.error('Failed to get recent changes:', error); throw error; } } /** * Get pending changes (in plan but not deployed) */ async getPendingChanges(planPath, targetDatabase) { const plan = (0, files_1.parsePlanFileSimple)(planPath); const allChanges = getChangesInOrder(planPath); const targetPool = (0, pg_cache_1.getPgPool)({ ...this.pgConfig, database: targetDatabase }); try { const deployedResult = await targetPool.query(` SELECT c.change_name FROM launchql_migrate.changes c WHERE c.package = $1 AND c.deployed_at IS NOT NULL `, [plan.package]); const deployedSet = new Set(deployedResult.rows.map((r) => r.change_name)); return allChanges.filter(c => !deployedSet.has(c.name)).map(c => c.name); } catch (error) { // If schema doesn't exist, all changes are pending if (error.code === '42P01') { // undefined_table return allChanges.map(c => c.name); } throw error; } } /** * Get all deployed changes for a project */ async getDeployedChanges(targetDatabase, packageName) { const targetPool = (0, pg_cache_1.getPgPool)({ ...this.pgConfig, database: targetDatabase }); try { const result = await targetPool.query(` SELECT c.change_name, c.deployed_at, c.script_hash FROM launchql_migrate.changes c WHERE c.package = $1 AND c.deployed_at IS NOT NULL ORDER BY c.deployed_at ASC `, [packageName]); return result.rows; } catch (error) { // If schema doesn't exist, no changes are deployed if (error.code === '42P01') { // undefined_table return []; } throw error; } } /** * Get dependencies for a change */ async getDependencies(packageName, changeName) { await this.initialize(); try { const result = await this.pool.query(`SELECT d.requires FROM launchql_migrate.dependencies d JOIN launchql_migrate.changes c ON c.change_id = d.change_id WHERE c.package = $1 AND c.change_name = $2`, [packageName, changeName]); return result.rows.map(row => row.requires); } catch (error) { log.error(`Failed to get dependencies for ${packageName}:${changeName}:`, error); return []; } } /** * Close the database connection pool */ async close() { // Pool is managed by PgPoolCacheManager, no need to close } } exports.LaunchQLMigrate = LaunchQLMigrate;