@reventlessdev/reventless-aws 3.0.0-alpha.420 → 3.0.0-alpha.422

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +10 -10
  3. package/src/adapter/CommandTopic/CommandTopicChannel_SQS.res +4 -1
  4. package/src/adapter/CommandTopic/CommandTopicChannel_SQS.res.mjs +1 -1
  5. package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Async.res +4 -1
  6. package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Async.res.mjs +1 -1
  7. package/src/adapter/CommandTopic/CommandTopicChannel_SQS_FIFO.res +4 -1
  8. package/src/adapter/CommandTopic/CommandTopicChannel_SQS_FIFO.res.mjs +1 -1
  9. package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Sync.res +4 -1
  10. package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Sync.res.mjs +1 -1
  11. package/src/adapter/EventCollector/EventCollectorChannel_SQS.res +4 -1
  12. package/src/adapter/EventCollector/EventCollectorChannel_SQS.res.mjs +1 -1
  13. package/src/adapter/EventCollector/EventCollectorChannel_SQS_FIFO.res +4 -1
  14. package/src/adapter/EventCollector/EventCollectorChannel_SQS_FIFO.res.mjs +1 -1
  15. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res +4 -1
  16. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res.mjs +1 -1
  17. package/src/adapter/Postgres/PgProjectionFeed.res +4 -1
  18. package/src/adapter/Postgres/PgProjectionFeed.res.mjs +1 -1
  19. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +12 -0
  20. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +4 -1
  21. package/src/adapter/Runtime/EventCollectorEntryPoint_Ops.res +2 -2
  22. package/src/adapter/Runtime/EventCollectorEntryPoint_Ops.res.mjs +1 -1
  23. package/src/adapter/Runtime/ProjectionEntryPoint_Ops.res +18 -1
  24. package/src/adapter/Runtime/ProjectionEntryPoint_Ops.res.mjs +20 -6
  25. package/src/util/Util_DeadLetterHandler.res +138 -0
  26. package/src/util/Util_DeadLetterHandler.res.mjs +113 -0
  27. package/src/util/Util_DeadLetterQueue.res +10 -53
  28. package/src/util/Util_DeadLetterQueue.res.mjs +6 -26
  29. package/tests/Util_DeadLetterHandlerTest.res +78 -0
  30. package/tests/Util_DeadLetterHandlerTest.res.mjs +101 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.422 (2026-11-05)
7
+
8
+ **Note:** Version bump only for package @reventlessdev/reventless-aws
9
+
10
+
11
+
12
+
13
+
14
+ # 3.0.0-alpha.421 (2026-11-02)
15
+
16
+ ### Bug Fixes
17
+
18
+ * **aws:** a dead letter's first delivery writes its full line ([3436633](https://github.com/ReventlessDev/reventless-core/commit/3436633809b671fc750fc5f717523f080c084b64))
19
+ ### Features
20
+
21
+ * a view row knows its last change, and the local platform keeps every change ([1a1fd71](https://github.com/ReventlessDev/reventless-core/commit/1a1fd7117d9837696a7c0080dcd6f0983e57780c))
22
+ * **local:** the app's API logs each request and subscription it answers ([31a163c](https://github.com/ReventlessDev/reventless-core/commit/31a163c945d8929e9a604cc0dc8a74939320f85e))
23
+
24
+
6
25
  # 3.0.0-alpha.420 (2026-11-01)
7
26
 
8
27
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.420",
3
+ "version": "3.0.0-alpha.422",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -22,19 +22,19 @@
22
22
  "sury": "11.0.0",
23
23
  "uuid": "^13.0.0",
24
24
  "yaml": "^2.8.3",
25
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.38",
26
- "@reventlessdev/rescript-jest": "1.0.0-alpha.13",
25
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.39",
27
26
  "@reventlessdev/rescript-effect": "1.0.0-alpha.1",
28
- "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.38",
29
- "@reventlessdev/rescript-node": "2.0.0-alpha.25",
27
+ "@reventlessdev/rescript-node": "2.0.0-alpha.26",
28
+ "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.39",
29
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.13",
30
30
  "@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
31
31
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.1",
32
32
  "@reventlessdev/rescript-web": "1.0.0-alpha.8",
33
- "@reventlessdev/reventless-infra": "3.0.0-alpha.221",
34
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.191",
35
- "@reventlessdev/reventless-core": "3.0.0-alpha.327",
36
- "@reventlessdev/reventless-spec": "3.0.0-alpha.192",
37
- "@reventlessdev/reventless-interop": "3.0.0-alpha.42"
33
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.223",
34
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.42",
35
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.194",
36
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.193",
37
+ "@reventlessdev/reventless-core": "3.0.0-alpha.329"
38
38
  },
39
39
  "devDependencies": {
40
40
  "rescript": "12.3.1",
@@ -60,7 +60,10 @@ let make: ReventlessCore.CommandTopic_Adapter.channelMaker<
60
60
  PulumiAws.SQS.Queue.visibilityTimeoutSeconds: (6 * 30)->Pulumi.Input.make, // TODO rethink timeout
61
61
  redrivePolicy: Util_DeadLetterQueue.queue.arn
62
62
  ->Pulumi.Output.apply(dlqArn =>
63
- PulumiAws.SQS.Queue.RedrivePolicy.make(~deadLetterTargetArn=dlqArn, ~maxReceiveCount=5)
63
+ PulumiAws.SQS.Queue.RedrivePolicy.make(
64
+ ~deadLetterTargetArn=dlqArn,
65
+ ~maxReceiveCount=Util_DeadLetterQueue.maxReceiveCount,
66
+ )
64
67
  )
65
68
  ->Pulumi.Output.asInput,
66
69
  sqsManagedSseEnabled: false->Pulumi.Input.make,
@@ -43,7 +43,7 @@ function make(name, owner, opts) {
43
43
  let opts$1 = Stdlib_Option.map(opts, Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions);
44
44
  let tags = AWS_Tags$ReventlessAws.make(name, CommandTopic$ReventlessCore.componentType, "CommandTopic", undefined, undefined, owner, undefined, undefined);
45
45
  let queue = new (Aws.sqs.Queue)(name, {
46
- redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, 5)),
46
+ redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, Util_DeadLetterQueue$ReventlessAws.maxReceiveCount)),
47
47
  tags: tags,
48
48
  visibilityTimeoutSeconds: 180,
49
49
  sqsManagedSseEnabled: false
@@ -37,7 +37,10 @@ let make: ReventlessCore.CommandTopic_Adapter.channelMaker<
37
37
  visibilityTimeoutSeconds: (6 * 30)->Pulumi.Input.make,
38
38
  redrivePolicy: Util_DeadLetterQueue.fifoQueue.arn
39
39
  ->Pulumi.Output.apply(dlqArn => {
40
- PulumiAws.SQS.Queue.RedrivePolicy.make(~deadLetterTargetArn=dlqArn, ~maxReceiveCount=5)
40
+ PulumiAws.SQS.Queue.RedrivePolicy.make(
41
+ ~deadLetterTargetArn=dlqArn,
42
+ ~maxReceiveCount=Util_DeadLetterQueue.maxReceiveCount,
43
+ )
41
44
  })
42
45
  ->Pulumi.Output.asInput,
43
46
  sqsManagedSseEnabled: false->Pulumi.Input.make,
@@ -24,7 +24,7 @@ function make(name, owner, opts) {
24
24
  let queue = new (Aws.sqs.Queue)(name, {
25
25
  contentBasedDeduplication: true,
26
26
  fifoQueue: true,
27
- redrivePolicy: Util_DeadLetterQueue$ReventlessAws.fifoQueue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, 5)),
27
+ redrivePolicy: Util_DeadLetterQueue$ReventlessAws.fifoQueue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, Util_DeadLetterQueue$ReventlessAws.maxReceiveCount)),
28
28
  tags: tags,
29
29
  visibilityTimeoutSeconds: 180,
30
30
  deduplicationScope: "messageGroup",
@@ -26,7 +26,10 @@ let make: ReventlessCore.CommandTopic_Adapter.channelMaker<
26
26
  visibilityTimeoutSeconds: (6 * 30)->Pulumi.Input.make,
27
27
  redrivePolicy: Util_DeadLetterQueue.fifoQueue.arn
28
28
  ->Pulumi.Output.apply(dlqArn => {
29
- PulumiAws.SQS.Queue.RedrivePolicy.make(~deadLetterTargetArn=dlqArn, ~maxReceiveCount=5)
29
+ PulumiAws.SQS.Queue.RedrivePolicy.make(
30
+ ~deadLetterTargetArn=dlqArn,
31
+ ~maxReceiveCount=Util_DeadLetterQueue.maxReceiveCount,
32
+ )
30
33
  })
31
34
  ->Pulumi.Output.asInput,
32
35
  sqsManagedSseEnabled: false->Pulumi.Input.make,
@@ -24,7 +24,7 @@ function make(name, owner, opts) {
24
24
  let queue = new (Aws.sqs.Queue)(name, {
25
25
  contentBasedDeduplication: true,
26
26
  fifoQueue: true,
27
- redrivePolicy: Util_DeadLetterQueue$ReventlessAws.fifoQueue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, 5)),
27
+ redrivePolicy: Util_DeadLetterQueue$ReventlessAws.fifoQueue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, Util_DeadLetterQueue$ReventlessAws.maxReceiveCount)),
28
28
  tags: tags,
29
29
  visibilityTimeoutSeconds: 180,
30
30
  deduplicationScope: "messageGroup",
@@ -40,7 +40,10 @@ let make: ReventlessCore.CommandTopic_Adapter.channelMaker<
40
40
  PulumiAws.SQS.Queue.visibilityTimeoutSeconds: (6 * 30)->Pulumi.Input.make,
41
41
  redrivePolicy: Util_DeadLetterQueue.queue.arn
42
42
  ->Pulumi.Output.apply(dlqArn =>
43
- PulumiAws.SQS.Queue.RedrivePolicy.make(~deadLetterTargetArn=dlqArn, ~maxReceiveCount=5)
43
+ PulumiAws.SQS.Queue.RedrivePolicy.make(
44
+ ~deadLetterTargetArn=dlqArn,
45
+ ~maxReceiveCount=Util_DeadLetterQueue.maxReceiveCount,
46
+ )
44
47
  )
45
48
  ->Pulumi.Output.asInput,
46
49
  sqsManagedSseEnabled: false->Pulumi.Input.make,
@@ -23,7 +23,7 @@ function make(name, owner, opts) {
23
23
  let opts$1 = Stdlib_Option.map(opts, Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions);
24
24
  let tags = AWS_Tags$ReventlessAws.make(name, CommandTopic$ReventlessCore.componentType, "CommandTopic", undefined, undefined, owner, undefined, undefined);
25
25
  let queue = new (Aws.sqs.Queue)(name, {
26
- redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, 5)),
26
+ redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, Util_DeadLetterQueue$ReventlessAws.maxReceiveCount)),
27
27
  tags: tags,
28
28
  visibilityTimeoutSeconds: 180,
29
29
  sqsManagedSseEnabled: false
@@ -49,7 +49,10 @@ let make: ReventlessCore.EventCollector_Adapter.channelMaker<
49
49
  PulumiAws.SQS.Queue.visibilityTimeoutSeconds: 120->Pulumi.Input.make,
50
50
  redrivePolicy: Util_DeadLetterQueue.queue.arn
51
51
  ->Pulumi.Output.apply(dlqArn =>
52
- PulumiAws.SQS.Queue.RedrivePolicy.make(~deadLetterTargetArn=dlqArn, ~maxReceiveCount=5)
52
+ PulumiAws.SQS.Queue.RedrivePolicy.make(
53
+ ~deadLetterTargetArn=dlqArn,
54
+ ~maxReceiveCount=Util_DeadLetterQueue.maxReceiveCount,
55
+ )
53
56
  )
54
57
  ->Pulumi.Output.asInput,
55
58
  sqsManagedSseEnabled: false->Pulumi.Input.make,
@@ -29,7 +29,7 @@ function make(name, eventTopics, owner, opts) {
29
29
  let opts$1 = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
30
30
  let tags = AWS_Tags$ReventlessAws.make(name, EventCollector$ReventlessCore.componentType, "EventCollector", undefined, undefined, owner, undefined, undefined);
31
31
  let queue = new (Aws.sqs.Queue)(name, {
32
- redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, 5)),
32
+ redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, Util_DeadLetterQueue$ReventlessAws.maxReceiveCount)),
33
33
  tags: tags,
34
34
  visibilityTimeoutSeconds: 120,
35
35
  sqsManagedSseEnabled: false
@@ -27,7 +27,10 @@ let make: ReventlessCore.EventCollector_Adapter.channelMaker<
27
27
  visibilityTimeoutSeconds: 30->Pulumi.Input.make, // TODO fix timeout
28
28
  redrivePolicy: Util_DeadLetterQueue.fifoQueue.arn
29
29
  ->Pulumi.Output.apply(dlqArn =>
30
- PulumiAws.SQS.Queue.RedrivePolicy.make(~deadLetterTargetArn=dlqArn, ~maxReceiveCount=5)
30
+ PulumiAws.SQS.Queue.RedrivePolicy.make(
31
+ ~deadLetterTargetArn=dlqArn,
32
+ ~maxReceiveCount=Util_DeadLetterQueue.maxReceiveCount,
33
+ )
31
34
  )
32
35
  ->Pulumi.Output.asInput,
33
36
  sqsManagedSseEnabled: false->Pulumi.Input.make,
@@ -16,7 +16,7 @@ function make(name, eventTopics, owner, opts) {
16
16
  let queue = new (Aws.sqs.Queue)(name, {
17
17
  contentBasedDeduplication: true,
18
18
  fifoQueue: true,
19
- redrivePolicy: Util_DeadLetterQueue$ReventlessAws.fifoQueue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, 5)),
19
+ redrivePolicy: Util_DeadLetterQueue$ReventlessAws.fifoQueue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, Util_DeadLetterQueue$ReventlessAws.maxReceiveCount)),
20
20
  tags: tags,
21
21
  visibilityTimeoutSeconds: 30,
22
22
  deduplicationScope: "messageGroup",
@@ -39,7 +39,10 @@ let make = (
39
39
  SQS.Queue.visibilityTimeoutSeconds: 60->Pulumi.Input.make,
40
40
  redrivePolicy: Util_DeadLetterQueue.queue.arn
41
41
  ->Pulumi.Output.apply(dlqArn =>
42
- SQS.Queue.RedrivePolicy.make(~deadLetterTargetArn=dlqArn, ~maxReceiveCount=5)
42
+ SQS.Queue.RedrivePolicy.make(
43
+ ~deadLetterTargetArn=dlqArn,
44
+ ~maxReceiveCount=Util_DeadLetterQueue.maxReceiveCount,
45
+ )
43
46
  )
44
47
  ->Pulumi.Output.asInput,
45
48
  sqsManagedSseEnabled: false->Pulumi.Input.make,
@@ -21,7 +21,7 @@ function channelNameOf(topicName) {
21
21
 
22
22
  function make(name, topicName, eventTopicOutputs, eventsApi, opts) {
23
23
  let queue = new (Aws.sqs.Queue)(name + "EventLogSubQueue", {
24
- redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, 5)),
24
+ redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, Util_DeadLetterQueue$ReventlessAws.maxReceiveCount)),
25
25
  tags: AWS_Tags$ReventlessAws.make(name + "EventLogSubQueue", EventTopic$ReventlessCore.componentType, "EventLogSubscription", undefined, name, undefined, undefined, undefined),
26
26
  visibilityTimeoutSeconds: 60,
27
27
  sqsManagedSseEnabled: false
@@ -47,7 +47,10 @@ let makeQueue = (
47
47
  PulumiAws.SQS.Queue.visibilityTimeoutSeconds: 120->Pulumi.Input.make,
48
48
  redrivePolicy: Util_DeadLetterQueue.queue.arn
49
49
  ->Pulumi.Output.apply(dlqArn =>
50
- PulumiAws.SQS.Queue.RedrivePolicy.make(~deadLetterTargetArn=dlqArn, ~maxReceiveCount=5)
50
+ PulumiAws.SQS.Queue.RedrivePolicy.make(
51
+ ~deadLetterTargetArn=dlqArn,
52
+ ~maxReceiveCount=Util_DeadLetterQueue.maxReceiveCount,
53
+ )
51
54
  )
52
55
  ->Pulumi.Output.asInput,
53
56
  sqsManagedSseEnabled: false->Pulumi.Input.make,
@@ -15,7 +15,7 @@ function getFeedQueues() {
15
15
 
16
16
  function makeQueue(name, scope, includeClassic, includeDcb, opts) {
17
17
  let queue = new (Aws.sqs.Queue)(name, {
18
- redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, 5)),
18
+ redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, Util_DeadLetterQueue$ReventlessAws.maxReceiveCount)),
19
19
  tags: AWS_Tags$ReventlessAws.make(name, "Plugin", "EventCollector", "Plugin", undefined, undefined, undefined, undefined),
20
20
  visibilityTimeoutSeconds: 120,
21
21
  sqsManagedSseEnabled: false
@@ -709,12 +709,24 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
709
709
  // is independent of whether this view has a sub-id door, and adding a third
710
710
  // dimension to that match would spell eight cases to say one thing.
711
711
  let refsResolvers = resolverRefs->Option.mapOr([], r => [r])
712
+ // The row's last change, for an elevated caller only. A field resolver, so it also covers
713
+ // a row reached through another view's cross-table field.
714
+ let changeResolver = Resolver.makeUnitJsResolver(
715
+ ~name=name ++ "RowChange",
716
+ ~api,
717
+ ~dataSourceName,
718
+ ~type_=parentTypeName->Pulumi.Input.make,
719
+ ~field=ReventlessCore.RowChange.key->Pulumi.Input.make,
720
+ ~code=Resolver.Functions.rowChangeField(~field=ReventlessCore.RowChange.key, ~elevatedGroups),
721
+ ~opts,
722
+ )
712
723
  Array.flat([
713
724
  mainResolvers,
714
725
  refsResolvers,
715
726
  resolversByIndex,
716
727
  idResolvers,
717
728
  idsResolvers,
729
+ [changeResolver],
718
730
  ])->Array.map(Util.AppSync.toResourceNative)
719
731
  }
720
732
 
@@ -9,6 +9,7 @@ import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/compon
9
9
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
10
10
  import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
11
11
  import * as Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Adapter.res.mjs";
12
+ import * as RowChange$ReventlessCore from "@reventlessdev/reventless-core/src/RowChange.res.mjs";
12
13
  import * as AppSync_Function$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/AppSync/AppSync_Function.res.mjs";
13
14
  import * as Util_AppSync$ReventlessAws from "../../util/Util_AppSync.res.mjs";
14
15
  import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
@@ -303,12 +304,14 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
303
304
  ]
304
305
  );
305
306
  let refsResolvers = Stdlib_Option.mapOr(resolverRefs, [], r => [r]);
307
+ let changeResolver = AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name$1 + "RowChange", api, dataSourceName, parentTypeName, RowChange$ReventlessCore.key, AppSync_Resolver_Functions$PulumiAws.rowChangeField(RowChange$ReventlessCore.key, elevatedGroups), opts);
306
308
  return [
307
309
  mainResolvers,
308
310
  refsResolvers,
309
311
  resolversByIndex,
310
312
  idResolvers,
311
- idsResolvers
313
+ idsResolvers,
314
+ [changeResolver]
312
315
  ].flat().map(Util_AppSync$ReventlessAws.toResourceNative);
313
316
  };
314
317
  return {
@@ -711,8 +711,8 @@ let makeSqsHandlerBundle = (input: bundleInput): bundle => {
711
711
  ),
712
712
  // Backs both the admin and every per-plugin EventCollector Lambda, so the
713
713
  // comp names the plugin whose events it drains — matching the
714
- // `Plugin(<id>)` shape Plugin_Callback logs under.
715
- comp: `Plugin(${input.pluginDefinition.id})`,
714
+ // `ExtensionRouting(<id>)` shape Plugin_Callback logs under.
715
+ comp: `ExtensionRouting(${input.pluginDefinition.id})`,
716
716
  }
717
717
  }
718
718
 
@@ -531,7 +531,7 @@ function makeSqsHandlerBundle(input) {
531
531
  };
532
532
  return {
533
533
  sqsHandler: EventCollectorChannel_SQS_Runtime$ReventlessAws.handleDynamoDbOrSqsEvent(queue, Callback.handleJsonEvents),
534
- comp: `Plugin(` + input.pluginDefinition.id + `)`
534
+ comp: `ExtensionRouting(` + input.pluginDefinition.id + `)`
535
535
  };
536
536
  }
537
537
 
@@ -124,6 +124,23 @@ let withDisplayName = (
124
124
  }
125
125
  }
126
126
 
127
+ /**
128
+ Stamps every saved row with the change being written (`_change`: flow, event, time), as
129
+ `QueryDb_Operations.Make` does on the typed path. Outermost, so the live update publishes
130
+ the stamped row.
131
+ */
132
+ let withRowChange = (
133
+ base: ReventlessCore.QueryDb_Adapter.operations,
134
+ ): ReventlessCore.QueryDb_Adapter.operations => {
135
+ ...base,
136
+ save: (id, state, saveMode, ttl) =>
137
+ base.save(id, ReventlessCore.RowChange.stamp(state), saveMode, ttl),
138
+ saveBatch: items =>
139
+ base.saveBatch(
140
+ items->Array.map(((id, state, ttl)) => (id, ReventlessCore.RowChange.stamp(state), ttl)),
141
+ ),
142
+ }
143
+
127
144
  let makeQueryDbOps = (
128
145
  ~queryDbTableName: string,
129
146
  ~pgConnection: option<PgConnection.connectionConfig>,
@@ -145,4 +162,4 @@ let makeQueryDbOps = (
145
162
  ?subIdField,
146
163
  })
147
164
  | None => QueryDbEntryPoint_Ops.makeDynamoQueryDbOps(~tableName=queryDbTableName)
148
- }
165
+ }->withRowChange
@@ -5,6 +5,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as DisplayName$Reventless from "@reventlessdev/reventless-spec/src/components/DisplayName.res.mjs";
6
6
  import * as TaggedUnion$Reventless from "@reventlessdev/reventless-spec/src/components/TaggedUnion.res.mjs";
7
7
  import * as StateTopicPublishMjs from "./StateTopicPublish.mjs";
8
+ import * as RowChange$ReventlessCore from "@reventlessdev/reventless-core/src/RowChange.res.mjs";
8
9
  import * as QueryDbEntryPoint_Ops$ReventlessAws from "./QueryDbEntryPoint_Ops.res.mjs";
9
10
  import * as QueryDbStorage_Postgres_Runtime$ReventlessAws from "../QueryDb/QueryDbStorage_Postgres_Runtime.res.mjs";
10
11
 
@@ -70,17 +71,29 @@ function withDisplayName(base, stateSchema) {
70
71
  };
71
72
  }
72
73
 
74
+ function withRowChange(base) {
75
+ return {
76
+ load: base.load,
77
+ loadStream: base.loadStream,
78
+ save: (id, state, saveMode, ttl) => base.save(id, RowChange$ReventlessCore.stamp(state), saveMode, ttl),
79
+ saveBatch: items => base.saveBatch(items.map(param => [
80
+ param[0],
81
+ RowChange$ReventlessCore.stamp(param[1]),
82
+ param[2]
83
+ ])),
84
+ count: base.count,
85
+ delete: base.delete,
86
+ deleteBatch: base.deleteBatch
87
+ };
88
+ }
89
+
73
90
  function makeQueryDbOps(queryDbTableName, pgConnection, stateTopicName, indexes, subIdField) {
74
- if (pgConnection !== undefined) {
75
- return StateTopicPublishMjs.withLiveUpdates(QueryDbStorage_Postgres_Runtime$ReventlessAws.opsFor(pgConnection, queryDbTableName, indexes, subIdField), {
91
+ return withRowChange(pgConnection !== undefined ? StateTopicPublishMjs.withLiveUpdates(QueryDbStorage_Postgres_Runtime$ReventlessAws.opsFor(pgConnection, queryDbTableName, indexes, subIdField), {
76
92
  endpoint: process.env["APPSYNC_ENDPOINT"],
77
93
  region: process.env["AWS_REGION"],
78
94
  topicName: stateTopicName,
79
95
  subIdField: subIdField
80
- });
81
- } else {
82
- return QueryDbEntryPoint_Ops$ReventlessAws.makeDynamoQueryDbOps(queryDbTableName);
83
- }
96
+ }) : QueryDbEntryPoint_Ops$ReventlessAws.makeDynamoQueryDbOps(queryDbTableName));
84
97
  }
85
98
 
86
99
  export {
@@ -89,6 +102,7 @@ export {
89
102
  withLiveUpdates,
90
103
  withUnionMemberTypes,
91
104
  withDisplayName,
105
+ withRowChange,
92
106
  makeQueryDbOps,
93
107
  }
94
108
  /* DisplayName-Reventless Not a pure module */
@@ -0,0 +1,138 @@
1
+ // The dead-letter function's handler. `Util_DeadLetterQueue` ships this module's compiled file
2
+ // as the function's whole code, so it must compile to a file that imports nothing: it keeps
3
+ // working when the layer or the handlers of a deploy are broken, which is when dead letters
4
+ // arrive. So only the Stdlib's externals (which compile inline) and plain switches here: no
5
+ // Stdlib function such as `Option.getOr`, which compiles to an import, and no `try`, whose
6
+ // compiled form imports the runtime's exception helpers.
7
+ //
8
+ // It logs what arrived, then FAILS the invocation on purpose.
9
+ //
10
+ // Returning success would let SQS delete the message, which is what this handler used to do,
11
+ // and it made a dead letter unobservable twice over: the queue's depth returned to 0, so a
12
+ // depth alarm could never fire, and the function's `Errors` metric stayed at 0, so neither
13
+ // could an error alarm. A plugin whose commands failed every 5 minutes for two days produced
14
+ // 217 dead letters and no signal at all; it was found by a person noticing a stale UI.
15
+ //
16
+ // Failing instead keeps the message on the queue and keeps `Errors` non-zero while the
17
+ // condition lasts. Both are conventional alarm subjects, and a monitoring backend attached
18
+ // through the `DeadLetterSink` seam now has something to attach to.
19
+ //
20
+ // The transport retries by design too: a failed batch returns to the queue and is redelivered
21
+ // until retention expires. A handler that fails forever, on a transport that retries forever,
22
+ // is a loop, and it outlives its cause, because nothing about a fixed bug removes the message
23
+ // that was stranded by it. So what the loop costs is a function of the line, not the count:
24
+ // the first delivery here writes the record's ids, kind, size and attribute names (never its
25
+ // body, which is a command or event and may be about a person), and every redelivery after it
26
+ // writes a single identity line. A stranded message is ~150 KB over a fortnight rather than
27
+ // ~17 MB, small enough that ending the loop early is an operator's job.
28
+
29
+ // How many times a consuming queue delivers a message before it moves it here. Every redrive
30
+ // policy in the framework takes it from here. SQS keeps a message's receive count when it
31
+ // moves it, so a dead letter's first delivery here reports `maxReceiveCount + 1`; a message
32
+ // sent here directly starts at 1.
33
+ let maxReceiveCount = 5
34
+ let firstDeliveryCount = maxReceiveCount + 1
35
+
36
+ type record = {
37
+ messageId?: string,
38
+ body?: string,
39
+ attributes?: dict<string>,
40
+ messageAttributes?: dict<JSON.t>,
41
+ eventSourceARN?: string,
42
+ }
43
+ type event = {@as("Records") records?: array<record>}
44
+
45
+ // The body parsed, or nothing when it is not JSON. A `try` would import the runtime, so the
46
+ // parse is guarded in plain JS.
47
+ let parseOrNull: string => Nullable.t<JSON.t> = %raw(`function (text) {
48
+ try { return JSON.parse(text) } catch (_) { return null }
49
+ }`)
50
+
51
+ // `Option.getOr` would import the Stdlib, so the default is given here.
52
+ let orElse = (value: option<string>, fallback: string) =>
53
+ switch value {
54
+ | Some(v) => v
55
+ | None => fallback
56
+ }
57
+
58
+ // The command's or event's tag, "sns" for an SNS envelope, else "unknown".
59
+ let kindOf = (body: string): string =>
60
+ switch parseOrNull(body)->Nullable.toOption {
61
+ | Some(JSON.Object(d)) =>
62
+ let tagOf = key =>
63
+ switch d->Dict.get(key) {
64
+ | Some(JSON.Object(inner)) =>
65
+ switch inner->Dict.get("TAG") {
66
+ | Some(JSON.String(tag)) => Some(tag)
67
+ | _ => None
68
+ }
69
+ | _ => None
70
+ }
71
+ switch (tagOf("command"), tagOf("event"), d->Dict.get("Message")) {
72
+ | (Some(tag), _, _) | (None, Some(tag), _) => tag
73
+ | (None, None, Some(_)) => "sns"
74
+ | (None, None, None) => "unknown"
75
+ }
76
+ | _ => "unknown"
77
+ }
78
+
79
+ let receiveCountOf = (attrs: dict<string>): int =>
80
+ switch attrs->Dict.get("ApproximateReceiveCount") {
81
+ | Some(text) =>
82
+ let n = Float.parseFloat(text)
83
+ Float.isNaN(n) ? 1 : Float.toInt(n)
84
+ | None => 1
85
+ }
86
+
87
+ // The line a record writes: the full one on its first delivery here, the short one after.
88
+ let lineOf = (record: record): string => {
89
+ let attrs = switch record.attributes {
90
+ | Some(a) => a
91
+ | None => Dict.make()
92
+ }
93
+ let receiveCount = receiveCountOf(attrs)
94
+ let body = record.body->orElse("")
95
+ let source = switch attrs->Dict.get("DeadLetterQueueSourceArn") {
96
+ | Some(arn) => arn
97
+ | None => record.eventSourceARN->orElse("unknown")
98
+ }
99
+ let identity =
100
+ "messageId=" ++
101
+ record.messageId->orElse("unknown") ++
102
+ " source=" ++
103
+ source ++
104
+ " receiveCount=" ++
105
+ Int.toString(receiveCount) ++
106
+ " bodyBytes=" ++
107
+ Int.toString(String.length(body))
108
+ if receiveCount <= firstDeliveryCount {
109
+ let attributeNames = switch record.messageAttributes {
110
+ | Some(m) => m->Dict.keysToArray
111
+ | None => []
112
+ }
113
+ "DEAD LETTER ITEM: " ++
114
+ identity ++
115
+ " kind=" ++
116
+ kindOf(body) ++
117
+ " attributes=" ++
118
+ attributeNames->Array.join(",")
119
+ } else {
120
+ "DEAD LETTER REDELIVERY: " ++ identity
121
+ }
122
+ }
123
+
124
+ let handler = async (event: event): unit => {
125
+ let records = switch event.records {
126
+ | Some(r) => r
127
+ | None => []
128
+ }
129
+ records->Array.forEach(record => Console.error(lineOf(record)))
130
+ JsError.throw(
131
+ JsError.make(
132
+ "Dead-lettered " ++
133
+ Int.toString(
134
+ Array.length(records),
135
+ ) ++ " message(s); see DEAD LETTER ITEM above. Failing so the messages are retained and Errors is non-zero.",
136
+ ),
137
+ )
138
+ }
@@ -0,0 +1,113 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ let firstDeliveryCount = 6;
5
+
6
+ let parseOrNull = (function (text) {
7
+ try { return JSON.parse(text) } catch (_) { return null }
8
+ });
9
+
10
+ function orElse(value, fallback) {
11
+ if (value !== undefined) {
12
+ return value;
13
+ } else {
14
+ return fallback;
15
+ }
16
+ }
17
+
18
+ function kindOf(body) {
19
+ let match = parseOrNull(body);
20
+ if (match == null) {
21
+ return "unknown";
22
+ }
23
+ if (typeof match !== "object" || match === null || Array.isArray(match)) {
24
+ return "unknown";
25
+ }
26
+ let tagOf = key => {
27
+ let match$1 = match[key];
28
+ if (match$1 === undefined) {
29
+ return;
30
+ }
31
+ if (typeof match$1 !== "object" || match$1 === null || Array.isArray(match$1)) {
32
+ return;
33
+ }
34
+ let match$2 = match$1["TAG"];
35
+ if (typeof match$2 === "string") {
36
+ return match$2;
37
+ }
38
+ };
39
+ let match$1 = tagOf("command");
40
+ let match$2 = tagOf("event");
41
+ let match$3 = match["Message"];
42
+ if (match$1 !== undefined) {
43
+ return match$1;
44
+ } else if (match$2 !== undefined) {
45
+ return match$2;
46
+ } else if (match$3 !== undefined) {
47
+ return "sns";
48
+ } else {
49
+ return "unknown";
50
+ }
51
+ }
52
+
53
+ function receiveCountOf(attrs) {
54
+ let text = attrs["ApproximateReceiveCount"];
55
+ if (text === undefined) {
56
+ return 1;
57
+ }
58
+ let n = parseFloat(text);
59
+ if (Number.isNaN(n)) {
60
+ return 1;
61
+ } else {
62
+ return n | 0;
63
+ }
64
+ }
65
+
66
+ function lineOf(record) {
67
+ let a = record.attributes;
68
+ let attrs = a !== undefined ? a : ({});
69
+ let receiveCount = receiveCountOf(attrs);
70
+ let value = record.body;
71
+ let body = value !== undefined ? value : "";
72
+ let arn = attrs["DeadLetterQueueSourceArn"];
73
+ let source;
74
+ if (arn !== undefined) {
75
+ source = arn;
76
+ } else {
77
+ let value$1 = record.eventSourceARN;
78
+ source = value$1 !== undefined ? value$1 : "unknown";
79
+ }
80
+ let value$2 = record.messageId;
81
+ let identity = "messageId=" + (
82
+ value$2 !== undefined ? value$2 : "unknown"
83
+ ) + " source=" + source + " receiveCount=" + receiveCount.toString() + " bodyBytes=" + body.length.toString();
84
+ if (receiveCount > firstDeliveryCount) {
85
+ return "DEAD LETTER REDELIVERY: " + identity;
86
+ }
87
+ let m = record.messageAttributes;
88
+ let attributeNames = m !== undefined ? Object.keys(m) : [];
89
+ return "DEAD LETTER ITEM: " + identity + " kind=" + kindOf(body) + " attributes=" + attributeNames.join(",");
90
+ }
91
+
92
+ async function handler(event) {
93
+ let r = event.Records;
94
+ let records = r !== undefined ? r : [];
95
+ records.forEach(record => {
96
+ console.error(lineOf(record));
97
+ });
98
+ throw new Error("Dead-lettered " + records.length.toString() + " message(s); see DEAD LETTER ITEM above. Failing so the messages are retained and Errors is non-zero.");
99
+ }
100
+
101
+ let maxReceiveCount = 5;
102
+
103
+ export {
104
+ maxReceiveCount,
105
+ firstDeliveryCount,
106
+ parseOrNull,
107
+ orElse,
108
+ kindOf,
109
+ receiveCountOf,
110
+ lineOf,
111
+ handler,
112
+ }
113
+ /* No side effect */
@@ -17,6 +17,10 @@ let queueTags = queueName =>
17
17
  // 4-day default can expire it over a long weekend before anyone reads it.
18
18
  let retentionSeconds = 14 * 24 * 60 * 60
19
19
 
20
+ // How many times a consuming queue delivers a message before it moves it here; every redrive
21
+ // policy in the framework takes it from here. Defined beside the handler, which needs it.
22
+ let maxReceiveCount = Util_DeadLetterHandler.maxReceiveCount
23
+
20
24
  // A dead letter has no latency requirement, and the handler below fails on
21
25
  // purpose — so a message returns to the queue and is redelivered until retention
22
26
  // expires. At 180 s that is ~480 redeliveries per message per day, which outlives
@@ -67,59 +71,12 @@ let lambdaRole = IAM.Role.makeWithDefaultPolicy(
67
71
  ~opts,
68
72
  )
69
73
 
70
- // Logs what arrived, then FAILS the invocation on purpose.
71
- //
72
- // Returning success here would let SQS delete the message, which is what this
73
- // handler used to do — and it made a dead letter unobservable twice over: the
74
- // queue's depth returned to 0, so a depth alarm could never fire, and the
75
- // function's `Errors` metric stayed at 0, so neither could an error alarm. A
76
- // plugin whose commands failed every 5 minutes for two days produced 217 dead
77
- // letters and no signal at all; it was found by a person noticing a stale UI.
78
- //
79
- // Failing instead keeps the message on the queue and keeps `Errors` non-zero
80
- // while the condition lasts. Both are conventional alarm subjects, and a
81
- // monitoring backend attached through the `DeadLetterSink` seam below now has
82
- // something to attach to.
83
- //
84
- // What that reasoning did not account for is that the transport retries by
85
- // design too: a failed batch returns to the queue and is redelivered until
86
- // retention expires. A handler that fails forever, on a transport that retries
87
- // forever, is a loop — and it outlives its cause, because nothing about a fixed
88
- // bug removes the message that was stranded by it. Seven of them were still
89
- // cycling twelve hours after the fix that made them impossible.
90
- //
91
- // What the loop costs is now a function of the line, not the count: the first
92
- // delivery writes the record's ids, kind, size and attribute names — never its
93
- // body, which is a command or event and may be about a person — and every
94
- // redelivery after it costs a single identity line. A stranded
95
- // message is ~150 KB over a fortnight rather than ~17 MB, which is small enough
96
- // that ending the loop early is an operator's job rather than the topology's.
97
- let entryPointCode = `export const handler = async (event) => {
98
- const records = event?.Records ?? [];
99
- for (const record of records) {
100
- const attrs = record?.attributes ?? {};
101
- const receiveCount = Number(attrs.ApproximateReceiveCount ?? 1);
102
- const identity =
103
- "messageId=" + (record?.messageId ?? "unknown") +
104
- " source=" + (attrs.DeadLetterQueueSourceArn ?? record?.eventSourceARN ?? "unknown") +
105
- " receiveCount=" + receiveCount +
106
- " bodyBytes=" + (record?.body?.length ?? 0);
107
- if (receiveCount <= 1) {
108
- // Ids, type and size, never the body: a dead letter is a command or event,
109
- // and its payload may be about a person.
110
- const body = (() => { try { return JSON.parse(record?.body ?? "null") } catch { return null } })();
111
- const kind = body?.command?.TAG ?? body?.event?.TAG ?? (body?.Message ? "sns" : "unknown");
112
- console.error("DEAD LETTER ITEM: " + identity + " kind=" + kind +
113
- " attributes=" + JSON.stringify(Object.keys(record?.messageAttributes ?? {})));
114
- } else {
115
- console.error("DEAD LETTER REDELIVERY: " + identity);
116
- }
117
- }
118
- throw new Error(
119
- "Dead-lettered " + records.length +
120
- " message(s); see DEAD LETTER ITEM above. Failing so the messages are retained and Errors is non-zero."
121
- );
122
- };`
74
+ // The function's whole code is `Util_DeadLetterHandler`'s compiled file, which imports
75
+ // nothing (see there), shipped as `index.mjs`. It sits beside `Util_Bundle`'s compiled file,
76
+ // so it resolves from there.
77
+ let entryPointCode = NodeFs.readFileSync(
78
+ Util_Bundle.localRequire->NodeModule.requireResolve("./Util_DeadLetterHandler.res.mjs"),
79
+ )
123
80
 
124
81
  let archiveContents: dict<Pulumi.Archive.assetOrArchive> = Dict.make()
125
82
  archiveContents->Dict.set(
@@ -1,5 +1,6 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Nodefs from "node:fs";
3
4
  import * as Aws from "@pulumi/aws";
4
5
  import * as IAM$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/IAM.res.mjs";
5
6
  import * as Pulumi from "@pulumi/pulumi";
@@ -11,6 +12,7 @@ import * as Monitoring$ReventlessCore from "@reventlessdev/reventless-core/src/a
11
12
  import * as Util_Bundle$ReventlessAws from "./Util_Bundle.res.mjs";
12
13
  import * as Util_Lambda$ReventlessAws from "./Util_Lambda.res.mjs";
13
14
  import * as Util_LambdaLogging$ReventlessAws from "./Util_LambdaLogging.res.mjs";
15
+ import * as Util_DeadLetterHandler$ReventlessAws from "./Util_DeadLetterHandler.res.mjs";
14
16
  import * as Util_EventSourceMapping$ReventlessAws from "./Util_EventSourceMapping.res.mjs";
15
17
 
16
18
  let name = "DeadLetterQueue";
@@ -45,32 +47,7 @@ let opts = {
45
47
 
46
48
  let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name, Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(name, "Plugin", "Identity", "Plugin", undefined, undefined, undefined, undefined), opts);
47
49
 
48
- let entryPointCode = `export const handler = async (event) => {
49
- const records = event?.Records ?? [];
50
- for (const record of records) {
51
- const attrs = record?.attributes ?? {};
52
- const receiveCount = Number(attrs.ApproximateReceiveCount ?? 1);
53
- const identity =
54
- "messageId=" + (record?.messageId ?? "unknown") +
55
- " source=" + (attrs.DeadLetterQueueSourceArn ?? record?.eventSourceARN ?? "unknown") +
56
- " receiveCount=" + receiveCount +
57
- " bodyBytes=" + (record?.body?.length ?? 0);
58
- if (receiveCount <= 1) {
59
- // Ids, type and size, never the body: a dead letter is a command or event,
60
- // and its payload may be about a person.
61
- const body = (() => { try { return JSON.parse(record?.body ?? "null") } catch { return null } })();
62
- const kind = body?.command?.TAG ?? body?.event?.TAG ?? (body?.Message ? "sns" : "unknown");
63
- console.error("DEAD LETTER ITEM: " + identity + " kind=" + kind +
64
- " attributes=" + JSON.stringify(Object.keys(record?.messageAttributes ?? {})));
65
- } else {
66
- console.error("DEAD LETTER REDELIVERY: " + identity);
67
- }
68
- }
69
- throw new Error(
70
- "Dead-lettered " + records.length +
71
- " message(s); see DEAD LETTER ITEM above. Failing so the messages are retained and Errors is non-zero."
72
- );
73
- };`;
50
+ let entryPointCode = Nodefs.readFileSync(Util_Bundle$ReventlessAws.localRequire.resolve("./Util_DeadLetterHandler.res.mjs"), "utf8");
74
51
 
75
52
  let archiveContents = {};
76
53
 
@@ -185,6 +162,8 @@ Pulumi.all([
185
162
 
186
163
  let retentionSeconds = 1209600;
187
164
 
165
+ let maxReceiveCount = Util_DeadLetterHandler$ReventlessAws.maxReceiveCount;
166
+
188
167
  let visibilityTimeoutSeconds = 900;
189
168
 
190
169
  export {
@@ -192,6 +171,7 @@ export {
192
171
  nameFifo,
193
172
  queueTags,
194
173
  retentionSeconds,
174
+ maxReceiveCount,
195
175
  visibilityTimeoutSeconds,
196
176
  queue,
197
177
  fifoQueue,
@@ -0,0 +1,78 @@
1
+ open JestGlobals
2
+
3
+ module H = Util_DeadLetterHandler
4
+
5
+ // A dead letter writes one full line on its first delivery here and a short one on every
6
+ // redelivery. A message moved here by a redrive policy keeps its receive count, so its first
7
+ // delivery reports `maxReceiveCount + 1`; reading only 1 as first meant the full line was
8
+ // never written for any of them.
9
+
10
+ let recordWith = (~receiveCount: int): H.record => {
11
+ messageId: "m-1",
12
+ body: `{"command":{"TAG":"Heartbeat"}}`,
13
+ attributes: Dict.fromArray([
14
+ ("ApproximateReceiveCount", Int.toString(receiveCount)),
15
+ ("DeadLetterQueueSourceArn", "arn:aws:sqs:eu-west-1:1:CmdTopic"),
16
+ ]),
17
+ }
18
+
19
+ let isItem = line => line->String.startsWith("DEAD LETTER ITEM:")
20
+
21
+ describe("the dead-letter handler's line", () => {
22
+ testSync("is the full one when a redriven message first arrives", () => {
23
+ let line = H.lineOf(recordWith(~receiveCount=H.maxReceiveCount + 1))
24
+ expect((isItem(line), line->String.includes("kind=Heartbeat")))->toEqual((true, true))
25
+ })
26
+
27
+ testSync("is the full one for a message sent here directly", () =>
28
+ expect(isItem(H.lineOf(recordWith(~receiveCount=1))))->toBe(true)
29
+ )
30
+
31
+ testSync("is the short one on a redelivery", () => {
32
+ let line = H.lineOf(recordWith(~receiveCount=H.maxReceiveCount + 2))
33
+ expect((
34
+ line->String.startsWith("DEAD LETTER REDELIVERY:"),
35
+ line->String.includes("kind="),
36
+ ))->toEqual((true, false))
37
+ })
38
+
39
+ testSync("names an unreadable body's kind unknown, and never writes the body", () => {
40
+ let line = H.lineOf({
41
+ messageId: "m-2",
42
+ body: "not json, about a person",
43
+ attributes: Dict.make(),
44
+ })
45
+ expect((
46
+ line->String.includes("kind=unknown"),
47
+ line->String.includes("about a person"),
48
+ ))->toEqual((true, false))
49
+ })
50
+ })
51
+
52
+ describe("the dead-letter handler", () => {
53
+ test("fails on purpose, so the messages stay on the queue", async () => {
54
+ let failed = switch await H.handler({records: [recordWith(~receiveCount=6)]}) {
55
+ | () => false
56
+ | exception _ => true
57
+ }
58
+ expect(failed)->toBe(true)
59
+ })
60
+
61
+ // The function ships the compiled file alone, so it must import nothing.
62
+ testSync("compiles to a file that imports nothing", () => {
63
+ let text = NodeFs.readFileSync(
64
+ NodePath.join([
65
+ NodePath.dirname(NodeUrl.fileURLToPath(%raw("import.meta.url"))),
66
+ "..",
67
+ "src",
68
+ "util",
69
+ "Util_DeadLetterHandler.res.mjs",
70
+ ]),
71
+ )
72
+ expect(
73
+ text
74
+ ->String.split("\n")
75
+ ->Array.some(l => l->String.startsWith("import ") || l->String.includes("require(")),
76
+ )->toBe(false)
77
+ })
78
+ })
@@ -0,0 +1,101 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodefs from "node:fs";
4
+ import * as Nodeurl from "node:url";
5
+ import * as Nodepath from "node:path";
6
+ import * as Util_DeadLetterHandler$ReventlessAws from "../src/util/Util_DeadLetterHandler.res.mjs";
7
+
8
+ function recordWith(receiveCount) {
9
+ return {
10
+ messageId: "m-1",
11
+ body: `{"command":{"TAG":"Heartbeat"}}`,
12
+ attributes: Object.fromEntries([
13
+ [
14
+ "ApproximateReceiveCount",
15
+ receiveCount.toString()
16
+ ],
17
+ [
18
+ "DeadLetterQueueSourceArn",
19
+ "arn:aws:sqs:eu-west-1:1:CmdTopic"
20
+ ]
21
+ ])
22
+ };
23
+ }
24
+
25
+ function isItem(line) {
26
+ return line.startsWith("DEAD LETTER ITEM:");
27
+ }
28
+
29
+ globalThis.describe("the dead-letter handler's line", () => {
30
+ globalThis.test("is the full one when a redriven message first arrives", () => {
31
+ let line = Util_DeadLetterHandler$ReventlessAws.lineOf(recordWith(Util_DeadLetterHandler$ReventlessAws.maxReceiveCount + 1 | 0));
32
+ globalThis.expect([
33
+ line.startsWith("DEAD LETTER ITEM:"),
34
+ line.includes("kind=Heartbeat")
35
+ ]).toEqual([
36
+ true,
37
+ true
38
+ ]);
39
+ });
40
+ globalThis.test("is the full one for a message sent here directly", () => {
41
+ globalThis.expect(Util_DeadLetterHandler$ReventlessAws.lineOf(recordWith(1)).startsWith("DEAD LETTER ITEM:")).toBe(true);
42
+ });
43
+ globalThis.test("is the short one on a redelivery", () => {
44
+ let line = Util_DeadLetterHandler$ReventlessAws.lineOf(recordWith(Util_DeadLetterHandler$ReventlessAws.maxReceiveCount + 2 | 0));
45
+ globalThis.expect([
46
+ line.startsWith("DEAD LETTER REDELIVERY:"),
47
+ line.includes("kind=")
48
+ ]).toEqual([
49
+ true,
50
+ false
51
+ ]);
52
+ });
53
+ globalThis.test("names an unreadable body's kind unknown, and never writes the body", () => {
54
+ let line = Util_DeadLetterHandler$ReventlessAws.lineOf({
55
+ messageId: "m-2",
56
+ body: "not json, about a person",
57
+ attributes: {}
58
+ });
59
+ globalThis.expect([
60
+ line.includes("kind=unknown"),
61
+ line.includes("about a person")
62
+ ]).toEqual([
63
+ true,
64
+ false
65
+ ]);
66
+ });
67
+ });
68
+
69
+ globalThis.describe("the dead-letter handler", () => {
70
+ globalThis.test("fails on purpose, so the messages stay on the queue", async () => {
71
+ let failed;
72
+ try {
73
+ await Util_DeadLetterHandler$ReventlessAws.handler({
74
+ Records: [recordWith(6)]
75
+ });
76
+ failed = false;
77
+ } catch (exn) {
78
+ failed = true;
79
+ }
80
+ globalThis.expect(failed).toBe(true);
81
+ });
82
+ globalThis.test("compiles to a file that imports nothing", () => {
83
+ let text = Nodefs.readFileSync(Nodepath.join(Nodepath.dirname(Nodeurl.fileURLToPath(import.meta.url)), "..", "src", "util", "Util_DeadLetterHandler.res.mjs"), "utf8");
84
+ globalThis.expect(text.split("\n").some(l => {
85
+ if (l.startsWith("import ")) {
86
+ return true;
87
+ } else {
88
+ return l.includes("require(");
89
+ }
90
+ })).toBe(false);
91
+ });
92
+ });
93
+
94
+ let H;
95
+
96
+ export {
97
+ H,
98
+ recordWith,
99
+ isItem,
100
+ }
101
+ /* Not a pure module */