@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.
- package/CHANGELOG.md +17 -0
- package/package.json +6 -6
- package/src/adapter/AWS_Tags.res +36 -0
- package/src/adapter/Adapter_Helpers.res +6 -0
- package/src/adapter/Adapter_Helpers.res.mjs +6 -0
- package/src/adapter/CommandTopic/CommandTopicChannel_Helpers.res +77 -48
- package/src/adapter/CommandTopic/CommandTopicChannel_Helpers.res.mjs +55 -40
- package/src/adapter/Counter/CounterHandler_DynamoDbStream.res +6 -0
- package/src/adapter/Counter/CounterHandler_DynamoDbStream.res.mjs +3 -1
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +29 -6
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs +13 -1
- package/src/adapter/EventCollector/EventCollectorChannel_Helpers.res +83 -60
- package/src/adapter/EventCollector/EventCollectorChannel_Helpers.res.mjs +20 -18
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res +44 -30
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res.mjs +22 -23
- package/src/adapter/Postgres/PgProjectionFeed.res +20 -1
- package/src/adapter/Postgres/PgProjectionFeed.res.mjs +3 -1
- package/src/adapter/Runtime/EventCollectorEntryPoint.mjs +6 -2
- package/src/adapter/Runtime/PluginExtensionPointEntryPoint.mjs +9 -2
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +10 -4
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +1 -1
- package/src/adapter/StateTopic/StateTopic_AppSync.res +8 -1
- package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +4 -2
- package/src/plugin/cloner/ClonerRunner_Fargate.res +6 -0
- package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +2 -1
- package/src/util/Util_DeadLetterQueue.res +39 -4
- package/src/util/Util_DeadLetterQueue.res.mjs +16 -4
- package/src/util/Util_EventSourceMapping.res +8 -0
- package/src/util/Util_EventSourceMapping.res.mjs +6 -4
- package/tests/CommandTopicQueuePolicyTest.res +65 -0
- package/tests/CommandTopicQueuePolicyTest.res.mjs +53 -0
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +50 -0
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res.mjs +47 -0
- package/tests/integration/DcbEventLogStorage_DynamoDb_IntegrationTest.res +58 -0
- 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
|
|
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(((
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
145
|
+
queueUrl: queue.id
|
|
136
146
|
}, opts);
|
|
137
147
|
new (Aws.sqs.QueuePolicy)(nameFifo, {
|
|
138
148
|
policy: createQueuePolicyDocument(nameFifo, fifoQueueArn, handlerArn),
|
|
139
|
-
queueUrl:
|
|
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;
|