@friggframework/devtools 2.0.0-next.75 → 2.0.0-next.77

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.
@@ -15,9 +15,15 @@
15
15
  * and externally-provided SQS queues.
16
16
  */
17
17
 
18
- const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder');
18
+ const {
19
+ InfrastructureBuilder,
20
+ ValidationResult,
21
+ } = require('../shared/base-builder');
19
22
  const IntegrationResourceResolver = require('./integration-resolver');
20
- const { createEmptyDiscoveryResult, ResourceOwnership } = require('../shared/types');
23
+ const {
24
+ createEmptyDiscoveryResult,
25
+ ResourceOwnership,
26
+ } = require('../shared/types');
21
27
 
22
28
  class IntegrationBuilder extends InfrastructureBuilder {
23
29
  constructor() {
@@ -26,7 +32,10 @@ class IntegrationBuilder extends InfrastructureBuilder {
26
32
  }
27
33
 
28
34
  shouldExecute(appDefinition) {
29
- return Array.isArray(appDefinition.integrations) && appDefinition.integrations.length > 0;
35
+ return (
36
+ Array.isArray(appDefinition.integrations) &&
37
+ appDefinition.integrations.length > 0
38
+ );
30
39
  }
31
40
 
32
41
  getDependencies() {
@@ -48,7 +57,9 @@ class IntegrationBuilder extends InfrastructureBuilder {
48
57
  // Validate each integration
49
58
  appDefinition.integrations.forEach((integration, index) => {
50
59
  if (!integration?.Definition?.name) {
51
- result.addError(`Integration at index ${index} is missing Definition or name`);
60
+ result.addError(
61
+ `Integration at index ${index} is missing Definition or name`
62
+ );
52
63
  }
53
64
  });
54
65
 
@@ -60,7 +71,9 @@ class IntegrationBuilder extends InfrastructureBuilder {
60
71
  */
61
72
  async build(appDefinition, discoveredResources) {
62
73
  console.log(`\n[${this.name}] Configuring integrations...`);
63
- console.log(` Processing ${appDefinition.integrations.length} integrations...`);
74
+ console.log(
75
+ ` Processing ${appDefinition.integrations.length} integrations...`
76
+ );
64
77
 
65
78
  const usePrismaLayer = appDefinition.usePrismaLambdaLayer !== false;
66
79
 
@@ -73,23 +86,34 @@ class IntegrationBuilder extends InfrastructureBuilder {
73
86
  };
74
87
 
75
88
  // Get structured discovery result
76
- const discovery = discoveredResources._structured || this.convertFlatDiscoveryToStructured(discoveredResources);
89
+ const discovery =
90
+ discoveredResources._structured ||
91
+ this.convertFlatDiscoveryToStructured(discoveredResources);
77
92
 
78
93
  // Use IntegrationResourceResolver to make ownership decisions
79
94
  const resolver = new IntegrationResourceResolver();
80
95
  const decisions = resolver.resolveAll(appDefinition, discovery);
81
96
 
82
97
  console.log('\n 📋 Resource Ownership Decisions:');
83
- console.log(` InternalErrorQueue: ${decisions.internalErrorQueue.ownership} - ${decisions.internalErrorQueue.reason}`);
98
+ console.log(
99
+ ` InternalErrorQueue: ${decisions.internalErrorQueue.ownership} - ${decisions.internalErrorQueue.reason}`
100
+ );
84
101
 
85
102
  // Log per-integration decisions
86
- Object.keys(decisions.integrations).forEach(integrationName => {
103
+ Object.keys(decisions.integrations).forEach((integrationName) => {
87
104
  const queueDecision = decisions.integrations[integrationName].queue;
88
- console.log(` ${integrationName}Queue: ${queueDecision.ownership} - ${queueDecision.reason}`);
105
+ console.log(
106
+ ` ${integrationName}Queue: ${queueDecision.ownership} - ${queueDecision.reason}`
107
+ );
89
108
  });
90
109
 
91
110
  // Build resources based on ownership decisions
92
- await this.buildFromDecisions(decisions, appDefinition, result, usePrismaLayer);
111
+ await this.buildFromDecisions(
112
+ decisions,
113
+ appDefinition,
114
+ result,
115
+ usePrismaLayer
116
+ );
93
117
 
94
118
  console.log(`[${this.name}] ✅ Integration configuration completed`);
95
119
  return result;
@@ -113,7 +137,7 @@ class IntegrationBuilder extends InfrastructureBuilder {
113
137
 
114
138
  // Add stack-managed resources from existingLogicalIds
115
139
  const existingLogicalIds = flatDiscovery.existingLogicalIds || [];
116
- existingLogicalIds.forEach(logicalId => {
140
+ existingLogicalIds.forEach((logicalId) => {
117
141
  let resourceType = '';
118
142
  let physicalId = '';
119
143
 
@@ -124,7 +148,9 @@ class IntegrationBuilder extends InfrastructureBuilder {
124
148
  } else if (logicalId.endsWith('Queue')) {
125
149
  // Integration-specific queue (e.g., SlackQueue, HubspotQueue)
126
150
  resourceType = 'AWS::SQS::Queue';
127
- const integrationName = logicalId.replace('Queue', '').toLowerCase();
151
+ const integrationName = logicalId
152
+ .replace('Queue', '')
153
+ .toLowerCase();
128
154
  physicalId = flatDiscovery[`${integrationName}QueueUrl`];
129
155
  }
130
156
 
@@ -132,7 +158,7 @@ class IntegrationBuilder extends InfrastructureBuilder {
132
158
  discovery.stackManaged.push({
133
159
  logicalId,
134
160
  physicalId,
135
- resourceType
161
+ resourceType,
136
162
  });
137
163
  }
138
164
  });
@@ -144,21 +170,32 @@ class IntegrationBuilder extends InfrastructureBuilder {
144
170
  /**
145
171
  * Build integration resources based on ownership decisions
146
172
  */
147
- async buildFromDecisions(decisions, appDefinition, result, usePrismaLayer = true) {
173
+ async buildFromDecisions(
174
+ decisions,
175
+ appDefinition,
176
+ result,
177
+ usePrismaLayer = true
178
+ ) {
179
+ // Create package config first — needed by all Lambda functions including DLQ processor
180
+ const functionPackageConfig =
181
+ this.createFunctionPackageConfig(usePrismaLayer);
182
+
148
183
  // Create InternalErrorQueue if ownership = STACK
149
- const shouldCreateInternalErrorQueue = decisions.internalErrorQueue.ownership === ResourceOwnership.STACK;
184
+ const shouldCreateInternalErrorQueue =
185
+ decisions.internalErrorQueue.ownership === ResourceOwnership.STACK;
150
186
 
151
187
  if (shouldCreateInternalErrorQueue) {
152
188
  console.log(' → Creating InternalErrorQueue in stack');
153
- this.createInternalErrorQueue(result);
189
+ this.createInternalErrorQueue(result, functionPackageConfig);
154
190
  } else {
155
191
  console.log(' → Using external InternalErrorQueue');
156
- this.useExternalInternalErrorQueue(decisions.internalErrorQueue, result);
192
+ this.useExternalInternalErrorQueue(
193
+ decisions.internalErrorQueue,
194
+ result,
195
+ functionPackageConfig
196
+ );
157
197
  }
158
198
 
159
- // Create Lambda function definitions and queue resources for each integration
160
- const functionPackageConfig = this.createFunctionPackageConfig(usePrismaLayer);
161
-
162
199
  for (const integration of appDefinition.integrations) {
163
200
  const integrationName = integration.Definition.name;
164
201
  const queueDecision = decisions.integrations[integrationName].queue;
@@ -166,17 +203,29 @@ class IntegrationBuilder extends InfrastructureBuilder {
166
203
  console.log(`\n Adding integration: ${integrationName}`);
167
204
 
168
205
  // Create Lambda function definitions (serverless template code)
169
- await this.createFunctionDefinitions(integration, functionPackageConfig, result, usePrismaLayer);
206
+ await this.createFunctionDefinitions(
207
+ integration,
208
+ functionPackageConfig,
209
+ result,
210
+ usePrismaLayer
211
+ );
170
212
 
171
213
  // Create or reference SQS queue based on ownership decision
172
- const shouldCreateQueue = queueDecision.ownership === ResourceOwnership.STACK;
214
+ const shouldCreateQueue =
215
+ queueDecision.ownership === ResourceOwnership.STACK;
173
216
 
174
217
  if (shouldCreateQueue) {
175
- console.log(` ✓ Creating ${integrationName}Queue in stack`);
218
+ console.log(
219
+ ` ✓ Creating ${integrationName}Queue in stack`
220
+ );
176
221
  this.createIntegrationQueue(integrationName, result);
177
222
  } else {
178
223
  console.log(` ✓ Using external ${integrationName}Queue`);
179
- this.useExternalIntegrationQueue(integrationName, queueDecision, result);
224
+ this.useExternalIntegrationQueue(
225
+ integrationName,
226
+ queueDecision,
227
+ result
228
+ );
180
229
  }
181
230
  }
182
231
  }
@@ -192,12 +241,14 @@ class IntegrationBuilder extends InfrastructureBuilder {
192
241
  'node_modules/@aws-sdk/**',
193
242
 
194
243
  // Exclude Prisma (provided via Lambda Layer)
195
- ...(usePrismaLayer ? [
196
- 'node_modules/@prisma/**',
197
- 'node_modules/.prisma/**',
198
- 'node_modules/prisma/**',
199
- 'node_modules/@friggframework/core/generated/**',
200
- ] : []),
244
+ ...(usePrismaLayer
245
+ ? [
246
+ 'node_modules/@prisma/**',
247
+ 'node_modules/.prisma/**',
248
+ 'node_modules/prisma/**',
249
+ 'node_modules/@friggframework/core/generated/**',
250
+ ]
251
+ : []),
201
252
 
202
253
  // Exclude ALL nested node_modules
203
254
  'node_modules/**/node_modules/**',
@@ -253,21 +304,31 @@ class IntegrationBuilder extends InfrastructureBuilder {
253
304
  * Create Lambda function definitions for an integration
254
305
  * These are serverless framework template function definitions
255
306
  */
256
- async createFunctionDefinitions(integration, functionPackageConfig, result, usePrismaLayer = true) {
307
+ async createFunctionDefinitions(
308
+ integration,
309
+ functionPackageConfig,
310
+ result,
311
+ usePrismaLayer = true
312
+ ) {
257
313
  const integrationName = integration.Definition.name;
258
314
 
259
315
  // Add webhook handler if enabled (BEFORE catch-all proxy route)
260
316
  // CRITICAL: Webhook routes must be defined before the catch-all {proxy+} route
261
317
  // to ensure proper route matching in AWS API Gateway/HTTP API
262
318
  const webhookConfig = integration.Definition.webhooks;
263
- if (webhookConfig && (webhookConfig === true || webhookConfig.enabled === true)) {
319
+ if (
320
+ webhookConfig &&
321
+ (webhookConfig === true || webhookConfig.enabled === true)
322
+ ) {
264
323
  const webhookFunctionName = `${integrationName}Webhook`;
265
324
 
266
325
  result.functions[webhookFunctionName] = {
267
326
  handler: `node_modules/@friggframework/core/handlers/routers/integration-webhook-routers.handlers.${integrationName}Webhook.handler`,
268
- skipEsbuild: true, // Nested exports in node_modules - skip esbuild bundling
327
+ skipEsbuild: true, // Nested exports in node_modules - skip esbuild bundling
269
328
  package: functionPackageConfig,
270
- ...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }), // Webhook handlers need Prisma for credential lookups
329
+ ...(usePrismaLayer && {
330
+ layers: [{ Ref: 'PrismaLambdaLayer' }],
331
+ }), // Webhook handlers need Prisma for credential lookups
271
332
  events: [
272
333
  {
273
334
  httpApi: {
@@ -289,9 +350,9 @@ class IntegrationBuilder extends InfrastructureBuilder {
289
350
  // Create HTTP API handler for integration (catch-all route AFTER webhooks)
290
351
  result.functions[integrationName] = {
291
352
  handler: `node_modules/@friggframework/core/handlers/routers/integration-defined-routers.handlers.${integrationName}.handler`,
292
- skipEsbuild: true, // Nested exports in node_modules - skip esbuild bundling
353
+ skipEsbuild: true, // Nested exports in node_modules - skip esbuild bundling
293
354
  package: functionPackageConfig,
294
- ...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }), // HTTP handlers need Prisma for integration queries
355
+ ...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }), // HTTP handlers need Prisma for integration queries
295
356
  events: [
296
357
  {
297
358
  httpApi: {
@@ -307,19 +368,25 @@ class IntegrationBuilder extends InfrastructureBuilder {
307
368
  const queueWorkerName = `${integrationName}QueueWorker`;
308
369
  result.functions[queueWorkerName] = {
309
370
  handler: `node_modules/@friggframework/core/handlers/workers/integration-defined-workers.handlers.${integrationName}.queueWorker`,
310
- skipEsbuild: true, // Nested exports in node_modules - skip esbuild bundling
371
+ skipEsbuild: true, // Nested exports in node_modules - skip esbuild bundling
311
372
  package: functionPackageConfig,
312
- ...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }), // Queue workers need Prisma for database operations
313
- reservedConcurrency: 5,
373
+ ...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }), // Queue workers need Prisma for database operations
374
+ reservedConcurrency: 20,
314
375
  events: [
315
376
  {
316
377
  sqs: {
317
- arn: { 'Fn::GetAtt': [`${this.capitalizeFirst(integrationName)}Queue`, 'Arn'] },
378
+ arn: {
379
+ 'Fn::GetAtt': [
380
+ `${this.capitalizeFirst(integrationName)}Queue`,
381
+ 'Arn',
382
+ ],
383
+ },
318
384
  batchSize: 1,
385
+ functionResponseType: 'ReportBatchItemFailures',
319
386
  },
320
387
  },
321
388
  ],
322
- timeout: 900, // 15 minutes max for queue workers (Lambda maximum)
389
+ timeout: 900, // 15 minutes max for queue workers (Lambda maximum)
323
390
  };
324
391
  console.log(` ✓ Queue worker function defined`);
325
392
  }
@@ -327,27 +394,102 @@ class IntegrationBuilder extends InfrastructureBuilder {
327
394
  /**
328
395
  * Create InternalErrorQueue CloudFormation resource
329
396
  */
330
- createInternalErrorQueue(result) {
397
+ createInternalErrorQueue(result, functionPackageConfig) {
398
+ const queueName =
399
+ '${self:service}-${self:provider.stage}-InternalErrorQueue';
400
+
401
+ result.custom.InternalErrorQueue = queueName;
402
+
331
403
  result.resources.InternalErrorQueue = {
332
404
  Type: 'AWS::SQS::Queue',
333
405
  Properties: {
334
- QueueName: '${self:service}-${self:provider.stage}-InternalErrorQueue',
406
+ QueueName: '${self:custom.InternalErrorQueue}',
335
407
  MessageRetentionPeriod: 1209600, // 14 days
336
- VisibilityTimeout: 300, // 5 minutes for error processing
408
+ VisibilityTimeout: 300, // 5 minutes must be >= 6x DLQ processor Lambda timeout (30s × 6 = 180s)
337
409
  },
338
410
  };
339
411
 
412
+ this.createDLQObservability(
413
+ result,
414
+ functionPackageConfig,
415
+ {
416
+ 'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
417
+ },
418
+ {
419
+ 'Fn::GetAtt': ['InternalErrorQueue', 'QueueName'],
420
+ }
421
+ );
422
+
340
423
  console.log(' ✓ Created InternalErrorQueue resource');
341
424
  }
342
425
 
343
426
  /**
344
427
  * Use external InternalErrorQueue
345
428
  */
346
- useExternalInternalErrorQueue(decision, result) {
429
+ useExternalInternalErrorQueue(decision, result, functionPackageConfig) {
347
430
  // Add ARN to environment for Lambda functions
348
431
  result.environment.INTERNAL_ERROR_QUEUE_ARN = decision.physicalId;
349
432
 
350
- console.log(` ✓ Using external InternalErrorQueue: ${decision.physicalId}`);
433
+ // Extract queue name from ARN for CloudWatch dimensions
434
+ const arnParts = decision.physicalId.split(':');
435
+ const queueName = arnParts[arnParts.length - 1];
436
+
437
+ this.createDLQObservability(
438
+ result,
439
+ functionPackageConfig,
440
+ decision.physicalId,
441
+ queueName
442
+ );
443
+
444
+ console.log(
445
+ ` ✓ Using external InternalErrorQueue: ${decision.physicalId}`
446
+ );
447
+ }
448
+
449
+ /**
450
+ * Create DLQ observability resources (alarm + processor Lambda).
451
+ * Called for both stack-owned and external InternalErrorQueues.
452
+ */
453
+ createDLQObservability(result, functionPackageConfig, queueArn, queueName) {
454
+ // CloudWatch Alarm: fires when any message lands in the DLQ
455
+ result.resources.DLQMessageAlarm = {
456
+ Type: 'AWS::CloudWatch::Alarm',
457
+ Properties: {
458
+ AlarmDescription:
459
+ 'Messages in dead-letter queue — integration queue processing failures',
460
+ Namespace: 'AWS/SQS',
461
+ MetricName: 'ApproximateNumberOfMessagesVisible',
462
+ Statistic: 'Maximum',
463
+ Threshold: 500,
464
+ ComparisonOperator: 'GreaterThanThreshold',
465
+ EvaluationPeriods: 1,
466
+ Period: 300,
467
+ AlarmActions: [{ Ref: 'InternalErrorBridgeTopic' }],
468
+ Dimensions: [{ Name: 'QueueName', Value: queueName }],
469
+ },
470
+ };
471
+
472
+ // DLQ processor Lambda: logs failed messages with structured context
473
+ result.functions.dlqProcessor = {
474
+ handler:
475
+ 'node_modules/@friggframework/core/handlers/workers/dlq-processor.dlqProcessor',
476
+ skipEsbuild: true,
477
+ package: functionPackageConfig,
478
+ reservedConcurrency: 1,
479
+ timeout: 30,
480
+ events: [
481
+ {
482
+ sqs: {
483
+ arn: queueArn,
484
+ batchSize: 10,
485
+ functionResponseType: 'ReportBatchItemFailures',
486
+ },
487
+ },
488
+ ],
489
+ };
490
+
491
+ console.log(' ✓ Created DLQ CloudWatch alarm');
492
+ console.log(' ✓ Created DLQ processor Lambda');
351
493
  }
352
494
 
353
495
  /**
@@ -361,10 +503,10 @@ class IntegrationBuilder extends InfrastructureBuilder {
361
503
  Type: 'AWS::SQS::Queue',
362
504
  Properties: {
363
505
  QueueName: `\${self:custom.${queueReference}}`,
364
- MessageRetentionPeriod: 60,
506
+ MessageRetentionPeriod: 345600, // 4 days (SQS default)
365
507
  VisibilityTimeout: 1800,
366
508
  RedrivePolicy: {
367
- maxReceiveCount: 1,
509
+ maxReceiveCount: 3,
368
510
  deadLetterTargetArn: {
369
511
  'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
370
512
  },
@@ -388,7 +530,8 @@ class IntegrationBuilder extends InfrastructureBuilder {
388
530
  */
389
531
  useExternalIntegrationQueue(integrationName, decision, result) {
390
532
  // Add queue URL to environment for Lambda functions
391
- result.environment[`${integrationName.toUpperCase()}_QUEUE_URL`] = decision.physicalId;
533
+ result.environment[`${integrationName.toUpperCase()}_QUEUE_URL`] =
534
+ decision.physicalId;
392
535
 
393
536
  console.log(` ✓ Using external queue: ${decision.physicalId}`);
394
537
  }
@@ -186,7 +186,7 @@ describe('IntegrationBuilder', () => {
186
186
 
187
187
  const result = await integrationBuilder.build(appDefinition, {});
188
188
 
189
- expect(result.resources.TestQueue.Properties.MessageRetentionPeriod).toBe(60);
189
+ expect(result.resources.TestQueue.Properties.MessageRetentionPeriod).toBe(345600);
190
190
  expect(result.resources.TestQueue.Properties.VisibilityTimeout).toBe(1800);
191
191
  });
192
192
 
@@ -200,7 +200,7 @@ describe('IntegrationBuilder', () => {
200
200
  const result = await integrationBuilder.build(appDefinition, {});
201
201
 
202
202
  expect(result.resources.TestQueue.Properties.RedrivePolicy).toEqual({
203
- maxReceiveCount: 1,
203
+ maxReceiveCount: 3,
204
204
  deadLetterTargetArn: {
205
205
  'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
206
206
  },
@@ -233,6 +233,7 @@ describe('IntegrationBuilder', () => {
233
233
  sqs: {
234
234
  arn: { 'Fn::GetAtt': ['TestQueue', 'Arn'] },
235
235
  batchSize: 1,
236
+ functionResponseType: 'ReportBatchItemFailures',
236
237
  },
237
238
  },
238
239
  ]);
@@ -259,7 +260,7 @@ describe('IntegrationBuilder', () => {
259
260
 
260
261
  const result = await integrationBuilder.build(appDefinition, {});
261
262
 
262
- expect(result.functions.testQueueWorker.reservedConcurrency).toBe(5);
263
+ expect(result.functions.testQueueWorker.reservedConcurrency).toBe(20);
263
264
  });
264
265
 
265
266
  it('should add queue URL to environment variables', async () => {
@@ -343,6 +344,112 @@ describe('IntegrationBuilder', () => {
343
344
  expect(result.functions['my-integration']).toBeDefined();
344
345
  expect(result.functions['my-integrationQueueWorker']).toBeDefined();
345
346
  });
347
+
348
+ // ============================================================
349
+ // Theory-proving tests: demonstrate current dangerous config
350
+ // These tests document the root cause of the Modern Midstay bug
351
+ // where POST_CREATE_SETUP messages were silently lost.
352
+ // ============================================================
353
+
354
+ it('THEORY: MessageRetentionPeriod is too short for delayed messages', async () => {
355
+ // POST_CREATE_SETUP uses DelaySeconds=35.
356
+ // With MessageRetentionPeriod=60, the message is only visible
357
+ // for 25 seconds before SQS silently deletes it.
358
+ // Messages that expire are NOT sent to DLQ — they vanish.
359
+ const appDefinition = {
360
+ integrations: [{ Definition: { name: 'test' } }],
361
+ };
362
+
363
+ const result = await integrationBuilder.build(appDefinition, {});
364
+ const retention = result.resources.TestQueue.Properties.MessageRetentionPeriod;
365
+
366
+ // The max SQS DelaySeconds is 900. Retention must comfortably
367
+ // exceed this to ensure delayed messages are never silently lost.
368
+ // Current value (60) fails this check.
369
+ expect(retention).toBeGreaterThan(900);
370
+ });
371
+
372
+ it('THEORY: maxReceiveCount=1 means zero retries on transient failures', async () => {
373
+ // A single transient error (network blip, cold start timeout,
374
+ // rate limit) sends the message straight to DLQ with no retry.
375
+ const appDefinition = {
376
+ integrations: [{ Definition: { name: 'test' } }],
377
+ };
378
+
379
+ const result = await integrationBuilder.build(appDefinition, {});
380
+ const maxReceiveCount = result.resources.TestQueue.Properties.RedrivePolicy.maxReceiveCount;
381
+
382
+ // Should allow at least 2 retries (maxReceiveCount >= 3)
383
+ expect(maxReceiveCount).toBeGreaterThanOrEqual(3);
384
+ });
385
+
386
+ it('THEORY: SQS event source should enable ReportBatchItemFailures', async () => {
387
+ // Without this, Lambda can't tell SQS which specific messages
388
+ // failed — it's all-or-nothing for the entire invocation.
389
+ const appDefinition = {
390
+ integrations: [{ Definition: { name: 'test' } }],
391
+ };
392
+
393
+ const result = await integrationBuilder.build(appDefinition, {});
394
+ const sqsEvent = result.functions.testQueueWorker.events[0].sqs;
395
+
396
+ expect(sqsEvent.functionResponseType).toBe('ReportBatchItemFailures');
397
+ });
398
+ });
399
+
400
+ describe('DLQ Observability', () => {
401
+ it('should create a CloudWatch alarm for DLQ message depth', async () => {
402
+ const appDefinition = {
403
+ integrations: [{ Definition: { name: 'test' } }],
404
+ };
405
+
406
+ const result = await integrationBuilder.build(appDefinition, {});
407
+
408
+ expect(result.resources.DLQMessageAlarm).toBeDefined();
409
+ expect(result.resources.DLQMessageAlarm.Type).toBe('AWS::CloudWatch::Alarm');
410
+ expect(result.resources.DLQMessageAlarm.Properties.MetricName).toBe('ApproximateNumberOfMessagesVisible');
411
+ expect(result.resources.DLQMessageAlarm.Properties.ComparisonOperator).toBe('GreaterThanThreshold');
412
+ expect(result.resources.DLQMessageAlarm.Properties.Threshold).toBe(500);
413
+ });
414
+
415
+ it('should wire alarm to InternalErrorBridgeTopic for notifications', async () => {
416
+ const appDefinition = {
417
+ integrations: [{ Definition: { name: 'test' } }],
418
+ };
419
+
420
+ const result = await integrationBuilder.build(appDefinition, {});
421
+
422
+ expect(result.resources.DLQMessageAlarm.Properties.AlarmActions).toEqual([
423
+ { Ref: 'InternalErrorBridgeTopic' },
424
+ ]);
425
+ });
426
+
427
+ it('should create a DLQ processor Lambda triggered by InternalErrorQueue', async () => {
428
+ const appDefinition = {
429
+ integrations: [{ Definition: { name: 'test' } }],
430
+ };
431
+
432
+ const result = await integrationBuilder.build(appDefinition, {});
433
+
434
+ expect(result.functions.dlqProcessor).toBeDefined();
435
+ expect(result.functions.dlqProcessor.events[0].sqs.arn).toEqual({
436
+ 'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
437
+ });
438
+ expect(result.functions.dlqProcessor.events[0].sqs.functionResponseType).toBe('ReportBatchItemFailures');
439
+ });
440
+
441
+ it('DLQ processor should have skipEsbuild, short timeout, and low concurrency', async () => {
442
+ const appDefinition = {
443
+ integrations: [{ Definition: { name: 'test' } }],
444
+ };
445
+
446
+ const result = await integrationBuilder.build(appDefinition, {});
447
+
448
+ expect(result.functions.dlqProcessor.skipEsbuild).toBe(true);
449
+ expect(result.functions.dlqProcessor.package).toBeDefined();
450
+ expect(result.functions.dlqProcessor.timeout).toBeLessThanOrEqual(60);
451
+ expect(result.functions.dlqProcessor.reservedConcurrency).toBe(1);
452
+ });
346
453
  });
347
454
 
348
455
  describe('getDependencies()', () => {
@@ -503,8 +610,9 @@ describe('IntegrationBuilder', () => {
503
610
 
504
611
  const functionKeys = Object.keys(result.functions);
505
612
 
506
- // Expected order: webhook, integration, queueWorker
613
+ // Expected order: dlqProcessor (from InternalErrorQueue), webhook, integration, queueWorker
507
614
  expect(functionKeys).toEqual([
615
+ 'dlqProcessor',
508
616
  'testWebhook',
509
617
  'test',
510
618
  'testQueueWorker',
@@ -1148,10 +1148,10 @@ describe('composeServerlessDefinition', () => {
1148
1148
  Type: 'AWS::SQS::Queue',
1149
1149
  Properties: {
1150
1150
  QueueName: '${self:custom.TestIntegrationQueue}',
1151
- MessageRetentionPeriod: 60,
1151
+ MessageRetentionPeriod: 345600,
1152
1152
  VisibilityTimeout: 1800,
1153
1153
  RedrivePolicy: {
1154
- maxReceiveCount: 1,
1154
+ maxReceiveCount: 3,
1155
1155
  deadLetterTargetArn: {
1156
1156
  'Fn::GetAtt': ['InternalErrorQueue', 'Arn']
1157
1157
  }
@@ -1162,13 +1162,14 @@ describe('composeServerlessDefinition', () => {
1162
1162
  // Check Queue Worker
1163
1163
  expect(result.functions.testIntegrationQueueWorker).toEqual({
1164
1164
  handler: 'node_modules/@friggframework/core/handlers/workers/integration-defined-workers.handlers.testIntegration.queueWorker',
1165
- reservedConcurrency: 5,
1165
+ reservedConcurrency: 20,
1166
1166
  events: [{
1167
1167
  sqs: {
1168
1168
  arn: {
1169
1169
  'Fn::GetAtt': ['TestIntegrationQueue', 'Arn']
1170
1170
  },
1171
- batchSize: 1
1171
+ batchSize: 1,
1172
+ functionResponseType: 'ReportBatchItemFailures'
1172
1173
  }
1173
1174
  }],
1174
1175
  timeout: 600
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/devtools",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0-next.75",
4
+ "version": "2.0.0-next.77",
5
5
  "bin": {
6
6
  "frigg": "./frigg-cli/index.js"
7
7
  },
@@ -25,9 +25,9 @@
25
25
  "@babel/eslint-parser": "^7.18.9",
26
26
  "@babel/parser": "^7.25.3",
27
27
  "@babel/traverse": "^7.25.3",
28
- "@friggframework/core": "2.0.0-next.75",
29
- "@friggframework/schemas": "2.0.0-next.75",
30
- "@friggframework/test": "2.0.0-next.75",
28
+ "@friggframework/core": "2.0.0-next.77",
29
+ "@friggframework/schemas": "2.0.0-next.77",
30
+ "@friggframework/test": "2.0.0-next.77",
31
31
  "@hapi/boom": "^10.0.1",
32
32
  "@inquirer/prompts": "^5.3.8",
33
33
  "axios": "^1.7.2",
@@ -55,8 +55,8 @@
55
55
  "validate-npm-package-name": "^5.0.0"
56
56
  },
57
57
  "devDependencies": {
58
- "@friggframework/eslint-config": "2.0.0-next.75",
59
- "@friggframework/prettier-config": "2.0.0-next.75",
58
+ "@friggframework/eslint-config": "2.0.0-next.77",
59
+ "@friggframework/prettier-config": "2.0.0-next.77",
60
60
  "aws-sdk-client-mock": "^4.1.0",
61
61
  "aws-sdk-client-mock-jest": "^4.1.0",
62
62
  "jest": "^30.1.3",
@@ -88,5 +88,5 @@
88
88
  "publishConfig": {
89
89
  "access": "public"
90
90
  },
91
- "gitHead": "d8b091ab80329d8d8ed22c019d120f3122419bb9"
91
+ "gitHead": "0f451bd5c8493b4ec6f82964a976cf2533aa94d8"
92
92
  }