@reventlessdev/reventless-aws 3.0.0-alpha.209 → 3.0.0-alpha.210
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.
- package/CHANGELOG.md +9 -0
- package/package.json +7 -7
- package/src/Platform.res +445 -71
- package/src/Platform.res.mjs +318 -151
- package/src/components/Api/AppSync_Adapter.res +144 -10
- package/src/components/Api/AppSync_Adapter.res.mjs +62 -7
- package/src/components/Api/AppSync_MergedApi.res +218 -0
- package/src/components/Api/AppSync_MergedApi.res.mjs +122 -0
- package/src/components/Api/AppSync_SdlDecorate.res +47 -0
- package/src/components/Api/AppSync_SdlDecorate.res.mjs +39 -0
- package/tests/AppSync_AdapterTest.res +176 -0
- package/tests/AppSync_AdapterTest.res.mjs +134 -0
- package/tests/AppSync_SdlDecorateTest.res +27 -0
- package/tests/AppSync_SdlDecorateTest.res.mjs +20 -0
package/src/Platform.res
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
// module App = MyPlugin.Make(Platform)
|
|
10
10
|
//
|
|
11
11
|
// Custom config:
|
|
12
|
-
// module Platform = Platform.MakeWithConfig({
|
|
12
|
+
// module Platform = Platform.MakeWithConfig({
|
|
13
|
+
// let splitApi = false; let mergedApi = false; let cloner = true
|
|
14
|
+
// })
|
|
13
15
|
|
|
14
16
|
let log = ReventlessCore.Logger.fromEnv()
|
|
15
17
|
|
|
@@ -51,6 +53,17 @@ let getSplitApiOutputs = () => splitApiOutputsRef.contents
|
|
|
51
53
|
module MakeWithConfig = (
|
|
52
54
|
Config: {
|
|
53
55
|
let splitApi: bool
|
|
56
|
+
/** Merged-API composition (docs/plans/merged-api-push-free-composition.md).
|
|
57
|
+
`true` (the default — `Make()` sets it): the platform creates AppSync
|
|
58
|
+
MERGED APIs and the platform-owned APIs are ordinary SOURCE APIs with
|
|
59
|
+
declarative schemas; plugin stacks (deployPlugin) create their own
|
|
60
|
+
source API and associate it against the platform's exported merged-API
|
|
61
|
+
ARN. No fragment registration, no reactive schema push. `false` keeps
|
|
62
|
+
the legacy push path until its Phase-5 retirement — switching an
|
|
63
|
+
already-deployed platform in either direction requires wiping its
|
|
64
|
+
stacks. Supported with deployPlatform + deployPlugin (makePlatform
|
|
65
|
+
predates the merge path); all stacks of one platform must agree. */
|
|
66
|
+
let mergedApi: bool
|
|
54
67
|
let cloner: bool
|
|
55
68
|
let commandHandlerConfig: ReventlessCore.Runtime.commandHandlerConfigs
|
|
56
69
|
/** B2.3c platform toggle. When `Some`, every DCB EventLog is Postgres-backed
|
|
@@ -109,6 +122,8 @@ module MakeWithConfig = (
|
|
|
109
122
|
// (DynamoDB scan) and the schema-push SideEffect — admin store, off Postgres too.
|
|
110
123
|
QueryDbBackend.exempt(ReventlessCore.ApiFragmentsReadModelSpec.name)
|
|
111
124
|
})
|
|
125
|
+
let mergedApiMode = Config.mergedApi
|
|
126
|
+
|
|
112
127
|
type api = Types.AppSync.api
|
|
113
128
|
type role = Types.AppSync.role
|
|
114
129
|
type apiTarget = Domain | Platform
|
|
@@ -145,9 +160,59 @@ module MakeWithConfig = (
|
|
|
145
160
|
}: PulumiAws.IAM.Role.t)
|
|
146
161
|
}
|
|
147
162
|
|
|
163
|
+
// ── Merged-mode source SDL assembly (merged-api plan, Phase 3) ────────────
|
|
164
|
+
// Empty base fragment — no types, no mutations, no queries. Used by the
|
|
165
|
+
// plugin Api in split mode (so plugin schema has no core fields) and as the
|
|
166
|
+
// base of the split-mode Domain source document below.
|
|
167
|
+
let emptyBaseFragment = ReventlessCore.GraphQL_Stitcher.encode({
|
|
168
|
+
types: [],
|
|
169
|
+
mutations: [],
|
|
170
|
+
queries: [],
|
|
171
|
+
subscriptions: [],
|
|
172
|
+
subscriptionSources: [],
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// The canonical base document for a merged API's platform-owned source:
|
|
176
|
+
// stitched with an EMPTY plugin list (the stitch injects the relay base
|
|
177
|
+
// types and the global `node` query — only these canonical documents carry
|
|
178
|
+
// `node`), AWS-dialect decorated, and `@canonical`-stamped so the platform-
|
|
179
|
+
// owned shared types win over every plugin source's standalone copy
|
|
180
|
+
// (divergence is shadowed, not MERGE_FAILED — Phase-0 finding 1).
|
|
181
|
+
let assembleCanonicalSourceSdl = (~baseFragment): string =>
|
|
182
|
+
AppSync_Adapter.stitchWithAwsDirectives(~baseFragment, ~pluginFragments=[])
|
|
183
|
+
->AppSync_SdlDecorate.stampCanonicalTypes
|
|
184
|
+
|
|
185
|
+
// Admin base as a source-API document — same assembly as the push path's
|
|
186
|
+
// preAdminResolversSchemaHook, plus the canonical stamp.
|
|
187
|
+
let adminSourceSdl = (): string =>
|
|
188
|
+
assembleCanonicalSourceSdl(
|
|
189
|
+
~baseFragment=AppSync_Adapter.injectAwsAuthAll(
|
|
190
|
+
ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
|
|
191
|
+
~group="Admin",
|
|
192
|
+
~iamFieldNames=ReventlessCore.AdminApi.systemCallerFieldNames,
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
// Split-mode Domain source document: relay base + `node` only — the Domain
|
|
197
|
+
// merged API's canonical owner (plugin fields come from plugin sources).
|
|
198
|
+
let relayBaseSourceSdl = (): string => assembleCanonicalSourceSdl(~baseFragment=emptyBaseFragment)
|
|
199
|
+
|
|
148
200
|
let (domainApi, domainApiRole, platformApi, platformApiRole) = switch platformStackRef {
|
|
149
201
|
| None =>
|
|
150
|
-
let (api, role) =
|
|
202
|
+
let (api, role) = if mergedApiMode {
|
|
203
|
+
// Merged mode: the Domain API is an ordinary GRAPHQL source API with a
|
|
204
|
+
// DECLARATIVE schema. Unified: it carries the admin base (the canonical
|
|
205
|
+
// document). Split: it carries only the relay base + `node`; the admin
|
|
206
|
+
// base lives on the Platform source API created in deployPlatform.
|
|
207
|
+
let schema = if Config.splitApi {
|
|
208
|
+
relayBaseSourceSdl()
|
|
209
|
+
} else {
|
|
210
|
+
adminSourceSdl()
|
|
211
|
+
}
|
|
212
|
+
AppSync_Adapter.makeSourceApiResource(~name="DomainApi", ~schema, ~opts={})
|
|
213
|
+
} else {
|
|
214
|
+
AppSync_Adapter.makeApiResource(~name="DomainApi", ~opts={})
|
|
215
|
+
}
|
|
151
216
|
// In platform/monolithic mode the platform API is not yet known — it is created
|
|
152
217
|
// during deployPlatform/makePlatform and the ref is updated there.
|
|
153
218
|
(api, role, api, role)
|
|
@@ -253,7 +318,51 @@ module MakeWithConfig = (
|
|
|
253
318
|
makePhantomRole(platformApiRoleArn)
|
|
254
319
|
})
|
|
255
320
|
|
|
256
|
-
|
|
321
|
+
if mergedApiMode {
|
|
322
|
+
// Merged mode (merged-api plan, Phase 4): the plugin stack owns a real
|
|
323
|
+
// SOURCE API — the single writer for its subgraph schema and resolvers.
|
|
324
|
+
// It fills all four API slots (resolver wiring is target-agnostic here;
|
|
325
|
+
// apiTarget only decides WHICH merged API the association in
|
|
326
|
+
// deployPlugin points at). The user pool comes from the platform's
|
|
327
|
+
// exports so Cognito primary auth matches across all sources of the
|
|
328
|
+
// merged endpoint — the plugin stack provisions no pool/client.
|
|
329
|
+
let cognitoPoolIdOutput: Pulumi.Output.t<option<string>> =
|
|
330
|
+
stackRef->Pulumi.StackReference.getOutput("cognitoUserPoolId")
|
|
331
|
+
let cognitoRegionOutput: Pulumi.Output.t<option<string>> =
|
|
332
|
+
stackRef->Pulumi.StackReference.getOutput("cognitoRegion")
|
|
333
|
+
let userPoolConfig =
|
|
334
|
+
(cognitoPoolIdOutput, cognitoRegionOutput, defaultOutput)
|
|
335
|
+
->Pulumi.Output.all3
|
|
336
|
+
->Pulumi.Output.apply(((directPoolId, directRegion, default)) => {
|
|
337
|
+
let getFromDefault = key =>
|
|
338
|
+
default
|
|
339
|
+
->Option.flatMap(d => d->JSON.Decode.object)
|
|
340
|
+
->Option.flatMap(d => d->Dict.get(key))
|
|
341
|
+
->Option.flatMap(v => v->JSON.Decode.string)
|
|
342
|
+
let userPoolId =
|
|
343
|
+
directPoolId
|
|
344
|
+
->Option.orElse(getFromDefault("cognitoUserPoolId"))
|
|
345
|
+
->Option.getOrThrow(
|
|
346
|
+
~message="Platform stack does not export 'cognitoUserPoolId' — redeploy the platform stack first",
|
|
347
|
+
)
|
|
348
|
+
let awsRegion = directRegion->Option.orElse(getFromDefault("cognitoRegion"))
|
|
349
|
+
(
|
|
350
|
+
{
|
|
351
|
+
userPoolId,
|
|
352
|
+
?awsRegion,
|
|
353
|
+
defaultAction: PulumiAws.AppSync.GraphQLApi.ALLOW,
|
|
354
|
+
}: PulumiAws.AppSync.GraphQLApi.userPoolConfig
|
|
355
|
+
)
|
|
356
|
+
})
|
|
357
|
+
let (api, role) = AppSync_Adapter.makePluginSourceApiResource(
|
|
358
|
+
~name="PluginSourceApi",
|
|
359
|
+
~userPoolConfig,
|
|
360
|
+
~opts={},
|
|
361
|
+
)
|
|
362
|
+
(api, role, api, role)
|
|
363
|
+
} else {
|
|
364
|
+
(phantomApi, phantomRole, phantomPlatformApi, phantomPlatformRole)
|
|
365
|
+
}
|
|
257
366
|
}
|
|
258
367
|
|
|
259
368
|
// Expose api/apiRole as Platform.T value bindings so DCB slice builders
|
|
@@ -580,15 +689,8 @@ module MakeWithConfig = (
|
|
|
580
689
|
// The API-fragment registry is a SINGLETON AGGREGATE now (not a DCB slice) — see
|
|
581
690
|
// ApiFragmentRegistryAggregate / ApiFragmentsReadModel below.
|
|
582
691
|
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
let emptyBaseFragment = ReventlessCore.GraphQL_Stitcher.encode({
|
|
586
|
-
types: [],
|
|
587
|
-
mutations: [],
|
|
588
|
-
queries: [],
|
|
589
|
-
subscriptions: [],
|
|
590
|
-
subscriptionSources: [],
|
|
591
|
-
})
|
|
692
|
+
// (emptyBaseFragment — the empty split-mode plugin base — is defined above,
|
|
693
|
+
// next to the merged-mode source SDL assembly that shares it.)
|
|
592
694
|
|
|
593
695
|
module Api = {
|
|
594
696
|
module Make = (
|
|
@@ -631,6 +733,12 @@ module MakeWithConfig = (
|
|
|
631
733
|
// plugins built afterwards read hooksApiRef (Domain/deploy-target). See platformHooks.adminApi.
|
|
632
734
|
let hooksAdminApiRef: ref<option<ReventlessCore.Plugin_Helpers.hookedValue<unknown>>> = ref(None)
|
|
633
735
|
|
|
736
|
+
// Merged mode (plugin stack): the subgraph schema-push Output produced by
|
|
737
|
+
// preResolversSchemaHook, captured so deployPlugin can sequence the
|
|
738
|
+
// SourceApiAssociation behind it (the association's initial merge needs the
|
|
739
|
+
// source schema to exist on the plugin's source API).
|
|
740
|
+
let mergedSchemaPushedRef: ref<option<Pulumi.Output.t<unit>>> = ref(None)
|
|
741
|
+
|
|
634
742
|
let resolveHookedApi = (): Types.AppSync.api =>
|
|
635
743
|
switch hooksApiRef.contents {
|
|
636
744
|
| Some({val}) => Obj.magic(val)
|
|
@@ -838,69 +946,107 @@ module MakeWithConfig = (
|
|
|
838
946
|
// makePlatform / deployPlatform before Admin.construct fires, is visible):
|
|
839
947
|
// - split mode: platformApi from splitApiOutputsRef
|
|
840
948
|
// - unified mode / not-yet-populated: domainApi
|
|
841
|
-
preAdminResolversSchemaHook: (~adminBarrier) =>
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
// ONLY when the PlatformApi is known; if the ref is not yet populated we
|
|
849
|
-
// SKIP (never fall back to domainApi). Unified mode legitimately shares one
|
|
850
|
-
// API, so an unpopulated ref there means domainApi.
|
|
851
|
-
let targetApiOpt = switch (Config.splitApi, splitApiOutputsRef.contents) {
|
|
852
|
-
| (_, Some({platformApi})) => Some(platformApi)
|
|
853
|
-
| (false, None) => Some(domainApi)
|
|
854
|
-
| (true, None) => None
|
|
855
|
-
}
|
|
856
|
-
switch targetApiOpt {
|
|
857
|
-
| None =>
|
|
858
|
-
log.error(
|
|
859
|
-
~comp="preAdminResolversSchemaHook",
|
|
860
|
-
"split mode but the PlatformApi is not available at hook time — SKIPPING the admin schema push to avoid clobbering the DomainApi (pushing admin-base here would wipe every plugin field)",
|
|
861
|
-
)
|
|
949
|
+
preAdminResolversSchemaHook: (~adminBarrier) =>
|
|
950
|
+
if mergedApiMode {
|
|
951
|
+
// Merged mode: the admin base SDL is DECLARATIVE on the source API
|
|
952
|
+
// resource (makeSourceApiResource) — the provider runs
|
|
953
|
+
// StartSchemaCreation + poll before the resource resolves, so admin
|
|
954
|
+
// resolvers chained on the API are already ordered after the schema
|
|
955
|
+
// is ACTIVE. No push.
|
|
862
956
|
adminBarrier
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
)
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
)
|
|
876
|
-
(
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
957
|
+
} else {
|
|
958
|
+
// The admin-base SDL stitches AdminApi.baseFragment with an EMPTY plugin
|
|
959
|
+
// list, and startSchemaCreation REPLACES the whole schema. In split mode
|
|
960
|
+
// the admin schema belongs on the PlatformApi ONLY — the DomainApi carries
|
|
961
|
+
// plugin fields (emptyBaseFragment). If we pushed the admin-base-only SDL
|
|
962
|
+
// to the DomainApi it would wipe every plugin field, leaving exactly the
|
|
963
|
+
// admin-base set (the alpha 2026-07-08 clobber). So in split mode we push
|
|
964
|
+
// ONLY when the PlatformApi is known; if the ref is not yet populated we
|
|
965
|
+
// SKIP (never fall back to domainApi). Unified mode legitimately shares one
|
|
966
|
+
// API, so an unpopulated ref there means domainApi.
|
|
967
|
+
let targetApiOpt = switch (Config.splitApi, splitApiOutputsRef.contents) {
|
|
968
|
+
| (_, Some({platformApi})) => Some(platformApi)
|
|
969
|
+
| (false, None) => Some(domainApi)
|
|
970
|
+
| (true, None) => None
|
|
971
|
+
}
|
|
972
|
+
switch targetApiOpt {
|
|
973
|
+
| None =>
|
|
974
|
+
log.error(
|
|
975
|
+
~comp="preAdminResolversSchemaHook",
|
|
976
|
+
"split mode but the PlatformApi is not available at hook time — SKIPPING the admin schema push to avoid clobbering the DomainApi (pushing admin-base here would wipe every plugin field)",
|
|
977
|
+
)
|
|
978
|
+
adminBarrier
|
|
979
|
+
| Some(targetApi) =>
|
|
980
|
+
let adminBaseFragment = AppSync_Adapter.injectAwsAuthAll(
|
|
981
|
+
ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
|
|
982
|
+
~group="Admin",
|
|
983
|
+
// The ApiFragmentRegistry register/deregister mutations + the Platform_ApiFragments
|
|
984
|
+
// status query are invoked by the plugin/standalone deploy as a SigV4 system caller, so
|
|
985
|
+
// they carry the dual-auth (@aws_cognito_user_pools @aws_iam) directive.
|
|
986
|
+
~iamFieldNames=ReventlessCore.AdminApi.systemCallerFieldNames,
|
|
987
|
+
)
|
|
988
|
+
let sdl = AppSync_Adapter.stitchWithAwsDirectives(
|
|
989
|
+
~baseFragment=adminBaseFragment,
|
|
990
|
+
~pluginFragments=[],
|
|
991
|
+
)
|
|
992
|
+
(targetApi, adminBarrier)
|
|
993
|
+
->Pulumi.Output.all2
|
|
994
|
+
->Pulumi.Output.flatMap(((api, _)) =>
|
|
995
|
+
api.id->Pulumi.Output.flatMap(apiId => {
|
|
996
|
+
log.info(~comp="preAdminResolversSchemaHook", `Pushing admin schema to ${apiId}`)
|
|
997
|
+
let client = AppSync_Adapter.getClient()
|
|
998
|
+
client
|
|
999
|
+
->AppSync_Adapter.startSchemaCreationRetrying({apiId, definition: sdl})
|
|
1000
|
+
->Promise.then(async _ => {
|
|
1001
|
+
log.info(
|
|
1002
|
+
~comp="preAdminResolversSchemaHook",
|
|
1003
|
+
"startSchemaCreation called, waiting for ACTIVE",
|
|
1004
|
+
)
|
|
1005
|
+
await AppSync_Adapter.waitForSchemaActive(client, apiId)
|
|
1006
|
+
log.info(~comp="preAdminResolversSchemaHook", "schema is ACTIVE")
|
|
1007
|
+
})
|
|
1008
|
+
->Pulumi.Output.fromPromise
|
|
891
1009
|
})
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
}
|
|
896
|
-
},
|
|
1010
|
+
)
|
|
1011
|
+
}
|
|
1012
|
+
},
|
|
897
1013
|
|
|
898
1014
|
// Staged deploy (deployPlugin against a running platform): register the
|
|
899
1015
|
// plugin's fragment via the Platform API (SigV4); the reactive ApiSchemaPush
|
|
900
1016
|
// SideEffect stitches + pushes cumulatively. The legacy all-at-once
|
|
901
1017
|
// deploy-schema:* write+scan+push path was retired in Phase 4b (makePlatform
|
|
902
1018
|
// with plugins is no longer supported on AWS).
|
|
903
|
-
preResolversSchemaHook: (~name, ~version, pluginFragment) =>
|
|
1019
|
+
preResolversSchemaHook: (~name, ~version, pluginFragment) =>
|
|
1020
|
+
if mergedApiMode {
|
|
1021
|
+
// Merged mode (merged-api plan, Phase 4): push the plugin's standalone
|
|
1022
|
+
// subgraph document to the plugin's OWN source API — a single writer
|
|
1023
|
+
// by construction, so no SigV4 registration, no reactive stitcher, no
|
|
1024
|
+
// waiter, no drift check, no destroy-path deregistration. The returned
|
|
1025
|
+
// Output gates resolver creation (as on the push path) and deployPlugin
|
|
1026
|
+
// additionally sequences the SourceApiAssociation behind it (the
|
|
1027
|
+
// initial merge needs the source schema present). Under AUTO_MERGE
|
|
1028
|
+
// every later schema update here re-merges automatically.
|
|
1029
|
+
let sdl = AppSync_Adapter.stitchStandaloneWithAwsDirectives(~fragment=pluginFragment)
|
|
1030
|
+
let pushed =
|
|
1031
|
+
domainApi->Pulumi.Output.flatMap(api =>
|
|
1032
|
+
api.id->Pulumi.Output.flatMap(apiId => {
|
|
1033
|
+
log.info(
|
|
1034
|
+
~comp="preResolversSchemaHook",
|
|
1035
|
+
`Pushing subgraph schema for ${name}@${version} to source API ${apiId}`,
|
|
1036
|
+
)
|
|
1037
|
+
let client = AppSync_Adapter.getClient()
|
|
1038
|
+
client
|
|
1039
|
+
->AppSync_Adapter.startSchemaCreationRetrying({apiId, definition: sdl})
|
|
1040
|
+
->Promise.then(async _ => {
|
|
1041
|
+
await AppSync_Adapter.waitForSchemaActive(client, apiId)
|
|
1042
|
+
log.info(~comp="preResolversSchemaHook", "subgraph schema is ACTIVE")
|
|
1043
|
+
})
|
|
1044
|
+
->Pulumi.Output.fromPromise
|
|
1045
|
+
})
|
|
1046
|
+
)
|
|
1047
|
+
mergedSchemaPushedRef := Some(pushed)
|
|
1048
|
+
pushed
|
|
1049
|
+
} else {
|
|
904
1050
|
log.info(
|
|
905
1051
|
~comp="preResolversSchemaHook",
|
|
906
1052
|
`Pushing schema for plugin ${name}@${version} to AppSync`,
|
|
@@ -929,7 +1075,7 @@ module MakeWithConfig = (
|
|
|
929
1075
|
"deployPlatform and each plugin with deployPlugin (staged register + reactive push).",
|
|
930
1076
|
)
|
|
931
1077
|
}
|
|
932
|
-
|
|
1078
|
+
},
|
|
933
1079
|
// DCB EventLog created hook — extracts DynamoDB table name for DCB CommandTopic Lambda handler.
|
|
934
1080
|
// Postgres-backed DCB logs (B2.3c) create no table, so there is no resource to read:
|
|
935
1081
|
// the DCB command Lambda derives its `dcb_event.log_name` from the plugin name and
|
|
@@ -1283,6 +1429,14 @@ module MakeWithConfig = (
|
|
|
1283
1429
|
// In unified mode, makePlatform is a no-op (schema stitching handled by events).
|
|
1284
1430
|
let makePlatform = (~version, ~plugins: array<module(PluginMaker)>) => {
|
|
1285
1431
|
log.info(~comp="Platform", `v${version}`)
|
|
1432
|
+
if mergedApiMode {
|
|
1433
|
+
// Merged-API composition is wired in deployPlatform only (the sole
|
|
1434
|
+
// supported staged AWS deploy path) — makePlatform predates it.
|
|
1435
|
+
failwith(
|
|
1436
|
+
"mergedApi mode is supported with deployPlatform only — deploy the platform with " ++
|
|
1437
|
+
"deployPlatform and each plugin with deployPlugin.",
|
|
1438
|
+
)
|
|
1439
|
+
}
|
|
1286
1440
|
// Create scheduler and populate platform context refs so Plugin_Builder
|
|
1287
1441
|
// can read them without app plugins having to pass them through.
|
|
1288
1442
|
let scheduler = makeScheduler()
|
|
@@ -1473,6 +1627,18 @@ module MakeWithConfig = (
|
|
|
1473
1627
|
bundleVersion: string,
|
|
1474
1628
|
}
|
|
1475
1629
|
|
|
1630
|
+
// Merged-mode outputs of deployPlatform — the merged API(s), plus the
|
|
1631
|
+
// deploy-time merge gates (Outputs that resolve on MERGE_SUCCESS and fail
|
|
1632
|
+
// the deploy on MERGE_FAILED; folded into the ARN exports so they are
|
|
1633
|
+
// consumed). platformMerged == domainMerged in unified mode, mirroring the
|
|
1634
|
+
// platformApi/domainApi convention.
|
|
1635
|
+
type mergedApiOutputs = {
|
|
1636
|
+
domainMerged: AppSync_MergedApi.t,
|
|
1637
|
+
platformMerged: AppSync_MergedApi.t,
|
|
1638
|
+
domainMergeGate: Pulumi.Output.t<unit>,
|
|
1639
|
+
platformMergeGate: Pulumi.Output.t<unit>,
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1476
1642
|
let deployPlatform = (~version, ~hostUiBundle: option<hostUiBundleConfig>=?) => {
|
|
1477
1643
|
log.info(~comp="Platform:deployPlatform", `v${version}`)
|
|
1478
1644
|
let scheduler = makeScheduler()
|
|
@@ -1483,12 +1649,79 @@ module MakeWithConfig = (
|
|
|
1483
1649
|
// Phase 2: create the Platform API resource early — before Admin.construct —
|
|
1484
1650
|
// so admin resolvers are attached to the correct API in split mode.
|
|
1485
1651
|
// In unified mode this is the same resource as the Domain API.
|
|
1652
|
+
// On the merge path the Platform API is an ordinary GRAPHQL source API
|
|
1653
|
+
// carrying the admin canonical document declaratively.
|
|
1486
1654
|
let (platformApi, platformApiRole) = if Config.splitApi {
|
|
1487
|
-
|
|
1655
|
+
if mergedApiMode {
|
|
1656
|
+
AppSync_Adapter.makeSourceApiResource(~name="PlatformApi", ~schema=adminSourceSdl(), ~opts={})
|
|
1657
|
+
} else {
|
|
1658
|
+
AppSync_Adapter.makeApiResource(~name="PlatformApi", ~opts={})
|
|
1659
|
+
}
|
|
1488
1660
|
} else {
|
|
1489
1661
|
(domainApi, domainApiRole)
|
|
1490
1662
|
}
|
|
1491
1663
|
|
|
1664
|
+
// ── Merged-API composition (merged-api plan, Phase 3) ──────────────────
|
|
1665
|
+
// Create the merged API(s) and associate the platform-owned source(s).
|
|
1666
|
+
// Plugin stacks associate their own source APIs against the exported
|
|
1667
|
+
// merged-API ARN (Phase 4); `pulumi destroy` of a plugin stack deletes
|
|
1668
|
+
// its association — retirement by construction.
|
|
1669
|
+
let mergedOutputs = if mergedApiMode {
|
|
1670
|
+
AppSync_MergedApi.assertCompatiblePrimaryAuth(
|
|
1671
|
+
~sourceMode=AppSync_MergedApi.authenticationTypeName(
|
|
1672
|
+
AppSync_Adapter.primaryAuthenticationType,
|
|
1673
|
+
),
|
|
1674
|
+
~mergedMode=AppSync_MergedApi.primaryAuthMode,
|
|
1675
|
+
)
|
|
1676
|
+
let domainMerged = AppSync_MergedApi.make(~name="DomainMergedApi", ~opts={})
|
|
1677
|
+
if Config.splitApi {
|
|
1678
|
+
// Split: admin source → Platform merged API; the relay-base Domain
|
|
1679
|
+
// source (the Domain merged API's canonical owner) → Domain merged.
|
|
1680
|
+
let platformMerged = AppSync_MergedApi.make(~name="PlatformMergedApi", ~opts={})
|
|
1681
|
+
let platformAssoc = AppSync_MergedApi.associateSource(
|
|
1682
|
+
~name="PlatformAdminSourceAssociation",
|
|
1683
|
+
~mergedApi=platformMerged,
|
|
1684
|
+
~sourceApi=platformApi,
|
|
1685
|
+
~opts={},
|
|
1686
|
+
)
|
|
1687
|
+
let domainAssoc = AppSync_MergedApi.associateSource(
|
|
1688
|
+
~name="DomainBaseSourceAssociation",
|
|
1689
|
+
~mergedApi=domainMerged,
|
|
1690
|
+
~sourceApi=domainApi,
|
|
1691
|
+
~opts={},
|
|
1692
|
+
)
|
|
1693
|
+
Some({
|
|
1694
|
+
domainMerged,
|
|
1695
|
+
platformMerged,
|
|
1696
|
+
domainMergeGate: AppSync_MergedApi.mergeStatusGate(
|
|
1697
|
+
~mergedApi=domainMerged,
|
|
1698
|
+
~association=domainAssoc,
|
|
1699
|
+
),
|
|
1700
|
+
platformMergeGate: AppSync_MergedApi.mergeStatusGate(
|
|
1701
|
+
~mergedApi=platformMerged,
|
|
1702
|
+
~association=platformAssoc,
|
|
1703
|
+
),
|
|
1704
|
+
})
|
|
1705
|
+
} else {
|
|
1706
|
+
// Unified: the single source API carries the admin canonical document.
|
|
1707
|
+
let assoc = AppSync_MergedApi.associateSource(
|
|
1708
|
+
~name="DomainAdminSourceAssociation",
|
|
1709
|
+
~mergedApi=domainMerged,
|
|
1710
|
+
~sourceApi=domainApi,
|
|
1711
|
+
~opts={},
|
|
1712
|
+
)
|
|
1713
|
+
let gate = AppSync_MergedApi.mergeStatusGate(~mergedApi=domainMerged, ~association=assoc)
|
|
1714
|
+
Some({
|
|
1715
|
+
domainMerged,
|
|
1716
|
+
platformMerged: domainMerged,
|
|
1717
|
+
domainMergeGate: gate,
|
|
1718
|
+
platformMergeGate: gate,
|
|
1719
|
+
})
|
|
1720
|
+
}
|
|
1721
|
+
} else {
|
|
1722
|
+
None
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1492
1725
|
// Admin DCB mutation resolvers bind to the Platform API (split mode) or the shared api
|
|
1493
1726
|
// (unified). Recorded now that the Platform API resource exists, so the admin's deferred
|
|
1494
1727
|
// dcbConnectFn (via ~onAdminApi) targets it rather than the Domain/deploy-target hooksApiRef.
|
|
@@ -1764,6 +1997,44 @@ module MakeWithConfig = (
|
|
|
1764
1997
|
)
|
|
1765
1998
|
Pulumi.Pulumi.export("domainApiRoleArn", domainApiRole->Pulumi.Output.flatMap(role => role.arn))
|
|
1766
1999
|
|
|
2000
|
+
// Merged-API exports — plugin stacks associate their source APIs against
|
|
2001
|
+
// these ARNs (this replaces the SigV4 RegisterApiFragment handshake as the
|
|
2002
|
+
// cross-stack wiring on the merge path), and `mergedApiPrimaryAuth` is the
|
|
2003
|
+
// primary-auth contract source APIs must match. The ARN exports are gated
|
|
2004
|
+
// on the merge-status poll so a MERGE_FAILED fails the deploy loudly
|
|
2005
|
+
// instead of silently serving the last-good merged schema.
|
|
2006
|
+
switch mergedOutputs {
|
|
2007
|
+
| Some({domainMerged, platformMerged, domainMergeGate, platformMergeGate}) =>
|
|
2008
|
+
let mergeGatedArn = (merged: AppSync_MergedApi.t, gate: Pulumi.Output.t<unit>) =>
|
|
2009
|
+
(
|
|
2010
|
+
merged.api->Pulumi.Output.flatMap((api: PulumiAws.AppSync.GraphQLApi.t) => api.arn),
|
|
2011
|
+
gate,
|
|
2012
|
+
)
|
|
2013
|
+
->Pulumi.Output.all2
|
|
2014
|
+
->Pulumi.Output.apply(((arn, _)) => arn)
|
|
2015
|
+
let mergedEndpoint = (merged: AppSync_MergedApi.t) =>
|
|
2016
|
+
merged.api->Pulumi.Output.flatMap((api: PulumiAws.AppSync.GraphQLApi.t) =>
|
|
2017
|
+
api.uris->Pulumi.Output.apply(uris => uris.graphQL)
|
|
2018
|
+
)
|
|
2019
|
+
Pulumi.Pulumi.export("domainMergedApiArn", mergeGatedArn(domainMerged, domainMergeGate))
|
|
2020
|
+
Pulumi.Pulumi.export(
|
|
2021
|
+
"domainMergedApiId",
|
|
2022
|
+
domainMerged.api->Pulumi.Output.flatMap((api: PulumiAws.AppSync.GraphQLApi.t) => api.id),
|
|
2023
|
+
)
|
|
2024
|
+
Pulumi.Pulumi.export("domainMergedApiEndpoint", mergedEndpoint(domainMerged))
|
|
2025
|
+
Pulumi.Pulumi.export("platformMergedApiArn", mergeGatedArn(platformMerged, platformMergeGate))
|
|
2026
|
+
Pulumi.Pulumi.export(
|
|
2027
|
+
"platformMergedApiId",
|
|
2028
|
+
platformMerged.api->Pulumi.Output.flatMap((api: PulumiAws.AppSync.GraphQLApi.t) => api.id),
|
|
2029
|
+
)
|
|
2030
|
+
Pulumi.Pulumi.export("platformMergedApiEndpoint", mergedEndpoint(platformMerged))
|
|
2031
|
+
Pulumi.Pulumi.export(
|
|
2032
|
+
"mergedApiPrimaryAuth",
|
|
2033
|
+
Pulumi.Output.make(AppSync_MergedApi.primaryAuthMode),
|
|
2034
|
+
)
|
|
2035
|
+
| None => ()
|
|
2036
|
+
}
|
|
2037
|
+
|
|
1767
2038
|
// Events API exports — consumed by plugin stacks to wire Source B (StateTopic) Lambdas.
|
|
1768
2039
|
switch domainEventsApiOpt {
|
|
1769
2040
|
| Some(eventsApi) =>
|
|
@@ -1800,11 +2071,22 @@ module MakeWithConfig = (
|
|
|
1800
2071
|
)
|
|
1801
2072
|
|
|
1802
2073
|
// Fire onPlatformDeployed hook with resolved platform metadata.
|
|
1803
|
-
|
|
2074
|
+
// Client-facing endpoints: on the merge path clients query the MERGED
|
|
2075
|
+
// endpoints (the source-API endpoints stay exported for coexisting
|
|
2076
|
+
// push-path stacks) — these also feed the host-UI config.json below.
|
|
2077
|
+
let clientDomainApi = switch mergedOutputs {
|
|
2078
|
+
| Some({domainMerged}) => domainMerged.api
|
|
2079
|
+
| None => domainApi
|
|
2080
|
+
}
|
|
2081
|
+
let clientPlatformApi = switch mergedOutputs {
|
|
2082
|
+
| Some({platformMerged}) => platformMerged.api
|
|
2083
|
+
| None => platformApi
|
|
2084
|
+
}
|
|
2085
|
+
let resolvedDomainApiEndpoint = clientDomainApi->Pulumi.Output.flatMap(api =>
|
|
1804
2086
|
api.uris->Pulumi.Output.apply(uris => uris.graphQL)
|
|
1805
2087
|
)
|
|
1806
2088
|
let resolvedDomainApiRoleArn = domainApiRole->Pulumi.Output.flatMap(role => role.arn)
|
|
1807
|
-
let resolvedPlatformApiEndpoint =
|
|
2089
|
+
let resolvedPlatformApiEndpoint = clientPlatformApi->Pulumi.Output.flatMap(api =>
|
|
1808
2090
|
api.uris->Pulumi.Output.apply(uris => uris.graphQL)
|
|
1809
2091
|
)
|
|
1810
2092
|
let resolvedPlatformApiRoleArn = platformApiRole->Pulumi.Output.flatMap(role => role.arn)
|
|
@@ -2024,6 +2306,97 @@ module MakeWithConfig = (
|
|
|
2024
2306
|
let pluginOutputs = pluginComponent->ReventlessCore.Component.outputs
|
|
2025
2307
|
ReventlessCore.Plugin_Helpers.exportPluginOutputs(pluginOutputs)
|
|
2026
2308
|
|
|
2309
|
+
// ── Merged-API association (merged-api plan, Phase 4) ───────────────────
|
|
2310
|
+
// Associate this plugin's source API with the platform's merged API —
|
|
2311
|
+
// this replaces the SigV4 RegisterApiFragment handshake + reactive push +
|
|
2312
|
+
// waiter as the schema-composition mechanism. `pulumi destroy` deletes
|
|
2313
|
+
// the association + source API: retirement by construction. Create-time
|
|
2314
|
+
// 409s (AWS serializes association creates per merged API) surface as a
|
|
2315
|
+
// deploy failure — retry concurrent FIRST-TIME plugin deploys; steady-
|
|
2316
|
+
// state schema updates never re-create the association.
|
|
2317
|
+
switch (mergedApiMode, platformStackRef) {
|
|
2318
|
+
| (true, Some(stackRef)) =>
|
|
2319
|
+
let defaultOutput: Pulumi.Output.t<option<JSON.t>> =
|
|
2320
|
+
stackRef->Pulumi.StackReference.getOutput("default")
|
|
2321
|
+
let getMergedExport = (key: string): Pulumi.Output.t<string> => {
|
|
2322
|
+
let direct: Pulumi.Output.t<option<string>> =
|
|
2323
|
+
stackRef->Pulumi.StackReference.getOutput(key)
|
|
2324
|
+
(direct, defaultOutput)
|
|
2325
|
+
->Pulumi.Output.all2
|
|
2326
|
+
->Pulumi.Output.apply(((direct, default)) =>
|
|
2327
|
+
switch direct {
|
|
2328
|
+
| Some(v) => v
|
|
2329
|
+
| None =>
|
|
2330
|
+
default
|
|
2331
|
+
->Option.flatMap(d => d->JSON.Decode.object)
|
|
2332
|
+
->Option.flatMap(d => d->Dict.get(key))
|
|
2333
|
+
->Option.flatMap(v => v->JSON.Decode.string)
|
|
2334
|
+
->Option.getOrThrow(
|
|
2335
|
+
~message=`Platform stack does not export '${key}' — deploy the platform with mergedApi=true first`,
|
|
2336
|
+
)
|
|
2337
|
+
}
|
|
2338
|
+
)
|
|
2339
|
+
}
|
|
2340
|
+
// apiTarget collapses to "which merged API ARN the association points at".
|
|
2341
|
+
let mergedApiArn = switch apiTarget {
|
|
2342
|
+
| Domain => getMergedExport("domainMergedApiArn")
|
|
2343
|
+
| Platform => getMergedExport("platformMergedApiArn")
|
|
2344
|
+
}
|
|
2345
|
+
// Primary-auth contract check (checked invariant, not a convention) —
|
|
2346
|
+
// folded into the ARN the association consumes so it always runs.
|
|
2347
|
+
let checkedMergedApiArn =
|
|
2348
|
+
(mergedApiArn, getMergedExport("mergedApiPrimaryAuth"))
|
|
2349
|
+
->Pulumi.Output.all2
|
|
2350
|
+
->Pulumi.Output.apply(((arn, mergedMode)) => {
|
|
2351
|
+
AppSync_MergedApi.assertCompatiblePrimaryAuth(
|
|
2352
|
+
~sourceMode=AppSync_MergedApi.authenticationTypeName(
|
|
2353
|
+
AppSync_Adapter.primaryAuthenticationType,
|
|
2354
|
+
),
|
|
2355
|
+
~mergedMode,
|
|
2356
|
+
)
|
|
2357
|
+
arn
|
|
2358
|
+
})
|
|
2359
|
+
// Sequence the association behind the subgraph schema push (the
|
|
2360
|
+
// intra-stack replacement of the schemaPushed cross-stack gate).
|
|
2361
|
+
let schemaPushed = switch mergedSchemaPushedRef.contents {
|
|
2362
|
+
| Some(pushed) => pushed
|
|
2363
|
+
| None => Pulumi.Output.make()
|
|
2364
|
+
}
|
|
2365
|
+
let arnAfterSchemaPush =
|
|
2366
|
+
(checkedMergedApiArn, schemaPushed)
|
|
2367
|
+
->Pulumi.Output.all2
|
|
2368
|
+
->Pulumi.Output.apply(((arn, _)) => arn)
|
|
2369
|
+
let association = AppSync_MergedApi.associateSourceWithMergedArn(
|
|
2370
|
+
~name="PluginSourceAssociation",
|
|
2371
|
+
~mergedApiArn=arnAfterSchemaPush,
|
|
2372
|
+
~sourceApi=domainApi,
|
|
2373
|
+
~opts={},
|
|
2374
|
+
)
|
|
2375
|
+
// Fail the deploy loudly on MERGE_FAILED — the gate is folded into the
|
|
2376
|
+
// exported association id so it is always consumed.
|
|
2377
|
+
let mergeGate = AppSync_MergedApi.mergeStatusGateWith(
|
|
2378
|
+
~mergedApiIdentifier=checkedMergedApiArn,
|
|
2379
|
+
~association,
|
|
2380
|
+
)
|
|
2381
|
+
Pulumi.Pulumi.export(
|
|
2382
|
+
"sourceApiAssociationId",
|
|
2383
|
+
(association.associationId, mergeGate)
|
|
2384
|
+
->Pulumi.Output.all2
|
|
2385
|
+
->Pulumi.Output.apply(((id, _)) => id),
|
|
2386
|
+
)
|
|
2387
|
+
Pulumi.Pulumi.export(
|
|
2388
|
+
"pluginSourceApiId",
|
|
2389
|
+
domainApi->Pulumi.Output.flatMap(api => api.id),
|
|
2390
|
+
)
|
|
2391
|
+
Pulumi.Pulumi.export(
|
|
2392
|
+
"pluginSourceApiEndpoint",
|
|
2393
|
+
domainApi->Pulumi.Output.flatMap(api =>
|
|
2394
|
+
api.uris->Pulumi.Output.apply(uris => uris.graphQL)
|
|
2395
|
+
),
|
|
2396
|
+
)
|
|
2397
|
+
| _ => ()
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2027
2400
|
// B2.3d: provision the Postgres change-feed relay (plugin-stack mode — this
|
|
2028
2401
|
// plugin's Postgres DCB log(s) + collector queue were registered during P.make()).
|
|
2029
2402
|
provisionPgChangeFeedRelay()
|
|
@@ -2102,6 +2475,7 @@ module Make = (): (
|
|
|
2102
2475
|
) => {
|
|
2103
2476
|
include MakeWithConfig({
|
|
2104
2477
|
let splitApi = true
|
|
2478
|
+
let mergedApi = true
|
|
2105
2479
|
let cloner = false
|
|
2106
2480
|
let commandHandlerConfig: ReventlessCore.Runtime.commandHandlerConfigs = {}
|
|
2107
2481
|
let pgConnection = None
|