@reventlessdev/reventless-aws 3.0.0-alpha.232 → 3.0.0-alpha.234

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 (110) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +7 -7
  3. package/src/adapter/Api/AppSyncEventsSigner_Ops.res +137 -0
  4. package/src/adapter/Api/AppSyncEventsSigner_Ops.res.mjs +121 -0
  5. package/src/adapter/Api/Platform_AdminScan_Ops.res +136 -0
  6. package/src/adapter/Api/Platform_AdminScan_Ops.res.mjs +98 -0
  7. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +12 -90
  8. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +11 -84
  9. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res +86 -0
  10. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res.mjs +79 -0
  11. package/src/adapter/Api/Platform_UIFragments_Lambda.res +9 -79
  12. package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +7 -143
  13. package/src/adapter/Api/Platform_UIFragments_Lambda_Ops.res +48 -0
  14. package/src/adapter/Api/Platform_UIFragments_Lambda_Ops.res.mjs +63 -0
  15. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res +21 -80
  16. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res.mjs +23 -68
  17. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync_Ops.res +70 -0
  18. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync_Ops.res.mjs +68 -0
  19. package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res +1 -1
  20. package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res.mjs +1 -1
  21. package/src/adapter/Postgres/PgMigration_Builder.res +1 -1
  22. package/src/adapter/Postgres/PgMigration_Builder.res.mjs +1 -1
  23. package/src/adapter/Runtime/AutomationSliceEntryPoint.mjs +42 -126
  24. package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res +246 -0
  25. package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res.mjs +145 -0
  26. package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res +27 -2
  27. package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res.mjs +10 -3
  28. package/src/adapter/Runtime/CounterEntryPoint.mjs +19 -73
  29. package/src/adapter/Runtime/CounterEntryPoint_Ops.res +145 -0
  30. package/src/adapter/Runtime/CounterEntryPoint_Ops.res.mjs +129 -0
  31. package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +14 -1
  32. package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res +22 -2
  33. package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res.mjs +15 -4
  34. package/src/adapter/Runtime/EventCollectorEntryPoint.mjs +52 -510
  35. package/src/adapter/Runtime/EventCollectorEntryPoint_Ops.res +816 -0
  36. package/src/adapter/Runtime/EventCollectorEntryPoint_Ops.res.mjs +651 -0
  37. package/src/adapter/Runtime/EventMapperEntryPoint.mjs +23 -67
  38. package/src/adapter/Runtime/EventMapperEntryPoint_Ops.res +126 -0
  39. package/src/adapter/Runtime/EventMapperEntryPoint_Ops.res.mjs +83 -0
  40. package/src/adapter/Runtime/ExtensionPointEntryPoint.mjs +16 -63
  41. package/src/adapter/Runtime/ExtensionPointEntryPoint_Ops.res +150 -0
  42. package/src/adapter/Runtime/ExtensionPointEntryPoint_Ops.res.mjs +111 -0
  43. package/src/adapter/Runtime/PgChangeFeedRelayEntryPoint.res +96 -0
  44. package/src/adapter/Runtime/PgChangeFeedRelayEntryPoint.res.mjs +70 -0
  45. package/src/adapter/Runtime/PgMigrationEntryPoint.res +54 -0
  46. package/src/adapter/Runtime/PgMigrationEntryPoint.res.mjs +37 -0
  47. package/src/adapter/Runtime/PgQueryResolverEntryPoint.mjs +20 -55
  48. package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res +155 -0
  49. package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res.mjs +94 -0
  50. package/src/adapter/Runtime/ProjectionEntryPoint_Ops.res +80 -0
  51. package/src/adapter/Runtime/ProjectionEntryPoint_Ops.res.mjs +39 -0
  52. package/src/adapter/Runtime/QueryDbEntryPoint_Ops.res +4 -4
  53. package/src/adapter/Runtime/ReadModelEntryPoint.mjs +34 -153
  54. package/src/adapter/Runtime/ReadModelEntryPoint_Ops.res +114 -0
  55. package/src/adapter/Runtime/ReadModelEntryPoint_Ops.res.mjs +83 -0
  56. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +26 -6
  57. package/src/adapter/Runtime/SideEffectEntryPoint.mjs +23 -64
  58. package/src/adapter/Runtime/SideEffectEntryPoint_Ops.res +71 -0
  59. package/src/adapter/Runtime/SideEffectEntryPoint_Ops.res.mjs +57 -0
  60. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +5 -2
  61. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +1 -1
  62. package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +34 -165
  63. package/src/adapter/Runtime/StateViewSliceEntryPoint_Ops.res +206 -0
  64. package/src/adapter/Runtime/StateViewSliceEntryPoint_Ops.res.mjs +105 -0
  65. package/src/adapter/Runtime/StreamRoutedEntryPoint_Ops.res +156 -0
  66. package/src/adapter/Runtime/StreamRoutedEntryPoint_Ops.res.mjs +123 -0
  67. package/src/adapter/Runtime/TaskBucketEntryPoint.mjs +18 -138
  68. package/src/adapter/Runtime/TaskBucketEntryPoint_Ops.res +198 -0
  69. package/src/adapter/Runtime/TaskBucketEntryPoint_Ops.res.mjs +155 -0
  70. package/src/adapter/StateTopic/StateTopic_AppSync.res +12 -170
  71. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +7 -167
  72. package/src/adapter/StateTopic/StateTopic_AppSync_Ops.res +208 -0
  73. package/src/adapter/StateTopic/StateTopic_AppSync_Ops.res.mjs +183 -0
  74. package/src/components/AutomationSlice_Builder.res +2 -0
  75. package/src/components/AutomationSlice_Builder.res.mjs +1 -1
  76. package/src/components/OutboundTranslationSlice_Builder.res +1 -0
  77. package/src/components/OutboundTranslationSlice_Builder.res.mjs +1 -1
  78. package/src/plugin/runtime/PluginRuntime_Builder.res +13 -4
  79. package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +1 -1
  80. package/tests/AppSyncEventsSigner_OpsTest.res +75 -0
  81. package/tests/AppSyncEventsSigner_OpsTest.res.mjs +44 -0
  82. package/tests/AppSyncEventsSigner_ParityFixture.mjs +36 -0
  83. package/tests/AutomationSliceEntryPoint_OpsTest.res +165 -0
  84. package/tests/AutomationSliceEntryPoint_OpsTest.res.mjs +280 -0
  85. package/tests/CounterEntryPoint_OpsTest.res +115 -0
  86. package/tests/CounterEntryPoint_OpsTest.res.mjs +150 -0
  87. package/tests/EventCollectorEntryPoint_OpsTest.res +216 -0
  88. package/tests/EventCollectorEntryPoint_OpsTest.res.mjs +339 -0
  89. package/tests/EventMapperEntryPoint_OpsTest.res +103 -0
  90. package/tests/EventMapperEntryPoint_OpsTest.res.mjs +120 -0
  91. package/tests/ExtensionPointEntryPoint_OpsTest.res +57 -0
  92. package/tests/ExtensionPointEntryPoint_OpsTest.res.mjs +54 -0
  93. package/tests/PgMigrationEntryPoint_IntegrationTest.res +22 -31
  94. package/tests/PgMigrationEntryPoint_IntegrationTest.res.mjs +20 -23
  95. package/tests/PgQueryResolverEntryPoint_OpsTest.res +38 -0
  96. package/tests/PgQueryResolverEntryPoint_OpsTest.res.mjs +31 -0
  97. package/tests/Platform_AdminScan_OpsTest.res +80 -0
  98. package/tests/Platform_AdminScan_OpsTest.res.mjs +127 -0
  99. package/tests/ReadModelEntryPoint_OpsTest.res +141 -0
  100. package/tests/ReadModelEntryPoint_OpsTest.res.mjs +267 -0
  101. package/tests/SideEffectEntryPoint_OpsTest.res +79 -0
  102. package/tests/SideEffectEntryPoint_OpsTest.res.mjs +105 -0
  103. package/tests/StateViewSliceEntryPoint_OpsTest.res +167 -0
  104. package/tests/StateViewSliceEntryPoint_OpsTest.res.mjs +296 -0
  105. package/tests/TaskBucketEntryPoint_OpsTest.res +117 -0
  106. package/tests/TaskBucketEntryPoint_OpsTest.res.mjs +165 -0
  107. package/tests/integration/StateViewSliceEntryPoint_IntegrationTest.res +42 -13
  108. package/tests/integration/StateViewSliceEntryPoint_IntegrationTest.res.mjs +29 -6
  109. package/src/adapter/Runtime/PgChangeFeedRelayEntryPoint.mjs +0 -57
  110. package/src/adapter/Runtime/PgMigrationEntryPoint.mjs +0 -40
@@ -0,0 +1,816 @@
1
+ // Typed cold-start core for the Plugin EventCollector Lambda entry point.
2
+ //
3
+ // The "typed core, thin shell" split (docs/plans/minimize-lambda-entrypoint-mjs-shell.md):
4
+ // EventCollectorEntryPoint.mjs owns ONLY the untyped seams — the dynamic
5
+ // `import()` of EP/extension/spec modules named in HANDLER_CONFIG and the
6
+ // functor applications consuming those runtime-loaded modules
7
+ // (ExtensionPoint_Operations.Make, Extension_Operations.Make, the mapping
8
+ // factories, patchSpecId). Everything else lives here, fully type-checked:
9
+ // HANDLER_CONFIG parsing/validation, the Plugin-RM row projection, the admin
10
+ // cross-plugin SNS subscription manager and its cold-start reconciliation, the
11
+ // publish/query/scheduler operations, the service-keyed handler-dict assembly,
12
+ // Plugin_Callback wiring, and the Lambda dispatch boundary.
13
+ //
14
+ // Runtime-pure: no `open PulumiAws` values — Pulumi appears in type positions
15
+ // only (erased). The two `%identity` casts below sit on documented JSON
16
+ // contracts (same pattern as AggregateEntryPoint_Ops.asConnectionConfig).
17
+
18
+ // ── Shim bindings (HandlerFactoryHelpers.mjs) ───────────────────────────────
19
+ // The structured-log + Effect dispatch boundary shared by every deployed entry
20
+ // point, and the DynamoDB scan backing the read-model query engine.
21
+
22
+ type dispatchOpts = {
23
+ correlationId?: string,
24
+ causationId?: string,
25
+ comp?: string,
26
+ timestamp?: float,
27
+ retryCount?: int,
28
+ detail?: string,
29
+ }
30
+
31
+ @module("./HandlerFactoryHelpers.mjs")
32
+ external setRequestId: string => unit = "setRequestId"
33
+ @module("./HandlerFactoryHelpers.mjs")
34
+ external runEffect: (Effect.t<'a, 'e, 'r>, dispatchOpts) => promise<unit> = "runEffect"
35
+ @module("./HandlerFactoryHelpers.mjs")
36
+ external extractMetaField: (
37
+ array<PulumiAws.Lambda.CallbackFunction.record>,
38
+ string,
39
+ ) => option<string> = "extractMetaField"
40
+ @module("./HandlerFactoryHelpers.mjs")
41
+ external extractSentTimestamp: array<PulumiAws.Lambda.CallbackFunction.record> => option<float> =
42
+ "extractSentTimestamp"
43
+ @module("./HandlerFactoryHelpers.mjs")
44
+ external extractRetryCount: array<PulumiAws.Lambda.CallbackFunction.record> => int =
45
+ "extractRetryCount"
46
+ @module("./HandlerFactoryHelpers.mjs") @scope("log")
47
+ external logDebug: (string, dispatchOpts) => unit = "debug"
48
+ @module("./HandlerFactoryHelpers.mjs") @scope("log")
49
+ external logInfo: (string, dispatchOpts) => unit = "info"
50
+ @module("./HandlerFactoryHelpers.mjs") @scope("log")
51
+ external logWarn: (string, dispatchOpts) => unit = "warn"
52
+ @module("./HandlerFactoryHelpers.mjs") @scope("log")
53
+ external logError: (string, dispatchOpts) => unit = "error"
54
+ @module("./HandlerFactoryHelpers.mjs")
55
+ external scanByTableName: (
56
+ string,
57
+ array<Reventless.QueryEngine.Filter.config>,
58
+ int,
59
+ ) => promise<array<JSON.t>> = "scanByTableName"
60
+
61
+ @val @scope("process") external processEnv: dict<string> = "env"
62
+
63
+ let exnMessage = (exn: exn): string =>
64
+ exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown error")
65
+
66
+ // ── js_nullable normalization ───────────────────────────────────────────────
67
+ // pluginDefinition option fields use @s.matches(js_nullable): when the record
68
+ // comes from a plain JSON.parse (not a sury parse), None is represented as
69
+ // `null` — which ReScript's option (undefined-based) would misread as Some.
70
+ // Normalize through Nullable so pattern matches see a real option.
71
+
72
+ external optionAsNullable: option<'a> => Nullable.t<'a> = "%identity"
73
+ let jsOption = (v: option<'a>): option<'a> => v->optionAsNullable->Nullable.toOption
74
+
75
+ // ── HANDLER_CONFIG ──────────────────────────────────────────────────────────
76
+ // Written by PluginRuntime_Builder.forPluginEventCollector; the field-by-field
77
+ // schema is documented in EventCollectorEntryPoint.mjs. Explicitly decoded (no
78
+ // blanket cast) so defaults land exactly where the former JS normalizer put
79
+ // them and a malformed deploy fails with the same named errors.
80
+
81
+ type extensionPointConfig = {
82
+ specModule: string,
83
+ mappingsModule: string,
84
+ eventTopicArn: string,
85
+ aggregateNames: array<string>,
86
+ }
87
+ type connectExtensionConfig = {
88
+ specModule: string,
89
+ mappingsModule: string,
90
+ extensionPointName: string,
91
+ }
92
+ type extensionConfig = {
93
+ name: string,
94
+ specModule: string,
95
+ mappingsModule: string,
96
+ delegateModule: string,
97
+ extensionPointName: string,
98
+ aggregateNames: array<string>,
99
+ readModelNames: array<string>,
100
+ }
101
+ type handlerConfig = {
102
+ queueUrl: string,
103
+ pluginExtensionPointCmdTopicUrl: string,
104
+ eventTopicArn: string,
105
+ pluginReadModelTableName: string,
106
+ appSyncApiId: string,
107
+ schedulerRoleArn: string,
108
+ schedulerQueueArn: string,
109
+ schedulerQueueName: string,
110
+ extensionPoints: array<extensionPointConfig>,
111
+ connectExtension: option<connectExtensionConfig>,
112
+ extensions: array<extensionConfig>,
113
+ publishToAggregates: dict<string>,
114
+ readModelQueueUrls: dict<string>,
115
+ readModelNamesForSourceName: dict<array<string>>,
116
+ }
117
+
118
+ let strOf = (obj: dict<JSON.t>, key: string): option<string> =>
119
+ obj->Dict.get(key)->Option.flatMap(JSON.Decode.string)
120
+ let strArrOf = (obj: dict<JSON.t>, key: string): array<string> =>
121
+ obj->Dict.get(key)->Option.flatMap(JSON.Decode.array)->Option.getOr([])->Array.filterMap(JSON.Decode.string)
122
+ let strDictOf = (obj: dict<JSON.t>, key: string): dict<string> =>
123
+ obj
124
+ ->Dict.get(key)
125
+ ->Option.flatMap(JSON.Decode.object)
126
+ ->Option.getOr(Dict.make())
127
+ ->Dict.toArray
128
+ ->Array.filterMap(((k, v)) => v->JSON.Decode.string->Option.map(s => (k, s)))
129
+ ->Dict.fromArray
130
+
131
+ let parseHandlerConfig = (rawJson: string): handlerConfig => {
132
+ if rawJson == "" {
133
+ JsError.throwWithMessage("HANDLER_CONFIG env var is empty")
134
+ }
135
+ let json = try rawJson->JSON.parseOrThrow catch {
136
+ | exn => JsError.throwWithMessage("HANDLER_CONFIG JSON parse error: " ++ exnMessage(exn))
137
+ }
138
+ let obj = switch json->JSON.Decode.object {
139
+ | Some(o) => o
140
+ | None => JsError.throwWithMessage("HANDLER_CONFIG is not a JSON object")
141
+ }
142
+ [
143
+ "queueUrl",
144
+ "pluginExtensionPointCmdTopicUrl",
145
+ "eventTopicArn",
146
+ "pluginReadModelTableName",
147
+ "appSyncApiId",
148
+ "schedulerRoleArn",
149
+ "schedulerQueueArn",
150
+ "schedulerQueueName",
151
+ "extensionPoints",
152
+ "extensions",
153
+ "publishToAggregates",
154
+ ]->Array.forEach(k =>
155
+ if obj->Dict.get(k)->Option.isNone {
156
+ JsError.throwWithMessage("HANDLER_CONFIG missing required field: " ++ k)
157
+ }
158
+ )
159
+ let extensionPoints = switch obj->Dict.get("extensionPoints")->Option.flatMap(JSON.Decode.array) {
160
+ | None => JsError.throwWithMessage("HANDLER_CONFIG.extensionPoints must be an array")
161
+ | Some(items) =>
162
+ items->Array.filterMap(item =>
163
+ item
164
+ ->JSON.Decode.object
165
+ ->Option.map(ep => {
166
+ specModule: ep->strOf("specModule")->Option.getOr(""),
167
+ mappingsModule: ep->strOf("mappingsModule")->Option.getOr(""),
168
+ eventTopicArn: ep->strOf("eventTopicArn")->Option.getOr(""),
169
+ aggregateNames: ep->strArrOf("aggregateNames"),
170
+ })
171
+ )
172
+ }
173
+ let extensions = switch obj->Dict.get("extensions")->Option.flatMap(JSON.Decode.array) {
174
+ | None => JsError.throwWithMessage("HANDLER_CONFIG.extensions must be an array")
175
+ | Some(items) =>
176
+ items->Array.filterMap(item =>
177
+ item
178
+ ->JSON.Decode.object
179
+ ->Option.map(ext => {
180
+ let extensionPointName = ext->strOf("extensionPointName")->Option.getOr("")
181
+ {
182
+ // Same fallback chain the former JS normalizer applied.
183
+ name: ext
184
+ ->strOf("name")
185
+ ->Option.getOr(extensionPointName == "" ? "unknown" : extensionPointName),
186
+ specModule: ext->strOf("specModule")->Option.getOr(""),
187
+ mappingsModule: ext->strOf("mappingsModule")->Option.getOr(""),
188
+ delegateModule: ext->strOf("delegateModule")->Option.getOr(""),
189
+ extensionPointName,
190
+ aggregateNames: ext->strArrOf("aggregateNames"),
191
+ readModelNames: ext->strArrOf("readModelNames"),
192
+ }
193
+ })
194
+ )
195
+ }
196
+ // connectExtension is null (admin) or absent in older configs — both → None.
197
+ let connectExtension =
198
+ obj
199
+ ->Dict.get("connectExtension")
200
+ ->Option.flatMap(JSON.Decode.object)
201
+ ->Option.map(ce => {
202
+ specModule: ce->strOf("specModule")->Option.getOr(""),
203
+ mappingsModule: ce->strOf("mappingsModule")->Option.getOr(""),
204
+ extensionPointName: ce->strOf("extensionPointName")->Option.getOr(""),
205
+ })
206
+ let readModelNamesForSourceName =
207
+ obj
208
+ ->Dict.get("readModelNamesForSourceName")
209
+ ->Option.flatMap(JSON.Decode.object)
210
+ ->Option.getOr(Dict.make())
211
+ ->Dict.toArray
212
+ ->Array.map(((k, v)) => (
213
+ k,
214
+ v->JSON.Decode.array->Option.getOr([])->Array.filterMap(JSON.Decode.string),
215
+ ))
216
+ ->Dict.fromArray
217
+ {
218
+ queueUrl: obj->strOf("queueUrl")->Option.getOr(""),
219
+ pluginExtensionPointCmdTopicUrl: obj->strOf("pluginExtensionPointCmdTopicUrl")->Option.getOr(""),
220
+ eventTopicArn: obj->strOf("eventTopicArn")->Option.getOr(""),
221
+ pluginReadModelTableName: obj->strOf("pluginReadModelTableName")->Option.getOr(""),
222
+ appSyncApiId: obj->strOf("appSyncApiId")->Option.getOr(""),
223
+ schedulerRoleArn: obj->strOf("schedulerRoleArn")->Option.getOr(""),
224
+ schedulerQueueArn: obj->strOf("schedulerQueueArn")->Option.getOr(""),
225
+ schedulerQueueName: obj->strOf("schedulerQueueName")->Option.getOr(""),
226
+ extensionPoints,
227
+ connectExtension,
228
+ extensions,
229
+ publishToAggregates: obj->strDictOf("publishToAggregates"),
230
+ readModelQueueUrls: obj->strDictOf("readModelQueueUrls"),
231
+ readModelNamesForSourceName,
232
+ }
233
+ }
234
+
235
+ // ── Plugin-RM row projection ────────────────────────────────────────────────
236
+ // The subset of the Plugin read-model state the subscription manager needs.
237
+ // Deliberately sidesteps sury: the state schema marks many fields
238
+ // `@s.matches(js_nullable …)`, but DDB drops undefined attributes on write, so
239
+ // the unmarshalled row arrives with those keys *missing* and sury's strict
240
+ // parser rejects them. Field types reuse the spec's definition records — the
241
+ // projection is a strict subset of Reventless.Plugin.pluginDefinition.
242
+
243
+ type pluginProjection = {
244
+ id: string,
245
+ status: string,
246
+ eventCollector: string,
247
+ extensions: array<Reventless.Plugin.extensionDefinition>,
248
+ extensionPoints: array<Reventless.Plugin.extensionPointDefinition>,
249
+ dcbEventLog: option<Reventless.Plugin.dcbEventLogDefinition>,
250
+ }
251
+
252
+ let projectPluginRow = (row: JSON.t): option<pluginProjection> =>
253
+ switch row->JSON.Decode.object {
254
+ | None => None
255
+ | Some(obj) =>
256
+ switch (obj->strOf("id"), obj->strOf("status")) {
257
+ | (Some(id), Some(status)) =>
258
+ let dcbEventLog =
259
+ obj
260
+ ->Dict.get("dcbEventLog")
261
+ ->Option.flatMap(JSON.Decode.object)
262
+ ->Option.flatMap(d =>
263
+ switch (d->strOf("name"), d->strOf("eventTopicArn")) {
264
+ | (Some(name), Some(eventTopicArn)) =>
265
+ Some(({name, eventTopicArn}: Reventless.Plugin.dcbEventLogDefinition))
266
+ | _ => None
267
+ }
268
+ )
269
+ let extensions =
270
+ obj
271
+ ->Dict.get("extensions")
272
+ ->Option.flatMap(JSON.Decode.array)
273
+ ->Option.getOr([])
274
+ ->Array.filterMap(e =>
275
+ e
276
+ ->JSON.Decode.object
277
+ ->Option.flatMap(eo =>
278
+ eo
279
+ ->strOf("extensionPointName")
280
+ ->Option.map((extensionPointName): Reventless.Plugin.extensionDefinition => {
281
+ name: eo->strOf("name")->Option.getOr(""),
282
+ extensionPointName,
283
+ dcbSources: eo->strArrOf("dcbSources"),
284
+ })
285
+ )
286
+ )
287
+ let extensionPoints =
288
+ obj
289
+ ->Dict.get("extensionPoints")
290
+ ->Option.flatMap(JSON.Decode.array)
291
+ ->Option.getOr([])
292
+ ->Array.filterMap(ep =>
293
+ ep
294
+ ->JSON.Decode.object
295
+ ->Option.flatMap(epo =>
296
+ switch (epo->strOf("name"), epo->strOf("eventTopic")) {
297
+ | (Some(name), Some(eventTopic)) =>
298
+ Some(
299
+ (
300
+ {
301
+ name,
302
+ commandTopic: epo->strOf("commandTopic")->Option.getOr(""),
303
+ eventTopic,
304
+ }: Reventless.Plugin.extensionPointDefinition
305
+ ),
306
+ )
307
+ | _ => None
308
+ }
309
+ )
310
+ )
311
+ Some({
312
+ id,
313
+ status,
314
+ eventCollector: obj->strOf("eventCollector")->Option.getOr(""),
315
+ extensions,
316
+ extensionPoints,
317
+ dcbEventLog,
318
+ })
319
+ | _ => None
320
+ }
321
+ }
322
+
323
+ // A pluginDefinition viewed as a projection (the manage logic reads only the
324
+ // shared subset; `status` is never read for the acting plugin). Array/option
325
+ // fields are js_nullable-normalized — the definition may come from a plain
326
+ // JSON.parse of pluginDefinition.json.
327
+ let projectionOfDefinition = (d: Reventless.Plugin.pluginDefinition): pluginProjection => {
328
+ id: d.id,
329
+ status: "",
330
+ eventCollector: d.eventCollector,
331
+ extensions: d.extensions,
332
+ extensionPoints: d.extensionPoints,
333
+ dcbEventLog: d.dcbEventLog->jsOption,
334
+ }
335
+
336
+ // ── Cross-plugin SNS subscription manager ───────────────────────────────────
337
+ // When DoConnectPlugin / DoDisconnectPlugin fires on the admin EP mapping, scan
338
+ // the Plugin RM for currently-Connected peers and wire (or tear down) SNS
339
+ // subscriptions between this plugin's EventCollector queue and peer plugins'
340
+ // EP/DCB event topics — in both directions. subscribeQueueToTopic is idempotent
341
+ // (lists existing subscriptions first); unsubscribeQueueFromTopic is
342
+ // best-effort. Failures are logged but do not throw — admin's RM projection
343
+ // still proceeds and cold-start reconciliation re-attempts missed work.
344
+
345
+ type action = [#connect | #disconnect]
346
+
347
+ let pluginNameOfId = (id: string): string => id->String.split("@")->Array.get(0)->Option.getOr("")
348
+
349
+ let trySubscribe = async (queueArn: string, topicArn: string, label: string) =>
350
+ if queueArn != "" && topicArn != "" {
351
+ try {
352
+ let _ = await AwsSdk.SNS_Helpers.subscribeQueueToTopic(queueArn, topicArn)
353
+ logInfo(`subscribed ${label}: ${topicArn} -> ${queueArn}`, {comp: "manageSubscriptions"})
354
+ } catch {
355
+ | exn =>
356
+ logError(
357
+ `subscribe failed ${label}: ${topicArn} -> ${queueArn}`,
358
+ {comp: "manageSubscriptions", detail: exnMessage(exn)},
359
+ )
360
+ }
361
+ }
362
+
363
+ let tryUnsubscribe = async (queueArn: string, topicArn: string, label: string) =>
364
+ if queueArn != "" && topicArn != "" {
365
+ try {
366
+ let _ = await AwsSdk.SNS_Helpers.unsubscribeQueueFromTopic(queueArn, topicArn)
367
+ logInfo(`unsubscribed ${label}: ${topicArn} -> ${queueArn}`, {comp: "manageSubscriptions"})
368
+ } catch {
369
+ | exn =>
370
+ logError(
371
+ `unsubscribe failed ${label}: ${topicArn} -> ${queueArn}`,
372
+ {comp: "manageSubscriptions", detail: exnMessage(exn)},
373
+ )
374
+ }
375
+ }
376
+
377
+ let connectedFilter: array<Reventless.QueryEngine.Filter.config> = [
378
+ ("status", Reventless.QueryEngine.Filter.Contains, Reventless.QueryEngine.String("Connected")),
379
+ ]
380
+
381
+ let scanConnectedPeers = async (tableName: string, excludeId: string): array<pluginProjection> => {
382
+ let rows = await scanByTableName(tableName, connectedFilter, 1000)
383
+ rows->Array.filterMap(projectPluginRow)->Array.filter(state => state.id != excludeId)
384
+ }
385
+
386
+ let makeManageSubscriptions = (tableName: string): option<
387
+ (pluginProjection, action) => promise<unit>,
388
+ > =>
389
+ tableName == "" || tableName == "NOT_AVAILABLE"
390
+ ? None
391
+ : Some(
392
+ async (pluginDef, action) => {
393
+ let isConnect = action == #connect
394
+ let op = isConnect ? trySubscribe : tryUnsubscribe
395
+ let peers = await scanConnectedPeers(tableName, pluginDef.id)
396
+ // Subscriptions are wired between SNS topics and SQS queues owned per
397
+ // PLUGIN NAME, not per version (EC queue, EP event topics, and DCB
398
+ // topic ARNs live in the plugin's stack, stable across upgrades).
399
+ // When a superseded version is Retired and its row transitions to
400
+ // Disconnected, this hook fires with #disconnect — but if a newer
401
+ // version of the same plugin is still Connected, the subscriptions
402
+ // are still in use; tearing them down would silently break
403
+ // cross-plugin event flow for the live version. Skip in that case.
404
+ let skip = if isConnect {
405
+ false
406
+ } else {
407
+ let myName = pluginNameOfId(pluginDef.id)
408
+ switch peers->Array.find(p => pluginNameOfId(p.id) == myName) {
409
+ | Some(sibling) =>
410
+ logInfo(
411
+ `disconnect skipped for ${pluginDef.id}: sibling version ${sibling.id} still Connected and shares the same EC queue / EP topics`,
412
+ {comp: "manageSubscriptions"},
413
+ )
414
+ true
415
+ | None => false
416
+ }
417
+ }
418
+ if !skip {
419
+ // This plugin's extensions: peer EP topic → this EC queue.
420
+ for i in 0 to pluginDef.extensions->Array.length - 1 {
421
+ let ext = pluginDef.extensions->Array.getUnsafe(i)
422
+ for j in 0 to peers->Array.length - 1 {
423
+ let peer = peers->Array.getUnsafe(j)
424
+ switch peer.extensionPoints->Array.find(ep => ep.name == ext.extensionPointName) {
425
+ | Some(peerEp) =>
426
+ await op(
427
+ pluginDef.eventCollector,
428
+ peerEp.eventTopic,
429
+ `${ext.extensionPointName} -> ${pluginDef.id}`,
430
+ )
431
+ | None => ()
432
+ }
433
+ }
434
+ }
435
+ // This plugin's EPs: this EP topic → peer EC queues.
436
+ for i in 0 to pluginDef.extensionPoints->Array.length - 1 {
437
+ let ep = pluginDef.extensionPoints->Array.getUnsafe(i)
438
+ for j in 0 to peers->Array.length - 1 {
439
+ let peer = peers->Array.getUnsafe(j)
440
+ switch peer.extensions->Array.find(e => e.extensionPointName == ep.name) {
441
+ | Some(_) => await op(peer.eventCollector, ep.eventTopic, `${ep.name} -> ${peer.id}`)
442
+ | None => ()
443
+ }
444
+ }
445
+ }
446
+ // DCB sources this plugin's extensions consume: owning peer's DCB
447
+ // EventTopic → this EC queue.
448
+ for i in 0 to pluginDef.extensions->Array.length - 1 {
449
+ let ext = pluginDef.extensions->Array.getUnsafe(i)
450
+ for j in 0 to ext.dcbSources->Array.length - 1 {
451
+ let dcbSourceName = ext.dcbSources->Array.getUnsafe(j)
452
+ switch peers->Array.find(p =>
453
+ switch p.dcbEventLog {
454
+ | Some(l) => l.name == dcbSourceName
455
+ | None => false
456
+ }
457
+ ) {
458
+ | Some(peer) =>
459
+ switch peer.dcbEventLog {
460
+ | Some(l) =>
461
+ await op(
462
+ pluginDef.eventCollector,
463
+ l.eventTopicArn,
464
+ `dcb ${dcbSourceName} -> ${pluginDef.id}`,
465
+ )
466
+ | None => ()
467
+ }
468
+ | None => ()
469
+ }
470
+ }
471
+ }
472
+ // If THIS plugin has a DCB EventLog: its topic → each consuming
473
+ // peer's EC queue.
474
+ switch pluginDef.dcbEventLog {
475
+ | Some(myDcb) =>
476
+ for j in 0 to peers->Array.length - 1 {
477
+ let peer = peers->Array.getUnsafe(j)
478
+ let consuming =
479
+ peer.extensions->Array.some(e => e.dcbSources->Array.includes(myDcb.name))
480
+ if consuming {
481
+ await op(
482
+ peer.eventCollector,
483
+ myDcb.eventTopicArn,
484
+ `dcb ${myDcb.name} -> ${peer.id}`,
485
+ )
486
+ }
487
+ }
488
+ | None => ()
489
+ }
490
+ }
491
+ },
492
+ )
493
+
494
+ // The definition-typed view the admin EP functor expects
495
+ // (PluginExtensionPoint_Plugin.Make's `manageSubscriptions` hook).
496
+ let manageForDefinition = (
497
+ manage: option<(pluginProjection, action) => promise<unit>>,
498
+ ): option<(Reventless.Plugin.pluginDefinition, action) => promise<unit>> =>
499
+ manage->Option.map(m => (d, a) => m(projectionOfDefinition(d), a))
500
+
501
+ // Cold-start reconciliation — scan the Plugin RM for every Connected plugin and
502
+ // rerun manageSubscriptions(p, #connect) for each. Idempotent at the SNS level;
503
+ // catches subscriptions lost out-of-band. Awaited by the shell inside the same
504
+ // invocation that initialised the Lambda (a floating promise would freeze with
505
+ // the runtime between invocations).
506
+ let reconcileSubscriptionsOnce = async (
507
+ tableName: string,
508
+ manage: option<(pluginProjection, action) => promise<unit>>,
509
+ ): unit =>
510
+ switch manage {
511
+ | Some(manage) if tableName != "" && tableName != "NOT_AVAILABLE" =>
512
+ try {
513
+ let rows = await scanByTableName(tableName, connectedFilter, 1000)
514
+ for i in 0 to rows->Array.length - 1 {
515
+ switch projectPluginRow(rows->Array.getUnsafe(i)) {
516
+ | Some(state) =>
517
+ try await manage(state, #connect) catch {
518
+ | exn =>
519
+ logWarn(
520
+ "manageSubscriptions failed",
521
+ {comp: "reconcileSubscriptions", detail: exnMessage(exn)},
522
+ )
523
+ }
524
+ | None => ()
525
+ }
526
+ }
527
+ } catch {
528
+ | exn => logWarn("scan failed", {comp: "reconcileSubscriptions", detail: exnMessage(exn)})
529
+ }
530
+ | _ => ()
531
+ }
532
+
533
+ // ── Operations the shell threads into the (untyped) functor applications ────
534
+
535
+ // Stack name, not the function name — the disconnect schedule's EventBridge
536
+ // rule prefix must be stable across deploys and unique per stack;
537
+ // AWS_LAMBDA_FUNCTION_NAME carries a content hash. PluginExtensionPointEntryPoint
538
+ // instantiates the same admin EP module and must derive this identically.
539
+ let environment = processEnv->Dict.get("Environment")->Option.getOr("unknown")
540
+
541
+ // Only what the admin EP mapping's callHandler needs: sendMessageToChannel for
542
+ // ForwardCommand.
543
+ let runtimeOps: ReventlessCore.PluginRuntimeOperations.operations = {
544
+ messagePublish: {sendMessageToChannel: Util_PluginMessage_Runtime.sendMessage},
545
+ }
546
+
547
+ let invalidNameChars = %re("/[^.\-_a-zA-Z0-9]/g")
548
+ let resourceNaming: ReventlessInfra.ResourceNaming.operations = {
549
+ validateName: n => n->String.replaceRegExp(invalidNameChars, "_"),
550
+ urnName: arn =>
551
+ arn->String.split(":")->Array.get(5)->Option.filter(s => s != "")->Option.getOr("unknown"),
552
+ }
553
+
554
+ let makeQueryEngine = (pluginReadModelTableName: string): Reventless.QueryEngine.operations => {
555
+ scan: (~readModelName as _, ~filterConfigs, ~limit) =>
556
+ scanByTableName(pluginReadModelTableName, filterConfigs, limit),
557
+ query: async (
558
+ ~readModelName as _,
559
+ ~key as _=?,
560
+ ~id as _,
561
+ ~subIdConfig as _=?,
562
+ ~filterConfigs as _=?,
563
+ ~ascending as _=?,
564
+ ~limit as _=?,
565
+ ) => JsError.throwWithMessage("QueryEngine.query not available in bundled Plugin EventCollector"),
566
+ }
567
+
568
+ let makeScheduler = (schedulerRoleArn: string): ReventlessCore.Scheduler.operations => {
569
+ createSchedule: ScheduledPublisher_CloudWatchEvents_Runtime.createSchedule(
570
+ ~roleArn=schedulerRoleArn,
571
+ ),
572
+ deleteSchedule: ScheduledPublisher_CloudWatchEvents_Runtime.deleteSchedule,
573
+ }
574
+
575
+ let makeCommandTopicResources = (config: handlerConfig): array<
576
+ ReventlessInfra.Adapter.resolvedResource,
577
+ > =>
578
+ config.schedulerQueueArn == ""
579
+ ? []
580
+ : [
581
+ {
582
+ name: config.schedulerQueueName,
583
+ id: config.schedulerQueueName,
584
+ urn: config.schedulerQueueArn,
585
+ service: "unknown",
586
+ resourceInfo: ReventlessInfra.Adapter.NoInfo,
587
+ role: "",
588
+ region: "",
589
+ resourceType: "",
590
+ configuration: Dict.make(),
591
+ tags: Dict.make(),
592
+ },
593
+ ]
594
+
595
+ let makePublishToEventTopic = (eventTopicArn: string): ReventlessCore.EventTopic.publishJson => {
596
+ let topic: Util_SNS_Runtime.resolvedTopic = {
597
+ name: eventTopicArn,
598
+ id: eventTopicArn,
599
+ arn: eventTopicArn,
600
+ }
601
+ (id, meta, json) => EventTopicPublisher_SNS_Runtime.publish(topic, id, meta, json)
602
+ }
603
+
604
+ let makePublishJsons = (queueUrl: string): ReventlessCore.CommandTopic.publishJsons => {
605
+ let queue: Util_SQS_Runtime.resolvedQueue = {id: queueUrl, name: queueUrl, arn: ""}
606
+ queue->CommandTopicChannel_SQS_Runtime.publishJsons(AWS.SQS_FIFO)
607
+ }
608
+
609
+ // aggregateName → publishJsons, resolving each queue URL via the env-var name
610
+ // carried in HANDLER_CONFIG.publishToAggregates.
611
+ let buildPublishToAggregates = (map: dict<string>): dict<ReventlessCore.CommandTopic.publishJsons> =>
612
+ map
613
+ ->Dict.toArray
614
+ ->Array.map(((aggName, envVarName)) => (
615
+ aggName,
616
+ makePublishJsons(processEnv->Dict.get(envVarName)->Option.getOr("")),
617
+ ))
618
+ ->Dict.fromArray
619
+
620
+ // Per-extension publish dicts, filtered to the extension's declared aggregate /
621
+ // read-model names; entries with no env-var mapping or an empty URL are skipped
622
+ // (same as the former JS `continue`s).
623
+ type extensionDicts = {
624
+ publishToAggregates: dict<ReventlessCore.CommandTopic.publishJsons>,
625
+ publishToReadModels: dict<ReventlessCore.CommandTopic.publishJsons>,
626
+ readModelNamesForSourceName: dict<array<string>>,
627
+ }
628
+
629
+ let extensionPublishDicts = (config: handlerConfig, ext: extensionConfig): extensionDicts => {
630
+ let resolve = (names: array<string>, envVarNames: dict<string>) =>
631
+ names
632
+ ->Array.filterMap(name =>
633
+ envVarNames
634
+ ->Dict.get(name)
635
+ ->Option.flatMap(envVarName => processEnv->Dict.get(envVarName))
636
+ ->Option.filter(queueUrl => queueUrl != "")
637
+ ->Option.map(queueUrl => (name, makePublishJsons(queueUrl)))
638
+ )
639
+ ->Dict.fromArray
640
+ {
641
+ publishToAggregates: resolve(ext.aggregateNames, config.publishToAggregates),
642
+ publishToReadModels: resolve(ext.readModelNames, config.readModelQueueUrls),
643
+ // Scoped to this extension's EP — Extension_Operations uses this dict to
644
+ // decide which RMs receive each incoming event.
645
+ readModelNamesForSourceName: Dict.fromArray([
646
+ (
647
+ ext.extensionPointName,
648
+ config.readModelNamesForSourceName->Dict.get(ext.extensionPointName)->Option.getOr([]),
649
+ ),
650
+ ]),
651
+ }
652
+ }
653
+
654
+ let warnSkippedExtension = (ext: extensionConfig) =>
655
+ logWarn(
656
+ `extension '${ext.name}' missing module specifier(s); skipping. specModule=${ext.specModule}, mappingsModule=${ext.mappingsModule}, delegateModule=${ext.delegateModule}`,
657
+ {comp: "EventCollectorEntryPoint"},
658
+ )
659
+
660
+ // ── Asset files (bundled alongside index.mjs in the code archive) ───────────
661
+ // pluginDefinition is shipped as a file rather than packed into HANDLER_CONFIG
662
+ // (UpdateFunctionConfiguration has a 5120-byte limit; pluginStructure alone blew
663
+ // past it). process.cwd() is /var/task in Lambda, so a relative URL resolves
664
+ // against the extracted zip.
665
+
666
+ type url
667
+ @new external makeUrl: (string, string) => url = "URL"
668
+ @module("node:fs") external readFileSyncUrl: (url, string) => string = "readFileSync"
669
+ @val @scope("process") external cwd: unit => string = "cwd"
670
+
671
+ // pluginDefinition.json is written at deploy time as a stable JSON literal
672
+ // matching Reventless.Plugin.pluginDefinitionSchema (PluginRuntime_Builder), so
673
+ // the parsed object IS the record shape — cast at the documented contract (the
674
+ // AggregateEntryPoint_Ops.asConnectionConfig pattern). js_nullable option
675
+ // fields may hold null; consumers normalize via jsOption.
676
+ external asPluginDefinition: JSON.t => Reventless.Plugin.pluginDefinition = "%identity"
677
+
678
+ let loadPluginDefinition = (): Reventless.Plugin.pluginDefinition =>
679
+ try {
680
+ readFileSyncUrl(makeUrl("./pluginDefinition.json", `file://${cwd()}/`), "utf-8")
681
+ ->JSON.parseOrThrow
682
+ ->asPluginDefinition
683
+ } catch {
684
+ | exn =>
685
+ JsError.throwWithMessage(
686
+ "Failed to load pluginDefinition.json from asset zip: " ++ exnMessage(exn),
687
+ )
688
+ }
689
+
690
+ // The plugin's UI-fragment manifest — its own asset since the manifest no
691
+ // longer rides pluginDefinition (the UiFragmentRegistry slice owns fragment
692
+ // state). JSON null (plugins without a UI) and a missing file (older archives)
693
+ // both map to None.
694
+ let loadUiFragments = (): option<JSON.t> =>
695
+ try {
696
+ switch readFileSyncUrl(makeUrl("./uiFragments.json", `file://${cwd()}/`), "utf-8")->JSON.parseOrThrow {
697
+ | JSON.Null => None
698
+ | json => Some(json)
699
+ }
700
+ } catch {
701
+ | _ => None
702
+ }
703
+
704
+ // ── Handler-dict assembly + Plugin_Callback wiring ──────────────────────────
705
+ // The shell hands back the handlers its functor applications produced; their
706
+ // types are concrete (Extension/ExtensionPoint_Operations outputs), so from
707
+ // here on everything is typed again.
708
+
709
+ type epHandler = {
710
+ aggregateNames: array<string>,
711
+ outgoingHandler: ReventlessCore.Plugin_Callback.jsonEventsHandler,
712
+ }
713
+ type connectHandler = {
714
+ extensionPointName: string,
715
+ incomingHandler: ReventlessCore.Plugin_Callback.jsonEventsHandler,
716
+ }
717
+ type extensionHandler = {
718
+ extensionPointName: string,
719
+ aggregateNames: array<string>,
720
+ incomingHandler: ReventlessCore.Plugin_Callback.jsonEventsHandler,
721
+ outgoingHandler: ReventlessCore.Plugin_Callback.jsonEventsHandler,
722
+ }
723
+
724
+ type bundleInput = {
725
+ pluginDefinition: Reventless.Plugin.pluginDefinition,
726
+ queueUrl: string,
727
+ epHandlers: array<epHandler>,
728
+ connectExtensionHandler?: connectHandler,
729
+ extensionHandlers: array<extensionHandler>,
730
+ }
731
+ type bundle = {
732
+ sqsHandler: (
733
+ PulumiAws.Lambda.CallbackFunction.event,
734
+ PulumiAws.Lambda.context,
735
+ ) => Effect.t<unit, string, unit>,
736
+ comp: string,
737
+ }
738
+
739
+ let pushHandler = (
740
+ d: ReventlessCore.Plugin_Callback.jsonEventsHandlersByService,
741
+ key: string,
742
+ h: ReventlessCore.Plugin_Callback.jsonEventsHandler,
743
+ ) =>
744
+ switch d->Dict.get(key) {
745
+ | Some(arr) => arr->Array.push(h)
746
+ | None => d->Dict.set(key, [h])
747
+ }
748
+
749
+ let makeSqsHandlerBundle = (input: bundleInput): bundle => {
750
+ let outgoingExtensionPointEventHandlers: ReventlessCore.Plugin_Callback.jsonEventsHandlersByService = Dict.make()
751
+ input.epHandlers->Array.forEach(eph =>
752
+ eph.aggregateNames->Array.forEach(k =>
753
+ pushHandler(outgoingExtensionPointEventHandlers, k, eph.outgoingHandler)
754
+ )
755
+ )
756
+ let incomingConnectExtensionEventHandlers: ReventlessCore.Plugin_Callback.jsonEventsHandlersByService = switch input.connectExtensionHandler {
757
+ | Some(c) => Dict.fromArray([(c.extensionPointName, [c.incomingHandler])])
758
+ | None => Dict.make()
759
+ }
760
+ let outgoingExtensionEventHandlers: ReventlessCore.Plugin_Callback.jsonEventsHandlersByService = Dict.make()
761
+ let incomingExtensionEventHandlers: ReventlessCore.Plugin_Callback.jsonEventsHandlersByService = Dict.make()
762
+ input.extensionHandlers->Array.forEach(eh => {
763
+ eh.aggregateNames->Array.forEach(k =>
764
+ pushHandler(outgoingExtensionEventHandlers, k, eh.outgoingHandler)
765
+ )
766
+ pushHandler(incomingExtensionEventHandlers, eh.extensionPointName, eh.incomingHandler)
767
+ })
768
+
769
+ module Callback = ReventlessCore.Plugin_Callback.Make({
770
+ let pluginDefinition = input.pluginDefinition
771
+ let incomingConnectExtensionEventHandlers = incomingConnectExtensionEventHandlers
772
+ let outgoingExtensionPointEventHandlers = outgoingExtensionPointEventHandlers
773
+ let outgoingExtensionEventHandlers = outgoingExtensionEventHandlers
774
+ let incomingExtensionEventHandlers = incomingExtensionEventHandlers
775
+ })
776
+
777
+ let queue: Util_SQS_Runtime.resolvedQueue = {
778
+ id: input.queueUrl,
779
+ name: input.queueUrl,
780
+ arn: "",
781
+ }
782
+ {
783
+ sqsHandler: EventCollectorChannel_SQS_Runtime.handleDynamoDbOrSqsEvent(
784
+ queue,
785
+ Callback.handleJsonEvents,
786
+ ),
787
+ // Backs both the admin and every per-plugin EventCollector Lambda, so the
788
+ // comp names the plugin whose events it drains — matching the
789
+ // `Plugin(<id>)` shape Plugin_Callback logs under.
790
+ comp: `Plugin(${input.pluginDefinition.id})`,
791
+ }
792
+ }
793
+
794
+ // ── Exported Lambda handler (dispatch boundary) ─────────────────────────────
795
+
796
+ let makeHandler = (bundlePromise: promise<bundle>) =>
797
+ async (event: PulumiAws.Lambda.CallbackFunction.event, context: PulumiAws.Lambda.context) => {
798
+ setRequestId(context.awsRequestId)
799
+ let records = event.records
800
+ logDebug(
801
+ `processing ${records->Array.length->Int.toString} record(s)`,
802
+ {comp: "PluginEventCollectorRuntime"},
803
+ )
804
+ let bundle = await bundlePromise
805
+ await runEffect(
806
+ bundle.sqsHandler(event, context),
807
+ {
808
+ correlationId: ?extractMetaField(records, "correlationId"),
809
+ causationId: ?extractMetaField(records, "causationId"),
810
+ comp: bundle.comp,
811
+ timestamp: ?extractSentTimestamp(records),
812
+ retryCount: extractRetryCount(records),
813
+ },
814
+ )
815
+ ""
816
+ }