@reventlessdev/reventless-aws 3.0.0-alpha.223 → 3.0.0-alpha.225

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 (35) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +6 -6
  3. package/src/adapter/AWS_Tags.res +36 -0
  4. package/src/adapter/Adapter_Helpers.res +6 -0
  5. package/src/adapter/Adapter_Helpers.res.mjs +6 -0
  6. package/src/adapter/CommandTopic/CommandTopicChannel_Helpers.res +77 -48
  7. package/src/adapter/CommandTopic/CommandTopicChannel_Helpers.res.mjs +55 -40
  8. package/src/adapter/Counter/CounterHandler_DynamoDbStream.res +6 -0
  9. package/src/adapter/Counter/CounterHandler_DynamoDbStream.res.mjs +3 -1
  10. package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +29 -6
  11. package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs +13 -1
  12. package/src/adapter/EventCollector/EventCollectorChannel_Helpers.res +83 -60
  13. package/src/adapter/EventCollector/EventCollectorChannel_Helpers.res.mjs +20 -18
  14. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res +44 -30
  15. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res.mjs +22 -23
  16. package/src/adapter/Postgres/PgProjectionFeed.res +20 -1
  17. package/src/adapter/Postgres/PgProjectionFeed.res.mjs +3 -1
  18. package/src/adapter/Runtime/EventCollectorEntryPoint.mjs +6 -2
  19. package/src/adapter/Runtime/PluginExtensionPointEntryPoint.mjs +9 -2
  20. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +10 -4
  21. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +1 -1
  22. package/src/adapter/StateTopic/StateTopic_AppSync.res +8 -1
  23. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +4 -2
  24. package/src/plugin/cloner/ClonerRunner_Fargate.res +6 -0
  25. package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +2 -1
  26. package/src/util/Util_DeadLetterQueue.res +39 -4
  27. package/src/util/Util_DeadLetterQueue.res.mjs +16 -4
  28. package/src/util/Util_EventSourceMapping.res +8 -0
  29. package/src/util/Util_EventSourceMapping.res.mjs +6 -4
  30. package/tests/CommandTopicQueuePolicyTest.res +65 -0
  31. package/tests/CommandTopicQueuePolicyTest.res.mjs +53 -0
  32. package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +50 -0
  33. package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res.mjs +47 -0
  34. package/tests/integration/DcbEventLogStorage_DynamoDb_IntegrationTest.res +58 -0
  35. package/tests/integration/DcbEventLogStorage_DynamoDb_IntegrationTest.res.mjs +78 -0
@@ -131,7 +131,8 @@ export const handler = async (event) => {
131
131
  memory: "4096",
132
132
  networkMode: "awsvpc",
133
133
  requiresCompatibilities: ["FARGATE"],
134
- executionRoleArn: taskExecutionRole.arn
134
+ executionRoleArn: taskExecutionRole.arn,
135
+ tags: AWS_Tags$ReventlessAws.make(name, Cloner$ReventlessCore.componentType, "DataTransfer", undefined, name, undefined, undefined, undefined)
135
136
  }, opts !== undefined ? Primitive_option.valFromOption(opts) : undefined);
136
137
  let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name, Pulumi.output(AWS$ReventlessAws.AppSync.principal), AWS_Tags$ReventlessAws.make(name, Cloner$ReventlessCore.componentType, "Identity", undefined, undefined, undefined, undefined, undefined), opts);
137
138
  let lambda = new (Aws.lambda.Function)(name, {
@@ -4,11 +4,20 @@ open PulumiAws
4
4
  let name = "DeadLetterQueue"
5
5
  let nameFifo = "FIFO" ++ name
6
6
 
7
+ let queueTags = queueName =>
8
+ AWS.Tags.make(
9
+ ~name=queueName,
10
+ ~kind=ReventlessCore.ComponentType.Plugin,
11
+ ~role=DeadLetter,
12
+ ~scope=Plugin,
13
+ )
14
+
7
15
  let queue = SQS.Queue.make(
8
16
  ~name,
9
17
  ~args={
10
18
  SQS.Queue.visibilityTimeoutSeconds: 180->Pulumi.Input.make,
11
19
  sqsManagedSseEnabled: false->Pulumi.Input.make,
20
+ tags: queueTags(name),
12
21
  },
13
22
  )
14
23
 
@@ -19,6 +28,7 @@ let fifoQueue = SQS.Queue.make(
19
28
  contentBasedDeduplication: true->Pulumi.Input.make,
20
29
  visibilityTimeoutSeconds: 180->Pulumi.Input.make,
21
30
  sqsManagedSseEnabled: false->Pulumi.Input.make,
31
+ tags: queueTags(nameFifo),
22
32
  },
23
33
  )
24
34
 
@@ -89,11 +99,26 @@ ReventlessCore.Monitoring.notify(
89
99
 
90
100
  let lambda = handler->Pulumi.Output.make
91
101
 
92
- let _subscription = Util_EventSourceMapping.subscribeSqs(~lambda, ~name, ~queue, ~opts)
102
+ let esmTags = esmName =>
103
+ AWS.Tags.make(
104
+ ~name=esmName,
105
+ ~kind=ReventlessCore.ComponentType.Plugin,
106
+ ~role=EventSourceMapping,
107
+ ~scope=Plugin,
108
+ )
109
+
110
+ let _subscription = Util_EventSourceMapping.subscribeSqs(
111
+ ~lambda,
112
+ ~name,
113
+ ~queue,
114
+ ~tags=esmTags(name),
115
+ ~opts,
116
+ )
93
117
  let _fifoSubscription = Util_EventSourceMapping.subscribeSqs(
94
118
  ~lambda,
95
119
  ~name=nameFifo,
96
120
  ~queue=fifoQueue,
121
+ ~tags=esmTags(nameFifo),
97
122
  ~opts,
98
123
  )
99
124
  let createQueuePolicyDocument = (name, queueArn: string, handlerArn: string) => {
@@ -122,7 +147,7 @@ let createQueuePolicyDocument = (name, queueArn: string, handlerArn: string) =>
122
147
  let _ =
123
148
  (queue.id, queue.arn, fifoQueue.id, fifoQueue.arn, handler.arn)
124
149
  ->Pulumi.Output.all5
125
- ->Pulumi.Output.apply(((queueId, queueArn, fifoQueueId, fifoQueueArn, handlerArn)) => {
150
+ ->Pulumi.Output.apply(((_queueId, queueArn, _fifoQueueId, fifoQueueArn, handlerArn)) => {
126
151
  let lambdaPolicyDocument = PulumiAws.PolicyDocument.make(
127
152
  ~id=name ++ "SQSPolicy",
128
153
  ~statements=[
@@ -152,10 +177,20 @@ let _ =
152
177
  ~opts,
153
178
  )
154
179
 
180
+ // `queueUrl` takes the queue's Output, not the resolved `queueId` string:
181
+ // that is what registers the policy -> queue dependency, without which
182
+ // Pulumi has no ordering constraint and can delete the queue first on a
183
+ // replacement, leaving the policy's delete polling a queue that is gone.
184
+ //
185
+ // Unlike the channel helpers, these two stay inside the apply. This module
186
+ // creates its resources at import time and this apply resolves late enough
187
+ // to race Jest's teardown; reshaping it (dropping the now-unused ids to
188
+ // narrow the tuple) shifts that timing and crashes the AWS unit suites, so
189
+ // `_queueId` / `_fifoQueueId` are deliberately kept.
155
190
  let _attachQueuePolicy = PulumiAws.SQS.QueuePolicy.make(
156
191
  ~name,
157
192
  ~args={
158
- queueUrl: queueId->Pulumi.Input.make,
193
+ queueUrl: queue.id->Pulumi.Output.asInput,
159
194
  policy: createQueuePolicyDocument(name, queueArn, handlerArn),
160
195
  },
161
196
  ~opts=Some(opts),
@@ -163,7 +198,7 @@ let _ =
163
198
  let _attachFifoQueuePolicy = PulumiAws.SQS.QueuePolicy.make(
164
199
  ~name=nameFifo,
165
200
  ~args={
166
- queueUrl: fifoQueueId->Pulumi.Input.make,
201
+ queueUrl: fifoQueue.id->Pulumi.Output.asInput,
167
202
  policy: createQueuePolicyDocument(nameFifo, fifoQueueArn, handlerArn),
168
203
  },
169
204
  ~opts=Some(opts),
@@ -17,7 +17,12 @@ let name = "DeadLetterQueue";
17
17
 
18
18
  let nameFifo = "FIFO" + name;
19
19
 
20
+ function queueTags(queueName) {
21
+ return AWS_Tags$ReventlessAws.make(queueName, "Plugin", "DeadLetter", "Plugin", undefined, undefined, undefined, undefined);
22
+ }
23
+
20
24
  let queue = new (Aws.sqs.Queue)(name, {
25
+ tags: queueTags(name),
21
26
  visibilityTimeoutSeconds: 180,
22
27
  sqsManagedSseEnabled: false
23
28
  });
@@ -25,6 +30,7 @@ let queue = new (Aws.sqs.Queue)(name, {
25
30
  let fifoQueue = new (Aws.sqs.Queue)(nameFifo, {
26
31
  contentBasedDeduplication: true,
27
32
  fifoQueue: true,
33
+ tags: queueTags(nameFifo),
28
34
  visibilityTimeoutSeconds: 180,
29
35
  sqsManagedSseEnabled: false
30
36
  });
@@ -73,9 +79,13 @@ Monitoring$ReventlessCore.notify("DeadLetterSink", name, Util_Lambda$ReventlessA
73
79
 
74
80
  let lambda = Pulumi.output(handler);
75
81
 
76
- let _subscription = Util_EventSourceMapping$ReventlessAws.subscribeSqs(lambda, name, queue, undefined, opts);
82
+ function esmTags(esmName) {
83
+ return AWS_Tags$ReventlessAws.make(esmName, "Plugin", "EventSourceMapping", "Plugin", undefined, undefined, undefined, undefined);
84
+ }
85
+
86
+ let _subscription = Util_EventSourceMapping$ReventlessAws.subscribeSqs(lambda, name, queue, undefined, esmTags(name), opts);
77
87
 
78
- let _fifoSubscription = Util_EventSourceMapping$ReventlessAws.subscribeSqs(lambda, nameFifo, fifoQueue, undefined, opts);
88
+ let _fifoSubscription = Util_EventSourceMapping$ReventlessAws.subscribeSqs(lambda, nameFifo, fifoQueue, undefined, esmTags(nameFifo), opts);
79
89
 
80
90
  function createQueuePolicyDocument(name, queueArn, handlerArn) {
81
91
  return PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "QueuePolicy", [{
@@ -132,17 +142,18 @@ Pulumi.all([
132
142
  }, opts);
133
143
  new (Aws.sqs.QueuePolicy)(name, {
134
144
  policy: createQueuePolicyDocument(name, queueArn, handlerArn),
135
- queueUrl: param[0]
145
+ queueUrl: queue.id
136
146
  }, opts);
137
147
  new (Aws.sqs.QueuePolicy)(nameFifo, {
138
148
  policy: createQueuePolicyDocument(nameFifo, fifoQueueArn, handlerArn),
139
- queueUrl: param[2]
149
+ queueUrl: fifoQueue.id
140
150
  }, opts);
141
151
  });
142
152
 
143
153
  export {
144
154
  name,
145
155
  nameFifo,
156
+ queueTags,
146
157
  queue,
147
158
  fifoQueue,
148
159
  opts,
@@ -154,6 +165,7 @@ export {
154
165
  layers,
155
166
  handler,
156
167
  lambda,
168
+ esmTags,
157
169
  _subscription,
158
170
  _fifoSubscription,
159
171
  createQueuePolicyDocument,
@@ -1,11 +1,16 @@
1
1
  open PulumiAws
2
2
 
3
+ // `~tags` is required rather than optional: an EventSourceMapping is created by
4
+ // several unrelated adapters, and an optional argument is exactly how the last
5
+ // coverage sweep left these bare. Making the caller name the owning piece is the
6
+ // regression guard — the same reasoning as `makeFromCodeAsset`'s `~componentKind`.
3
7
  let subscribe = (
4
8
  ~batchSize=?,
5
9
  ~lambda: Pulumi.Output.t<PulumiAws.Lambda.Function.t>,
6
10
  ~targetName,
7
11
  ~sourceName,
8
12
  ~source: ReventlessInfra.Adapter.resource,
13
+ ~tags: Pulumi.Input.t<Aws.tags>,
9
14
  ~opts,
10
15
  ) =>
11
16
  EventSourceMapping.make(
@@ -17,6 +22,7 @@ let subscribe = (
17
22
  eventSourceArn: source.urn->Pulumi.Output.asInput,
18
23
  startingPosition: LATEST,
19
24
  ?batchSize,
25
+ tags,
20
26
  },
21
27
  ~opts=Some(opts),
22
28
  )
@@ -26,6 +32,7 @@ let subscribeSqs = (
26
32
  ~name,
27
33
  ~queue: PulumiAws.SQS.Queue.t,
28
34
  ~batchSize=?,
35
+ ~tags: Pulumi.Input.t<Aws.tags>,
29
36
  ~opts,
30
37
  ) => {
31
38
  let esm = EventSourceMapping.make(
@@ -36,6 +43,7 @@ let subscribeSqs = (
36
43
  ->Pulumi.Output.asInput,
37
44
  eventSourceArn: queue.arn->Pulumi.Output.asInput,
38
45
  ?batchSize,
46
+ tags,
39
47
  },
40
48
  ~opts=Some(opts),
41
49
  )
@@ -7,20 +7,22 @@ import * as AWS$ReventlessAws from "../adapter/AWS.res.mjs";
7
7
  import * as Util$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util.res.mjs";
8
8
  import * as Adapter$ReventlessInfra from "@reventlessdev/reventless-infra/src/adapter/Adapter.res.mjs";
9
9
 
10
- function subscribe(batchSize, lambda, targetName, sourceName, source, opts) {
10
+ function subscribe(batchSize, lambda, targetName, sourceName, source, tags, opts) {
11
11
  return new (Aws.lambda.EventSourceMapping)(Util$ReventlessCore.baseName(sourceName) + ("2" + targetName), {
12
12
  functionName: Output$Pulumi.flatMap(lambda, lambda => lambda.arn),
13
13
  batchSize: batchSize,
14
14
  eventSourceArn: source.urn,
15
- startingPosition: "LATEST"
15
+ startingPosition: "LATEST",
16
+ tags: tags
16
17
  }, opts);
17
18
  }
18
19
 
19
- function subscribeSqs(lambda, name, queue, batchSize, opts) {
20
+ function subscribeSqs(lambda, name, queue, batchSize, tags, opts) {
20
21
  let esm = new (Aws.lambda.EventSourceMapping)(name, {
21
22
  functionName: Output$Pulumi.flatMap(lambda, lambda => lambda.arn),
22
23
  batchSize: batchSize,
23
- eventSourceArn: queue.arn
24
+ eventSourceArn: queue.arn,
25
+ tags: tags
24
26
  }, opts);
25
27
  return Adapter$ReventlessInfra.make(esm.id, esm.id, esm.arn, esm.id.apply(param => AWS$ReventlessAws.SQS.service), undefined, undefined, undefined, Pulumi.output("aws:lambda:EventSourceMapping"), undefined, undefined);
26
28
  }
@@ -0,0 +1,65 @@
1
+ open JestGlobals
2
+
3
+ // Guards the CommandTopic queue policy's service-principal scoping. The
4
+ // EventBridge grant cannot name a single rule ARN — ScheduledPublisher creates
5
+ // rules at runtime and EventBridge authorises SQS targets against the queue's
6
+ // resource policy — so the condition keys are the only bound on who may send
7
+ // into a command topic. A dropped condition is invisible at deploy time.
8
+
9
+ let queueArn = "arn:aws:sqs:eu-west-1:123456789012:CatalogAggrCmdTopic-5150050"
10
+ let lambdaArn = "arn:aws:lambda:eu-west-1:123456789012:function:AllAggregates-1a2b3c4"
11
+
12
+ let policy =
13
+ CommandTopicChannel_Helpers.createQueuePolicyDocument(
14
+ ~name="CatalogAggrCmdTopic",
15
+ ~queueArn,
16
+ ~lambdaArn,
17
+ )->JSON.parseOrThrow
18
+
19
+ let field = (json, key) =>
20
+ json->JSON.Decode.object->Option.flatMap(o => o->Dict.get(key))
21
+
22
+ let statement = sid =>
23
+ policy
24
+ ->field("Statement")
25
+ ->Option.flatMap(JSON.Decode.array)
26
+ ->Option.getOr([])
27
+ ->Array.find(s => s->field("Sid")->Option.flatMap(JSON.Decode.string) == Some(sid))
28
+
29
+ let condition = (sid, operator, key) =>
30
+ statement(sid)
31
+ ->Option.flatMap(s => s->field("Condition"))
32
+ ->Option.flatMap(c => c->field(operator))
33
+ ->Option.flatMap(o => o->field(key))
34
+ ->Option.flatMap(JSON.Decode.string)
35
+
36
+ describe("CommandTopic queue policy — EventBridge grant", () => {
37
+ testSync("is scoped to this account", () => {
38
+ expect(
39
+ condition("AllowCloudWatchEventsToSendToQueue", "StringEquals", "aws:SourceAccount"),
40
+ )->toEqual(Some("123456789012"))
41
+ })
42
+
43
+ testSync("is scoped to EventBridge rule ARNs in this account", () => {
44
+ expect(condition("AllowCloudWatchEventsToSendToQueue", "ArnLike", "aws:SourceArn"))->toEqual(
45
+ Some("arn:aws:events:*:123456789012:rule/*"),
46
+ )
47
+ })
48
+
49
+ testSync("names the EventBridge service principal", () => {
50
+ let principal =
51
+ statement("AllowCloudWatchEventsToSendToQueue")
52
+ ->Option.flatMap(s => s->field("Principal"))
53
+ ->Option.flatMap(p => p->field("Service"))
54
+ ->Option.flatMap(JSON.Decode.string)
55
+ expect(principal)->toEqual(Some("events.amazonaws.com"))
56
+ })
57
+ })
58
+
59
+ describe("CommandTopic queue policy — Lambda grant", () => {
60
+ testSync("stays pinned to the consuming function ARN", () => {
61
+ expect(condition("AllowLambdaToAccessQueue", "ArnEquals", "AWS:SourceArn"))->toEqual(
62
+ Some(lambdaArn),
63
+ )
64
+ })
65
+ })
@@ -0,0 +1,53 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
6
+ import * as CommandTopicChannel_Helpers$ReventlessAws from "../src/adapter/CommandTopic/CommandTopicChannel_Helpers.res.mjs";
7
+
8
+ let queueArn = "arn:aws:sqs:eu-west-1:123456789012:CatalogAggrCmdTopic-5150050";
9
+
10
+ let lambdaArn = "arn:aws:lambda:eu-west-1:123456789012:function:AllAggregates-1a2b3c4";
11
+
12
+ let policy = JSON.parse(CommandTopicChannel_Helpers$ReventlessAws.createQueuePolicyDocument("CatalogAggrCmdTopic", queueArn, lambdaArn));
13
+
14
+ function field(json, key) {
15
+ return Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), o => o[key]);
16
+ }
17
+
18
+ function statement(sid) {
19
+ return Stdlib_Option.getOr(Stdlib_Option.flatMap(field(policy, "Statement"), Stdlib_JSON.Decode.array), []).find(s => Primitive_object.equal(Stdlib_Option.flatMap(field(s, "Sid"), Stdlib_JSON.Decode.string), sid));
20
+ }
21
+
22
+ function condition(sid, operator, key) {
23
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(statement(sid), s => field(s, "Condition")), c => field(c, operator)), o => field(o, key)), Stdlib_JSON.Decode.string);
24
+ }
25
+
26
+ globalThis.describe("CommandTopic queue policy — EventBridge grant", () => {
27
+ globalThis.test("is scoped to this account", () => {
28
+ globalThis.expect(condition("AllowCloudWatchEventsToSendToQueue", "StringEquals", "aws:SourceAccount")).toEqual("123456789012");
29
+ });
30
+ globalThis.test("is scoped to EventBridge rule ARNs in this account", () => {
31
+ globalThis.expect(condition("AllowCloudWatchEventsToSendToQueue", "ArnLike", "aws:SourceArn")).toEqual("arn:aws:events:*:123456789012:rule/*");
32
+ });
33
+ globalThis.test("names the EventBridge service principal", () => {
34
+ let principal = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(statement("AllowCloudWatchEventsToSendToQueue"), s => field(s, "Principal")), p => field(p, "Service")), Stdlib_JSON.Decode.string);
35
+ globalThis.expect(principal).toEqual("events.amazonaws.com");
36
+ });
37
+ });
38
+
39
+ globalThis.describe("CommandTopic queue policy — Lambda grant", () => {
40
+ globalThis.test("stays pinned to the consuming function ARN", () => {
41
+ globalThis.expect(condition("AllowLambdaToAccessQueue", "ArnEquals", "AWS:SourceArn")).toEqual(lambdaArn);
42
+ });
43
+ });
44
+
45
+ export {
46
+ queueArn,
47
+ lambdaArn,
48
+ policy,
49
+ field,
50
+ statement,
51
+ condition,
52
+ }
53
+ /* policy Not a pure module */
@@ -559,6 +559,56 @@ describe("Runtime.toItem — tag_composite is keyed on the event's entity tags",
559
559
  })
560
560
  })
561
561
 
562
+ describe("Runtime.toItem — an empty tag value skips its GSI attribute", () => {
563
+ // `tag_<key>` is a GSI hash key, and DynamoDB rejects an empty string for a key
564
+ // attribute — writing one fails the whole append. An empty value is legitimate
565
+ // (an absent composite-partition member), so the attribute is skipped and that
566
+ // index goes sparse. See docs/plans/dcb-empty-tag-values-break-append.md.
567
+ let tagOf = (key, value): Reventless.DcbTag.tag => {key, value}
568
+ let tags = [tagOf("environment", "prod"), tagOf("componentName", "")]
569
+ let storedEvent: ReventlessCore.DcbEventLog_Adapter.rawStoredEvent = {
570
+ eventType: "ResourceAdded",
571
+ data: JSON.Object(Dict.make()),
572
+ tags,
573
+ meta: testMeta(),
574
+ }
575
+ let item =
576
+ Runtime.toItem("100", storedEvent, ~recordedAt="2026-07-08T00:00:00Z")
577
+ ->JSON.Decode.object
578
+ ->Option.getOrThrow
579
+
580
+ testSync("the non-empty tag keeps its attribute", () => {
581
+ expect(item->Dict.get("tag_environment"))->toEqual(Some("prod"->JSON.Encode.string))
582
+ })
583
+
584
+ testSync("the empty tag has no attribute at all", () => {
585
+ expect(item->Dict.get("tag_componentName"))->toEqual(None)
586
+ })
587
+
588
+ testSync("tag_composite still carries both tags", () => {
589
+ expect(item->Dict.get("tag_composite"))->toEqual(
590
+ Some(Runtime.compositeTagKey(tags)->JSON.Encode.string),
591
+ )
592
+ })
593
+
594
+ testSync("the tags payload records the empty value verbatim", () => {
595
+ let payloadTags =
596
+ item
597
+ ->Dict.get("tags")
598
+ ->Option.flatMap(JSON.Decode.array)
599
+ ->Option.getOrThrow
600
+ ->Array.filterMap(t =>
601
+ t
602
+ ->JSON.Decode.object
603
+ ->Option.map(o => (
604
+ o->Dict.get("key")->Option.flatMap(JSON.Decode.string)->Option.getOr(""),
605
+ o->Dict.get("value")->Option.flatMap(JSON.Decode.string)->Option.getOr("!missing"),
606
+ ))
607
+ )
608
+ expect(payloadTags)->toEqual([("environment", "prod"), ("componentName", "")])
609
+ })
610
+ })
611
+
562
612
  describe("Runtime.buildConditionalTransactItems — folded create guard (after=None)", () => {
563
613
  let event = (eventType, tags): ReventlessCore.DcbEventLog_Adapter.rawStoredEvent => {
564
614
  eventType,
@@ -690,6 +690,53 @@ globalThis.describe("Runtime.toItem — tag_composite is keyed on the event's en
690
690
  });
691
691
  });
692
692
 
693
+ globalThis.describe("Runtime.toItem — an empty tag value skips its GSI attribute", () => {
694
+ let tags = [
695
+ {
696
+ key: "environment",
697
+ value: "prod"
698
+ },
699
+ {
700
+ key: "componentName",
701
+ value: ""
702
+ }
703
+ ];
704
+ let storedEvent_data = {};
705
+ let storedEvent_meta = testMeta();
706
+ let storedEvent = {
707
+ eventType: "ResourceAdded",
708
+ data: storedEvent_data,
709
+ tags: tags,
710
+ meta: storedEvent_meta
711
+ };
712
+ let item = Stdlib_Option.getOrThrow(Stdlib_JSON.Decode.object(DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.toItem("100", storedEvent, undefined, "2026-07-08T00:00:00Z")), undefined);
713
+ globalThis.test("the non-empty tag keeps its attribute", () => {
714
+ globalThis.expect(item["tag_environment"]).toEqual("prod");
715
+ });
716
+ globalThis.test("the empty tag has no attribute at all", () => {
717
+ globalThis.expect(item["tag_componentName"]).toEqual(undefined);
718
+ });
719
+ globalThis.test("tag_composite still carries both tags", () => {
720
+ globalThis.expect(item["tag_composite"]).toEqual(DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.compositeTagKey(tags));
721
+ });
722
+ globalThis.test("the tags payload records the empty value verbatim", () => {
723
+ let payloadTags = Stdlib_Array.filterMap(Stdlib_Option.getOrThrow(Stdlib_Option.flatMap(item["tags"], Stdlib_JSON.Decode.array), undefined), t => Stdlib_Option.map(Stdlib_JSON.Decode.object(t), o => [
724
+ Stdlib_Option.getOr(Stdlib_Option.flatMap(o["key"], Stdlib_JSON.Decode.string), ""),
725
+ Stdlib_Option.getOr(Stdlib_Option.flatMap(o["value"], Stdlib_JSON.Decode.string), "!missing")
726
+ ]));
727
+ globalThis.expect(payloadTags).toEqual([
728
+ [
729
+ "environment",
730
+ "prod"
731
+ ],
732
+ [
733
+ "componentName",
734
+ ""
735
+ ]
736
+ ]);
737
+ });
738
+ });
739
+
693
740
  globalThis.describe("Runtime.buildConditionalTransactItems — folded create guard (after=None)", () => {
694
741
  let event = (eventType, tags) => ({
695
742
  eventType: eventType,
@@ -458,3 +458,61 @@ describe("DCB DynamoDb integration — per-type fence granularity", () => {
458
458
  expect(conflicts >= 1)->toBe(true)
459
459
  })
460
460
  })
461
+
462
+ // An empty tag value is a legitimate model state (an absent member of a composite
463
+ // partition key), and the in-memory and SQLite backends have always accepted one.
464
+ // DynamoDB used to reject the whole append: `tag_<key>` is a GSI hash key and a key
465
+ // attribute cannot hold an empty string. The adapter now skips that one attribute,
466
+ // leaving the index sparse — everything else about the event is unchanged.
467
+ // See docs/plans/dcb-empty-tag-values-break-append.md.
468
+ describe("DCB DynamoDb integration — an empty tag value does not break the append", () => {
469
+ let tags = [tag("orderId", "O-empty"), tag("customerId", "")]
470
+
471
+ testAsync("appends, and reads back through its partition with the tag intact", async () => {
472
+ let table = await H.freshTable()
473
+ let result = await Runtime.appendConditional(
474
+ table,
475
+ [event("OrderPlaced", tags)],
476
+ {query: [{tags: tags}], after: ?None},
477
+ ~partitionTag=simple("orderId"),
478
+ )
479
+ expect(isOk(result))->toBe(true)
480
+
481
+ let byPartition = await Runtime.read(table)(~query=[{tags: [tag("orderId", "O-empty")]}])
482
+ expect(byPartition.events->Array.length)->toBe(1)
483
+ let stored = byPartition.events->Array.getUnsafe(0)
484
+ expect(stored.eventType)->toBe("OrderPlaced")
485
+ // The event records the empty value verbatim — only the index skips it.
486
+ expect(stored.tags->Array.map(t => (t.key, t.value)))->toEqual([
487
+ ("orderId", "O-empty"),
488
+ ("customerId", ""),
489
+ ])
490
+ })
491
+
492
+ testAsync("is found by a composite (multi-tag) read", async () => {
493
+ let table = await H.freshTable()
494
+ let _ = await Runtime.appendUnconditional(
495
+ table,
496
+ [event("OrderPlaced", tags)],
497
+ ~partitionTag=simple("orderId"),
498
+ )
499
+ let composite = await Runtime.read(table)(~query=[{tags: tags}])
500
+ expect(composite.events->Array.length)->toBe(1)
501
+ })
502
+
503
+ testAsync("is absent from that tag's single-tag index rather than erroring", async () => {
504
+ let table = await H.freshTable()
505
+ let _ = await Runtime.appendUnconditional(
506
+ table,
507
+ [event("OrderPlaced", tags)],
508
+ ~partitionTag=simple("orderId"),
509
+ )
510
+ // A cross-partition read goes through the `tag_customerId` GSI, which carries
511
+ // no empty-valued key. DynamoDB would reject the key condition outright, so the
512
+ // adapter short-circuits: empty result, no exception.
513
+ let crossPartition = await Runtime.read(table, ~crossPartitionTagKeys=["customerId"])(
514
+ ~query=[{tags: [tag("customerId", "")]}],
515
+ )
516
+ expect(crossPartition.events->Array.length)->toBe(0)
517
+ })
518
+ })
@@ -563,6 +563,84 @@ globalThis.describe("DCB DynamoDb integration — per-type fence granularity", (
563
563
  });
564
564
  });
565
565
 
566
+ globalThis.describe("DCB DynamoDb integration — an empty tag value does not break the append", () => {
567
+ let tags = [
568
+ {
569
+ key: "orderId",
570
+ value: "O-empty"
571
+ },
572
+ {
573
+ key: "customerId",
574
+ value: ""
575
+ }
576
+ ];
577
+ globalThis.test("appends, and reads back through its partition with the tag intact", async () => {
578
+ let table = await DcbIntegrationHarness$ReventlessAws.freshTable();
579
+ let result = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event("OrderPlaced", tags)], {
580
+ query: [{
581
+ tags: tags
582
+ }]
583
+ }, {
584
+ TAG: "Simple",
585
+ _0: {
586
+ key: "orderId"
587
+ }
588
+ }, undefined);
589
+ globalThis.expect(isOk(result)).toBe(true);
590
+ let byPartition = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.read(table, undefined)([{
591
+ tags: [{
592
+ key: "orderId",
593
+ value: "O-empty"
594
+ }]
595
+ }], undefined);
596
+ globalThis.expect(byPartition.events.length).toBe(1);
597
+ let stored = byPartition.events[0];
598
+ globalThis.expect(stored.eventType).toBe("OrderPlaced");
599
+ globalThis.expect(stored.tags.map(t => [
600
+ t.key,
601
+ t.value
602
+ ])).toEqual([
603
+ [
604
+ "orderId",
605
+ "O-empty"
606
+ ],
607
+ [
608
+ "customerId",
609
+ ""
610
+ ]
611
+ ]);
612
+ });
613
+ globalThis.test("is found by a composite (multi-tag) read", async () => {
614
+ let table = await DcbIntegrationHarness$ReventlessAws.freshTable();
615
+ await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendUnconditional(table, [event("OrderPlaced", tags)], {
616
+ TAG: "Simple",
617
+ _0: {
618
+ key: "orderId"
619
+ }
620
+ });
621
+ let composite = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.read(table, undefined)([{
622
+ tags: tags
623
+ }], undefined);
624
+ globalThis.expect(composite.events.length).toBe(1);
625
+ });
626
+ globalThis.test("is absent from that tag's single-tag index rather than erroring", async () => {
627
+ let table = await DcbIntegrationHarness$ReventlessAws.freshTable();
628
+ await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendUnconditional(table, [event("OrderPlaced", tags)], {
629
+ TAG: "Simple",
630
+ _0: {
631
+ key: "orderId"
632
+ }
633
+ });
634
+ let crossPartition = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.read(table, ["customerId"])([{
635
+ tags: [{
636
+ key: "customerId",
637
+ value: ""
638
+ }]
639
+ }], undefined);
640
+ globalThis.expect(crossPartition.events.length).toBe(0);
641
+ });
642
+ });
643
+
566
644
  let H;
567
645
 
568
646
  let Runtime;