@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
@@ -35,6 +35,32 @@ type connectionConfig = {
35
35
  secretArn: string,
36
36
  }
37
37
 
38
+ /** Serialize a resolved `connectionConfig` into the `pgConnection` JSON object
39
+ the runtime entry points read from `HANDLER_CONFIG` (and `PgRuntime.poolFor`
40
+ reconstructs). One shared producer so all seven deploy-time builders emit a
41
+ byte-identical shape — the field set is a cold-start contract with the entry
42
+ points, so a drift here silently breaks a Lambda. `~lockStrategy` is included
43
+ only on the DCB command path (the sole Postgres append that takes a lock);
44
+ omit it everywhere else. Kept pure (no Pulumi) so it is directly unit-testable. */
45
+ let connectionConfigToJson = (
46
+ ~lockStrategy: option<ReventlessPostgres.DcbEventLogStorage_Postgres.lockStrategy>=?,
47
+ cc: connectionConfig,
48
+ ): JSON.t => {
49
+ let fields = [
50
+ ("host", cc.host->JSON.Encode.string),
51
+ ("port", cc.port->Int.toFloat->JSON.Encode.float),
52
+ ("database", cc.database->JSON.Encode.string),
53
+ ("username", cc.username->JSON.Encode.string),
54
+ ("secretArn", cc.secretArn->JSON.Encode.string),
55
+ ]
56
+ switch lockStrategy {
57
+ | Some(#AdvisoryLocks) => fields->Array.push(("lockStrategy", "AdvisoryLocks"->JSON.Encode.string))
58
+ | Some(#RowLocks) => fields->Array.push(("lockStrategy", "RowLocks"->JSON.Encode.string))
59
+ | None => ()
60
+ }
61
+ fields->Dict.fromArray->JSON.Encode.object
62
+ }
63
+
38
64
  type t = {
39
65
  /** Deploy-time resources (currently the RDS instance) for dependency wiring —
40
66
  e.g. the A3 migration Lambda and the B-phase adapters order after these. */
@@ -46,6 +72,14 @@ type t = {
46
72
  /** The DB's private subnets, echoed back for the Lambda `vpcConfig` so
47
73
  handlers land in the same subnets as the database. */
48
74
  subnetIds: array<Pulumi.Input.t<string>>,
75
+ /** DCB append lock strategy (C2). `#AdvisoryLocks` (default) is
76
+ transaction-scoped and lowest-overhead when Lambdas hold a few long-lived
77
+ pools **directly** against RDS, but it **pins connections on RDS Proxy**
78
+ (advisory locks are session state Proxy cannot multiplex). Choose
79
+ `#RowLocks` when fronting the DB with RDS Proxy so the Proxy can share
80
+ connections across invocations. Threaded to the DCB command Lambda via
81
+ `DcbBackend`; classic EventLog and QueryDb appends take no such lock. */
82
+ lockStrategy: ReventlessPostgres.DcbEventLogStorage_Postgres.lockStrategy,
49
83
  }
50
84
 
51
85
  /** Postgres wire port. */
@@ -65,8 +99,13 @@ let make = (
65
99
  ~backupRetentionPeriod=7,
66
100
  ~deletionProtection=true,
67
101
  ~skipFinalSnapshot=false,
102
+ ~lockStrategy: ReventlessPostgres.DcbEventLogStorage_Postgres.lockStrategy=#AdvisoryLocks,
68
103
  ~opts: option<Pulumi.ComponentResource.options>=?,
69
104
  ): t => {
105
+ // Keep the caller's ComponentResource.options for sub-builders that take it
106
+ // directly (PgMigration_Builder → RuntimeEnvironment_Lambda); the RDS/SG/subnet
107
+ // resources below want the CustomResourceOptions form.
108
+ let componentOpts = opts
70
109
  let opts =
71
110
  opts->Option.map(ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions)
72
111
 
@@ -151,19 +190,40 @@ let make = (
151
190
  },
152
191
  })
153
192
 
193
+ // A3: run PgSchema.ensureSchema against the fresh instance via a one-shot
194
+ // in-VPC migration Lambda invoked during `pulumi up`. Its resources join the
195
+ // instance's so the B-phase storage adapters can order after the schema exists.
196
+ // The connection is passed as pre-serialized pieces to avoid a module cycle
197
+ // (PgMigration_Builder must not reference this module's `connectionConfig`).
198
+ let migrationHandlerConfig = connectionConfig->Pulumi.Output.apply(cc =>
199
+ `{"pgConnection":${cc->connectionConfigToJson->JSON.stringify}}`
200
+ )
201
+ let migrationResources = PgMigration_Builder.make(
202
+ ~name=`${name}-pg-migrate`,
203
+ ~handlerConfig=migrationHandlerConfig,
204
+ ~secretArn=connectionConfig->Pulumi.Output.apply(cc => cc.secretArn),
205
+ ~securityGroupId=sg.id,
206
+ ~subnetIds,
207
+ ~opts=?componentOpts,
208
+ )
209
+
154
210
  {
155
- resources: [
156
- ReventlessInfra.Adapter.make(
157
- ~name=name->Pulumi.Output.make,
158
- ~id=instance.id,
159
- ~urn=instance.arn,
160
- ~service="aws:rds"->Pulumi.Output.make,
161
- ~role="postgres"->Pulumi.Output.make,
162
- ~resourceType="aws:rds:Instance"->Pulumi.Output.make,
163
- ),
164
- ],
211
+ resources: Array.concat(
212
+ [
213
+ ReventlessInfra.Adapter.make(
214
+ ~name=name->Pulumi.Output.make,
215
+ ~id=instance.id,
216
+ ~urn=instance.arn,
217
+ ~service="aws:rds"->Pulumi.Output.make,
218
+ ~role="postgres"->Pulumi.Output.make,
219
+ ~resourceType="aws:rds:Instance"->Pulumi.Output.make,
220
+ ),
221
+ ],
222
+ migrationResources,
223
+ ),
165
224
  connectionConfig,
166
225
  securityGroupId: sg.id,
167
226
  subnetIds,
227
+ lockStrategy,
168
228
  }
169
229
  }
@@ -9,6 +9,7 @@ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js
9
9
  import * as Adapter$ReventlessInfra from "@reventlessdev/reventless-infra/src/adapter/Adapter.res.mjs";
10
10
  import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
11
11
  import * as EC2_SecurityGroup$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/EC2/EC2_SecurityGroup.res.mjs";
12
+ import * as PgMigration_Builder$ReventlessAws from "./PgMigration_Builder.res.mjs";
12
13
 
13
14
  let connectionConfigSchema = S.schema(s => ({
14
15
  host: s.m(S.string),
@@ -18,7 +19,46 @@ let connectionConfigSchema = S.schema(s => ({
18
19
  secretArn: s.m(S.string)
19
20
  }));
20
21
 
21
- function make(name, vpcId, subnetIds, databaseNameOpt, usernameOpt, engineVersionOpt, instanceClassOpt, allocatedStorageOpt, multiAzOpt, storageEncryptedOpt, backupRetentionPeriodOpt, deletionProtectionOpt, skipFinalSnapshotOpt, opts) {
22
+ function connectionConfigToJson(lockStrategy, cc) {
23
+ let fields = [
24
+ [
25
+ "host",
26
+ cc.host
27
+ ],
28
+ [
29
+ "port",
30
+ cc.port
31
+ ],
32
+ [
33
+ "database",
34
+ cc.database
35
+ ],
36
+ [
37
+ "username",
38
+ cc.username
39
+ ],
40
+ [
41
+ "secretArn",
42
+ cc.secretArn
43
+ ]
44
+ ];
45
+ if (lockStrategy !== undefined) {
46
+ if (lockStrategy === "AdvisoryLocks") {
47
+ fields.push([
48
+ "lockStrategy",
49
+ "AdvisoryLocks"
50
+ ]);
51
+ } else {
52
+ fields.push([
53
+ "lockStrategy",
54
+ "RowLocks"
55
+ ]);
56
+ }
57
+ }
58
+ return Object.fromEntries(fields);
59
+ }
60
+
61
+ function make(name, vpcId, subnetIds, databaseNameOpt, usernameOpt, engineVersionOpt, instanceClassOpt, allocatedStorageOpt, multiAzOpt, storageEncryptedOpt, backupRetentionPeriodOpt, deletionProtectionOpt, skipFinalSnapshotOpt, lockStrategyOpt, opts) {
22
62
  let databaseName = databaseNameOpt !== undefined ? databaseNameOpt : "reventless";
23
63
  let username = usernameOpt !== undefined ? usernameOpt : "reventless_admin";
24
64
  let engineVersion = engineVersionOpt !== undefined ? engineVersionOpt : "16";
@@ -29,6 +69,7 @@ function make(name, vpcId, subnetIds, databaseNameOpt, usernameOpt, engineVersio
29
69
  let backupRetentionPeriod = backupRetentionPeriodOpt !== undefined ? backupRetentionPeriodOpt : 7;
30
70
  let deletionProtection = deletionProtectionOpt !== undefined ? deletionProtectionOpt : true;
31
71
  let skipFinalSnapshot = skipFinalSnapshotOpt !== undefined ? skipFinalSnapshotOpt : false;
72
+ let lockStrategy = lockStrategyOpt !== undefined ? lockStrategyOpt : "AdvisoryLocks";
32
73
  let opts$1 = Stdlib_Option.map(opts, Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions);
33
74
  let tags = Object.fromEntries([
34
75
  [
@@ -94,11 +135,14 @@ function make(name, vpcId, subnetIds, databaseNameOpt, usernameOpt, engineVersio
94
135
  secretArn: secret !== undefined ? secret.secretArn : Stdlib_JsError.throwWithMessage("RDS instance exposes no master user secret — manageMasterUserPassword must be enabled")
95
136
  };
96
137
  });
138
+ let migrationHandlerConfig = connectionConfig.apply(cc => `{"pgConnection":` + JSON.stringify(connectionConfigToJson(undefined, cc)) + `}`);
139
+ let migrationResources = PgMigration_Builder$ReventlessAws.make(name + `-pg-migrate`, migrationHandlerConfig, connectionConfig.apply(cc => cc.secretArn), sg.id, subnetIds, opts);
97
140
  return {
98
- resources: [Adapter$ReventlessInfra.make(Pulumi.output(name), instance.id, instance.arn, Pulumi.output("aws:rds"), undefined, Pulumi.output("postgres"), undefined, Pulumi.output("aws:rds:Instance"), undefined, undefined)],
141
+ resources: [Adapter$ReventlessInfra.make(Pulumi.output(name), instance.id, instance.arn, Pulumi.output("aws:rds"), undefined, Pulumi.output("postgres"), undefined, Pulumi.output("aws:rds:Instance"), undefined, undefined)].concat(migrationResources),
99
142
  connectionConfig: connectionConfig,
100
143
  securityGroupId: sg.id,
101
- subnetIds: subnetIds
144
+ subnetIds: subnetIds,
145
+ lockStrategy: lockStrategy
102
146
  };
103
147
  }
104
148
 
@@ -106,6 +150,7 @@ let port = 5432;
106
150
 
107
151
  export {
108
152
  connectionConfigSchema,
153
+ connectionConfigToJson,
109
154
  port,
110
155
  make,
111
156
  }
@@ -0,0 +1,127 @@
1
+ // Deploy-time builder for the one-shot Postgres schema migration (A3).
2
+ //
3
+ // Provisions an in-VPC Lambda whose only job is to run `PgSchema.ensureSchema`
4
+ // against the freshly-provisioned RDS instance, and an `aws.lambda.Invocation`
5
+ // resource that invokes it once during `pulumi up`. Because the invocation runs
6
+ // the Lambda *inside* the DB's VPC, the deploy runner itself needs no network
7
+ // path to the private instance — the reason the plan (A3) chose a migration
8
+ // Lambda over a Pulumi `command`/dynamic resource that would have to connect
9
+ // from the runner.
10
+ //
11
+ // `PgConnection.make` calls this after creating the instance so every
12
+ // `PgConnection` provisions its own schema; the returned resources are appended
13
+ // to `PgConnection.t.resources` so the B-phase storage adapters order after the
14
+ // migration. `ensureSchema` is idempotent (all `IF NOT EXISTS`), so the
15
+ // invocation re-runs safely whenever the migration code or the Reventless Lambda
16
+ // layer (which ships the DDL) changes.
17
+
18
+ // Takes the connection as already-serialized pieces rather than the
19
+ // `PgConnection.connectionConfig` record: `PgConnection` calls this builder, so
20
+ // referencing its type here would form a module cycle. The caller passes:
21
+ // ~handlerConfig — the full HANDLER_CONFIG JSON (`{"pgConnection":{…}}`),
22
+ // ~secretArn — the DB's RDS-managed master secret ARN (for the IAM grant).
23
+ // ~securityGroupId: DB-access SG the migration Lambda attaches (PgConnection.securityGroupId).
24
+ // ~subnetIds: private subnets for the in-VPC migration Lambda (PgConnection.subnetIds).
25
+ let make = (
26
+ ~name: string,
27
+ ~handlerConfig: Pulumi.Output.t<string>,
28
+ ~secretArn: Pulumi.Output.t<string>,
29
+ ~securityGroupId: Pulumi.Output.t<string>,
30
+ ~subnetIds: array<Pulumi.Input.t<string>>,
31
+ ~opts: option<Pulumi.ComponentResource.options>=?,
32
+ ): array<ReventlessInfra.Adapter.resource> => {
33
+ open PulumiAws
34
+
35
+ let {code, sourceCodeHash} = Util_Bundle.buildCodeArchive(
36
+ ~entryPointModule="@reventlessdev/reventless-aws/src/adapter/Runtime/PgMigrationEntryPoint.mjs",
37
+ ~packageDirs=Dict.make(),
38
+ )
39
+
40
+ let envVars = Dict.fromArray([("HANDLER_CONFIG", handlerConfig->Pulumi.Output.asInput)])
41
+
42
+ let vpcConfig =
43
+ securityGroupId
44
+ ->Pulumi.Output.apply(sgId =>
45
+ (
46
+ {
47
+ Lambda.Function.subnetIds: subnetIds->Pulumi.Input.make,
48
+ securityGroupIds: [sgId->Pulumi.Input.make]->Pulumi.Input.make,
49
+ }: Lambda.Function.vpcConfig
50
+ )
51
+ )
52
+ ->Pulumi.Output.asInput
53
+
54
+ let runtime = RuntimeEnvironment_Lambda.makeFromCodeAsset(
55
+ ~name,
56
+ ~code,
57
+ ~sourceCodeHash,
58
+ ~envVars,
59
+ ~vpcConfig,
60
+ ~opts?,
61
+ )
62
+
63
+ // IAM: GetSecretValue on the DB's RDS-managed master secret. Built outside an
64
+ // Output.apply so the RolePolicy is a top-level resource the invocation can
65
+ // `dependsOn` (invoke only after the Lambda can read the secret).
66
+ let rolePolicy = {
67
+ open PulumiAws.PolicyDocument
68
+ IAM.RolePolicy.make(
69
+ ~name=`${name}MigrationAccess`,
70
+ ~args={
71
+ policy: secretArn
72
+ ->Pulumi.Output.apply(arn =>
73
+ PulumiAws.PolicyDocument.make(
74
+ ~id=`${name}MigrationAccessPolicy`,
75
+ ~statements=[
76
+ {
77
+ sid: "AllowGetSecret",
78
+ effect: Allow,
79
+ actions: Action("secretsmanager:GetSecretValue"),
80
+ resources: Resource(arn),
81
+ },
82
+ ],
83
+ )->PulumiAws.PolicyDocument.toJsonString
84
+ )
85
+ ->Pulumi.Output.asInput,
86
+ role: runtime.parts.lambdaRole.id->Pulumi.Output.asInput,
87
+ },
88
+ )
89
+ }
90
+
91
+ // Re-invoke whenever the migration code or the Reventless layer (which ships
92
+ // PgSchema's DDL) changes; idempotent DDL makes an unchanged re-run a no-op.
93
+ let layerArn = PulumiAws.Lambda.reventlessLayerArn->Option.getOr("no-layer")
94
+ let inputJson =
95
+ [("trigger", `${sourceCodeHash}:${layerArn}`->JSON.Encode.string)]
96
+ ->Dict.fromArray
97
+ ->JSON.Encode.object
98
+ ->JSON.stringify
99
+
100
+ let invocation = Lambda.Invocation.make(
101
+ ~name=`${name}Migrate`,
102
+ ~args={
103
+ // Ordering: functionName ties to the Lambda, whose env depends on
104
+ // connectionConfig (the instance's resolved outputs), so the invocation
105
+ // waits for the DB to be available. dependsOn adds the secret-access policy.
106
+ functionName: runtime.parts.lambda->Pulumi.Output.flatMap(l => l.name)->Pulumi.Output.asInput,
107
+ input: inputJson->Pulumi.Input.make,
108
+ },
109
+ ~opts={
110
+ dependsOn: [rolePolicy->Pulumi.Resource.makeFromJs]->Pulumi.Input.make,
111
+ },
112
+ )
113
+
114
+ Array.concat(
115
+ runtime.resources,
116
+ [
117
+ ReventlessInfra.Adapter.make(
118
+ ~name=`${name}Migration`->Pulumi.Output.make,
119
+ ~id=invocation.id,
120
+ ~urn=invocation.id,
121
+ ~service="aws:lambda"->Pulumi.Output.make,
122
+ ~role="postgres-migration"->Pulumi.Output.make,
123
+ ~resourceType="aws:lambda:Invocation"->Pulumi.Output.make,
124
+ ),
125
+ ],
126
+ )
127
+ }
@@ -0,0 +1,51 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Aws from "@pulumi/aws";
4
+ import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output.res.mjs";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Pulumi from "@pulumi/pulumi";
7
+ import * as Lambda$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Lambda/Lambda.res.mjs";
8
+ import * as Adapter$ReventlessInfra from "@reventlessdev/reventless-infra/src/adapter/Adapter.res.mjs";
9
+ import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
10
+ import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
11
+ import * as RuntimeEnvironment_Lambda$ReventlessAws from "../Runtime/RuntimeEnvironment_Lambda.res.mjs";
12
+
13
+ function make(name, handlerConfig, secretArn, securityGroupId, subnetIds, opts) {
14
+ let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Runtime/PgMigrationEntryPoint.mjs", {}, undefined);
15
+ let sourceCodeHash = match.sourceCodeHash;
16
+ let envVars = Object.fromEntries([[
17
+ "HANDLER_CONFIG",
18
+ handlerConfig
19
+ ]]);
20
+ let vpcConfig = securityGroupId.apply(sgId => ({
21
+ subnetIds: subnetIds,
22
+ securityGroupIds: [sgId]
23
+ }));
24
+ let runtime = RuntimeEnvironment_Lambda$ReventlessAws.makeFromCodeAsset(name, match.code, sourceCodeHash, envVars, undefined, undefined, undefined, undefined, undefined, undefined, vpcConfig, opts);
25
+ let rolePolicy = new (Aws.iam.RolePolicy)(name + `MigrationAccess`, {
26
+ policy: secretArn.apply(arn => PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + `MigrationAccessPolicy`, [{
27
+ Sid: "AllowGetSecret",
28
+ Effect: "Allow",
29
+ Action: "secretsmanager:GetSecretValue",
30
+ Resource: arn
31
+ }]))),
32
+ role: runtime.parts.lambdaRole.id
33
+ });
34
+ let layerArn = Stdlib_Option.getOr(Lambda$PulumiAws.reventlessLayerArn, "no-layer");
35
+ let inputJson = JSON.stringify(Object.fromEntries([[
36
+ "trigger",
37
+ sourceCodeHash + `:` + layerArn
38
+ ]]));
39
+ let invocation = new (Aws.lambda.Invocation)(name + `Migrate`, {
40
+ functionName: Output$Pulumi.flatMap(runtime.parts.lambda, l => l.name),
41
+ input: inputJson
42
+ }, {
43
+ dependsOn: [rolePolicy]
44
+ });
45
+ return runtime.resources.concat([Adapter$ReventlessInfra.make(Pulumi.output(name + `Migration`), invocation.id, invocation.id, Pulumi.output("aws:lambda"), undefined, Pulumi.output("postgres-migration"), undefined, Pulumi.output("aws:lambda:Invocation"), undefined, undefined)]);
46
+ }
47
+
48
+ export {
49
+ make,
50
+ }
51
+ /* @pulumi/aws Not a pure module */
@@ -0,0 +1,279 @@
1
+ // Deploy-time provisioner for the shared Postgres GraphQL-read Lambda (B3.2b).
2
+ //
3
+ // Postgres-backed read models have no per-table AppSync data source, so instead
4
+ // of the direct DynamoDB resolvers there is ONE in-VPC "PgQueryResolver" Lambda
5
+ // (PgQueryResolverEntryPoint.mjs → PgQueryResolver_Lambda.dispatch) registered
6
+ // as a single AppSync Lambda data source. Every Postgres read model's resolvers
7
+ // (QueryDbResolvers_Lambda) are thin APPSYNC_JS `Invoke` templates pointing at
8
+ // this one data source.
9
+ //
10
+ // Ordering wrinkle: `QueryDbStorage_Postgres.make` runs DURING construct and
11
+ // must return the data source name, but the Lambda's code bundle needs the set
12
+ // of Postgres read models (their spec packages), known only AFTER construct. So
13
+ // the data source name is a DEFERRED Output (`dataSourceName`), fulfilled by
14
+ // `provision` — which the platform calls in makePlatform once every plugin is
15
+ // built (alongside provisionPgChangeFeedRelay). The resolvers themselves are
16
+ // created even later (inside the schema-pushed `resourcesMaker`), so they read
17
+ // the name after `provision` has resolved it.
18
+
19
+ open PulumiAws
20
+
21
+ let log = ReventlessCore.Logger.fromEnv()
22
+
23
+ // -- Deferred data source name ----------------------------------------------
24
+ // Created once at module load. `QueryDbStorage_Postgres.make` returns this for
25
+ // every Postgres read model (one shared data source); `provision` resolves it.
26
+ let resolveDataSourceName: ref<string => unit> = ref(_ => ())
27
+ let dataSourceNamePromise: promise<string> = Promise.make((resolve, _reject) =>
28
+ resolveDataSourceName := resolve
29
+ )
30
+ let dataSourceName: Pulumi.Output.t<string> = Pulumi.Output.fromPromise(dataSourceNamePromise)
31
+
32
+ // -- Resolver-binding registry ----------------------------------------------
33
+ // Populated by QueryDbResolvers_Lambda.make during construct (per Postgres read
34
+ // model): the bits `provision` bakes into the Lambda env config that the entry
35
+ // point can't get from the spec module (they come from the deploy-time
36
+ // queryFieldNamesRegistry). specModulePath is looked up from
37
+ // EventCollectorRuntime_Builder_Single.readModelInfos at provision time.
38
+ type resolverEntry = {
39
+ readModelName: string,
40
+ labelField: string,
41
+ includeIdParam: bool,
42
+ }
43
+ let entries: dict<resolverEntry> = Dict.make()
44
+ let register = (entry: resolverEntry): unit =>
45
+ entries->Dict.set(entry.readModelName, entry)
46
+
47
+ // Relay node type → read-model-name, populated per RM by QueryDbResolvers_Lambda.
48
+ // Baked into the env config so the entry point can serve the shared node(id).
49
+ let nodeTypes: dict<string> = Dict.make()
50
+ let registerNodeType = (~typeName: string, ~readModelName: string): unit =>
51
+ nodeTypes->Dict.set(typeName, readModelName)
52
+
53
+ // -- Provision --------------------------------------------------------------
54
+ let bool = b => b ? "true" : "false"
55
+
56
+ // One `node(id: ID!)` resolver on the shared data source → Invoke {kind:"node"}.
57
+ let nodeResolverCode =
58
+ `import { util } from '@aws-appsync/utils';
59
+ export function request(ctx) {
60
+ const id = ctx.identity;
61
+ return {
62
+ operation: 'Invoke',
63
+ payload: {
64
+ readModelName: '',
65
+ kind: 'node',
66
+ arguments: ctx.args,
67
+ identity: id != null && id.sub != null
68
+ ? { userId: id.sub, username: id.username, groups: id.claims?.['cognito:groups'] ?? [], claims: id.claims, provider: 'Cognito' }
69
+ : id != null
70
+ ? { userArn: id.userArn ?? null, accountId: id.accountId ?? null, username: id.username ?? null, provider: 'IAM' }
71
+ : null
72
+ }
73
+ };
74
+ }
75
+ export function response(ctx) {
76
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
77
+ return ctx.result;
78
+ }
79
+ `->Pulumi.Input.make
80
+
81
+ // Serialize the shared connection config into the env-config JSON (same shape
82
+ // EventCollectorRuntime_Builder_Single bakes into HANDLER_CONFIG).
83
+ let pgConnectionJson = (cc: PgConnection.connectionConfig): string =>
84
+ cc->PgConnection.connectionConfigToJson->JSON.stringify
85
+
86
+ let provision = (
87
+ ~api: Pulumi.Output.t<AppSync.GraphQLApi.t>,
88
+ ~selection: QueryDbBackend.selection,
89
+ ~opts: Pulumi.ComponentResource.options,
90
+ // The shared node(id) resolver is a single resolver on the API's Query.node
91
+ // field. In plugin-stack mode each plugin stack deploys independently onto one
92
+ // shared API, so only ONE stack may own that field — and a per-plugin node
93
+ // resolver only knows its own plugin's types anyway. So node is provisioned in
94
+ // monolithic mode only; plugin stacks pass false. (Node is not wired on the
95
+ // DynamoDB path either — it stays a monolithic-only capability for now.)
96
+ ~createNodeResolver: bool=true,
97
+ ): unit => {
98
+ // Join the resolver-binding registry (labelField/includeIdParam) with the
99
+ // ReadModel runtime registry (specModulePath, pgBacked). Only read models
100
+ // that both got Lambda resolvers AND are Postgres-backed are handled here.
101
+ let handlers =
102
+ entries
103
+ ->Dict.valuesToArray
104
+ ->Array.filterMap(entry =>
105
+ switch EventCollectorRuntime_Builder_Single.readModelInfos->Dict.get(entry.readModelName) {
106
+ | Some(info) if info.pgBacked => Some((entry, info.specModulePath))
107
+ | _ => None
108
+ }
109
+ )
110
+ if handlers->Array.length == 0 {
111
+ // No Postgres read models with Lambda resolvers — nothing to provision.
112
+ ()
113
+ } else {
114
+ // makeFromCodeAsset takes ComponentResource.options (the original); the raw
115
+ // IAM / DataSource resources take the CustomResourceOptions projection.
116
+ let customOpts =
117
+ opts->ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions
118
+ let name = "PgQueryResolver"
119
+
120
+ // Bundle the entry point plus each read model's spec package (imported at
121
+ // Lambda init for indexes / subIdField / schema → capability).
122
+ let packageDirs: dict<string> = Dict.make()
123
+ handlers->Array.forEach(((_entry, specModulePath)) => {
124
+ let pkg = Util_Bundle.extractPackageName(specModulePath)
125
+ packageDirs->Dict.set(pkg, Util_Bundle.resolvePackageRoot(pkg))
126
+ })
127
+
128
+ // Env config: shared pgConnection + one handler per read model. Sorted for
129
+ // deploy-stable output. specModule/labelField/includeIdParam are baked;
130
+ // indexes/subIdField/authorization come from the imported spec at runtime.
131
+ let handlerJsons =
132
+ handlers
133
+ ->Array.toSorted(((a, _), (b, _)) => String.compare(a.readModelName, b.readModelName))
134
+ ->Array.map(((entry, specModulePath)) => {
135
+ let specModule = specModulePath->JSON.stringifyAny->Option.getOr(`""`)
136
+ `{"readModelName":"${entry.readModelName}","specModule":${specModule},"labelField":"${entry.labelField}","includeIdParam":${bool(
137
+ entry.includeIdParam,
138
+ )}}`
139
+ })
140
+
141
+ // Relay node type → read-model map, baked so the entry point can serve node(id).
142
+ let nodeTypesJson =
143
+ nodeTypes
144
+ ->Dict.toArray
145
+ ->Array.toSorted(((a, _), (b, _)) => String.compare(a, b))
146
+ ->Array.map(((typeName, rm)) => `"${typeName}":"${rm}"`)
147
+ ->Array.join(",")
148
+
149
+ let queryResolverConfig =
150
+ selection.connectionConfig->Pulumi.Output.apply(cc =>
151
+ `{"pgConnection":${pgConnectionJson(cc)},"handlers":[${handlerJsons->Array.join(
152
+ ",",
153
+ )}],"nodeTypes":{${nodeTypesJson}}}`
154
+ )
155
+
156
+ let envVars: dict<Pulumi.Input.t<string>> = Dict.make()
157
+ envVars->Dict.set("QUERY_RESOLVER_CONFIG", queryResolverConfig->Pulumi.Output.asInput)
158
+ envVars->Dict.set("Environment", Pulumi.Pulumi.getStackName()->Pulumi.Input.make)
159
+
160
+ let {code, sourceCodeHash} = Util_Bundle.buildCodeArchive(
161
+ ~entryPointModule="@reventlessdev/reventless-aws/src/adapter/Runtime/PgQueryResolverEntryPoint.mjs",
162
+ ~packageDirs,
163
+ )
164
+
165
+ // In-VPC on the DB-access security group + private subnets (reach RDS).
166
+ let vpcConfig =
167
+ selection.securityGroupId
168
+ ->Pulumi.Output.apply(sgId =>
169
+ (
170
+ {
171
+ Lambda.Function.subnetIds: selection.subnetIds->Pulumi.Input.make,
172
+ securityGroupIds: [sgId->Pulumi.Input.make]->Pulumi.Input.make,
173
+ }: Lambda.Function.vpcConfig
174
+ )
175
+ )
176
+ ->Pulumi.Output.asInput
177
+
178
+ let runtime = RuntimeEnvironment_Lambda.makeFromCodeAsset(
179
+ ~name,
180
+ ~code,
181
+ ~sourceCodeHash,
182
+ ~envVars,
183
+ ~memorySize=512,
184
+ ~timeout=30,
185
+ ~vpcConfig,
186
+ ~opts,
187
+ )
188
+
189
+ // Read the RDS-managed secret so PgRuntime can resolve the DB password.
190
+ let _ = selection.connectionConfig->Pulumi.Output.apply(cc => {
191
+ open PolicyDocument
192
+ IAM.RolePolicy.make(
193
+ ~name=name ++ "-pgSecret",
194
+ ~args={
195
+ policy: PolicyDocument.make(
196
+ ~id=name ++ "-pgSecretPolicy",
197
+ ~statements=[
198
+ {
199
+ sid: "AllowGetPgSecret",
200
+ effect: Allow,
201
+ actions: Action("secretsmanager:GetSecretValue"),
202
+ resources: Resource(cc.secretArn),
203
+ },
204
+ ],
205
+ )
206
+ ->PolicyDocument.toJsonString
207
+ ->Pulumi.Input.make,
208
+ role: runtime.parts.lambdaRole.id->Pulumi.Output.asInput,
209
+ },
210
+ )
211
+ })
212
+
213
+ // AppSync → Lambda data source (one, shared by every Postgres RM's resolvers).
214
+ let dataSourceRole = IAM.Role.makeWithDefaultPolicy(
215
+ ~name=name ++ "DataSource",
216
+ ~servicePrincipal=AWS.AppSync.principal->Pulumi.Output.make,
217
+ ~opts=customOpts,
218
+ )
219
+ let lambdaArn = runtime.parts.lambda->Pulumi.Output.flatMap(l => l.arn)
220
+ let _ =
221
+ (lambdaArn, dataSourceRole.id)
222
+ ->Pulumi.Output.all2
223
+ ->Pulumi.Output.apply(((arn, dsRoleId)) => {
224
+ open PolicyDocument
225
+ IAM.RolePolicy.make(
226
+ ~name=name ++ "DataSourceInvoke",
227
+ ~args={
228
+ policy: PolicyDocument.make(
229
+ ~id=name ++ "DataSourceInvokePolicy",
230
+ ~statements=[
231
+ {
232
+ sid: "AllowDataSourceInvokeLambda",
233
+ effect: Allow,
234
+ actions: Action("lambda:InvokeFunction"),
235
+ resources: Resource(arn),
236
+ },
237
+ ],
238
+ )
239
+ ->PolicyDocument.toJsonString
240
+ ->Pulumi.Input.make,
241
+ role: dsRoleId->Pulumi.Input.make,
242
+ },
243
+ )
244
+ })
245
+
246
+ let dataSource = AppSync.DataSource.make(
247
+ ~name=name ++ "DataSource",
248
+ ~args={
249
+ type_: AWS_LAMBDA,
250
+ apiId: api->Pulumi.Output.flatMap(a => a.id)->Pulumi.Output.asInput,
251
+ lambdaConfig: {
252
+ AppSync.DataSource.functionArn: lambdaArn->Pulumi.Output.asInput,
253
+ }->Pulumi.Input.make,
254
+ serviceRoleArn: dataSourceRole.arn->Pulumi.Output.asInput,
255
+ },
256
+ ~opts=Some(customOpts),
257
+ )
258
+
259
+ // Shared node(id) resolver (B3.2c) — one for the whole API, dispatched by
260
+ // typeName in the Lambda. Only when some entity registered a node type
261
+ // (else the `node` field isn't in the schema) AND in monolithic mode
262
+ // (plugin stacks would collide on the single Query.node field).
263
+ if createNodeResolver && nodeTypes->Dict.toArray->Array.length > 0 {
264
+ let _nodeResolver = AppSync_Resolver_Retrying.makeUnitJsResolver(
265
+ ~name=name ++ "NodeResolver",
266
+ ~api,
267
+ ~dataSourceName=dataSource.name->Pulumi.Output.asInput,
268
+ ~type_="Query"->Pulumi.Input.make,
269
+ ~field="node"->Pulumi.Input.make,
270
+ ~code=nodeResolverCode,
271
+ ~opts=customOpts,
272
+ )
273
+ }
274
+
275
+ // Fulfil the deferred name the Postgres storage maker handed to resolvers.
276
+ let _ = dataSource.name->Pulumi.Output.apply(n => resolveDataSourceName.contents(n))
277
+ log.info(~comp="PgQueryResolver_Builder", `provisioned for ${handlers->Array.length->Int.toString} read model(s)`)
278
+ }
279
+ }