@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
@@ -201,20 +201,9 @@ let finish = () =>
201
201
  let pgSelection = EventLogBackend.get()
202
202
  let pgConnectionFragment = switch pgSelection {
203
203
  | Some(sel) =>
204
- sel.connectionConfig->Pulumi.Output.apply(cc => {
205
- let pgConnectionJson =
206
- [
207
- ("host", cc.host->JSON.Encode.string),
208
- ("port", cc.port->Int.toFloat->JSON.Encode.float),
209
- ("database", cc.database->JSON.Encode.string),
210
- ("username", cc.username->JSON.Encode.string),
211
- ("secretArn", cc.secretArn->JSON.Encode.string),
212
- ]
213
- ->Dict.fromArray
214
- ->JSON.Encode.object
215
- ->JSON.stringify
216
- `,"pgConnection":${pgConnectionJson}`
217
- })
204
+ sel.connectionConfig->Pulumi.Output.apply(cc =>
205
+ `,"pgConnection":${cc->PgConnection.connectionConfigToJson->JSON.stringify}`
206
+ )
218
207
  | None => Pulumi.Output.make("")
219
208
  }
220
209
 
@@ -10,6 +10,7 @@ import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/
10
10
  import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
11
11
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
12
12
  import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
13
+ import * as PgConnection$ReventlessAws from "../Postgres/PgConnection.res.mjs";
13
14
  import * as EventLogBackend$ReventlessAws from "../EventLog/EventLogBackend.res.mjs";
14
15
  import * as RuntimeEnvironment_Lambda$ReventlessAws from "./RuntimeEnvironment_Lambda.res.mjs";
15
16
  import * as CommandTopicChannel_SQS_Async$ReventlessAws from "../CommandTopic/CommandTopicChannel_SQS_Async.res.mjs";
@@ -148,31 +149,7 @@ function finish() {
148
149
  let handlerOutputs = [];
149
150
  let packageDirs = {};
150
151
  let pgSelection = EventLogBackend$ReventlessAws.get();
151
- let pgConnectionFragment = pgSelection !== undefined ? pgSelection.connectionConfig.apply(cc => {
152
- let pgConnectionJson = JSON.stringify(Object.fromEntries([
153
- [
154
- "host",
155
- cc.host
156
- ],
157
- [
158
- "port",
159
- cc.port
160
- ],
161
- [
162
- "database",
163
- cc.database
164
- ],
165
- [
166
- "username",
167
- cc.username
168
- ],
169
- [
170
- "secretArn",
171
- cc.secretArn
172
- ]
173
- ]));
174
- return `,"pgConnection":` + pgConnectionJson;
175
- }) : Pulumi.output("");
152
+ let pgConnectionFragment = pgSelection !== undefined ? pgSelection.connectionConfig.apply(cc => `,"pgConnection":` + JSON.stringify(PgConnection$ReventlessAws.connectionConfigToJson(undefined, cc))) : Pulumi.output("");
176
153
  specs.forEach(spec => {
177
154
  let info = aggregateInfos[spec.aggregateName];
178
155
  if (info === undefined) {
@@ -99,7 +99,13 @@ export async function buildHandlersForConfig(config, opts = {}) {
99
99
  // (see docs/plans/aws-postgres-change-feed-bridge.md), not by this Lambda.
100
100
  const rawStorageOps = config.pgConnection
101
101
  ? (() => {
102
- const pgOps = pgDcbEventLogOpsFor(config.pgConnection, config.dcbEventLogTableName);
102
+ // C2: `lockStrategy` ("AdvisoryLocks" | "RowLocks") is the polyvariant's
103
+ // string name; `opsFor` defaults to "AdvisoryLocks" when it's absent.
104
+ const pgOps = pgDcbEventLogOpsFor(
105
+ config.pgConnection,
106
+ config.dcbEventLogTableName,
107
+ config.pgConnection.lockStrategy,
108
+ );
103
109
  return { read: pgOps.read, append: pgOps.append, readStream: pgOps.readStream };
104
110
  })()
105
111
  : (() => {
@@ -18,6 +18,18 @@ type readModelInfo = {
18
18
 
19
19
  let readModelInfos: dict<readModelInfo> = Dict.make()
20
20
 
21
+ // B3.3: the AppSync Events API a Postgres-backed stream read model publishes live
22
+ // updates to. Set once by `Platform.makePlatform` (monolithic mode) before the
23
+ // plugins build, so `finish` can add the APPSYNC_ENDPOINT env + appsync:EventPublish
24
+ // IAM to the projection Lambda. None → no live updates (no events API, or the
25
+ // platform runs without one).
26
+ type eventsApiConfig = {
27
+ endpoint: Pulumi.Output.t<string>,
28
+ apiArn: Pulumi.Output.t<string>,
29
+ }
30
+ let eventsApiConfig: ref<option<eventsApiConfig>> = ref(None)
31
+ let setEventsApiConfig = (cfg: eventsApiConfig) => eventsApiConfig := Some(cfg)
32
+
21
33
  let registerReadModel = (
22
34
  ~readModelName,
23
35
  ~specModulePath,
@@ -147,20 +159,9 @@ let finish = () =>
147
159
  readModelInfos->Dict.valuesToArray->Array.some(info => info.pgBacked)
148
160
  let pgConnectionFragment = switch (qdbSelection, anyPgBacked) {
149
161
  | (Some(sel), true) =>
150
- sel.connectionConfig->Pulumi.Output.apply(cc => {
151
- let pgConnectionJson =
152
- [
153
- ("host", cc.host->JSON.Encode.string),
154
- ("port", cc.port->Int.toFloat->JSON.Encode.float),
155
- ("database", cc.database->JSON.Encode.string),
156
- ("username", cc.username->JSON.Encode.string),
157
- ("secretArn", cc.secretArn->JSON.Encode.string),
158
- ]
159
- ->Dict.fromArray
160
- ->JSON.Encode.object
161
- ->JSON.stringify
162
- `,"pgConnection":${pgConnectionJson}`
163
- })
162
+ sel.connectionConfig->Pulumi.Output.apply(cc =>
163
+ `,"pgConnection":${cc->PgConnection.connectionConfigToJson->JSON.stringify}`
164
+ )
164
165
  | _ => Pulumi.Output.make("")
165
166
  }
166
167
 
@@ -192,6 +193,23 @@ let finish = () =>
192
193
  let handlerPgFragment = info.pgBacked
193
194
  ? pgConnectionFragment
194
195
  : Pulumi.Output.make("")
196
+ // B3.3: a subscription-enabled Postgres read model publishes live
197
+ // updates from the projection Lambda — bake its AppSync Events channel
198
+ // root (the plural LIST field name, same source as the DynamoDB
199
+ // subscriptionInfraHook). Deploy-time resolved, so a plain string.
200
+ let stateTopicFragment =
201
+ info.pgBacked &&
202
+ QueryDbBackend.postgresStreamRegistry->Set.has(spec.componentName) &&
203
+ eventsApiConfig.contents->Option.isSome
204
+ ? {
205
+ let topic =
206
+ ReventlessCore.Plugin_Helpers.queryFieldNamesRegistry
207
+ ->Dict.get(spec.componentName)
208
+ ->Option.map(qn => qn.listFieldName)
209
+ ->Option.getOr(spec.componentName)
210
+ `,"stateTopicName":${topic->JSON.Encode.string->JSON.stringify}`
211
+ }
212
+ : ""
195
213
  let handlerJson =
196
214
  Pulumi.Output.all4((
197
215
  info.queryDbTableName,
@@ -203,7 +221,7 @@ let finish = () =>
203
221
  // No stream resource (Postgres-backed source) → dispatch on the
204
222
  // feed queue's ARN instead of a stream URN.
205
223
  let sourceUrn = urns->Array.get(0)->Option.getOr(feedArn)
206
- `{"specModule":${specModule},"mappingsModule":${mappingsModule},"queryDbTableName":"${tableName}","sourceUrn":"${sourceUrn}"${pgFragment}}`
224
+ `{"specModule":${specModule},"mappingsModule":${mappingsModule},"queryDbTableName":"${tableName}","sourceUrn":"${sourceUrn}"${pgFragment}${stateTopicFragment}}`
207
225
  })
208
226
  let _ = handlerOutputs->Array.push(handlerJson)
209
227
  | None =>
@@ -223,6 +241,22 @@ let finish = () =>
223
241
  let envVars: dict<Pulumi.Input.t<string>> = Dict.make()
224
242
  envVars->Dict.set("HANDLER_CONFIG", handlerConfigOutput->Pulumi.Output.asInput)
225
243
 
244
+ // B3.3: when any Postgres-backed read model is subscription-enabled, the
245
+ // projection Lambda publishes live updates to the AppSync Events API —
246
+ // give it the endpoint (env) and appsync:EventPublish (IAM, below).
247
+ let anyPgStream =
248
+ readModelInfos
249
+ ->Dict.keysToArray
250
+ ->Array.some(rmName =>
251
+ (readModelInfos->Dict.get(rmName)->Option.mapOr(false, i => i.pgBacked)) &&
252
+ QueryDbBackend.postgresStreamRegistry->Set.has(rmName)
253
+ )
254
+ let pgStreamConfig = anyPgStream ? eventsApiConfig.contents : None
255
+ switch pgStreamConfig {
256
+ | Some(cfg) => envVars->Dict.set("APPSYNC_ENDPOINT", cfg.endpoint->Pulumi.Output.asInput)
257
+ | None => ()
258
+ }
259
+
226
260
  let {code, sourceCodeHash} = Util_Bundle.buildCodeArchive(
227
261
  ~entryPointModule="@reventlessdev/reventless-aws/src/adapter/Runtime/ReadModelEntryPoint.mjs",
228
262
  ~packageDirs,
@@ -287,6 +321,35 @@ let finish = () =>
287
321
  | _ => ()
288
322
  }
289
323
 
324
+ // B3.3: grant the projection Lambda appsync:EventPublish on the events API
325
+ // so a Postgres-backed stream read model can push live-update descriptors.
326
+ switch pgStreamConfig {
327
+ | Some(cfg) =>
328
+ let _ = cfg.apiArn->Pulumi.Output.apply(apiArn => {
329
+ open PulumiAws.PolicyDocument
330
+ PulumiAws.IAM.RolePolicy.make(
331
+ ~name="AllReadModels-appsyncPublish",
332
+ ~args={
333
+ policy: PulumiAws.PolicyDocument.make(
334
+ ~id="AllReadModels-appsyncPublishPolicy",
335
+ ~statements=[
336
+ {
337
+ sid: "AllowPublishAppSyncEvents",
338
+ effect: Allow,
339
+ actions: Action("appsync:EventPublish"),
340
+ resources: Resource(apiArn ++ "/*"),
341
+ },
342
+ ],
343
+ )
344
+ ->PulumiAws.PolicyDocument.toJsonString
345
+ ->Pulumi.Input.make,
346
+ role: runtime.parts.lambdaRole.id->Pulumi.Output.asInput,
347
+ },
348
+ )
349
+ })
350
+ | None => ()
351
+ }
352
+
290
353
  let channelSpecs = storedSpecs->Array.map(({channelSpec}) => channelSpec)
291
354
  let _connectResources = EventCollectorChannel.connect(
292
355
  ~name="AllReadModels",
@@ -11,9 +11,11 @@ import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/co
11
11
  import * as DcbBackend$ReventlessAws from "../DcbEventLog/DcbBackend.res.mjs";
12
12
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
13
13
  import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
14
+ import * as PgConnection$ReventlessAws from "../Postgres/PgConnection.res.mjs";
14
15
  import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
15
16
  import * as QueryDbBackend$ReventlessAws from "../QueryDb/QueryDbBackend.res.mjs";
16
17
  import * as EventLogBackend$ReventlessAws from "../EventLog/EventLogBackend.res.mjs";
18
+ import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
17
19
  import * as PgProjectionFeed$ReventlessAws from "../Postgres/PgProjectionFeed.res.mjs";
18
20
  import * as RuntimeEnvironment_Lambda$ReventlessAws from "./RuntimeEnvironment_Lambda.res.mjs";
19
21
  import * as EventCollectorChannel_DynamoDbStream$ReventlessAws from "../EventCollector/EventCollectorChannel_DynamoDbStream.res.mjs";
@@ -22,6 +24,14 @@ let log = Logger$ReventlessCore.fromEnv();
22
24
 
23
25
  let readModelInfos = {};
24
26
 
27
+ let eventsApiConfig = {
28
+ contents: undefined
29
+ };
30
+
31
+ function setEventsApiConfig(cfg) {
32
+ eventsApiConfig.contents = cfg;
33
+ }
34
+
25
35
  function registerReadModel(readModelName, specModulePath, mappingsModulePath, queryDbTableName, pgBackedOpt) {
26
36
  let pgBacked = pgBackedOpt !== undefined ? pgBackedOpt : false;
27
37
  readModelInfos[readModelName] = {
@@ -95,31 +105,7 @@ function finish() {
95
105
  let feedArnOutput = feedQueue !== undefined ? feedQueue.arn : Pulumi.output("");
96
106
  let qdbSelection = QueryDbBackend$ReventlessAws.get();
97
107
  let anyPgBacked = Object.values(readModelInfos).some(info => info.pgBacked);
98
- let pgConnectionFragment = qdbSelection !== undefined && anyPgBacked ? qdbSelection.connectionConfig.apply(cc => {
99
- let pgConnectionJson = JSON.stringify(Object.fromEntries([
100
- [
101
- "host",
102
- cc.host
103
- ],
104
- [
105
- "port",
106
- cc.port
107
- ],
108
- [
109
- "database",
110
- cc.database
111
- ],
112
- [
113
- "username",
114
- cc.username
115
- ],
116
- [
117
- "secretArn",
118
- cc.secretArn
119
- ]
120
- ]));
121
- return `,"pgConnection":` + pgConnectionJson;
122
- }) : Pulumi.output("");
108
+ let pgConnectionFragment = qdbSelection !== undefined && anyPgBacked ? qdbSelection.connectionConfig.apply(cc => `,"pgConnection":` + JSON.stringify(PgConnection$ReventlessAws.connectionConfigToJson(undefined, cc))) : Pulumi.output("");
123
109
  let handlerOutputs = [];
124
110
  let packageDirs = {};
125
111
  let sortedSpecs = storedSpecs.toSorted((a, b) => Primitive_string.compare(a.componentName, b.componentName));
@@ -135,6 +121,13 @@ function finish() {
135
121
  let specModule = Stdlib_Option.getOr(JSON.stringify(info.specModulePath), `""`);
136
122
  let mappingsModule = Stdlib_Option.getOr(JSON.stringify(info.mappingsModulePath), `""`);
137
123
  let handlerPgFragment = info.pgBacked ? pgConnectionFragment : Pulumi.output("");
124
+ let stateTopicFragment;
125
+ if (info.pgBacked && QueryDbBackend$ReventlessAws.postgresStreamRegistry.has(spec.componentName) && Stdlib_Option.isSome(eventsApiConfig.contents)) {
126
+ let topic = Stdlib_Option.getOr(Stdlib_Option.map(Plugin_Helpers$ReventlessCore.queryFieldNamesRegistry[spec.componentName], qn => qn.listFieldName), spec.componentName);
127
+ stateTopicFragment = `,"stateTopicName":` + JSON.stringify(topic);
128
+ } else {
129
+ stateTopicFragment = "";
130
+ }
138
131
  let handlerJson = Pulumi.all([
139
132
  info.queryDbTableName,
140
133
  spec.sourceUrns,
@@ -142,13 +135,24 @@ function finish() {
142
135
  handlerPgFragment
143
136
  ]).apply(param => {
144
137
  let sourceUrn = Stdlib_Option.getOr(param[1][0], param[2]);
145
- return `{"specModule":` + specModule + `,"mappingsModule":` + mappingsModule + `,"queryDbTableName":"` + param[0] + `","sourceUrn":"` + sourceUrn + `"` + param[3] + `}`;
138
+ return `{"specModule":` + specModule + `,"mappingsModule":` + mappingsModule + `,"queryDbTableName":"` + param[0] + `","sourceUrn":"` + sourceUrn + `"` + param[3] + stateTopicFragment + `}`;
146
139
  });
147
140
  handlerOutputs.push(handlerJson);
148
141
  });
149
142
  let handlerConfigOutput = Pulumi.all(handlerOutputs).apply(handlers => `{"handlers":[` + handlers.join(",") + `]}`);
150
143
  let envVars = {};
151
144
  envVars["HANDLER_CONFIG"] = handlerConfigOutput;
145
+ let anyPgStream = Object.keys(readModelInfos).some(rmName => {
146
+ if (Stdlib_Option.mapOr(readModelInfos[rmName], false, i => i.pgBacked)) {
147
+ return QueryDbBackend$ReventlessAws.postgresStreamRegistry.has(rmName);
148
+ } else {
149
+ return false;
150
+ }
151
+ });
152
+ let pgStreamConfig = anyPgStream ? eventsApiConfig.contents : undefined;
153
+ if (pgStreamConfig !== undefined) {
154
+ envVars["APPSYNC_ENDPOINT"] = pgStreamConfig.endpoint;
155
+ }
152
156
  let match$1 = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Runtime/ReadModelEntryPoint.mjs", packageDirs, undefined);
153
157
  let vpcConfig = qdbSelection !== undefined && anyPgBacked ? qdbSelection.securityGroupId.apply(sgId => ({
154
158
  subnetIds: qdbSelection.subnetIds,
@@ -166,6 +170,17 @@ function finish() {
166
170
  role: runtime.parts.lambdaRole.id
167
171
  }));
168
172
  }
173
+ if (pgStreamConfig !== undefined) {
174
+ pgStreamConfig.apiArn.apply(apiArn => new (Aws.iam.RolePolicy)("AllReadModels-appsyncPublish", {
175
+ policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, "AllReadModels-appsyncPublishPolicy", [{
176
+ Sid: "AllowPublishAppSyncEvents",
177
+ Effect: "Allow",
178
+ Action: "appsync:EventPublish",
179
+ Resource: apiArn + "/*"
180
+ }])),
181
+ role: runtime.parts.lambdaRole.id
182
+ }));
183
+ }
169
184
  let channelSpecs = storedSpecs.map(param => param.channelSpec);
170
185
  EventCollectorChannel_DynamoDbStream$ReventlessAws.connect("AllReadModels", channelSpecs, runtime, opts);
171
186
  if (feedQueue !== undefined) {
@@ -187,6 +202,8 @@ export {
187
202
  RuntimeEnvironment,
188
203
  log,
189
204
  readModelInfos,
205
+ eventsApiConfig,
206
+ setEventsApiConfig,
190
207
  registerReadModel,
191
208
  storedSpecs,
192
209
  grandParent,
@@ -0,0 +1,40 @@
1
+ // One-shot Postgres schema-migration Lambda entry point (A3).
2
+ //
3
+ // Invoked once during `pulumi up` by an `aws.lambda.Invocation` resource, from
4
+ // INSIDE the DB's VPC, so the deploy runner needs no network path to a private
5
+ // RDS instance. Runs the exact idempotent `PgSchema.ensureSchema` the local
6
+ // backend runs at startup (reventless-local's `postgres` smart constructor), so
7
+ // the AWS schema is byte-identical to local — zero drift. Every statement is
8
+ // `IF NOT EXISTS`, so re-invocation (e.g. after a schema-DDL change bumps the
9
+ // Lambda layer) is safe.
10
+ //
11
+ // HANDLER_CONFIG shape:
12
+ // { "pgConnection": { host, port, database, username, secretArn } }
13
+
14
+ import { poolFor } from "@reventlessdev/reventless-aws/src/adapter/Postgres/PgRuntime.res.mjs";
15
+ import { ensureSchema } from "@reventlessdev/reventless-postgres/src/PgSchema.res.mjs";
16
+ import { log } from "./HandlerFactoryHelpers.mjs";
17
+
18
+ // Config parse → pool → ensureSchema, with the pool source injectable so the
19
+ // orchestration is integration-testable against a real Postgres without the
20
+ // Secrets Manager round-trip (mirrors PgChangeFeedRelay_Runtime's
21
+ // relayWithPool/relay split). `opts.makePool` defaults to the production `poolFor`.
22
+ export async function runMigration(config, opts = {}) {
23
+ if (!config.pgConnection) {
24
+ throw new Error("PgMigrationEntryPoint: HANDLER_CONFIG has no pgConnection");
25
+ }
26
+ // `poolFor` memoises one pool per secret ARN per container and resolves the
27
+ // RDS-managed password from Secrets Manager via a cached provider.
28
+ const makePool = opts.makePool ?? poolFor;
29
+ const pool = makePool(config.pgConnection);
30
+ await ensureSchema(pool);
31
+ log.info("ensureSchema completed for " + config.pgConnection.database, {
32
+ comp: "PgMigration",
33
+ });
34
+ return "ok";
35
+ }
36
+
37
+ export async function handler(_event, _context) {
38
+ const config = JSON.parse(process.env["HANDLER_CONFIG"] || "{}");
39
+ return runMigration(config);
40
+ }
@@ -0,0 +1,88 @@
1
+ // Shared PgQueryResolver Lambda entry point (B3.2b).
2
+ //
3
+ // One in-VPC Lambda serves every Postgres-backed read model's GraphQL Query
4
+ // fields (they have no per-table AppSync data source). At cold start it reads
5
+ // QUERY_RESOLVER_CONFIG (one shared pgConnection + one handler per read model),
6
+ // dynamically imports each spec package, and registers a per-read-model
7
+ // `binding` (ops + QueryEnginePostgres push-downs + indexes/subIdField/capability
8
+ // + baked labelField/includeIdParam + authorization) into
9
+ // PgQueryResolver_Lambda. AppSync invokes `handler` with the resolver payload
10
+ // { readModelName, kind, index?, arguments, identity }, which
11
+ // PgQueryResolver_Lambda.dispatch routes.
12
+ //
13
+ // ReScript labeled args compile positionally, so the engine push-downs
14
+ // (indexLookup/byIds/listPage/scan) are assigned straight into the `pushdowns`
15
+ // record — same calling convention PgQueryResolver_Lambda.dispatch invokes them
16
+ // with (verified against the compiled QueryEnginePostgres output).
17
+
18
+ import { poolFor } from "@reventlessdev/reventless-aws/src/adapter/Postgres/PgRuntime.res.mjs";
19
+ import { Make as makeEngine } from "@reventlessdev/reventless-postgres/src/QueryEnginePostgres.res.mjs";
20
+ import { opsFor as pgQdbOpsFor } from "@reventlessdev/reventless-aws/src/adapter/QueryDb/QueryDbStorage_Postgres_Runtime.res.mjs";
21
+ import { deriveServerCapability } from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
22
+ import { register, registerNodeType, handler as dispatchHandler } from "@reventlessdev/reventless-aws/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs";
23
+ import { patchSpecId, log } from "./HandlerFactoryHelpers.mjs";
24
+
25
+ const dynamicImport = (specifier) => import('/var/task/node_modules/' + specifier);
26
+
27
+ // A bounded full-materialisation for the list fallback (shapes listPage
28
+ // declines). Kept high; the fallback is only hit for search/searchPrefix/ids/
29
+ // backward pagination, which the AutoUI does not issue for large models.
30
+ const SCAN_ALL_LIMIT = 100000;
31
+
32
+ async function buildAllBindings() {
33
+ const configStr = process.env["QUERY_RESOLVER_CONFIG"] || '{"handlers":[]}';
34
+ const config = JSON.parse(configStr);
35
+ const pgConnection = config.pgConnection;
36
+ if (!pgConnection) {
37
+ log.warn("no pgConnection in QUERY_RESOLVER_CONFIG", { comp: "PgQueryResolver" });
38
+ return;
39
+ }
40
+ // Container-lifetime pool (memoised by poolFor); the engine and the ops set
41
+ // share it, so a Lambda holds one pool regardless of read model count.
42
+ const pool = poolFor(pgConnection);
43
+ const engine = makeEngine({ pool });
44
+
45
+ // Relay node type → read-model map (B3.2c), baked into the config.
46
+ for (const [typeName, rm] of Object.entries(config.nodeTypes || {})) {
47
+ registerNodeType(typeName, rm);
48
+ }
49
+
50
+ const pushdowns = {
51
+ indexLookup: engine.indexLookup,
52
+ byIds: engine.byIds,
53
+ listPage: engine.listPage,
54
+ itemsPage: engine.itemsPage,
55
+ scanAll: (readModelName) => engine.scan(readModelName, [], SCAN_ALL_LIMIT),
56
+ };
57
+
58
+ await Promise.all((config.handlers || []).map(async h => {
59
+ const specModule = patchSpecId(await dynamicImport(h.specModule));
60
+ const indexes = (specModule.config && specModule.config.indexes) || [];
61
+ const subIdField = specModule.subIdConfig ? specModule.subIdConfig.subIdField : undefined;
62
+ const capability = deriveServerCapability(specModule.stateSchema);
63
+ const ops = pgQdbOpsFor(pgConnection, h.readModelName, indexes, subIdField);
64
+
65
+ register(h.readModelName, {
66
+ ops,
67
+ pushdowns,
68
+ indexes,
69
+ subIdField,
70
+ capability,
71
+ labelField: h.labelField,
72
+ includeIdParam: h.includeIdParam,
73
+ // Compiled string variant (e.g. "AllowAuthenticated"), injected on the
74
+ // spec by @@reventless.spec — the shape Reventless.Authorization.isAllowed
75
+ // matches on.
76
+ authorization: specModule.authorization,
77
+ });
78
+ log.debug("registered resolver binding for " + h.readModelName, { comp: "PgQueryResolver" });
79
+ }));
80
+ }
81
+
82
+ const initPromise = buildAllBindings();
83
+
84
+ // AppSync Lambda data source: the invocation payload IS the event.
85
+ export async function handler(event, context) {
86
+ await initPromise;
87
+ return dispatchHandler(event, context);
88
+ }
@@ -8,6 +8,7 @@ import { tag as requestContextTag } from "@reventlessdev/reventless-core/src/Req
8
8
  import { Make as readModelCallbackMake } from "@reventlessdev/reventless-core/src/components/ReadModel/ReadModel_Callback.res.mjs";
9
9
  import { load as qdbLoad, loadStream as qdbLoadStream, save as qdbSave, saveBatch as qdbSaveBatch, count as qdbCount, $$delete as qdbDelete, deleteBatch as qdbDeleteBatch } from "@reventlessdev/reventless-aws/src/adapter/QueryDb/QueryDbStorage_DynamoDb_Runtime.res.mjs";
10
10
  import { opsFor as pgQdbOpsFor } from "@reventlessdev/reventless-aws/src/adapter/QueryDb/QueryDbStorage_Postgres_Runtime.res.mjs";
11
+ import { withLiveUpdates } from "./StateTopicPublish.mjs";
11
12
  import { handleStreamEvent } from "@reventlessdev/reventless-aws/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream_Runtime.res.mjs";
12
13
 
13
14
  const dynamicImport = (specifier) => import('/var/task/node_modules/' + specifier);
@@ -74,17 +75,28 @@ function groupBySource(records) {
74
75
  // runtime builder injected for a Postgres-backed read model: bind the Postgres
75
76
  // operation set (queryDbTableName is then the read-model spec name, the shared
76
77
  // `qdb_<name>` discriminator). Absent → the DynamoDB path is byte-identical.
77
- function buildReadModelHandler(specModule, mappingsModule, queryDbTableName, pgConnection) {
78
+ function buildReadModelHandler(specModule, mappingsModule, queryDbTableName, pgConnection, stateTopicName) {
78
79
  const patchedSpec = patchSpecId(specModule);
79
80
  let operations;
80
81
  if (pgConnection) {
81
82
  const indexes = (patchedSpec.config && patchedSpec.config.indexes) || [];
82
83
  const subIdField = patchedSpec.subIdConfig ? patchedSpec.subIdConfig.subIdField : undefined;
83
84
  const pgOps = pgQdbOpsFor(pgConnection, queryDbTableName, indexes, subIdField);
85
+ // B3.3: on a subscription-enabled (Stream) Postgres read model, publish a
86
+ // live-update descriptor after each save/delete (no DynamoDB stream exists).
87
+ // `stateTopicName` (present only for stream RMs) + APPSYNC_ENDPOINT gate it;
88
+ // absent → withLiveUpdates returns pgOps unchanged. Wrap BEFORE mkInjectIdSave
89
+ // so the publish sees the id-injected state (id, subId, updatedAt).
90
+ const livePgOps = withLiveUpdates(pgOps, {
91
+ endpoint: process.env.APPSYNC_ENDPOINT,
92
+ region: process.env.AWS_REGION,
93
+ topicName: stateTopicName,
94
+ subIdField,
95
+ });
84
96
  operations = {
85
- ...pgOps,
86
- save: mkInjectIdSave(pgOps.save),
87
- saveBatch: mkInjectIdSaveBatch(pgOps.saveBatch),
97
+ ...livePgOps,
98
+ save: mkInjectIdSave(livePgOps.save),
99
+ saveBatch: mkInjectIdSaveBatch(livePgOps.saveBatch),
88
100
  };
89
101
  } else {
90
102
  const table = makeTableRef(queryDbTableName);
@@ -119,7 +131,7 @@ async function buildAllHandlers() {
119
131
  await Promise.all(config.handlers.map(async h => {
120
132
  const specModule = await dynamicImport(h.specModule);
121
133
  const mappingsModule = await dynamicImport(h.mappingsModule);
122
- const handler = buildReadModelHandler(specModule, mappingsModule, h.queryDbTableName, h.pgConnection);
134
+ const handler = buildReadModelHandler(specModule, mappingsModule, h.queryDbTableName, h.pgConnection, h.stateTopicName);
123
135
  // Multiple read models can share one source stream — e.g. every admin read
124
136
  // model (Plugins, PluginHistory, PlatformEventGraph, UIFragmentRegistry)
125
137
  // projects the Plugin aggregate's EventLog stream. Accumulate ALL handlers
@@ -180,6 +180,16 @@ let makeFromCodeAsset: (
180
180
  variables->Dict.set(key, value)
181
181
  })
182
182
 
183
+ // ESM self-containment (Option C): every code archive built by
184
+ // Util_Bundle.buildCodeArchive ships register-hook.mjs + layer-resolver.mjs at
185
+ // /var/task. --import registers the resolve hook so the ESM entry point (and the
186
+ // user spec/behavior modules it dynamically imports) can resolve bare specifiers
187
+ // — @reventlessdev/*, effect, sury from the layer, @aws-sdk/* from the runtime —
188
+ // that ESM `import` would otherwise not find outside /var/task. Framework
189
+ // invariant: set last so it wins over any caller-supplied value.
190
+ variables->Dict.set("NODE_OPTIONS", Util_Bundle.esmLoaderNodeOptions->Pulumi.Input.make)
191
+ variables->Dict.set("ESM_FALLBACK_DIRS", Util_Bundle.esmFallbackDirs->Pulumi.Input.make)
192
+
183
193
  let tags = AWS.Tags.make(~name, ReventlessCore.CommandTopic.componentType)
184
194
  let lambda = Lambda.Function.make(
185
195
  ~name,
@@ -13,6 +13,7 @@ import * as AWS_Tags$ReventlessAws from "../AWS_Tags.res.mjs";
13
13
  import * as Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Adapter.res.mjs";
14
14
  import * as Runtime$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Runtime/Runtime.res.mjs";
15
15
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
16
+ import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
16
17
  import * as Util_Lambda$ReventlessAws from "../../util/Util_Lambda.res.mjs";
17
18
  import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
18
19
  import * as CommandTopic$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic.res.mjs";
@@ -91,6 +92,8 @@ function makeFromCodeAsset(name, code, sourceCodeHash, envVarsOpt, memorySizeOpt
91
92
  Stdlib_Dict.forEachWithKey(additionalEnvVars, (value, key) => {
92
93
  variables[key] = value;
93
94
  });
95
+ variables["NODE_OPTIONS"] = Util_Bundle$ReventlessAws.esmLoaderNodeOptions;
96
+ variables["ESM_FALLBACK_DIRS"] = Util_Bundle$ReventlessAws.esmFallbackDirs;
94
97
  let tags = AWS_Tags$ReventlessAws.make(name, CommandTopic$ReventlessCore.componentType);
95
98
  let lambda = new (Aws.lambda.Function)(name, {
96
99
  handler: "index.handler",
@@ -95,20 +95,15 @@ let forDcbCommandTopic = (
95
95
  // fragment merged into HANDLER_CONFIG below.
96
96
  let pgConnectionFragment = switch pgSelection {
97
97
  | Some(sel) =>
98
- sel.connectionConfig->Pulumi.Output.apply(cc => {
99
- let pgConnectionJson =
100
- [
101
- ("host", cc.host->JSON.Encode.string),
102
- ("port", cc.port->Int.toFloat->JSON.Encode.float),
103
- ("database", cc.database->JSON.Encode.string),
104
- ("username", cc.username->JSON.Encode.string),
105
- ("secretArn", cc.secretArn->JSON.Encode.string),
106
- ]
107
- ->Dict.fromArray
108
- ->JSON.Encode.object
109
- ->JSON.stringify
110
- `,"pgConnection":${pgConnectionJson}`
111
- })
98
+ // C2: the DCB command Lambda is the only Postgres path that takes an append
99
+ // lock, so `lockStrategy` rides in this fragment (not in `connectionConfig`,
100
+ // which the classic/QueryDb paths share). The entry point passes it to
101
+ // `DcbEventLogStorage_Postgres_Runtime.opsFor`.
102
+ sel.connectionConfig->Pulumi.Output.apply(cc =>
103
+ `,"pgConnection":${cc
104
+ ->PgConnection.connectionConfigToJson(~lockStrategy=sel.lockStrategy)
105
+ ->JSON.stringify}`
106
+ )
112
107
  | None => Pulumi.Output.make("")
113
108
  }
114
109
 
@@ -9,6 +9,7 @@ import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/co
9
9
  import * as DcbBackend$ReventlessAws from "../DcbEventLog/DcbBackend.res.mjs";
10
10
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
11
11
  import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
12
+ import * as PgConnection$ReventlessAws from "../Postgres/PgConnection.res.mjs";
12
13
  import * as CommandTopicChannel_SQS$ReventlessAws from "../CommandTopic/CommandTopicChannel_SQS.res.mjs";
13
14
  import * as RuntimeEnvironment_Lambda$ReventlessAws from "./RuntimeEnvironment_Lambda.res.mjs";
14
15
 
@@ -51,31 +52,7 @@ function forDcbCommandTopic(slicePaths, dcbTableName, pluginName, syncConfig, as
51
52
  let b = Stdlib_Option.getOr(JSON.stringify(param[1]), `""`);
52
53
  return `{"spec":` + s + `,"behavior":` + b + `}`;
53
54
  }).join(",");
54
- let pgConnectionFragment = pgSelection !== undefined ? pgSelection.connectionConfig.apply(cc => {
55
- let pgConnectionJson = JSON.stringify(Object.fromEntries([
56
- [
57
- "host",
58
- cc.host
59
- ],
60
- [
61
- "port",
62
- cc.port
63
- ],
64
- [
65
- "database",
66
- cc.database
67
- ],
68
- [
69
- "username",
70
- cc.username
71
- ],
72
- [
73
- "secretArn",
74
- cc.secretArn
75
- ]
76
- ]));
77
- return `,"pgConnection":` + pgConnectionJson;
78
- }) : Pulumi.output("");
55
+ let pgConnectionFragment = pgSelection !== undefined ? pgSelection.connectionConfig.apply(cc => `,"pgConnection":` + JSON.stringify(PgConnection$ReventlessAws.connectionConfigToJson(pgSelection.lockStrategy, cc))) : Pulumi.output("");
79
56
  let handlerConfigJson = Pulumi.all([
80
57
  dcbTableName$1,
81
58
  queue.id,