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

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.
@@ -145,20 +145,20 @@ class IntegrationBuilder extends InfrastructureBuilder {
145
145
  * Build integration resources based on ownership decisions
146
146
  */
147
147
  async buildFromDecisions(decisions, appDefinition, result, usePrismaLayer = true) {
148
+ // Create package config first — needed by all Lambda functions including DLQ processor
149
+ const functionPackageConfig = this.createFunctionPackageConfig(usePrismaLayer);
150
+
148
151
  // Create InternalErrorQueue if ownership = STACK
149
152
  const shouldCreateInternalErrorQueue = decisions.internalErrorQueue.ownership === ResourceOwnership.STACK;
150
153
 
151
154
  if (shouldCreateInternalErrorQueue) {
152
155
  console.log(' → Creating InternalErrorQueue in stack');
153
- this.createInternalErrorQueue(result);
156
+ this.createInternalErrorQueue(result, functionPackageConfig);
154
157
  } else {
155
158
  console.log(' → Using external InternalErrorQueue');
156
- this.useExternalInternalErrorQueue(decisions.internalErrorQueue, result);
159
+ this.useExternalInternalErrorQueue(decisions.internalErrorQueue, result, functionPackageConfig);
157
160
  }
158
161
 
159
- // Create Lambda function definitions and queue resources for each integration
160
- const functionPackageConfig = this.createFunctionPackageConfig(usePrismaLayer);
161
-
162
162
  for (const integration of appDefinition.integrations) {
163
163
  const integrationName = integration.Definition.name;
164
164
  const queueDecision = decisions.integrations[integrationName].queue;
@@ -316,6 +316,7 @@ class IntegrationBuilder extends InfrastructureBuilder {
316
316
  sqs: {
317
317
  arn: { 'Fn::GetAtt': [`${this.capitalizeFirst(integrationName)}Queue`, 'Arn'] },
318
318
  batchSize: 1,
319
+ functionResponseType: 'ReportBatchItemFailures',
319
320
  },
320
321
  },
321
322
  ],
@@ -327,29 +328,87 @@ class IntegrationBuilder extends InfrastructureBuilder {
327
328
  /**
328
329
  * Create InternalErrorQueue CloudFormation resource
329
330
  */
330
- createInternalErrorQueue(result) {
331
+ createInternalErrorQueue(result, functionPackageConfig) {
331
332
  result.resources.InternalErrorQueue = {
332
333
  Type: 'AWS::SQS::Queue',
333
334
  Properties: {
334
335
  QueueName: '${self:service}-${self:provider.stage}-InternalErrorQueue',
335
336
  MessageRetentionPeriod: 1209600, // 14 days
336
- VisibilityTimeout: 300, // 5 minutes for error processing
337
+ VisibilityTimeout: 300, // 5 minutes must be >= 6x DLQ processor Lambda timeout (30s × 6 = 180s)
337
338
  },
338
339
  };
339
340
 
341
+ this.createDLQObservability(result, functionPackageConfig, {
342
+ 'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
343
+ }, {
344
+ 'Fn::GetAtt': ['InternalErrorQueue', 'QueueName'],
345
+ });
346
+
340
347
  console.log(' ✓ Created InternalErrorQueue resource');
341
348
  }
342
349
 
343
350
  /**
344
351
  * Use external InternalErrorQueue
345
352
  */
346
- useExternalInternalErrorQueue(decision, result) {
353
+ useExternalInternalErrorQueue(decision, result, functionPackageConfig) {
347
354
  // Add ARN to environment for Lambda functions
348
355
  result.environment.INTERNAL_ERROR_QUEUE_ARN = decision.physicalId;
349
356
 
357
+ // Extract queue name from ARN for CloudWatch dimensions
358
+ const arnParts = decision.physicalId.split(':');
359
+ const queueName = arnParts[arnParts.length - 1];
360
+
361
+ this.createDLQObservability(result, functionPackageConfig, decision.physicalId, queueName);
362
+
350
363
  console.log(` ✓ Using external InternalErrorQueue: ${decision.physicalId}`);
351
364
  }
352
365
 
366
+ /**
367
+ * Create DLQ observability resources (alarm + processor Lambda).
368
+ * Called for both stack-owned and external InternalErrorQueues.
369
+ */
370
+ createDLQObservability(result, functionPackageConfig, queueArn, queueName) {
371
+ // CloudWatch Alarm: fires when any message lands in the DLQ
372
+ result.resources.DLQMessageAlarm = {
373
+ Type: 'AWS::CloudWatch::Alarm',
374
+ Properties: {
375
+ AlarmDescription: 'Messages in dead-letter queue — integration queue processing failures',
376
+ Namespace: 'AWS/SQS',
377
+ MetricName: 'ApproximateNumberOfMessagesVisible',
378
+ Statistic: 'Maximum',
379
+ Threshold: 500,
380
+ ComparisonOperator: 'GreaterThanThreshold',
381
+ EvaluationPeriods: 1,
382
+ Period: 300,
383
+ AlarmActions: [{ Ref: 'InternalErrorBridgeTopic' }],
384
+ Dimensions: [
385
+ { Name: 'QueueName', Value: queueName },
386
+ ],
387
+ },
388
+ };
389
+
390
+ // DLQ processor Lambda: logs failed messages with structured context
391
+ result.functions.dlqProcessor = {
392
+ handler: 'node_modules/@friggframework/core/handlers/workers/dlq-processor.dlqProcessor',
393
+ skipEsbuild: true,
394
+ package: functionPackageConfig,
395
+ reservedConcurrency: 1,
396
+ timeout: 30,
397
+ events: [
398
+ {
399
+ sqs: {
400
+ arn: queueArn,
401
+ batchSize: 10,
402
+ functionResponseType: 'ReportBatchItemFailures',
403
+ },
404
+ },
405
+ ],
406
+ };
407
+
408
+ console.log(' ✓ Created DLQ CloudWatch alarm');
409
+ console.log(' ✓ Created DLQ processor Lambda');
410
+ }
411
+
353
412
  /**
354
413
  * Create integration-specific SQS queue CloudFormation resource
355
414
  */
@@ -361,10 +420,10 @@ class IntegrationBuilder extends InfrastructureBuilder {
361
420
  Type: 'AWS::SQS::Queue',
362
421
  Properties: {
363
422
  QueueName: `\${self:custom.${queueReference}}`,
364
- MessageRetentionPeriod: 60,
423
+ MessageRetentionPeriod: 345600, // 4 days (SQS default)
365
424
  VisibilityTimeout: 1800,
366
425
  RedrivePolicy: {
367
- maxReceiveCount: 1,
426
+ maxReceiveCount: 3,
368
427
  deadLetterTargetArn: {
369
428
  'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
370
429
  },
@@ -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
  ]);
@@ -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
  }
@@ -1168,7 +1168,8 @@ describe('composeServerlessDefinition', () => {
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.76",
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.76",
29
+ "@friggframework/schemas": "2.0.0-next.76",
30
+ "@friggframework/test": "2.0.0-next.76",
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.76",
59
+ "@friggframework/prettier-config": "2.0.0-next.76",
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": "5f03113234df57301443502f2751f1864dd44e73"
92
92
  }