@reventlessdev/reventless-aws 3.0.0-alpha.224 → 3.0.0-alpha.226

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 (33) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +5 -5
  3. package/src/adapter/AWS_Tags.res +36 -0
  4. package/src/adapter/CommandTopic/CommandTopicChannel_Helpers.res +12 -1
  5. package/src/adapter/CommandTopic/CommandTopicChannel_Helpers.res.mjs +3 -1
  6. package/src/adapter/Counter/CounterHandler_DynamoDbStream.res +6 -0
  7. package/src/adapter/Counter/CounterHandler_DynamoDbStream.res.mjs +3 -1
  8. package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +29 -6
  9. package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs +13 -1
  10. package/src/adapter/EventCollector/EventCollectorChannel_Helpers.res +22 -1
  11. package/src/adapter/EventCollector/EventCollectorChannel_Helpers.res.mjs +10 -2
  12. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res +12 -0
  13. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res.mjs +2 -1
  14. package/src/adapter/Postgres/PgProjectionFeed.res +20 -1
  15. package/src/adapter/Postgres/PgProjectionFeed.res.mjs +3 -1
  16. package/src/adapter/Runtime/EventCollectorEntryPoint.mjs +6 -2
  17. package/src/adapter/Runtime/PluginExtensionPointEntryPoint.mjs +9 -2
  18. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +10 -4
  19. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +1 -1
  20. package/src/adapter/StateTopic/StateTopic_AppSync.res +8 -1
  21. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +4 -2
  22. package/src/plugin/cloner/ClonerRunner_Fargate.res +6 -0
  23. package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +2 -1
  24. package/src/util/Util_DeadLetterQueue.res +26 -1
  25. package/src/util/Util_DeadLetterQueue.res.mjs +14 -2
  26. package/src/util/Util_EventSourceMapping.res +8 -0
  27. package/src/util/Util_EventSourceMapping.res.mjs +6 -4
  28. package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +50 -0
  29. package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res.mjs +47 -0
  30. package/tests/QueryDbResolvers_AppSyncTest.res +41 -0
  31. package/tests/QueryDbResolvers_AppSyncTest.res.mjs +24 -0
  32. package/tests/integration/DcbEventLogStorage_DynamoDb_IntegrationTest.res +58 -0
  33. package/tests/integration/DcbEventLogStorage_DynamoDb_IntegrationTest.res.mjs +78 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
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.226 (2026-07-23)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** round-trip the DynamoDB Scan cursor in full-list connections ([0e21008](https://github.com/ReventlessDev/reventless-core/commit/0e2100860767a153a5c2f50a88fd6b501787c7f4))
11
+
12
+
13
+ # 3.0.0-alpha.225 (2026-07-22)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **aws:** keep an empty DCB tag value from breaking the append ([1c0ab40](https://github.com/ReventlessDev/reventless-core/commit/1c0ab40f25b596702a81f42686d77c5e423e9b44))
18
+ * **aws:** key the disconnect schedule's rule name to the stack, not the Lambda ([05aebcf](https://github.com/ReventlessDev/reventless-core/commit/05aebcfacac34fd9c7cb42f353ee91e20066067e))
19
+ * **aws:** tag every taggable resource the framework still left bare ([d4f7a90](https://github.com/ReventlessDev/reventless-core/commit/d4f7a908bcaf686ff59a85e300efac4e5e6199df))
20
+
21
+
6
22
  # 3.0.0-alpha.224 (2026-07-22)
7
23
 
8
24
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.224",
3
+ "version": "3.0.0-alpha.226",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -11,14 +11,14 @@
11
11
  "@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.24",
12
12
  "@reventlessdev/rescript-effect": "0.1.0-alpha.30",
13
13
  "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
14
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.57",
15
14
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
16
- "@reventlessdev/rescript-uuid": "1.1.0-alpha.17",
15
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.59",
17
16
  "@reventlessdev/reventless-infra": "3.0.0-alpha.104",
18
- "@reventlessdev/reventless-core": "3.0.0-alpha.177",
17
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.43",
19
18
  "@reventlessdev/reventless-interop": "3.0.0-alpha.29",
19
+ "@reventlessdev/reventless-core": "3.0.0-alpha.179",
20
20
  "@reventlessdev/reventless-spec": "3.0.0-alpha.80",
21
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.41"
21
+ "@reventlessdev/rescript-uuid": "1.1.0-alpha.17"
22
22
  },
23
23
  "devDependencies": {
24
24
  "rescript": "12.3.0",
@@ -103,6 +103,42 @@ let makeDict = (
103
103
  ]->Dict.fromArray
104
104
  }
105
105
 
106
+ /***
107
+ Framework-created AWS resource types that carry NO attribution tags, and why.
108
+ Keep this list here rather than in a plan: it is what tells a coverage audit
109
+ whether a bare resource is a gap to close or a fact to work around.
110
+
111
+ **Cannot be tagged** — the provider type exposes no `tags` property at all
112
+ (verified against `@pulumi/aws` 7.19.0; re-verify on a major provider bump, since
113
+ AWS does add tag support over time — `lambda/eventSourceMapping` gained it after
114
+ the first sweep and is now tagged):
115
+
116
+ aws:appsync/dataSource aws:appsync/resolver (incl. aws-native)
117
+ aws:appsync/function aws:appsync/sourceApiAssociation
118
+ aws:iam/rolePolicy aws:iam/rolePolicyAttachment
119
+ aws:sqs/queuePolicy aws:sns/topicSubscription
120
+ aws:lambda/permission aws:lambda/invocation
121
+ aws:cloudwatch/logMetricFilter aws:cloudwatch/eventTarget
122
+ aws:s3/bucketPolicy aws:s3/bucketPublicAccessBlock
123
+ aws:cloudfront/originAccessControl
124
+ aws:cognito/userPoolClient aws:cognito/userGroup
125
+ aws:route53/record aws:acm/certificateValidation
126
+ aws:ses/emailIdentity aws:ses/identityPolicy
127
+
128
+ Each is attributable only through its parent (function, log group, API, role,
129
+ bucket, pool, certificate) — i.e. structurally, by URN ancestry. A consumer that
130
+ treats "carries `reventless:*` tags" as universal will be wrong about every row
131
+ above, and no amount of coverage work changes that. Note the ReScript binding for
132
+ `IAM.RolePolicy` carries a `tags?` field that AWS ignores; do not read its
133
+ presence as tag support.
134
+
135
+ **Taggable, deliberately untagged** — `aws:s3/bucketObject`. Static-bundle assets
136
+ are bucket *contents*, not infrastructure: they are fully attributed by the
137
+ bucket that holds them, and S3 bills object tags per object per month, so tagging
138
+ a few hundred bundle files would cost money to restate a fact the parent already
139
+ carries.
140
+ */
141
+
106
142
  /**
107
143
  The same schema as `makeDict`, wrapped as a Pulumi input — what nearly every
108
144
  resource's `tags` field expects. Use `makeDict` only for the bindings that take
@@ -211,4 +211,15 @@ let createLambdaPolicy = (
211
211
  }
212
212
 
213
213
  let subscribeLambda2SqsTopic = (~batchSize=?, lambda, name, queue, opts) =>
214
- Util_EventSourceMapping.subscribeSqs(~lambda, ~name, ~queue, ~batchSize?, ~opts)
214
+ Util_EventSourceMapping.subscribeSqs(
215
+ ~lambda,
216
+ ~name,
217
+ ~queue,
218
+ ~batchSize?,
219
+ ~tags=AWS.Tags.make(
220
+ ~name,
221
+ ~kind=ReventlessCore.CommandTopic.componentType,
222
+ ~role=EventSourceMapping,
223
+ ),
224
+ ~opts,
225
+ )
@@ -6,8 +6,10 @@ import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output
6
6
  import * as Pulumi from "@pulumi/pulumi";
7
7
  import * as Lambda$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Lambda/Lambda.res.mjs";
8
8
  import * as AWS$ReventlessAws from "../AWS.res.mjs";
9
+ import * as AWS_Tags$ReventlessAws from "../AWS_Tags.res.mjs";
9
10
  import * as Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Adapter.res.mjs";
10
11
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
12
+ import * as CommandTopic$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic.res.mjs";
11
13
  import * as Adapter_Helpers$ReventlessAws from "../Adapter_Helpers.res.mjs";
12
14
  import * as Util_EventSourceMapping$ReventlessAws from "../../util/Util_EventSourceMapping.res.mjs";
13
15
 
@@ -146,7 +148,7 @@ function createLambdaPolicy(lambdaRole, name, queue, resources, opts) {
146
148
  }
147
149
 
148
150
  function subscribeLambda2SqsTopic(batchSize, lambda, name, queue, opts) {
149
- return Util_EventSourceMapping$ReventlessAws.subscribeSqs(lambda, name, queue, batchSize, opts);
151
+ return Util_EventSourceMapping$ReventlessAws.subscribeSqs(lambda, name, queue, batchSize, AWS_Tags$ReventlessAws.make(name, CommandTopic$ReventlessCore.componentType, "EventSourceMapping", undefined, undefined, undefined, undefined, undefined), opts);
150
152
  }
151
153
 
152
154
  export {
@@ -77,6 +77,12 @@ let make: ReventlessCore.Counter_Adapter.handlerMaker = (
77
77
  ~targetName=name,
78
78
  ~sourceName,
79
79
  ~source,
80
+ ~tags=AWS.Tags.make(
81
+ ~name=sourceName->ReventlessCore.Util.baseName ++ ("2" ++ name),
82
+ ~kind=ReventlessCore.ComponentType.Counter,
83
+ ~role=EventSourceMapping,
84
+ ~component=name,
85
+ ),
80
86
  ~opts,
81
87
  )
82
88
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
4
  import * as Pulumi from "@pulumi/pulumi";
5
+ import * as Util$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util.res.mjs";
6
+ import * as AWS_Tags$ReventlessAws from "../AWS_Tags.res.mjs";
5
7
  import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
6
8
  import * as Util_DynamoDbStream$ReventlessAws from "../../util/Util_DynamoDbStream.res.mjs";
7
9
  import * as Util_EventSourceMapping$ReventlessAws from "../../util/Util_EventSourceMapping.res.mjs";
@@ -42,7 +44,7 @@ function make(name, referencesName, referencesDb, countsName, countsDb, param, s
42
44
  _0: "Counter"
43
45
  }, "Counter", match.code, match.sourceCodeHash, envVars, 1024, 30, undefined, undefined, undefined, undefined, undefined, componentOpts);
44
46
  let lambda = runtime.parts.lambda;
45
- let subscribe = (sourceName, source) => Util_EventSourceMapping$ReventlessAws.subscribe(undefined, lambda, name, sourceName, source, opts);
47
+ let subscribe = (sourceName, source) => Util_EventSourceMapping$ReventlessAws.subscribe(undefined, lambda, name, sourceName, source, AWS_Tags$ReventlessAws.make(Util$ReventlessCore.baseName(sourceName) + ("2" + name), "Counter", "EventSourceMapping", undefined, name, undefined, undefined, undefined), opts);
46
48
  subscribe(referencesName, referencesStream);
47
49
  subscribe(countsName, countsStream);
48
50
  return {
@@ -118,11 +118,19 @@ let toItem = (
118
118
  ->ReventlessCore.Message.decomposeMeta
119
119
  ->Array.forEach(((k, v)) => item->Dict.set(k, v))
120
120
 
121
- // Add individual tag attributes for GSI queries
122
- event.tags->Array.forEach(tag => {
123
- let attrName = tagToAttributeName(tag.key)
124
- item->Dict.set(attrName, tag.value->JSON.Encode.string)
125
- })
121
+ // Add individual tag attributes for GSI queries. An empty tag value is a
122
+ // legitimate model state (a composite-partition member that is absent at this
123
+ // level of the hierarchy), but `tag_<key>` is a GSI hash key and DynamoDB
124
+ // rejects an empty string for a key attribute — the whole append fails. Skip
125
+ // the attribute instead: the index goes sparse (its intended mechanism), the
126
+ // event still records the tag in its `tags` payload, and `tag_composite`, the
127
+ // partition key and the OCC fences are all unaffected.
128
+ event.tags->Array.forEach(tag =>
129
+ if tag.value != "" {
130
+ let attrName = tagToAttributeName(tag.key)
131
+ item->Dict.set(attrName, tag.value->JSON.Encode.string)
132
+ }
133
+ )
126
134
 
127
135
  // Add composite tag attribute if multiple tags. The tag set here is exactly the
128
136
  // event's *entity* tags (no framework provenance tag is smuggled in — see
@@ -370,7 +378,7 @@ let resolveBaseItem = (table: resolvedTable, keyItem: JSON.t): Effect.t<
370
378
  }
371
379
  }
372
380
 
373
- let queryBySingleTagCrossPartitionStream = (
381
+ let queryBySingleTagCrossPartitionStreamIndexed = (
374
382
  table: resolvedTable,
375
383
  tag: Reventless.DcbTag.tag,
376
384
  ~after: option<string>=?,
@@ -419,6 +427,21 @@ let queryBySingleTagCrossPartitionStream = (
419
427
  )
420
428
  }
421
429
 
430
+ let queryBySingleTagCrossPartitionStream = (
431
+ table: resolvedTable,
432
+ tag: Reventless.DcbTag.tag,
433
+ ~after: option<string>=?,
434
+ ) =>
435
+ if tag.value == "" {
436
+ // `toItem` does not index an empty tag value (a GSI hash key cannot hold an
437
+ // empty string), so nothing carries it in this index. Querying for it would
438
+ // be rejected outright too — a key condition value cannot be empty either.
439
+ // The empty result is the honest answer; an exception is not.
440
+ Stream.empty
441
+ } else {
442
+ queryBySingleTagCrossPartitionStreamIndexed(table, tag, ~after?)
443
+ }
444
+
422
445
  // --- Query Item Execution ---
423
446
 
424
447
  let executeQueryItem = async (
@@ -103,6 +103,9 @@ function toItem(position, event, partitionTag, recordedAt) {
103
103
  item[param[0]] = param[1];
104
104
  });
105
105
  event.tags.forEach(tag => {
106
+ if (tag.value === "") {
107
+ return;
108
+ }
106
109
  let attrName = tagToAttributeName(tag.key);
107
110
  item[attrName] = tag.value;
108
111
  });
@@ -281,7 +284,7 @@ function resolveBaseItem(table, keyItem) {
281
284
  }))), DynamoDb_Error$ReventlessAws.retrySchedule), err => Effect$1.fail(DynamoDb_Error$ReventlessAws.message(err))), result => result.Item);
282
285
  }
283
286
 
284
- function queryBySingleTagCrossPartitionStream(table, tag, after) {
287
+ function queryBySingleTagCrossPartitionStreamIndexed(table, tag, after) {
285
288
  let indexName = tagToAttributeName(tag.key);
286
289
  let expressionAttributeValues = Object.fromEntries([[
287
290
  ":val",
@@ -326,6 +329,14 @@ function queryBySingleTagCrossPartitionStream(table, tag, after) {
326
329
  });
327
330
  }
328
331
 
332
+ function queryBySingleTagCrossPartitionStream(table, tag, after) {
333
+ if (tag.value === "") {
334
+ return Stream$1.empty;
335
+ } else {
336
+ return queryBySingleTagCrossPartitionStreamIndexed(table, tag, after);
337
+ }
338
+ }
339
+
329
340
  async function executeQueryItem(table, queryItem, after, crossPartitionTagKeysOpt) {
330
341
  let crossPartitionTagKeys = crossPartitionTagKeysOpt !== undefined ? crossPartitionTagKeysOpt : [];
331
342
  let tags = queryItem.tags;
@@ -1164,6 +1175,7 @@ export {
1164
1175
  buildQueryByPartitionKeyInput,
1165
1176
  queryByPartitionKey,
1166
1177
  resolveBaseItem,
1178
+ queryBySingleTagCrossPartitionStreamIndexed,
1167
1179
  queryBySingleTagCrossPartitionStream,
1168
1180
  executeQueryItem,
1169
1181
  deduplicateByPosition,
@@ -98,6 +98,17 @@ let connectSqsQueue2SnsTopics = (queue: PulumiAws.SQS.Queue.t, name, eventTopics
98
98
  })
99
99
  }
100
100
 
101
+ // The EventCollector's transport wiring: one mapping per stream source plus one
102
+ // per buffer queue. `~component=name` keeps them attributed to the collector they
103
+ // feed, not to the mapping's own generated name.
104
+ let esmTags = (name, esmName) =>
105
+ AWS.Tags.make(
106
+ ~name=esmName,
107
+ ~kind=ReventlessCore.EventCollector.componentType,
108
+ ~role=EventSourceMapping,
109
+ ~component=name,
110
+ )
111
+
101
112
  let connectLambda = (
102
113
  lambda: Pulumi.Output.t<PulumiAws.Lambda.Function.t>,
103
114
  name: string,
@@ -254,6 +265,10 @@ let connectLambda = (
254
265
  ~targetName=name,
255
266
  ~sourceName=dynamoDbStreamResource.name,
256
267
  ~source=dynamoDbStreamResource->ReventlessCore.AdapterDeploytime.resolvedToResource,
268
+ ~tags=esmTags(
269
+ name,
270
+ dynamoDbStreamResource.name->ReventlessCore.Util.baseName ++ ("2" ++ name),
271
+ ),
257
272
  ~opts,
258
273
  )
259
274
  )
@@ -261,7 +276,13 @@ let connectLambda = (
261
276
 
262
277
  let subscriptionResources = queues->Array.mapWithIndex((queue, idx) => {
263
278
  let esmName = queues->Array.length > 1 ? `${name}Sqs${Int.toString(idx)}` : name
264
- Util_EventSourceMapping.subscribeSqs(~lambda, ~name=esmName, ~queue, ~opts)
279
+ Util_EventSourceMapping.subscribeSqs(
280
+ ~lambda,
281
+ ~name=esmName,
282
+ ~queue,
283
+ ~tags=esmTags(name, esmName),
284
+ ~opts,
285
+ )
265
286
  })
266
287
 
267
288
  subscriptionResources
@@ -5,11 +5,14 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
5
  import * as Pulumi from "@pulumi/pulumi";
6
6
  import * as Lambda$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Lambda/Lambda.res.mjs";
7
7
  import * as AWS$ReventlessAws from "../AWS.res.mjs";
8
+ import * as Util$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util.res.mjs";
8
9
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
10
+ import * as AWS_Tags$ReventlessAws from "../AWS_Tags.res.mjs";
9
11
  import * as Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Adapter.res.mjs";
10
12
  import * as Util_SQS$ReventlessAws from "../../util/Util_SQS.res.mjs";
11
13
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
12
14
  import * as Adapter_Helpers$ReventlessAws from "../Adapter_Helpers.res.mjs";
15
+ import * as EventCollector$ReventlessCore from "@reventlessdev/reventless-core/src/components/EventCollector/EventCollector.res.mjs";
13
16
  import * as AdapterDeploytime$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/AdapterDeploytime.res.mjs";
14
17
  import * as Util_EventSourceMapping$ReventlessAws from "../../util/Util_EventSourceMapping.res.mjs";
15
18
 
@@ -68,6 +71,10 @@ function connectSqsQueue2SnsTopics(queue, name, eventTopics, opts) {
68
71
  });
69
72
  }
70
73
 
74
+ function esmTags(name, esmName) {
75
+ return AWS_Tags$ReventlessAws.make(esmName, EventCollector$ReventlessCore.componentType, "EventSourceMapping", undefined, name, undefined, undefined, undefined);
76
+ }
77
+
71
78
  function connectLambda(lambda, name, lambdaRole, queues, eventTopics, resources, opts) {
72
79
  Pulumi.all([
73
80
  toResources(eventTopics),
@@ -141,11 +148,11 @@ function connectLambda(lambda, name, lambdaRole, queues, eventTopics, resources,
141
148
  ])),
142
149
  role: lambdaRole.id
143
150
  }, opts);
144
- dynamoDbStreamResources.map(dynamoDbStreamResource => Util_EventSourceMapping$ReventlessAws.subscribe(undefined, lambda, name, dynamoDbStreamResource.name, AdapterDeploytime$ReventlessCore.resolvedToResource(dynamoDbStreamResource), opts));
151
+ dynamoDbStreamResources.map(dynamoDbStreamResource => Util_EventSourceMapping$ReventlessAws.subscribe(undefined, lambda, name, dynamoDbStreamResource.name, AdapterDeploytime$ReventlessCore.resolvedToResource(dynamoDbStreamResource), esmTags(name, Util$ReventlessCore.baseName(dynamoDbStreamResource.name) + ("2" + name)), opts));
145
152
  });
146
153
  return queues.map((queue, idx) => {
147
154
  let esmName = queues.length > 1 ? name + `Sqs` + idx.toString() : name;
148
- return Util_EventSourceMapping$ReventlessAws.subscribeSqs(lambda, esmName, queue, undefined, opts);
155
+ return Util_EventSourceMapping$ReventlessAws.subscribeSqs(lambda, esmName, queue, undefined, esmTags(name, esmName), opts);
149
156
  });
150
157
  }
151
158
 
@@ -155,6 +162,7 @@ export {
155
162
  createQueuePolicy,
156
163
  subscribeQueue2SnsTopic,
157
164
  connectSqsQueue2SnsTopics,
165
+ esmTags,
158
166
  connectLambda,
159
167
  }
160
168
  /* log Not a pure module */
@@ -108,6 +108,12 @@ let make = (
108
108
  )
109
109
  ->Pulumi.Output.asInput,
110
110
  sqsManagedSseEnabled: false->Pulumi.Input.make,
111
+ tags: AWS.Tags.make(
112
+ ~name=name ++ "EventLogSubQueue",
113
+ ~kind=ReventlessCore.EventTopic.componentType,
114
+ ~role=EventLogSubscription,
115
+ ~component=name,
116
+ ),
111
117
  },
112
118
  ~opts,
113
119
  )
@@ -266,6 +272,12 @@ let make = (
266
272
  ~lambda=lambdaOutput,
267
273
  ~name=name ++ "EventLogSubEventSourceMapping",
268
274
  ~queue,
275
+ ~tags=AWS.Tags.make(
276
+ ~name=name ++ "EventLogSubEventSourceMapping",
277
+ ~kind=ReventlessCore.EventTopic.componentType,
278
+ ~role=EventSourceMapping,
279
+ ~component=name,
280
+ ),
269
281
  ~opts,
270
282
  )
271
283
  }
@@ -81,6 +81,7 @@ export async function handler(event) {
81
81
  function make(name, topicName, eventTopicOutputs, eventsApi, opts) {
82
82
  let queue = new (Aws.sqs.Queue)(name + "EventLogSubQueue", {
83
83
  redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, 5)),
84
+ tags: AWS_Tags$ReventlessAws.make(name + "EventLogSubQueue", EventTopic$ReventlessCore.componentType, "EventLogSubscription", undefined, name, undefined, undefined, undefined),
84
85
  visibilityTimeoutSeconds: 60,
85
86
  sqsManagedSseEnabled: false
86
87
  }, opts);
@@ -172,7 +173,7 @@ function make(name, topicName, eventTopicOutputs, eventsApi, opts) {
172
173
  sourceCodeHash: sourceCodeHash
173
174
  }, opts);
174
175
  let lambdaOutput = Pulumi.output(lambda);
175
- Util_EventSourceMapping$ReventlessAws.subscribeSqs(lambdaOutput, name + "EventLogSubEventSourceMapping", queue, undefined, opts);
176
+ Util_EventSourceMapping$ReventlessAws.subscribeSqs(lambdaOutput, name + "EventLogSubEventSourceMapping", queue, undefined, AWS_Tags$ReventlessAws.make(name + "EventLogSubEventSourceMapping", EventTopic$ReventlessCore.componentType, "EventSourceMapping", undefined, name, undefined, undefined, undefined), opts);
176
177
  }
177
178
 
178
179
  export {
@@ -51,6 +51,14 @@ let makeQueue = (
51
51
  )
52
52
  ->Pulumi.Output.asInput,
53
53
  sqsManagedSseEnabled: false->Pulumi.Input.make,
54
+ // Plugin substrate: one shared feed per plugin ("AllReadModelsFeed"), not a
55
+ // resource any single component owns.
56
+ tags: AWS.Tags.make(
57
+ ~name,
58
+ ~kind=ReventlessCore.ComponentType.Plugin,
59
+ ~role=EventCollector,
60
+ ~scope=Plugin,
61
+ ),
54
62
  },
55
63
  ~opts,
56
64
  )
@@ -69,7 +77,18 @@ let connect = (
69
77
  ~lambdaRole: PulumiAws.IAM.Role.t,
70
78
  ~opts: Pulumi.CustomResourceOptions.t,
71
79
  ) => {
72
- let _esm = Util_EventSourceMapping.subscribeSqs(~lambda, ~name, ~queue, ~opts)
80
+ let _esm = Util_EventSourceMapping.subscribeSqs(
81
+ ~lambda,
82
+ ~name,
83
+ ~queue,
84
+ ~tags=AWS.Tags.make(
85
+ ~name,
86
+ ~kind=ReventlessCore.ComponentType.Plugin,
87
+ ~role=EventSourceMapping,
88
+ ~scope=Plugin,
89
+ ),
90
+ ~opts,
91
+ )
73
92
  let _policy = queue.arn->Pulumi.Output.apply(queueArn => {
74
93
  open PulumiAws.PolicyDocument
75
94
  PulumiAws.IAM.RolePolicy.make(
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as Aws from "@pulumi/aws";
4
4
  import * as SQS_Queue$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/SQS/SQS_Queue.res.mjs";
5
+ import * as AWS_Tags$ReventlessAws from "../AWS_Tags.res.mjs";
5
6
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
6
7
  import * as Util_DeadLetterQueue$ReventlessAws from "../../util/Util_DeadLetterQueue.res.mjs";
7
8
  import * as Util_EventSourceMapping$ReventlessAws from "../../util/Util_EventSourceMapping.res.mjs";
@@ -15,6 +16,7 @@ function getFeedQueues() {
15
16
  function makeQueue(name, scope, includeClassic, includeDcb, opts) {
16
17
  let queue = new (Aws.sqs.Queue)(name, {
17
18
  redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, 5)),
19
+ tags: AWS_Tags$ReventlessAws.make(name, "Plugin", "EventCollector", "Plugin", undefined, undefined, undefined, undefined),
18
20
  visibilityTimeoutSeconds: 120,
19
21
  sqsManagedSseEnabled: false
20
22
  }, opts);
@@ -29,7 +31,7 @@ function makeQueue(name, scope, includeClassic, includeDcb, opts) {
29
31
  }
30
32
 
31
33
  function connect(name, queue, lambda, lambdaRole, opts) {
32
- Util_EventSourceMapping$ReventlessAws.subscribeSqs(lambda, name, queue, undefined, opts);
34
+ Util_EventSourceMapping$ReventlessAws.subscribeSqs(lambda, name, queue, undefined, AWS_Tags$ReventlessAws.make(name, "Plugin", "EventSourceMapping", "Plugin", undefined, undefined, undefined, undefined), opts);
33
35
  queue.arn.apply(queueArn => new (Aws.iam.RolePolicy)(name + `Receive`, {
34
36
  policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + `ReceivePolicy`, [{
35
37
  Sid: "AllowReceiveFeedQueue",
@@ -430,7 +430,11 @@ const importFromAsset = (() => {
430
430
  async function buildHandler() {
431
431
  const config = parseHandlerConfig(process.env["HANDLER_CONFIG"]);
432
432
  const pluginDefinition = loadPluginDefinition();
433
- const lambdaFunctionName = process.env["AWS_LAMBDA_FUNCTION_NAME"] || "unknown";
433
+ // Stack name, not the function name — see PluginExtensionPointEntryPoint for
434
+ // why the disconnect schedule's rule prefix must not carry a content hash.
435
+ // Both entry points instantiate the same admin Plugin EP module, so they have
436
+ // to derive this identically.
437
+ const environment = process.env["Environment"] || "unknown";
434
438
 
435
439
  // runtimeOps carries only what the admin EP mapping's callHandler actually
436
440
  // needs: sendMessageToChannel for ForwardCommand. Cross-plugin SNS
@@ -496,7 +500,7 @@ async function buildHandler() {
496
500
  // Admin's Plugin EP: invoke the Make functor with runtime ops.
497
501
  const epModule = mappingsMod.Make({
498
502
  runtimeOps,
499
- environment: lambdaFunctionName,
503
+ environment,
500
504
  // Connect-driven schema self-heal retired (Phase 4b) — the ApiSchemaPush
501
505
  // SideEffect on ApiFragmentRegistry events is the single schema writer.
502
506
  updateApiSchema: undefined,
@@ -37,10 +37,17 @@ function buildHandler() {
37
37
  // Instantiate Plugin EP mapping. updateApiSchema and manageSubscriptions are
38
38
  // admin-only hooks; this Lambda only handles incoming commands (Heartbeat,
39
39
  // ForwardCommand) so they stay undefined here.
40
- const lambdaFunctionName = process.env["AWS_LAMBDA_FUNCTION_NAME"] || "unknown";
40
+ //
41
+ // `environment` prefixes the disconnect schedule's EventBridge rule name, so it
42
+ // must be stable across deploys and unique per stack. The stack name is both;
43
+ // AWS_LAMBDA_FUNCTION_NAME is neither — it carries a content hash, so replacing
44
+ // this Lambda would orphan every outstanding rule the previous generation
45
+ // created. EventCollectorEntryPoint instantiates the same EP module and must
46
+ // agree, or one Lambda creates rules the other cannot delete.
47
+ const environment = process.env["Environment"] || "unknown";
41
48
  const pluginModule = pluginEPPluginMake({
42
49
  runtimeOps,
43
- environment: lambdaFunctionName,
50
+ environment,
44
51
  updateApiSchema: undefined,
45
52
  manageSubscriptions: undefined,
46
53
  });
@@ -27,15 +27,21 @@ let make: ReventlessCore.Runtime.environmentMaker<'event, context, 'result, part
27
27
  let opts =
28
28
  opts->Option.map(ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions)
29
29
 
30
+ // Legacy path (see above): no component kind reaches here because nothing calls
31
+ // it, so it is attributed to the Platform rather than to an invented domain kind.
32
+ let tags = AWS.Tags.make(~name, ~kind=ReventlessCore.ComponentType.Platform, ~role=Runtime, ~scope=Platform)
33
+
30
34
  let lambdaRole = IAM.Role.makeWithDefaultPolicy(
31
35
  ~name,
32
36
  ~servicePrincipal=AWS.Lambda.principal->Pulumi.Output.make,
37
+ ~tags=AWS.Tags.make(
38
+ ~name,
39
+ ~kind=ReventlessCore.ComponentType.Platform,
40
+ ~role=Identity,
41
+ ~scope=Platform,
42
+ ),
33
43
  ~opts?,
34
44
  )
35
-
36
- // Legacy path (see above): no component kind reaches here because nothing calls
37
- // it, so it is attributed to the Platform rather than to an invented domain kind.
38
- let tags = AWS.Tags.make(~name, ~kind=ReventlessCore.ComponentType.Platform, ~role=Runtime, ~scope=Platform)
39
45
  let lambda =
40
46
  handler->Pulumi.Output.apply(handler =>
41
47
  Lambda.CallbackFunction.make(
@@ -29,8 +29,8 @@ function make(name, handler, memorySizeOpt, timeoutOpt, opts) {
29
29
  let memorySize = memorySizeOpt !== undefined ? memorySizeOpt : 1024;
30
30
  let timeout = timeoutOpt !== undefined ? timeoutOpt : 30;
31
31
  let opts$1 = Stdlib_Option.map(opts, Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions);
32
- let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name, Pulumi.output(AWS$ReventlessAws.Lambda.principal), undefined, opts$1);
33
32
  let tags = AWS_Tags$ReventlessAws.make(name, "Platform", "Runtime", "Platform", undefined, undefined, undefined, undefined);
33
+ let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name, Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(name, "Platform", "Identity", "Platform", undefined, undefined, undefined, undefined), opts$1);
34
34
  let lambda = handler.apply(handler => new (Aws.lambda.CallbackFunction)(name, Lambda$PulumiAws.CallbackFunction.Args.make(handler, lambdaRole, undefined, undefined, undefined, memorySize, timeout, undefined, undefined, undefined, tags, undefined), opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined));
35
35
  return {
36
36
  parts: {
@@ -410,14 +410,21 @@ let finish = (
410
410
  // isolates the bad record across those retries instead of redelivering
411
411
  // the whole batch each time.
412
412
  entries->Array.forEach(entry => {
413
+ let esmName = entry.topicName ++ "Stream2" ++ name ++ "StateTopic"
413
414
  let _esm = EventSourceMapping.make(
414
- ~name=entry.topicName ++ "Stream2" ++ name ++ "StateTopic",
415
+ ~name=esmName,
415
416
  ~args={
416
417
  EventSourceMapping.functionName: lambda.arn->Pulumi.Output.asInput,
417
418
  eventSourceArn: entry.streamArn->Pulumi.Output.asInput,
418
419
  startingPosition: LATEST,
419
420
  maximumRetryAttempts: 3,
420
421
  bisectBatchOnFunctionError: true,
422
+ tags: AWS.Tags.make(
423
+ ~name=esmName,
424
+ ~kind=ReventlessCore.QueryDb.componentType,
425
+ ~role=EventSourceMapping,
426
+ ~component=name,
427
+ ),
421
428
  },
422
429
  ~opts=Some(opts),
423
430
  )
@@ -284,12 +284,14 @@ function finish(eventsApi, opts) {
284
284
  sourceCodeHash: sourceCodeHash
285
285
  }, opts);
286
286
  entries.forEach(entry => {
287
- new (Aws.lambda.EventSourceMapping)(entry.topicName + "Stream2" + name + "StateTopic", {
287
+ let esmName = entry.topicName + "Stream2" + name + "StateTopic";
288
+ new (Aws.lambda.EventSourceMapping)(esmName, {
288
289
  functionName: lambda.arn,
289
290
  bisectBatchOnFunctionError: true,
290
291
  eventSourceArn: entry.streamArn,
291
292
  maximumRetryAttempts: 3,
292
- startingPosition: "LATEST"
293
+ startingPosition: "LATEST",
294
+ tags: AWS_Tags$ReventlessAws.make(esmName, QueryDb$ReventlessCore.componentType, "EventSourceMapping", undefined, name, undefined, undefined, undefined)
293
295
  }, opts);
294
296
  });
295
297
  registry[key] = [];
@@ -209,6 +209,12 @@ export const handler = async (event) => {
209
209
  cpu: "1024"->Pulumi.Input.make,
210
210
  requiresCompatibilities: ["FARGATE"],
211
211
  networkMode: #awsvpc,
212
+ tags: AWS.Tags.make(
213
+ ~name,
214
+ ~kind=ReventlessCore.Cloner.componentType,
215
+ ~role=DataTransfer,
216
+ ~component=name,
217
+ ),
212
218
  },
213
219
  ~opts?,
214
220
  )
@@ -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) => {
@@ -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", [{
@@ -143,6 +153,7 @@ Pulumi.all([
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
  }
@@ -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,
@@ -38,3 +38,44 @@ describe("AppSync_Resolver_Retrying.Functions.listAllItemsConnection", () => {
38
38
  expect(code->String.includes("attribute_exists"))->toBe(false)
39
39
  })
40
40
  })
41
+
42
+ // Tripwires for docs/plans/aws-scan-connection-cursor-roundtrip.md. The Scan
43
+ // resolver's cursor must round-trip DynamoDB's own continuation token, not a
44
+ // synthetic index — every list past page 1 was unreachable before this.
45
+ describe("listAllItemsConnection — Scan cursor round-trip (paging fixes 1–3)", () => {
46
+ let code = AppSync_Resolver_Retrying.Functions.listAllItemsConnection(~labelField="name")->codeOf
47
+
48
+ testSync("Fix 1: response encodes the real nextToken as the cursor", () => {
49
+ expect(code->String.includes("const next = ctx.result?.nextToken ?? null;"))->toBe(true)
50
+ expect(
51
+ code->String.includes("util.base64Encode(JSON.stringify({ token: next, index: i }))"),
52
+ )->toBe(true)
53
+ expect(code->String.includes("hasNextPage: !!next,"))->toBe(true)
54
+ })
55
+
56
+ testSync("Fix 1: request decodes `after` back to the DynamoDB token", () => {
57
+ expect(code->String.includes("JSON.parse(util.base64Decode(ctx.args.after))"))->toBe(true)
58
+ expect(code->String.includes("nextToken: after,"))->toBe(true)
59
+ })
60
+
61
+ testSync("Fix 1: the old synthetic-index cursor is gone", () => {
62
+ // The regression: `cursor: ctx.args.after ? ctx.args.after + '_' + i : '' + i`.
63
+ expect(code->String.includes("ctx.args.after + '_' + i"))->toBe(false)
64
+ })
65
+
66
+ testSync("Fix 2: backward paging (last/before) is rejected, not silently mishandled", () => {
67
+ expect(code->String.includes("ctx.args.before != null || ctx.args.last != null"))->toBe(true)
68
+ expect(code->String.includes("UnsupportedPagination"))->toBe(true)
69
+ })
70
+
71
+ testSync("Fix 3: an empty/short filtered page still yields a resumable boundary cursor", () => {
72
+ expect(
73
+ code->String.includes(
74
+ "const boundary = next ? util.base64Encode(JSON.stringify({ token: next, index: -1 })) : null;",
75
+ ),
76
+ )->toBe(true)
77
+ expect(
78
+ code->String.includes("edges.length > 0 ? edges[edges.length - 1].cursor : boundary"),
79
+ )->toBe(true)
80
+ })
81
+ })
@@ -29,6 +29,30 @@ globalThis.describe("AppSync_Resolver_Retrying.Functions.listAllItemsConnection"
29
29
  });
30
30
  });
31
31
 
32
+ globalThis.describe("listAllItemsConnection — Scan cursor round-trip (paging fixes 1–3)", () => {
33
+ let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined);
34
+ globalThis.test("Fix 1: response encodes the real nextToken as the cursor", () => {
35
+ globalThis.expect(code.includes("const next = ctx.result?.nextToken ?? null;")).toBe(true);
36
+ globalThis.expect(code.includes("util.base64Encode(JSON.stringify({ token: next, index: i }))")).toBe(true);
37
+ globalThis.expect(code.includes("hasNextPage: !!next,")).toBe(true);
38
+ });
39
+ globalThis.test("Fix 1: request decodes `after` back to the DynamoDB token", () => {
40
+ globalThis.expect(code.includes("JSON.parse(util.base64Decode(ctx.args.after))")).toBe(true);
41
+ globalThis.expect(code.includes("nextToken: after,")).toBe(true);
42
+ });
43
+ globalThis.test("Fix 1: the old synthetic-index cursor is gone", () => {
44
+ globalThis.expect(code.includes("ctx.args.after + '_' + i")).toBe(false);
45
+ });
46
+ globalThis.test("Fix 2: backward paging (last/before) is rejected, not silently mishandled", () => {
47
+ globalThis.expect(code.includes("ctx.args.before != null || ctx.args.last != null")).toBe(true);
48
+ globalThis.expect(code.includes("UnsupportedPagination")).toBe(true);
49
+ });
50
+ globalThis.test("Fix 3: an empty/short filtered page still yields a resumable boundary cursor", () => {
51
+ globalThis.expect(code.includes("const boundary = next ? util.base64Encode(JSON.stringify({ token: next, index: -1 })) : null;")).toBe(true);
52
+ globalThis.expect(code.includes("edges.length > 0 ? edges[edges.length - 1].cursor : boundary")).toBe(true);
53
+ });
54
+ });
55
+
32
56
  export {
33
57
  codeOf,
34
58
  }
@@ -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;