diff --git a/packages/core/database/use-cases/resolve-migration-via-worker-use-case.js b/packages/core/database/use-cases/resolve-migration-via-worker-use-case.js new file mode 100644 index 000000000..2598aedd5 --- /dev/null +++ b/packages/core/database/use-cases/resolve-migration-via-worker-use-case.js @@ -0,0 +1,49 @@ +/** + * Resolve Migration Via Worker Use Case + * + * Resolves a failed Prisma migration (P3009) by invoking the worker Lambda, + * which has the Prisma CLI installed. Keeps the router Lambda lightweight — + * same delegation pattern as GetDatabaseStateViaWorkerUseCase. + */ +class ResolveMigrationViaWorkerUseCase { + /** + * @param {Object} dependencies + * @param {LambdaInvoker} dependencies.lambdaInvoker - Lambda invocation adapter + * @param {string} dependencies.workerFunctionName - Worker Lambda function name + */ + constructor({ lambdaInvoker, workerFunctionName }) { + if (!lambdaInvoker) { + throw new Error('lambdaInvoker dependency is required'); + } + if (!workerFunctionName) { + throw new Error('workerFunctionName is required'); + } + this.lambdaInvoker = lambdaInvoker; + this.workerFunctionName = workerFunctionName; + } + + /** + * @param {Object} params + * @param {string} params.migrationName - Migration to resolve + * @param {'applied'|'rolled-back'} [params.action] - Resolution mode + * @param {string} [params.stage] - Deployment stage + * @returns {Promise} Worker result body + */ + async execute({ migrationName, action = 'applied', stage }) { + const dbType = process.env.DB_TYPE || 'postgresql'; + + console.log( + `Invoking worker Lambda to resolve migration "${migrationName}" as ${action}: ${this.workerFunctionName}` + ); + + return this.lambdaInvoker.invoke(this.workerFunctionName, { + action: 'resolve', + migrationName, + resolveAction: action, + dbType, + stage, + }); + } +} + +module.exports = { ResolveMigrationViaWorkerUseCase }; diff --git a/packages/core/database/use-cases/resolve-migration-via-worker-use-case.test.js b/packages/core/database/use-cases/resolve-migration-via-worker-use-case.test.js new file mode 100644 index 000000000..12d32436d --- /dev/null +++ b/packages/core/database/use-cases/resolve-migration-via-worker-use-case.test.js @@ -0,0 +1,106 @@ +/** + * Tests for ResolveMigrationViaWorkerUseCase + * Domain layer - resolves a failed migration by invoking the worker Lambda + */ + +const { + ResolveMigrationViaWorkerUseCase, +} = require('./resolve-migration-via-worker-use-case'); + +describe('ResolveMigrationViaWorkerUseCase', () => { + let useCase; + let mockLambdaInvoker; + const workerFunctionName = 'my-app-prod-dbMigrationWorker'; + + beforeEach(() => { + mockLambdaInvoker = { + invoke: jest.fn(), + }; + useCase = new ResolveMigrationViaWorkerUseCase({ + lambdaInvoker: mockLambdaInvoker, + workerFunctionName, + }); + }); + + describe('constructor', () => { + it('should require lambdaInvoker dependency', () => { + expect( + () => + new ResolveMigrationViaWorkerUseCase({ workerFunctionName }) + ).toThrow('lambdaInvoker dependency is required'); + }); + + it('should require workerFunctionName dependency', () => { + expect( + () => + new ResolveMigrationViaWorkerUseCase({ + lambdaInvoker: mockLambdaInvoker, + }) + ).toThrow('workerFunctionName is required'); + }); + }); + + describe('execute()', () => { + it('should invoke worker with a resolve payload (default applied)', async () => { + mockLambdaInvoker.invoke.mockResolvedValue({ success: true }); + + await useCase.execute({ + migrationName: '20260422120001_create_process_table', + stage: 'prod', + }); + + expect(mockLambdaInvoker.invoke).toHaveBeenCalledWith( + workerFunctionName, + { + action: 'resolve', + migrationName: '20260422120001_create_process_table', + resolveAction: 'applied', + dbType: 'postgresql', + stage: 'prod', + } + ); + }); + + it('should pass through rolled-back as resolveAction', async () => { + mockLambdaInvoker.invoke.mockResolvedValue({ success: true }); + + await useCase.execute({ + migrationName: 'mig', + action: 'rolled-back', + stage: 'dev', + }); + + expect(mockLambdaInvoker.invoke).toHaveBeenCalledWith( + workerFunctionName, + expect.objectContaining({ resolveAction: 'rolled-back' }) + ); + }); + + it('should return the worker result body', async () => { + const body = { + success: true, + message: 'Migration mig marked as applied', + migrationName: 'mig', + action: 'applied', + }; + mockLambdaInvoker.invoke.mockResolvedValue(body); + + const result = await useCase.execute({ + migrationName: 'mig', + stage: 'prod', + }); + + expect(result).toEqual(body); + }); + + it('should propagate worker errors', async () => { + mockLambdaInvoker.invoke.mockRejectedValue( + new Error('Worker Lambda failed') + ); + + await expect( + useCase.execute({ migrationName: 'mig', stage: 'prod' }) + ).rejects.toThrow('Worker Lambda failed'); + }); + }); +}); diff --git a/packages/core/database/utils/prisma-runner.js b/packages/core/database/utils/prisma-runner.js index 8041fce77..cbba0f549 100644 --- a/packages/core/database/utils/prisma-runner.js +++ b/packages/core/database/utils/prisma-runner.js @@ -405,14 +405,25 @@ async function runPrismaMigrateResolve(migrationName, action = 'applied', verbos const [executable, ...executableArgs] = prismaBin.split(' '); const fullArgs = [...executableArgs, ...args]; + let stdout = ''; + let stderr = ''; const proc = spawn(executable, fullArgs, { - stdio: 'inherit', + stdio: ['inherit', 'pipe', 'pipe'], env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: '1' } }); + proc.stdout.on('data', (data) => { + stdout += data.toString(); + if (verbose) process.stdout.write(data); + }); + proc.stderr.on('data', (data) => { + stderr += data.toString(); + if (verbose) process.stderr.write(data); + }); + proc.on('error', (error) => { resolve({ success: false, @@ -427,9 +438,12 @@ async function runPrismaMigrateResolve(migrationName, action = 'applied', verbos output: `Migration ${migrationName} marked as ${action}` }); } else { + const detail = (stderr || stdout).trim(); resolve({ success: false, - error: `Resolve process exited with code ${code}` + error: detail + ? `Prisma migrate resolve failed (exit ${code}): ${detail}` + : `Resolve process exited with code ${code}` }); } }); diff --git a/packages/core/handlers/routers/db-migration.js b/packages/core/handlers/routers/db-migration.js index e63e89ab7..47e6d84d4 100644 --- a/packages/core/handlers/routers/db-migration.js +++ b/packages/core/handlers/routers/db-migration.js @@ -31,10 +31,16 @@ const { ValidationError: GetValidationError, NotFoundError, } = require('../../database/use-cases/get-migration-status-use-case'); -const { LambdaInvoker } = require('../../database/adapters/lambda-invoker'); +const { + LambdaInvoker, + LambdaInvocationError, +} = require('../../database/adapters/lambda-invoker'); const { GetDatabaseStateViaWorkerUseCase, } = require('../../database/use-cases/get-database-state-via-worker-use-case'); +const { + ResolveMigrationViaWorkerUseCase, +} = require('../../database/use-cases/resolve-migration-via-worker-use-case'); const router = Router(); @@ -58,6 +64,10 @@ const getDatabaseStateUseCase = new GetDatabaseStateViaWorkerUseCase({ lambdaInvoker, workerFunctionName, }); +const resolveMigrationUseCase = new ResolveMigrationViaWorkerUseCase({ + lambdaInvoker, + workerFunctionName, +}); // Apply admin API key validation to all routes (shared middleware) router.use(validateAdminApiKey); @@ -255,6 +265,13 @@ router.post( }); } + if (!/^\d{14}_[a-z0-9_]+$/i.test(migrationName)) { + return res.status(400).json({ + success: false, + error: 'migrationName is not a valid migration identifier' + }); + } + if (!['applied', 'rolled-back'].includes(action)) { return res.status(400).json({ success: false, @@ -262,30 +279,31 @@ router.post( }); } - try { - // Import prismaRunner here to avoid circular dependencies - const prismaRunner = require('../../database/utils/prisma-runner'); - - const result = await prismaRunner.runPrismaMigrateResolve(migrationName, action, true); + const stage = req.body.stage || process.env.STAGE || 'production'; - if (!result.success) { - return res.status(500).json({ - success: false, - error: `Failed to resolve migration: ${result.error}` - }); - } - - res.status(200).json({ - success: true, - message: `Migration ${migrationName} marked as ${action}`, + try { + const result = await resolveMigrationUseCase.execute({ migrationName, - action + action, + stage, }); + + res.status(200).json(result); } catch (error) { console.error('Migration resolve failed:', error); + if ( + error instanceof LambdaInvocationError && + error.statusCode === 400 + ) { + return res.status(400).json({ + success: false, + error: error.message, + }); + } return res.status(500).json({ success: false, - error: error.message + error: 'Failed to resolve migration', + details: error.message, }); } }) diff --git a/packages/core/handlers/routers/db-migration.test.js b/packages/core/handlers/routers/db-migration.test.js index 17f08360c..e4bd8d083 100644 --- a/packages/core/handlers/routers/db-migration.test.js +++ b/packages/core/handlers/routers/db-migration.test.js @@ -70,4 +70,22 @@ describe('Database Migration Router - Adapter Layer', () => { // If router loads without error, dependency injection worked }); }); + + describe('POST /admin/db-migrate/resolve endpoint', () => { + it('should register the resolve route (P3009 recovery)', () => { + const router = require('./db-migration').router; + const routes = router.stack + .filter((layer) => layer.route) + .map((layer) => ({ + path: layer.route.path, + methods: Object.keys(layer.route.methods), + })); + + const resolveRoute = routes.find( + (r) => r.path === '/admin/db-migrate/resolve' + ); + expect(resolveRoute).toBeDefined(); + expect(resolveRoute.methods).toContain('post'); + }); + }); }); diff --git a/packages/core/handlers/workers/db-migration.js b/packages/core/handlers/workers/db-migration.js index cc5b703f4..68c085c4e 100644 --- a/packages/core/handlers/workers/db-migration.js +++ b/packages/core/handlers/workers/db-migration.js @@ -188,6 +188,81 @@ exports.handler = async (event, context) => { } } + if (action === 'resolve') { + const { migrationName, resolveAction = 'applied' } = event; + console.log(`\n========================================`); + console.log( + `Action: resolve (migration=${migrationName}, mode=${resolveAction})` + ); + console.log(`========================================`); + + if (!migrationName) { + return { + statusCode: 400, + body: { success: false, error: 'migrationName is required' }, + }; + } + if (!/^\d{14}_[a-z0-9_]+$/i.test(migrationName)) { + return { + statusCode: 400, + body: { + success: false, + error: 'migrationName is not a valid migration identifier', + }, + }; + } + if (!['applied', 'rolled-back'].includes(resolveAction)) { + return { + statusCode: 400, + body: { + success: false, + error: 'resolveAction must be "applied" or "rolled-back"', + }, + }; + } + if (dbType !== 'postgresql') { + return { + statusCode: 400, + body: { + success: false, + error: `Migration resolve is only supported for postgresql, not "${dbType}"`, + }, + }; + } + + try { + const result = await prismaRunner.runPrismaMigrateResolve( + migrationName, + resolveAction, + true + ); + if (!result.success) { + return { + statusCode: 500, + body: { + success: false, + error: sanitizeError(result.error), + }, + }; + } + return { + statusCode: 200, + body: { + success: true, + message: `Migration ${migrationName} marked as ${resolveAction}`, + migrationName, + action: resolveAction, + }, + }; + } catch (error) { + console.error('❌ Migration resolve failed:', error.message); + return { + statusCode: 500, + body: { success: false, error: sanitizeError(error.message) }, + }; + } + } + // Otherwise, handle migration (existing code) console.log(`\n========================================`); console.log(`Action: migrate (migrationId=${migrationId || 'new'})`); diff --git a/packages/core/handlers/workers/db-migration.test.js b/packages/core/handlers/workers/db-migration.test.js index 2f112d27b..f7a6707cd 100644 --- a/packages/core/handlers/workers/db-migration.test.js +++ b/packages/core/handlers/workers/db-migration.test.js @@ -170,4 +170,134 @@ describe('Database Migration Worker - Adapter Layer', () => { expect(mockPrismaRunner.checkDatabaseState).toHaveBeenCalledWith('documentdb'); }); }); + + describe('resolve action', () => { + let handler; + let mockPrismaRunner; + const context = { + requestId: 'test-request-id', + functionName: 'test-function', + getRemainingTimeInMillis: () => 30000, + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + + mockPrismaRunner = { + runMigration: jest.fn(), + deployMigration: jest.fn(), + checkDatabaseState: jest.fn(), + runPrismaMigrateResolve: jest.fn(), + }; + jest.mock('../../database/utils/prisma-runner', () => mockPrismaRunner); + + handler = require('./db-migration').handler; + }); + + it('resolves a migration and returns 200', async () => { + mockPrismaRunner.runPrismaMigrateResolve.mockResolvedValue({ + success: true, + }); + + const result = await handler( + { + action: 'resolve', + migrationName: '20260422120001_create_process_table', + resolveAction: 'applied', + stage: 'prod', + }, + context + ); + + expect(result.statusCode).toBe(200); + expect(result.body.success).toBe(true); + expect(result.body.migrationName).toBe( + '20260422120001_create_process_table' + ); + expect(result.body.action).toBe('applied'); + expect(mockPrismaRunner.runPrismaMigrateResolve).toHaveBeenCalledWith( + '20260422120001_create_process_table', + 'applied', + true + ); + }); + + it('returns 400 when migrationName is missing', async () => { + const result = await handler( + { action: 'resolve', resolveAction: 'applied', stage: 'prod' }, + context + ); + + expect(result.statusCode).toBe(400); + expect(mockPrismaRunner.runPrismaMigrateResolve).not.toHaveBeenCalled(); + }); + + it('returns 400 for an invalid resolveAction', async () => { + const result = await handler( + { + action: 'resolve', + migrationName: '20260422120001_create_process_table', + resolveAction: 'bogus', + stage: 'prod', + }, + context + ); + + expect(result.statusCode).toBe(400); + expect(mockPrismaRunner.runPrismaMigrateResolve).not.toHaveBeenCalled(); + }); + + it('returns 400 for a malformed migrationName (flag injection guard)', async () => { + const result = await handler( + { + action: 'resolve', + migrationName: '--schema=/etc/passwd', + resolveAction: 'applied', + stage: 'prod', + }, + context + ); + + expect(result.statusCode).toBe(400); + expect(mockPrismaRunner.runPrismaMigrateResolve).not.toHaveBeenCalled(); + }); + + it('returns 400 for a non-postgresql dbType', async () => { + const result = await handler( + { + action: 'resolve', + migrationName: '20260422120001_create_process_table', + resolveAction: 'applied', + dbType: 'mongodb', + stage: 'prod', + }, + context + ); + + expect(result.statusCode).toBe(400); + expect(mockPrismaRunner.runPrismaMigrateResolve).not.toHaveBeenCalled(); + }); + + it('returns 500 (with the Prisma error) when the resolve fails', async () => { + mockPrismaRunner.runPrismaMigrateResolve.mockResolvedValue({ + success: false, + error: 'Prisma migrate resolve failed (exit 1): P3011 ...', + }); + + const result = await handler( + { + action: 'resolve', + migrationName: '20260422120001_create_process_table', + resolveAction: 'rolled-back', + stage: 'prod', + }, + context + ); + + expect(result.statusCode).toBe(500); + expect(result.body.success).toBe(false); + expect(result.body.error).toContain('P3011'); + }); + }); }); diff --git a/packages/devtools/infrastructure/domains/database/migration-builder.js b/packages/devtools/infrastructure/domains/database/migration-builder.js index ac1f03db1..36a0defb1 100644 --- a/packages/devtools/infrastructure/domains/database/migration-builder.js +++ b/packages/devtools/infrastructure/domains/database/migration-builder.js @@ -478,6 +478,12 @@ class MigrationBuilder extends InfrastructureBuilder { }, }, { httpApi: { path: '/admin/db-migrate', method: 'POST' } }, + { + httpApi: { + path: '/admin/db-migrate/resolve', + method: 'POST', + }, + }, { httpApi: { path: '/admin/db-migrate/{processId}', diff --git a/packages/devtools/infrastructure/domains/database/migration-builder.test.js b/packages/devtools/infrastructure/domains/database/migration-builder.test.js index bfd582b48..ae8e32866 100644 --- a/packages/devtools/infrastructure/domains/database/migration-builder.test.js +++ b/packages/devtools/infrastructure/domains/database/migration-builder.test.js @@ -229,7 +229,7 @@ describe('MigrationBuilder', () => { expect(result.functions.dbMigrationRouter.skipEsbuild).toBe(true); expect(result.functions.dbMigrationRouter.timeout).toBe(30); expect(result.functions.dbMigrationRouter.memorySize).toBe(512); - expect(result.functions.dbMigrationRouter.events).toHaveLength(3); + expect(result.functions.dbMigrationRouter.events).toHaveLength(4); // Must match core's Express router mounted under /admin/db-migrate. expect(result.functions.dbMigrationRouter.events).toContainEqual({ httpApi: { path: '/admin/db-migrate/status', method: 'GET' }, @@ -237,6 +237,9 @@ describe('MigrationBuilder', () => { expect(result.functions.dbMigrationRouter.events).toContainEqual({ httpApi: { path: '/admin/db-migrate', method: 'POST' }, }); + expect(result.functions.dbMigrationRouter.events).toContainEqual({ + httpApi: { path: '/admin/db-migrate/resolve', method: 'POST' }, + }); expect(result.functions.dbMigrationRouter.events).toContainEqual({ httpApi: { path: '/admin/db-migrate/{processId}', method: 'GET' }, });