@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
@@ -81,164 +81,6 @@ let registry: dict<array<streamEntry>> = Dict.make()
81
81
  // Uses native Node.js crypto + fetch with SigV4 — no extra SDK packages needed.
82
82
  // Credentials come from the Lambda execution role via the process.env AWS_* variables.
83
83
 
84
- let handlerCode: string = `
85
- import { createHmac, createHash } from "node:crypto";
86
- import { unmarshall } from "@aws-sdk/util-dynamodb";
87
-
88
- const TOPIC_MAP = JSON.parse(process.env.STATE_TOPIC_MAP || "{}");
89
- const APPSYNC_ENDPOINT = process.env.APPSYNC_ENDPOINT; // "https://{eventsApi.dns.http}"
90
- const AWS_REGION = process.env.AWS_REGION ?? "eu-west-1";
91
-
92
- function sha256hex(data) {
93
- return createHash("sha256").update(typeof data === "string" ? data : JSON.stringify(data)).digest("hex");
94
- }
95
- function hmacBuf(key, data) {
96
- return createHmac("sha256", key).update(data).digest();
97
- }
98
-
99
- async function signedHeaders(host, path, body) {
100
- const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
101
- const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
102
- const sessionToken = process.env.AWS_SESSION_TOKEN;
103
- const now = new Date();
104
- const amzDate = now.toISOString().replace(/[:\\-]|\\..../g, "").slice(0, 15) + "Z";
105
- const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, "");
106
- const headers = { host, "x-amz-date": amzDate, ...(sessionToken ? { "x-amz-security-token": sessionToken } : {}) };
107
- const canonH = Object.entries(headers).sort(([a],[b]) => a.localeCompare(b)).map(([k,v]) => \`\${k}:\${v}\\n\`).join("");
108
- const signH = Object.keys(headers).sort().join(";");
109
- const cr = ["POST", path, "", canonH, signH, sha256hex(body)].join("\\n");
110
- const scope = \`\${dateStamp}/\${AWS_REGION}/appsync/aws4_request\`;
111
- const sts = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(cr)].join("\\n");
112
- const kDate = hmacBuf("AWS4" + secretAccessKey, dateStamp);
113
- const kSigning = hmacBuf(hmacBuf(hmacBuf(kDate, AWS_REGION), "appsync"), "aws4_request");
114
- const sig = createHmac("sha256", kSigning).update(sts).digest("hex");
115
- return { ...headers, Authorization: \`AWS4-HMAC-SHA256 Credential=\${accessKeyId}/\${scope}, SignedHeaders=\${signH}, Signature=\${sig}\` };
116
- }
117
-
118
- // AppSync Events channel segments allow only [A-Za-z0-9-]. Anything else
119
- // (\`@\`, \`.\`, \`:\`, \`_\`, \`/\`, …) collapses to \`-\` so the segment is a
120
- // legal channel-path token. The UI's AutoLive.normalizeSegment uses the same
121
- // rule for Entity-scoped subscribes.
122
- function pathSegment(value) {
123
- return String(value).replace(/[^A-Za-z0-9-]/g, "-");
124
- }
125
-
126
- // Map record.eventSourceARN → topicRoot via STATE_TOPIC_MAP.
127
- // Stream ARNs look like: arn:aws:dynamodb:<region>:<acct>:table/<TableName>/stream/<ts>
128
- function topicRootFromEventSourceArn(arn) {
129
- const m = arn && arn.match(/:table\\/([^/]+)\\/stream\\//);
130
- if (!m) return undefined;
131
- const topicName = TOPIC_MAP[m[1]];
132
- if (topicName === undefined) return undefined;
133
- return pathSegment(topicName);
134
- }
135
-
136
- // Build entityKey from record.dynamodb.Keys. Framework convention:
137
- // partition-key attr is named "id"; composite tables have one extra attr (the
138
- // sort key) whose name comes from the projection's subIdConfig.subIdField.
139
- // Returns the ORIGINAL key string — the descriptor body uses this verbatim so
140
- // AutoListView can match it against the GraphQL row.id. The channel path
141
- // segment is derived later via pathSegment().
142
- function entityKeyFromRecord(record) {
143
- const keys = unmarshall(record.dynamodb.Keys ?? {});
144
- const id = keys.id;
145
- if (id === undefined) {
146
- // Defensive: framework always names the partition key "id". If a future
147
- // table breaks the convention, fall back to a stable sort-join.
148
- const names = Object.keys(keys).sort();
149
- return names.map((n) => String(keys[n])).join("-");
150
- }
151
- const subIdName = Object.keys(keys).find((k) => k !== "id");
152
- return subIdName === undefined
153
- ? String(id)
154
- : String(id) + "-" + String(keys[subIdName]);
155
- }
156
-
157
- // Map DDB stream eventName → descriptor changeKind.
158
- function changeKindFor(eventName) {
159
- switch (eventName) {
160
- case "INSERT": return "Added";
161
- case "MODIFY": return "Updated";
162
- case "REMOVE": return "Removed";
163
- default: return "Updated"; // defensive default — shouldn't fire
164
- }
165
- }
166
-
167
- // Pick the "natural" sort timestamp from an unmarshalled image. Conventions
168
- // in this codebase prefer updatedAt; createdAt is the fallback for views that
169
- // never mutate after insert. Returns undefined if neither is present.
170
- function pickSortKeyValue(image) {
171
- if (image && typeof image.updatedAt === "string") return image.updatedAt;
172
- if (image && typeof image.createdAt === "string") return image.createdAt;
173
- return undefined;
174
- }
175
-
176
- export async function handler(event) {
177
- const url = new URL(APPSYNC_ENDPOINT);
178
- let transientErr;
179
- for (const record of event.Records) {
180
- const topicRoot = topicRootFromEventSourceArn(record.eventSourceARN);
181
- if (topicRoot === undefined) {
182
- console.warn("StateTopic: unknown table for ARN", record.eventSourceARN);
183
- continue;
184
- }
185
- const channelRoot = "/default/" + topicRoot;
186
- const image =
187
- record.eventName === "REMOVE"
188
- ? record.dynamodb.OldImage
189
- : record.dynamodb.NewImage;
190
- if (image === undefined) continue;
191
- const entityKey = entityKeyFromRecord(record);
192
- const channel = channelRoot + "/" + pathSegment(entityKey);
193
- const unmarshalled = unmarshall(image);
194
- const descriptor = {
195
- changeKind: changeKindFor(record.eventName),
196
- id: entityKey,
197
- };
198
- const sortKeyValue = pickSortKeyValue(unmarshalled);
199
- if (sortKeyValue !== undefined) descriptor.sortKeyValue = sortKeyValue;
200
- const body = JSON.stringify({ id: record.eventID, channel, events: [JSON.stringify(descriptor)] });
201
- const auth = await signedHeaders(url.hostname, "/event", body);
202
- try {
203
- const res = await fetch(APPSYNC_ENDPOINT + "/event", {
204
- method: "POST",
205
- headers: { accept: "application/json, text/javascript", "content-encoding": "amz-1.0", "content-type": "application/json; charset=UTF-8", ...auth },
206
- body,
207
- });
208
- if (!res.ok) {
209
- const txt = await res.text();
210
- // 4xx: permanent (bad request, auth, channel format) — retrying won't
211
- // help. Log structurally so a CloudWatch metric filter on
212
- // "STATE_TOPIC_PUBLISH_FAILED type=permanent" surfaces it; continue
213
- // processing so one bad record doesn't block the rest of the batch.
214
- // 5xx / network: transient — record it and throw at the end so the
215
- // ESM retries (bounded by maximumRetryAttempts + bisectBatch).
216
- const isTransient = res.status >= 500;
217
- const tag = isTransient ? "transient" : "permanent";
218
- console.error(
219
- "STATE_TOPIC_PUBLISH_FAILED type=" + tag +
220
- " status=" + res.status +
221
- " channel=" + channel +
222
- " body=" + txt.slice(0, 500)
223
- );
224
- if (isTransient) transientErr = new Error("StateTopic publish failed: " + res.status);
225
- }
226
- } catch (e) {
227
- // Network-level error (DNS, connection refused, timeout) — always transient.
228
- console.error(
229
- "STATE_TOPIC_PUBLISH_FAILED type=transient status=network" +
230
- " channel=" + channel +
231
- " err=" + (e && e.message)
232
- );
233
- transientErr = e;
234
- }
235
- }
236
- // Throw after the loop so a single transient error doesn't stop us from
237
- // emitting other records' descriptors first. The ESM will retry the batch;
238
- // bisectBatchOnFunctionError isolates the bad record across retries.
239
- if (transientErr) throw transientErr;
240
- }
241
- `
242
84
 
243
85
  // ── Registration ──────────────────────────────────────────────────────────────
244
86
 
@@ -353,19 +195,19 @@ let finish = (
353
195
  dict->JSON.Encode.object->JSON.stringify
354
196
  })
355
197
 
356
- // Shared Lambda — handler code is identical for every stream-enabled RM;
357
- // routing is per-record via STATE_TOPIC_MAP env var.
358
- let archiveContents: dict<Pulumi.Archive.assetOrArchive> = Dict.make()
359
- archiveContents->Dict.set(
360
- "index.mjs",
361
- Pulumi.Asset.stringAsset(handlerCode)->Pulumi.Archive.assetToAssetOrArchive,
198
+ // Shared Lambda — the compiled `_Ops` handler is identical for every
199
+ // stream-enabled RM; routing is per-record via STATE_TOPIC_MAP env var.
200
+ // Bundle reventless-aws (the handler + its node:crypto signer + the
201
+ // util-dynamodb unmarshaller live inside it) and re-export its `handler`;
202
+ // buildCodeArchive ships the ESM resolve-hook so `@rescript/runtime` and the
203
+ // handler's bare @aws-sdk/util-dynamodb resolve from layer / managed runtime.
204
+ let packageDirs = Dict.fromArray([
205
+ ("@reventlessdev/reventless-aws", Util_Bundle.resolvePackageRoot("@reventlessdev/reventless-aws")),
206
+ ])
207
+ let {code, sourceCodeHash} = Util_Bundle.buildCodeArchive(
208
+ ~entryPointModule="@reventlessdev/reventless-aws/src/adapter/StateTopic/StateTopic_AppSync_Ops.res.mjs",
209
+ ~packageDirs,
362
210
  )
363
- // ESM self-containment: the handler imports @aws-sdk/util-dynamodb, provided
364
- // by the nodejs22.x runtime only under /var/runtime — unreachable from
365
- // /var/task ESM without the resolver hook. Ship the loader + set its env vars.
366
- let loaderHash = Util_Bundle.addEsmLoaderAssets(archiveContents)
367
- let code = Pulumi.Archive.assetArchive(archiveContents)
368
- let sourceCodeHash = Util_Bundle.hashString(handlerCode ++ "\n---\n" ++ loaderHash)
369
211
 
370
212
  let layers =
371
213
  Lambda.reventlessLayerArn
@@ -16,165 +16,6 @@ import * as Util_DynamoDbStream$ReventlessAws from "../../util/Util_DynamoDbStre
16
16
 
17
17
  let registry = {};
18
18
 
19
- let handlerCode = `
20
- import { createHmac, createHash } from "node:crypto";
21
- import { unmarshall } from "@aws-sdk/util-dynamodb";
22
-
23
- const TOPIC_MAP = JSON.parse(process.env.STATE_TOPIC_MAP || "{}");
24
- const APPSYNC_ENDPOINT = process.env.APPSYNC_ENDPOINT; // "https://{eventsApi.dns.http}"
25
- const AWS_REGION = process.env.AWS_REGION ?? "eu-west-1";
26
-
27
- function sha256hex(data) {
28
- return createHash("sha256").update(typeof data === "string" ? data : JSON.stringify(data)).digest("hex");
29
- }
30
- function hmacBuf(key, data) {
31
- return createHmac("sha256", key).update(data).digest();
32
- }
33
-
34
- async function signedHeaders(host, path, body) {
35
- const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
36
- const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
37
- const sessionToken = process.env.AWS_SESSION_TOKEN;
38
- const now = new Date();
39
- const amzDate = now.toISOString().replace(/[:\\-]|\\..../g, "").slice(0, 15) + "Z";
40
- const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, "");
41
- const headers = { host, "x-amz-date": amzDate, ...(sessionToken ? { "x-amz-security-token": sessionToken } : {}) };
42
- const canonH = Object.entries(headers).sort(([a],[b]) => a.localeCompare(b)).map(([k,v]) => \`\${k}:\${v}\\n\`).join("");
43
- const signH = Object.keys(headers).sort().join(";");
44
- const cr = ["POST", path, "", canonH, signH, sha256hex(body)].join("\\n");
45
- const scope = \`\${dateStamp}/\${AWS_REGION}/appsync/aws4_request\`;
46
- const sts = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(cr)].join("\\n");
47
- const kDate = hmacBuf("AWS4" + secretAccessKey, dateStamp);
48
- const kSigning = hmacBuf(hmacBuf(hmacBuf(kDate, AWS_REGION), "appsync"), "aws4_request");
49
- const sig = createHmac("sha256", kSigning).update(sts).digest("hex");
50
- return { ...headers, Authorization: \`AWS4-HMAC-SHA256 Credential=\${accessKeyId}/\${scope}, SignedHeaders=\${signH}, Signature=\${sig}\` };
51
- }
52
-
53
- // AppSync Events channel segments allow only [A-Za-z0-9-]. Anything else
54
- // (\`@\`, \`.\`, \`:\`, \`_\`, \`/\`, …) collapses to \`-\` so the segment is a
55
- // legal channel-path token. The UI's AutoLive.normalizeSegment uses the same
56
- // rule for Entity-scoped subscribes.
57
- function pathSegment(value) {
58
- return String(value).replace(/[^A-Za-z0-9-]/g, "-");
59
- }
60
-
61
- // Map record.eventSourceARN → topicRoot via STATE_TOPIC_MAP.
62
- // Stream ARNs look like: arn:aws:dynamodb:<region>:<acct>:table/<TableName>/stream/<ts>
63
- function topicRootFromEventSourceArn(arn) {
64
- const m = arn && arn.match(/:table\\/([^/]+)\\/stream\\//);
65
- if (!m) return undefined;
66
- const topicName = TOPIC_MAP[m[1]];
67
- if (topicName === undefined) return undefined;
68
- return pathSegment(topicName);
69
- }
70
-
71
- // Build entityKey from record.dynamodb.Keys. Framework convention:
72
- // partition-key attr is named "id"; composite tables have one extra attr (the
73
- // sort key) whose name comes from the projection's subIdConfig.subIdField.
74
- // Returns the ORIGINAL key string — the descriptor body uses this verbatim so
75
- // AutoListView can match it against the GraphQL row.id. The channel path
76
- // segment is derived later via pathSegment().
77
- function entityKeyFromRecord(record) {
78
- const keys = unmarshall(record.dynamodb.Keys ?? {});
79
- const id = keys.id;
80
- if (id === undefined) {
81
- // Defensive: framework always names the partition key "id". If a future
82
- // table breaks the convention, fall back to a stable sort-join.
83
- const names = Object.keys(keys).sort();
84
- return names.map((n) => String(keys[n])).join("-");
85
- }
86
- const subIdName = Object.keys(keys).find((k) => k !== "id");
87
- return subIdName === undefined
88
- ? String(id)
89
- : String(id) + "-" + String(keys[subIdName]);
90
- }
91
-
92
- // Map DDB stream eventName → descriptor changeKind.
93
- function changeKindFor(eventName) {
94
- switch (eventName) {
95
- case "INSERT": return "Added";
96
- case "MODIFY": return "Updated";
97
- case "REMOVE": return "Removed";
98
- default: return "Updated"; // defensive default — shouldn't fire
99
- }
100
- }
101
-
102
- // Pick the "natural" sort timestamp from an unmarshalled image. Conventions
103
- // in this codebase prefer updatedAt; createdAt is the fallback for views that
104
- // never mutate after insert. Returns undefined if neither is present.
105
- function pickSortKeyValue(image) {
106
- if (image && typeof image.updatedAt === "string") return image.updatedAt;
107
- if (image && typeof image.createdAt === "string") return image.createdAt;
108
- return undefined;
109
- }
110
-
111
- export async function handler(event) {
112
- const url = new URL(APPSYNC_ENDPOINT);
113
- let transientErr;
114
- for (const record of event.Records) {
115
- const topicRoot = topicRootFromEventSourceArn(record.eventSourceARN);
116
- if (topicRoot === undefined) {
117
- console.warn("StateTopic: unknown table for ARN", record.eventSourceARN);
118
- continue;
119
- }
120
- const channelRoot = "/default/" + topicRoot;
121
- const image =
122
- record.eventName === "REMOVE"
123
- ? record.dynamodb.OldImage
124
- : record.dynamodb.NewImage;
125
- if (image === undefined) continue;
126
- const entityKey = entityKeyFromRecord(record);
127
- const channel = channelRoot + "/" + pathSegment(entityKey);
128
- const unmarshalled = unmarshall(image);
129
- const descriptor = {
130
- changeKind: changeKindFor(record.eventName),
131
- id: entityKey,
132
- };
133
- const sortKeyValue = pickSortKeyValue(unmarshalled);
134
- if (sortKeyValue !== undefined) descriptor.sortKeyValue = sortKeyValue;
135
- const body = JSON.stringify({ id: record.eventID, channel, events: [JSON.stringify(descriptor)] });
136
- const auth = await signedHeaders(url.hostname, "/event", body);
137
- try {
138
- const res = await fetch(APPSYNC_ENDPOINT + "/event", {
139
- method: "POST",
140
- headers: { accept: "application/json, text/javascript", "content-encoding": "amz-1.0", "content-type": "application/json; charset=UTF-8", ...auth },
141
- body,
142
- });
143
- if (!res.ok) {
144
- const txt = await res.text();
145
- // 4xx: permanent (bad request, auth, channel format) — retrying won't
146
- // help. Log structurally so a CloudWatch metric filter on
147
- // "STATE_TOPIC_PUBLISH_FAILED type=permanent" surfaces it; continue
148
- // processing so one bad record doesn't block the rest of the batch.
149
- // 5xx / network: transient — record it and throw at the end so the
150
- // ESM retries (bounded by maximumRetryAttempts + bisectBatch).
151
- const isTransient = res.status >= 500;
152
- const tag = isTransient ? "transient" : "permanent";
153
- console.error(
154
- "STATE_TOPIC_PUBLISH_FAILED type=" + tag +
155
- " status=" + res.status +
156
- " channel=" + channel +
157
- " body=" + txt.slice(0, 500)
158
- );
159
- if (isTransient) transientErr = new Error("StateTopic publish failed: " + res.status);
160
- }
161
- } catch (e) {
162
- // Network-level error (DNS, connection refused, timeout) — always transient.
163
- console.error(
164
- "STATE_TOPIC_PUBLISH_FAILED type=transient status=network" +
165
- " channel=" + channel +
166
- " err=" + (e && e.message)
167
- );
168
- transientErr = e;
169
- }
170
- }
171
- // Throw after the loop so a single transient error doesn't stop us from
172
- // emitting other records' descriptors first. The ESM will retry the batch;
173
- // bisectBatchOnFunctionError isolates the bad record across retries.
174
- if (transientErr) throw transientErr;
175
- }
176
- `;
177
-
178
19
  function make(readModelName, topicName, allQueryDbs, eventsApi, param) {
179
20
  let streamResource = Util_DynamoDbStream$ReventlessAws.findResource(Util_ReadModel$ReventlessCore.queryDbStorageResources(allQueryDbs, readModelName));
180
21
  let streamArn = Util_DynamoDbStream$ReventlessAws.streamArnFromDynamoDbTableResource(streamResource);
@@ -241,17 +82,17 @@ function finish(eventsApi, opts) {
241
82
  });
242
83
  return JSON.stringify(dict);
243
84
  });
244
- let archiveContents = {};
245
- archiveContents["index.mjs"] = new (Pulumi.asset.StringAsset)(handlerCode);
246
- let loaderHash = Util_Bundle$ReventlessAws.addEsmLoaderAssets(archiveContents);
247
- let code = new (Pulumi.asset.AssetArchive)(archiveContents);
248
- let sourceCodeHash = Util_Bundle$ReventlessAws.hashString(handlerCode + "\n---\n" + loaderHash);
85
+ let packageDirs = Object.fromEntries([[
86
+ "@reventlessdev/reventless-aws",
87
+ Util_Bundle$ReventlessAws.resolvePackageRoot("@reventlessdev/reventless-aws")
88
+ ]]);
89
+ let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/StateTopic/StateTopic_AppSync_Ops.res.mjs", packageDirs, undefined);
249
90
  let layers = Stdlib_Option.getOr(Stdlib_Option.map(Lambda$PulumiAws.reventlessLayerArn, arn => [arn]), []);
250
91
  let appsyncEndpoint = AppSync_EventsApi$ReventlessAws.httpEndpoint(eventsApi);
251
92
  let lambda = new (Aws.lambda.Function)(name + "StateTopicPublisher", {
252
93
  handler: "index.handler",
253
94
  runtime: "nodejs22.x",
254
- code: code,
95
+ code: match.code,
255
96
  role: lambdaRole.arn,
256
97
  memorySize: 256,
257
98
  timeout: 30,
@@ -281,7 +122,7 @@ function finish(eventsApi, opts) {
281
122
  ]
282
123
  ])
283
124
  },
284
- sourceCodeHash: sourceCodeHash
125
+ sourceCodeHash: match.sourceCodeHash
285
126
  }, opts);
286
127
  entries.forEach(entry => {
287
128
  let esmName = entry.topicName + "Stream2" + name + "StateTopic";
@@ -299,7 +140,6 @@ function finish(eventsApi, opts) {
299
140
 
300
141
  export {
301
142
  registry,
302
- handlerCode,
303
143
  make,
304
144
  finish,
305
145
  }
@@ -0,0 +1,208 @@
1
+ // Runtime handler for the read-model → AppSync Events state-topic relay —
2
+ // compiled, type-checked ReScript (replaces the inline JS `handlerCode` string
3
+ // in StateTopic_AppSync.res). Runtime-pure and SDK-free for signing (node:crypto
4
+ // via AppSyncEventsSigner_Ops; see the parity test); it uses the rescript-aws-sdk
5
+ // util-dynamodb unmarshaller (also Pulumi-free).
6
+ //
7
+ // Triggered by DynamoDB streams (one shared Lambda; routing is per-record via
8
+ // STATE_TOPIC_MAP). For each changed row it derives the entity channel and a
9
+ // `{changeKind, id, sortKeyValue?}` descriptor and publishes it. 4xx failures
10
+ // are logged and skipped; 5xx / network failures are recorded and rethrown after
11
+ // the batch so the EventSourceMapping retries (bisectBatchOnFunctionError).
12
+
13
+ @val @scope("process") external processEnv: dict<string> = "env"
14
+
15
+ let endpoint = processEnv->Dict.get("APPSYNC_ENDPOINT")->Option.getOr("")
16
+
17
+ // STATE_TOPIC_MAP: `{ <tableName>: <topicName> }`, injected at deploy time.
18
+ let topicMap: dict<string> =
19
+ switch processEnv->Dict.get("STATE_TOPIC_MAP")->Option.getOr("{}")->JSON.parseOrThrow->JSON.Decode.object {
20
+ | Some(obj) =>
21
+ obj
22
+ ->Dict.toArray
23
+ ->Array.filterMap(((k, v)) => v->JSON.Decode.string->Option.map(s => (k, s)))
24
+ ->Dict.fromArray
25
+ | None => Dict.make()
26
+ }
27
+
28
+ // ── DynamoDB stream event (only the fields this handler reads) ───────────────
29
+
30
+ type attributeValue = AwsSdk.DynamoDb.Util.attributeValue
31
+ type streamRecord = {
32
+ @as("Keys") keys: dict<attributeValue>,
33
+ @as("NewImage") newImage?: dict<attributeValue>,
34
+ @as("OldImage") oldImage?: dict<attributeValue>,
35
+ }
36
+ type record = {
37
+ eventID: string,
38
+ eventName: string, // "INSERT" | "MODIFY" | "REMOVE"
39
+ eventSourceARN: string,
40
+ dynamodb?: streamRecord,
41
+ }
42
+ type event = {@as("Records") records: array<record>}
43
+
44
+ // ── Per-record derivations (ports of the former inline JS helpers) ───────────
45
+
46
+ // Map record.eventSourceARN → topicRoot via STATE_TOPIC_MAP. Stream ARNs look
47
+ // like arn:aws:dynamodb:<region>:<acct>:table/<TableName>/stream/<ts>; split on
48
+ // "/" gives [ "...:table", "<TableName>", "stream", "<ts>" ].
49
+ let topicRootFromEventSourceArn = (arn: string): option<string> => {
50
+ let parts = arn->String.split("/")
51
+ switch (parts->Array.get(0), parts->Array.get(1), parts->Array.get(2)) {
52
+ | (Some(prefix), Some(tableName), Some("stream")) if prefix->String.endsWith(":table") =>
53
+ topicMap->Dict.get(tableName)->Option.map(AppSyncEventsSigner_Ops.pathSegment)
54
+ | _ => None
55
+ }
56
+ }
57
+
58
+ // `String(value)` parity for scalar key attributes.
59
+ let jsonToString = (j: JSON.t): string =>
60
+ switch j {
61
+ | String(s) => s
62
+ | Number(n) => n->Float.toString
63
+ | Boolean(b) => b ? "true" : "false"
64
+ | Null => "null"
65
+ | _ => j->JSON.stringify
66
+ }
67
+
68
+ // Build entityKey from the record's Keys. Framework convention: the partition
69
+ // key attribute is "id"; a composite table adds one sort-key attribute. Returns
70
+ // the ORIGINAL key string (channel-segmentised later via pathSegment).
71
+ let entityKeyFromRecord = (dynamodb: streamRecord): string => {
72
+ let keys: dict<JSON.t> = AwsSdk.DynamoDb_Util_Helpers.unmarshallDict(dynamodb.keys)
73
+ switch keys->Dict.get("id") {
74
+ | None =>
75
+ // Defensive: framework always names the partition key "id"; fall back to a
76
+ // stable sort-join if a future table breaks the convention.
77
+ keys
78
+ ->Dict.keysToArray
79
+ ->Array.toSorted(String.compare)
80
+ ->Array.map(n => keys->Dict.get(n)->Option.mapOr("", jsonToString))
81
+ ->Array.join("-")
82
+ | Some(id) =>
83
+ switch keys->Dict.keysToArray->Array.find(k => k != "id") {
84
+ | None => id->jsonToString
85
+ | Some(subIdName) =>
86
+ id->jsonToString ++ "-" ++ keys->Dict.get(subIdName)->Option.mapOr("", jsonToString)
87
+ }
88
+ }
89
+ }
90
+
91
+ // Map DDB stream eventName → descriptor changeKind.
92
+ let changeKindFor = (eventName: string): string =>
93
+ switch eventName {
94
+ | "INSERT" => "Added"
95
+ | "MODIFY" => "Updated"
96
+ | "REMOVE" => "Removed"
97
+ | _ => "Updated" // defensive default — shouldn't fire
98
+ }
99
+
100
+ // Prefer updatedAt, then createdAt; None if neither is a string.
101
+ let pickSortKeyValue = (image: dict<JSON.t>): option<string> =>
102
+ switch image->Dict.get("updatedAt")->Option.flatMap(JSON.Decode.string) {
103
+ | Some(v) => Some(v)
104
+ | None => image->Dict.get("createdAt")->Option.flatMap(JSON.Decode.string)
105
+ }
106
+
107
+ // Publish one record; returns Some(errorMessage) on a transient (5xx / network)
108
+ // failure so the caller can rethrow after the batch, None otherwise.
109
+ let processRecord = async (
110
+ ~record: record,
111
+ ~region: string,
112
+ ~creds: AppSyncEventsSigner_Ops.creds,
113
+ ): option<string> =>
114
+ switch topicRootFromEventSourceArn(record.eventSourceARN) {
115
+ | None =>
116
+ Console.warn2("StateTopic: unknown table for ARN", record.eventSourceARN)
117
+ None
118
+ | Some(topicRoot) =>
119
+ switch record.dynamodb {
120
+ | None => None
121
+ | Some(dynamodb) =>
122
+ let image = if record.eventName == "REMOVE" {
123
+ dynamodb.oldImage
124
+ } else {
125
+ dynamodb.newImage
126
+ }
127
+ switch image {
128
+ | None => None
129
+ | Some(image) =>
130
+ let entityKey = entityKeyFromRecord(dynamodb)
131
+ let channel = `/default/${topicRoot}/${AppSyncEventsSigner_Ops.pathSegment(entityKey)}`
132
+ let unmarshalled: dict<JSON.t> = AwsSdk.DynamoDb_Util_Helpers.unmarshallDict(image)
133
+ let descriptor = Dict.make()
134
+ descriptor->Dict.set("changeKind", JSON.Encode.string(changeKindFor(record.eventName)))
135
+ descriptor->Dict.set("id", JSON.Encode.string(entityKey))
136
+ pickSortKeyValue(unmarshalled)->Option.forEach(v =>
137
+ descriptor->Dict.set("sortKeyValue", JSON.Encode.string(v))
138
+ )
139
+ let body =
140
+ Dict.fromArray([
141
+ ("id", JSON.Encode.string(record.eventID)),
142
+ ("channel", JSON.Encode.string(channel)),
143
+ (
144
+ "events",
145
+ JSON.Encode.array([JSON.Encode.string(descriptor->JSON.Encode.object->JSON.stringify)]),
146
+ ),
147
+ ])
148
+ ->JSON.Encode.object
149
+ ->JSON.stringify
150
+ try {
151
+ let res = await AppSyncEventsSigner_Ops.postEvent(
152
+ ~endpoint,
153
+ ~region,
154
+ ~isoNow=Date.make()->Date.toISOString,
155
+ ~creds,
156
+ ~body,
157
+ )
158
+ if res->AppSyncEventsSigner_Ops.responseOk {
159
+ None
160
+ } else {
161
+ let status = res->AppSyncEventsSigner_Ops.responseStatus
162
+ let txt = await res->AppSyncEventsSigner_Ops.responseText
163
+ let isTransient = status >= 500
164
+ let tag = isTransient ? "transient" : "permanent"
165
+ // 4xx: permanent (bad request, auth, channel format) — retrying won't
166
+ // help. 5xx: transient — recorded and rethrown so the ESM retries.
167
+ // Structured so a CloudWatch metric filter can surface each type.
168
+ Console.error(
169
+ `STATE_TOPIC_PUBLISH_FAILED type=${tag} status=${status->Int.toString} channel=${channel} body=${txt->String.slice(
170
+ ~start=0,
171
+ ~end=500,
172
+ )}`,
173
+ )
174
+ isTransient ? Some(`StateTopic publish failed: ${status->Int.toString}`) : None
175
+ }
176
+ } catch {
177
+ | exn =>
178
+ // Network-level error (DNS, connection refused, timeout) — always transient.
179
+ let msg = exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("")
180
+ Console.error(
181
+ `STATE_TOPIC_PUBLISH_FAILED type=transient status=network channel=${channel} err=${msg}`,
182
+ )
183
+ Some(msg == "" ? "StateTopic network error" : msg)
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ let handler = async (event: event): unit => {
190
+ let region = AppSyncEventsSigner_Ops.region()
191
+ let creds = AppSyncEventsSigner_Ops.envCreds()
192
+ let transientErr = ref(None)
193
+ // Sequential, mirroring the former for-await loop.
194
+ await event.records->Array.reduce(Promise.resolve(), (acc, record) =>
195
+ acc->Promise.then(async _ =>
196
+ switch await processRecord(~record, ~region, ~creds) {
197
+ | Some(msg) => transientErr := Some(msg)
198
+ | None => ()
199
+ }
200
+ )
201
+ )
202
+ // Throw after the loop so one transient failure doesn't stop the other records'
203
+ // descriptors from being emitted first; the ESM retries the batch.
204
+ switch transientErr.contents {
205
+ | Some(msg) => JsError.throwWithMessage(msg)
206
+ | None => ()
207
+ }
208
+ }