diff --git a/PLAN.md b/PLAN.md index 2ec8d8cfd..131c51b7a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -240,6 +240,8 @@ For existing LaunchQL projects: - Command-line compatibility layer - Unit test suite - Test project setup +- Transaction support with --tx/--no-tx flags +- Cross-project dependency support (project:change format) **IN PROGRESS:** - Integration testing diff --git a/__fixtures__/migrate/cross-project/project-a/deploy/base_schema.sql b/__fixtures__/migrate/cross-project/project-a/deploy/base_schema.sql new file mode 100644 index 000000000..b088835c4 --- /dev/null +++ b/__fixtures__/migrate/cross-project/project-a/deploy/base_schema.sql @@ -0,0 +1,7 @@ +-- Deploy project-a:base_schema to pg + +BEGIN; + +CREATE SCHEMA base; + +COMMIT; \ No newline at end of file diff --git a/__fixtures__/migrate/cross-project/project-a/deploy/base_types.sql b/__fixtures__/migrate/cross-project/project-a/deploy/base_types.sql new file mode 100644 index 000000000..17e7f2515 --- /dev/null +++ b/__fixtures__/migrate/cross-project/project-a/deploy/base_types.sql @@ -0,0 +1,8 @@ +-- Deploy project-a:base_types to pg +-- requires: base_schema + +BEGIN; + +CREATE TYPE base.status AS ENUM ('active', 'inactive', 'pending'); + +COMMIT; \ No newline at end of file diff --git a/__fixtures__/migrate/cross-project/project-a/revert/base_schema.sql b/__fixtures__/migrate/cross-project/project-a/revert/base_schema.sql new file mode 100644 index 000000000..e5c4c94b2 --- /dev/null +++ b/__fixtures__/migrate/cross-project/project-a/revert/base_schema.sql @@ -0,0 +1,7 @@ +-- Revert project-a:base_schema from pg + +BEGIN; + +DROP SCHEMA base CASCADE; + +COMMIT; \ No newline at end of file diff --git a/__fixtures__/migrate/cross-project/project-a/revert/base_types.sql b/__fixtures__/migrate/cross-project/project-a/revert/base_types.sql new file mode 100644 index 000000000..5e41db8e2 --- /dev/null +++ b/__fixtures__/migrate/cross-project/project-a/revert/base_types.sql @@ -0,0 +1,7 @@ +-- Revert project-a:base_types from pg + +BEGIN; + +DROP TYPE base.status; + +COMMIT; \ No newline at end of file diff --git a/__fixtures__/migrate/cross-project/project-a/sqitch.plan b/__fixtures__/migrate/cross-project/project-a/sqitch.plan new file mode 100644 index 000000000..c20ac7da6 --- /dev/null +++ b/__fixtures__/migrate/cross-project/project-a/sqitch.plan @@ -0,0 +1,6 @@ +%syntax-version=1.0.0 +%project=project-a +%uri=https://github.com/test/project-a + +base_schema 2024-01-01T00:00:00Z test # Create base schema +base_types [base_schema] 2024-01-02T00:00:00Z test # Create base types \ No newline at end of file diff --git a/__fixtures__/migrate/cross-project/project-b/deploy/app_schema.sql b/__fixtures__/migrate/cross-project/project-b/deploy/app_schema.sql new file mode 100644 index 000000000..c860ac968 --- /dev/null +++ b/__fixtures__/migrate/cross-project/project-b/deploy/app_schema.sql @@ -0,0 +1,8 @@ +-- Deploy project-b:app_schema to pg +-- requires: project-a:base_schema + +BEGIN; + +CREATE SCHEMA app; + +COMMIT; \ No newline at end of file diff --git a/__fixtures__/migrate/cross-project/project-b/deploy/app_tables.sql b/__fixtures__/migrate/cross-project/project-b/deploy/app_tables.sql new file mode 100644 index 000000000..058ee3c12 --- /dev/null +++ b/__fixtures__/migrate/cross-project/project-b/deploy/app_tables.sql @@ -0,0 +1,13 @@ +-- Deploy project-b:app_tables to pg +-- requires: app_schema +-- requires: project-a:base_types + +BEGIN; + +CREATE TABLE app.items ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + status base.status DEFAULT 'pending' +); + +COMMIT; \ No newline at end of file diff --git a/__fixtures__/migrate/cross-project/project-b/sqitch.plan b/__fixtures__/migrate/cross-project/project-b/sqitch.plan new file mode 100644 index 000000000..db7b0825b --- /dev/null +++ b/__fixtures__/migrate/cross-project/project-b/sqitch.plan @@ -0,0 +1,6 @@ +%syntax-version=1.0.0 +%project=project-b +%uri=https://github.com/test/project-b + +app_schema [project-a:base_schema] 2024-01-03T00:00:00Z test # Create app schema +app_tables [app_schema project-a:base_types] 2024-01-04T00:00:00Z test # Create app tables using base types \ No newline at end of file diff --git a/__fixtures__/migrate/simple/deploy/index.sql b/__fixtures__/migrate/simple/deploy/index.sql new file mode 100644 index 000000000..0833efbdb --- /dev/null +++ b/__fixtures__/migrate/simple/deploy/index.sql @@ -0,0 +1,9 @@ +-- Deploy test-simple:index to pg +-- requires: table + +BEGIN; + +CREATE INDEX idx_users_email ON test_app.users(email); +CREATE INDEX idx_users_created_at ON test_app.users(created_at); + +COMMIT; \ No newline at end of file diff --git a/__fixtures__/migrate/simple/deploy/schema.sql b/__fixtures__/migrate/simple/deploy/schema.sql new file mode 100644 index 000000000..5ea6c8ae6 --- /dev/null +++ b/__fixtures__/migrate/simple/deploy/schema.sql @@ -0,0 +1,7 @@ +-- Deploy test-simple:schema to pg + +BEGIN; + +CREATE SCHEMA test_app; + +COMMIT; \ No newline at end of file diff --git a/__fixtures__/migrate/simple/deploy/table.sql b/__fixtures__/migrate/simple/deploy/table.sql new file mode 100644 index 000000000..d024c67e1 --- /dev/null +++ b/__fixtures__/migrate/simple/deploy/table.sql @@ -0,0 +1,13 @@ +-- Deploy test-simple:table to pg +-- requires: schema + +BEGIN; + +CREATE TABLE test_app.users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +COMMIT; \ No newline at end of file diff --git a/__fixtures__/migrate/simple/revert/index.sql b/__fixtures__/migrate/simple/revert/index.sql new file mode 100644 index 000000000..97d1945ae --- /dev/null +++ b/__fixtures__/migrate/simple/revert/index.sql @@ -0,0 +1,8 @@ +-- Revert test-simple:index from pg + +BEGIN; + +DROP INDEX test_app.idx_users_email; +DROP INDEX test_app.idx_users_created_at; + +COMMIT; \ No newline at end of file diff --git a/__fixtures__/migrate/simple/revert/schema.sql b/__fixtures__/migrate/simple/revert/schema.sql new file mode 100644 index 000000000..ae71dcbcb --- /dev/null +++ b/__fixtures__/migrate/simple/revert/schema.sql @@ -0,0 +1,7 @@ +-- Revert test-simple:schema from pg + +BEGIN; + +DROP SCHEMA test_app CASCADE; + +COMMIT; \ No newline at end of file diff --git a/__fixtures__/migrate/simple/revert/table.sql b/__fixtures__/migrate/simple/revert/table.sql new file mode 100644 index 000000000..47e8b2ff2 --- /dev/null +++ b/__fixtures__/migrate/simple/revert/table.sql @@ -0,0 +1,7 @@ +-- Revert test-simple:table from pg + +BEGIN; + +DROP TABLE test_app.users; + +COMMIT; \ No newline at end of file diff --git a/__fixtures__/migrate/simple/sqitch.plan b/__fixtures__/migrate/simple/sqitch.plan new file mode 100644 index 000000000..4864078e8 --- /dev/null +++ b/__fixtures__/migrate/simple/sqitch.plan @@ -0,0 +1,7 @@ +%syntax-version=1.0.0 +%project=test-simple +%uri=https://github.com/test/simple + +schema 2024-01-01T00:00:00Z test # Create schema +table [schema] 2024-01-02T00:00:00Z test # Create table +index [table] 2024-01-03T00:00:00Z test # Create index \ No newline at end of file diff --git a/__fixtures__/migrate/simple/verify/index.sql b/__fixtures__/migrate/simple/verify/index.sql new file mode 100644 index 000000000..f87b62298 --- /dev/null +++ b/__fixtures__/migrate/simple/verify/index.sql @@ -0,0 +1,7 @@ +-- Verify test-simple:index on pg + +SELECT 1/COUNT(*) FROM pg_indexes +WHERE schemaname = 'test_app' +AND tablename = 'users' +AND indexname IN ('idx_users_email', 'idx_users_created_at') +HAVING COUNT(*) = 2; \ No newline at end of file diff --git a/__fixtures__/migrate/simple/verify/schema.sql b/__fixtures__/migrate/simple/verify/schema.sql new file mode 100644 index 000000000..42142d493 --- /dev/null +++ b/__fixtures__/migrate/simple/verify/schema.sql @@ -0,0 +1,3 @@ +-- Verify test-simple:schema on pg + +SELECT 1/COUNT(*) FROM information_schema.schemata WHERE schema_name = 'test_app'; \ No newline at end of file diff --git a/__fixtures__/migrate/simple/verify/table.sql b/__fixtures__/migrate/simple/verify/table.sql new file mode 100644 index 000000000..6cbc72758 --- /dev/null +++ b/__fixtures__/migrate/simple/verify/table.sql @@ -0,0 +1,4 @@ +-- Verify test-simple:table on pg + +SELECT 1/COUNT(*) FROM information_schema.tables +WHERE table_schema = 'test_app' AND table_name = 'users'; \ No newline at end of file diff --git a/packages/cli/src/commands/revert.ts b/packages/cli/src/commands/revert.ts index 232be1a32..37a1f3e5f 100644 --- a/packages/cli/src/commands/revert.ts +++ b/packages/cli/src/commands/revert.ts @@ -1,5 +1,5 @@ import { CLIOptions, Inquirerer, Question } from 'inquirerer'; -import { listModules, revert } from '@launchql/core'; +import { LaunchQLProject, revert } from '@launchql/core'; import { errors, getEnvOptions, LaunchQLOptions } from '@launchql/types'; import { getPgEnvOptions, getSpawnEnvWithPg } from 'pg-env'; import { Logger } from '@launchql/logger'; @@ -43,34 +43,38 @@ export default async ( return; } + log.debug(`Using current directory: ${cwd}`); + + const project = new LaunchQLProject(cwd); + if (recursive) { - const modules = await listModules(cwd); - const mods = Object.keys(modules); + const modules = await project.getModules(); + const moduleNames = modules.map(mod => mod.getModuleName()); - if (!mods.length) { - log.error('No modules found to revert.'); + if (!moduleNames.length) { + log.error('No modules found in the specified directory.'); prompter.close(); - throw errors.NOT_FOUND({}, 'No modules found to revert.'); + throw errors.NOT_FOUND({}, 'No modules found in the specified directory.'); } - const { project } = await prompter.prompt(argv, [ + const { project: selectedProject } = await prompter.prompt(argv, [ { type: 'autocomplete', name: 'project', message: 'Choose a project to revert', - options: mods, + options: moduleNames, required: true } ]); - log.success(`Reverting project ${project} on database ${database}...`); + log.success(`Reverting project ${selectedProject} on database ${database}...`); const options: LaunchQLOptions = getEnvOptions({ pg: { database } }); - await revert(options, project, database, cwd, { useSqitch, useTransaction: tx }); + await revert(options, selectedProject, database, cwd, { useSqitch, useTransaction: tx }); log.success('Revert complete.'); } else { const pgEnv = getPgEnvOptions(); diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index 47ece9dd5..d404c640b 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -1,10 +1,11 @@ import { CLIOptions, Inquirerer, Question } from 'inquirerer'; -import { listModules, verify } from '@launchql/core'; +import { LaunchQLProject, verify } from '@launchql/core'; import { errors, getEnvOptions, LaunchQLOptions } from '@launchql/types'; import { getPgEnvOptions, getSpawnEnvWithPg } from 'pg-env'; import { Logger } from '@launchql/logger'; import { verifyCommand } from '@launchql/migrate'; import { execSync } from 'child_process'; +import { getTargetDatabase } from '../utils'; const log = new Logger('verify'); @@ -13,38 +14,34 @@ export default async ( prompter: Inquirerer, _options: CLIOptions ) => { - const questions: Question[] = [ - { - name: 'database', - message: 'Database name', - type: 'text', - required: true - } - ]; + const database = await getTargetDatabase(argv, prompter, { + message: 'Select database' + }); - let { database, recursive, cwd, 'use-sqitch': useSqitch } = await prompter.prompt(argv, questions); + const questions: Question[] = []; - if (!cwd) { - cwd = process.cwd(); - log.debug(`Using current directory: ${cwd}`); - } + let { recursive, cwd, 'use-sqitch': useSqitch } = await prompter.prompt(argv, questions); + + log.debug(`Using current directory: ${cwd}`); + + const project = new LaunchQLProject(cwd); if (recursive) { - const modules = await listModules(cwd); - const mods = Object.keys(modules); + const modules = await project.getModules(); + const moduleNames = modules.map(mod => mod.getModuleName()); - if (!mods.length) { - log.error('No modules found to verify.'); + if (!moduleNames.length) { + log.error('No modules found in the specified directory.'); prompter.close(); - throw errors.NOT_FOUND({}, 'No modules found to verify.'); + throw errors.NOT_FOUND({}, 'No modules found in the specified directory.'); } - const { project } = await prompter.prompt(argv, [ + const { project: selectedProject } = await prompter.prompt(argv, [ { type: 'autocomplete', name: 'project', message: 'Choose a project to verify', - options: mods, + options: moduleNames, required: true } ]); @@ -55,8 +52,8 @@ export default async ( } }); - log.info(`Verifying project ${project} on database ${database}...`); - await verify(options, project, database, cwd, { useSqitch }); + log.info(`Verifying project ${selectedProject} on database ${database}...`); + await verify(options, selectedProject, database, cwd, { useSqitch }); log.success('Verify complete.'); } else { const pgEnv = getPgEnvOptions(); diff --git a/packages/migrate/CHANGELOG.md b/packages/migrate/CHANGELOG.md deleted file mode 100644 index 01acb1b8c..000000000 --- a/packages/migrate/CHANGELOG.md +++ /dev/null @@ -1,8 +0,0 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -## [2.2.1](https://github.com/launchql/launchql/compare/@launchql/migrate@2.1.14...@launchql/migrate@2.2.1) (2025-06-28) - -**Note:** Version bump only for package @launchql/migrate diff --git a/packages/migrate/__tests__/cross-project.test.ts b/packages/migrate/__tests__/cross-project.test.ts new file mode 100644 index 000000000..3b776899d --- /dev/null +++ b/packages/migrate/__tests__/cross-project.test.ts @@ -0,0 +1,202 @@ +import { LaunchQLMigrate } from '../src/client'; +import { MigrateTestFixture, TestDatabase } from '../test-utils'; +import { join } from 'path'; + +describe('Cross-Project Dependencies', () => { + let fixture: MigrateTestFixture; + let db: TestDatabase; + let client: LaunchQLMigrate; + + beforeEach(async () => { + fixture = new MigrateTestFixture(); + db = await fixture.setupTestDatabase(); + client = new LaunchQLMigrate(db.config); + }); + + afterEach(async () => { + await fixture.cleanup(); + }); + + test('deploys cross-project dependencies', async () => { + const basePath = fixture.setupFixture('cross-project'); + + // First deploy project-a + const resultA = await client.deploy({ + project: 'project-a', + targetDatabase: db.name, + planPath: join(basePath, 'project-a', 'sqitch.plan'), + deployPath: 'deploy' + }); + + expect(resultA.deployed).toEqual(['base_schema', 'base_types']); + expect(await db.exists('schema', 'base')).toBe(true); + + // Then deploy project-b which depends on project-a + const resultB = await client.deploy({ + project: 'project-b', + targetDatabase: db.name, + planPath: join(basePath, 'project-b', 'sqitch.plan'), + deployPath: 'deploy' + }); + + expect(resultB.deployed).toEqual(['app_schema', 'app_tables']); + expect(await db.exists('schema', 'app')).toBe(true); + expect(await db.exists('table', 'app.items')).toBe(true); + + // Verify cross-project dependencies were recorded + const appSchemaDeps = await db.getDependencies('project-b', 'app_schema'); + expect(appSchemaDeps).toContain('project-a:base_schema'); + + const appTablesDeps = await db.getDependencies('project-b', 'app_tables'); + expect(appTablesDeps).toContain('project-a:base_types'); + }); + + test('fails deployment when cross-project dependency missing', async () => { + const basePath = fixture.setupFixture('cross-project'); + + // Try to deploy project-b without project-a + await expect(client.deploy({ + 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 + const deployed = await db.getDeployedChanges(); + expect(deployed).toHaveLength(0); + }); + + test('prevents revert of changes with cross-project dependents', async () => { + const basePath = fixture.setupFixture('cross-project'); + + // Deploy both projects + await client.deploy({ + 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 + // Note: toChange means "revert TO this change", so to revert base_types we revert to base_schema + await expect(client.revert({ + 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/); + + // Verify nothing was reverted + expect(await db.exists('schema', 'base')).toBe(true); + const deployed = await db.getDeployedChanges(); + expect(deployed).toHaveLength(4); // All 4 changes still deployed + }); + + test('lists cross-project dependents correctly', async () => { + const basePath = fixture.setupFixture('cross-project'); + + // Deploy both projects + await client.deploy({ + 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 + const result = await db.query( + `SELECT * FROM launchql_migrate.get_dependents($1, $2)`, + ['project-a', 'base_schema'] + ); + + expect(result.rows).toContainEqual({ + project: 'project-b', + change_name: 'app_schema', + dependency: 'project-a:base_schema' + }); + }); + + test('handles complex cross-project dependency chains', async () => { + // Create a more complex scenario + const projectA = fixture.createPlanFile('complex-a', [ + { name: 'base' }, + { name: 'utils', dependencies: ['base'] } + ]); + + const projectB = fixture.createPlanFile('complex-b', [ + { name: 'schema', dependencies: ['complex-a:base'] }, + { name: 'types', dependencies: ['schema', 'complex-a:utils'] } + ]); + + const projectC = fixture.createPlanFile('complex-c', [ + { name: 'app', dependencies: ['complex-b:schema'] }, + { name: 'tables', dependencies: ['app', 'complex-b:types', 'complex-a:utils'] } + ]); + + // Create minimal deploy and revert scripts + ['base', 'utils'].forEach(name => { + fixture.createScript(projectA, 'deploy', name, `SELECT 1; -- ${name}`); + fixture.createScript(projectA, 'revert', name, `SELECT 1; -- revert ${name}`); + }); + ['schema', 'types'].forEach(name => { + fixture.createScript(projectB, 'deploy', name, `SELECT 1; -- ${name}`); + fixture.createScript(projectB, 'revert', name, `SELECT 1; -- revert ${name}`); + }); + ['app', 'tables'].forEach(name => { + fixture.createScript(projectC, 'deploy', name, `SELECT 1; -- ${name}`); + fixture.createScript(projectC, 'revert', name, `SELECT 1; -- revert ${name}`); + }); + + // Deploy in order + await client.deploy({ + 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 + const deployed = await db.getDeployedChanges(); + expect(deployed).toHaveLength(6); + + // Try to revert a change that many depend on + // Note: to revert 'utils', we revert TO 'base' (which keeps base but reverts utils) + await expect(client.revert({ + project: 'complex-a', + targetDatabase: db.name, + planPath: join(projectA, 'sqitch.plan'), + revertPath: 'revert', + toChange: 'base' + })).rejects.toThrow(/Cannot revert utils: required by/); + }); +}); \ No newline at end of file diff --git a/packages/migrate/__tests__/deploy.test.ts b/packages/migrate/__tests__/deploy.test.ts new file mode 100644 index 000000000..ff29750f7 --- /dev/null +++ b/packages/migrate/__tests__/deploy.test.ts @@ -0,0 +1,170 @@ +import { LaunchQLMigrate } from '../src/client'; +import { MigrateTestFixture, TestDatabase } from '../test-utils'; +import { join } from 'path'; + +describe('Deploy Command', () => { + let fixture: MigrateTestFixture; + let db: TestDatabase; + + beforeEach(async () => { + fixture = new MigrateTestFixture(); + db = await fixture.setupTestDatabase(); + }); + + afterEach(async () => { + await fixture.cleanup(); + }); + + test('deploys single change', async () => { + const fixturePath = fixture.setupFixture('simple'); + const client = new LaunchQLMigrate(db.config); + + // Deploy only the schema change + const result = await client.deploy({ + project: 'test-simple', + targetDatabase: db.name, + planPath: join(fixturePath, 'sqitch.plan'), + deployPath: 'deploy', + toChange: 'schema' + }); + + expect(result.deployed).toContain('schema'); + expect(result.deployed).toHaveLength(1); + expect(await db.exists('schema', 'test_app')).toBe(true); + + // Verify it was recorded + const deployed = await db.getDeployedChanges(); + expect(deployed).toHaveLength(1); + expect(deployed[0]).toMatchObject({ + project: 'test-simple', + change_name: 'schema' + }); + }); + + test('deploys changes in dependency order', async () => { + const fixturePath = fixture.setupFixture('simple'); + const client = new LaunchQLMigrate(db.config); + + // Deploy all changes + const result = await client.deploy({ + project: 'test-simple', + targetDatabase: db.name, + planPath: join(fixturePath, 'sqitch.plan'), + deployPath: 'deploy' + }); + + expect(result.deployed).toEqual(['schema', 'table', 'index']); + + // Verify all objects exist + expect(await db.exists('schema', 'test_app')).toBe(true); + expect(await db.exists('table', 'test_app.users')).toBe(true); + + // Verify dependencies were recorded + const tableDeps = await db.getDependencies('test-simple', 'table'); + expect(tableDeps).toContain('schema'); + + const indexDeps = await db.getDependencies('test-simple', 'index'); + expect(indexDeps).toContain('table'); + }); + + test('skips already deployed changes', async () => { + const fixturePath = fixture.setupFixture('simple'); + const client = new LaunchQLMigrate(db.config); + + // First deployment + const result1 = await client.deploy({ + project: 'test-simple', + targetDatabase: db.name, + planPath: join(fixturePath, 'sqitch.plan'), + deployPath: 'deploy', + toChange: 'table' + }); + + expect(result1.deployed).toEqual(['schema', 'table']); + + // Second deployment - should skip already deployed + const result2 = await client.deploy({ + project: 'test-simple', + targetDatabase: db.name, + planPath: join(fixturePath, 'sqitch.plan'), + deployPath: 'deploy' + }); + + expect(result2.deployed).toEqual(['index']); + expect(result2.skipped).toEqual(['schema', 'table']); + }); + + test('rolls back on failure with transaction', async () => { + const tempDir = fixture.createPlanFile('test-fail', [ + { name: 'good_change' }, + { name: 'bad_change', dependencies: ['good_change'] } + ]); + + // Create good deploy script + fixture.createScript(tempDir, 'deploy', 'good_change', + 'CREATE TABLE test_table (id INT);' + ); + + // Create bad deploy script with syntax error + fixture.createScript(tempDir, 'deploy', 'bad_change', + 'CREATE TABLE bad_table (id INT; -- syntax error' + ); + + const client = new LaunchQLMigrate(db.config); + + await expect(client.deploy({ + project: 'test-fail', + targetDatabase: db.name, + planPath: join(tempDir, 'sqitch.plan'), + deployPath: 'deploy', + useTransaction: true // default + })).rejects.toThrow(); + + // Verify nothing was deployed due to rollback + const deployed = await db.getDeployedChanges(); + expect(deployed).toHaveLength(0); + + // Verify table doesn't exist + expect(await db.exists('table', 'test_table')).toBe(false); + }); + + test('continues on failure without transaction', async () => { + const tempDir = fixture.createPlanFile('test-fail', [ + { name: 'good_change' }, + { name: 'bad_change', dependencies: ['good_change'] }, + { name: 'another_good', dependencies: ['good_change'] } + ]); + + // Create scripts + fixture.createScript(tempDir, 'deploy', 'good_change', + 'CREATE TABLE test_table (id INT);' + ); + fixture.createScript(tempDir, 'deploy', 'bad_change', + 'CREATE TABLE bad_table (id INT; -- syntax error' + ); + fixture.createScript(tempDir, 'deploy', 'another_good', + 'CREATE TABLE another_table (id INT);' + ); + + const client = new LaunchQLMigrate(db.config); + + await expect(client.deploy({ + project: 'test-fail', + targetDatabase: db.name, + planPath: join(tempDir, 'sqitch.plan'), + deployPath: 'deploy', + useTransaction: false + })).rejects.toThrow(); + + // Verify first change was deployed + const deployed = await db.getDeployedChanges(); + expect(deployed).toHaveLength(1); + expect(deployed[0].change_name).toBe('good_change'); + + // Verify first table exists + expect(await db.exists('table', 'test_table')).toBe(true); + + // Verify third change was not attempted (stopped at error) + expect(await db.exists('table', 'another_table')).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/migrate/__tests__/first.test.ts b/packages/migrate/__tests__/first.test.ts deleted file mode 100644 index 2d48e8da8..000000000 --- a/packages/migrate/__tests__/first.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -it('works', () => { - console.log('hello test world!'); -}) \ No newline at end of file diff --git a/packages/migrate/__tests__/fixtures/cross-project/project-a/deploy/base_schema.sql b/packages/migrate/__tests__/fixtures/cross-project/project-a/deploy/base_schema.sql new file mode 100644 index 000000000..febe1bac8 --- /dev/null +++ b/packages/migrate/__tests__/fixtures/cross-project/project-a/deploy/base_schema.sql @@ -0,0 +1 @@ +CREATE SCHEMA base; \ No newline at end of file diff --git a/packages/migrate/__tests__/fixtures/cross-project/project-a/deploy/base_types.sql b/packages/migrate/__tests__/fixtures/cross-project/project-a/deploy/base_types.sql new file mode 100644 index 000000000..0fc7aa86a --- /dev/null +++ b/packages/migrate/__tests__/fixtures/cross-project/project-a/deploy/base_types.sql @@ -0,0 +1 @@ +CREATE TYPE base.status AS ENUM ('active', 'inactive'); \ No newline at end of file diff --git a/packages/migrate/__tests__/fixtures/cross-project/project-a/revert/base_schema.sql b/packages/migrate/__tests__/fixtures/cross-project/project-a/revert/base_schema.sql new file mode 100644 index 000000000..532b685e6 --- /dev/null +++ b/packages/migrate/__tests__/fixtures/cross-project/project-a/revert/base_schema.sql @@ -0,0 +1 @@ +DROP SCHEMA base CASCADE; \ No newline at end of file diff --git a/packages/migrate/__tests__/fixtures/cross-project/project-a/revert/base_types.sql b/packages/migrate/__tests__/fixtures/cross-project/project-a/revert/base_types.sql new file mode 100644 index 000000000..310dd99f2 --- /dev/null +++ b/packages/migrate/__tests__/fixtures/cross-project/project-a/revert/base_types.sql @@ -0,0 +1 @@ +DROP TYPE base.status; \ No newline at end of file diff --git a/packages/migrate/__tests__/fixtures/cross-project/project-a/sqitch.plan b/packages/migrate/__tests__/fixtures/cross-project/project-a/sqitch.plan new file mode 100644 index 000000000..e58297a1a --- /dev/null +++ b/packages/migrate/__tests__/fixtures/cross-project/project-a/sqitch.plan @@ -0,0 +1,6 @@ +%syntax-version=1.0.0 +%project=project-a +%uri=https://github.com/test/project-a + +base_schema 2023-01-01T00:00:00Z test # Create base schema +base_types [base_schema] 2023-01-01T00:01:00Z test # Create base types \ No newline at end of file diff --git a/packages/migrate/__tests__/fixtures/cross-project/project-b/deploy/app_schema.sql b/packages/migrate/__tests__/fixtures/cross-project/project-b/deploy/app_schema.sql new file mode 100644 index 000000000..a7abd1e09 --- /dev/null +++ b/packages/migrate/__tests__/fixtures/cross-project/project-b/deploy/app_schema.sql @@ -0,0 +1 @@ +CREATE SCHEMA app; \ No newline at end of file diff --git a/packages/migrate/__tests__/fixtures/cross-project/project-b/deploy/app_tables.sql b/packages/migrate/__tests__/fixtures/cross-project/project-b/deploy/app_tables.sql new file mode 100644 index 000000000..42d9d0813 --- /dev/null +++ b/packages/migrate/__tests__/fixtures/cross-project/project-b/deploy/app_tables.sql @@ -0,0 +1,4 @@ +CREATE TABLE app.users ( + id SERIAL PRIMARY KEY, + status base.status DEFAULT 'active' +); \ No newline at end of file diff --git a/packages/migrate/__tests__/fixtures/cross-project/project-b/revert/app_schema.sql b/packages/migrate/__tests__/fixtures/cross-project/project-b/revert/app_schema.sql new file mode 100644 index 000000000..c7aa0f697 --- /dev/null +++ b/packages/migrate/__tests__/fixtures/cross-project/project-b/revert/app_schema.sql @@ -0,0 +1 @@ +DROP SCHEMA app CASCADE; \ No newline at end of file diff --git a/packages/migrate/__tests__/fixtures/cross-project/project-b/revert/app_tables.sql b/packages/migrate/__tests__/fixtures/cross-project/project-b/revert/app_tables.sql new file mode 100644 index 000000000..feeddd066 --- /dev/null +++ b/packages/migrate/__tests__/fixtures/cross-project/project-b/revert/app_tables.sql @@ -0,0 +1 @@ +DROP TABLE app.users; \ No newline at end of file diff --git a/packages/migrate/__tests__/fixtures/cross-project/project-b/sqitch.plan b/packages/migrate/__tests__/fixtures/cross-project/project-b/sqitch.plan new file mode 100644 index 000000000..bb11297b9 --- /dev/null +++ b/packages/migrate/__tests__/fixtures/cross-project/project-b/sqitch.plan @@ -0,0 +1,6 @@ +%syntax-version=1.0.0 +%project=project-b +%uri=https://github.com/test/project-b + +app_schema [project-a:base_schema] 2023-01-01T00:02:00Z test # Create app schema +app_tables [app_schema project-a:base_types] 2023-01-01T00:03:00Z test # Create app tables \ No newline at end of file diff --git a/packages/migrate/jest.config.js b/packages/migrate/jest.config.js index 0aa3aaa49..3bb3fd295 100644 --- a/packages/migrate/jest.config.js +++ b/packages/migrate/jest.config.js @@ -2,6 +2,7 @@ module.exports = { preset: "ts-jest", testEnvironment: "node", + setupFilesAfterEnv: ["/jest.setup.ts"], transform: { "^.+\\.tsx?$": [ "ts-jest", diff --git a/packages/migrate/jest.setup.ts b/packages/migrate/jest.setup.ts new file mode 100644 index 000000000..fbe5aa2ac --- /dev/null +++ b/packages/migrate/jest.setup.ts @@ -0,0 +1,9 @@ +import { teardownAllPools } from './test-utils'; + +// Default test timeout +jest.setTimeout(10000); + +// Global teardown after all tests +afterAll(async () => { + await teardownAllPools(); +}); \ No newline at end of file diff --git a/packages/migrate/src/client.ts b/packages/migrate/src/client.ts index 199df7550..33e3a945b 100644 --- a/packages/migrate/src/client.ts +++ b/packages/migrate/src/client.ts @@ -72,7 +72,7 @@ export class LaunchQLMigrate { } else { log.success('Migration schema found and ready'); } - + this.initialized = true; } catch (error) { log.error('Failed to initialize migration schema:', error); @@ -512,6 +512,28 @@ export class LaunchQLMigrate { } } + /** + * Get dependencies for a change + */ + async getDependencies(project: string, changeName: string): Promise { + 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.project = $1 AND c.change_name = $2`, + [project, changeName] + ); + + return result.rows.map(row => row.requires); + } catch (error) { + log.error(`Failed to get dependencies for ${project}:${changeName}:`, error); + return []; + } + } + /** * Close the database connection pool */ diff --git a/packages/migrate/src/sql/procedures.sql b/packages/migrate/src/sql/procedures.sql index 4e055c205..18e6e0b80 100644 --- a/packages/migrate/src/sql/procedures.sql +++ b/packages/migrate/src/sql/procedures.sql @@ -8,18 +8,37 @@ BEGIN END; $$; --- Check if a change is deployed +-- Check if a change is deployed (handles both local and cross-project dependencies) CREATE FUNCTION launchql_migrate.is_deployed( p_project TEXT, p_change_name TEXT ) RETURNS BOOLEAN -LANGUAGE sql STABLE AS $$ - SELECT EXISTS ( +LANGUAGE plpgsql STABLE AS $$ +DECLARE + v_actual_project TEXT; + v_actual_change TEXT; + v_colon_pos INT; +BEGIN + -- Check if change_name contains a project prefix (cross-project dependency) + v_colon_pos := position(':' in p_change_name); + + IF v_colon_pos > 0 THEN + -- Split into project and change name + v_actual_project := substring(p_change_name from 1 for v_colon_pos - 1); + v_actual_change := substring(p_change_name from v_colon_pos + 1); + ELSE + -- Use provided project as default + v_actual_project := p_project; + v_actual_change := p_change_name; + END IF; + + RETURN EXISTS ( SELECT 1 FROM launchql_migrate.changes - WHERE project = p_project - AND change_name = p_change_name + WHERE project = v_actual_project + AND change_name = v_actual_change ); +END; $$; -- Deploy a change @@ -110,14 +129,39 @@ BEGIN RAISE EXCEPTION 'Change % not deployed in project %', p_change_name, p_project; END IF; - -- Check if other changes depend on this + -- Check if other changes depend on this (including cross-project dependencies) 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 + WHERE ( + -- Local dependency within same project + (d.requires = p_change_name AND c.project = p_project) + OR + -- Cross-project dependency + (d.requires = p_project || ':' || p_change_name) + ) ) THEN - RAISE EXCEPTION 'Other changes depend on %', p_change_name; + -- Get list of dependent changes for better error message + DECLARE + dependent_changes TEXT; + BEGIN + SELECT string_agg( + CASE + WHEN d.requires = p_change_name THEN c.change_name + ELSE c.project || ':' || c.change_name + END, + ', ' + ) INTO dependent_changes + 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) + OR + (d.requires = p_project || ':' || p_change_name) + ); + + RAISE EXCEPTION 'Cannot revert %: required by %', p_change_name, dependent_changes; + END; END IF; -- Execute revert @@ -161,6 +205,26 @@ LANGUAGE sql STABLE AS $$ ORDER BY deployed_at; $$; +-- Get changes that depend on a given change +CREATE FUNCTION launchql_migrate.get_dependents( + p_project TEXT, + p_change_name TEXT +) +RETURNS TABLE(project TEXT, change_name TEXT, dependency TEXT) +LANGUAGE sql STABLE AS $$ + SELECT c.project, c.change_name, d.requires as dependency + FROM launchql_migrate.dependencies d + JOIN launchql_migrate.changes c ON c.change_id = d.change_id + WHERE ( + -- Local dependency within same project + (d.requires = p_change_name AND c.project = p_project) + OR + -- Cross-project dependency + (d.requires = p_project || ':' || p_change_name) + ) + ORDER BY c.project, c.change_name; +$$; + -- Get deployment status CREATE FUNCTION launchql_migrate.status( p_project TEXT DEFAULT NULL diff --git a/packages/migrate/test-utils/index.ts b/packages/migrate/test-utils/index.ts new file mode 100644 index 000000000..43a34b7c7 --- /dev/null +++ b/packages/migrate/test-utils/index.ts @@ -0,0 +1,282 @@ +import { MigrateConfig } from '../src/types'; +import { LaunchQLMigrate } from '../src/client'; +import { mkdtempSync, rmSync, cpSync, writeFileSync, mkdirSync } from 'fs'; +import { join, resolve } from 'path'; +import { tmpdir } from 'os'; +import { getPgPool, teardownPgPools } from 'pg-cache'; +import { getPgEnvOptions, PgConfig } from 'pg-env'; +import { Pool } from 'pg'; + +export const FIXTURES_PATH = resolve(__dirname, '../../../__fixtures__/migrate'); + +// Global teardown function to be called after all tests +export async function teardownAllPools(): Promise { + await teardownPgPools(); +} + +// Helper to close specific database pools +export async function closeDatabasePools(databases: string[]): Promise { + // For now, we'll rely on the global teardown + // Individual database pool cleanup can cause issues with pg-cache +} + +export interface TestDatabase { + name: string; + config: MigrateConfig; + query(sql: string, params?: any[]): Promise; + exists(type: 'schema' | 'table', name: string): Promise; + getDeployedChanges(): Promise; + getDependencies(project: string, changeName: string): Promise; + close(): Promise; +} + +export interface Change { + name: string; + dependencies?: string[]; + timestamp?: string; + planner?: string; + email?: string; + comment?: string; +} + +export class MigrateTestFixture { + private tempDirs: string[] = []; + private databases: TestDatabase[] = []; + private dbCounter = 0; + private pools: Pool[] = []; + + async setupTestDatabase(): Promise { + const dbName = `test_migrate_${Date.now()}_${Math.random().toString(36).substring(2, 8)}_${this.dbCounter++}`; + + // Get base config from environment using pg-env + const baseConfig = getPgEnvOptions({ + database: 'postgres' + }); + + // Create database using admin pool + const adminPool = getPgPool(baseConfig); + await adminPool.query(`CREATE DATABASE "${dbName}"`); + + // Get config for the new test database + const pgConfig = getPgEnvOptions({ + database: dbName + }); + + const config: MigrateConfig = { + host: pgConfig.host, + port: pgConfig.port, + user: pgConfig.user, + password: pgConfig.password, + database: pgConfig.database + }; + + // Initialize migrate schema + const migrate = new LaunchQLMigrate(config); + await migrate.initialize(); + + // Get pool for test database operations + const pool = getPgPool(pgConfig); + this.pools.push(pool); + + const db: TestDatabase = { + name: dbName, + config, + + async query(sql: string, params?: any[]) { + return pool.query(sql, params); + }, + + async exists(type: 'schema' | 'table', name: string) { + if (type === 'schema') { + const result = await pool.query( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.schemata + WHERE schema_name = $1 + ) as exists`, + [name] + ); + return result.rows[0].exists; + } else { + const [schema, table] = name.includes('.') ? name.split('.') : ['public', name]; + const result = await pool.query( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = $1 AND table_name = $2 + ) as exists`, + [schema, table] + ); + return result.rows[0].exists; + } + }, + + async getDeployedChanges() { + const result = await pool.query( + `SELECT project, change_name, deployed_at + FROM launchql_migrate.changes + ORDER BY deployed_at` + ); + return result.rows; + }, + + async getDependencies(project: string, changeName: string) { + const result = await pool.query( + `SELECT d.requires + FROM launchql_migrate.dependencies d + JOIN launchql_migrate.changes c ON c.change_id = d.change_id + WHERE c.project = $1 AND c.change_name = $2`, + [project, changeName] + ); + return result.rows.map((row: any) => row.requires); + }, + + async close() { + // Don't close the pool here as it's managed by pg-cache + // Just mark this database as closed + } + }; + + this.databases.push(db); + return db; + } + + setupFixture(fixtureName: string): string { + const originalPath = join(FIXTURES_PATH, fixtureName); + const tempDir = mkdtempSync(join(tmpdir(), 'migrate-test-')); + const fixturePath = join(tempDir, fixtureName); + + cpSync(originalPath, fixturePath, { recursive: true }); + this.tempDirs.push(tempDir); + + return fixturePath; + } + + createPlanFile(project: string, changes: Change[]): string { + const tempDir = mkdtempSync(join(tmpdir(), 'migrate-test-')); + this.tempDirs.push(tempDir); + + const lines = [ + '%syntax-version=1.0.0', + `%project=${project}`, + `%uri=https://github.com/test/${project}`, + '' + ]; + + for (const change of changes) { + let line = change.name; + + if (change.dependencies && change.dependencies.length > 0) { + line += ` [${change.dependencies.join(' ')}]`; + } + + line += ` ${change.timestamp || new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')}`; + line += ` ${change.planner || 'test'}`; + line += ` <${change.email || 'test@example.com'}>`; + + if (change.comment) { + line += ` # ${change.comment}`; + } + + lines.push(line); + } + + const planPath = join(tempDir, 'sqitch.plan'); + writeFileSync(planPath, lines.join('\n')); + + return tempDir; + } + + createScript(dir: string, type: 'deploy' | 'revert' | 'verify', name: string, content: string): void { + const scriptDir = join(dir, type); + mkdirSync(scriptDir, { recursive: true }); + writeFileSync(join(scriptDir, `${name}.sql`), content); + } + + async cleanup(): Promise { + // Close all test database connections first + const dbNames = this.databases.map(db => db.name); + + // Close all pools for test databases + await closeDatabasePools(dbNames); + + // 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)); + + for (const db of this.databases) { + try { + // Drop the database + // await adminPool.query(`DROP DATABASE IF EXISTS "${db.name}"`); + } catch (e) { + // Ignore errors - database might have active connections + } + } + + // Remove temporary directories + for (const dir of this.tempDirs) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch (e) { + // Ignore errors during cleanup + } + } + } +} + +// Helper class for building plan files programmatically +export class PlanBuilder { + private project: string; + private changes: Change[] = []; + + constructor(project: string) { + this.project = project; + } + + addChange(name: string, options: Partial = {}): this { + this.changes.push({ + name, + ...options + }); + return this; + } + + build(): { project: string; changes: Change[] } { + return { + project: this.project, + changes: this.changes + }; + } + + toString(): string { + const lines = [ + '%syntax-version=1.0.0', + `%project=${this.project}`, + `%uri=https://github.com/test/${this.project}`, + '' + ]; + + for (const change of this.changes) { + let line = change.name; + + if (change.dependencies && change.dependencies.length > 0) { + line += ` [${change.dependencies.join(' ')}]`; + } + + line += ` ${change.timestamp || new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')}`; + line += ` ${change.planner || 'test'}`; + line += ` <${change.email || 'test@example.com'}>`; + + if (change.comment) { + line += ` # ${change.comment}`; + } + + lines.push(line); + } + + return lines.join('\n'); + } +} \ No newline at end of file