@friggframework/core 2.0.0--canary.461.a0527a3.0 → 2.0.0--canary.461.4b9ba0b.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.
@@ -18,7 +18,7 @@ function getDatabaseType() {
18
18
  if (process.env.DB_TYPE) {
19
19
  return process.env.DB_TYPE;
20
20
  }
21
-
21
+
22
22
  // Fallback: Load app definition
23
23
  try {
24
24
  const path = require('node:path');
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Migration Status Repository - S3 Storage
3
+ *
4
+ * Infrastructure Layer - Hexagonal Architecture
5
+ *
6
+ * Stores migration status in S3 to avoid chicken-and-egg dependency on User/Process tables.
7
+ * Initial database migrations can't use Process table (requires User FK which doesn't exist yet).
8
+ */
9
+
10
+ const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
11
+ const { randomUUID } = require('crypto');
12
+
13
+ class MigrationStatusRepositoryS3 {
14
+ /**
15
+ * @param {string} bucketName - S3 bucket name for migration status storage
16
+ * @param {S3Client} s3Client - Optional S3 client (for testing)
17
+ */
18
+ constructor(bucketName, s3Client = null) {
19
+ this.bucketName = bucketName;
20
+ this.s3Client = s3Client || new S3Client({ region: process.env.AWS_REGION || 'us-east-1' });
21
+ }
22
+
23
+ /**
24
+ * Build S3 key for migration status
25
+ * @param {string} migrationId - Migration identifier
26
+ * @param {string} stage - Deployment stage
27
+ * @returns {string} S3 key
28
+ */
29
+ _buildS3Key(migrationId, stage) {
30
+ return `migrations/${stage}/${migrationId}.json`;
31
+ }
32
+
33
+ /**
34
+ * Create new migration status record
35
+ * @param {Object} data - Migration data
36
+ * @param {string} [data.migrationId] - Migration ID (generates UUID if not provided)
37
+ * @param {string} data.stage - Deployment stage
38
+ * @param {string} [data.triggeredBy] - User or system that triggered migration
39
+ * @param {string} [data.triggeredAt] - ISO timestamp
40
+ * @returns {Promise<Object>} Created migration status
41
+ */
42
+ async create(data) {
43
+ const migrationId = data.migrationId || randomUUID();
44
+ const timestamp = data.triggeredAt || new Date().toISOString();
45
+
46
+ const status = {
47
+ migrationId,
48
+ stage: data.stage,
49
+ state: 'INITIALIZING',
50
+ progress: 0,
51
+ triggeredBy: data.triggeredBy || 'system',
52
+ triggeredAt: timestamp,
53
+ createdAt: timestamp,
54
+ updatedAt: timestamp,
55
+ };
56
+
57
+ const key = this._buildS3Key(migrationId, data.stage);
58
+
59
+ await this.s3Client.send(
60
+ new PutObjectCommand({
61
+ Bucket: this.bucketName,
62
+ Key: key,
63
+ Body: JSON.stringify(status, null, 2),
64
+ ContentType: 'application/json',
65
+ })
66
+ );
67
+
68
+ return status;
69
+ }
70
+
71
+ /**
72
+ * Update existing migration status
73
+ * @param {Object} data - Update data
74
+ * @param {string} data.migrationId - Migration ID
75
+ * @param {string} data.stage - Deployment stage
76
+ * @param {string} [data.state] - New state
77
+ * @param {number} [data.progress] - Progress percentage (0-100)
78
+ * @param {string} [data.error] - Error message if failed
79
+ * @param {string} [data.completedAt] - Completion timestamp
80
+ * @returns {Promise<Object>} Updated migration status
81
+ */
82
+ async update(data) {
83
+ const key = this._buildS3Key(data.migrationId, data.stage);
84
+
85
+ // Get existing status
86
+ const existing = await this.get(data.migrationId, data.stage);
87
+
88
+ // Merge updates
89
+ const updated = {
90
+ ...existing,
91
+ ...data,
92
+ updatedAt: new Date().toISOString(),
93
+ };
94
+
95
+ await this.s3Client.send(
96
+ new PutObjectCommand({
97
+ Bucket: this.bucketName,
98
+ Key: key,
99
+ Body: JSON.stringify(updated, null, 2),
100
+ ContentType: 'application/json',
101
+ })
102
+ );
103
+
104
+ return updated;
105
+ }
106
+
107
+ /**
108
+ * Get migration status by ID
109
+ * @param {string} migrationId - Migration ID
110
+ * @param {string} stage - Deployment stage
111
+ * @returns {Promise<Object>} Migration status
112
+ * @throws {Error} If migration not found
113
+ */
114
+ async get(migrationId, stage) {
115
+ const key = this._buildS3Key(migrationId, stage);
116
+
117
+ try {
118
+ const response = await this.s3Client.send(
119
+ new GetObjectCommand({
120
+ Bucket: this.bucketName,
121
+ Key: key,
122
+ })
123
+ );
124
+
125
+ const body = await response.Body.transformToString();
126
+ return JSON.parse(body);
127
+ } catch (error) {
128
+ if (error.name === 'NoSuchKey') {
129
+ throw new Error(`Migration not found: ${migrationId}`);
130
+ }
131
+ throw error;
132
+ }
133
+ }
134
+ }
135
+
136
+ module.exports = { MigrationStatusRepositoryS3 };
137
+
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Tests for Migration Status Repository (S3)
3
+ *
4
+ * Tests S3-based storage for migration status tracking
5
+ * (avoids chicken-and-egg dependency on User/Process tables)
6
+ */
7
+
8
+ const { MigrationStatusRepositoryS3 } = require('./migration-status-repository-s3');
9
+
10
+ describe('MigrationStatusRepositoryS3', () => {
11
+ let repository;
12
+ let mockS3Client;
13
+
14
+ beforeEach(() => {
15
+ mockS3Client = {
16
+ send: jest.fn(),
17
+ };
18
+ repository = new MigrationStatusRepositoryS3('test-bucket', mockS3Client);
19
+ });
20
+
21
+ describe('create()', () => {
22
+ it('should create new migration status record in S3', async () => {
23
+ const migrationData = {
24
+ migrationId: 'migration-123',
25
+ stage: 'dev',
26
+ triggeredBy: 'admin',
27
+ triggeredAt: '2025-10-19T12:00:00Z',
28
+ };
29
+
30
+ mockS3Client.send.mockResolvedValue({});
31
+
32
+ const result = await repository.create(migrationData);
33
+
34
+ expect(result.migrationId).toBe('migration-123');
35
+ expect(result.state).toBe('INITIALIZING');
36
+ expect(mockS3Client.send).toHaveBeenCalled();
37
+ });
38
+
39
+ it('should generate UUID if migrationId not provided', async () => {
40
+ const migrationData = {
41
+ stage: 'dev',
42
+ triggeredBy: 'admin',
43
+ triggeredAt: '2025-10-19T12:00:00Z',
44
+ };
45
+
46
+ mockS3Client.send.mockResolvedValue({});
47
+
48
+ const result = await repository.create(migrationData);
49
+
50
+ expect(result.migrationId).toMatch(/^[a-f0-9-]{36}$/); // UUID format
51
+ expect(result.state).toBe('INITIALIZING');
52
+ });
53
+
54
+ it('should store status at correct S3 key', async () => {
55
+ const migrationData = {
56
+ migrationId: 'migration-123',
57
+ stage: 'dev',
58
+ };
59
+
60
+ mockS3Client.send.mockResolvedValue({});
61
+
62
+ await repository.create(migrationData);
63
+
64
+ const putCommand = mockS3Client.send.mock.calls[0][0];
65
+ expect(putCommand.input.Bucket).toBe('test-bucket');
66
+ expect(putCommand.input.Key).toBe('migrations/dev/migration-123.json');
67
+ });
68
+ });
69
+
70
+ describe('update()', () => {
71
+ it('should update existing migration status', async () => {
72
+ mockS3Client.send.mockResolvedValue({
73
+ Body: {
74
+ transformToString: () => JSON.stringify({
75
+ migrationId: 'migration-123',
76
+ state: 'INITIALIZING',
77
+ progress: 0,
78
+ }),
79
+ },
80
+ });
81
+
82
+ const updateData = {
83
+ migrationId: 'migration-123',
84
+ stage: 'dev',
85
+ state: 'RUNNING',
86
+ progress: 50,
87
+ };
88
+
89
+ await repository.update(updateData);
90
+
91
+ expect(mockS3Client.send).toHaveBeenCalledTimes(2); // GET then PUT
92
+ });
93
+
94
+ it('should merge updates with existing data', async () => {
95
+ mockS3Client.send
96
+ .mockResolvedValueOnce({
97
+ Body: {
98
+ transformToString: () => JSON.stringify({
99
+ migrationId: 'migration-123',
100
+ state: 'INITIALIZING',
101
+ progress: 0,
102
+ triggeredAt: '2025-10-19T12:00:00Z',
103
+ }),
104
+ },
105
+ })
106
+ .mockResolvedValueOnce({});
107
+
108
+ await repository.update({
109
+ migrationId: 'migration-123',
110
+ stage: 'dev',
111
+ state: 'COMPLETED',
112
+ progress: 100,
113
+ });
114
+
115
+ const putCommand = mockS3Client.send.mock.calls[1][0];
116
+ const storedData = JSON.parse(putCommand.input.Body);
117
+ expect(storedData.triggeredAt).toBe('2025-10-19T12:00:00Z'); // Preserved
118
+ expect(storedData.state).toBe('COMPLETED'); // Updated
119
+ });
120
+ });
121
+
122
+ describe('get()', () => {
123
+ it('should retrieve migration status from S3', async () => {
124
+ const statusData = {
125
+ migrationId: 'migration-123',
126
+ state: 'COMPLETED',
127
+ progress: 100,
128
+ };
129
+
130
+ mockS3Client.send.mockResolvedValue({
131
+ Body: {
132
+ transformToString: () => JSON.stringify(statusData),
133
+ },
134
+ });
135
+
136
+ const result = await repository.get('migration-123', 'dev');
137
+
138
+ expect(result).toEqual(statusData);
139
+ expect(mockS3Client.send).toHaveBeenCalled();
140
+ });
141
+
142
+ it('should throw error if migration not found', async () => {
143
+ mockS3Client.send.mockRejectedValue({ name: 'NoSuchKey' });
144
+
145
+ await expect(repository.get('nonexistent', 'dev')).rejects.toThrow(
146
+ 'Migration not found'
147
+ );
148
+ });
149
+ });
150
+
151
+ describe('S3 Key Generation', () => {
152
+ it('should use consistent key format', () => {
153
+ const key = repository._buildS3Key('migration-123', 'production');
154
+ expect(key).toBe('migrations/production/migration-123.json');
155
+ });
156
+ });
157
+ });
158
+
@@ -13,58 +13,54 @@
13
13
  class GetMigrationStatusUseCase {
14
14
  /**
15
15
  * @param {Object} dependencies
16
- * @param {Object} dependencies.processRepository - Repository for process data access
16
+ * @param {Object} dependencies.migrationStatusRepository - Repository for migration status (S3)
17
17
  */
18
- constructor({ processRepository }) {
19
- if (!processRepository) {
20
- throw new Error('processRepository dependency is required');
18
+ constructor({ migrationStatusRepository }) {
19
+ if (!migrationStatusRepository) {
20
+ throw new Error('migrationStatusRepository dependency is required');
21
21
  }
22
- this.processRepository = processRepository;
22
+ this.migrationStatusRepository = migrationStatusRepository;
23
23
  }
24
24
 
25
25
  /**
26
26
  * Execute get migration status
27
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
28
+ * @param {string} migrationId - Migration ID to retrieve
29
+ * @param {string} [stage] - Deployment stage (defaults to env.STAGE)
30
+ * @returns {Promise<Object>} Migration status from S3
31
+ * @throws {NotFoundError} If migration not found
32
+ * @throws {ValidationError} If migrationId is invalid
33
33
  */
34
- async execute({ processId }) {
34
+ async execute(migrationId, stage = null) {
35
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
- }
36
+ this._validateParams(migrationId);
43
37
 
44
- // Get process from repository
45
- const process = await this.processRepository.findById(processId);
38
+ const effectiveStage = stage || process.env.STAGE || 'production';
46
39
 
47
- if (!process) {
48
- throw new NotFoundError(`Migration process not found: ${processId}`);
40
+ // Get migration status from S3
41
+ try {
42
+ const migrationStatus = await this.migrationStatusRepository.get(migrationId, effectiveStage);
43
+ return migrationStatus;
44
+ } catch (error) {
45
+ if (error.message.includes('not found')) {
46
+ throw new NotFoundError(`Migration not found: ${migrationId}`);
47
+ }
48
+ throw error;
49
49
  }
50
+ }
50
51
 
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
- );
52
+ /**
53
+ * Validate parameters
54
+ * @private
55
+ */
56
+ _validateParams(migrationId) {
57
+ if (!migrationId) {
58
+ throw new ValidationError('migrationId is required');
56
59
  }
57
60
 
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
- };
61
+ if (typeof migrationId !== 'string') {
62
+ throw new ValidationError('migrationId must be a string');
63
+ }
68
64
  }
69
65
  }
70
66
 
@@ -10,17 +10,17 @@ const {
10
10
 
11
11
  describe('GetMigrationStatusUseCase', () => {
12
12
  let useCase;
13
- let mockProcessRepository;
13
+ let mockMigrationStatusRepository;
14
14
 
15
15
  beforeEach(() => {
16
16
  // Create mock repository
17
- mockProcessRepository = {
18
- findById: jest.fn(),
17
+ mockMigrationStatusRepository = {
18
+ get: jest.fn(),
19
19
  };
20
20
 
21
21
  // Create use case with mock
22
22
  useCase = new GetMigrationStatusUseCase({
23
- processRepository: mockProcessRepository,
23
+ migrationStatusRepository: mockMigrationStatusRepository,
24
24
  });
25
25
  });
26
26
 
@@ -29,10 +29,10 @@ describe('GetMigrationStatusUseCase', () => {
29
29
  });
30
30
 
31
31
  describe('constructor', () => {
32
- it('should throw error if processRepository not provided', () => {
32
+ it('should throw error if migrationStatusRepository not provided', () => {
33
33
  expect(() => {
34
34
  new GetMigrationStatusUseCase({});
35
- }).toThrow('processRepository dependency is required');
35
+ }).toThrow('migrationStatusRepository dependency is required');
36
36
  });
37
37
  });
38
38
 
@@ -56,25 +56,17 @@ describe('GetMigrationStatusUseCase', () => {
56
56
  updatedAt: new Date('2025-10-18T10:30:02Z'),
57
57
  };
58
58
 
59
- mockProcessRepository.findById.mockResolvedValue(mockProcess);
59
+ mockMigrationStatusRepository.get.mockResolvedValue(mockProcess);
60
60
 
61
- const result = await useCase.execute({ processId: 'process-123' });
61
+ const result = await useCase.execute('migration-123', 'production');
62
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
- });
63
+ expect(mockMigrationStatusRepository.get).toHaveBeenCalledWith('migration-123', 'production');
64
+ expect(result).toEqual(mockProcess); // S3 repository returns full status object
73
65
  });
74
66
 
75
- it('should return migration status for RUNNING process', async () => {
67
+ it('should return migration status for RUNNING migration', async () => {
76
68
  const mockProcess = {
77
- id: 'process-456',
69
+ migrationId: 'migration-456',
78
70
  type: 'DATABASE_MIGRATION',
79
71
  state: 'RUNNING',
80
72
  context: {
@@ -87,109 +79,73 @@ describe('GetMigrationStatusUseCase', () => {
87
79
  updatedAt: new Date('2025-10-18T10:30:00Z'),
88
80
  };
89
81
 
90
- mockProcessRepository.findById.mockResolvedValue(mockProcess);
82
+ mockMigrationStatusRepository.get.mockResolvedValue(mockProcess);
91
83
 
92
- const result = await useCase.execute({ processId: 'process-456' });
84
+ const result = await useCase.execute('migration-456', 'dev');
93
85
 
94
86
  expect(result.state).toBe('RUNNING');
95
87
  expect(result.context.dbType).toBe('mongodb');
96
88
  });
97
89
 
98
- it('should return migration status for FAILED process', async () => {
99
- const mockProcess = {
100
- id: 'process-789',
101
- type: 'DATABASE_MIGRATION',
90
+ it('should return migration status for FAILED migration', async () => {
91
+ const mockStatus = {
92
+ migrationId: 'migration-789',
93
+ stage: 'production',
102
94
  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'),
95
+ progress: 0,
96
+ error: 'Migration failed: syntax error',
97
+ triggeredBy: 'admin',
98
+ triggeredAt: '2025-10-18T10:29:55Z',
99
+ completedAt: '2025-10-18T10:30:00Z',
114
100
  };
115
101
 
116
- mockProcessRepository.findById.mockResolvedValue(mockProcess);
102
+ mockMigrationStatusRepository.get.mockResolvedValue(mockStatus);
117
103
 
118
- const result = await useCase.execute({ processId: 'process-789' });
104
+ const result = await useCase.execute('migration-789', 'production');
119
105
 
106
+ expect(mockMigrationStatusRepository.get).toHaveBeenCalledWith('migration-789', 'production');
120
107
  expect(result.state).toBe('FAILED');
121
- expect(result.results.error).toContain('Migration failed');
108
+ expect(result.error).toContain('Migration failed');
122
109
  });
123
110
 
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);
111
+ // Removed - already covered by "should return minimal migration status"
134
112
 
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);
113
+ it('should throw NotFoundError if migration does not exist', async () => {
114
+ mockMigrationStatusRepository.get.mockRejectedValue(new Error('Migration not found: nonexistent-123'));
143
115
 
144
116
  await expect(
145
- useCase.execute({ processId: 'nonexistent-123' })
117
+ useCase.execute('nonexistent-123', 'dev')
146
118
  ).rejects.toThrow(NotFoundError);
147
119
 
148
120
  await expect(
149
- useCase.execute({ processId: 'nonexistent-123' })
150
- ).rejects.toThrow('Migration process not found: nonexistent-123');
121
+ useCase.execute('nonexistent-123', 'dev')
122
+ ).rejects.toThrow('Migration not found');
151
123
  });
152
124
 
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
- });
125
+ // Removed: S3 repository only stores migrations, no type validation needed
170
126
 
171
- it('should throw ValidationError if processId is missing', async () => {
127
+ it('should throw ValidationError if migrationId is missing', async () => {
172
128
  await expect(
173
- useCase.execute({})
129
+ useCase.execute(null)
174
130
  ).rejects.toThrow(ValidationError);
175
131
 
176
132
  await expect(
177
- useCase.execute({})
178
- ).rejects.toThrow('processId is required');
133
+ useCase.execute(undefined)
134
+ ).rejects.toThrow('migrationId is required');
179
135
  });
180
136
 
181
- it('should throw ValidationError if processId is not a string', async () => {
137
+ it('should throw ValidationError if migrationId is not a string', async () => {
182
138
  await expect(
183
- useCase.execute({ processId: 123 })
184
- ).rejects.toThrow('processId must be a string');
139
+ useCase.execute(123)
140
+ ).rejects.toThrow('migrationId must be a string');
185
141
  });
186
142
 
187
143
  it('should handle repository errors', async () => {
188
- mockProcessRepository.findById.mockRejectedValue(new Error('Database connection failed'));
144
+ mockMigrationStatusRepository.get.mockRejectedValue(new Error('S3 connection failed'));
189
145
 
190
146
  await expect(
191
- useCase.execute({ processId: 'process-123' })
192
- ).rejects.toThrow('Database connection failed');
147
+ useCase.execute('migration-123', 'dev')
148
+ ).rejects.toThrow('S3 connection failed');
193
149
  });
194
150
  });
195
151
 
@@ -22,14 +22,14 @@ const { QueuerUtil } = require('../../queues/queuer-util');
22
22
  class TriggerDatabaseMigrationUseCase {
23
23
  /**
24
24
  * @param {Object} dependencies
25
- * @param {Object} dependencies.processRepository - Repository for process data access
25
+ * @param {Object} dependencies.migrationStatusRepository - Repository for migration status (S3)
26
26
  * @param {Object} [dependencies.queuerUtil] - SQS utility (injectable for testing)
27
27
  */
28
- constructor({ processRepository, queuerUtil = QueuerUtil }) {
29
- if (!processRepository) {
30
- throw new Error('processRepository dependency is required');
28
+ constructor({ migrationStatusRepository, queuerUtil = QueuerUtil }) {
29
+ if (!migrationStatusRepository) {
30
+ throw new Error('migrationStatusRepository dependency is required');
31
31
  }
32
- this.processRepository = processRepository;
32
+ this.migrationStatusRepository = migrationStatusRepository;
33
33
  this.queuerUtil = queuerUtil;
34
34
  }
35
35
 
@@ -48,22 +48,14 @@ class TriggerDatabaseMigrationUseCase {
48
48
  // Validation
49
49
  this._validateParams({ userId, dbType, stage });
50
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: {},
51
+ // Create migration status in S3 (no User table dependency)
52
+ const migrationStatus = await this.migrationStatusRepository.create({
53
+ stage: stage || process.env.STAGE || 'production',
54
+ triggeredBy: userId || 'system',
55
+ triggeredAt: new Date().toISOString(),
64
56
  });
65
57
 
66
- console.log(`Created migration process: ${migrationProcess.id}`);
58
+ console.log(`Created migration status: ${migrationStatus.migrationId}`);
67
59
 
68
60
  // Get queue URL from environment
69
61
  const queueUrl = process.env.DB_MIGRATION_QUEUE_URL;
@@ -78,38 +70,37 @@ class TriggerDatabaseMigrationUseCase {
78
70
  try {
79
71
  await this.queuerUtil.send(
80
72
  {
81
- processId: migrationProcess.id,
73
+ migrationId: migrationStatus.migrationId,
82
74
  dbType,
83
75
  stage,
84
76
  },
85
77
  queueUrl
86
78
  );
87
79
 
88
- console.log(`Sent migration job to queue for process: ${migrationProcess.id}`);
80
+ console.log(`Sent migration job to queue: ${migrationStatus.migrationId}`);
89
81
  } catch (error) {
90
82
  console.error(`Failed to send migration to queue:`, error);
91
83
 
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
- );
84
+ // Update migration status to FAILED
85
+ await this.migrationStatusRepository.update({
86
+ migrationId: migrationStatus.migrationId,
87
+ stage: migrationStatus.stage,
88
+ state: 'FAILED',
89
+ error: `Failed to queue migration: ${error.message}`,
90
+ });
101
91
 
102
92
  throw new Error(
103
93
  `Failed to queue migration: ${error.message}`
104
94
  );
105
95
  }
106
96
 
107
- // Return process info immediately (don't wait for migration completion)
97
+ // Return migration info immediately (don't wait for migration completion)
108
98
  return {
109
99
  success: true,
110
- processId: migrationProcess.id,
111
- state: migrationProcess.state,
112
- statusUrl: `/db-migrate/${migrationProcess.id}`,
100
+ migrationId: migrationStatus.migrationId,
101
+ state: migrationStatus.state,
102
+ statusUrl: `/db-migrate/${migrationStatus.migrationId}`,
103
+ s3Key: `migrations/${migrationStatus.stage}/${migrationStatus.migrationId}.json`,
113
104
  message: 'Database migration queued successfully',
114
105
  };
115
106
  }
@@ -119,11 +110,8 @@ class TriggerDatabaseMigrationUseCase {
119
110
  * @private
120
111
  */
121
112
  _validateParams({ userId, dbType, stage }) {
122
- if (!userId) {
123
- throw new ValidationError('userId is required');
124
- }
125
-
126
- if (typeof userId !== 'string') {
113
+ // userId is optional for system migrations
114
+ if (userId && typeof userId !== 'string') {
127
115
  throw new ValidationError('userId must be a string');
128
116
  }
129
117
 
@@ -9,7 +9,7 @@ const {
9
9
 
10
10
  describe('TriggerDatabaseMigrationUseCase', () => {
11
11
  let useCase;
12
- let mockProcessRepository;
12
+ let mockMigrationStatusRepository;
13
13
  let mockQueuerUtil;
14
14
  let originalEnv;
15
15
 
@@ -21,23 +21,18 @@ describe('TriggerDatabaseMigrationUseCase', () => {
21
21
  process.env.DB_MIGRATION_QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue';
22
22
 
23
23
  // Create mock repository
24
- mockProcessRepository = {
24
+ mockMigrationStatusRepository = {
25
25
  create: jest.fn().mockResolvedValue({
26
- id: 'process-123',
27
- userId: 'user-456',
28
- integrationId: null,
29
- name: 'database-migration',
30
- type: 'DATABASE_MIGRATION',
26
+ migrationId: 'migration-123',
27
+ stage: 'production',
31
28
  state: 'INITIALIZING',
32
- context: {
33
- dbType: 'postgresql',
34
- stage: 'production',
35
- },
36
- results: {},
37
- createdAt: new Date(),
38
- updatedAt: new Date(),
29
+ progress: 0,
30
+ triggeredBy: 'user-456',
31
+ triggeredAt: new Date().toISOString(),
32
+ createdAt: new Date().toISOString(),
33
+ updatedAt: new Date().toISOString(),
39
34
  }),
40
- updateState: jest.fn().mockResolvedValue(true),
35
+ update: jest.fn().mockResolvedValue(true),
41
36
  };
42
37
 
43
38
  // Create mock queuer util
@@ -47,7 +42,7 @@ describe('TriggerDatabaseMigrationUseCase', () => {
47
42
 
48
43
  // Create use case with mocks
49
44
  useCase = new TriggerDatabaseMigrationUseCase({
50
- processRepository: mockProcessRepository,
45
+ migrationStatusRepository: mockMigrationStatusRepository,
51
46
  queuerUtil: mockQueuerUtil,
52
47
  });
53
48
  });
@@ -63,16 +58,16 @@ describe('TriggerDatabaseMigrationUseCase', () => {
63
58
  });
64
59
 
65
60
  describe('constructor', () => {
66
- it('should throw error if processRepository not provided', () => {
61
+ it('should throw error if migrationStatusRepository not provided', () => {
67
62
  expect(() => {
68
63
  new TriggerDatabaseMigrationUseCase({});
69
- }).toThrow('processRepository dependency is required');
64
+ }).toThrow('migrationStatusRepository dependency is required');
70
65
  });
71
66
 
72
67
  it('should accept custom queuerUtil', () => {
73
68
  const customQueuer = { send: jest.fn() };
74
69
  const instance = new TriggerDatabaseMigrationUseCase({
75
- processRepository: mockProcessRepository,
70
+ migrationStatusRepository: mockMigrationStatusRepository,
76
71
  queuerUtil: customQueuer,
77
72
  });
78
73
 
@@ -88,24 +83,17 @@ describe('TriggerDatabaseMigrationUseCase', () => {
88
83
  stage: 'production',
89
84
  });
90
85
 
91
- // Verify process creation
92
- expect(mockProcessRepository.create).toHaveBeenCalledWith({
93
- userId: 'user-456',
94
- integrationId: null,
95
- name: 'database-migration',
96
- type: 'DATABASE_MIGRATION',
97
- state: 'INITIALIZING',
98
- context: expect.objectContaining({
99
- dbType: 'postgresql',
100
- stage: 'production',
101
- }),
102
- results: {},
86
+ // Verify migration status creation (S3 repository interface)
87
+ expect(mockMigrationStatusRepository.create).toHaveBeenCalledWith({
88
+ stage: 'production',
89
+ triggeredBy: 'user-456',
90
+ triggeredAt: expect.any(String),
103
91
  });
104
92
 
105
93
  // Verify SQS message sent
106
94
  expect(mockQueuerUtil.send).toHaveBeenCalledWith(
107
95
  {
108
- processId: 'process-123',
96
+ migrationId: 'migration-123',
109
97
  dbType: 'postgresql',
110
98
  stage: 'production',
111
99
  },
@@ -115,9 +103,10 @@ describe('TriggerDatabaseMigrationUseCase', () => {
115
103
  // Verify response
116
104
  expect(result).toEqual({
117
105
  success: true,
118
- processId: 'process-123',
106
+ migrationId: 'migration-123',
119
107
  state: 'INITIALIZING',
120
- statusUrl: '/db-migrate/process-123',
108
+ statusUrl: '/db-migrate/migration-123',
109
+ s3Key: expect.stringContaining('migrations/'),
121
110
  message: 'Database migration queued successfully',
122
111
  });
123
112
  });
@@ -129,30 +118,27 @@ describe('TriggerDatabaseMigrationUseCase', () => {
129
118
  stage: 'dev',
130
119
  });
131
120
 
132
- expect(mockProcessRepository.create).toHaveBeenCalledWith(
133
- expect.objectContaining({
134
- context: expect.objectContaining({
135
- dbType: 'mongodb',
136
- stage: 'dev',
137
- }),
138
- })
139
- );
121
+ expect(mockMigrationStatusRepository.create).toHaveBeenCalledWith({
122
+ stage: 'dev',
123
+ triggeredBy: 'user-456',
124
+ triggeredAt: expect.any(String),
125
+ });
140
126
  });
141
127
 
142
- it('should throw ValidationError if userId is missing', async () => {
143
- await expect(
144
- useCase.execute({
145
- dbType: 'postgresql',
146
- stage: 'production',
147
- })
148
- ).rejects.toThrow(ValidationError);
128
+ it('should allow userId to be omitted (system migrations)', async () => {
129
+ const result = await useCase.execute({
130
+ dbType: 'postgresql',
131
+ stage: 'production',
132
+ });
149
133
 
150
- await expect(
151
- useCase.execute({
152
- dbType: 'postgresql',
153
- stage: 'production',
154
- })
155
- ).rejects.toThrow('userId is required');
134
+ // Should use 'system' as triggeredBy when userId not provided
135
+ expect(mockMigrationStatusRepository.create).toHaveBeenCalledWith({
136
+ stage: 'production',
137
+ triggeredBy: 'system',
138
+ triggeredAt: expect.any(String),
139
+ });
140
+
141
+ expect(result.success).toBe(true);
156
142
  });
157
143
 
158
144
  it('should throw ValidationError if userId is not a string', async () => {
@@ -226,19 +212,18 @@ describe('TriggerDatabaseMigrationUseCase', () => {
226
212
  })
227
213
  ).rejects.toThrow('Failed to queue migration: SQS unavailable');
228
214
 
229
- // Verify process was marked as failed
230
- expect(mockProcessRepository.updateState).toHaveBeenCalledWith(
231
- 'process-123',
232
- 'FAILED',
233
- {
234
- error: 'Failed to queue migration job',
235
- errorDetails: 'SQS unavailable',
236
- }
215
+ // Verify migration status was marked as failed
216
+ expect(mockMigrationStatusRepository.update).toHaveBeenCalledWith(
217
+ expect.objectContaining({
218
+ migrationId: 'migration-123',
219
+ state: 'FAILED',
220
+ error: expect.stringContaining('Failed to queue migration'),
221
+ })
237
222
  );
238
223
  });
239
224
 
240
- it('should handle process creation failure', async () => {
241
- mockProcessRepository.create.mockRejectedValue(new Error('Database error'));
225
+ it('should handle migration status creation failure', async () => {
226
+ mockMigrationStatusRepository.create.mockRejectedValue(new Error('S3 error'));
242
227
 
243
228
  await expect(
244
229
  useCase.execute({
@@ -246,7 +231,7 @@ describe('TriggerDatabaseMigrationUseCase', () => {
246
231
  dbType: 'postgresql',
247
232
  stage: 'production',
248
233
  })
249
- ).rejects.toThrow('Database error');
234
+ ).rejects.toThrow('S3 error');
250
235
 
251
236
  // Should not attempt to send to queue if process creation fails
252
237
  expect(mockQueuerUtil.send).not.toHaveBeenCalled();
@@ -17,7 +17,7 @@
17
17
 
18
18
  const { Router } = require('express');
19
19
  const catchAsyncError = require('express-async-handler');
20
- const { ProcessRepositoryPostgres } = require('../../integrations/repositories/process-repository-postgres');
20
+ const { MigrationStatusRepositoryS3 } = require('../../database/repositories/migration-status-repository-s3');
21
21
  const {
22
22
  TriggerDatabaseMigrationUseCase,
23
23
  ValidationError: TriggerValidationError,
@@ -31,14 +31,15 @@ const {
31
31
  const router = Router();
32
32
 
33
33
  // Dependency injection
34
- // Note: Migrations are PostgreSQL-only, so we directly use ProcessRepositoryPostgres
35
- // This avoids loading app definition (which requires integration classes)
36
- const processRepository = new ProcessRepositoryPostgres();
34
+ // Use S3 repository to avoid User table dependency (chicken-and-egg problem)
35
+ const bucketName = process.env.S3_BUCKET_NAME || process.env.MIGRATION_STATUS_BUCKET;
36
+ const migrationStatusRepository = new MigrationStatusRepositoryS3(bucketName);
37
+
37
38
  const triggerMigrationUseCase = new TriggerDatabaseMigrationUseCase({
38
- processRepository,
39
+ migrationStatusRepository,
39
40
  // Note: QueuerUtil is used directly in the use case (static utility)
40
41
  });
41
- const getStatusUseCase = new GetMigrationStatusUseCase({ processRepository });
42
+ const getStatusUseCase = new GetMigrationStatusUseCase({ migrationStatusRepository });
42
43
 
43
44
  /**
44
45
  * Admin API key validation middleware
@@ -118,9 +119,9 @@ router.post(
118
119
  );
119
120
 
120
121
  /**
121
- * GET /db-migrate/:processId
122
+ * GET /db-migrate/:migrationId
122
123
  *
123
- * Get migration status by process ID
124
+ * Get migration status by migration ID
124
125
  *
125
126
  * Response (200 OK):
126
127
  * {
@@ -142,14 +143,15 @@ router.post(
142
143
  * }
143
144
  */
144
145
  router.get(
145
- '/db-migrate/:processId',
146
+ '/db-migrate/:migrationId',
146
147
  catchAsyncError(async (req, res) => {
147
- const { processId } = req.params;
148
+ const { migrationId } = req.params;
149
+ const stage = req.query.stage || process.env.STAGE || 'production';
148
150
 
149
- console.log(`Migration status request: processId=${processId}`);
151
+ console.log(`Migration status request: migrationId=${migrationId}, stage=${stage}`);
150
152
 
151
153
  try {
152
- const status = await getStatusUseCase.execute({ processId });
154
+ const status = await getStatusUseCase.execute(migrationId, stage);
153
155
 
154
156
  res.status(200).json(status);
155
157
  } catch (error) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.461.a0527a3.0",
4
+ "version": "2.0.0--canary.461.4b9ba0b.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -37,9 +37,9 @@
37
37
  }
38
38
  },
39
39
  "devDependencies": {
40
- "@friggframework/eslint-config": "2.0.0--canary.461.a0527a3.0",
41
- "@friggframework/prettier-config": "2.0.0--canary.461.a0527a3.0",
42
- "@friggframework/test": "2.0.0--canary.461.a0527a3.0",
40
+ "@friggframework/eslint-config": "2.0.0--canary.461.4b9ba0b.0",
41
+ "@friggframework/prettier-config": "2.0.0--canary.461.4b9ba0b.0",
42
+ "@friggframework/test": "2.0.0--canary.461.4b9ba0b.0",
43
43
  "@prisma/client": "^6.17.0",
44
44
  "@types/lodash": "4.17.15",
45
45
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -79,5 +79,5 @@
79
79
  "publishConfig": {
80
80
  "access": "public"
81
81
  },
82
- "gitHead": "a0527a318705a351410471083bb6e5a6b6dc7165"
82
+ "gitHead": "4b9ba0be386a8d8c9af0398356f6644d41e89af6"
83
83
  }