@friggframework/core 2.0.0--canary.461.e58db0a.0 → 2.0.0--canary.461.4f3c330.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/database/use-cases/get-migration-status-use-case.js +97 -0
- package/database/use-cases/get-migration-status-use-case.test.js +215 -0
- package/database/use-cases/trigger-database-migration-use-case.js +169 -0
- package/database/use-cases/trigger-database-migration-use-case.test.js +265 -0
- package/handlers/routers/db-migration.handler.js +20 -0
- package/handlers/routers/db-migration.js +175 -0
- package/handlers/workers/db-migration.js +112 -18
- package/package.json +5 -5
- package/handlers/workers/db-migration.test.js +0 -437
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get Migration Status Use Case
|
|
3
|
+
*
|
|
4
|
+
* Retrieves the status of a database migration by process ID.
|
|
5
|
+
* Formats the Process record for migration-specific response.
|
|
6
|
+
*
|
|
7
|
+
* This use case follows the Frigg hexagonal architecture pattern where:
|
|
8
|
+
* - Routers (adapters) call use cases
|
|
9
|
+
* - Use cases contain business logic and formatting
|
|
10
|
+
* - Use cases call repositories for data access
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
class GetMigrationStatusUseCase {
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object} dependencies
|
|
16
|
+
* @param {Object} dependencies.processRepository - Repository for process data access
|
|
17
|
+
*/
|
|
18
|
+
constructor({ processRepository }) {
|
|
19
|
+
if (!processRepository) {
|
|
20
|
+
throw new Error('processRepository dependency is required');
|
|
21
|
+
}
|
|
22
|
+
this.processRepository = processRepository;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Execute get migration status
|
|
27
|
+
*
|
|
28
|
+
* @param {Object} params
|
|
29
|
+
* @param {string} params.processId - Process ID to retrieve
|
|
30
|
+
* @returns {Promise<Object>} Migration status with process details
|
|
31
|
+
* @throws {NotFoundError} If process not found
|
|
32
|
+
* @throws {Error} If process is not a migration process
|
|
33
|
+
*/
|
|
34
|
+
async execute({ processId }) {
|
|
35
|
+
// Validation
|
|
36
|
+
if (!processId) {
|
|
37
|
+
throw new ValidationError('processId is required');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (typeof processId !== 'string') {
|
|
41
|
+
throw new ValidationError('processId must be a string');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Get process from repository
|
|
45
|
+
const process = await this.processRepository.findById(processId);
|
|
46
|
+
|
|
47
|
+
if (!process) {
|
|
48
|
+
throw new NotFoundError(`Migration process not found: ${processId}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Verify this is a migration process
|
|
52
|
+
if (process.type !== 'DATABASE_MIGRATION') {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Process ${processId} is not a migration process (type: ${process.type})`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Format response
|
|
59
|
+
return {
|
|
60
|
+
processId: process.id,
|
|
61
|
+
type: process.type,
|
|
62
|
+
state: process.state,
|
|
63
|
+
context: process.context || {},
|
|
64
|
+
results: process.results || {},
|
|
65
|
+
createdAt: process.createdAt,
|
|
66
|
+
updatedAt: process.updatedAt,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Custom error for validation failures
|
|
73
|
+
*/
|
|
74
|
+
class ValidationError extends Error {
|
|
75
|
+
constructor(message) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.name = 'ValidationError';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Custom error for not found resources
|
|
83
|
+
*/
|
|
84
|
+
class NotFoundError extends Error {
|
|
85
|
+
constructor(message) {
|
|
86
|
+
super(message);
|
|
87
|
+
this.name = 'NotFoundError';
|
|
88
|
+
this.statusCode = 404;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
GetMigrationStatusUseCase,
|
|
94
|
+
ValidationError,
|
|
95
|
+
NotFoundError,
|
|
96
|
+
};
|
|
97
|
+
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for GetMigrationStatusUseCase
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
GetMigrationStatusUseCase,
|
|
7
|
+
ValidationError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
} = require('./get-migration-status-use-case');
|
|
10
|
+
|
|
11
|
+
describe('GetMigrationStatusUseCase', () => {
|
|
12
|
+
let useCase;
|
|
13
|
+
let mockProcessRepository;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
// Create mock repository
|
|
17
|
+
mockProcessRepository = {
|
|
18
|
+
findById: jest.fn(),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Create use case with mock
|
|
22
|
+
useCase = new GetMigrationStatusUseCase({
|
|
23
|
+
processRepository: mockProcessRepository,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
jest.clearAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('constructor', () => {
|
|
32
|
+
it('should throw error if processRepository not provided', () => {
|
|
33
|
+
expect(() => {
|
|
34
|
+
new GetMigrationStatusUseCase({});
|
|
35
|
+
}).toThrow('processRepository dependency is required');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('execute', () => {
|
|
40
|
+
it('should return migration status for COMPLETED process', async () => {
|
|
41
|
+
const mockProcess = {
|
|
42
|
+
id: 'process-123',
|
|
43
|
+
type: 'DATABASE_MIGRATION',
|
|
44
|
+
state: 'COMPLETED',
|
|
45
|
+
context: {
|
|
46
|
+
dbType: 'postgresql',
|
|
47
|
+
stage: 'production',
|
|
48
|
+
migrationCommand: 'migrate deploy',
|
|
49
|
+
},
|
|
50
|
+
results: {
|
|
51
|
+
success: true,
|
|
52
|
+
duration: '2341ms',
|
|
53
|
+
timestamp: '2025-10-18T10:30:00Z',
|
|
54
|
+
},
|
|
55
|
+
createdAt: new Date('2025-10-18T10:29:55Z'),
|
|
56
|
+
updatedAt: new Date('2025-10-18T10:30:02Z'),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
mockProcessRepository.findById.mockResolvedValue(mockProcess);
|
|
60
|
+
|
|
61
|
+
const result = await useCase.execute({ processId: 'process-123' });
|
|
62
|
+
|
|
63
|
+
expect(mockProcessRepository.findById).toHaveBeenCalledWith('process-123');
|
|
64
|
+
expect(result).toEqual({
|
|
65
|
+
processId: 'process-123',
|
|
66
|
+
type: 'DATABASE_MIGRATION',
|
|
67
|
+
state: 'COMPLETED',
|
|
68
|
+
context: mockProcess.context,
|
|
69
|
+
results: mockProcess.results,
|
|
70
|
+
createdAt: mockProcess.createdAt,
|
|
71
|
+
updatedAt: mockProcess.updatedAt,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should return migration status for RUNNING process', async () => {
|
|
76
|
+
const mockProcess = {
|
|
77
|
+
id: 'process-456',
|
|
78
|
+
type: 'DATABASE_MIGRATION',
|
|
79
|
+
state: 'RUNNING',
|
|
80
|
+
context: {
|
|
81
|
+
dbType: 'mongodb',
|
|
82
|
+
stage: 'dev',
|
|
83
|
+
startedAt: '2025-10-18T10:30:00Z',
|
|
84
|
+
},
|
|
85
|
+
results: {},
|
|
86
|
+
createdAt: new Date('2025-10-18T10:29:55Z'),
|
|
87
|
+
updatedAt: new Date('2025-10-18T10:30:00Z'),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
mockProcessRepository.findById.mockResolvedValue(mockProcess);
|
|
91
|
+
|
|
92
|
+
const result = await useCase.execute({ processId: 'process-456' });
|
|
93
|
+
|
|
94
|
+
expect(result.state).toBe('RUNNING');
|
|
95
|
+
expect(result.context.dbType).toBe('mongodb');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should return migration status for FAILED process', async () => {
|
|
99
|
+
const mockProcess = {
|
|
100
|
+
id: 'process-789',
|
|
101
|
+
type: 'DATABASE_MIGRATION',
|
|
102
|
+
state: 'FAILED',
|
|
103
|
+
context: {
|
|
104
|
+
dbType: 'postgresql',
|
|
105
|
+
stage: 'production',
|
|
106
|
+
failedAt: '2025-10-18T10:30:00Z',
|
|
107
|
+
},
|
|
108
|
+
results: {
|
|
109
|
+
error: 'Migration failed: syntax error',
|
|
110
|
+
errorType: 'MigrationError',
|
|
111
|
+
},
|
|
112
|
+
createdAt: new Date('2025-10-18T10:29:55Z'),
|
|
113
|
+
updatedAt: new Date('2025-10-18T10:30:00Z'),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
mockProcessRepository.findById.mockResolvedValue(mockProcess);
|
|
117
|
+
|
|
118
|
+
const result = await useCase.execute({ processId: 'process-789' });
|
|
119
|
+
|
|
120
|
+
expect(result.state).toBe('FAILED');
|
|
121
|
+
expect(result.results.error).toContain('Migration failed');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should handle empty context and results', async () => {
|
|
125
|
+
const mockProcess = {
|
|
126
|
+
id: 'process-999',
|
|
127
|
+
type: 'DATABASE_MIGRATION',
|
|
128
|
+
state: 'INITIALIZING',
|
|
129
|
+
createdAt: new Date(),
|
|
130
|
+
updatedAt: new Date(),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
mockProcessRepository.findById.mockResolvedValue(mockProcess);
|
|
134
|
+
|
|
135
|
+
const result = await useCase.execute({ processId: 'process-999' });
|
|
136
|
+
|
|
137
|
+
expect(result.context).toEqual({});
|
|
138
|
+
expect(result.results).toEqual({});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should throw NotFoundError if process does not exist', async () => {
|
|
142
|
+
mockProcessRepository.findById.mockResolvedValue(null);
|
|
143
|
+
|
|
144
|
+
await expect(
|
|
145
|
+
useCase.execute({ processId: 'nonexistent-123' })
|
|
146
|
+
).rejects.toThrow(NotFoundError);
|
|
147
|
+
|
|
148
|
+
await expect(
|
|
149
|
+
useCase.execute({ processId: 'nonexistent-123' })
|
|
150
|
+
).rejects.toThrow('Migration process not found: nonexistent-123');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should throw error if process is not a migration process', async () => {
|
|
154
|
+
const nonMigrationProcess = {
|
|
155
|
+
id: 'process-999',
|
|
156
|
+
type: 'CRM_SYNC', // Not a migration
|
|
157
|
+
state: 'RUNNING',
|
|
158
|
+
context: {},
|
|
159
|
+
results: {},
|
|
160
|
+
createdAt: new Date(),
|
|
161
|
+
updatedAt: new Date(),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
mockProcessRepository.findById.mockResolvedValue(nonMigrationProcess);
|
|
165
|
+
|
|
166
|
+
await expect(
|
|
167
|
+
useCase.execute({ processId: 'process-999' })
|
|
168
|
+
).rejects.toThrow('Process process-999 is not a migration process (type: CRM_SYNC)');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should throw ValidationError if processId is missing', async () => {
|
|
172
|
+
await expect(
|
|
173
|
+
useCase.execute({})
|
|
174
|
+
).rejects.toThrow(ValidationError);
|
|
175
|
+
|
|
176
|
+
await expect(
|
|
177
|
+
useCase.execute({})
|
|
178
|
+
).rejects.toThrow('processId is required');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should throw ValidationError if processId is not a string', async () => {
|
|
182
|
+
await expect(
|
|
183
|
+
useCase.execute({ processId: 123 })
|
|
184
|
+
).rejects.toThrow('processId must be a string');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should handle repository errors', async () => {
|
|
188
|
+
mockProcessRepository.findById.mockRejectedValue(new Error('Database connection failed'));
|
|
189
|
+
|
|
190
|
+
await expect(
|
|
191
|
+
useCase.execute({ processId: 'process-123' })
|
|
192
|
+
).rejects.toThrow('Database connection failed');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('NotFoundError', () => {
|
|
197
|
+
it('should have correct properties', () => {
|
|
198
|
+
const error = new NotFoundError('test message');
|
|
199
|
+
expect(error.name).toBe('NotFoundError');
|
|
200
|
+
expect(error.message).toBe('test message');
|
|
201
|
+
expect(error.statusCode).toBe(404);
|
|
202
|
+
expect(error instanceof Error).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('ValidationError', () => {
|
|
207
|
+
it('should have correct name', () => {
|
|
208
|
+
const error = new ValidationError('test message');
|
|
209
|
+
expect(error.name).toBe('ValidationError');
|
|
210
|
+
expect(error.message).toBe('test message');
|
|
211
|
+
expect(error instanceof Error).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trigger Database Migration Use Case
|
|
3
|
+
*
|
|
4
|
+
* Business logic for triggering async database migrations via SQS queue.
|
|
5
|
+
* Creates a Process record for tracking and sends migration job to queue.
|
|
6
|
+
*
|
|
7
|
+
* This use case follows the Frigg hexagonal architecture pattern where:
|
|
8
|
+
* - Routers (adapters) call use cases
|
|
9
|
+
* - Use cases contain business logic and orchestration
|
|
10
|
+
* - Use cases call repositories for data access
|
|
11
|
+
* - Use cases delegate infrastructure concerns (SQS) to utilities
|
|
12
|
+
*
|
|
13
|
+
* Flow:
|
|
14
|
+
* 1. Validate migration parameters
|
|
15
|
+
* 2. Create Process record (state: INITIALIZING)
|
|
16
|
+
* 3. Send message to SQS queue (fire-and-forget)
|
|
17
|
+
* 4. Return process info immediately (async pattern)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { QueuerUtil } = require('../../queues/queuer-util');
|
|
21
|
+
|
|
22
|
+
class TriggerDatabaseMigrationUseCase {
|
|
23
|
+
/**
|
|
24
|
+
* @param {Object} dependencies
|
|
25
|
+
* @param {Object} dependencies.processRepository - Repository for process data access
|
|
26
|
+
* @param {Object} [dependencies.queuerUtil] - SQS utility (injectable for testing)
|
|
27
|
+
*/
|
|
28
|
+
constructor({ processRepository, queuerUtil = QueuerUtil }) {
|
|
29
|
+
if (!processRepository) {
|
|
30
|
+
throw new Error('processRepository dependency is required');
|
|
31
|
+
}
|
|
32
|
+
this.processRepository = processRepository;
|
|
33
|
+
this.queuerUtil = queuerUtil;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Execute database migration trigger
|
|
38
|
+
*
|
|
39
|
+
* @param {Object} params
|
|
40
|
+
* @param {string} params.userId - User ID triggering the migration
|
|
41
|
+
* @param {string} params.dbType - Database type ('postgresql' or 'mongodb')
|
|
42
|
+
* @param {string} params.stage - Deployment stage (determines migration command)
|
|
43
|
+
* @returns {Promise<Object>} Process info { success, processId, state, statusUrl, message }
|
|
44
|
+
* @throws {ValidationError} If parameters are invalid
|
|
45
|
+
* @throws {Error} If process creation or queue send fails
|
|
46
|
+
*/
|
|
47
|
+
async execute({ userId, dbType, stage }) {
|
|
48
|
+
// Validation
|
|
49
|
+
this._validateParams({ userId, dbType, stage });
|
|
50
|
+
|
|
51
|
+
// Create Process record for tracking
|
|
52
|
+
const migrationProcess = await this.processRepository.create({
|
|
53
|
+
userId,
|
|
54
|
+
integrationId: null, // System operation, not tied to integration
|
|
55
|
+
name: 'database-migration',
|
|
56
|
+
type: 'DATABASE_MIGRATION',
|
|
57
|
+
state: 'INITIALIZING',
|
|
58
|
+
context: {
|
|
59
|
+
dbType,
|
|
60
|
+
stage,
|
|
61
|
+
triggeredAt: new Date().toISOString(),
|
|
62
|
+
},
|
|
63
|
+
results: {},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
console.log(`Created migration process: ${migrationProcess.id}`);
|
|
67
|
+
|
|
68
|
+
// Get queue URL from environment
|
|
69
|
+
const queueUrl = process.env.DB_MIGRATION_QUEUE_URL;
|
|
70
|
+
if (!queueUrl) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
'DB_MIGRATION_QUEUE_URL environment variable is not set. ' +
|
|
73
|
+
'Cannot send migration to queue.'
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Send message to SQS queue (async fire-and-forget)
|
|
78
|
+
try {
|
|
79
|
+
await this.queuerUtil.send(
|
|
80
|
+
{
|
|
81
|
+
processId: migrationProcess.id,
|
|
82
|
+
dbType,
|
|
83
|
+
stage,
|
|
84
|
+
},
|
|
85
|
+
queueUrl
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
console.log(`Sent migration job to queue for process: ${migrationProcess.id}`);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error(`Failed to send migration to queue:`, error);
|
|
91
|
+
|
|
92
|
+
// Update process state to FAILED
|
|
93
|
+
await this.processRepository.updateState(
|
|
94
|
+
migrationProcess.id,
|
|
95
|
+
'FAILED',
|
|
96
|
+
{
|
|
97
|
+
error: 'Failed to queue migration job',
|
|
98
|
+
errorDetails: error.message,
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Failed to queue migration: ${error.message}`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Return process info immediately (don't wait for migration completion)
|
|
108
|
+
return {
|
|
109
|
+
success: true,
|
|
110
|
+
processId: migrationProcess.id,
|
|
111
|
+
state: migrationProcess.state,
|
|
112
|
+
statusUrl: `/db-migrate/${migrationProcess.id}`,
|
|
113
|
+
message: 'Database migration queued successfully',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Validate execution parameters
|
|
119
|
+
* @private
|
|
120
|
+
*/
|
|
121
|
+
_validateParams({ userId, dbType, stage }) {
|
|
122
|
+
if (!userId) {
|
|
123
|
+
throw new ValidationError('userId is required');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (typeof userId !== 'string') {
|
|
127
|
+
throw new ValidationError('userId must be a string');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!dbType) {
|
|
131
|
+
throw new ValidationError('dbType is required');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (typeof dbType !== 'string') {
|
|
135
|
+
throw new ValidationError('dbType must be a string');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const validDbTypes = ['postgresql', 'mongodb'];
|
|
139
|
+
if (!validDbTypes.includes(dbType)) {
|
|
140
|
+
throw new ValidationError(
|
|
141
|
+
`Invalid dbType: "${dbType}". Must be one of: ${validDbTypes.join(', ')}`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!stage) {
|
|
146
|
+
throw new ValidationError('stage is required');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (typeof stage !== 'string') {
|
|
150
|
+
throw new ValidationError('stage must be a string');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Custom error for validation failures
|
|
157
|
+
*/
|
|
158
|
+
class ValidationError extends Error {
|
|
159
|
+
constructor(message) {
|
|
160
|
+
super(message);
|
|
161
|
+
this.name = 'ValidationError';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = {
|
|
166
|
+
TriggerDatabaseMigrationUseCase,
|
|
167
|
+
ValidationError,
|
|
168
|
+
};
|
|
169
|
+
|