Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Object>} 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 };
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
18 changes: 16 additions & 2 deletions packages/core/database/utils/prisma-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}`
});
}
});
Expand Down
54 changes: 36 additions & 18 deletions packages/core/handlers/routers/db-migration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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);
Expand Down Expand Up @@ -255,37 +265,45 @@ 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,
error: 'action must be either "applied" or "rolled-back"'
});
}

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,
});
}
})
Expand Down
18 changes: 18 additions & 0 deletions packages/core/handlers/routers/db-migration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
75 changes: 75 additions & 0 deletions packages/core/handlers/workers/db-migration.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@
* @param {Object} context - Lambda context (contains AWS request ID, timeout info)
* @returns {Promise<Object>} Response with statusCode and body
*/
exports.handler = async (event, context) => {

Check failure on line 143 in packages/core/handlers/workers/db-migration.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 37 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=friggframework_frigg&issues=AZ9gkC08BbwD4N7BleLY&open=AZ9gkC08BbwD4N7BleLY&pullRequest=627
console.log('========================================');
console.log('Database Migration Lambda Started');
console.log('========================================');
Expand Down Expand Up @@ -188,6 +188,81 @@
}
}

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'})`);
Expand Down
Loading
Loading