diff --git a/packages/core/__tests__/projects/__snapshots__/deploy-failure-scenarios.test.ts.snap b/packages/core/__tests__/projects/__snapshots__/deploy-failure-scenarios.test.ts.snap index 3a17b1f80..1dd654f0c 100644 --- a/packages/core/__tests__/projects/__snapshots__/deploy-failure-scenarios.test.ts.snap +++ b/packages/core/__tests__/projects/__snapshots__/deploy-failure-scenarios.test.ts.snap @@ -4,8 +4,16 @@ exports[`Deploy Failure Scenarios constraint violation with transaction - automa { "changeCount": 0, "changes": [], - "eventCount": 0, - "events": [], + "eventCount": 1, + "events": [ + { + "change_name": "violate_constraint", + "error_code": "23505", + "error_message": "duplicate key value violates unique constraint "test_users_email_key"", + "event_type": "deploy", + "project": "test-constraint-fail", + }, + ], } `; @@ -24,15 +32,26 @@ exports[`Deploy Failure Scenarios constraint violation without transaction - par "script_hash": "833d7d349e3c4f07e1a24ed40ac9814329efc87c180180342a09874f8124a037", }, ], - "eventCount": 2, + "eventCount": 3, "events": [ { "change_name": "create_table", + "error_code": null, + "error_message": null, "event_type": "deploy", "project": "test-constraint-partial", }, { "change_name": "add_record", + "error_code": null, + "error_message": null, + "event_type": "deploy", + "project": "test-constraint-partial", + }, + { + "change_name": "violate_constraint", + "error_code": "23505", + "error_message": "duplicate key value violates unique constraint "test_products_sku_key"", "event_type": "deploy", "project": "test-constraint-partial", }, @@ -40,42 +59,91 @@ exports[`Deploy Failure Scenarios constraint violation without transaction - par } `; -exports[`Deploy Failure Scenarios verify database state after constraint failure: partial-deployment-state-comparison 1`] = ` +exports[`Deploy Failure Scenarios non-transaction mode - partial deployment on constraint failure: non-transaction-mode-constraint-failure 1`] = ` { "changeCount": 2, "changes": [ { "change_name": "setup_schema", - "project": "test-state-check", + "project": "test-nontransaction-partial", "script_hash": "a3419a48994fd13a668befcaab23c4d0d7e9e08e6e6a9093effb3c85b7e953d9", }, { "change_name": "create_constraint_table", - "project": "test-state-check", + "project": "test-nontransaction-partial", "script_hash": "ec5b17e155a2cd4e098716204192083d31d096a4cf163550d5ea176a4615a4d2", }, ], - "eventCount": 2, + "eventCount": 3, "events": [ { "change_name": "setup_schema", + "error_code": null, + "error_message": null, "event_type": "deploy", - "project": "test-state-check", + "project": "test-nontransaction-partial", }, { "change_name": "create_constraint_table", + "error_code": null, + "error_message": null, + "event_type": "deploy", + "project": "test-nontransaction-partial", + }, + { + "change_name": "fail_on_constraint", + "error_code": "23514", + "error_message": "new row for relation "orders" violates check constraint "orders_amount_check"", "event_type": "deploy", - "project": "test-state-check", + "project": "test-nontransaction-partial", }, ], } `; -exports[`Deploy Failure Scenarios verify database state after constraint failure: transaction-rollback-state-comparison 1`] = ` +exports[`Deploy Failure Scenarios transaction mode - complete rollback on constraint failure: transaction-mode-constraint-failure 1`] = ` { "changeCount": 0, "changes": [], - "eventCount": 0, - "events": [], + "eventCount": 1, + "events": [ + { + "change_name": "fail_on_constraint", + "error_code": "23514", + "error_message": "new row for relation "orders" violates check constraint "orders_amount_check"", + "event_type": "deploy", + "project": "test-transaction-rollback", + }, + ], +} +`; + +exports[`Deploy Failure Scenarios verify failure - non-existent table reference: verify-failure-non-existent-table 1`] = ` +{ + "changeCount": 1, + "changes": [ + { + "change_name": "create_simple_table", + "project": "test-verify-fail", + "script_hash": "f5f0794a55d611246115a67e39747c887da6d6f83d79f63c3aa730fa97772942", + }, + ], + "eventCount": 2, + "events": [ + { + "change_name": "create_simple_table", + "error_code": null, + "error_message": null, + "event_type": "deploy", + "project": "test-verify-fail", + }, + { + "change_name": "create_simple_table", + "error_code": "VERIFICATION_FAILED", + "error_message": "Verification failed for create_simple_table", + "event_type": "verify", + "project": "test-verify-fail", + }, + ], } `; diff --git a/packages/core/__tests__/projects/deploy-failure-scenarios.test.ts b/packages/core/__tests__/projects/deploy-failure-scenarios.test.ts index e8619714a..d38144648 100644 --- a/packages/core/__tests__/projects/deploy-failure-scenarios.test.ts +++ b/packages/core/__tests__/projects/deploy-failure-scenarios.test.ts @@ -66,7 +66,9 @@ describe('Deploy Failure Scenarios', () => { expect(finalState).toMatchSnapshot('transaction-rollback-migration-state'); expect(finalState.changeCount).toBe(0); - expect(finalState.eventCount).toBe(0); // Complete rollback - no events logged + expect(finalState.eventCount).toBe(1); // Now expect deploy failure event to be logged + expect(finalState.events[0].event_type).toBe('deploy'); + expect(finalState.events[0].error_message).toContain('duplicate key value violates unique constraint'); expect(await db.exists('table', 'test_users')).toBe(false); }); @@ -135,23 +137,27 @@ describe('Deploy Failure Scenarios', () => { const finalRecord = await db.query("SELECT * FROM test_products WHERE sku = 'PROD-002'"); expect(finalRecord.rows).toHaveLength(0); - const successEvents = finalState.events.filter((e: any) => e.event_type === 'deploy'); + const successEvents = finalState.events.filter((e: any) => e.event_type === 'deploy' && !e.error_message); expect(successEvents.length).toBe(2); // create_table, add_record - expect(finalState.eventCount).toBe(2); // Only successful deployments logged + const failEvents = finalState.events.filter((e: any) => e.event_type === 'deploy' && e.error_message); + expect(failEvents.length).toBe(1); // violate_constraint failure logged + expect(finalState.eventCount).toBe(3); // 2 successful deployments + 1 failure }); - test('verify database state after constraint failure', async () => { + test('transaction mode - complete rollback on constraint failure', async () => { /* - * SCENARIO: Comparison of transaction vs non-transaction behavior + * SCENARIO: Transaction-based deployment with constraint failure * - * This test demonstrates the key difference between transaction and non-transaction - * deployment modes when failures occur. It shows how the same failure scenario - * results in completely different database states. + * This test demonstrates LaunchQL's complete rollback behavior when useTransaction: true (default). + * When ANY change fails during deployment, ALL changes are automatically rolled back. * - * Transaction mode: Complete rollback (clean state) - * Non-transaction mode: Partial deployment (mixed state requiring cleanup) + * Expected behavior: + * - All 3 changes attempted in single transaction + * - Constraint violation on 3rd change triggers complete rollback + * - Database state: clean (as if deployment never happened) + * - Migration tracking: zero deployed changes, failure event logged outside transaction */ - const tempDir = fixture.createPlanFile('test-state-check', [ + const tempDir = fixture.createPlanFile('test-transaction-rollback', [ { name: 'setup_schema' }, { name: 'create_constraint_table', dependencies: ['setup_schema'] }, { name: 'fail_on_constraint', dependencies: ['create_constraint_table'] } @@ -176,21 +182,71 @@ describe('Deploy Failure Scenarios', () => { useTransaction: true })).rejects.toThrow(/violates check constraint/); - const transactionState = await db.getMigrationState(); + const finalState = await db.getMigrationState(); - expect(transactionState).toMatchSnapshot('transaction-rollback-state-comparison'); + expect(finalState).toMatchSnapshot('transaction-mode-constraint-failure'); expect(await db.exists('schema', 'test_schema')).toBe(false); - expect(transactionState.changeCount).toBe(0); + expect(finalState.changeCount).toBe(0); + expect(finalState.eventCount).toBe(1); // Deploy failure event logged outside transaction + expect(finalState.events[0].event_type).toBe('deploy'); + expect(finalState.events[0].error_message).toContain('violates check constraint'); + + /* + * KEY INSIGHT: Transaction mode provides complete rollback + * + * - launchql_migrate.changes: 0 rows (complete rollback) + * - launchql_migrate.events: 1 failure event (logged outside transaction) + * - Database objects: none (clean state) + * + * RECOMMENDATION: Use transaction mode (default) for atomic deployments + * where you want all-or-nothing behavior. + */ + }); + + test('non-transaction mode - partial deployment on constraint failure', async () => { + /* + * SCENARIO: Non-transaction deployment with constraint failure + * + * This test demonstrates LaunchQL's partial deployment behavior when useTransaction: false. + * Each change is deployed individually - successful changes remain deployed + * even when later changes fail. Deployment stops at first failure. + * + * Expected behavior: + * - Changes deployed one-by-one (no transaction wrapper) + * - First 2 changes succeed and remain deployed + * - 3rd change fails on constraint violation, deployment stops + * - Database state: partial (successful changes persist) + * - Migration tracking: shows successful deployments + failure event + */ + const tempDir = fixture.createPlanFile('test-nontransaction-partial', [ + { name: 'setup_schema' }, + { name: 'create_constraint_table', dependencies: ['setup_schema'] }, + { name: 'fail_on_constraint', dependencies: ['create_constraint_table'] } + ]); + + fixture.createScript(tempDir, 'deploy', 'setup_schema', + 'CREATE SCHEMA test_schema;' + ); + + fixture.createScript(tempDir, 'deploy', 'create_constraint_table', + 'CREATE TABLE test_schema.orders (id SERIAL PRIMARY KEY, amount DECIMAL(10,2) CHECK (amount > 0));' + ); + + fixture.createScript(tempDir, 'deploy', 'fail_on_constraint', + 'INSERT INTO test_schema.orders (amount) VALUES (-100.00);' + ); + + const client = new LaunchQLMigrate(db.config); await expect(client.deploy({ modulePath: tempDir, useTransaction: false })).rejects.toThrow(/violates check constraint/); - const partialState = await db.getMigrationState(); + const finalState = await db.getMigrationState(); - expect(partialState).toMatchSnapshot('partial-deployment-state-comparison'); + expect(finalState).toMatchSnapshot('non-transaction-mode-constraint-failure'); expect(await db.exists('schema', 'test_schema')).toBe(true); expect(await db.exists('table', 'test_schema.orders')).toBe(true); @@ -198,28 +254,77 @@ describe('Deploy Failure Scenarios', () => { const records = await db.query('SELECT * FROM test_schema.orders'); expect(records.rows).toHaveLength(0); - expect(partialState.changeCount).toBe(2); - expect(partialState.changes.map((c: any) => c.change_name)).toEqual(['setup_schema', 'create_constraint_table']); + expect(finalState.changeCount).toBe(2); + expect(finalState.changes.map((c: any) => c.change_name)).toEqual(['setup_schema', 'create_constraint_table']); - const successEvents = partialState.events.filter((e: any) => e.event_type === 'deploy'); + const successEvents = finalState.events.filter((e: any) => e.event_type === 'deploy' && !e.error_message); expect(successEvents.length).toBe(2); // setup_schema, create_constraint_table - expect(partialState.eventCount).toBe(2); // Only successful deployments logged + const failEvents = finalState.events.filter((e: any) => e.event_type === 'deploy' && e.error_message); + expect(failEvents.length).toBe(1); // fail_on_constraint failure logged + expect(finalState.eventCount).toBe(3); // 2 successful deployments + 1 failure /* - * KEY INSIGHT: Same failure scenario, different outcomes + * KEY INSIGHT: Non-transaction mode provides partial deployment * - * Transaction mode: - * - launchql_migrate.changes: 0 rows (complete rollback) - * - launchql_migrate.events: failure events only - * - Database objects: none (clean state) - * - * Non-transaction mode: * - launchql_migrate.changes: 2 rows (partial success) - * - launchql_migrate.events: mix of success + failure events + * - launchql_migrate.events: 2 success + 1 failure event * - Database objects: schema + table exist (mixed state) * - * RECOMMENDATION: Use transaction mode (default) unless you specifically + * IMPORTANT: Deployment stops immediately at first failure, just like transaction mode. + * The difference is in state persistence, not error handling behavior. + * + * RECOMMENDATION: Use non-transaction mode only when you specifically * need partial deployment behavior for incremental rollout scenarios. */ }); + + test('verify failure - non-existent table reference', async () => { + /* + * SCENARIO: Verify script references a table that doesn't exist + * + * This test demonstrates LaunchQL's verify failure behavior when a verify script + * tries to check a table that was never created or was dropped. + * + * Expected behavior: + * - Deploy succeeds (creates a simple table) + * - Verify fails because script references non-existent table + * - Failure event logged with detailed error information + */ + const tempDir = fixture.createPlanFile('test-verify-fail', [ + { name: 'create_simple_table' } + ]); + + fixture.createScript(tempDir, 'deploy', 'create_simple_table', + 'CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(100));' + ); + + fixture.createScript(tempDir, 'verify', 'create_simple_table', + 'SELECT 1 FROM non_existent_table LIMIT 1;' + ); + + const client = new LaunchQLMigrate(db.config); + + await client.deploy({ + modulePath: tempDir, + useTransaction: true + }); + + const deployState = await db.getMigrationState(); + expect(deployState.changeCount).toBe(1); + expect(await db.exists('table', 'users')).toBe(true); + + await expect(client.verify({ + modulePath: tempDir + })).rejects.toThrow('Verification failed for 1 change(s): create_simple_table'); + + const finalState = await db.getMigrationState(); + + expect(finalState).toMatchSnapshot('verify-failure-non-existent-table'); + + // Should have deploy success event + verify failure event + const verifyEvents = finalState.events.filter((e: any) => e.event_type === 'verify'); + expect(verifyEvents.length).toBe(1); + expect(verifyEvents[0].error_message).toBe('Verification failed for create_simple_table'); + expect(verifyEvents[0].error_code).toBe('VERIFICATION_FAILED'); + }); }); diff --git a/packages/core/src/migrate/client.ts b/packages/core/src/migrate/client.ts index d11c00562..671e447e3 100644 --- a/packages/core/src/migrate/client.ts +++ b/packages/core/src/migrate/client.ts @@ -1,5 +1,4 @@ import { Logger } from '@launchql/logger'; -import { getDeploymentEnvOptions } from '@launchql/env'; import { readFileSync } from 'fs'; import { dirname,join } from 'path'; import { Pool } from 'pg'; @@ -19,6 +18,7 @@ import { StatusResult, VerifyOptions, VerifyResult} from './types'; +import { EventLogger } from './utils/event-logger'; import { hashFile, hashSqlFile } from './utils/hash'; import { executeQuery, withTransaction } from './utils/transaction'; @@ -45,6 +45,7 @@ export class LaunchQLMigrate { private pool: Pool; private pgConfig: PgConfig; private hashMethod: HashMethod; + private eventLogger: EventLogger; private initialized: boolean = false; constructor(config: PgConfig, options: LaunchQLMigrateOptions = {}) { @@ -53,6 +54,7 @@ export class LaunchQLMigrate { const envHashMethod = process.env.DEPLOYMENT_HASH_METHOD as HashMethod; this.hashMethod = options.hashMethod || envHashMethod || 'content'; this.pool = getPgPool(this.pgConfig); + this.eventLogger = new EventLogger(this.pgConfig); } /** @@ -209,6 +211,15 @@ export class LaunchQLMigrate { deployed.push(change.name); log.success(`Successfully ${logOnly ? 'logged' : 'deployed'}: ${change.name}`); } catch (error: any) { + // Log failure event outside of transaction + await this.eventLogger.logEvent({ + eventType: 'deploy', + changeName: change.name, + project: plan.project, + errorMessage: error.message || 'Unknown error', + errorCode: error.code || null + }); + // Build comprehensive error message const errorLines = []; errorLines.push(`Failed to deploy ${change.name}:`); @@ -457,7 +468,16 @@ export class LaunchQLMigrate { reverted.push(change.name); log.success(`Successfully reverted: ${change.name}`); - } catch (error) { + } catch (error: any) { + // Log failure event outside of transaction + await this.eventLogger.logEvent({ + eventType: 'revert', + changeName: change.name, + project: plan.project, + 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 @@ -516,10 +536,20 @@ export class LaunchQLMigrate { verified.push(change.name); log.success(`Successfully verified: ${change.name}`); } else { - failed.push(change.name); - log.error(`Verification failed: ${change.name}`); + const verificationError = new Error(`Verification failed for ${change.name}`) as any; + verificationError.code = 'VERIFICATION_FAILED'; + throw verificationError; } - } catch (error) { + } catch (error: any) { + // Log failure event with rich error information + await this.eventLogger.logEvent({ + eventType: 'verify', + changeName: change.name, + project: plan.project, + errorMessage: error.message || 'Unknown error', + errorCode: error.code || null + }); + log.error(`Failed to verify ${change.name}:`, error); failed.push(change.name); } @@ -527,6 +557,10 @@ export class LaunchQLMigrate { } finally { } + if (failed.length > 0) { + throw new Error(`Verification failed for ${failed.length} change(s): ${failed.join(', ')}`); + } + return { verified, failed }; } diff --git a/packages/core/src/migrate/sql/procedures.sql b/packages/core/src/migrate/sql/procedures.sql index 02dd3e913..8fc15ab1a 100644 --- a/packages/core/src/migrate/sql/procedures.sql +++ b/packages/core/src/migrate/sql/procedures.sql @@ -97,8 +97,6 @@ BEGIN BEGIN EXECUTE p_deploy_sql; EXCEPTION WHEN OTHERS THEN - INSERT INTO launchql_migrate.events (event_type, change_name, project) - VALUES ('fail', p_change_name, p_project); RAISE; END; END IF; diff --git a/packages/core/src/migrate/sql/schema.sql b/packages/core/src/migrate/sql/schema.sql index 33c1bcf87..88cf952cc 100644 --- a/packages/core/src/migrate/sql/schema.sql +++ b/packages/core/src/migrate/sql/schema.sql @@ -28,8 +28,10 @@ CREATE TABLE launchql_migrate.dependencies ( -- 4. Event log (minimal history for rollback) CREATE TABLE launchql_migrate.events ( event_id SERIAL PRIMARY KEY, - event_type TEXT NOT NULL CHECK (event_type IN ('deploy', 'revert', 'fail')), + event_type TEXT NOT NULL CHECK (event_type IN ('deploy', 'revert', 'verify')), change_name TEXT NOT NULL, project TEXT NOT NULL, - occurred_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() + occurred_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + error_message TEXT, + error_code TEXT ); diff --git a/packages/core/src/migrate/utils/event-logger.ts b/packages/core/src/migrate/utils/event-logger.ts new file mode 100644 index 000000000..08fe229ac --- /dev/null +++ b/packages/core/src/migrate/utils/event-logger.ts @@ -0,0 +1,43 @@ +import { Logger } from '@launchql/logger'; +import { Pool } from 'pg'; +import { getPgPool } from 'pg-cache'; +import { PgConfig } from 'pg-env'; + +const log = new Logger('migrate:event-logger'); + +export interface EventLogEntry { + eventType: 'deploy' | 'revert' | 'verify'; + changeName: string; + project: string; + errorMessage?: string; + errorCode?: string; +} + +export class EventLogger { + private pool: Pool; + + constructor(config: PgConfig) { + this.pool = getPgPool(config); + } + + + async logEvent(entry: EventLogEntry): Promise { + try { + await this.pool.query(` + INSERT INTO launchql_migrate.events + (event_type, change_name, project, error_message, error_code) + VALUES ($1::TEXT, $2::TEXT, $3::TEXT, $4::TEXT, $5::TEXT) + `, [ + entry.eventType, + entry.changeName, + entry.project, + entry.errorMessage || null, + entry.errorCode || null + ]); + + log.debug(`Logged ${entry.eventType} event for ${entry.project}:${entry.changeName}`); + } catch (error: any) { + log.error(`Failed to log event: ${error.message}`); + } + } +} diff --git a/packages/core/test-utils/MigrateTestFixture.ts b/packages/core/test-utils/MigrateTestFixture.ts index 458c51c50..ec5cb35cf 100644 --- a/packages/core/test-utils/MigrateTestFixture.ts +++ b/packages/core/test-utils/MigrateTestFixture.ts @@ -61,6 +61,7 @@ export class MigrateTestFixture { const pool = getPgPool(pgConfig); this.pools.push(pool); + const fixture = this; const db: TestDatabase = { name: dbName, config, @@ -109,14 +110,16 @@ export class MigrateTestFixture { `); const events = await pool.query(` - SELECT project, change_name, event_type, occurred_at + SELECT project, change_name, event_type, occurred_at, error_message, error_code FROM launchql_migrate.events ORDER BY occurred_at `); + const sanitizedEvents = events.rows; + // Remove timestamps from objects for consistent snapshots const cleanChanges = changes.rows.map(({ deployed_at, ...change }) => change); - const cleanEvents = events.rows.map(({ occurred_at, ...event }) => event); + const cleanEvents = sanitizedEvents.map(({ occurred_at, ...event }) => event); return { changes: cleanChanges, @@ -147,6 +150,7 @@ export class MigrateTestFixture { return db; } + setupFixture(fixturePath: string[]): string { const originalPath = join(FIXTURES_PATH, ...fixturePath); const fixtureName = fixturePath[fixturePath.length - 1]; // Use last element as fixture name