@friggframework/core 2.0.0--canary.461.ec909cf.0 → 2.0.0--canary.461.7b36f0f.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/core/Worker.js CHANGED
@@ -1,10 +1,9 @@
1
- const AWS = require('aws-sdk');
1
+ const { SQSClient, GetQueueUrlCommand, SendMessageCommand } = require('@aws-sdk/client-sqs');
2
2
  const _ = require('lodash');
3
3
  const { RequiredPropertyError } = require('../errors');
4
4
  const { get } = require('../assertions');
5
5
 
6
- AWS.config.update({ region: process.env.AWS_REGION });
7
- const sqs = new AWS.SQS({ apiVersion: '2012-11-05' });
6
+ const sqs = new SQSClient({ region: process.env.AWS_REGION });
8
7
 
9
8
  class Worker {
10
9
  async getQueueURL(params) {
@@ -12,15 +11,9 @@ class Worker {
12
11
  // let params = {
13
12
  // QueueName: process.env.QueueName
14
13
  // };
15
- return new Promise((resolve, reject) => {
16
- sqs.getQueueUrl(params, (err, data) => {
17
- if (err) {
18
- reject(err);
19
- } else {
20
- resolve(data.QueueUrl);
21
- }
22
- });
23
- });
14
+ const command = new GetQueueUrlCommand(params);
15
+ const data = await sqs.send(command);
16
+ return data.QueueUrl;
24
17
  }
25
18
 
26
19
  async run(params, context = {}) {
@@ -54,15 +47,9 @@ class Worker {
54
47
  }
55
48
 
56
49
  async sendAsyncSQSMessage(params) {
57
- return new Promise((resolve, reject) => {
58
- sqs.sendMessage(params, (err, data) => {
59
- if (err) {
60
- reject(err);
61
- } else {
62
- resolve(data.MessageId);
63
- }
64
- });
65
- });
50
+ const command = new SendMessageCommand(params);
51
+ const data = await sqs.send(command);
52
+ return data.MessageId;
66
53
  }
67
54
 
68
55
  // Throw an exception if the params do not validate
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Tests for Worker - AWS SDK v3 Migration
3
+ *
4
+ * Tests SQS Worker operations using aws-sdk-client-mock
5
+ */
6
+
7
+ const { mockClient } = require('aws-sdk-client-mock');
8
+ const { SQSClient, GetQueueUrlCommand, SendMessageCommand } = require('@aws-sdk/client-sqs');
9
+ const { Worker } = require('./Worker');
10
+
11
+ describe('Worker - AWS SDK v3', () => {
12
+ let sqsMock;
13
+ let worker;
14
+ const originalEnv = process.env;
15
+
16
+ beforeEach(() => {
17
+ sqsMock = mockClient(SQSClient);
18
+ worker = new Worker();
19
+ jest.clearAllMocks();
20
+ process.env = { ...originalEnv, AWS_REGION: 'us-east-1' };
21
+ });
22
+
23
+ afterEach(() => {
24
+ sqsMock.reset();
25
+ process.env = originalEnv;
26
+ });
27
+
28
+ describe('getQueueURL()', () => {
29
+ it('should get queue URL from SQS', async () => {
30
+ sqsMock.on(GetQueueUrlCommand).resolves({
31
+ QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue',
32
+ });
33
+
34
+ const result = await worker.getQueueURL({ QueueName: 'test-queue' });
35
+
36
+ expect(result).toBe('https://sqs.us-east-1.amazonaws.com/123456789/test-queue');
37
+ expect(sqsMock.calls()).toHaveLength(1);
38
+
39
+ const call = sqsMock.call(0);
40
+ expect(call.args[0].input).toMatchObject({
41
+ QueueName: 'test-queue',
42
+ });
43
+ });
44
+
45
+ it('should handle queue not found error', async () => {
46
+ sqsMock.on(GetQueueUrlCommand).rejects(new Error('Queue does not exist'));
47
+
48
+ await expect(worker.getQueueURL({ QueueName: 'nonexistent-queue' }))
49
+ .rejects.toThrow('Queue does not exist');
50
+ });
51
+ });
52
+
53
+ describe('sendAsyncSQSMessage()', () => {
54
+ it('should send message and return MessageId', async () => {
55
+ sqsMock.on(SendMessageCommand).resolves({
56
+ MessageId: 'message-123',
57
+ });
58
+
59
+ const params = {
60
+ QueueUrl: 'https://queue-url',
61
+ MessageBody: JSON.stringify({ test: 'data' }),
62
+ };
63
+
64
+ const result = await worker.sendAsyncSQSMessage(params);
65
+
66
+ expect(result).toBe('message-123');
67
+ expect(sqsMock.calls()).toHaveLength(1);
68
+ });
69
+
70
+ it('should handle send errors', async () => {
71
+ sqsMock.on(SendMessageCommand).rejects(new Error('Send failed'));
72
+
73
+ const params = {
74
+ QueueUrl: 'https://queue-url',
75
+ MessageBody: 'test',
76
+ };
77
+
78
+ await expect(worker.sendAsyncSQSMessage(params)).rejects.toThrow('Send failed');
79
+ });
80
+ });
81
+
82
+ describe('send()', () => {
83
+ it('should validate params and send message with delay', async () => {
84
+ sqsMock.on(SendMessageCommand).resolves({
85
+ MessageId: 'delayed-message-id',
86
+ });
87
+
88
+ worker._validateParams = jest.fn(); // Mock validation
89
+
90
+ const params = {
91
+ QueueUrl: 'https://queue-url',
92
+ data: 'test',
93
+ };
94
+
95
+ const result = await worker.send(params, 5);
96
+
97
+ expect(worker._validateParams).toHaveBeenCalledWith(params);
98
+ expect(result).toBe('delayed-message-id');
99
+
100
+ const call = sqsMock.call(0);
101
+ expect(call.args[0].input.DelaySeconds).toBe(5);
102
+ });
103
+
104
+ it('should send message with zero delay by default', async () => {
105
+ sqsMock.on(SendMessageCommand).resolves({
106
+ MessageId: 'message-id',
107
+ });
108
+
109
+ worker._validateParams = jest.fn();
110
+
111
+ const params = {
112
+ QueueUrl: 'https://queue-url',
113
+ data: 'test',
114
+ };
115
+
116
+ await worker.send(params);
117
+
118
+ const call = sqsMock.call(0);
119
+ expect(call.args[0].input.DelaySeconds).toBe(0);
120
+ });
121
+ });
122
+
123
+ describe('run()', () => {
124
+ it('should process SQS records', async () => {
125
+ worker._validateParams = jest.fn();
126
+ worker._run = jest.fn().mockResolvedValue(undefined);
127
+
128
+ const params = {
129
+ Records: [
130
+ { body: JSON.stringify({ task: 'test-1' }) },
131
+ { body: JSON.stringify({ task: 'test-2' }) },
132
+ ],
133
+ };
134
+
135
+ await worker.run(params);
136
+
137
+ expect(worker._run).toHaveBeenCalledTimes(2);
138
+ expect(worker._run).toHaveBeenCalledWith({ task: 'test-1' }, {});
139
+ expect(worker._run).toHaveBeenCalledWith({ task: 'test-2' }, {});
140
+ });
141
+
142
+ it('should pass context to _run method', async () => {
143
+ worker._validateParams = jest.fn();
144
+ worker._run = jest.fn().mockResolvedValue(undefined);
145
+
146
+ const params = {
147
+ Records: [
148
+ { body: JSON.stringify({ task: 'test' }) },
149
+ ],
150
+ };
151
+ const context = { userId: '123' };
152
+
153
+ await worker.run(params, context);
154
+
155
+ expect(worker._run).toHaveBeenCalledWith({ task: 'test' }, context);
156
+ });
157
+ });
158
+ });
159
+
@@ -1,5 +1,8 @@
1
1
  const { mongoose } = require('../mongoose');
2
- const AWS = require('aws-sdk');
2
+ const {
3
+ ApiGatewayManagementApiClient,
4
+ PostToConnectionCommand,
5
+ } = require('@aws-sdk/client-apigatewaymanagementapi');
3
6
 
4
7
  const schema = new mongoose.Schema({
5
8
  connectionId: { type: mongoose.Schema.Types.String },
@@ -17,20 +20,18 @@ schema.statics.getActiveConnections = async function () {
17
20
  return connections.map((conn) => ({
18
21
  connectionId: conn.connectionId,
19
22
  send: async (data) => {
20
- const apigwManagementApi = new AWS.ApiGatewayManagementApi({
21
- apiVersion: '2018-11-29',
23
+ const apigwManagementApi = new ApiGatewayManagementApiClient({
22
24
  endpoint: process.env.WEBSOCKET_API_ENDPOINT,
23
25
  });
24
26
 
25
27
  try {
26
- await apigwManagementApi
27
- .postToConnection({
28
- ConnectionId: conn.connectionId,
29
- Data: JSON.stringify(data),
30
- })
31
- .promise();
28
+ const command = new PostToConnectionCommand({
29
+ ConnectionId: conn.connectionId,
30
+ Data: JSON.stringify(data),
31
+ });
32
+ await apigwManagementApi.send(command);
32
33
  } catch (error) {
33
- if (error.statusCode === 410) {
34
+ if (error.statusCode === 410 || error.$metadata?.httpStatusCode === 410) {
34
35
  console.log(`Stale connection ${conn.connectionId}`);
35
36
  await this.deleteOne({
36
37
  connectionId: conn.connectionId,
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  const crypto = require('crypto');
20
- const AWS = require('aws-sdk');
20
+ const { KMSClient, GenerateDataKeyCommand, DecryptCommand } = require('@aws-sdk/client-kms');
21
21
  const aes = require('./aes');
22
22
 
23
23
  class Cryptor {
@@ -27,16 +27,15 @@ class Cryptor {
27
27
 
28
28
  async generateDataKey() {
29
29
  if (this.shouldUseAws) {
30
- const kmsClient = new AWS.KMS();
31
- const dataKey = await kmsClient
32
- .generateDataKey({
33
- KeyId: process.env.KMS_KEY_ARN,
34
- KeySpec: 'AES_256',
35
- })
36
- .promise();
30
+ const kmsClient = new KMSClient({});
31
+ const command = new GenerateDataKeyCommand({
32
+ KeyId: process.env.KMS_KEY_ARN,
33
+ KeySpec: 'AES_256',
34
+ });
35
+ const dataKey = await kmsClient.send(command);
37
36
 
38
37
  const keyId = Buffer.from(dataKey.KeyId).toString('base64');
39
- const encryptedKey = dataKey.CiphertextBlob.toString('base64');
38
+ const encryptedKey = Buffer.from(dataKey.CiphertextBlob).toString('base64');
40
39
  const plaintext = dataKey.Plaintext;
41
40
  return { keyId, encryptedKey, plaintext };
42
41
  }
@@ -70,13 +69,12 @@ class Cryptor {
70
69
 
71
70
  async decryptDataKey(keyId, encryptedKey) {
72
71
  if (this.shouldUseAws) {
73
- const kmsClient = new AWS.KMS();
74
- const dataKey = await kmsClient
75
- .decrypt({
76
- KeyId: keyId,
77
- CiphertextBlob: encryptedKey,
78
- })
79
- .promise();
72
+ const kmsClient = new KMSClient({});
73
+ const command = new DecryptCommand({
74
+ KeyId: keyId,
75
+ CiphertextBlob: encryptedKey,
76
+ });
77
+ const dataKey = await kmsClient.send(command);
80
78
 
81
79
  return dataKey.Plaintext;
82
80
  }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Tests for Cryptor - AWS SDK v3 Migration
3
+ *
4
+ * Tests KMS encryption/decryption operations using aws-sdk-client-mock
5
+ */
6
+
7
+ const { mockClient } = require('aws-sdk-client-mock');
8
+ const { KMSClient, GenerateDataKeyCommand, DecryptCommand } = require('@aws-sdk/client-kms');
9
+ const { Cryptor } = require('./Cryptor');
10
+
11
+ describe('Cryptor - AWS SDK v3', () => {
12
+ let kmsMock;
13
+ const originalEnv = process.env;
14
+
15
+ beforeEach(() => {
16
+ kmsMock = mockClient(KMSClient);
17
+ jest.clearAllMocks();
18
+ process.env = { ...originalEnv };
19
+ });
20
+
21
+ afterEach(() => {
22
+ kmsMock.reset();
23
+ process.env = originalEnv;
24
+ });
25
+
26
+ describe('KMS Mode (shouldUseAws: true)', () => {
27
+ beforeEach(() => {
28
+ process.env.KMS_KEY_ARN = 'arn:aws:kms:us-east-1:123456789:key/test-key-id';
29
+ });
30
+
31
+ describe('encrypt()', () => {
32
+ it('should encrypt text using KMS data key', async () => {
33
+ const mockPlaintext = Buffer.from('mock-plaintext-key-32-bytes-long');
34
+ const mockCiphertextBlob = Buffer.from('mock-encrypted-key');
35
+
36
+ kmsMock.on(GenerateDataKeyCommand).resolves({
37
+ KeyId: 'test-key-id',
38
+ Plaintext: mockPlaintext,
39
+ CiphertextBlob: mockCiphertextBlob,
40
+ });
41
+
42
+ const cryptor = new Cryptor({ shouldUseAws: true });
43
+ const result = await cryptor.encrypt('sensitive-data');
44
+
45
+ // Result should be in format: "keyId:encryptedText:encryptedKey"
46
+ expect(result).toBeDefined();
47
+ expect(result.split(':').length).toBe(4); // keyId:iv:ciphertext:encryptedKey format from aes
48
+
49
+ expect(kmsMock.calls()).toHaveLength(1);
50
+ const call = kmsMock.call(0);
51
+ expect(call.args[0].input).toMatchObject({
52
+ KeyId: process.env.KMS_KEY_ARN,
53
+ KeySpec: 'AES_256',
54
+ });
55
+ });
56
+
57
+ it('should handle KMS errors during encryption', async () => {
58
+ kmsMock.on(GenerateDataKeyCommand).rejects(new Error('KMS unavailable'));
59
+
60
+ const cryptor = new Cryptor({ shouldUseAws: true });
61
+
62
+ await expect(cryptor.encrypt('sensitive-data')).rejects.toThrow('KMS unavailable');
63
+ });
64
+ });
65
+
66
+ describe('decrypt()', () => {
67
+ it('should decrypt text using KMS', async () => {
68
+ const mockPlaintext = Buffer.from('mock-plaintext-key');
69
+
70
+ kmsMock.on(DecryptCommand).resolves({
71
+ Plaintext: mockPlaintext,
72
+ });
73
+
74
+ const cryptor = new Cryptor({ shouldUseAws: true });
75
+
76
+ // First encrypt some data
77
+ const mockDataKey = Buffer.from('test-key-32-bytes-long-exactly');
78
+ kmsMock.on(GenerateDataKeyCommand).resolves({
79
+ KeyId: 'test-key-id',
80
+ Plaintext: mockDataKey,
81
+ CiphertextBlob: Buffer.from('encrypted-key'),
82
+ });
83
+
84
+ const encrypted = await cryptor.encrypt('test-data');
85
+
86
+ // Then decrypt
87
+ kmsMock.reset();
88
+ kmsMock.on(DecryptCommand).resolves({
89
+ Plaintext: mockDataKey,
90
+ });
91
+
92
+ const decrypted = await cryptor.decrypt(encrypted);
93
+
94
+ expect(decrypted).toBe('test-data');
95
+ expect(kmsMock.calls()).toHaveLength(1);
96
+ });
97
+
98
+ it('should handle KMS errors during decryption', async () => {
99
+ kmsMock.on(DecryptCommand).rejects(new Error('Invalid ciphertext'));
100
+
101
+ const cryptor = new Cryptor({ shouldUseAws: true });
102
+ const fakeEncrypted = Buffer.from('test-key-id').toString('base64') + ':fake:data:' + Buffer.from('fake-key').toString('base64');
103
+
104
+ await expect(cryptor.decrypt(fakeEncrypted)).rejects.toThrow('Invalid ciphertext');
105
+ });
106
+ });
107
+ });
108
+
109
+ describe('Local Mode (shouldUseAws: false)', () => {
110
+ beforeEach(() => {
111
+ process.env.AES_KEY = 'test-aes-key-32-bytes-long-123';
112
+ process.env.AES_KEY_ID = 'local-key-id';
113
+ });
114
+
115
+ it('should encrypt using local AES key', async () => {
116
+ const cryptor = new Cryptor({ shouldUseAws: false });
117
+ const result = await cryptor.encrypt('sensitive-data');
118
+
119
+ expect(result).toBeDefined();
120
+ expect(result.split(':').length).toBeGreaterThanOrEqual(3);
121
+ expect(kmsMock.calls()).toHaveLength(0); // Should not call KMS
122
+ });
123
+
124
+ it('should decrypt using local AES key', async () => {
125
+ const cryptor = new Cryptor({ shouldUseAws: false });
126
+
127
+ const encrypted = await cryptor.encrypt('test-data');
128
+ const decrypted = await cryptor.decrypt(encrypted);
129
+
130
+ expect(decrypted).toBe('test-data');
131
+ expect(kmsMock.calls()).toHaveLength(0); // Should not call KMS
132
+ });
133
+
134
+ it('should throw error if encryption key not found', async () => {
135
+ delete process.env.AES_KEY_ID;
136
+
137
+ const cryptor = new Cryptor({ shouldUseAws: false });
138
+ const fakeEncrypted = 'unknown-key:data:key';
139
+
140
+ await expect(cryptor.decrypt(fakeEncrypted)).rejects.toThrow('Encryption key not found');
141
+ });
142
+ });
143
+ });
144
+
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Database Migration Handler for AWS Lambda
3
+ *
4
+ * Executes Prisma migrations in a Lambda environment.
5
+ * Based on AWS best practices for running migrations in serverless environments.
6
+ *
7
+ * Supported Commands:
8
+ * - deploy: Apply pending migrations to the database (production-safe)
9
+ * - reset: Reset database and apply all migrations (DANGEROUS - dev only)
10
+ *
11
+ * Usage:
12
+ * // Via Lambda invoke
13
+ * {
14
+ * "command": "deploy" // or "reset"
15
+ * }
16
+ *
17
+ * Requirements:
18
+ * - Prisma CLI must be included in deployment or Lambda layer
19
+ * - DATABASE_URL environment variable must be set
20
+ * - VPC configuration for Aurora access
21
+ *
22
+ * Reference: https://www.prisma.io/docs/guides/deployment/deployment-guides/deploying-to-aws-lambda
23
+ */
24
+
25
+ const { execFile } = require('child_process');
26
+ const path = require('path');
27
+
28
+ /**
29
+ * Execute Prisma migration command
30
+ *
31
+ * @param {string} command - Migration command ('deploy' or 'reset')
32
+ * @param {string} schemaPath - Path to Prisma schema file
33
+ * @returns {Promise<number>} Exit code
34
+ */
35
+ async function executePrismaMigration(command, schemaPath) {
36
+ console.log(`Executing Prisma migration: ${command}`);
37
+ console.log(`Schema path: ${schemaPath}`);
38
+ console.log(`Database URL: ${process.env.DATABASE_URL ? '[SET]' : '[NOT SET]'}`);
39
+
40
+ return new Promise((resolve, reject) => {
41
+ // Build command arguments
42
+ const args = ['migrate', command];
43
+
44
+ // Add command-specific options
45
+ if (command === 'reset') {
46
+ args.push('--force'); // Skip confirmation prompt
47
+ args.push('--skip-generate'); // Skip client generation (already done in layer)
48
+ }
49
+
50
+ // Add schema path if provided
51
+ if (schemaPath) {
52
+ args.push('--schema', schemaPath);
53
+ }
54
+
55
+ console.log(`Running: prisma ${args.join(' ')}`);
56
+
57
+ // Execute Prisma CLI
58
+ execFile(
59
+ path.resolve('./node_modules/prisma/build/index.js'),
60
+ args,
61
+ {
62
+ env: {
63
+ ...process.env,
64
+ // Ensure Prisma uses the correct binary target
65
+ PRISMA_CLI_BINARY_TARGETS: 'rhel-openssl-3.0.x',
66
+ }
67
+ },
68
+ (error, stdout, stderr) => {
69
+ // Log all output
70
+ if (stdout) {
71
+ console.log('STDOUT:', stdout);
72
+ }
73
+ if (stderr) {
74
+ console.error('STDERR:', stderr);
75
+ }
76
+
77
+ if (error) {
78
+ console.error(`Migration ${command} exited with error:`, error.message);
79
+ console.error(`Exit code: ${error.code || 1}`);
80
+ resolve(error.code || 1);
81
+ } else {
82
+ console.log(`Migration ${command} completed successfully`);
83
+ resolve(0);
84
+ }
85
+ }
86
+ );
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Validate migration command
92
+ */
93
+ function validateCommand(command) {
94
+ const validCommands = ['deploy', 'reset'];
95
+
96
+ if (!validCommands.includes(command)) {
97
+ throw new Error(
98
+ `Invalid migration command: "${command}". ` +
99
+ `Valid commands are: ${validCommands.join(', ')}`
100
+ );
101
+ }
102
+
103
+ // Extra validation for dangerous commands
104
+ if (command === 'reset') {
105
+ const stage = process.env.STAGE || process.env.NODE_ENV;
106
+ if (stage === 'production' || stage === 'prod') {
107
+ throw new Error(
108
+ 'BLOCKED: "reset" command is not allowed in production environment. ' +
109
+ 'This command would delete all data. Use "deploy" instead.'
110
+ );
111
+ }
112
+ console.warn('⚠️ WARNING: "reset" will DELETE all data and reset the database!');
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Determine which Prisma schema to use based on database type
118
+ */
119
+ function getSchemaPath() {
120
+ // In Lambda, schemas are in @friggframework/core/generated/
121
+ const baseSchemaPath = './node_modules/@friggframework/core/generated';
122
+
123
+ // Check if Postgres is enabled
124
+ if (process.env.DATABASE_URL?.includes('postgresql') || process.env.DATABASE_URL?.includes('postgres')) {
125
+ const schemaPath = `${baseSchemaPath}/prisma-postgresql/schema.prisma`;
126
+ console.log(`Using PostgreSQL schema: ${schemaPath}`);
127
+ return schemaPath;
128
+ }
129
+
130
+ // Check if MongoDB is enabled
131
+ if (process.env.DATABASE_URL?.includes('mongodb')) {
132
+ const schemaPath = `${baseSchemaPath}/prisma-mongodb/schema.prisma`;
133
+ console.log(`Using MongoDB schema: ${schemaPath}`);
134
+ return schemaPath;
135
+ }
136
+
137
+ // Default to PostgreSQL
138
+ console.log('DATABASE_URL not set or database type unknown, defaulting to PostgreSQL');
139
+ return `${baseSchemaPath}/prisma-postgresql/schema.prisma`;
140
+ }
141
+
142
+ /**
143
+ * Lambda handler for database migrations
144
+ *
145
+ * @param {Object} event - Lambda event
146
+ * @param {string} event.command - Migration command ('deploy' or 'reset')
147
+ * @param {Object} context - Lambda context
148
+ * @returns {Promise<Object>} Migration result
149
+ */
150
+ exports.handler = async (event, context) => {
151
+ const startTime = Date.now();
152
+
153
+ console.log('='.repeat(60));
154
+ console.log('Database Migration Handler');
155
+ console.log('='.repeat(60));
156
+ console.log('Event:', JSON.stringify(event, null, 2));
157
+ console.log('Context:', JSON.stringify({
158
+ functionName: context.functionName,
159
+ functionVersion: context.functionVersion,
160
+ memoryLimitInMB: context.memoryLimitInMB,
161
+ logGroupName: context.logGroupName,
162
+ }, null, 2));
163
+
164
+ try {
165
+ // Get migration command (default to 'deploy')
166
+ const command = event.command || 'deploy';
167
+
168
+ // Validate command
169
+ validateCommand(command);
170
+
171
+ // Check required environment variables
172
+ if (!process.env.DATABASE_URL) {
173
+ throw new Error(
174
+ 'DATABASE_URL environment variable is not set. ' +
175
+ 'Cannot connect to database for migrations.'
176
+ );
177
+ }
178
+
179
+ // Determine schema path
180
+ const schemaPath = getSchemaPath();
181
+
182
+ // Execute migration
183
+ const exitCode = await executePrismaMigration(command, schemaPath);
184
+
185
+ const duration = Date.now() - startTime;
186
+
187
+ if (exitCode === 0) {
188
+ const result = {
189
+ success: true,
190
+ command,
191
+ message: `Migration ${command} completed successfully`,
192
+ duration: `${duration}ms`,
193
+ timestamp: new Date().toISOString(),
194
+ };
195
+
196
+ console.log('='.repeat(60));
197
+ console.log('Migration completed successfully');
198
+ console.log(JSON.stringify(result, null, 2));
199
+ console.log('='.repeat(60));
200
+
201
+ return result;
202
+ } else {
203
+ throw new Error(`Migration ${command} failed with exit code ${exitCode}`);
204
+ }
205
+
206
+ } catch (error) {
207
+ const duration = Date.now() - startTime;
208
+
209
+ console.error('='.repeat(60));
210
+ console.error('Migration failed');
211
+ console.error('Error:', error.message);
212
+ console.error('Stack:', error.stack);
213
+ console.error('='.repeat(60));
214
+
215
+ const errorResult = {
216
+ success: false,
217
+ command: event.command || 'unknown',
218
+ error: error.message,
219
+ duration: `${duration}ms`,
220
+ timestamp: new Date().toISOString(),
221
+ };
222
+
223
+ // Return error (don't throw) so Lambda doesn't retry
224
+ return errorResult;
225
+ }
226
+ };
227
+
package/logs/logger.js CHANGED
@@ -1,5 +1,4 @@
1
1
  const util = require('util');
2
- const aws = require('aws-sdk');
3
2
 
4
3
  // Except in some outlier circumstances, for example steam or event error handlers, this should be the only place that calls `console.*`. That way, this file can be modified to log everything properly on a variety of platforms because all the logging code is here in one place.
5
4
  /* eslint-disable no-console */
@@ -7,9 +6,6 @@ const aws = require('aws-sdk');
7
6
  const logs = [];
8
7
  let flushCalled = false;
9
8
 
10
- // Log AWS SDK calls
11
- aws.config.logger = { log: debug };
12
-
13
9
  function debug(...messages) {
14
10
  if (messages.length) {
15
11
  const date = new Date();
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.461.ec909cf.0",
4
+ "version": "2.0.0--canary.461.7b36f0f.0",
5
5
  "dependencies": {
6
+ "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
+ "@aws-sdk/client-kms": "^3.588.0",
8
+ "@aws-sdk/client-sqs": "^3.588.0",
6
9
  "@hapi/boom": "^10.0.1",
7
10
  "bcryptjs": "^2.4.3",
8
11
  "body-parser": "^1.20.2",
@@ -23,7 +26,6 @@
23
26
  },
24
27
  "peerDependencies": {
25
28
  "@prisma/client": "^6.16.3",
26
- "aws-sdk": "^2.1200.0",
27
29
  "prisma": "^6.16.3"
28
30
  },
29
31
  "peerDependenciesMeta": {
@@ -32,15 +34,12 @@
32
34
  },
33
35
  "prisma": {
34
36
  "optional": true
35
- },
36
- "aws-sdk": {
37
- "optional": true
38
37
  }
39
38
  },
40
39
  "devDependencies": {
41
- "@friggframework/eslint-config": "2.0.0--canary.461.ec909cf.0",
42
- "@friggframework/prettier-config": "2.0.0--canary.461.ec909cf.0",
43
- "@friggframework/test": "2.0.0--canary.461.ec909cf.0",
40
+ "@friggframework/eslint-config": "2.0.0--canary.461.7b36f0f.0",
41
+ "@friggframework/prettier-config": "2.0.0--canary.461.7b36f0f.0",
42
+ "@friggframework/test": "2.0.0--canary.461.7b36f0f.0",
44
43
  "@prisma/client": "^6.17.0",
45
44
  "@types/lodash": "4.17.15",
46
45
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -80,5 +79,5 @@
80
79
  "publishConfig": {
81
80
  "access": "public"
82
81
  },
83
- "gitHead": "ec909cf5076fa52ca3e914ee671a1c13c2cb11ee"
82
+ "gitHead": "7b36f0f437980499b06c66adc4ddbc3e80f6d8e6"
84
83
  }
@@ -1,5 +1,6 @@
1
1
  const { v4: uuid } = require('uuid');
2
- const AWS = require('aws-sdk');
2
+ const { SQSClient, SendMessageCommand, SendMessageBatchCommand } = require('@aws-sdk/client-sqs');
3
+
3
4
  const awsConfigOptions = () => {
4
5
  const config = {};
5
6
  if (process.env.IS_OFFLINE) {
@@ -10,18 +11,17 @@ const awsConfigOptions = () => {
10
11
  }
11
12
  return config;
12
13
  };
13
- AWS.config.update(awsConfigOptions());
14
- const sqs = new AWS.SQS();
14
+
15
+ const sqs = new SQSClient(awsConfigOptions());
15
16
 
16
17
  const QueuerUtil = {
17
18
  send: async (message, queueUrl) => {
18
19
  console.log(`Enqueuing message to SQS queue ${queueUrl}`);
19
- return sqs
20
- .sendMessage({
21
- MessageBody: JSON.stringify(message),
22
- QueueUrl: queueUrl,
23
- })
24
- .promise();
20
+ const command = new SendMessageCommand({
21
+ MessageBody: JSON.stringify(message),
22
+ QueueUrl: queueUrl,
23
+ });
24
+ return sqs.send(command);
25
25
  },
26
26
 
27
27
  batchSend: async (entries = [], queueUrl) => {
@@ -39,12 +39,11 @@ const QueuerUtil = {
39
39
  // Sends 10, then purges the buffer
40
40
  if (buffer.length === batchSize) {
41
41
  console.log('Buffer at 10, sending batch');
42
- await sqs
43
- .sendMessageBatch({
44
- Entries: buffer,
45
- QueueUrl: queueUrl,
46
- })
47
- .promise();
42
+ const command = new SendMessageBatchCommand({
43
+ Entries: buffer,
44
+ QueueUrl: queueUrl,
45
+ });
46
+ await sqs.send(command);
48
47
  // Purge the buffer
49
48
  buffer.splice(0, buffer.length);
50
49
  }
@@ -54,12 +53,11 @@ const QueuerUtil = {
54
53
  // If any remaining entries under 10 are left in the buffer, send and return
55
54
  if (buffer.length > 0) {
56
55
  console.log(buffer);
57
- return sqs
58
- .sendMessageBatch({
59
- Entries: buffer,
60
- QueueUrl: queueUrl,
61
- })
62
- .promise();
56
+ const command = new SendMessageBatchCommand({
57
+ Entries: buffer,
58
+ QueueUrl: queueUrl,
59
+ });
60
+ return sqs.send(command);
63
61
  }
64
62
 
65
63
  // If we're exact... just return an empty object for now
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Tests for QueuerUtil - AWS SDK v3 Migration
3
+ *
4
+ * Tests SQS operations using aws-sdk-client-mock
5
+ */
6
+
7
+ const { mockClient } = require('aws-sdk-client-mock');
8
+ const { SQSClient, SendMessageCommand, SendMessageBatchCommand } = require('@aws-sdk/client-sqs');
9
+ const { QueuerUtil } = require('./queuer-util');
10
+
11
+ describe('QueuerUtil - AWS SDK v3', () => {
12
+ let sqsMock;
13
+
14
+ beforeEach(() => {
15
+ sqsMock = mockClient(SQSClient);
16
+ jest.clearAllMocks();
17
+ });
18
+
19
+ afterEach(() => {
20
+ sqsMock.reset();
21
+ });
22
+
23
+ describe('send()', () => {
24
+ it('should send single message to SQS', async () => {
25
+ sqsMock.on(SendMessageCommand).resolves({
26
+ MessageId: 'test-message-id-123'
27
+ });
28
+
29
+ const message = { test: 'data', id: 1 };
30
+ const queueUrl = 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue';
31
+
32
+ const result = await QueuerUtil.send(message, queueUrl);
33
+
34
+ expect(result.MessageId).toBe('test-message-id-123');
35
+ expect(sqsMock.calls()).toHaveLength(1);
36
+
37
+ const call = sqsMock.call(0);
38
+ expect(call.args[0].input).toMatchObject({
39
+ MessageBody: JSON.stringify(message),
40
+ QueueUrl: queueUrl,
41
+ });
42
+ });
43
+
44
+ it('should handle SQS errors', async () => {
45
+ sqsMock.on(SendMessageCommand).rejects(new Error('SQS Error'));
46
+
47
+ const message = { test: 'data' };
48
+ const queueUrl = 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue';
49
+
50
+ await expect(QueuerUtil.send(message, queueUrl)).rejects.toThrow('SQS Error');
51
+ });
52
+ });
53
+
54
+ describe('batchSend()', () => {
55
+ it('should send batch of messages to SQS', async () => {
56
+ sqsMock.on(SendMessageBatchCommand).resolves({
57
+ Successful: [{ MessageId: 'msg-1' }],
58
+ Failed: []
59
+ });
60
+
61
+ const entries = Array(5).fill().map((_, i) => ({ data: `test-${i}` }));
62
+ const queueUrl = 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue';
63
+
64
+ const result = await QueuerUtil.batchSend(entries, queueUrl);
65
+
66
+ expect(sqsMock.calls()).toHaveLength(1);
67
+
68
+ const call = sqsMock.call(0);
69
+ expect(call.args[0].input.Entries).toHaveLength(5);
70
+ expect(call.args[0].input.QueueUrl).toBe(queueUrl);
71
+ });
72
+
73
+ it('should send multiple batches for large entry sets (10 per batch)', async () => {
74
+ sqsMock.on(SendMessageBatchCommand).resolves({
75
+ Successful: [],
76
+ Failed: []
77
+ });
78
+
79
+ const entries = Array(25).fill().map((_, i) => ({ data: `test-${i}` }));
80
+ const queueUrl = 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue';
81
+
82
+ await QueuerUtil.batchSend(entries, queueUrl);
83
+
84
+ // Should send 3 batches (10 + 10 + 5)
85
+ expect(sqsMock.calls()).toHaveLength(3);
86
+
87
+ expect(sqsMock.call(0).args[0].input.Entries).toHaveLength(10);
88
+ expect(sqsMock.call(1).args[0].input.Entries).toHaveLength(10);
89
+ expect(sqsMock.call(2).args[0].input.Entries).toHaveLength(5);
90
+ });
91
+
92
+ it('should handle empty entries array', async () => {
93
+ const result = await QueuerUtil.batchSend([], 'https://queue-url');
94
+
95
+ expect(result).toEqual({});
96
+ expect(sqsMock.calls()).toHaveLength(0);
97
+ });
98
+
99
+ it('should send exact batch of 10 without remainder', async () => {
100
+ sqsMock.on(SendMessageBatchCommand).resolves({
101
+ Successful: [],
102
+ Failed: []
103
+ });
104
+
105
+ const entries = Array(10).fill().map((_, i) => ({ data: `test-${i}` }));
106
+ const queueUrl = 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue';
107
+
108
+ const result = await QueuerUtil.batchSend(entries, queueUrl);
109
+
110
+ expect(sqsMock.calls()).toHaveLength(1);
111
+ expect(result).toEqual({}); // Returns empty object when exact batch
112
+ });
113
+
114
+ it('should generate unique IDs for each entry', async () => {
115
+ sqsMock.on(SendMessageBatchCommand).resolves({
116
+ Successful: [],
117
+ Failed: []
118
+ });
119
+
120
+ const entries = [{ data: 'test-1' }, { data: 'test-2' }];
121
+ const queueUrl = 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue';
122
+
123
+ await QueuerUtil.batchSend(entries, queueUrl);
124
+
125
+ const sentEntries = sqsMock.call(0).args[0].input.Entries;
126
+ expect(sentEntries[0].Id).toBeDefined();
127
+ expect(sentEntries[1].Id).toBeDefined();
128
+ expect(sentEntries[0].Id).not.toBe(sentEntries[1].Id);
129
+ });
130
+ });
131
+ });
132
+
@@ -1,5 +1,5 @@
1
1
  declare module "@friggframework/core" {
2
- import { SQS } from "aws-sdk";
2
+ import type { SendMessageCommandInput } from "@aws-sdk/client-sqs";
3
3
 
4
4
  export class Delegate implements IFriggDelegate {
5
5
  delegate: any;
@@ -50,5 +50,5 @@ declare module "@friggframework/core" {
50
50
  QueueOwnerAWSAccountId?: string;
51
51
  };
52
52
 
53
- type SendSQSMessageParams = SQS.SendMessageRequest;
53
+ type SendSQSMessageParams = SendMessageCommandInput;
54
54
  }
@@ -1,5 +1,8 @@
1
1
  const { prisma } = require('../../database/prisma');
2
- const AWS = require('aws-sdk');
2
+ const {
3
+ ApiGatewayManagementApiClient,
4
+ PostToConnectionCommand,
5
+ } = require('@aws-sdk/client-apigatewaymanagementapi');
3
6
  const {
4
7
  WebsocketConnectionRepositoryInterface,
5
8
  } = require('./websocket-connection-repository-interface');
@@ -74,20 +77,18 @@ class WebsocketConnectionRepositoryMongo extends WebsocketConnectionRepositoryIn
74
77
  return connections.map((conn) => ({
75
78
  connectionId: conn.connectionId,
76
79
  send: async (data) => {
77
- const apigwManagementApi = new AWS.ApiGatewayManagementApi({
78
- apiVersion: '2018-11-29',
80
+ const apigwManagementApi = new ApiGatewayManagementApiClient({
79
81
  endpoint: process.env.WEBSOCKET_API_ENDPOINT,
80
82
  });
81
83
 
82
84
  try {
83
- await apigwManagementApi
84
- .postToConnection({
85
- ConnectionId: conn.connectionId,
86
- Data: JSON.stringify(data),
87
- })
88
- .promise();
85
+ const command = new PostToConnectionCommand({
86
+ ConnectionId: conn.connectionId,
87
+ Data: JSON.stringify(data),
88
+ });
89
+ await apigwManagementApi.send(command);
89
90
  } catch (error) {
90
- if (error.statusCode === 410) {
91
+ if (error.statusCode === 410 || error.$metadata?.httpStatusCode === 410) {
91
92
  console.log(
92
93
  `Stale connection ${conn.connectionId}`
93
94
  );
@@ -1,5 +1,8 @@
1
1
  const { prisma } = require('../../database/prisma');
2
- const AWS = require('aws-sdk');
2
+ const {
3
+ ApiGatewayManagementApiClient,
4
+ PostToConnectionCommand,
5
+ } = require('@aws-sdk/client-apigatewaymanagementapi');
3
6
  const {
4
7
  WebsocketConnectionRepositoryInterface,
5
8
  } = require('./websocket-connection-repository-interface');
@@ -108,20 +111,18 @@ class WebsocketConnectionRepositoryPostgres extends WebsocketConnectionRepositor
108
111
  return connections.map((conn) => ({
109
112
  connectionId: conn.connectionId,
110
113
  send: async (data) => {
111
- const apigwManagementApi = new AWS.ApiGatewayManagementApi({
112
- apiVersion: '2018-11-29',
114
+ const apigwManagementApi = new ApiGatewayManagementApiClient({
113
115
  endpoint: process.env.WEBSOCKET_API_ENDPOINT,
114
116
  });
115
117
 
116
118
  try {
117
- await apigwManagementApi
118
- .postToConnection({
119
- ConnectionId: conn.connectionId,
120
- Data: JSON.stringify(data),
121
- })
122
- .promise();
119
+ const command = new PostToConnectionCommand({
120
+ ConnectionId: conn.connectionId,
121
+ Data: JSON.stringify(data),
122
+ });
123
+ await apigwManagementApi.send(command);
123
124
  } catch (error) {
124
- if (error.statusCode === 410) {
125
+ if (error.statusCode === 410 || error.$metadata?.httpStatusCode === 410) {
125
126
  console.log(
126
127
  `Stale connection ${conn.connectionId}`
127
128
  );
@@ -1,5 +1,8 @@
1
1
  const { prisma } = require('../../database/prisma');
2
- const AWS = require('aws-sdk');
2
+ const {
3
+ ApiGatewayManagementApiClient,
4
+ PostToConnectionCommand,
5
+ } = require('@aws-sdk/client-apigatewaymanagementapi');
3
6
  const {
4
7
  WebsocketConnectionRepositoryInterface,
5
8
  } = require('./websocket-connection-repository-interface');
@@ -79,20 +82,18 @@ class WebsocketConnectionRepository extends WebsocketConnectionRepositoryInterfa
79
82
  return connections.map((conn) => ({
80
83
  connectionId: conn.connectionId,
81
84
  send: async (data) => {
82
- const apigwManagementApi = new AWS.ApiGatewayManagementApi({
83
- apiVersion: '2018-11-29',
85
+ const apigwManagementApi = new ApiGatewayManagementApiClient({
84
86
  endpoint: process.env.WEBSOCKET_API_ENDPOINT,
85
87
  });
86
88
 
87
89
  try {
88
- await apigwManagementApi
89
- .postToConnection({
90
- ConnectionId: conn.connectionId,
91
- Data: JSON.stringify(data),
92
- })
93
- .promise();
90
+ const command = new PostToConnectionCommand({
91
+ ConnectionId: conn.connectionId,
92
+ Data: JSON.stringify(data),
93
+ });
94
+ await apigwManagementApi.send(command);
94
95
  } catch (error) {
95
- if (error.statusCode === 410) {
96
+ if (error.statusCode === 410 || error.$metadata?.httpStatusCode === 410) {
96
97
  console.log(
97
98
  `Stale connection ${conn.connectionId}`
98
99
  );
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Tests for WebSocket Connection Repository - AWS SDK v3 Migration
3
+ *
4
+ * Tests API Gateway Management API operations using aws-sdk-client-mock
5
+ */
6
+
7
+ const { mockClient } = require('aws-sdk-client-mock');
8
+ const { ApiGatewayManagementApiClient, PostToConnectionCommand } = require('@aws-sdk/client-apigatewaymanagementapi');
9
+ const { WebsocketConnectionRepository } = require('./websocket-connection-repository');
10
+
11
+ // Mock Prisma
12
+ jest.mock('../../database/prisma', () => ({
13
+ prisma: {
14
+ websocketConnection: {
15
+ create: jest.fn(),
16
+ delete: jest.fn(),
17
+ findMany: jest.fn(),
18
+ findFirst: jest.fn(),
19
+ findUnique: jest.fn(),
20
+ deleteMany: jest.fn(),
21
+ },
22
+ },
23
+ }));
24
+
25
+ const { prisma } = require('../../database/prisma');
26
+
27
+ describe('WebsocketConnectionRepository - AWS SDK v3', () => {
28
+ let apiGatewayMock;
29
+ let repository;
30
+ const originalEnv = process.env;
31
+
32
+ beforeEach(() => {
33
+ apiGatewayMock = mockClient(ApiGatewayManagementApiClient);
34
+ repository = new WebsocketConnectionRepository();
35
+ jest.clearAllMocks();
36
+ process.env = {
37
+ ...originalEnv,
38
+ WEBSOCKET_API_ENDPOINT: 'https://test.execute-api.us-east-1.amazonaws.com/dev'
39
+ };
40
+ });
41
+
42
+ afterEach(() => {
43
+ apiGatewayMock.reset();
44
+ process.env = originalEnv;
45
+ });
46
+
47
+ describe('createConnection()', () => {
48
+ it('should create websocket connection record', async () => {
49
+ const mockConnection = { id: '1', connectionId: 'test-connection-123' };
50
+ prisma.websocketConnection.create.mockResolvedValue(mockConnection);
51
+
52
+ const result = await repository.createConnection('test-connection-123');
53
+
54
+ expect(result).toEqual(mockConnection);
55
+ expect(prisma.websocketConnection.create).toHaveBeenCalledWith({
56
+ data: { connectionId: 'test-connection-123' },
57
+ });
58
+ });
59
+ });
60
+
61
+ describe('deleteConnection()', () => {
62
+ it('should delete websocket connection', async () => {
63
+ prisma.websocketConnection.delete.mockResolvedValue({});
64
+
65
+ const result = await repository.deleteConnection('test-connection-123');
66
+
67
+ expect(result).toEqual({ acknowledged: true, deletedCount: 1 });
68
+ expect(prisma.websocketConnection.delete).toHaveBeenCalledWith({
69
+ where: { connectionId: 'test-connection-123' },
70
+ });
71
+ });
72
+
73
+ it('should handle connection not found', async () => {
74
+ const error = new Error('Record not found');
75
+ error.code = 'P2025';
76
+ prisma.websocketConnection.delete.mockRejectedValue(error);
77
+
78
+ const result = await repository.deleteConnection('nonexistent');
79
+
80
+ expect(result).toEqual({ acknowledged: true, deletedCount: 0 });
81
+ });
82
+ });
83
+
84
+ describe('getActiveConnections()', () => {
85
+ it('should return empty array if no WEBSOCKET_API_ENDPOINT', async () => {
86
+ delete process.env.WEBSOCKET_API_ENDPOINT;
87
+
88
+ const result = await repository.getActiveConnections();
89
+
90
+ expect(result).toEqual([]);
91
+ expect(prisma.websocketConnection.findMany).not.toHaveBeenCalled();
92
+ });
93
+
94
+ it('should get active connections with send capability', async () => {
95
+ prisma.websocketConnection.findMany.mockResolvedValue([
96
+ { connectionId: 'conn-1' },
97
+ { connectionId: 'conn-2' },
98
+ ]);
99
+
100
+ apiGatewayMock.on(PostToConnectionCommand).resolves({});
101
+
102
+ const connections = await repository.getActiveConnections();
103
+
104
+ expect(connections).toHaveLength(2);
105
+ expect(connections[0].connectionId).toBe('conn-1');
106
+ expect(connections[1].connectionId).toBe('conn-2');
107
+ expect(typeof connections[0].send).toBe('function');
108
+ });
109
+
110
+ it('should send data through API Gateway Management API', async () => {
111
+ prisma.websocketConnection.findMany.mockResolvedValue([
112
+ { connectionId: 'conn-test' },
113
+ ]);
114
+
115
+ apiGatewayMock.on(PostToConnectionCommand).resolves({});
116
+
117
+ const connections = await repository.getActiveConnections();
118
+ await connections[0].send({ message: 'hello' });
119
+
120
+ expect(apiGatewayMock.calls()).toHaveLength(1);
121
+
122
+ const call = apiGatewayMock.call(0);
123
+ expect(call.args[0].input).toMatchObject({
124
+ ConnectionId: 'conn-test',
125
+ Data: JSON.stringify({ message: 'hello' }),
126
+ });
127
+ });
128
+
129
+ it('should delete stale connection on 410 error', async () => {
130
+ prisma.websocketConnection.findMany.mockResolvedValue([
131
+ { connectionId: 'stale-conn' },
132
+ ]);
133
+
134
+ const error = new Error('Gone');
135
+ error.statusCode = 410;
136
+ apiGatewayMock.on(PostToConnectionCommand).rejects(error);
137
+
138
+ prisma.websocketConnection.deleteMany.mockResolvedValue({ count: 1 });
139
+
140
+ const connections = await repository.getActiveConnections();
141
+ await connections[0].send({ message: 'test' });
142
+
143
+ // Should have called deleteMany to remove stale connection
144
+ expect(prisma.websocketConnection.deleteMany).toHaveBeenCalledWith({
145
+ where: { connectionId: 'stale-conn' },
146
+ });
147
+ });
148
+
149
+ it('should delete stale connection on 410 error (v3 metadata format)', async () => {
150
+ prisma.websocketConnection.findMany.mockResolvedValue([
151
+ { connectionId: 'stale-conn' },
152
+ ]);
153
+
154
+ const error = new Error('Gone');
155
+ error.$metadata = { httpStatusCode: 410 };
156
+ apiGatewayMock.on(PostToConnectionCommand).rejects(error);
157
+
158
+ prisma.websocketConnection.deleteMany.mockResolvedValue({ count: 1 });
159
+
160
+ const connections = await repository.getActiveConnections();
161
+ await connections[0].send({ message: 'test' });
162
+
163
+ expect(prisma.websocketConnection.deleteMany).toHaveBeenCalledWith({
164
+ where: { connectionId: 'stale-conn' },
165
+ });
166
+ });
167
+
168
+ it('should throw non-410 errors', async () => {
169
+ prisma.websocketConnection.findMany.mockResolvedValue([
170
+ { connectionId: 'conn-1' },
171
+ ]);
172
+
173
+ apiGatewayMock.on(PostToConnectionCommand).rejects(new Error('Network error'));
174
+
175
+ const connections = await repository.getActiveConnections();
176
+
177
+ await expect(connections[0].send({ message: 'test' })).rejects.toThrow('Network error');
178
+ });
179
+ });
180
+
181
+ describe('findConnection()', () => {
182
+ it('should find connection by connectionId', async () => {
183
+ const mockConnection = { id: '1', connectionId: 'conn-123' };
184
+ prisma.websocketConnection.findFirst.mockResolvedValue(mockConnection);
185
+
186
+ const result = await repository.findConnection('conn-123');
187
+
188
+ expect(result).toEqual(mockConnection);
189
+ expect(prisma.websocketConnection.findFirst).toHaveBeenCalledWith({
190
+ where: { connectionId: 'conn-123' },
191
+ });
192
+ });
193
+
194
+ it('should return null if not found', async () => {
195
+ prisma.websocketConnection.findFirst.mockResolvedValue(null);
196
+
197
+ const result = await repository.findConnection('nonexistent');
198
+
199
+ expect(result).toBeNull();
200
+ });
201
+ });
202
+
203
+ describe('getAllConnections()', () => {
204
+ it('should get all connections', async () => {
205
+ const mockConnections = [
206
+ { id: '1', connectionId: 'conn-1' },
207
+ { id: '2', connectionId: 'conn-2' },
208
+ ];
209
+ prisma.websocketConnection.findMany.mockResolvedValue(mockConnections);
210
+
211
+ const result = await repository.getAllConnections();
212
+
213
+ expect(result).toEqual(mockConnections);
214
+ });
215
+ });
216
+
217
+ describe('deleteAllConnections()', () => {
218
+ it('should delete all connections', async () => {
219
+ prisma.websocketConnection.deleteMany.mockResolvedValue({ count: 5 });
220
+
221
+ const result = await repository.deleteAllConnections();
222
+
223
+ expect(result).toEqual({ acknowledged: true, deletedCount: 5 });
224
+ });
225
+ });
226
+ });
227
+