@reventlessdev/reventless-aws 3.0.0-alpha.177 → 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 (68) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/docker-compose.postgres.yml +21 -0
  3. package/package.json +6 -5
  4. package/scripts/run-pg-integration-tests.sh +58 -0
  5. package/src/Platform.res +37 -0
  6. package/src/Platform.res.mjs +41 -4
  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 +11 -13
  23. package/src/adapter/QueryDb/PgQueryResolver_Builder.res.mjs +5 -24
  24. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +51 -3
  25. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +48 -18
  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.mjs +1 -1
  29. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res +31 -7
  30. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res.mjs +16 -10
  31. package/src/adapter/QueryDb/QueryDbStorage.res +6 -1
  32. package/src/adapter/QueryDb/QueryDbStorage.res.mjs +2 -1
  33. package/src/adapter/QueryDb/QueryDbStorage_Postgres.res.mjs +1 -1
  34. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +3 -14
  35. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +2 -25
  36. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +3 -14
  37. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +2 -25
  38. package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +7 -1
  39. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +78 -15
  40. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res.mjs +43 -26
  41. package/src/adapter/Runtime/PgMigrationEntryPoint.mjs +40 -0
  42. package/src/adapter/Runtime/ReadModelEntryPoint.mjs +17 -5
  43. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +10 -0
  44. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +3 -0
  45. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +9 -14
  46. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +2 -25
  47. package/src/adapter/Runtime/StateTopicPublish.mjs +204 -0
  48. package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +17 -3
  49. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res +86 -17
  50. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs +50 -33
  51. package/src/adapter/StateTopic/StateTopic_AppSync.res +7 -1
  52. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +10 -1
  53. package/src/plugin/cloner/ClonerRunner_Fargate.res +7 -1
  54. package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +10 -1
  55. package/src/util/Util_Bundle.res +71 -1
  56. package/src/util/Util_Bundle.res.mjs +49 -1
  57. package/tests/PgChangeFeedRelay_IntegrationTest.res +3 -3
  58. package/tests/PgConnectionJsonTest.res +84 -0
  59. package/tests/PgConnectionJsonTest.res.mjs +68 -0
  60. package/tests/PgMigrationEntryPoint_IntegrationTest.res +82 -0
  61. package/tests/PgMigrationEntryPoint_IntegrationTest.res.mjs +63 -0
  62. package/tests/PgPipeline_IntegrationTest.res +3 -1
  63. package/tests/PgQueryResolver_LambdaTest.res +59 -0
  64. package/tests/PgQueryResolver_LambdaTest.res.mjs +95 -16
  65. package/tests/StateTopicPublishTest.res +162 -0
  66. package/tests/StateTopicPublishTest.res.mjs +158 -0
  67. package/tests/Util_BundleEsmLoaderTest.res +57 -0
  68. package/tests/Util_BundleEsmLoaderTest.res.mjs +44 -0
@@ -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,
@@ -243,8 +243,9 @@ function finish(eventsApi, opts) {
243
243
  });
244
244
  let archiveContents = {};
245
245
  archiveContents["index.mjs"] = new (Pulumi.asset.StringAsset)(handlerCode);
246
+ let loaderHash = Util_Bundle$ReventlessAws.addEsmLoaderAssets(archiveContents);
246
247
  let code = new (Pulumi.asset.AssetArchive)(archiveContents);
247
- let sourceCodeHash = Util_Bundle$ReventlessAws.hashString(handlerCode);
248
+ let sourceCodeHash = Util_Bundle$ReventlessAws.hashString(handlerCode + "\n---\n" + loaderHash);
248
249
  let layers = Stdlib_Option.getOr(Stdlib_Option.map(Lambda$PulumiAws.reventlessLayerArn, arn => [arn]), []);
249
250
  let appsyncEndpoint = AppSync_EventsApi$ReventlessAws.httpEndpoint(eventsApi);
250
251
  let lambda = new (Aws.lambda.Function)(name + "StateTopicPublisher", {
@@ -269,6 +270,14 @@ function finish(eventsApi, opts) {
269
270
  [
270
271
  "STATE_TOPIC_MAP",
271
272
  topicMapJson
273
+ ],
274
+ [
275
+ "NODE_OPTIONS",
276
+ Util_Bundle$ReventlessAws.esmLoaderNodeOptions
277
+ ],
278
+ [
279
+ "ESM_FALLBACK_DIRS",
280
+ Util_Bundle$ReventlessAws.esmFallbackDirs
272
281
  ]
273
282
  ])
274
283
  },
@@ -116,8 +116,12 @@ export const handler = async (event) => {
116
116
  "index.mjs",
117
117
  Pulumi.Asset.stringAsset(entryPointCode)->Pulumi.Archive.assetToAssetOrArchive,
118
118
  )
119
+ // ESM self-containment: the handler imports @aws-sdk/client-ecs, provided by the
120
+ // nodejs22.x runtime only under /var/runtime — unreachable from /var/task ESM
121
+ // without the resolver hook. Ship the loader + set its env vars on the Lambda.
122
+ let loaderHash = Util_Bundle.addEsmLoaderAssets(clonerArchiveContents)
119
123
  let code = Pulumi.Archive.assetArchive(clonerArchiveContents)
120
- let sourceCodeHash = Util_Bundle.hashString(entryPointCode)
124
+ let sourceCodeHash = Util_Bundle.hashString(entryPointCode ++ "\n---\n" ++ loaderHash)
121
125
 
122
126
  let layers =
123
127
  Lambda.reventlessLayerArn
@@ -231,6 +235,8 @@ export const handler = async (event) => {
231
235
  ->Pulumi.Input.make,
232
236
  ),
233
237
  ("Environment", Pulumi.Pulumi.getStackName()->Pulumi.Input.make),
238
+ ("NODE_OPTIONS", Util_Bundle.esmLoaderNodeOptions->Pulumi.Input.make),
239
+ ("ESM_FALLBACK_DIRS", Util_Bundle.esmFallbackDirs->Pulumi.Input.make),
234
240
  ]),
235
241
  }: Lambda.Function.functionEnvironment
236
242
  )->Pulumi.Input.make,
@@ -81,8 +81,9 @@ export const handler = async (event) => {
81
81
  };`;
82
82
  let clonerArchiveContents = {};
83
83
  clonerArchiveContents["index.mjs"] = new (Pulumi.asset.StringAsset)(entryPointCode);
84
+ let loaderHash = Util_Bundle$ReventlessAws.addEsmLoaderAssets(clonerArchiveContents);
84
85
  let code = new (Pulumi.asset.AssetArchive)(clonerArchiveContents);
85
- let sourceCodeHash = Util_Bundle$ReventlessAws.hashString(entryPointCode);
86
+ let sourceCodeHash = Util_Bundle$ReventlessAws.hashString(entryPointCode + "\n---\n" + loaderHash);
86
87
  let layers = Stdlib_Option.getOr(Stdlib_Option.map(Lambda$PulumiAws.reventlessLayerArn, arn => [arn]), []);
87
88
  let vpcStackName = Stdlib_Option.getOrThrow(new Pulumi.Config("vpc").get("stack"), undefined);
88
89
  let vpcConfig = Util_Vpc$ReventlessAws.getVpcConfig(vpcStackName, "vpc");
@@ -168,6 +169,14 @@ export const handler = async (event) => {
168
169
  [
169
170
  "Environment",
170
171
  Pulumi.getStack()
172
+ ],
173
+ [
174
+ "NODE_OPTIONS",
175
+ Util_Bundle$ReventlessAws.esmLoaderNodeOptions
176
+ ],
177
+ [
178
+ "ESM_FALLBACK_DIRS",
179
+ Util_Bundle$ReventlessAws.esmFallbackDirs
171
180
  ]
172
181
  ])
173
182
  },
@@ -112,6 +112,72 @@ let resolvePackageRoot = (packageName: string): string => {
112
112
  let hashString = (str: string): string =>
113
113
  createHash("sha256")->update(str)->digest("base64")
114
114
 
115
+ // ── ESM self-containment loader (Option C) ──────────────────────────────────
116
+ // Deployed Lambda entry points are ESM (`.mjs`) and statically/dynamically import
117
+ // bare specifiers (`@reventlessdev/*`, `effect`, `sury`, `@aws-sdk/*`) that live in
118
+ // the attached layer at /opt/nodejs/node_modules or the runtime SDK dir. ESM
119
+ // `import` ignores NODE_PATH and /opt, so those imports fail from /var/task with
120
+ // ERR_MODULE_NOT_FOUND. These two files install a `resolve` hook that, on an
121
+ // unresolved bare specifier, retries against each dir named in ESM_FALLBACK_DIRS
122
+ // (set on the Lambda env by RuntimeEnvironment_Lambda.makeFromCodeAsset). Shipped
123
+ // at the root of every code archive; NODE_OPTIONS=--import points Node at
124
+ // register-hook.mjs. Validated on a Node 22.17.1 rig; see
125
+ // docs/plans/deployed-lambda-esm-self-containment.md.
126
+ let registerHookFileName = "register-hook.mjs"
127
+ let layerResolverFileName = "layer-resolver.mjs"
128
+
129
+ let registerHookSource = `import { register } from "node:module";
130
+ register("./${layerResolverFileName}", import.meta.url);
131
+ `
132
+
133
+ let layerResolverSource = `// Fallback dirs (real Lambda: /opt/nodejs for the layer, /var/runtime for the SDK).
134
+ // Passed via env so the same hook is testable locally.
135
+ const FALLBACKS = (process.env.ESM_FALLBACK_DIRS || "").split(":").filter(Boolean);
136
+ export async function resolve(specifier, context, nextResolve) {
137
+ try {
138
+ return await nextResolve(specifier, context);
139
+ } catch (err) {
140
+ const bare = !/^[./]|^file:|^node:/.test(specifier);
141
+ if (err?.code !== "ERR_MODULE_NOT_FOUND" || !bare) throw err;
142
+ for (const dir of FALLBACKS) {
143
+ try {
144
+ // Re-resolve as if imported from a virtual file inside the fallback dir,
145
+ // so Node's own node_modules walk finds it (honours package.json exports).
146
+ const parentURL = new URL("file://" + dir + "/__resolver__.mjs").href;
147
+ return await nextResolve(specifier, { ...context, parentURL });
148
+ } catch { /* try next fallback */ }
149
+ }
150
+ throw err;
151
+ }
152
+ }
153
+ `
154
+
155
+ // Env vars that activate the loader. Any Lambda whose archive includes the loader
156
+ // files (via addEsmLoaderAssets / buildCodeArchive) MUST also set these two; any
157
+ // that does NOT ship the loader MUST NOT set NODE_OPTIONS (it would --import a
158
+ // missing file and fail cold start). --import registers the resolve hook;
159
+ // ESM_FALLBACK_DIRS names the layer (/opt/nodejs) and runtime-SDK (/var/runtime)
160
+ // node_modules dirs the hook retries against for @aws-sdk/*.
161
+ let esmLoaderNodeOptions = `--import file:///var/task/${registerHookFileName}`
162
+ let esmFallbackDirs = "/opt/nodejs/node_modules:/var/runtime/node_modules"
163
+
164
+ /**
165
+ * Add the ESM self-containment loader files to an archive-contents dict and return
166
+ * a hash fragment so callers can fold them into their sourceCodeHash. Every code
167
+ * archive whose Lambda sets the esmLoader env vars MUST include these files.
168
+ */
169
+ let addEsmLoaderAssets = (archiveContents: dict<Pulumi.Archive.assetOrArchive>): string => {
170
+ archiveContents->Dict.set(
171
+ registerHookFileName,
172
+ Pulumi.Asset.stringAsset(registerHookSource)->Pulumi.Archive.assetToAssetOrArchive,
173
+ )
174
+ archiveContents->Dict.set(
175
+ layerResolverFileName,
176
+ Pulumi.Asset.stringAsset(layerResolverSource)->Pulumi.Archive.assetToAssetOrArchive,
177
+ )
178
+ hashString(registerHookSource ++ "\n---\n" ++ layerResolverSource)
179
+ }
180
+
115
181
  let isSkippedDir = (n: string) =>
116
182
  n == "node_modules" ||
117
183
  n == "lib" ||
@@ -207,6 +273,8 @@ let buildCodeArchive = (
207
273
  "index.mjs",
208
274
  Pulumi.Asset.stringAsset(reExportCode)->Pulumi.Archive.assetToAssetOrArchive,
209
275
  )
276
+ // ESM self-containment loader — shipped in every archive (see addEsmLoaderAssets).
277
+ let loaderHash = addEsmLoaderAssets(archiveContents)
210
278
  extraStringAssets->Dict.forEachWithKey((content, fileName) => {
211
279
  archiveContents->Dict.set(
212
280
  fileName,
@@ -249,7 +317,9 @@ let buildCodeArchive = (
249
317
  "\n---\n" ++
250
318
  packageContentHashes.contents->Array.join(",") ++
251
319
  "\n---\n" ++
252
- extraHashEntries->Array.join("\n"),
320
+ extraHashEntries->Array.join("\n") ++
321
+ "\n---\n" ++
322
+ loaderHash,
253
323
  )
254
324
  {code, sourceCodeHash}
255
325
  }
@@ -78,6 +78,44 @@ function hashString(str) {
78
78
  return Crypto.createHash("sha256").update(str).digest("base64");
79
79
  }
80
80
 
81
+ let registerHookFileName = "register-hook.mjs";
82
+
83
+ let layerResolverFileName = "layer-resolver.mjs";
84
+
85
+ let registerHookSource = `import { register } from "node:module";
86
+ register("./` + layerResolverFileName + `", import.meta.url);
87
+ `;
88
+
89
+ let layerResolverSource = `// Fallback dirs (real Lambda: /opt/nodejs for the layer, /var/runtime for the SDK).
90
+ // Passed via env so the same hook is testable locally.
91
+ const FALLBACKS = (process.env.ESM_FALLBACK_DIRS || "").split(":").filter(Boolean);
92
+ export async function resolve(specifier, context, nextResolve) {
93
+ try {
94
+ return await nextResolve(specifier, context);
95
+ } catch (err) {
96
+ const bare = !/^[./]|^file:|^node:/.test(specifier);
97
+ if (err?.code !== "ERR_MODULE_NOT_FOUND" || !bare) throw err;
98
+ for (const dir of FALLBACKS) {
99
+ try {
100
+ // Re-resolve as if imported from a virtual file inside the fallback dir,
101
+ // so Node's own node_modules walk finds it (honours package.json exports).
102
+ const parentURL = new URL("file://" + dir + "/__resolver__.mjs").href;
103
+ return await nextResolve(specifier, { ...context, parentURL });
104
+ } catch { /* try next fallback */ }
105
+ }
106
+ throw err;
107
+ }
108
+ }
109
+ `;
110
+
111
+ let esmLoaderNodeOptions = `--import file:///var/task/` + registerHookFileName;
112
+
113
+ function addEsmLoaderAssets(archiveContents) {
114
+ archiveContents[registerHookFileName] = new (Pulumi.asset.StringAsset)(registerHookSource);
115
+ archiveContents[layerResolverFileName] = new (Pulumi.asset.StringAsset)(layerResolverSource);
116
+ return hashString(registerHookSource + "\n---\n" + layerResolverSource);
117
+ }
118
+
81
119
  function isSkippedDir(n) {
82
120
  if (n === "node_modules" || n === "lib" || n === "cjs" || n === "dts" || n === "tests" || n === "test" || n === "__mocks__" || n === "__tests__" || n === ".git") {
83
121
  return true;
@@ -129,6 +167,7 @@ function buildCodeArchive(entryPointModule, packageDirs, extraStringAssetsOpt) {
129
167
  let reExportCode = `export { handler } from "` + entryPointModule + `";`;
130
168
  let archiveContents = {};
131
169
  archiveContents["index.mjs"] = new (Pulumi.asset.StringAsset)(reExportCode);
170
+ let loaderHash = addEsmLoaderAssets(archiveContents);
132
171
  Stdlib_Dict.forEachWithKey(extraStringAssets, (content, fileName) => {
133
172
  archiveContents[fileName] = new (Pulumi.asset.StringAsset)(content);
134
173
  });
@@ -157,13 +196,15 @@ function buildCodeArchive(entryPointModule, packageDirs, extraStringAssetsOpt) {
157
196
  });
158
197
  let code = new (Pulumi.asset.AssetArchive)(archiveContents);
159
198
  packageContentHashes.contents.sort(Primitive_string.compare);
160
- let sourceCodeHash = hashString(reExportCode + "\n---\n" + packageContentHashes.contents.join(",") + "\n---\n" + extraHashEntries.join("\n"));
199
+ let sourceCodeHash = hashString(reExportCode + "\n---\n" + packageContentHashes.contents.join(",") + "\n---\n" + extraHashEntries.join("\n") + "\n---\n" + loaderHash);
161
200
  return {
162
201
  code: code,
163
202
  sourceCodeHash: sourceCodeHash
164
203
  };
165
204
  }
166
205
 
206
+ let esmFallbackDirs = "/opt/nodejs/node_modules:/var/runtime/node_modules";
207
+
167
208
  export {
168
209
  packageRootCache,
169
210
  localRequire,
@@ -171,6 +212,13 @@ export {
171
212
  extractPackageName,
172
213
  resolvePackageRoot,
173
214
  hashString,
215
+ registerHookFileName,
216
+ layerResolverFileName,
217
+ registerHookSource,
218
+ layerResolverSource,
219
+ esmLoaderNodeOptions,
220
+ esmFallbackDirs,
221
+ addEsmLoaderAssets,
174
222
  isSkippedDir,
175
223
  walkDir,
176
224
  createFilteredPackageArchive,
@@ -1,8 +1,8 @@
1
1
  // Live-Postgres integration suite for the change-feed relay (B2.4).
2
2
  //
3
- // Skipped unless PG_URL is set, so the default `pnpm test` stays dependency-free.
4
- // Run against a real database with e.g.:
5
- // PG_URL=postgres://postgres:postgres@localhost:5432/postgres pnpm test
3
+ // Run via `pnpm run test:integration:pg` (boots a Postgres sidecar, runs the PG
4
+ // suites serially, tears down). Excluded from the default parallel `pnpm test`;
5
+ // self-skips unless PG_URL is set (the script exports it).
6
6
  //
7
7
  // Exercises the deployed propagation path end-to-end minus AWS: DCB events are
8
8
  // appended to a real `dcb_event` log (classic events to `event_log`), then