diff --git a/packages/core/src/class/launchql.ts b/packages/core/src/class/launchql.ts index 10ea15bef4..4c9bbee696 100644 --- a/packages/core/src/class/launchql.ts +++ b/packages/core/src/class/launchql.ts @@ -258,14 +258,6 @@ export class LaunchQLProject { // Create sqitch.conf file (minimal configuration) const sqitchConf = `[core] \tengine = pg -\tplan_file = sqitch.plan -\ttop_dir = . -[engine "pg"] -\ttarget = db:pg: -[deploy] -\tverify = true -[rebase] -\tverify = true `; writeFileSync(path.join(targetPath, 'sqitch.conf'), sqitchConf); diff --git a/MIGRATE_SPEC.md b/packages/migrate/MIGRATE_SPEC.md similarity index 51% rename from MIGRATE_SPEC.md rename to packages/migrate/MIGRATE_SPEC.md index d6f35a5ecc..ec41b10e89 100644 --- a/MIGRATE_SPEC.md +++ b/packages/migrate/MIGRATE_SPEC.md @@ -61,208 +61,6 @@ CREATE TABLE launchql_migrate.events ( ); ``` -## Stored Procedures API - -### Project Management - -```sql --- Register a project (auto-called by deploy if needed) -CREATE OR REPLACE PROCEDURE launchql_migrate.register_project(p_project TEXT) -LANGUAGE plpgsql AS $$ -BEGIN - INSERT INTO launchql_migrate.projects (project) - VALUES (p_project) - ON CONFLICT (project) DO NOTHING; -END; -$$; -``` - -### Deployment Functions - -```sql --- Check if a change is deployed -CREATE OR REPLACE FUNCTION launchql_migrate.is_deployed( - p_project TEXT, - p_change_name TEXT -) -RETURNS BOOLEAN -LANGUAGE sql STABLE AS $$ - SELECT EXISTS ( - SELECT 1 FROM launchql_migrate.changes - WHERE project = p_project - AND change_name = p_change_name - ); -$$; - --- Deploy a change -CREATE OR REPLACE PROCEDURE launchql_migrate.deploy( - p_project TEXT, - p_change_name TEXT, - p_script_hash TEXT, - p_requires TEXT[], - p_deploy_sql TEXT, - p_verify_sql TEXT DEFAULT NULL -) -LANGUAGE plpgsql AS $$ -DECLARE - v_change_id TEXT; -BEGIN - -- Ensure project exists - CALL launchql_migrate.register_project(p_project); - - -- Generate simple ID - v_change_id := encode(sha256((p_project || p_change_name || p_script_hash)::bytea), 'hex'); - - -- Check if already deployed - IF launchql_migrate.is_deployed(p_project, p_change_name) THEN - RAISE EXCEPTION 'Change % already deployed in project %', p_change_name, p_project; - END IF; - - -- Check dependencies - IF p_requires IS NOT NULL THEN - PERFORM 1 FROM unnest(p_requires) AS req - WHERE NOT launchql_migrate.is_deployed(p_project, req); - IF FOUND THEN - RAISE EXCEPTION 'Missing required changes'; - END IF; - END IF; - - -- Execute deploy - 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; - - -- Execute verify if provided - IF p_verify_sql IS NOT NULL THEN - BEGIN - EXECUTE p_verify_sql; - EXCEPTION WHEN OTHERS THEN - INSERT INTO launchql_migrate.events (event_type, change_name, project) - VALUES ('fail', p_change_name, p_project); - RAISE EXCEPTION 'Verification failed'; - END; - END IF; - - -- Record deployment - INSERT INTO launchql_migrate.changes (change_id, change_name, project, script_hash) - VALUES (v_change_id, p_change_name, p_project, p_script_hash); - - -- Record dependencies (INSERTED AFTER SUCCESSFUL DEPLOYMENT) - IF p_requires IS NOT NULL THEN - INSERT INTO launchql_migrate.dependencies (change_id, requires) - SELECT v_change_id, req FROM unnest(p_requires) AS req; - END IF; - - -- Log success - INSERT INTO launchql_migrate.events (event_type, change_name, project) - VALUES ('deploy', p_change_name, p_project); -END; -$$; - --- Revert a change -CREATE OR REPLACE PROCEDURE launchql_migrate.revert( - p_project TEXT, - p_change_name TEXT, - p_revert_sql TEXT -) -LANGUAGE plpgsql AS $$ -BEGIN - -- Check if deployed - IF NOT launchql_migrate.is_deployed(p_project, p_change_name) THEN - RAISE EXCEPTION 'Change % not deployed in project %', p_change_name, p_project; - END IF; - - -- Check if other changes depend on this - IF EXISTS ( - SELECT 1 FROM launchql_migrate.dependencies d - JOIN launchql_migrate.changes c ON c.change_id = d.change_id - WHERE d.requires = p_change_name - AND c.project = p_project - ) THEN - RAISE EXCEPTION 'Other changes depend on %', p_change_name; - END IF; - - -- Execute revert - EXECUTE p_revert_sql; - - -- Remove from deployed - DELETE FROM launchql_migrate.changes - WHERE project = p_project AND change_name = p_change_name; - - -- Log revert - INSERT INTO launchql_migrate.events (event_type, change_name, project) - VALUES ('revert', p_change_name, p_project); -END; -$$; - --- Verify a change -CREATE OR REPLACE FUNCTION launchql_migrate.verify( - p_project TEXT, - p_change_name TEXT, - p_verify_sql TEXT -) -RETURNS BOOLEAN -LANGUAGE plpgsql AS $$ -BEGIN - EXECUTE p_verify_sql; - RETURN TRUE; -EXCEPTION WHEN OTHERS THEN - RETURN FALSE; -END; -$$; -``` - -### Query Functions - -```sql --- List deployed changes -CREATE OR REPLACE FUNCTION launchql_migrate.deployed_changes( - p_project TEXT DEFAULT NULL -) -RETURNS TABLE(project TEXT, change_name TEXT, deployed_at TIMESTAMPTZ) -LANGUAGE sql STABLE AS $$ - SELECT project, change_name, deployed_at - FROM launchql_migrate.changes - WHERE p_project IS NULL OR project = p_project - ORDER BY deployed_at; -$$; - --- Get deployment status -CREATE OR REPLACE FUNCTION launchql_migrate.status( - p_project TEXT DEFAULT NULL -) -RETURNS TABLE( - project TEXT, - total_deployed INTEGER, - last_change TEXT, - last_deployed TIMESTAMPTZ -) -LANGUAGE sql STABLE AS $$ - WITH latest AS ( - SELECT DISTINCT ON (project) - project, - change_name, - deployed_at - FROM launchql_migrate.changes - WHERE p_project IS NULL OR project = p_project - ORDER BY project, deployed_at DESC - ) - SELECT - c.project, - COUNT(*)::INTEGER AS total_deployed, - l.change_name AS last_change, - l.deployed_at AS last_deployed - FROM launchql_migrate.changes c - JOIN latest l ON l.project = c.project - WHERE p_project IS NULL OR c.project = p_project - GROUP BY c.project, l.change_name, l.deployed_at; -$$; -``` - ## How It Works ### Deployment Flow @@ -420,62 +218,6 @@ Your PostgreSQL Database └── events ``` -To install: -```bash -# Connect to YOUR application database -psql -U myuser -d myapp_db -f launchql_migrate.sql -``` - -## TypeScript Integration - -```typescript -import { Client } from 'pg'; - -class LaunchQLMigrate { - constructor(private db: Client) {} - - async deploy( - project: string, - change: string, - scriptHash: string, - requires: string[] | null, - deployScript: string - ) { - await this.db.query( - 'CALL launchql_migrate.deploy($1, $2, $3, $4, $5)', - [project, change, scriptHash, requires, deployScript] - ); - } - - async revert( - project: string, - change: string, - revertScript: string - ) { - await this.db.query( - 'CALL launchql_migrate.revert($1, $2, $3)', - [project, change, revertScript] - ); - } - - async status(project?: string) { - const result = await this.db.query( - 'SELECT * FROM launchql_migrate.status($1)', - [project] - ); - return result.rows; - } - - async isDeployed(project: string, change: string): Promise { - const result = await this.db.query( - 'SELECT launchql_migrate.is_deployed($1, $2)', - [project, change] - ); - return result.rows[0].is_deployed; - } -} -``` - ## Summary @launchql/migrate provides a TypeScript-based alternative to Sqitch that: diff --git a/packages/migrate/__tests__/cross-project.test.ts b/packages/migrate/__tests__/cross-project.test.ts index 3b776899db..3392328f4c 100644 --- a/packages/migrate/__tests__/cross-project.test.ts +++ b/packages/migrate/__tests__/cross-project.test.ts @@ -25,7 +25,6 @@ describe('Cross-Project Dependencies', () => { project: 'project-a', targetDatabase: db.name, planPath: join(basePath, 'project-a', 'sqitch.plan'), - deployPath: 'deploy' }); expect(resultA.deployed).toEqual(['base_schema', 'base_types']); @@ -36,7 +35,6 @@ describe('Cross-Project Dependencies', () => { project: 'project-b', targetDatabase: db.name, planPath: join(basePath, 'project-b', 'sqitch.plan'), - deployPath: 'deploy' }); expect(resultB.deployed).toEqual(['app_schema', 'app_tables']); @@ -59,7 +57,6 @@ describe('Cross-Project Dependencies', () => { project: 'project-b', targetDatabase: db.name, planPath: join(basePath, 'project-b', 'sqitch.plan'), - deployPath: 'deploy' })).rejects.toThrow(/project-a:base_schema/); // Verify nothing was deployed @@ -75,14 +72,12 @@ describe('Cross-Project Dependencies', () => { project: 'project-a', targetDatabase: db.name, planPath: join(basePath, 'project-a', 'sqitch.plan'), - deployPath: 'deploy' }); await client.deploy({ project: 'project-b', targetDatabase: db.name, planPath: join(basePath, 'project-b', 'sqitch.plan'), - deployPath: 'deploy' }); // Try to revert project-a:base_types which project-b depends on @@ -91,7 +86,6 @@ describe('Cross-Project Dependencies', () => { project: 'project-a', targetDatabase: db.name, planPath: join(basePath, 'project-a', 'sqitch.plan'), - revertPath: 'revert', toChange: 'base_schema' })).rejects.toThrow(/Cannot revert base_types: required by project-b:app_tables/); @@ -109,14 +103,12 @@ describe('Cross-Project Dependencies', () => { project: 'project-a', targetDatabase: db.name, planPath: join(basePath, 'project-a', 'sqitch.plan'), - deployPath: 'deploy' }); await client.deploy({ project: 'project-b', targetDatabase: db.name, planPath: join(basePath, 'project-b', 'sqitch.plan'), - deployPath: 'deploy' }); // Query dependents using the SQL function @@ -168,21 +160,18 @@ describe('Cross-Project Dependencies', () => { project: 'complex-a', targetDatabase: db.name, planPath: join(projectA, 'sqitch.plan'), - deployPath: 'deploy' }); await client.deploy({ project: 'complex-b', targetDatabase: db.name, planPath: join(projectB, 'sqitch.plan'), - deployPath: 'deploy' }); await client.deploy({ project: 'complex-c', targetDatabase: db.name, planPath: join(projectC, 'sqitch.plan'), - deployPath: 'deploy' }); // Verify all deployed @@ -195,7 +184,6 @@ describe('Cross-Project Dependencies', () => { project: 'complex-a', targetDatabase: db.name, planPath: join(projectA, 'sqitch.plan'), - revertPath: 'revert', toChange: 'base' })).rejects.toThrow(/Cannot revert utils: required by/); }); diff --git a/packages/migrate/__tests__/deploy.test.ts b/packages/migrate/__tests__/deploy.test.ts index ff29750f7d..19dbee3954 100644 --- a/packages/migrate/__tests__/deploy.test.ts +++ b/packages/migrate/__tests__/deploy.test.ts @@ -24,7 +24,6 @@ describe('Deploy Command', () => { project: 'test-simple', targetDatabase: db.name, planPath: join(fixturePath, 'sqitch.plan'), - deployPath: 'deploy', toChange: 'schema' }); @@ -50,7 +49,6 @@ describe('Deploy Command', () => { project: 'test-simple', targetDatabase: db.name, planPath: join(fixturePath, 'sqitch.plan'), - deployPath: 'deploy' }); expect(result.deployed).toEqual(['schema', 'table', 'index']); @@ -76,7 +74,6 @@ describe('Deploy Command', () => { project: 'test-simple', targetDatabase: db.name, planPath: join(fixturePath, 'sqitch.plan'), - deployPath: 'deploy', toChange: 'table' }); @@ -87,7 +84,6 @@ describe('Deploy Command', () => { project: 'test-simple', targetDatabase: db.name, planPath: join(fixturePath, 'sqitch.plan'), - deployPath: 'deploy' }); expect(result2.deployed).toEqual(['index']); @@ -116,7 +112,6 @@ describe('Deploy Command', () => { project: 'test-fail', targetDatabase: db.name, planPath: join(tempDir, 'sqitch.plan'), - deployPath: 'deploy', useTransaction: true // default })).rejects.toThrow(); @@ -152,7 +147,6 @@ describe('Deploy Command', () => { project: 'test-fail', targetDatabase: db.name, planPath: join(tempDir, 'sqitch.plan'), - deployPath: 'deploy', useTransaction: false })).rejects.toThrow(); diff --git a/packages/migrate/src/client.ts b/packages/migrate/src/client.ts index 33e3a945bd..a17d60e073 100644 --- a/packages/migrate/src/client.ts +++ b/packages/migrate/src/client.ts @@ -86,7 +86,7 @@ export class LaunchQLMigrate { async deploy(options: DeployOptions): Promise { await this.initialize(); - const { project, targetDatabase, planPath, deployPath, verifyPath, toChange, useTransaction = true } = options; + const { project, targetDatabase, planPath, toChange, useTransaction = true } = options; const plan = parsePlanFile(planPath); const changes = getChangesInOrder(planPath); @@ -131,7 +131,7 @@ export class LaunchQLMigrate { } // Read deploy script - const deployScript = readScript(dirname(planPath), deployPath, change.name); + const deployScript = readScript(dirname(planPath), 'deploy', change.name); if (!deployScript) { log.error(`Deploy script not found for change: ${change.name}`); failed = change.name; @@ -141,7 +141,7 @@ export class LaunchQLMigrate { const cleanDeploySql = await cleanSql(deployScript, false, '$EOFCODE$'); // Calculate script hash - const scriptHash = hashFile(join(dirname(planPath), deployPath, `${change.name}.sql`)); + const scriptHash = hashFile(join(dirname(planPath), 'deploy', `${change.name}.sql`)); try { // Call the deploy stored procedure @@ -181,7 +181,7 @@ export class LaunchQLMigrate { async revert(options: RevertOptions): Promise { await this.initialize(); - const { project, targetDatabase, planPath, revertPath, toChange, useTransaction = true } = options; + const { project, targetDatabase, planPath, toChange, useTransaction = true } = options; const plan = parsePlanFile(planPath); const changes = getChangesInOrder(planPath, true); // Reverse order for revert @@ -217,7 +217,7 @@ export class LaunchQLMigrate { } // Read revert script - const revertScript = readScript(dirname(planPath), revertPath, change.name); + const revertScript = readScript(dirname(planPath), 'revert', change.name); if (!revertScript) { log.error(`Revert script not found for change: ${change.name}`); failed = change.name; @@ -253,7 +253,7 @@ export class LaunchQLMigrate { async verify(options: VerifyOptions): Promise { await this.initialize(); - const { project, targetDatabase, planPath, verifyPath } = options; + const { project, targetDatabase, planPath } = options; const plan = parsePlanFile(planPath); const changes = getChangesInOrder(planPath); @@ -275,7 +275,7 @@ export class LaunchQLMigrate { } // Read verify script - const verifyScript = readScript(dirname(planPath), verifyPath, change.name); + const verifyScript = readScript(dirname(planPath), 'verify', change.name); if (!verifyScript) { log.warn(`Verify script not found for change: ${change.name}`); continue; diff --git a/packages/migrate/src/commands/deploy.ts b/packages/migrate/src/commands/deploy.ts index 94acaebd58..a2b66f6ae8 100644 --- a/packages/migrate/src/commands/deploy.ts +++ b/packages/migrate/src/commands/deploy.ts @@ -41,8 +41,6 @@ export async function deployCommand( project: '', // Will be read from plan file targetDatabase: database, planPath, - deployPath: 'deploy', - verifyPath: existsSync(join(cwd, 'verify')) ? 'verify' : undefined, toChange: options?.toChange, useTransaction: options?.useTransaction }); diff --git a/packages/migrate/src/commands/revert.ts b/packages/migrate/src/commands/revert.ts index 39c7b3ccb0..7e0ad17769 100644 --- a/packages/migrate/src/commands/revert.ts +++ b/packages/migrate/src/commands/revert.ts @@ -41,7 +41,6 @@ export async function revertCommand( project: '', // Will be read from plan file targetDatabase: database, planPath, - revertPath: 'revert', toChange: options?.toChange, useTransaction: options?.useTransaction }); diff --git a/packages/migrate/src/commands/verify.ts b/packages/migrate/src/commands/verify.ts index 153ada34a8..1d02ce3555 100644 --- a/packages/migrate/src/commands/verify.ts +++ b/packages/migrate/src/commands/verify.ts @@ -21,11 +21,7 @@ export async function verifyCommand( throw new Error(`No sqitch.plan found in ${cwd}`); } - const verifyPath = join(cwd, 'verify'); - if (!existsSync(verifyPath)) { - log.warn('No verify directory found, nothing to verify'); - return; - } + // The verify method will handle missing verify scripts per change // Provide defaults for missing config values const fullConfig: MigrateConfig = { @@ -42,8 +38,7 @@ export async function verifyCommand( const result = await client.verify({ project: '', // Will be read from plan file targetDatabase: database, - planPath, - verifyPath: 'verify' + planPath }); if (result.failed.length > 0) { diff --git a/packages/migrate/src/types.ts b/packages/migrate/src/types.ts index 16caad6c2d..ddff72f59d 100644 --- a/packages/migrate/src/types.ts +++ b/packages/migrate/src/types.ts @@ -19,8 +19,6 @@ export interface DeployOptions { project: string; targetDatabase: string; planPath: string; - deployPath: string; - verifyPath?: string; toChange?: string; useTransaction?: boolean; } @@ -29,7 +27,6 @@ export interface RevertOptions { project: string; targetDatabase: string; planPath: string; - revertPath: string; toChange?: string; useTransaction?: boolean; } @@ -38,7 +35,6 @@ export interface VerifyOptions { project: string; targetDatabase: string; planPath: string; - verifyPath: string; } export interface DeployResult { diff --git a/packages/migrate/test-utils/index.ts b/packages/migrate/test-utils/index.ts index 43a34b7c76..450320fc8b 100644 --- a/packages/migrate/test-utils/index.ts +++ b/packages/migrate/test-utils/index.ts @@ -55,7 +55,18 @@ export class MigrateTestFixture { // Create database using admin pool const adminPool = getPgPool(baseConfig); - await adminPool.query(`CREATE DATABASE "${dbName}"`); + try { + await adminPool.query(`CREATE DATABASE "${dbName}"`); + } catch (e) { + if (e instanceof AggregateError) { + for (const err of e.errors) { + console.error('AggregateError item:', err); + } + } else { + console.error('Test failure:', e); + } + throw e; + } // Get config for the new test database const pgConfig = getPgEnvOptions({ @@ -192,27 +203,34 @@ export class MigrateTestFixture { } async cleanup(): Promise { - // Close all test database connections first - const dbNames = this.databases.map(db => db.name); + // Close all test database pools FIRST + for (const pool of this.pools) { + try { + await pool.end(); + } catch (e) { + // Ignore errors during pool closure + } + } - // Close all pools for test databases - await closeDatabasePools(dbNames); + // Clear the pools array + this.pools = []; - // Get admin pool for database cleanup - // const adminConfig = getPgEnvOptions({ - // database: 'postgres' - // }); - // const adminPool = getPgPool(adminConfig); - - // Small delay to ensure connections are closed - await new Promise(resolve => setTimeout(resolve, 50)); + // Small delay to ensure connections are fully closed + await new Promise(resolve => setTimeout(resolve, 10)); + + // Now get admin pool for database cleanup + const adminConfig = getPgEnvOptions({ + database: 'postgres' + }); + const adminPool = getPgPool(adminConfig); + // Drop all test databases for (const db of this.databases) { try { - // Drop the database - // await adminPool.query(`DROP DATABASE IF EXISTS "${db.name}"`); + await adminPool.query(`DROP DATABASE IF EXISTS "${db.name}"`); } catch (e) { // Ignore errors - database might have active connections + console.warn(`Failed to drop database ${db.name}:`, (e as Error).message); } } @@ -224,6 +242,9 @@ export class MigrateTestFixture { // Ignore errors during cleanup } } + + // Clear the databases array + this.databases = []; } }