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

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 (104) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +8 -8
  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_Ops.res +11 -1
  32. package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res.mjs +9 -3
  33. package/src/adapter/Runtime/EventCollectorEntryPoint.mjs +52 -510
  34. package/src/adapter/Runtime/EventCollectorEntryPoint_Ops.res +816 -0
  35. package/src/adapter/Runtime/EventCollectorEntryPoint_Ops.res.mjs +651 -0
  36. package/src/adapter/Runtime/EventMapperEntryPoint.mjs +23 -67
  37. package/src/adapter/Runtime/EventMapperEntryPoint_Ops.res +126 -0
  38. package/src/adapter/Runtime/EventMapperEntryPoint_Ops.res.mjs +83 -0
  39. package/src/adapter/Runtime/ExtensionPointEntryPoint.mjs +16 -63
  40. package/src/adapter/Runtime/ExtensionPointEntryPoint_Ops.res +150 -0
  41. package/src/adapter/Runtime/ExtensionPointEntryPoint_Ops.res.mjs +111 -0
  42. package/src/adapter/Runtime/PgChangeFeedRelayEntryPoint.res +96 -0
  43. package/src/adapter/Runtime/PgChangeFeedRelayEntryPoint.res.mjs +70 -0
  44. package/src/adapter/Runtime/PgMigrationEntryPoint.res +54 -0
  45. package/src/adapter/Runtime/PgMigrationEntryPoint.res.mjs +37 -0
  46. package/src/adapter/Runtime/PgQueryResolverEntryPoint.mjs +20 -55
  47. package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res +155 -0
  48. package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res.mjs +94 -0
  49. package/src/adapter/Runtime/ProjectionEntryPoint_Ops.res +80 -0
  50. package/src/adapter/Runtime/ProjectionEntryPoint_Ops.res.mjs +39 -0
  51. package/src/adapter/Runtime/QueryDbEntryPoint_Ops.res +4 -4
  52. package/src/adapter/Runtime/ReadModelEntryPoint.mjs +34 -153
  53. package/src/adapter/Runtime/ReadModelEntryPoint_Ops.res +114 -0
  54. package/src/adapter/Runtime/ReadModelEntryPoint_Ops.res.mjs +83 -0
  55. package/src/adapter/Runtime/SideEffectEntryPoint.mjs +23 -64
  56. package/src/adapter/Runtime/SideEffectEntryPoint_Ops.res +71 -0
  57. package/src/adapter/Runtime/SideEffectEntryPoint_Ops.res.mjs +57 -0
  58. package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +34 -165
  59. package/src/adapter/Runtime/StateViewSliceEntryPoint_Ops.res +206 -0
  60. package/src/adapter/Runtime/StateViewSliceEntryPoint_Ops.res.mjs +105 -0
  61. package/src/adapter/Runtime/StreamRoutedEntryPoint_Ops.res +156 -0
  62. package/src/adapter/Runtime/StreamRoutedEntryPoint_Ops.res.mjs +123 -0
  63. package/src/adapter/Runtime/TaskBucketEntryPoint.mjs +18 -138
  64. package/src/adapter/Runtime/TaskBucketEntryPoint_Ops.res +198 -0
  65. package/src/adapter/Runtime/TaskBucketEntryPoint_Ops.res.mjs +155 -0
  66. package/src/adapter/StateTopic/StateTopic_AppSync.res +12 -170
  67. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +7 -167
  68. package/src/adapter/StateTopic/StateTopic_AppSync_Ops.res +208 -0
  69. package/src/adapter/StateTopic/StateTopic_AppSync_Ops.res.mjs +183 -0
  70. package/src/components/AutomationSlice_Builder.res +2 -0
  71. package/src/components/AutomationSlice_Builder.res.mjs +1 -1
  72. package/src/components/OutboundTranslationSlice_Builder.res +1 -0
  73. package/src/components/OutboundTranslationSlice_Builder.res.mjs +1 -1
  74. package/tests/AppSyncEventsSigner_OpsTest.res +75 -0
  75. package/tests/AppSyncEventsSigner_OpsTest.res.mjs +44 -0
  76. package/tests/AppSyncEventsSigner_ParityFixture.mjs +36 -0
  77. package/tests/AutomationSliceEntryPoint_OpsTest.res +165 -0
  78. package/tests/AutomationSliceEntryPoint_OpsTest.res.mjs +280 -0
  79. package/tests/CounterEntryPoint_OpsTest.res +115 -0
  80. package/tests/CounterEntryPoint_OpsTest.res.mjs +150 -0
  81. package/tests/EventCollectorEntryPoint_OpsTest.res +216 -0
  82. package/tests/EventCollectorEntryPoint_OpsTest.res.mjs +339 -0
  83. package/tests/EventMapperEntryPoint_OpsTest.res +103 -0
  84. package/tests/EventMapperEntryPoint_OpsTest.res.mjs +120 -0
  85. package/tests/ExtensionPointEntryPoint_OpsTest.res +57 -0
  86. package/tests/ExtensionPointEntryPoint_OpsTest.res.mjs +54 -0
  87. package/tests/PgMigrationEntryPoint_IntegrationTest.res +22 -31
  88. package/tests/PgMigrationEntryPoint_IntegrationTest.res.mjs +20 -23
  89. package/tests/PgQueryResolverEntryPoint_OpsTest.res +38 -0
  90. package/tests/PgQueryResolverEntryPoint_OpsTest.res.mjs +31 -0
  91. package/tests/Platform_AdminScan_OpsTest.res +80 -0
  92. package/tests/Platform_AdminScan_OpsTest.res.mjs +127 -0
  93. package/tests/ReadModelEntryPoint_OpsTest.res +141 -0
  94. package/tests/ReadModelEntryPoint_OpsTest.res.mjs +267 -0
  95. package/tests/SideEffectEntryPoint_OpsTest.res +79 -0
  96. package/tests/SideEffectEntryPoint_OpsTest.res.mjs +105 -0
  97. package/tests/StateViewSliceEntryPoint_OpsTest.res +167 -0
  98. package/tests/StateViewSliceEntryPoint_OpsTest.res.mjs +296 -0
  99. package/tests/TaskBucketEntryPoint_OpsTest.res +117 -0
  100. package/tests/TaskBucketEntryPoint_OpsTest.res.mjs +165 -0
  101. package/tests/integration/StateViewSliceEntryPoint_IntegrationTest.res +42 -13
  102. package/tests/integration/StateViewSliceEntryPoint_IntegrationTest.res.mjs +29 -6
  103. package/src/adapter/Runtime/PgChangeFeedRelayEntryPoint.mjs +0 -57
  104. package/src/adapter/Runtime/PgMigrationEntryPoint.mjs +0 -40
@@ -2,6 +2,15 @@
2
2
  // per-plugin EventCollector Lambda. This module is plugin-agnostic: behaviour is
3
3
  // driven entirely by HANDLER_CONFIG.
4
4
  //
5
+ // Thin untyped shell ("typed core, thin shell" —
6
+ // docs/plans/minimize-lambda-entrypoint-mjs-shell.md): this file owns ONLY the
7
+ // seams that are inherently untyped — the dynamic `import()` of EP/extension/spec
8
+ // modules named in HANDLER_CONFIG and the functor applications consuming those
9
+ // runtime-loaded modules. Everything else (config parse/validation, the Plugin-RM
10
+ // projection, the cross-plugin SNS subscription manager, publish/query/scheduler
11
+ // operations, handler-dict assembly, Plugin_Callback wiring, dispatch boundary)
12
+ // lives type-checked in EventCollectorEntryPoint_Ops.res.
13
+ //
5
14
  // HANDLER_CONFIG schema (JSON in env var "HANDLER_CONFIG"):
6
15
  //
7
16
  // {
@@ -9,16 +18,10 @@
9
18
  // "pluginExtensionPointCmdTopicUrl":string // SQS URL of PlatformPluginExtPoint cmd topic (admin's Plugin EP)
10
19
  // "eventTopicArn": string // SNS ARN the EP's outgoing events publish to ("NOT_AVAILABLE" if none)
11
20
  // "pluginReadModelTableName": string // DynamoDB table for Plugin RM (used by QueryEngine.scan)
12
- // "pluginSchemaPersistenceTableName":string // DynamoDB table holding deploy-time SDL fragments
13
- // // (rows keyed "deploy-schema:<name>"). Durable source
14
- // // for the runtime schema stitch; "NOT_AVAILABLE" →
15
- // // fall back to Plugin RM Connected-row scan.
16
21
  // "appSyncApiId": string // "NOT_AVAILABLE" disables schema stitching
17
- // "clonerEnabled": boolean
18
22
  // "schedulerRoleArn": string // IAM role for CloudWatch Events schedule targets
19
23
  // "schedulerQueueArn": string // ARN of the cmd topic the scheduler publishes to ("" → no commandTopicResources)
20
24
  // "schedulerQueueName": string
21
- // "pluginDefinition": Plugin.pluginDefinition // serialized; admin uses a fake one (id="Admin@INTERNAL")
22
25
  // "extensionPoints": [ // EPs whose OUTGOING events this Lambda processes; admin: 1 entry, plugins: []
23
26
  // {
24
27
  // "specModule": string, // dynamic import specifier for the EP spec module
@@ -44,8 +47,7 @@
44
47
  // }
45
48
  // ],
46
49
  // "publishToAggregates": { // aggregateName → env-var name holding the aggregate's cmd-topic SQS URL
47
- // "Plugin": "PTA_Plugin_QUEUE_URL",
48
- // "RecordProductDemand": "PTA_RecordProductDemand_QUEUE_URL"
50
+ // "Plugin": "PTA_Plugin_QUEUE_URL"
49
51
  // },
50
52
  // "readModelQueueUrls": { // rmName → env-var name holding the RM's EventCollector SQS URL
51
53
  // "ProductDemands": "PRM_ProductDemands_QUEUE_URL"
@@ -60,353 +62,16 @@
60
62
  // asset (this file). The runtime differences are entirely data — which EPs to
61
63
  // process outgoing events for (admin only) and which Connect extension to wire
62
64
  // (plugin only). Plugin_Callback.Make routes incoming SQS events to the right
63
- // service-keyed handler dict; we reconstruct those dicts here from HANDLER_CONFIG.
65
+ // service-keyed handler dict; Ops reconstructs those dicts from what we wire here.
64
66
 
65
- import { readFileSync } from "node:fs";
66
67
  import { createRequire } from "node:module";
67
68
  import { pathToFileURL } from "node:url";
68
- import {
69
- patchSpecId,
70
- makeQueueRef,
71
- scanByTableName,
72
- log,
73
- runEffect,
74
- setRequestId,
75
- extractMetaField,
76
- extractSentTimestamp,
77
- extractRetryCount,
78
- } from "./HandlerFactoryHelpers.mjs";
79
- import { subscribeQueueToTopic, unsubscribeQueueFromTopic } from "@reventlessdev/rescript-aws-sdk/src/SNS_Helpers.res.mjs";
80
- import { sendMessage } from "@reventlessdev/reventless-aws/src/plugin/runtime/Util_PluginMessage_Runtime.res.mjs";
81
- import { publish as snsPublish } from "@reventlessdev/reventless-aws/src/adapter/EventTopic/EventTopicPublisher_SNS_Runtime.res.mjs";
82
- import { publishJsons as sqsPublishJsons } from "@reventlessdev/reventless-aws/src/adapter/CommandTopic/CommandTopicChannel_SQS_Runtime.res.mjs";
69
+ import { patchSpecId } from "./HandlerFactoryHelpers.mjs";
83
70
  import { Make as extensionPointOperationsMake } from "@reventlessdev/reventless-core/src/components/ExtensionPoint/ExtensionPoint_Operations.res.mjs";
84
71
  import { Make as extensionOperationsMake } from "@reventlessdev/reventless-core/src/components/Extension/Extension_Operations.res.mjs";
85
72
  import { Make as extensionMappingMake } from "@reventlessdev/reventless-infra/src/types/ExtensionMapping.res.mjs";
86
73
  import { Make as extensionPointMappingMake } from "@reventlessdev/reventless-infra/src/types/ExtensionPointMapping.res.mjs";
87
- import { Make as pluginCallbackMake } from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Callback.res.mjs";
88
- import { handleDynamoDbOrSqsEvent } from "@reventlessdev/reventless-aws/src/adapter/EventCollector/EventCollectorChannel_SQS_Runtime.res.mjs";
89
- import { createSchedule as cwCreateSchedule, deleteSchedule as cwDeleteSchedule } from "@reventlessdev/reventless-aws/src/adapter/ScheduledPublisher/ScheduledPublisher_CloudWatchEvents_Runtime.res.mjs";
90
- import {
91
- decode as decodeFragment,
92
- } from "@reventlessdev/reventless-core/src/components/Api/GraphQL_Stitcher.res.mjs";
93
-
94
- // The Plugin RM state schema uses `@s.matches(_jsNullable …)` for many
95
- // optional fields — top-level AND nested. DDB writes drop undefined
96
- // attributes, so the unmarshalled row arrives with the relevant keys
97
- // *missing* (not set to null). Sury's strict parser then rejects them as
98
- // "received undefined". Tracking and patching every nullable path by hand is
99
- // whack-a-mole; instead, manageSubscriptions and reconcileSubscriptions only
100
- // need a tiny subset of the state (id / status / extensions / extensionPoints
101
- // / eventCollector), so they sidestep sury entirely and read the DDB row
102
- // directly via this small projection.
103
- function projectPluginRow(row) {
104
- if (!row || typeof row !== "object") return null;
105
- if (typeof row.id !== "string" || typeof row.status !== "string") return null;
106
- const dcbEventLog =
107
- row.dcbEventLog &&
108
- typeof row.dcbEventLog === "object" &&
109
- typeof row.dcbEventLog.name === "string" &&
110
- typeof row.dcbEventLog.eventTopicArn === "string"
111
- ? { name: row.dcbEventLog.name, eventTopicArn: row.dcbEventLog.eventTopicArn }
112
- : null;
113
- return {
114
- id: row.id,
115
- status: row.status,
116
- eventCollector: typeof row.eventCollector === "string" ? row.eventCollector : "",
117
- extensions: Array.isArray(row.extensions)
118
- ? row.extensions
119
- .filter((e) => e && typeof e.extensionPointName === "string")
120
- .map((e) => ({
121
- name: typeof e.name === "string" ? e.name : "",
122
- extensionPointName: e.extensionPointName,
123
- dcbSources: Array.isArray(e.dcbSources)
124
- ? e.dcbSources.filter((s) => typeof s === "string")
125
- : [],
126
- }))
127
- : [],
128
- extensionPoints: Array.isArray(row.extensionPoints)
129
- ? row.extensionPoints
130
- .filter((ep) => ep && typeof ep.name === "string" && typeof ep.eventTopic === "string")
131
- .map((ep) => ({
132
- name: ep.name,
133
- commandTopic: typeof ep.commandTopic === "string" ? ep.commandTopic : "",
134
- eventTopic: ep.eventTopic,
135
- }))
136
- : [],
137
- dcbEventLog,
138
- };
139
- }
140
-
141
- function parseHandlerConfig(rawJson) {
142
- if (!rawJson) throw new Error("HANDLER_CONFIG env var is empty");
143
- let config;
144
- try {
145
- config = JSON.parse(rawJson);
146
- } catch (e) {
147
- throw new Error("HANDLER_CONFIG JSON parse error: " + (e && e.message));
148
- }
149
- const required = [
150
- "queueUrl",
151
- "pluginExtensionPointCmdTopicUrl",
152
- "eventTopicArn",
153
- "pluginReadModelTableName",
154
- "appSyncApiId",
155
- "schedulerRoleArn",
156
- "schedulerQueueArn",
157
- "schedulerQueueName",
158
- "extensionPoints",
159
- "extensions",
160
- "publishToAggregates",
161
- ];
162
- for (const k of required) {
163
- if (config[k] === undefined) {
164
- throw new Error("HANDLER_CONFIG missing required field: " + k);
165
- }
166
- }
167
- if (!Array.isArray(config.extensionPoints)) throw new Error("HANDLER_CONFIG.extensionPoints must be an array");
168
- if (!Array.isArray(config.extensions)) throw new Error("HANDLER_CONFIG.extensions must be an array");
169
- // connectExtension is allowed to be null or absent (admin case)
170
- if (config.connectExtension === undefined) config.connectExtension = null;
171
- // Optional fields with empty defaults — admin context omits them entirely.
172
- if (config.readModelQueueUrls === undefined) config.readModelQueueUrls = {};
173
- if (config.readModelNamesForSourceName === undefined) config.readModelNamesForSourceName = {};
174
- // Per-extension defaults so older serialisers (or partial admin configs) keep working.
175
- for (const ext of config.extensions) {
176
- if (!Array.isArray(ext.aggregateNames)) ext.aggregateNames = [];
177
- if (!Array.isArray(ext.readModelNames)) ext.readModelNames = [];
178
- if (typeof ext.name !== "string") ext.name = ext.extensionPointName || "unknown";
179
- if (typeof ext.delegateModule !== "string") ext.delegateModule = "";
180
- }
181
- return config;
182
- }
183
-
184
- const invalidNameChars = /[^.\-_a-zA-Z0-9]/g;
185
- const resourceNaming = {
186
- validateName: (n) => n.replace(invalidNameChars, "_"),
187
- urnName: (arn) => {
188
- const parts = arn.split(":");
189
- return parts[5] || "unknown";
190
- },
191
- };
192
-
193
- // Admin-mediated cross-plugin SNS subscription manager (Plan Phase 3, Step 1).
194
- // When DoConnectPlugin / DoDisconnectPlugin fires on the admin EP mapping, we
195
- // scan the Plugin RM for currently-Connected peers and wire (or tear down) SNS
196
- // subscriptions between this plugin's EventCollector queue and peer plugins'
197
- // EP event topics — in both directions:
198
- // - peer EP topic → this plugin's EC queue, for each of this plugin's
199
- // extensions whose `extensionPointName` matches a peer EP
200
- // - this plugin's EP topics → peer EC queues, for each peer extension whose
201
- // `extensionPointName` matches one of this plugin's EPs
202
- // subscribeQueueToTopic is idempotent (lists existing subscriptions first);
203
- // unsubscribeQueueFromTopic is best-effort and swallows the "no subscription"
204
- // case. Failures are logged but do not throw — admin's RM projection still
205
- // proceeds and cold-start reconciliation will re-attempt missed work.
206
- function mkManageSubscriptions(tableName) {
207
- if (!tableName || tableName === "NOT_AVAILABLE") return undefined;
208
- const trySubscribe = async (queueArn, topicArn, label) => {
209
- if (!queueArn || !topicArn) return;
210
- try {
211
- await subscribeQueueToTopic(queueArn, topicArn);
212
- log.info(`subscribed ${label}: ${topicArn} -> ${queueArn}`, { comp: "manageSubscriptions" });
213
- } catch (e) {
214
- const msg = (e && e.message) || String(e);
215
- log.error(`subscribe failed ${label}: ${topicArn} -> ${queueArn}`, { comp: "manageSubscriptions", detail: msg });
216
- }
217
- };
218
- const tryUnsubscribe = async (queueArn, topicArn, label) => {
219
- if (!queueArn || !topicArn) return;
220
- try {
221
- await unsubscribeQueueFromTopic(queueArn, topicArn);
222
- log.info(`unsubscribed ${label}: ${topicArn} -> ${queueArn}`, { comp: "manageSubscriptions" });
223
- } catch (e) {
224
- const msg = (e && e.message) || String(e);
225
- log.error(`unsubscribe failed ${label}: ${topicArn} -> ${queueArn}`, { comp: "manageSubscriptions", detail: msg });
226
- }
227
- };
228
- const scanConnectedPeers = async (excludeId) => {
229
- const rows = await scanByTableName(
230
- tableName,
231
- [["status", { TAG: "Contains" }, { TAG: "String", _0: "Connected" }]],
232
- 1000
233
- );
234
- const peers = [];
235
- for (const row of rows) {
236
- const state = projectPluginRow(row);
237
- if (state && state.id !== excludeId) peers.push(state);
238
- }
239
- return peers;
240
- };
241
- // Extract the plugin display name from a versioned id like "Catalog@1.0.0-alpha.65".
242
- const pluginNameOfId = (id) => (typeof id === "string" ? id.split("@")[0] : "");
243
- return async (pluginDef, action) => {
244
- const isConnect = action === "connect";
245
- const op = isConnect ? trySubscribe : tryUnsubscribe;
246
- const peers = await scanConnectedPeers(pluginDef.id);
247
- // Subscriptions are wired between SNS topics and SQS queues that are owned
248
- // per PLUGIN NAME, not per version (the EC queue, EP event topics, and DCB
249
- // event topic ARNs all live in the plugin's stack and are stable across
250
- // version-to-version upgrades). When a superseded version is Retired and
251
- // its row transitions to Disconnected, this hook fires with action="disconnect"
252
- // — but if a newer version of the same plugin is still Connected, the
253
- // subscriptions are still in use. Tearing them down here would silently
254
- // break cross-plugin event flow for the live version (and only cold-start
255
- // reconciliation on the surviving plugin would heal it — which happens on
256
- // its next Lambda init, not immediately). Skip the tear-down in that case.
257
- if (!isConnect) {
258
- const myName = pluginNameOfId(pluginDef.id);
259
- const liveSibling = peers.find((p) => pluginNameOfId(p.id) === myName);
260
- if (liveSibling) {
261
- log.info(`disconnect skipped for ${pluginDef.id}: sibling version ${liveSibling.id} still Connected and shares the same EC queue / EP topics`, { comp: "manageSubscriptions" });
262
- return;
263
- }
264
- }
265
- // Subscriptions for this plugin's extensions: peer EP topic → this EC queue
266
- for (const ext of pluginDef.extensions || []) {
267
- for (const peer of peers) {
268
- const peerEp = (peer.extensionPoints || []).find(
269
- (ep) => ep.name === ext.extensionPointName
270
- );
271
- if (peerEp) {
272
- await op(pluginDef.eventCollector, peerEp.eventTopic, `${ext.extensionPointName} -> ${pluginDef.id}`);
273
- }
274
- }
275
- }
276
- // Subscriptions for this plugin's EPs: this EP topic → peer EC queues
277
- for (const ep of pluginDef.extensionPoints || []) {
278
- for (const peer of peers) {
279
- const matchingExt = (peer.extensions || []).find(
280
- (e) => e.extensionPointName === ep.name
281
- );
282
- if (matchingExt) {
283
- await op(peer.eventCollector, ep.eventTopic, `${ep.name} -> ${peer.id}`);
284
- }
285
- }
286
- }
287
- // ── Phase 4: DCB topic subscriptions ──────────────────────────────────
288
- // For each DCB source this plugin's extensions reference, subscribe this
289
- // plugin's EventCollector to the owning peer plugin's DCB EventTopic.
290
- for (const ext of pluginDef.extensions || []) {
291
- for (const dcbSourceName of ext.dcbSources || []) {
292
- const peer = peers.find(
293
- (p) => p.dcbEventLog && p.dcbEventLog.name === dcbSourceName
294
- );
295
- if (peer) {
296
- await op(
297
- pluginDef.eventCollector,
298
- peer.dcbEventLog.eventTopicArn,
299
- `dcb ${dcbSourceName} -> ${pluginDef.id}`
300
- );
301
- }
302
- }
303
- }
304
- // If THIS plugin has a DCB EventLog, subscribe each peer plugin's
305
- // EventCollector that references it via dcbSources.
306
- if (pluginDef.dcbEventLog) {
307
- const myDcbName = pluginDef.dcbEventLog.name;
308
- for (const peer of peers) {
309
- const consuming = (peer.extensions || []).some((e) =>
310
- (e.dcbSources || []).includes(myDcbName)
311
- );
312
- if (consuming) {
313
- await op(
314
- peer.eventCollector,
315
- pluginDef.dcbEventLog.eventTopicArn,
316
- `dcb ${myDcbName} -> ${peer.id}`
317
- );
318
- }
319
- }
320
- }
321
- };
322
- }
323
-
324
- // Cold-start reconciliation — scan the Plugin RM for every Connected plugin
325
- // and rerun manageSubscriptions(p, "connect") for each. Idempotent at the SNS
326
- // level (subscribeQueueToTopic dedupes), so this is safe to run repeatedly;
327
- // it catches subscriptions lost out-of-band (topic deleted+recreated, plugin
328
- // restarted with a new EC ARN, etc.). Fire-and-forget — admin still serves
329
- // incoming events while reconciliation completes.
330
- async function reconcileSubscriptionsOnce(tableName, manageSubscriptions) {
331
- if (!tableName || tableName === "NOT_AVAILABLE" || !manageSubscriptions) return;
332
- try {
333
- const rows = await scanByTableName(
334
- tableName,
335
- [["status", { TAG: "Contains" }, { TAG: "String", _0: "Connected" }]],
336
- 1000
337
- );
338
- for (const row of rows) {
339
- const state = projectPluginRow(row);
340
- if (state) {
341
- try {
342
- await manageSubscriptions(state, "connect");
343
- } catch (e) {
344
- log.warn("manageSubscriptions failed", { comp: "reconcileSubscriptions", detail: (e && e.message) || String(e) });
345
- }
346
- }
347
- }
348
- } catch (e) {
349
- log.warn("scan failed", { comp: "reconcileSubscriptions", detail: (e && e.message) || String(e) });
350
- }
351
- }
352
-
353
- function buildPublishToAggregates(map) {
354
- const out = {};
355
- for (const [aggName, envVarName] of Object.entries(map || {})) {
356
- const queueUrl = process.env[envVarName] || "";
357
- out[aggName] = sqsPublishJsons(makeQueueRef(queueUrl), "SQS_FIFO");
358
- }
359
- return out;
360
- }
361
-
362
- function dictByServiceKeys(serviceKeys, handler) {
363
- const dict = {};
364
- for (const k of serviceKeys || []) {
365
- if (!dict[k]) dict[k] = [];
366
- dict[k].push(handler);
367
- }
368
- return dict;
369
- }
370
-
371
- function mergeDicts(...dicts) {
372
- const out = {};
373
- for (const d of dicts) {
374
- for (const [k, v] of Object.entries(d)) {
375
- out[k] = (out[k] || []).concat(v);
376
- }
377
- }
378
- return out;
379
- }
380
-
381
- // pluginDefinition is shipped as a file in the asset zip rather than packed
382
- // into HANDLER_CONFIG (AWS Lambda's UpdateFunctionConfiguration request has a
383
- // 5120-byte limit; pluginStructure alone — schemas inline per component —
384
- // blew past that). The Lambda runtime extracts the zip to /var/task, and the
385
- // re-export index.mjs lives at /var/task/index.mjs alongside
386
- // pluginDefinition.json. process.cwd() is /var/task in Lambda, so a relative
387
- // path resolves correctly. The file is parsed once at cold start.
388
- function loadPluginDefinition() {
389
- try {
390
- const raw = readFileSync(new URL("./pluginDefinition.json", `file://${process.cwd()}/`), "utf-8");
391
- return JSON.parse(raw);
392
- } catch (e) {
393
- const msg = (e && e.message) || String(e);
394
- throw new Error("Failed to load pluginDefinition.json from asset zip: " + msg);
395
- }
396
- }
397
-
398
- // The plugin's UI-fragment manifest — shipped as its own asset since the
399
- // manifest no longer rides pluginDefinition (the UiFragmentRegistry slice owns
400
- // fragment state). Contains JSON `null` for plugins without a UI; tolerate a
401
- // missing file (archives built before the asset existed) the same way.
402
- function loadUiFragments() {
403
- try {
404
- const raw = readFileSync(new URL("./uiFragments.json", `file://${process.cwd()}/`), "utf-8");
405
- return JSON.parse(raw);
406
- } catch (e) {
407
- return null;
408
- }
409
- }
74
+ import * as Ops from "./EventCollectorEntryPoint_Ops.res.mjs";
410
75
 
411
76
  // Cross-plugin spec packages (e.g. "@reventlessdev/online-shop-hybrid-catalog-spec")
412
77
  // are bundled into the function asset under /var/task/node_modules/ by
@@ -428,52 +93,13 @@ const importFromAsset = (() => {
428
93
  })();
429
94
 
430
95
  async function buildHandler() {
431
- const config = parseHandlerConfig(process.env["HANDLER_CONFIG"]);
432
- const pluginDefinition = loadPluginDefinition();
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";
96
+ const config = Ops.parseHandlerConfig(process.env["HANDLER_CONFIG"] || "");
97
+ const pluginDefinition = Ops.loadPluginDefinition();
438
98
 
439
- // runtimeOps carries only what the admin EP mapping's callHandler actually
440
- // needs: sendMessageToChannel for ForwardCommand. Cross-plugin SNS
441
- // subscription wiring moved to manageSubscriptionsFn (Phase 3 Step 1 +
442
- // Step 3) — the topicSubscription stubs that the plugin Connect extension
443
- // used to call no longer exist on either side.
444
- const runtimeOps = {
445
- messagePublish: { sendMessageToChannel: sendMessage },
446
- };
447
-
448
- const queryEngine = {
449
- scan: (readModelName, filterConfigs, limit) =>
450
- scanByTableName(config.pluginReadModelTableName, filterConfigs, limit),
451
- query: async () => {
452
- throw new Error("QueryEngine.query not available in bundled Plugin EventCollector");
453
- },
454
- };
455
-
456
- const scheduler = {
457
- createSchedule: cwCreateSchedule(config.schedulerRoleArn),
458
- deleteSchedule: cwDeleteSchedule,
459
- };
460
-
461
- const commandTopicResources = config.schedulerQueueArn !== ""
462
- ? [{
463
- name: config.schedulerQueueName,
464
- id: config.schedulerQueueName,
465
- urn: config.schedulerQueueArn,
466
- service: "unknown",
467
- resourceInfo: "NoInfo",
468
- role: "",
469
- region: "",
470
- resourceType: "",
471
- configuration: {},
472
- tags: {},
473
- }]
474
- : [];
475
-
476
- const manageSubscriptionsFn = mkManageSubscriptions(config.pluginReadModelTableName);
99
+ const queryEngine = Ops.makeQueryEngine(config.pluginReadModelTableName);
100
+ const scheduler = Ops.makeScheduler(config.schedulerRoleArn);
101
+ const commandTopicResources = Ops.makeCommandTopicResources(config);
102
+ const manageSubscriptionsFn = Ops.makeManageSubscriptions(config.pluginReadModelTableName);
477
103
 
478
104
  // EP operations — admin has 1 entry (Plugin EP), plugins have N user EPs.
479
105
  //
@@ -489,8 +115,7 @@ async function buildHandler() {
489
115
  // that declares an ExtensionPoint. The ExtensionPoint spec module is
490
116
  // erased at compile time and must be re-attached at runtime.
491
117
  //
492
- // Dispatch on `typeof Make === function` to handle both. Mirrors the
493
- // user-extensions branch below which already does this for ExtensionMappings.
118
+ // Dispatch on `typeof Make === function` to handle both.
494
119
  const epHandlers = await Promise.all(config.extensionPoints.map(async (ep) => {
495
120
  const specMod = patchSpecId(await importFromAsset(ep.specModule));
496
121
  const mappingsMod = await importFromAsset(ep.mappingsModule);
@@ -499,12 +124,12 @@ async function buildHandler() {
499
124
  if (typeof mappingsMod.Make === "function") {
500
125
  // Admin's Plugin EP: invoke the Make functor with runtime ops.
501
126
  const epModule = mappingsMod.Make({
502
- runtimeOps,
503
- environment,
504
- // Connect-driven schema self-heal retired (Phase 4b) — the ApiSchemaPush
127
+ runtimeOps: Ops.runtimeOps,
128
+ environment: Ops.environment,
129
+ // Connect-driven schema self-heal retired — the ApiSchemaPush
505
130
  // SideEffect on ApiFragmentRegistry events is the single schema writer.
506
131
  updateApiSchema: undefined,
507
- manageSubscriptions: manageSubscriptionsFn,
132
+ manageSubscriptions: Ops.manageForDefinition(manageSubscriptionsFn),
508
133
  });
509
134
  mappingsModule = { mappings: [epModule.Mapping] };
510
135
  } else {
@@ -522,55 +147,40 @@ async function buildHandler() {
522
147
  Delegate: patchSpecId(mappingsMod.Delegate),
523
148
  moduleUrl: ep.mappingsModule,
524
149
  };
525
- const transformedMapping = extensionPointMappingMake(mappingImpl);
526
- mappingsModule = { mappings: [transformedMapping] };
150
+ mappingsModule = { mappings: [extensionPointMappingMake(mappingImpl)] };
527
151
  }
528
152
 
529
- const resolvedTopic = {
530
- name: ep.eventTopicArn,
531
- id: ep.eventTopicArn,
532
- arn: ep.eventTopicArn,
533
- };
534
153
  const ops = extensionPointOperationsMake(specMod)(mappingsModule)({
535
- publishToEventTopic: (id, meta, json) => snsPublish(resolvedTopic, id, meta, json),
154
+ publishToEventTopic: Ops.makePublishToEventTopic(ep.eventTopicArn),
536
155
  commandTopicResources,
537
156
  scheduler,
538
157
  queryEngine,
539
- resourceNaming,
158
+ resourceNaming: Ops.resourceNaming,
540
159
  });
541
- return {
542
- aggregateNames: ep.aggregateNames || [],
543
- outgoingHandler: ops.outgoingJsonEventsHandler,
544
- };
160
+ return { aggregateNames: ep.aggregateNames, outgoingHandler: ops.outgoingJsonEventsHandler };
545
161
  }));
546
162
 
547
- // Connect extension — exactly one entry per plugin Lambda; null for admin.
548
- const publishToAggregates = buildPublishToAggregates(config.publishToAggregates);
549
- const publishToPluginExtensionPoint = sqsPublishJsons(
550
- makeQueueRef(config.pluginExtensionPointCmdTopicUrl),
551
- "SQS_FIFO",
552
- );
163
+ const publishToAggregates = Ops.buildPublishToAggregates(config.publishToAggregates);
164
+ const publishToPluginExtensionPoint = Ops.makePublishJsons(config.pluginExtensionPointCmdTopicUrl);
553
165
 
554
- let connectExtensionHandler = null;
166
+ // Connect extension — exactly one entry per plugin Lambda; null for admin.
167
+ let connectExtensionHandler = undefined;
555
168
  if (config.connectExtension) {
556
169
  const ext = config.connectExtension;
557
170
  const specMod = patchSpecId(await importFromAsset(ext.specModule));
558
171
  const mappingsMod = await importFromAsset(ext.mappingsModule);
559
- // After Phase 3 of plugin-eventcollector-runtime-rewire, the Connect
560
- // extension Spec carries pluginDefinition plus the plugin's UI-fragment
561
- // manifest (its own asset) cross-plugin subscribe / unsubscribe
562
- // directives moved to admin's manageSubscriptions hook. ReScript None
563
- // compiles to `undefined`, so map the asset's JSON null accordingly.
172
+ // The Connect extension Spec carries pluginDefinition plus the plugin's
173
+ // UI-fragment manifest (its own asset). loadUiFragments returns a ReScript
174
+ // option `undefined` (None) for plugins without a UI.
564
175
  const extBuilder = mappingsMod.Make({
565
176
  pluginDefinition,
566
- uiFragments: loadUiFragments() ?? undefined,
177
+ uiFragments: Ops.loadUiFragments(),
567
178
  });
568
179
  // PluginConnectExtension_Builder.Make returns a module exposing:
569
180
  // - ConnectPluginMapping (single mapping)
570
181
  // - ConnectPluginMappings (Mappings module: {Spec, name, mappings, moduleUrl})
571
182
  // Use ConnectPluginMappings as the Mappings arg to Extension_Operations.Make.
572
- const mappingsModule = extBuilder.ConnectPluginMappings;
573
- const ops = extensionOperationsMake(specMod)(mappingsModule)({
183
+ const ops = extensionOperationsMake(specMod)(extBuilder.ConnectPluginMappings)({
574
184
  publishToAggregates,
575
185
  publishToPluginExtensionPoint,
576
186
  readModelNamesForSourceName: {},
@@ -589,7 +199,7 @@ async function buildHandler() {
589
199
  // it through ExtensionMapping.Make, and build per-extension Extension_Operations.
590
200
  const userExtensionHandlers = await Promise.all(config.extensions.map(async (ext) => {
591
201
  if (!ext.specModule || !ext.mappingsModule || !ext.delegateModule) {
592
- log.warn("extension '" + ext.name + "' missing module specifier(s); skipping. specModule=" + ext.specModule + ", mappingsModule=" + ext.mappingsModule + ", delegateModule=" + ext.delegateModule, { comp: "EventCollectorEntryPoint" });
202
+ Ops.warnSkippedExtension(ext);
593
203
  return null;
594
204
  }
595
205
  const epSpec = patchSpecId(await importFromAsset(ext.specModule));
@@ -615,42 +225,12 @@ async function buildHandler() {
615
225
  mappings: [transformedMapping],
616
226
  };
617
227
 
618
- // Per-extension publishToAggregates filtered to ext.aggregateNames —
619
- // each aggregate's cmd-topic SQS URL is looked up via the
620
- // PTA_<aggName>_QUEUE_URL env var name carried in HANDLER_CONFIG.
621
- const extPublishToAggregates = {};
622
- for (const aggName of ext.aggregateNames) {
623
- const envVarName = config.publishToAggregates[aggName];
624
- if (!envVarName) continue;
625
- const queueUrl = process.env[envVarName] || "";
626
- if (!queueUrl) continue;
627
- extPublishToAggregates[aggName] = sqsPublishJsons(makeQueueRef(queueUrl), "SQS_FIFO");
628
- }
629
-
630
- // Per-extension publishToReadModels — each RM's EventCollector SQS URL
631
- // is looked up via the PRM_<rmName>_QUEUE_URL env var name carried in
632
- // HANDLER_CONFIG.readModelQueueUrls.
633
- const extPublishToReadModels = {};
634
- for (const rmName of ext.readModelNames) {
635
- const envVarName = config.readModelQueueUrls[rmName];
636
- if (!envVarName) continue;
637
- const queueUrl = process.env[envVarName] || "";
638
- if (!queueUrl) continue;
639
- extPublishToReadModels[rmName] = sqsPublishJsons(makeQueueRef(queueUrl), "SQS_FIFO");
640
- }
641
-
642
- // readModelNamesForSourceName scoped to this extension's EP — Extension_Operations
643
- // uses this dict to decide which RMs receive each incoming event.
644
- const extReadModelNamesForSourceName = {
645
- [ext.extensionPointName]:
646
- config.readModelNamesForSourceName[ext.extensionPointName] || [],
647
- };
648
-
228
+ const dicts = Ops.extensionPublishDicts(config, ext);
649
229
  const ops = extensionOperationsMake(epSpec)(mappingsModule)({
650
- publishToAggregates: extPublishToAggregates,
230
+ publishToAggregates: dicts.publishToAggregates,
651
231
  publishToPluginExtensionPoint,
652
- readModelNamesForSourceName: extReadModelNamesForSourceName,
653
- publishToReadModels: extPublishToReadModels,
232
+ readModelNamesForSourceName: dicts.readModelNamesForSourceName,
233
+ publishToReadModels: dicts.publishToReadModels,
654
234
  queryEngine,
655
235
  });
656
236
 
@@ -661,57 +241,19 @@ async function buildHandler() {
661
241
  outgoingHandler: ops.outgoingJsonEventsHandler,
662
242
  };
663
243
  }));
664
- const extensionHandlers = userExtensionHandlers.filter(Boolean);
665
244
 
666
- // Build the four service-keyed dicts that Plugin_Callback.Make consumes.
667
- const outgoingExtensionPointEventHandlers = epHandlers
668
- .map((eph) => dictByServiceKeys(eph.aggregateNames, eph.outgoingHandler))
669
- .reduce(mergeDicts, {});
670
- const incomingConnectExtensionEventHandlers = connectExtensionHandler
671
- ? { [connectExtensionHandler.extensionPointName]: [connectExtensionHandler.incomingHandler] }
672
- : {};
673
- const outgoingExtensionEventHandlers = extensionHandlers
674
- .map((eh) => dictByServiceKeys(eh.aggregateNames, eh.outgoingHandler))
675
- .reduce(mergeDicts, {});
676
- const incomingExtensionEventHandlers = extensionHandlers
677
- .map((eh) => ({ [eh.extensionPointName]: [eh.incomingHandler] }))
678
- .reduce(mergeDicts, {});
245
+ // Cold-start reconciliation awaited so it finishes inside the same
246
+ // invocation that initialised the Lambda (a floating promise pauses when the
247
+ // runtime freezes between invocations). Idempotent at the SNS level.
248
+ await Ops.reconcileSubscriptionsOnce(config.pluginReadModelTableName, manageSubscriptionsFn);
679
249
 
680
- const callback = pluginCallbackMake({
250
+ return Ops.makeSqsHandlerBundle({
681
251
  pluginDefinition,
682
- incomingConnectExtensionEventHandlers,
683
- outgoingExtensionPointEventHandlers,
684
- outgoingExtensionEventHandlers,
685
- incomingExtensionEventHandlers,
252
+ queueUrl: config.queueUrl,
253
+ epHandlers,
254
+ connectExtensionHandler,
255
+ extensionHandlers: userExtensionHandlers.filter(Boolean),
686
256
  });
687
-
688
- // Cold-start reconciliation — awaited so it finishes inside the same
689
- // invocation that initialised the Lambda. Fire-and-forget doesn't work on
690
- // Lambda: any unfinished promise pauses when the runtime freezes between
691
- // invocations and rarely gets a chance to resume. Idempotent at the SNS
692
- // level, so the modest extra init latency is paid only on true cold starts.
693
- await reconcileSubscriptionsOnce(config.pluginReadModelTableName, manageSubscriptionsFn);
694
-
695
- const sqsHandler = handleDynamoDbOrSqsEvent(makeQueueRef(config.queueUrl), callback.handleJsonEvents);
696
- // This module backs both the admin and every per-plugin EventCollector
697
- // Lambda, so the comp names the plugin whose events it drains — matching the
698
- // `Plugin(<id>)` shape Plugin_Callback logs under.
699
- return { sqsHandler, comp: `Plugin(${pluginDefinition.id})` };
700
257
  }
701
258
 
702
- const handlerBundlePromise = buildHandler();
703
-
704
- export async function handler(event, context) {
705
- setRequestId(context?.awsRequestId);
706
- const records = event.Records || [];
707
- log.debug("processing " + records.length.toString() + " record(s)", { comp: "PluginEventCollectorRuntime" });
708
- const { sqsHandler, comp } = await handlerBundlePromise;
709
- await runEffect(sqsHandler(event, context), {
710
- correlationId: extractMetaField(records, "correlationId"),
711
- causationId: extractMetaField(records, "causationId"),
712
- comp,
713
- timestamp: extractSentTimestamp(records),
714
- retryCount: extractRetryCount(records),
715
- });
716
- return "";
717
- }
259
+ export const handler = Ops.makeHandler(buildHandler());