diff --git a/packages/core/__tests__/projects/DEPLOYMENT_FAILURE_ANALYSIS.md b/packages/core/__tests__/projects/DEPLOYMENT_FAILURE_ANALYSIS.md new file mode 100644 index 000000000..aff8d3193 --- /dev/null +++ b/packages/core/__tests__/projects/DEPLOYMENT_FAILURE_ANALYSIS.md @@ -0,0 +1,115 @@ +# Deployment Failure Analysis + +## Overview + +This document analyzes the behavior of LaunchQL's migration system when SQL changes fail during deployment, specifically examining the state of the `launchql_migrate` schema and tables. + +## Key Findings + +### Transaction Mode (Default: `useTransaction: true`) + +When a deployment fails in transaction mode: + +- **Complete Rollback**: All changes are automatically rolled back +- **Database State**: Clean (as if deployment never happened) +- **Migration Tracking**: + - `launchql_migrate.changes`: 0 rows + - `launchql_migrate.events`: 0 rows +- **Behavior**: Entire deployment is wrapped in a single transaction + +**Snapshot Evidence:** +```json +{ + "changeCount": 0, + "changes": [], + "eventCount": 0, + "events": [] +} +``` + +### Non-Transaction Mode (`useTransaction: false`) + +When a deployment fails in non-transaction mode: + +- **Partial Deployment**: Successful changes remain deployed +- **Database State**: Mixed (successful changes persist) +- **Migration Tracking**: + - `launchql_migrate.changes`: Contains successful deployments + - `launchql_migrate.events`: Contains `deploy` events for successful changes +- **Behavior**: Each change deployed individually + +**Snapshot Evidence:** +```json +{ + "changeCount": 2, + "changes": [ + { + "change_name": "create_table", + "deployed_at": "2025-07-20T09:15:13.265Z", + "project": "test-constraint-partial", + "script_hash": "0624b3e2276299c8c3b8bfa514fe0d128906193769b3aeaea6732e71c0e352e6" + }, + { + "change_name": "add_record", + "deployed_at": "2025-07-20T09:15:13.269Z", + "project": "test-constraint-partial", + "script_hash": "833d7d349e3c4f07e1a24ed40ac9814329efc87c180180342a09874f8124a037" + } + ], + "eventCount": 2, + "events": [ + { + "change_name": "create_table", + "event_type": "deploy", + "occurred_at": "2025-07-20T09:15:13.266Z", + "project": "test-constraint-partial" + }, + { + "change_name": "add_record", + "event_type": "deploy", + "occurred_at": "2025-07-20T09:15:13.269Z", + "project": "test-constraint-partial" + } + ] +} +``` + +## Important Observations + +### Failure Event Logging + +**Critical Discovery**: Failure events are NOT logged to the `launchql_migrate.events` table. Only successful deployments create entries with `event_type: 'deploy'`. + +- Failed deployments are logged to application logs but not persisted in the migration tracking tables +- The `launchql_migrate.events` table only contains successful deployment records +- This means you cannot query the migration tables to see deployment failure history + +### Schema Structure + +The `launchql_migrate.events` table supports failure tracking with: +```sql +event_type TEXT NOT NULL CHECK (event_type IN ('deploy', 'revert', 'fail')) +``` + +However, in practice, only `'deploy'` events are currently being logged. + +## Recommendations + +### For Production Use + +1. **Use Transaction Mode (Default)**: Provides automatic rollback and clean state on failure +2. **Monitor Application Logs**: Failure details are logged but not persisted in migration tables +3. **Manual Cleanup**: In non-transaction mode, failed deployments require manual cleanup of successful changes + +### For Development/Testing + +- Non-transaction mode can be useful for incremental rollout scenarios where partial success is acceptable +- Always verify the state of `launchql_migrate.changes` after deployment failures + +## Test Coverage + +The deployment failure scenarios are tested in: +- `packages/core/__tests__/projects/deploy-failure-scenarios.test.ts` +- Snapshots: `packages/core/__tests__/projects/__snapshots__/deploy-failure-scenarios.test.ts.snap` + +These tests demonstrate the exact database state differences between transaction and non-transaction failure modes using constraint violations as the failure mechanism. 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 new file mode 100644 index 000000000..3a17b1f80 --- /dev/null +++ b/packages/core/__tests__/projects/__snapshots__/deploy-failure-scenarios.test.ts.snap @@ -0,0 +1,81 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Deploy Failure Scenarios constraint violation with transaction - automatic rollback: transaction-rollback-migration-state 1`] = ` +{ + "changeCount": 0, + "changes": [], + "eventCount": 0, + "events": [], +} +`; + +exports[`Deploy Failure Scenarios constraint violation without transaction - partial deployment: partial-deployment-migration-state 1`] = ` +{ + "changeCount": 2, + "changes": [ + { + "change_name": "create_table", + "project": "test-constraint-partial", + "script_hash": "0624b3e2276299c8c3b8bfa514fe0d128906193769b3aeaea6732e71c0e352e6", + }, + { + "change_name": "add_record", + "project": "test-constraint-partial", + "script_hash": "833d7d349e3c4f07e1a24ed40ac9814329efc87c180180342a09874f8124a037", + }, + ], + "eventCount": 2, + "events": [ + { + "change_name": "create_table", + "event_type": "deploy", + "project": "test-constraint-partial", + }, + { + "change_name": "add_record", + "event_type": "deploy", + "project": "test-constraint-partial", + }, + ], +} +`; + +exports[`Deploy Failure Scenarios verify database state after constraint failure: partial-deployment-state-comparison 1`] = ` +{ + "changeCount": 2, + "changes": [ + { + "change_name": "setup_schema", + "project": "test-state-check", + "script_hash": "a3419a48994fd13a668befcaab23c4d0d7e9e08e6e6a9093effb3c85b7e953d9", + }, + { + "change_name": "create_constraint_table", + "project": "test-state-check", + "script_hash": "ec5b17e155a2cd4e098716204192083d31d096a4cf163550d5ea176a4615a4d2", + }, + ], + "eventCount": 2, + "events": [ + { + "change_name": "setup_schema", + "event_type": "deploy", + "project": "test-state-check", + }, + { + "change_name": "create_constraint_table", + "event_type": "deploy", + "project": "test-state-check", + }, + ], +} +`; + +exports[`Deploy Failure Scenarios verify database state after constraint failure: transaction-rollback-state-comparison 1`] = ` +{ + "changeCount": 0, + "changes": [], + "eventCount": 0, + "events": [], +} +`; diff --git a/packages/core/__tests__/projects/deploy-failure-scenarios.test.ts b/packages/core/__tests__/projects/deploy-failure-scenarios.test.ts new file mode 100644 index 000000000..e8619714a --- /dev/null +++ b/packages/core/__tests__/projects/deploy-failure-scenarios.test.ts @@ -0,0 +1,225 @@ +import { LaunchQLMigrate } from '../../src/migrate/client'; +import { MigrateTestFixture, teardownAllPools, TestDatabase } from '../../test-utils'; + +describe('Deploy Failure Scenarios', () => { + let fixture: MigrateTestFixture; + let db: TestDatabase; + + beforeEach(async () => { + fixture = new MigrateTestFixture(); + db = await fixture.setupTestDatabase(); + }); + + afterEach(async () => { + await fixture.cleanup(); + }); + + afterAll(async () => { + await teardownAllPools(); + }); + + + test('constraint violation with transaction - automatic rollback', async () => { + /* + * SCENARIO: Transaction-based deployment with constraint violation + * + * This test demonstrates LaunchQL's automatic rollback behavior when useTransaction: true (default). + * When ANY change fails during deployment, ALL changes are automatically rolled back. + * + * 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 events logged + */ + const tempDir = fixture.createPlanFile('test-constraint-fail', [ + { name: 'create_table' }, + { name: 'add_constraint', dependencies: ['create_table'] }, + { name: 'violate_constraint', dependencies: ['add_constraint'] } + ]); + + fixture.createScript(tempDir, 'deploy', 'create_table', + 'CREATE TABLE test_users (id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE);' + ); + + fixture.createScript(tempDir, 'deploy', 'add_constraint', + "INSERT INTO test_users (email) VALUES ('test@example.com');" + ); + + fixture.createScript(tempDir, 'deploy', 'violate_constraint', + "INSERT INTO test_users (email) VALUES ('test@example.com');" + ); + + const client = new LaunchQLMigrate(db.config); + + const initialState = await db.getMigrationState(); + expect(initialState.changeCount).toBe(0); + expect(initialState.eventCount).toBe(0); + + await expect(client.deploy({ + modulePath: tempDir, + useTransaction: true + })).rejects.toThrow(/duplicate key value violates unique constraint/); + + const finalState = await db.getMigrationState(); + + expect(finalState).toMatchSnapshot('transaction-rollback-migration-state'); + + expect(finalState.changeCount).toBe(0); + expect(finalState.eventCount).toBe(0); // Complete rollback - no events logged + + expect(await db.exists('table', 'test_users')).toBe(false); + }); + + test('constraint violation without transaction - partial deployment', async () => { + /* + * SCENARIO: Non-transaction deployment with constraint violation + * + * This test demonstrates LaunchQL's behavior when useTransaction: false. + * Each change is deployed individually - successful changes remain deployed + * even when later changes fail. Manual cleanup is required. + * + * 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 + * - 4th change never attempted (deployment stops at first failure) + * - Database state: partial (successful changes persist) + * - Migration tracking: shows successful deployments + failure events + */ + const tempDir = fixture.createPlanFile('test-constraint-partial', [ + { name: 'create_table' }, + { name: 'add_record', dependencies: ['create_table'] }, + { name: 'violate_constraint', dependencies: ['add_record'] }, + { name: 'final_change', dependencies: ['add_record'] } + ]); + + fixture.createScript(tempDir, 'deploy', 'create_table', + 'CREATE TABLE test_products (id SERIAL PRIMARY KEY, sku VARCHAR(50) UNIQUE);' + ); + + fixture.createScript(tempDir, 'deploy', 'add_record', + "INSERT INTO test_products (sku) VALUES ('PROD-001');" + ); + + fixture.createScript(tempDir, 'deploy', 'violate_constraint', + "INSERT INTO test_products (sku) VALUES ('PROD-001');" + ); + + fixture.createScript(tempDir, 'deploy', 'final_change', + "INSERT INTO test_products (sku) VALUES ('PROD-002');" + ); + + const client = new LaunchQLMigrate(db.config); + + const initialState = await db.getMigrationState(); + expect(initialState.changeCount).toBe(0); + + await expect(client.deploy({ + modulePath: tempDir, + useTransaction: false + })).rejects.toThrow(/duplicate key value violates unique constraint/); + + const finalState = await db.getMigrationState(); + + expect(finalState).toMatchSnapshot('partial-deployment-migration-state'); + + expect(finalState.changeCount).toBe(2); + expect(finalState.changes.map((c: any) => c.change_name)).toEqual(['create_table', 'add_record']); + + expect(await db.exists('table', 'test_products')).toBe(true); + const records = await db.query('SELECT * FROM test_products'); + expect(records.rows).toHaveLength(1); + expect(records.rows[0].sku).toBe('PROD-001'); + + 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'); + expect(successEvents.length).toBe(2); // create_table, add_record + expect(finalState.eventCount).toBe(2); // Only successful deployments logged + }); + + test('verify database state after constraint failure', async () => { + /* + * SCENARIO: Comparison of transaction vs non-transaction behavior + * + * 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. + * + * Transaction mode: Complete rollback (clean state) + * Non-transaction mode: Partial deployment (mixed state requiring cleanup) + */ + const tempDir = fixture.createPlanFile('test-state-check', [ + { 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: true + })).rejects.toThrow(/violates check constraint/); + + const transactionState = await db.getMigrationState(); + + expect(transactionState).toMatchSnapshot('transaction-rollback-state-comparison'); + + expect(await db.exists('schema', 'test_schema')).toBe(false); + expect(transactionState.changeCount).toBe(0); + + await expect(client.deploy({ + modulePath: tempDir, + useTransaction: false + })).rejects.toThrow(/violates check constraint/); + + const partialState = await db.getMigrationState(); + + expect(partialState).toMatchSnapshot('partial-deployment-state-comparison'); + + expect(await db.exists('schema', 'test_schema')).toBe(true); + expect(await db.exists('table', 'test_schema.orders')).toBe(true); + + 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']); + + const successEvents = partialState.events.filter((e: any) => e.event_type === 'deploy'); + expect(successEvents.length).toBe(2); // setup_schema, create_constraint_table + expect(partialState.eventCount).toBe(2); // Only successful deployments logged + + /* + * KEY INSIGHT: Same failure scenario, different outcomes + * + * 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 + * - Database objects: schema + table exist (mixed state) + * + * RECOMMENDATION: Use transaction mode (default) unless you specifically + * need partial deployment behavior for incremental rollout scenarios. + */ + }); +}); diff --git a/packages/core/test-utils/MigrateTestFixture.ts b/packages/core/test-utils/MigrateTestFixture.ts index d370d2b24..458c51c50 100644 --- a/packages/core/test-utils/MigrateTestFixture.ts +++ b/packages/core/test-utils/MigrateTestFixture.ts @@ -101,6 +101,31 @@ export class MigrateTestFixture { return result.rows; }, + async getMigrationState() { + const changes = await pool.query(` + SELECT project, change_name, script_hash, deployed_at + FROM launchql_migrate.changes + ORDER BY deployed_at + `); + + const events = await pool.query(` + SELECT project, change_name, event_type, occurred_at + FROM launchql_migrate.events + ORDER BY occurred_at + `); + + // 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); + + return { + changes: cleanChanges, + events: cleanEvents, + changeCount: cleanChanges.length, + eventCount: cleanEvents.length + }; + }, + async getDependencies(project: string, changeName: string) { const result = await pool.query( `SELECT d.requires diff --git a/packages/core/test-utils/TestDatabase.ts b/packages/core/test-utils/TestDatabase.ts index b6de2f62c..e528d5339 100644 --- a/packages/core/test-utils/TestDatabase.ts +++ b/packages/core/test-utils/TestDatabase.ts @@ -1,11 +1,17 @@ -import { MigrateConfig } from '../src/migrate/types'; +import { PgConfig } from 'pg-env'; export interface TestDatabase { name: string; - config: MigrateConfig; + config: PgConfig; query(sql: string, params?: any[]): Promise; exists(type: 'schema' | 'table', name: string): Promise; getDeployedChanges(): Promise; getDependencies(project: string, changeName: string): Promise; + getMigrationState(): Promise<{ + changes: any[]; + events: any[]; + changeCount: number; + eventCount: number; + }>; close(): Promise; }