@reventlessdev/reventless-aws 3.0.0-alpha.176 → 3.0.0-alpha.178

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 (71) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/docker-compose.postgres.yml +21 -0
  3. package/package.json +7 -6
  4. package/scripts/run-pg-integration-tests.sh +58 -0
  5. package/src/Platform.res +49 -0
  6. package/src/Platform.res.mjs +48 -2
  7. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +7 -1
  8. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +10 -1
  9. package/src/adapter/Api/Platform_UIFragments_Lambda.res +7 -1
  10. package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +10 -1
  11. package/src/adapter/Counter/CounterHandler_DynamoDbStream.res +6 -21
  12. package/src/adapter/Counter/CounterHandler_DynamoDbStream.res.mjs +2 -17
  13. package/src/adapter/DcbEventLog/DcbBackend.res +4 -0
  14. package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res +2 -2
  15. package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res.mjs +7 -1
  16. package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res +1 -11
  17. package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res.mjs +2 -24
  18. package/src/adapter/Postgres/PgConnection.res +70 -10
  19. package/src/adapter/Postgres/PgConnection.res.mjs +48 -3
  20. package/src/adapter/Postgres/PgMigration_Builder.res +127 -0
  21. package/src/adapter/Postgres/PgMigration_Builder.res.mjs +51 -0
  22. package/src/adapter/QueryDb/PgQueryResolver_Builder.res +279 -0
  23. package/src/adapter/QueryDb/PgQueryResolver_Builder.res.mjs +170 -0
  24. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +202 -11
  25. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +150 -9
  26. package/src/adapter/QueryDb/QueryDbBackend.res +8 -0
  27. package/src/adapter/QueryDb/QueryDbBackend.res.mjs +4 -1
  28. package/src/adapter/QueryDb/QueryDbResolvers.res +6 -5
  29. package/src/adapter/QueryDb/QueryDbResolvers.res.mjs +6 -3
  30. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res +288 -0
  31. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res.mjs +170 -0
  32. package/src/adapter/QueryDb/QueryDbStorage.res +6 -1
  33. package/src/adapter/QueryDb/QueryDbStorage.res.mjs +2 -1
  34. package/src/adapter/QueryDb/QueryDbStorage_Postgres.res +5 -1
  35. package/src/adapter/QueryDb/QueryDbStorage_Postgres.res.mjs +3 -3
  36. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +3 -14
  37. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +2 -25
  38. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +3 -14
  39. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +2 -25
  40. package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +7 -1
  41. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +78 -15
  42. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res.mjs +43 -26
  43. package/src/adapter/Runtime/PgMigrationEntryPoint.mjs +40 -0
  44. package/src/adapter/Runtime/PgQueryResolverEntryPoint.mjs +88 -0
  45. package/src/adapter/Runtime/ReadModelEntryPoint.mjs +17 -5
  46. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +10 -0
  47. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +3 -0
  48. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +9 -14
  49. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +2 -25
  50. package/src/adapter/Runtime/StateTopicPublish.mjs +204 -0
  51. package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +17 -3
  52. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res +86 -17
  53. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs +50 -33
  54. package/src/adapter/StateTopic/StateTopic_AppSync.res +7 -1
  55. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +10 -1
  56. package/src/plugin/cloner/ClonerRunner_Fargate.res +7 -1
  57. package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +10 -1
  58. package/src/util/Util_Bundle.res +71 -1
  59. package/src/util/Util_Bundle.res.mjs +49 -1
  60. package/tests/PgChangeFeedRelay_IntegrationTest.res +3 -3
  61. package/tests/PgConnectionJsonTest.res +84 -0
  62. package/tests/PgConnectionJsonTest.res.mjs +68 -0
  63. package/tests/PgMigrationEntryPoint_IntegrationTest.res +82 -0
  64. package/tests/PgMigrationEntryPoint_IntegrationTest.res.mjs +63 -0
  65. package/tests/PgPipeline_IntegrationTest.res +3 -1
  66. package/tests/PgQueryResolver_LambdaTest.res +163 -2
  67. package/tests/PgQueryResolver_LambdaTest.res.mjs +226 -14
  68. package/tests/StateTopicPublishTest.res +162 -0
  69. package/tests/StateTopicPublishTest.res.mjs +158 -0
  70. package/tests/Util_BundleEsmLoaderTest.res +57 -0
  71. package/tests/Util_BundleEsmLoaderTest.res.mjs +44 -0
@@ -0,0 +1,204 @@
1
+ // StateTopicPublish.mjs
2
+ // B3.3: Postgres live-update publisher — the projection-Lambda-side counterpart
3
+ // of the DynamoDB StateTopic Lambda (StateTopic_AppSync.res).
4
+ //
5
+ // On the DynamoDB path a shared Lambda reads the table's stream and POSTs a
6
+ // change descriptor to the AppSync Events API. Postgres read models have no
7
+ // stream, so (design (a), mirroring the reventless-local Sqlite/InMemory
8
+ // backends) the projection Lambda publishes the SAME descriptor itself, right
9
+ // after it writes the `qdb_<name>` row. The wire contract — channel layout,
10
+ // descriptor shape, SigV4 signing — is byte-identical to StateTopic_AppSync so
11
+ // the browser/AutoLive side cannot tell the two backends apart.
12
+ //
13
+ // Channel: /default/{pathSegment(topicName)}/{pathSegment(entityKey)}
14
+ // Descriptor: { changeKind: "Updated" | "Removed", id: <entityKey>,
15
+ // sortKeyValue?: <updatedAt | createdAt if present> }
16
+ //
17
+ // changeKind is fixed per operation (save→"Updated", delete→"Removed"): a
18
+ // Postgres upsert doesn't distinguish insert vs update, and the Sqlite/InMemory
19
+ // backends already collapse save→"Updated" (LocalBus.res:213) — the UI treats a
20
+ // first-seen "Updated" row as an insert, so no Added detection is needed.
21
+ //
22
+ // Publish failures are LOGGED AND SWALLOWED: the projection row is already
23
+ // committed when this runs, and the projection Lambda is at-least-once — throwing
24
+ // would re-run the projection and duplicate work. A dropped live notification
25
+ // only means the UI refreshes the list on its next load, not data loss.
26
+ //
27
+ // Uses native Node.js crypto + fetch with SigV4; credentials come from the
28
+ // Lambda execution role via the process.env AWS_* variables.
29
+
30
+ import { createHmac, createHash } from "node:crypto";
31
+
32
+ function sha256hex(data) {
33
+ return createHash("sha256").update(typeof data === "string" ? data : JSON.stringify(data)).digest("hex");
34
+ }
35
+ function hmacBuf(key, data) {
36
+ return createHmac("sha256", key).update(data).digest();
37
+ }
38
+
39
+ async function signedHeaders(host, path, body, region) {
40
+ const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
41
+ const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
42
+ const sessionToken = process.env.AWS_SESSION_TOKEN;
43
+ const now = new Date();
44
+ const amzDate = now.toISOString().replace(/[:\-]|\..../g, "").slice(0, 15) + "Z";
45
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, "");
46
+ const headers = { host, "x-amz-date": amzDate, ...(sessionToken ? { "x-amz-security-token": sessionToken } : {}) };
47
+ const canonH = Object.entries(headers).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}:${v}\n`).join("");
48
+ const signH = Object.keys(headers).sort().join(";");
49
+ const cr = ["POST", path, "", canonH, signH, sha256hex(body)].join("\n");
50
+ const scope = `${dateStamp}/${region}/appsync/aws4_request`;
51
+ const sts = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(cr)].join("\n");
52
+ const kDate = hmacBuf("AWS4" + secretAccessKey, dateStamp);
53
+ const kSigning = hmacBuf(hmacBuf(hmacBuf(kDate, region), "appsync"), "aws4_request");
54
+ const sig = createHmac("sha256", kSigning).update(sts).digest("hex");
55
+ return { ...headers, Authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signH}, Signature=${sig}` };
56
+ }
57
+
58
+ // AppSync Events channel segments allow only [A-Za-z0-9-]; anything else
59
+ // collapses to `-`. The descriptor body keeps the ORIGINAL entityKey so the UI
60
+ // can match it against GraphQL row ids. Same rule as StateTopic_AppSync and the
61
+ // UI's AutoLive.normalizeSegment.
62
+ function pathSegment(value) {
63
+ return String(value).replace(/[^A-Za-z0-9-]/g, "-");
64
+ }
65
+
66
+ // Pick the natural sort timestamp from the saved state (updatedAt preferred,
67
+ // createdAt fallback). Mirrors StateTopic_AppSync.pickSortKeyValue and
68
+ // LocalBus.pickSortKeyValue.
69
+ function pickSortKeyValue(state) {
70
+ if (state && typeof state === "object" && !Array.isArray(state)) {
71
+ if (typeof state.updatedAt === "string") return state.updatedAt;
72
+ if (typeof state.createdAt === "string") return state.createdAt;
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ // Build the descriptor + channel and POST to {endpoint}/event.
78
+ // - endpoint: base AppSync Events HTTP endpoint (no trailing /event).
79
+ // - region: AWS region for the SigV4 credential scope.
80
+ // - topicName: the plugin-prefixed query LIST field name (channel root).
81
+ // - entityKey: ORIGINAL entity key (id or `id-subKey`) — descriptor body + path.
82
+ // - changeKind: "Updated" (save) | "Removed" (delete).
83
+ // - sortKeyValue: optional; included when defined.
84
+ // - dedupeId: AppSync publish `id` (idempotency hint).
85
+ export async function publishStateChange({ endpoint, region, topicName, entityKey, changeKind, sortKeyValue, dedupeId }) {
86
+ if (!endpoint || !topicName) return; // not live-enabled — no-op
87
+ try {
88
+ const url = new URL(endpoint);
89
+ const channel = "/default/" + pathSegment(topicName) + "/" + pathSegment(entityKey);
90
+ const descriptor = { changeKind, id: entityKey };
91
+ if (sortKeyValue !== undefined) descriptor.sortKeyValue = sortKeyValue;
92
+ const body = JSON.stringify({
93
+ id: dedupeId || (topicName + ":" + entityKey + ":" + changeKind),
94
+ channel,
95
+ events: [JSON.stringify(descriptor)],
96
+ });
97
+ const auth = await signedHeaders(url.hostname, "/event", body, region || process.env.AWS_REGION || "eu-west-1");
98
+ const res = await fetch(endpoint + "/event", {
99
+ method: "POST",
100
+ headers: {
101
+ accept: "application/json, text/javascript",
102
+ "content-encoding": "amz-1.0",
103
+ "content-type": "application/json; charset=UTF-8",
104
+ ...auth,
105
+ },
106
+ body,
107
+ });
108
+ if (!res.ok) {
109
+ const txt = await res.text();
110
+ // Swallow (see module docstring): the row is already committed. Log
111
+ // structurally so a CloudWatch metric filter on
112
+ // "PG_STATE_TOPIC_PUBLISH_FAILED" can surface persistent breakage.
113
+ console.error(
114
+ "PG_STATE_TOPIC_PUBLISH_FAILED status=" + res.status +
115
+ " channel=" + channel +
116
+ " body=" + txt.slice(0, 500)
117
+ );
118
+ }
119
+ } catch (e) {
120
+ console.error("PG_STATE_TOPIC_PUBLISH_FAILED status=network err=" + (e && e.message));
121
+ }
122
+ }
123
+
124
+ // Compose the entity key from a save/delete's id + optional sub-key. Matches
125
+ // StateTopic_AppSync.entityKeyFromRecord and QueryDbStorage_Sqlite.entityKeyFor:
126
+ // single-key tables → id; composite tables → `id-subKeyValue`.
127
+ export function entityKeyFor(id, state, subIdField) {
128
+ if (subIdField === undefined || subIdField === null) return String(id);
129
+ const subValue =
130
+ state && typeof state === "object" && !Array.isArray(state) ? state[subIdField] : undefined;
131
+ return subValue === undefined || subValue === null
132
+ ? String(id)
133
+ : String(id) + "-" + String(subValue);
134
+ }
135
+
136
+ // Wrap a Postgres QueryDb operation set so every save/delete also publishes a
137
+ // live-update descriptor. `liveConfig` = { endpoint, region, topicName,
138
+ // subIdField }. When endpoint/topicName are absent the ops pass through
139
+ // unchanged (non-stream / non-live read models pay nothing).
140
+ //
141
+ // Note on batch ops: saveBatch/deleteBatch fan out one publish per item, after
142
+ // the batch write resolves — the row set is committed as a unit, and each row
143
+ // gets its own descriptor exactly as N single writes would.
144
+ export function withLiveUpdates(ops, liveConfig) {
145
+ const { endpoint, topicName, region, subIdField } = liveConfig || {};
146
+ if (!endpoint || !topicName) return ops;
147
+ // `publish` is injectable for headless testing (default = the real SigV4
148
+ // publisher); tests pass a capturing stub so the wrapping logic can be
149
+ // asserted without fetch/crypto.
150
+ const publish = (liveConfig && liveConfig.publish) || publishStateChange;
151
+
152
+ const publishSaved = async (id, state) =>
153
+ publish({
154
+ endpoint, region, topicName,
155
+ entityKey: entityKeyFor(id, state, subIdField),
156
+ changeKind: "Updated",
157
+ sortKeyValue: pickSortKeyValue(state),
158
+ });
159
+
160
+ const publishRemovedKey = async (entityKey) =>
161
+ publish({ endpoint, region, topicName, entityKey, changeKind: "Removed" });
162
+
163
+ // Resolve the entity keys a delete will remove — computed BEFORE the delete so
164
+ // a partition-wide delete on a composite table can still enumerate its rows
165
+ // (mirrors QueryDbStorage_Sqlite.delete's rowKeysForPartition). `subIdOpt` is
166
+ // the adapter's `option<(field, subValue)>`: undefined (partition-wide) or a
167
+ // `[field, subValue]` array (one specific row).
168
+ const removedKeysFor = async (id, subIdOpt) => {
169
+ if (subIdOpt) return [String(id) + "-" + String(subIdOpt[1])];
170
+ if (subIdField === undefined || subIdField === null) return [String(id)];
171
+ // Composite table, whole-partition delete (`Delete(id)` / `DeleteMany`):
172
+ // read the live rows so each removed sub-row gets its own descriptor,
173
+ // exactly as N DynamoDB stream REMOVE records would.
174
+ const res = await ops.load(id);
175
+ const rows = res && res.TAG === "Ok" ? res._0 : [];
176
+ return rows.map((row) => entityKeyFor(id, row, subIdField));
177
+ };
178
+
179
+ return {
180
+ ...ops,
181
+ save: async (id, state, saveMode, ttl) => {
182
+ const r = await ops.save(id, state, saveMode, ttl);
183
+ await publishSaved(id, state);
184
+ return r;
185
+ },
186
+ saveBatch: async (items) => {
187
+ const r = await ops.saveBatch(items);
188
+ await Promise.all(items.map(([id, state]) => publishSaved(id, state)));
189
+ return r;
190
+ },
191
+ delete: async (id, subIdOpt) => {
192
+ const keys = await removedKeysFor(id, subIdOpt);
193
+ const r = await ops.delete(id, subIdOpt);
194
+ await Promise.all(keys.map(publishRemovedKey));
195
+ return r;
196
+ },
197
+ deleteBatch: async (entries) => {
198
+ const keyGroups = await Promise.all(entries.map(([id, subIdOpt]) => removedKeysFor(id, subIdOpt)));
199
+ const r = await ops.deleteBatch(entries);
200
+ await Promise.all(keyGroups.flat().map(publishRemovedKey));
201
+ return r;
202
+ },
203
+ };
204
+ }
@@ -11,6 +11,7 @@ import { tag as requestContextTag } from "@reventlessdev/reventless-core/src/Req
11
11
  import { handleAction } from "@reventlessdev/reventless-core/src/Projection.res.mjs";
12
12
  import { load, loadStream, save, saveBatch, count, $$delete, deleteBatch } from "@reventlessdev/reventless-aws/src/adapter/QueryDb/QueryDbStorage_DynamoDb_Runtime.res.mjs";
13
13
  import { opsFor as pgQdbOpsFor } from "@reventlessdev/reventless-aws/src/adapter/QueryDb/QueryDbStorage_Postgres_Runtime.res.mjs";
14
+ import { withLiveUpdates } from "./StateTopicPublish.mjs";
14
15
  import { handleStreamEvent } from "@reventlessdev/reventless-aws/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream_Runtime.res.mjs";
15
16
 
16
17
  const dynamicImport = (specifier) => import('/var/task/node_modules/' + specifier);
@@ -44,12 +45,22 @@ function groupBySource(records) {
44
45
  // `pgConnection`, when present, selects the Postgres QueryDb runtime for this
45
46
  // slice's view table (`queryDbTableName` is then the slice spec name, the shared
46
47
  // `qdb_<name>` discriminator). Absent → the DynamoDB path is byte-identical.
47
- function buildJsonEventsHandler(specModule, projectionModule, queryDbTableName, pgConnection) {
48
+ function buildJsonEventsHandler(specModule, projectionModule, queryDbTableName, pgConnection, stateTopicName) {
48
49
  let queryDbOps;
49
50
  if (pgConnection) {
50
51
  const indexes = (specModule.config && specModule.config.indexes) || [];
51
52
  const subIdField = specModule.subIdConfig ? specModule.subIdConfig.subIdField : undefined;
52
- queryDbOps = pgQdbOpsFor(pgConnection, queryDbTableName, indexes, subIdField);
53
+ const pgOps = pgQdbOpsFor(pgConnection, queryDbTableName, indexes, subIdField);
54
+ // B3.3: subscription-enabled (Stream) Postgres view slice → publish a
55
+ // live-update descriptor after save/delete. `stateTopicName` + APPSYNC_ENDPOINT
56
+ // gate it; absent → withLiveUpdates passes pgOps through. The view slice's id
57
+ // is the save's first arg (no id injection), so no wrap-ordering concern.
58
+ queryDbOps = withLiveUpdates(pgOps, {
59
+ endpoint: process.env.APPSYNC_ENDPOINT,
60
+ region: process.env.AWS_REGION,
61
+ topicName: stateTopicName,
62
+ subIdField,
63
+ });
53
64
  } else {
54
65
  const table = makeTableRef(queryDbTableName);
55
66
  queryDbOps = {
@@ -105,6 +116,9 @@ function expandHandlers(config) {
105
116
  // Shared across a plugin's slices (all follow the platform backend toggle);
106
117
  // hoisted to the top level of the compressed config like base/sourceUrn.
107
118
  pgConnection: config.pgConnection,
119
+ // B3.3: per-slice AppSync Events channel root (list field name). Present
120
+ // only for subscription-enabled (Stream) Postgres view slices.
121
+ stateTopicName: h.t,
108
122
  };
109
123
  });
110
124
  }
@@ -118,7 +132,7 @@ async function buildAllHandlers() {
118
132
  dynamicImport(h.specModule),
119
133
  dynamicImport(h.projectionModule),
120
134
  ]);
121
- const jsonEventsHandler = buildJsonEventsHandler(specModule, projectionModule, h.queryDbTableName, h.pgConnection);
135
+ const jsonEventsHandler = buildJsonEventsHandler(specModule, projectionModule, h.queryDbTableName, h.pgConnection, h.stateTopicName);
122
136
  const streamHandler = (event, context) => handleStreamEvent(jsonEventsHandler, event, context);
123
137
  const existing = handlers[h.sourceUrn] || [];
124
138
  existing.push(streamHandler);
@@ -13,6 +13,9 @@ type handlerEntry = {
13
13
  projectionModule: string,
14
14
  queryDbTableName: string,
15
15
  sourceUrn: string,
16
+ // B3.3: AppSync Events channel root for a subscription-enabled Postgres view
17
+ // slice; "" when the slice isn't live-enabled (compact key `t`, omitted then).
18
+ stateTopicName: string,
16
19
  }
17
20
 
18
21
  // Longest common prefix of a set of strings. Used to factor the shared
@@ -40,6 +43,27 @@ type sliceInfo = {
40
43
 
41
44
  let sliceInfos: dict<sliceInfo> = Dict.make()
42
45
 
46
+ // B3.3: the AppSync Events API a Postgres-backed stream view slice publishes live
47
+ // updates to. Set once by `Platform.makePlatform` before plugins build (mirrors
48
+ // EventCollectorRuntime_Builder_Single). None → no live updates.
49
+ type eventsApiConfig = {
50
+ endpoint: Pulumi.Output.t<string>,
51
+ apiArn: Pulumi.Output.t<string>,
52
+ }
53
+ let eventsApiConfig: ref<option<eventsApiConfig>> = ref(None)
54
+ let setEventsApiConfig = (cfg: eventsApiConfig) => eventsApiConfig := Some(cfg)
55
+
56
+ // Channel root for a subscription-enabled Postgres view slice (the plural LIST
57
+ // field name, same source as the DynamoDB subscriptionInfraHook); "" when the
58
+ // slice isn't stream-routed or there's no events API.
59
+ let stateTopicNameFor = (name: string): string =>
60
+ QueryDbBackend.postgresStreamRegistry->Set.has(name) && eventsApiConfig.contents->Option.isSome
61
+ ? ReventlessCore.Plugin_Helpers.queryFieldNamesRegistry
62
+ ->Dict.get(name)
63
+ ->Option.map(qn => qn.listFieldName)
64
+ ->Option.getOr(name)
65
+ : ""
66
+
43
67
  let registerStateViewSlice = (
44
68
  ~name,
45
69
  ~specModulePath,
@@ -130,6 +154,9 @@ let buildLambda = (
130
154
  ~packageDirs,
131
155
  ~channelSpecs,
132
156
  ~feedQueue: option<PulumiAws.SQS.Queue.t>=None,
157
+ // B3.3: Some when any slice on this Lambda is a subscription-enabled Postgres
158
+ // view slice — arms the APPSYNC_ENDPOINT env + appsync:EventPublish IAM.
159
+ ~pgStreamConfig: option<eventsApiConfig>=None,
133
160
  ~memorySize=1024,
134
161
  ~timeout=30,
135
162
  ) => {
@@ -141,20 +168,9 @@ let buildLambda = (
141
168
  let qdbSelection = QueryDbBackend.get()
142
169
  let pgConnectionFragment = switch qdbSelection {
143
170
  | Some(sel) =>
144
- sel.connectionConfig->Pulumi.Output.apply(cc => {
145
- let pgConnectionJson =
146
- [
147
- ("host", cc.host->JSON.Encode.string),
148
- ("port", cc.port->Int.toFloat->JSON.Encode.float),
149
- ("database", cc.database->JSON.Encode.string),
150
- ("username", cc.username->JSON.Encode.string),
151
- ("secretArn", cc.secretArn->JSON.Encode.string),
152
- ]
153
- ->Dict.fromArray
154
- ->JSON.Encode.object
155
- ->JSON.stringify
156
- `,"pgConnection":${pgConnectionJson}`
157
- })
171
+ sel.connectionConfig->Pulumi.Output.apply(cc =>
172
+ `,"pgConnection":${cc->PgConnection.connectionConfigToJson->JSON.stringify}`
173
+ )
158
174
  | None => Pulumi.Output.make("")
159
175
  }
160
176
 
@@ -191,11 +207,16 @@ let buildLambda = (
191
207
  ->JSON.stringifyAny
192
208
  ->Option.getOr(`""`)
193
209
  let q = h.queryDbTableName->JSON.stringifyAny->Option.getOr(`""`)
210
+ // B3.3: compact key `t` = live-update channel root; omit when empty.
211
+ let t =
212
+ h.stateTopicName == ""
213
+ ? ""
214
+ : `,"t":${h.stateTopicName->JSON.Encode.string->JSON.stringify}`
194
215
  if sharedUrn != "" {
195
- `{"s":${s},"p":${p},"q":${q}}`
216
+ `{"s":${s},"p":${p},"q":${q}${t}}`
196
217
  } else {
197
218
  let u = h.sourceUrn->JSON.stringifyAny->Option.getOr(`""`)
198
- `{"s":${s},"p":${p},"q":${q},"u":${u}}`
219
+ `{"s":${s},"p":${p},"q":${q},"u":${u}${t}}`
199
220
  }
200
221
  })
201
222
  ->Array.join(",")
@@ -207,6 +228,13 @@ let buildLambda = (
207
228
  let envVars: dict<Pulumi.Input.t<string>> = Dict.make()
208
229
  envVars->Dict.set("HANDLER_CONFIG", handlerConfigOutput->Pulumi.Output.asInput)
209
230
 
231
+ // B3.3: give the projection Lambda the AppSync Events endpoint when any of its
232
+ // slices publish live updates (IAM added below).
233
+ switch pgStreamConfig {
234
+ | Some(cfg) => envVars->Dict.set("APPSYNC_ENDPOINT", cfg.endpoint->Pulumi.Output.asInput)
235
+ | None => ()
236
+ }
237
+
210
238
  // Bundle the framework packages alongside the entry point so the deployed
211
239
  // Lambda picks up local edits without waiting for a Lambda Layer rebuild
212
240
  // (the layer fetches @reventlessdev/reventless-* from GitHub Packages, not
@@ -286,6 +314,35 @@ let buildLambda = (
286
314
  | None => ()
287
315
  }
288
316
 
317
+ // B3.3: grant appsync:EventPublish so subscription-enabled Postgres view slices
318
+ // can push live-update descriptors from this Lambda.
319
+ switch pgStreamConfig {
320
+ | Some(cfg) =>
321
+ let _ = cfg.apiArn->Pulumi.Output.apply(apiArn => {
322
+ open PulumiAws.PolicyDocument
323
+ PulumiAws.IAM.RolePolicy.make(
324
+ ~name="AllStateViewSlices-appsyncPublish",
325
+ ~args={
326
+ policy: PulumiAws.PolicyDocument.make(
327
+ ~id="AllStateViewSlices-appsyncPublishPolicy",
328
+ ~statements=[
329
+ {
330
+ sid: "AllowPublishAppSyncEvents",
331
+ effect: Allow,
332
+ actions: Action("appsync:EventPublish"),
333
+ resources: Resource(apiArn ++ "/*"),
334
+ },
335
+ ],
336
+ )
337
+ ->PulumiAws.PolicyDocument.toJsonString
338
+ ->Pulumi.Input.make,
339
+ role: runtime.parts.lambdaRole.id->Pulumi.Output.asInput,
340
+ },
341
+ )
342
+ })
343
+ | None => ()
344
+ }
345
+
289
346
  let _connectResources = EventCollectorChannel.connect(
290
347
  ~name="AllStateViewSlices",
291
348
  ~channelSpecs,
@@ -347,7 +404,7 @@ let finishWithDcbEventLog = (dcbEventLog: ReventlessCore.DcbEventLog.component)
347
404
  let packageDirs: dict<string> = Dict.make()
348
405
  let allQueryDbResources: array<ReventlessInfra.Adapter.resource> = []
349
406
 
350
- sliceInfos->Dict.forEachWithKey((info, _name) => {
407
+ sliceInfos->Dict.forEachWithKey((info, name) => {
351
408
  info.queryDbResources->Array.forEach(r => allQueryDbResources->Array.push(r)->ignore)
352
409
  let specPkg = Util_Bundle.extractPackageName(info.specModulePath)
353
410
  packageDirs->Dict.set(specPkg, Util_Bundle.resolvePackageRoot(specPkg))
@@ -369,18 +426,24 @@ let finishWithDcbEventLog = (dcbEventLog: ReventlessCore.DcbEventLog.component)
369
426
  projectionModule: info.projectionModulePath,
370
427
  queryDbTableName: tableName,
371
428
  sourceUrn: urn,
429
+ stateTopicName: stateTopicNameFor(name),
372
430
  }
373
431
  entry
374
432
  })
375
433
  let _ = handlerOutputs->Array.push(handlerJson)
376
434
  })
377
435
 
436
+ let anyPgStream =
437
+ sliceInfos
438
+ ->Dict.keysToArray
439
+ ->Array.some(n => QueryDbBackend.postgresStreamRegistry->Set.has(n))
378
440
  buildLambda(
379
441
  ~parent,
380
442
  ~handlerOutputs,
381
443
  ~packageDirs,
382
444
  ~channelSpecs=[{channel: channel, eventTopics, resources: allQueryDbResources}],
383
445
  ~feedQueue,
446
+ ~pgStreamConfig=anyPgStream ? eventsApiConfig.contents : None,
384
447
  )
385
448
  | None =>
386
449
  log.warn(
@@ -445,6 +508,7 @@ let finish = () =>
445
508
  queryDbTableName: tableName,
446
509
  // No stream resource (Postgres) → dispatch on the feed queue ARN.
447
510
  sourceUrn: urns->Array.get(0)->Option.getOr(feedArn),
511
+ stateTopicName: stateTopicNameFor(spec.componentName),
448
512
  }
449
513
  entry
450
514
  })
@@ -457,12 +521,17 @@ let finish = () =>
457
521
  }
458
522
  })
459
523
 
524
+ let anyPgStream =
525
+ storedSpecs->Array.some(spec =>
526
+ QueryDbBackend.postgresStreamRegistry->Set.has(spec.componentName)
527
+ )
460
528
  buildLambda(
461
529
  ~parent,
462
530
  ~handlerOutputs,
463
531
  ~packageDirs,
464
532
  ~channelSpecs=storedSpecs->Array.map(({channelSpec}) => channelSpec),
465
533
  ~feedQueue,
534
+ ~pgStreamConfig=anyPgStream ? eventsApiConfig.contents : None,
466
535
  ~memorySize=maxMemorySize,
467
536
  ~timeout=maxTimeout,
468
537
  )
@@ -13,8 +13,10 @@ import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/co
13
13
  import * as DcbBackend$ReventlessAws from "../DcbEventLog/DcbBackend.res.mjs";
14
14
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
15
15
  import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
16
+ import * as PgConnection$ReventlessAws from "../Postgres/PgConnection.res.mjs";
16
17
  import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
17
18
  import * as QueryDbBackend$ReventlessAws from "../QueryDb/QueryDbBackend.res.mjs";
19
+ import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
18
20
  import * as PgProjectionFeed$ReventlessAws from "../Postgres/PgProjectionFeed.res.mjs";
19
21
  import * as RuntimeEnvironment_Lambda$ReventlessAws from "./RuntimeEnvironment_Lambda.res.mjs";
20
22
  import * as EventCollectorChannel_DynamoDbStream$ReventlessAws from "../EventCollector/EventCollectorChannel_DynamoDbStream.res.mjs";
@@ -38,6 +40,22 @@ function commonPrefix(strings) {
38
40
 
39
41
  let sliceInfos = {};
40
42
 
43
+ let eventsApiConfig = {
44
+ contents: undefined
45
+ };
46
+
47
+ function setEventsApiConfig(cfg) {
48
+ eventsApiConfig.contents = cfg;
49
+ }
50
+
51
+ function stateTopicNameFor(name) {
52
+ if (QueryDbBackend$ReventlessAws.postgresStreamRegistry.has(name) && Stdlib_Option.isSome(eventsApiConfig.contents)) {
53
+ return Stdlib_Option.getOr(Stdlib_Option.map(Plugin_Helpers$ReventlessCore.queryFieldNamesRegistry[name], qn => qn.listFieldName), name);
54
+ } else {
55
+ return "";
56
+ }
57
+ }
58
+
41
59
  function registerStateViewSlice(name, specModulePath, projectionModulePath, queryDbTableName, queryDbResourcesOpt) {
42
60
  let queryDbResources = queryDbResourcesOpt !== undefined ? queryDbResourcesOpt : [];
43
61
  sliceInfos[name] = {
@@ -88,8 +106,9 @@ let finished = {
88
106
  contents: false
89
107
  };
90
108
 
91
- function buildLambda(parent, handlerOutputs, packageDirs, channelSpecs, feedQueueOpt, memorySizeOpt, timeoutOpt) {
109
+ function buildLambda(parent, handlerOutputs, packageDirs, channelSpecs, feedQueueOpt, pgStreamConfigOpt, memorySizeOpt, timeoutOpt) {
92
110
  let feedQueue = feedQueueOpt !== undefined ? Primitive_option.valFromOption(feedQueueOpt) : undefined;
111
+ let pgStreamConfig = pgStreamConfigOpt !== undefined ? Primitive_option.valFromOption(pgStreamConfigOpt) : undefined;
93
112
  let memorySize = memorySizeOpt !== undefined ? memorySizeOpt : 1024;
94
113
  let timeout = timeoutOpt !== undefined ? timeoutOpt : 30;
95
114
  let opts_parent = parent;
@@ -97,31 +116,7 @@ function buildLambda(parent, handlerOutputs, packageDirs, channelSpecs, feedQueu
97
116
  parent: opts_parent
98
117
  };
99
118
  let qdbSelection = QueryDbBackend$ReventlessAws.get();
100
- let pgConnectionFragment = qdbSelection !== undefined ? qdbSelection.connectionConfig.apply(cc => {
101
- let pgConnectionJson = JSON.stringify(Object.fromEntries([
102
- [
103
- "host",
104
- cc.host
105
- ],
106
- [
107
- "port",
108
- cc.port
109
- ],
110
- [
111
- "database",
112
- cc.database
113
- ],
114
- [
115
- "username",
116
- cc.username
117
- ],
118
- [
119
- "secretArn",
120
- cc.secretArn
121
- ]
122
- ]));
123
- return `,"pgConnection":` + pgConnectionJson;
124
- }) : Pulumi.output("");
119
+ let pgConnectionFragment = qdbSelection !== undefined ? qdbSelection.connectionConfig.apply(cc => `,"pgConnection":` + JSON.stringify(PgConnection$ReventlessAws.connectionConfigToJson(undefined, cc))) : Pulumi.output("");
125
120
  let handlerConfigOutput = Pulumi.all([
126
121
  Pulumi.all(handlerOutputs),
127
122
  pgConnectionFragment
@@ -140,11 +135,12 @@ function buildLambda(parent, handlerOutputs, packageDirs, channelSpecs, feedQueu
140
135
  let s = Stdlib_Option.getOr(JSON.stringify(h.specModule.slice(baseLen, h.specModule.length)), `""`);
141
136
  let p = Stdlib_Option.getOr(JSON.stringify(h.projectionModule.slice(baseLen, h.projectionModule.length)), `""`);
142
137
  let q = Stdlib_Option.getOr(JSON.stringify(h.queryDbTableName), `""`);
138
+ let t = h.stateTopicName === "" ? "" : `,"t":` + JSON.stringify(h.stateTopicName);
143
139
  if (sharedUrn !== "") {
144
- return `{"s":` + s + `,"p":` + p + `,"q":` + q + `}`;
140
+ return `{"s":` + s + `,"p":` + p + `,"q":` + q + t + `}`;
145
141
  }
146
142
  let u = Stdlib_Option.getOr(JSON.stringify(h.sourceUrn), `""`);
147
- return `{"s":` + s + `,"p":` + p + `,"q":` + q + `,"u":` + u + `}`;
143
+ return `{"s":` + s + `,"p":` + p + `,"q":` + q + `,"u":` + u + t + `}`;
148
144
  }).join(",");
149
145
  let baseJson = Stdlib_Option.getOr(JSON.stringify(base), `""`);
150
146
  let urnJson = Stdlib_Option.getOr(JSON.stringify(sharedUrn), `""`);
@@ -152,6 +148,9 @@ function buildLambda(parent, handlerOutputs, packageDirs, channelSpecs, feedQueu
152
148
  });
153
149
  let envVars = {};
154
150
  envVars["HANDLER_CONFIG"] = handlerConfigOutput;
151
+ if (pgStreamConfig !== undefined) {
152
+ envVars["APPSYNC_ENDPOINT"] = pgStreamConfig.endpoint;
153
+ }
155
154
  packageDirs["@reventlessdev/reventless-aws"] = Util_Bundle$ReventlessAws.resolvePackageRoot("@reventlessdev/reventless-aws");
156
155
  packageDirs["@reventlessdev/reventless-core"] = Util_Bundle$ReventlessAws.resolvePackageRoot("@reventlessdev/reventless-core");
157
156
  let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Runtime/StateViewSliceEntryPoint.mjs", packageDirs, undefined);
@@ -171,6 +170,17 @@ function buildLambda(parent, handlerOutputs, packageDirs, channelSpecs, feedQueu
171
170
  role: runtime.parts.lambdaRole.id
172
171
  }));
173
172
  }
173
+ if (pgStreamConfig !== undefined) {
174
+ pgStreamConfig.apiArn.apply(apiArn => new (Aws.iam.RolePolicy)("AllStateViewSlices-appsyncPublish", {
175
+ policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, "AllStateViewSlices-appsyncPublishPolicy", [{
176
+ Sid: "AllowPublishAppSyncEvents",
177
+ Effect: "Allow",
178
+ Action: "appsync:EventPublish",
179
+ Resource: apiArn + "/*"
180
+ }])),
181
+ role: runtime.parts.lambdaRole.id
182
+ }));
183
+ }
174
184
  EventCollectorChannel_DynamoDbStream$ReventlessAws.connect("AllStateViewSlices", channelSpecs, runtime, opts);
175
185
  if (feedQueue !== undefined) {
176
186
  return PgProjectionFeed$ReventlessAws.connect("AllStateViewSlicesFeed", feedQueue, runtime.parts.lambda, runtime.parts.lambdaRole, Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts));
@@ -201,7 +211,7 @@ function finishWithDcbEventLog(dcbEventLog) {
201
211
  let handlerOutputs = [];
202
212
  let packageDirs = {};
203
213
  let allQueryDbResources = [];
204
- Stdlib_Dict.forEachWithKey(sliceInfos, (info, _name) => {
214
+ Stdlib_Dict.forEachWithKey(sliceInfos, (info, name) => {
205
215
  info.queryDbResources.forEach(r => {
206
216
  allQueryDbResources.push(r);
207
217
  });
@@ -219,15 +229,17 @@ function finishWithDcbEventLog(dcbEventLog) {
219
229
  specModule: info.specModulePath,
220
230
  projectionModule: info.projectionModulePath,
221
231
  queryDbTableName: param[0],
222
- sourceUrn: param[1]
232
+ sourceUrn: param[1],
233
+ stateTopicName: stateTopicNameFor(name)
223
234
  }));
224
235
  handlerOutputs.push(handlerJson);
225
236
  });
237
+ let anyPgStream = Object.keys(sliceInfos).some(n => QueryDbBackend$ReventlessAws.postgresStreamRegistry.has(n));
226
238
  buildLambda(parent, handlerOutputs, packageDirs, [{
227
239
  channel: channel,
228
240
  eventTopics: eventTopics,
229
241
  resources: allQueryDbResources
230
- }], Primitive_option.some(feedQueue), undefined, undefined);
242
+ }], Primitive_option.some(feedQueue), Primitive_option.some(anyPgStream ? eventsApiConfig.contents : undefined), undefined, undefined);
231
243
  } else {
232
244
  log.warn("StateViewSliceRuntime_Builder_Single", undefined, "finishWithDcbEventLog: DCB EventLog has no parent");
233
245
  }
@@ -273,11 +285,13 @@ function finish() {
273
285
  specModule: info.specModulePath,
274
286
  projectionModule: info.projectionModulePath,
275
287
  queryDbTableName: param[0],
276
- sourceUrn: Stdlib_Option.getOr(param[1][0], param[2])
288
+ sourceUrn: Stdlib_Option.getOr(param[1][0], param[2]),
289
+ stateTopicName: stateTopicNameFor(spec.componentName)
277
290
  }));
278
291
  handlerOutputs.push(handlerJson);
279
292
  });
280
- buildLambda(parent, handlerOutputs, packageDirs, storedSpecs.map(param => param.channelSpec), Primitive_option.some(feedQueue), match[0], match[1]);
293
+ let anyPgStream = storedSpecs.some(spec => QueryDbBackend$ReventlessAws.postgresStreamRegistry.has(spec.componentName));
294
+ buildLambda(parent, handlerOutputs, packageDirs, storedSpecs.map(param => param.channelSpec), Primitive_option.some(feedQueue), Primitive_option.some(anyPgStream ? eventsApiConfig.contents : undefined), match[0], match[1]);
281
295
  } else {
282
296
  log.warn("StateViewSliceRuntime_Builder_Single", undefined, `finish: grandParent not set`);
283
297
  }
@@ -295,6 +309,9 @@ export {
295
309
  log,
296
310
  commonPrefix,
297
311
  sliceInfos,
312
+ eventsApiConfig,
313
+ setEventsApiConfig,
314
+ stateTopicNameFor,
298
315
  registerStateViewSlice,
299
316
  storedSpecs,
300
317
  grandParent,
@@ -354,8 +354,12 @@ let finish = (
354
354
  "index.mjs",
355
355
  Pulumi.Asset.stringAsset(handlerCode)->Pulumi.Archive.assetToAssetOrArchive,
356
356
  )
357
+ // ESM self-containment: the handler imports @aws-sdk/util-dynamodb, provided
358
+ // by the nodejs22.x runtime only under /var/runtime — unreachable from
359
+ // /var/task ESM without the resolver hook. Ship the loader + set its env vars.
360
+ let loaderHash = Util_Bundle.addEsmLoaderAssets(archiveContents)
357
361
  let code = Pulumi.Archive.assetArchive(archiveContents)
358
- let sourceCodeHash = Util_Bundle.hashString(handlerCode)
362
+ let sourceCodeHash = Util_Bundle.hashString(handlerCode ++ "\n---\n" ++ loaderHash)
359
363
 
360
364
  let layers =
361
365
  Lambda.reventlessLayerArn
@@ -383,6 +387,8 @@ let finish = (
383
387
  ("Environment", Pulumi.Pulumi.getStackName()->Pulumi.Input.make),
384
388
  ("APPSYNC_ENDPOINT", appsyncEndpoint->Pulumi.Output.asInput),
385
389
  ("STATE_TOPIC_MAP", topicMapJson->Pulumi.Output.asInput),
390
+ ("NODE_OPTIONS", Util_Bundle.esmLoaderNodeOptions->Pulumi.Input.make),
391
+ ("ESM_FALLBACK_DIRS", Util_Bundle.esmFallbackDirs->Pulumi.Input.make),
386
392
  ]),
387
393
  }: Lambda.Function.functionEnvironment
388
394
  )->Pulumi.Input.make,