@reventlessdev/reventless-aws 3.0.0-alpha.208 → 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 +17 -0
- package/package.json +7 -7
- package/src/Platform.res +478 -545
- package/src/Platform.res.mjs +393 -717
- package/src/adapter/Runtime/AdminEventCollectorEntryPoint.mjs +7 -382
- package/src/adapter/Runtime/ApiSchemaPush_Runtime.mjs +8 -1
- package/src/components/Api/AppSync_Adapter.res +144 -101
- package/src/components/Api/AppSync_Adapter.res.mjs +62 -91
- 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/src/plugin/runtime/PluginRuntime_Builder.res +0 -139
- package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +4 -80
- 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/tests/MCP_LambdaTest.res +6 -4
- package/tests/MCP_LambdaTest.res.mjs +2 -2
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
|
|
|
@@ -17,14 +19,6 @@ let log = ReventlessCore.Logger.fromEnv()
|
|
|
17
19
|
// runtime RUNTIME_SCHEMA_SHRINK_THRESHOLD in AdminEventCollectorEntryPoint.mjs).
|
|
18
20
|
// A push whose stitched SDL has fewer than (threshold × live) root fields is
|
|
19
21
|
// refused as a likely stale concurrent-deploy stitch. Default 0.5; override via
|
|
20
|
-
// DEPLOY_SCHEMA_SHRINK_THRESHOLD; values outside (0, 1) fall back to the default.
|
|
21
|
-
@val @scope("process") external processEnv: Dict.t<string> = "env"
|
|
22
|
-
let deploySchemaShrinkThreshold: float =
|
|
23
|
-
switch processEnv->Dict.get("DEPLOY_SCHEMA_SHRINK_THRESHOLD")->Option.flatMap(Float.fromString) {
|
|
24
|
-
| Some(n) if n > 0. && n < 1. => n
|
|
25
|
-
| _ => 0.5
|
|
26
|
-
}
|
|
27
|
-
|
|
28
22
|
// API config ref — populated during MakeWithConfig so slice builders
|
|
29
23
|
// can access api/apiRole outside the functor constraint.
|
|
30
24
|
//
|
|
@@ -59,6 +53,17 @@ let getSplitApiOutputs = () => splitApiOutputsRef.contents
|
|
|
59
53
|
module MakeWithConfig = (
|
|
60
54
|
Config: {
|
|
61
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
|
|
62
67
|
let cloner: bool
|
|
63
68
|
let commandHandlerConfig: ReventlessCore.Runtime.commandHandlerConfigs
|
|
64
69
|
/** B2.3c platform toggle. When `Some`, every DCB EventLog is Postgres-backed
|
|
@@ -117,6 +122,8 @@ module MakeWithConfig = (
|
|
|
117
122
|
// (DynamoDB scan) and the schema-push SideEffect — admin store, off Postgres too.
|
|
118
123
|
QueryDbBackend.exempt(ReventlessCore.ApiFragmentsReadModelSpec.name)
|
|
119
124
|
})
|
|
125
|
+
let mergedApiMode = Config.mergedApi
|
|
126
|
+
|
|
120
127
|
type api = Types.AppSync.api
|
|
121
128
|
type role = Types.AppSync.role
|
|
122
129
|
type apiTarget = Domain | Platform
|
|
@@ -153,9 +160,59 @@ module MakeWithConfig = (
|
|
|
153
160
|
}: PulumiAws.IAM.Role.t)
|
|
154
161
|
}
|
|
155
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
|
+
|
|
156
200
|
let (domainApi, domainApiRole, platformApi, platformApiRole) = switch platformStackRef {
|
|
157
201
|
| None =>
|
|
158
|
-
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
|
+
}
|
|
159
216
|
// In platform/monolithic mode the platform API is not yet known — it is created
|
|
160
217
|
// during deployPlatform/makePlatform and the ref is updated there.
|
|
161
218
|
(api, role, api, role)
|
|
@@ -261,7 +318,51 @@ module MakeWithConfig = (
|
|
|
261
318
|
makePhantomRole(platformApiRoleArn)
|
|
262
319
|
})
|
|
263
320
|
|
|
264
|
-
|
|
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
|
+
}
|
|
265
366
|
}
|
|
266
367
|
|
|
267
368
|
// Expose api/apiRole as Platform.T value bindings so DCB slice builders
|
|
@@ -588,15 +689,8 @@ module MakeWithConfig = (
|
|
|
588
689
|
// The API-fragment registry is a SINGLETON AGGREGATE now (not a DCB slice) — see
|
|
589
690
|
// ApiFragmentRegistryAggregate / ApiFragmentsReadModel below.
|
|
590
691
|
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
let emptyBaseFragment = ReventlessCore.GraphQL_Stitcher.encode({
|
|
594
|
-
types: [],
|
|
595
|
-
mutations: [],
|
|
596
|
-
queries: [],
|
|
597
|
-
subscriptions: [],
|
|
598
|
-
subscriptionSources: [],
|
|
599
|
-
})
|
|
692
|
+
// (emptyBaseFragment — the empty split-mode plugin base — is defined above,
|
|
693
|
+
// next to the merged-mode source SDL assembly that shares it.)
|
|
600
694
|
|
|
601
695
|
module Api = {
|
|
602
696
|
module Make = (
|
|
@@ -622,34 +716,6 @@ module MakeWithConfig = (
|
|
|
622
716
|
|
|
623
717
|
// AWS platform hooks — all AWS-specific callbacks defined as a record.
|
|
624
718
|
// In-memory hooks (mutationResolverHook etc.) are absent (optional = None).
|
|
625
|
-
let deploySchemaPrefix = "deploy-schema:"
|
|
626
|
-
let deploySchemaPlatformPrefix = "deploy-schema-platform:"
|
|
627
|
-
let deploySchemaHashPrefix = "deploy-schema-hash:"
|
|
628
|
-
|
|
629
|
-
let readSchemaHash = async (~tableName: string, ~apiId: string): option<string> => {
|
|
630
|
-
open AwsSdk.DynamoDb.DocumentClient
|
|
631
|
-
let key = Dict.fromArray([("id", `${deploySchemaHashPrefix}${apiId}`->JSON.Encode.string)])
|
|
632
|
-
try {
|
|
633
|
-
let result = await GetCommand.send(GetCommand.make({GetCommand.tableName, key}))
|
|
634
|
-
result.item
|
|
635
|
-
->Option.flatMap(item => item->JSON.Decode.object)
|
|
636
|
-
->Option.flatMap(d => d->Dict.get("hash"))
|
|
637
|
-
->Option.flatMap(v => v->JSON.Decode.string)
|
|
638
|
-
} catch {
|
|
639
|
-
| _ => None
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
let writeSchemaHash = async (~tableName: string, ~apiId: string, ~hash: string): unit => {
|
|
644
|
-
open AwsSdk.DynamoDb.DocumentClient
|
|
645
|
-
let item =
|
|
646
|
-
Dict.fromArray([
|
|
647
|
-
("id", `${deploySchemaHashPrefix}${apiId}`->JSON.Encode.string),
|
|
648
|
-
("hash", hash->JSON.Encode.string),
|
|
649
|
-
])->JSON.Encode.object
|
|
650
|
-
let _ = await PutCommand.send(PutCommand.make({PutCommand.tableName, item}))
|
|
651
|
-
}
|
|
652
|
-
|
|
653
719
|
// (Deploy-time retire hook removed: supersession is now decided by the
|
|
654
720
|
// name-keyed Plugin aggregate (VersionSuperseded) — no RM scan drives a command.)
|
|
655
721
|
|
|
@@ -667,6 +733,12 @@ module MakeWithConfig = (
|
|
|
667
733
|
// plugins built afterwards read hooksApiRef (Domain/deploy-target). See platformHooks.adminApi.
|
|
668
734
|
let hooksAdminApiRef: ref<option<ReventlessCore.Plugin_Helpers.hookedValue<unknown>>> = ref(None)
|
|
669
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
|
+
|
|
670
742
|
let resolveHookedApi = (): Types.AppSync.api =>
|
|
671
743
|
switch hooksApiRef.contents {
|
|
672
744
|
| Some({val}) => Obj.magic(val)
|
|
@@ -874,69 +946,107 @@ module MakeWithConfig = (
|
|
|
874
946
|
// makePlatform / deployPlatform before Admin.construct fires, is visible):
|
|
875
947
|
// - split mode: platformApi from splitApiOutputsRef
|
|
876
948
|
// - unified mode / not-yet-populated: domainApi
|
|
877
|
-
preAdminResolversSchemaHook: (~adminBarrier) =>
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
// ONLY when the PlatformApi is known; if the ref is not yet populated we
|
|
885
|
-
// SKIP (never fall back to domainApi). Unified mode legitimately shares one
|
|
886
|
-
// API, so an unpopulated ref there means domainApi.
|
|
887
|
-
let targetApiOpt = switch (Config.splitApi, splitApiOutputsRef.contents) {
|
|
888
|
-
| (_, Some({platformApi})) => Some(platformApi)
|
|
889
|
-
| (false, None) => Some(domainApi)
|
|
890
|
-
| (true, None) => None
|
|
891
|
-
}
|
|
892
|
-
switch targetApiOpt {
|
|
893
|
-
| None =>
|
|
894
|
-
log.error(
|
|
895
|
-
~comp="preAdminResolversSchemaHook",
|
|
896
|
-
"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)",
|
|
897
|
-
)
|
|
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.
|
|
898
956
|
adminBarrier
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
)
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
)
|
|
912
|
-
(
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
|
1009
|
+
})
|
|
1010
|
+
)
|
|
1011
|
+
}
|
|
1012
|
+
},
|
|
1013
|
+
|
|
1014
|
+
// Staged deploy (deployPlugin against a running platform): register the
|
|
1015
|
+
// plugin's fragment via the Platform API (SigV4); the reactive ApiSchemaPush
|
|
1016
|
+
// SideEffect stitches + pushes cumulatively. The legacy all-at-once
|
|
1017
|
+
// deploy-schema:* write+scan+push path was retired in Phase 4b (makePlatform
|
|
1018
|
+
// with plugins is no longer supported on AWS).
|
|
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 => {
|
|
921
1033
|
log.info(
|
|
922
|
-
~comp="
|
|
923
|
-
|
|
1034
|
+
~comp="preResolversSchemaHook",
|
|
1035
|
+
`Pushing subgraph schema for ${name}@${version} to source API ${apiId}`,
|
|
924
1036
|
)
|
|
925
|
-
|
|
926
|
-
|
|
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
|
|
927
1045
|
})
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
}
|
|
932
|
-
},
|
|
933
|
-
|
|
934
|
-
// Accumulate fragments across independent plugin deployments: each plugin
|
|
935
|
-
// writes its fragment to the Plugin RM table (keyed "deploy-schema:<name>")
|
|
936
|
-
// at deploy time. The hook then scans for ALL deploy-schema entries and
|
|
937
|
-
// stitches them together — ensuring the schema is cumulative rather than
|
|
938
|
-
// overwritten by each plugin deployment.
|
|
939
|
-
preResolversSchemaHook: (~name, ~version, pluginFragment) => {
|
|
1046
|
+
)
|
|
1047
|
+
mergedSchemaPushedRef := Some(pushed)
|
|
1048
|
+
pushed
|
|
1049
|
+
} else {
|
|
940
1050
|
log.info(
|
|
941
1051
|
~comp="preResolversSchemaHook",
|
|
942
1052
|
`Pushing schema for plugin ${name}@${version} to AppSync`,
|
|
@@ -956,344 +1066,16 @@ module MakeWithConfig = (
|
|
|
956
1066
|
}
|
|
957
1067
|
registerFragmentViaApi(~name, ~fragment=pluginFragment, ~apiTargetName)
|
|
958
1068
|
| None =>
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
let (schemaPrefix, targetApi) = switch capturedDeployTarget {
|
|
967
|
-
| Domain => (deploySchemaPrefix, domainApi)
|
|
968
|
-
| Platform =>
|
|
969
|
-
let api = switch apiConfigRef.contents {
|
|
970
|
-
| Some({platformApi}) => platformApi
|
|
971
|
-
| None => domainApi // fallback: Core API not yet constructed
|
|
972
|
-
}
|
|
973
|
-
(deploySchemaPlatformPrefix, api)
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
// Read a string output from the platform StackReference, falling back to
|
|
977
|
-
// the bundled "default" output object if the named export is not present
|
|
978
|
-
// (matches the layout Pulumi emits when a stack uses a single default
|
|
979
|
-
// export rather than per-key exports).
|
|
980
|
-
let readStackRefString = (stackRef, key) => {
|
|
981
|
-
let direct: Pulumi.Output.t<option<JSON.t>> =
|
|
982
|
-
stackRef->Pulumi.StackReference.getOutput(key)
|
|
983
|
-
let defaultOutput: Pulumi.Output.t<option<JSON.t>> =
|
|
984
|
-
stackRef->Pulumi.StackReference.getOutput("default")
|
|
985
|
-
(direct, defaultOutput)
|
|
986
|
-
->Pulumi.Output.all2
|
|
987
|
-
->Pulumi.Output.apply(((direct, default)) =>
|
|
988
|
-
switch direct->Option.flatMap(v => v->JSON.Decode.string) {
|
|
989
|
-
| Some(name) => Some(name)
|
|
990
|
-
| None =>
|
|
991
|
-
default
|
|
992
|
-
->Option.flatMap(d => d->JSON.Decode.object)
|
|
993
|
-
->Option.flatMap(d => d->Dict.get(key))
|
|
994
|
-
->Option.flatMap(v => v->JSON.Decode.string)
|
|
995
|
-
}
|
|
1069
|
+
// makePlatform(~plugins=[…]) all-at-once deploy-schema push retired in Phase 4b.
|
|
1070
|
+
// Staged deployPlatform + deployPlugin (register + reactive SideEffect push) is the
|
|
1071
|
+
// sole supported AWS deploy path. deployPlatform deploys no plugins, so this hook only
|
|
1072
|
+
// fires for makePlatform-with-plugins — no longer supported on AWS.
|
|
1073
|
+
failwith(
|
|
1074
|
+
"makePlatform(~plugins=[…]) is no longer supported on AWS — deploy the platform with " ++
|
|
1075
|
+
"deployPlatform and each plugin with deployPlugin (staged register + reactive push).",
|
|
996
1076
|
)
|
|
997
1077
|
}
|
|
998
|
-
|
|
999
|
-
// Prefer the dedicated PluginSchemaPersistence table (post-platform-fix);
|
|
1000
|
-
// fall back to the Plugin RM table for backward compatibility with
|
|
1001
|
-
// platforms deployed before the schema-persistence table existed. The
|
|
1002
|
-
// Plugin RM table must not be reused for new schema-fragment writes —
|
|
1003
|
-
// doing so leaks deploy-schema rows through the Platform_Plugins AppSync
|
|
1004
|
-
// Connection resolver.
|
|
1005
|
-
let schemaPersistenceTableNameOutput: Pulumi.Output.t<option<string>> = switch platformStackRef {
|
|
1006
|
-
| Some(stackRef) =>
|
|
1007
|
-
(
|
|
1008
|
-
readStackRefString(stackRef, "pluginSchemaPersistenceTableName"),
|
|
1009
|
-
readStackRefString(stackRef, "pluginRmTableName"),
|
|
1010
|
-
)
|
|
1011
|
-
->Pulumi.Output.all2
|
|
1012
|
-
->Pulumi.Output.apply(((dedicated, legacy)) =>
|
|
1013
|
-
switch dedicated {
|
|
1014
|
-
| Some(_) as s => s
|
|
1015
|
-
| None => legacy
|
|
1016
|
-
}
|
|
1017
|
-
)
|
|
1018
|
-
| None => Pulumi.Output.make(None)
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
schemaPersistenceTableNameOutput
|
|
1022
|
-
->Pulumi.Output.flatMap(tableNameOpt => {
|
|
1023
|
-
// Write this plugin's fragment to DynamoDB, then scan all deploy-schema
|
|
1024
|
-
// entries to collect every deployed plugin's fragment.
|
|
1025
|
-
let writeAndScanFragments = () =>
|
|
1026
|
-
switch tableNameOpt {
|
|
1027
|
-
| None =>
|
|
1028
|
-
log.info(
|
|
1029
|
-
~comp="preResolversSchemaHook",
|
|
1030
|
-
"No pluginSchemaPersistenceTableName / pluginRmTableName — skipping fragment persistence",
|
|
1031
|
-
)
|
|
1032
|
-
Promise.resolve([pluginFragment])
|
|
1033
|
-
| Some(tableName) =>
|
|
1034
|
-
open AwsSdk.DynamoDb.DocumentClient
|
|
1035
|
-
// Write this plugin's fragment so subsequent plugin deployments find it.
|
|
1036
|
-
let deployItem =
|
|
1037
|
-
Dict.fromArray([
|
|
1038
|
-
("id", `${schemaPrefix}${name}`->JSON.Encode.string),
|
|
1039
|
-
("fragment", pluginFragment.encoded->JSON.Encode.string),
|
|
1040
|
-
])->JSON.Encode.object
|
|
1041
|
-
log.info(
|
|
1042
|
-
~comp="preResolversSchemaHook",
|
|
1043
|
-
`Writing deploy-schema entry for ${name} to ${tableName}`,
|
|
1044
|
-
)
|
|
1045
|
-
// Paginated scan — accumulate every deploy-schema entry across pages.
|
|
1046
|
-
// A single ScanCommand returns at most 1 MB before yielding a
|
|
1047
|
-
// LastEvaluatedKey; loop until the table is exhausted so a platform
|
|
1048
|
-
// with many plugin fragments never stitches a partial schema.
|
|
1049
|
-
let scanAllDeploySchemaItems = async () => {
|
|
1050
|
-
let allItems = []
|
|
1051
|
-
let startKey = ref(None)
|
|
1052
|
-
let more = ref(true)
|
|
1053
|
-
while more.contents {
|
|
1054
|
-
let result = await ScanCommand.send(
|
|
1055
|
-
ScanCommand.make({
|
|
1056
|
-
ScanCommand.tableName: tableName,
|
|
1057
|
-
filterExpression: "begins_with(#id, :prefix)",
|
|
1058
|
-
expressionAttributeNames: Dict.fromArray([("#id", "id")]),
|
|
1059
|
-
expressionAttributeValues: Dict.fromArray([
|
|
1060
|
-
(":prefix", schemaPrefix->JSON.Encode.string),
|
|
1061
|
-
]),
|
|
1062
|
-
exclusiveStartKey: ?startKey.contents,
|
|
1063
|
-
}),
|
|
1064
|
-
)
|
|
1065
|
-
result.items->Option.getOr([])->Array.forEach(item => allItems->Array.push(item))
|
|
1066
|
-
switch result.lastEvaluatedKey {
|
|
1067
|
-
| Some(_) as k => startKey := k
|
|
1068
|
-
| None => more := false
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
allItems
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
PutCommand.send(PutCommand.make({PutCommand.tableName: tableName, item: deployItem}))
|
|
1075
|
-
->Promise.then(_ => {
|
|
1076
|
-
// Scan for all deploy-schema entries from previously deployed plugins.
|
|
1077
|
-
log.info(
|
|
1078
|
-
~comp="preResolversSchemaHook",
|
|
1079
|
-
`Scanning ${tableName} for deploy-schema entries`,
|
|
1080
|
-
)
|
|
1081
|
-
scanAllDeploySchemaItems()
|
|
1082
|
-
})
|
|
1083
|
-
->Promise.then(items => {
|
|
1084
|
-
let fragments = items->Array.filterMap(item => {
|
|
1085
|
-
try {
|
|
1086
|
-
let obj = item->JSON.stringify->JSON.parseOrThrow
|
|
1087
|
-
switch obj->JSON.Decode.object->Option.flatMap(d => d->Dict.get("fragment")) {
|
|
1088
|
-
| Some(fragmentJson) =>
|
|
1089
|
-
switch fragmentJson->JSON.Decode.string {
|
|
1090
|
-
| Some(encoded) =>
|
|
1091
|
-
Some({Reventless.Plugin.encoded, protocol: "graphql"})
|
|
1092
|
-
| None => None
|
|
1093
|
-
}
|
|
1094
|
-
| None => None
|
|
1095
|
-
}
|
|
1096
|
-
} catch {
|
|
1097
|
-
| _ => None
|
|
1098
|
-
}
|
|
1099
|
-
})
|
|
1100
|
-
log.info(
|
|
1101
|
-
~comp="preResolversSchemaHook",
|
|
1102
|
-
`Found ${fragments->Array.length->Int.toString} deploy-schema entries`,
|
|
1103
|
-
)
|
|
1104
|
-
Promise.resolve(fragments)
|
|
1105
|
-
})
|
|
1106
|
-
->Promise.catch(err => {
|
|
1107
|
-
let msg =
|
|
1108
|
-
err
|
|
1109
|
-
->JsExn.fromException
|
|
1110
|
-
->Option.flatMap(JsExn.message)
|
|
1111
|
-
->Option.getOr("unknown")
|
|
1112
|
-
log.info(
|
|
1113
|
-
~comp="preResolversSchemaHook",
|
|
1114
|
-
`DynamoDB write/scan failed (${msg}) — using current plugin only`,
|
|
1115
|
-
)
|
|
1116
|
-
Promise.resolve([pluginFragment])
|
|
1117
|
-
})
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
targetApi->Pulumi.Output.flatMap(api =>
|
|
1121
|
-
api.id->Pulumi.Output.flatMap(apiId => {
|
|
1122
|
-
// Serialise write-row → scan → stitch → push under the shared
|
|
1123
|
-
// schema-push lease so a concurrent peer's stale scan can't clobber
|
|
1124
|
-
// this stack's fields (see AppSync_Adapter.withSchemaPushLock).
|
|
1125
|
-
let runSchemaPush = () => {
|
|
1126
|
-
writeAndScanFragments()
|
|
1127
|
-
->Promise.then(async allPluginFragments => {
|
|
1128
|
-
// Base fragment selection:
|
|
1129
|
-
// - Platform target: always include admin base (the Core API owns admin ops).
|
|
1130
|
-
// - Domain target, split mode: empty base (admin lives on Core API).
|
|
1131
|
-
// - Domain target, unified mode: include admin base (single API has everything).
|
|
1132
|
-
// Use capturedDeployTarget (set synchronously above) — currentDeployTarget has
|
|
1133
|
-
// been reset to Domain by deployPlugin before this async callback runs.
|
|
1134
|
-
let baseFragment = switch capturedDeployTarget {
|
|
1135
|
-
| Platform =>
|
|
1136
|
-
AppSync_Adapter.injectAwsAuthAll(
|
|
1137
|
-
ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
|
|
1138
|
-
~group="Admin",
|
|
1139
|
-
~iamFieldNames=ReventlessCore.AdminApi.systemCallerFieldNames,
|
|
1140
|
-
)
|
|
1141
|
-
| Domain =>
|
|
1142
|
-
if Config.splitApi {
|
|
1143
|
-
emptyBaseFragment
|
|
1144
|
-
} else {
|
|
1145
|
-
AppSync_Adapter.injectAwsAuthAll(
|
|
1146
|
-
ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
|
|
1147
|
-
~group="Admin",
|
|
1148
|
-
)
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
let sdl = AppSync_Adapter.stitchWithAwsDirectives(
|
|
1152
|
-
~baseFragment,
|
|
1153
|
-
~pluginFragments=allPluginFragments,
|
|
1154
|
-
)
|
|
1155
|
-
let currentHash = AppSync_Adapter.sha256Hex(sdl)
|
|
1156
|
-
let storedHash = switch tableNameOpt {
|
|
1157
|
-
| Some(tn) => await readSchemaHash(~tableName=tn, ~apiId)
|
|
1158
|
-
| None => None
|
|
1159
|
-
}
|
|
1160
|
-
let client = AppSync_Adapter.getClient()
|
|
1161
|
-
|
|
1162
|
-
// Introspect the live schema once — reused for the hash-match
|
|
1163
|
-
// drift/repair check and the catastrophic-shrink guard on the push.
|
|
1164
|
-
let liveSdl = await AppSync_Adapter.getIntrospectionSdl(client, apiId)
|
|
1165
|
-
|
|
1166
|
-
// The stored hash records what the DEPLOY last pushed. A runtime
|
|
1167
|
-
// re-stitch (mkUpdateApiSchema) can clobber the live schema
|
|
1168
|
-
// out-of-band WITHOUT updating this hash, so a matching hash does
|
|
1169
|
-
// not guarantee the live schema is intact. Before trusting the
|
|
1170
|
-
// hash to skip the push, introspect the live schema and confirm it
|
|
1171
|
-
// still carries at least as many root-type (Mutation + Query +
|
|
1172
|
-
// Subscription) fields as the SDL we would push. If it has drifted
|
|
1173
|
-
// (shrunk) — or cannot be introspected despite a stored hash, which
|
|
1174
|
-
// means a real failure rather than a first deploy — force the
|
|
1175
|
-
// repair push so a clobbered schema heals on the next deploy.
|
|
1176
|
-
let countRoots = s =>
|
|
1177
|
-
ReventlessCore.GraphQL_Stitcher.countRootTypeFields(~sdl=s, ~typeName="Mutation") +
|
|
1178
|
-
ReventlessCore.GraphQL_Stitcher.countRootTypeFields(~sdl=s, ~typeName="Query") +
|
|
1179
|
-
ReventlessCore.GraphQL_Stitcher.countRootTypeFields(
|
|
1180
|
-
~sdl=s,
|
|
1181
|
-
~typeName="Subscription",
|
|
1182
|
-
)
|
|
1183
|
-
// Identity-aware drift check (not a bare count): the live schema is
|
|
1184
|
-
// "intact" only when it is a SUPERSET of every root field we would
|
|
1185
|
-
// push. Comparing name SETS heals equal-cardinality drift and field
|
|
1186
|
-
// *swaps* — an admin-base clobber that leaves the DomainApi with the
|
|
1187
|
-
// SAME number of root fields but the WRONG ones (admin-base instead
|
|
1188
|
-
// of plugin fields) has a matching count yet is missing every
|
|
1189
|
-
// expected plugin field, so a count test would wrongly skip.
|
|
1190
|
-
let missingFields = ReventlessCore.GraphQL_Stitcher.missingRootFields(
|
|
1191
|
-
~expectedSdl=sdl,
|
|
1192
|
-
~liveSdl,
|
|
1193
|
-
)
|
|
1194
|
-
let skipPush = switch storedHash {
|
|
1195
|
-
| Some(prev) if prev == currentHash =>
|
|
1196
|
-
if liveSdl == "" {
|
|
1197
|
-
log.info(
|
|
1198
|
-
~comp="preResolversSchemaHook",
|
|
1199
|
-
`hash matches but live schema could not be introspected — forcing repair push (check appsync:GetIntrospectionSchema permission)`,
|
|
1200
|
-
)
|
|
1201
|
-
false
|
|
1202
|
-
} else if missingFields->Array.length > 0 {
|
|
1203
|
-
log.info(
|
|
1204
|
-
~comp="preResolversSchemaHook",
|
|
1205
|
-
`hash matches but live schema is missing ${missingFields
|
|
1206
|
-
->Array.length
|
|
1207
|
-
->Int.toString} expected root field(s) (e.g. ${missingFields
|
|
1208
|
-
->Array.slice(~start=0, ~end=5)
|
|
1209
|
-
->Array.join(", ")}) — forcing repair push`,
|
|
1210
|
-
)
|
|
1211
|
-
false
|
|
1212
|
-
} else {
|
|
1213
|
-
log.info(
|
|
1214
|
-
~comp="preResolversSchemaHook",
|
|
1215
|
-
`SDL unchanged (hash ${currentHash->String.slice(
|
|
1216
|
-
~start=0,
|
|
1217
|
-
~end=12,
|
|
1218
|
-
)}…) and live schema is a superset of the expected root fields (${countRoots(
|
|
1219
|
-
liveSdl,
|
|
1220
|
-
)->Int.toString} live); skipping push`,
|
|
1221
|
-
)
|
|
1222
|
-
true
|
|
1223
|
-
}
|
|
1224
|
-
| _ => false
|
|
1225
|
-
}
|
|
1226
|
-
if !skipPush {
|
|
1227
|
-
// Shrink guard — deploy-time counterpart of the runtime
|
|
1228
|
-
// mkUpdateApiSchema guard (AdminEventCollectorEntryPoint.mjs).
|
|
1229
|
-
// Plugin/service stacks share one AppSync API and StartSchemaCreation
|
|
1230
|
-
// REPLACES the whole schema. A concurrent peer that scanned the
|
|
1231
|
-
// deploy-schema table before this stack wrote its fragment row
|
|
1232
|
-
// stitches an SDL missing this stack's fields; pushing it would drop
|
|
1233
|
-
// the live fields and orphan their resolvers (NotFoundException: No
|
|
1234
|
-
// field named X). Refuse a push that would catastrophically shrink
|
|
1235
|
-
// the live schema — the field-owner's own deploy (whose scan includes
|
|
1236
|
-
// its freshly-written row) pushes the complete set.
|
|
1237
|
-
// isCatastrophicSchemaShrink returns false when the live schema is
|
|
1238
|
-
// empty (first deploy / introspection unavailable), so the initial
|
|
1239
|
-
// push still proceeds.
|
|
1240
|
-
if (
|
|
1241
|
-
ReventlessCore.GraphQL_Stitcher.isCatastrophicSchemaShrink(
|
|
1242
|
-
~currentSdl=liveSdl,
|
|
1243
|
-
~newSdl=sdl,
|
|
1244
|
-
~threshold=deploySchemaShrinkThreshold,
|
|
1245
|
-
)
|
|
1246
|
-
) {
|
|
1247
|
-
log.error(
|
|
1248
|
-
~comp="preResolversSchemaHook",
|
|
1249
|
-
`ABORTED schema push for ${apiId}: stitched SDL (${countRoots(
|
|
1250
|
-
sdl,
|
|
1251
|
-
)->Int.toString} root field(s)) would catastrophically shrink the live schema (${countRoots(
|
|
1252
|
-
liveSdl,
|
|
1253
|
-
)->Int.toString} root field(s), threshold ${deploySchemaShrinkThreshold->Float.toString}) — refusing to clobber resolvers (likely a stale concurrent-deploy scan)`,
|
|
1254
|
-
)
|
|
1255
|
-
} else {
|
|
1256
|
-
log.info(
|
|
1257
|
-
~comp="preResolversSchemaHook",
|
|
1258
|
-
`Pushing schema to API ${apiId} (${allPluginFragments->Array.length->Int.toString} plugin fragments, new hash: ${currentHash->String.slice(~start=0, ~end=12)}…)`,
|
|
1259
|
-
)
|
|
1260
|
-
await client->AppSync_Adapter.startSchemaCreationRetrying({
|
|
1261
|
-
apiId,
|
|
1262
|
-
definition: sdl,
|
|
1263
|
-
})
|
|
1264
|
-
log.info(
|
|
1265
|
-
~comp="preResolversSchemaHook",
|
|
1266
|
-
"startSchemaCreation called, waiting for ACTIVE",
|
|
1267
|
-
)
|
|
1268
|
-
await AppSync_Adapter.waitForSchemaActive(client, apiId)
|
|
1269
|
-
log.info(~comp="preResolversSchemaHook", "Schema is ACTIVE")
|
|
1270
|
-
switch tableNameOpt {
|
|
1271
|
-
| Some(tn) =>
|
|
1272
|
-
await writeSchemaHash(~tableName=tn, ~apiId, ~hash=currentHash)
|
|
1273
|
-
| None => ()
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
// No deploy-time retire scan: the name-keyed Plugin aggregate
|
|
1279
|
-
// decides supersession itself (VersionSuperseded) when the new
|
|
1280
|
-
// version connects, so the manifest carries only the current
|
|
1281
|
-
// version without any RM-read-driven command.
|
|
1282
|
-
})
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
(
|
|
1286
|
-
switch tableNameOpt {
|
|
1287
|
-
| Some(tableName) =>
|
|
1288
|
-
AppSync_Adapter.withSchemaPushLock(~tableName, ~apiId, runSchemaPush)
|
|
1289
|
-
| None => runSchemaPush()
|
|
1290
|
-
}
|
|
1291
|
-
)->Pulumi.Output.fromPromise
|
|
1292
|
-
})
|
|
1293
|
-
)
|
|
1294
|
-
})
|
|
1295
|
-
}
|
|
1296
|
-
},
|
|
1078
|
+
},
|
|
1297
1079
|
// DCB EventLog created hook — extracts DynamoDB table name for DCB CommandTopic Lambda handler.
|
|
1298
1080
|
// Postgres-backed DCB logs (B2.3c) create no table, so there is no resource to read:
|
|
1299
1081
|
// the DCB command Lambda derives its `dcb_event.log_name` from the plugin name and
|
|
@@ -1647,6 +1429,14 @@ module MakeWithConfig = (
|
|
|
1647
1429
|
// In unified mode, makePlatform is a no-op (schema stitching handled by events).
|
|
1648
1430
|
let makePlatform = (~version, ~plugins: array<module(PluginMaker)>) => {
|
|
1649
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
|
+
}
|
|
1650
1440
|
// Create scheduler and populate platform context refs so Plugin_Builder
|
|
1651
1441
|
// can read them without app plugins having to pass them through.
|
|
1652
1442
|
let scheduler = makeScheduler()
|
|
@@ -1837,6 +1627,18 @@ module MakeWithConfig = (
|
|
|
1837
1627
|
bundleVersion: string,
|
|
1838
1628
|
}
|
|
1839
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
|
+
|
|
1840
1642
|
let deployPlatform = (~version, ~hostUiBundle: option<hostUiBundleConfig>=?) => {
|
|
1841
1643
|
log.info(~comp="Platform:deployPlatform", `v${version}`)
|
|
1842
1644
|
let scheduler = makeScheduler()
|
|
@@ -1847,12 +1649,79 @@ module MakeWithConfig = (
|
|
|
1847
1649
|
// Phase 2: create the Platform API resource early — before Admin.construct —
|
|
1848
1650
|
// so admin resolvers are attached to the correct API in split mode.
|
|
1849
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.
|
|
1850
1654
|
let (platformApi, platformApiRole) = if Config.splitApi {
|
|
1851
|
-
|
|
1655
|
+
if mergedApiMode {
|
|
1656
|
+
AppSync_Adapter.makeSourceApiResource(~name="PlatformApi", ~schema=adminSourceSdl(), ~opts={})
|
|
1657
|
+
} else {
|
|
1658
|
+
AppSync_Adapter.makeApiResource(~name="PlatformApi", ~opts={})
|
|
1659
|
+
}
|
|
1852
1660
|
} else {
|
|
1853
1661
|
(domainApi, domainApiRole)
|
|
1854
1662
|
}
|
|
1855
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
|
+
|
|
1856
1725
|
// Admin DCB mutation resolvers bind to the Platform API (split mode) or the shared api
|
|
1857
1726
|
// (unified). Recorded now that the Platform API resource exists, so the admin's deferred
|
|
1858
1727
|
// dcbConnectFn (via ~onAdminApi) targets it rather than the Domain/deploy-target hooksApiRef.
|
|
@@ -1890,47 +1759,9 @@ module MakeWithConfig = (
|
|
|
1890
1759
|
// (Heartbeat, ForwardCommand). None here keeps the deploy-time path
|
|
1891
1760
|
// unchanged; the .mjs entry point supplies a real implementation.
|
|
1892
1761
|
let manageSubscriptions = None
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
let plugins = await queryEngine.scan(
|
|
1897
|
-
~readModelName="Plugins",
|
|
1898
|
-
~filterConfigs=[("status", Contains, String("Connected"))],
|
|
1899
|
-
~limit=1000,
|
|
1900
|
-
)
|
|
1901
|
-
let fragments = plugins->Array.filterMap(json =>
|
|
1902
|
-
try {
|
|
1903
|
-
let state = json->S.parseOrThrow(ReventlessCore.PluginsReadModelSpec.stateSchema)
|
|
1904
|
-
// Exclude Platform-target plugins — their schema belongs on the PlatformApi,
|
|
1905
|
-
// not the DomainApi. Absent apiTarget defaults to "Domain".
|
|
1906
|
-
switch state.apiTarget {
|
|
1907
|
-
| Some("Platform") => None
|
|
1908
|
-
| _ => state.apiSchemaFragment
|
|
1909
|
-
}
|
|
1910
|
-
} catch {
|
|
1911
|
-
| _ => None
|
|
1912
|
-
}
|
|
1913
|
-
)
|
|
1914
|
-
// In split mode, the plugin API only has plugin schema (admin is on the core API).
|
|
1915
|
-
// In unified mode, stitch admin + plugins into the single shared API.
|
|
1916
|
-
let baseFragment = if Config.splitApi {
|
|
1917
|
-
emptyBaseFragment
|
|
1918
|
-
} else {
|
|
1919
|
-
AppSync_Adapter.injectAwsAuthAll(
|
|
1920
|
-
ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
|
|
1921
|
-
~group="Admin",
|
|
1922
|
-
~iamFieldNames=ReventlessCore.AdminApi.systemCallerFieldNames,
|
|
1923
|
-
)
|
|
1924
|
-
}
|
|
1925
|
-
let sdl = AppSync_Adapter.stitchWithAwsDirectives(
|
|
1926
|
-
~baseFragment,
|
|
1927
|
-
~pluginFragments=fragments,
|
|
1928
|
-
)
|
|
1929
|
-
await AppSync_Adapter.getClient()->AppSync_Adapter.startSchemaCreationRetrying({
|
|
1930
|
-
apiId,
|
|
1931
|
-
definition: sdl,
|
|
1932
|
-
})
|
|
1933
|
-
})
|
|
1762
|
+
// Runtime connect-driven schema self-heal retired in Phase 4b — the reactive
|
|
1763
|
+
// ApiSchemaPush SideEffect (on ApiFragmentRegistry events) is the single writer.
|
|
1764
|
+
let updateApiSchema = None
|
|
1934
1765
|
})
|
|
1935
1766
|
|
|
1936
1767
|
// Phase 2: Admin resolvers go on the Platform API (platformApi) in split mode,
|
|
@@ -1960,18 +1791,26 @@ module MakeWithConfig = (
|
|
|
1960
1791
|
// deploy caller fires RegisterApiFragment); makePlatform pushes the schema directly.
|
|
1961
1792
|
let apiSchemaPushEventTopics = ReventlessCore.Aggregate.allEventTopics(admin.aggregatesOutputs)
|
|
1962
1793
|
let apiSchemaPushCmdTopics = ReventlessCore.Aggregate.allCommandTopics(admin.aggregatesOutputs)
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1794
|
+
// MUST be a `switch`, NOT `->Option.map(...)->Option.getOr(...)`. The map/getOr form
|
|
1795
|
+
// materialises an `option<Pulumi.Output.t<string>>`, and wrapping a Pulumi Output in a
|
|
1796
|
+
// ReScript option collapses the nested Output to `undefined` at runtime (the documented
|
|
1797
|
+
// "option(Pulumi.Output.t) doesn't work" pitfall). That made API_SCHEMA_PUSH_CMD_TOPIC_URL
|
|
1798
|
+
// resolve to undefined → Pulumi dropped the env var → the ApiSchemaPush runtime logged
|
|
1799
|
+
// "no command-topic URL configured — skipping" and never pushed/recorded, so the deploy
|
|
1800
|
+
// waiter timed out. Verified via local `pulumi preview`: map/getOr → isValidOutput=false;
|
|
1801
|
+
// switch → isValidOutput=true.
|
|
1802
|
+
let apiSchemaPushCmdTopicUrl = switch admin.aggregatesOutputs->Dict.get(
|
|
1803
|
+
ReventlessCore.ApiFragmentRegistrySpec.name,
|
|
1804
|
+
) {
|
|
1805
|
+
| Some(agg) =>
|
|
1806
|
+
agg.commandTopic->Pulumi.Output.flatMap(ct =>
|
|
1807
|
+
switch ct.resources->Array.get(0) {
|
|
1808
|
+
| Some(r) => r.id
|
|
1809
|
+
| None => Pulumi.Output.make("")
|
|
1810
|
+
}
|
|
1973
1811
|
)
|
|
1974
|
-
|
|
1812
|
+
| None => Pulumi.Output.make("")
|
|
1813
|
+
}
|
|
1975
1814
|
let apiSchemaPushEnv = Dict.fromArray([
|
|
1976
1815
|
("API_SCHEMA_PUSH_DOMAIN_API_ID", domainApiId->Pulumi.Output.asInput),
|
|
1977
1816
|
(
|
|
@@ -2029,19 +1868,6 @@ module MakeWithConfig = (
|
|
|
2029
1868
|
| None => None
|
|
2030
1869
|
}
|
|
2031
1870
|
|
|
2032
|
-
// Dedicated DynamoDB table for deploy-time schema-fragment persistence
|
|
2033
|
-
// (deploy-schema:<name>, deploy-schema-platform:<name>, deploy-schema-hash:<apiId>).
|
|
2034
|
-
// Previously these infrastructure rows shared the Plugin RM table, which
|
|
2035
|
-
// caused them to leak through Platform_Plugins' auto-generated AppSync
|
|
2036
|
-
// Connection resolver (an unfiltered Scan). Hosting them on their own
|
|
2037
|
-
// table keeps Plugin RM = Plugin aggregate entities only and restores
|
|
2038
|
-
// parity with the in-memory adapter (which has no preResolversSchemaHook).
|
|
2039
|
-
let pluginSchemaPersistenceTable = Util.DynamoDb.makeTable(
|
|
2040
|
-
"PluginSchemaPersistence",
|
|
2041
|
-
~attributes=[{name: "id", type_: "S"}],
|
|
2042
|
-
~opts={},
|
|
2043
|
-
)
|
|
2044
|
-
|
|
2045
1871
|
PluginExtensionPointRuntime_Builder.registerPluginExtensionPoint(
|
|
2046
1872
|
~pluginReadModelTableName?,
|
|
2047
1873
|
~schedulerRoleArn=hooks.schedulerRoleUrn.contents,
|
|
@@ -2075,34 +1901,8 @@ module MakeWithConfig = (
|
|
|
2075
1901
|
~eventTopicArn=pluginEpEventTopicArn,
|
|
2076
1902
|
~appSyncApiId=domainApiId,
|
|
2077
1903
|
~pluginReadModelTableName?,
|
|
2078
|
-
// Runtime schema stitch reads deploy-time fragments from this durable table
|
|
2079
|
-
// rather than the lifecycle-volatile Plugin RM Connected rows.
|
|
2080
|
-
~pluginSchemaPersistenceTableName=pluginSchemaPersistenceTable.name,
|
|
2081
1904
|
~schedulerRoleArn=hooks.schedulerRoleUrn.contents,
|
|
2082
1905
|
~clonerEnabled=Config.cloner,
|
|
2083
|
-
// 2e: the reactive ApiFragmentRegistry single writer (admin EventCollector).
|
|
2084
|
-
// The Platform AppSync id it pushes Platform-target fragments to (unified →
|
|
2085
|
-
// domainApi, since platformApi == domainApi); the ApiFragments StateViewSlice
|
|
2086
|
-
// table it re-folds from; the admin DCB command-topic FIFO URL it dispatches
|
|
2087
|
-
// RecordApiFragmentPush to (captured during Admin.construct); and the mode flag.
|
|
2088
|
-
~platformApiId=platformApi->Pulumi.Output.flatMap(api => api.id),
|
|
2089
|
-
// NB: must NOT be `->Option.map(r => r.name)`. `apiFragmentRegistryTableName`
|
|
2090
|
-
// is `option<Pulumi.Output.t<string>>` (the forbidden pattern, CLAUDE.md code
|
|
2091
|
-
// smells). The generic `Option.map` body runs `Primitive_option.some(r.name)`,
|
|
2092
|
-
// and because a Pulumi Output lifts arbitrary property access, `some` inspects
|
|
2093
|
-
// `.BS_PRIVATE_NESTED_SOME_NONE`, mis-reads the Output as a nested option, and
|
|
2094
|
-
// stores the sentinel `{BS_PRIVATE_NESTED_SOME_NONE: 0}` instead of the Output —
|
|
2095
|
-
// so the consumer's `tableOutput->Pulumi.Output.apply` crashes with
|
|
2096
|
-
// "apply is not a function". A `Some(r.name)` LITERAL compiles unboxed (bare
|
|
2097
|
-
// r.name), preserving the Output — the same dodge `pluginReadModelTableName` uses.
|
|
2098
|
-
~apiFragmentRegistryTableName=?switch admin.readModelsOutputs
|
|
2099
|
-
->Dict.get("ApiFragments")
|
|
2100
|
-
->Option.flatMap(rm => rm.queryDb.resources->Array.get(0)) {
|
|
2101
|
-
| Some(r) => Some(r.name)
|
|
2102
|
-
| None => None
|
|
2103
|
-
},
|
|
2104
|
-
~adminDcbCmdTopicUrl=?AutomationSliceRuntime_Builder_Single.getDcbQueueUrl(),
|
|
2105
|
-
~splitApi=Config.splitApi,
|
|
2106
1906
|
(),
|
|
2107
1907
|
)
|
|
2108
1908
|
|
|
@@ -2197,6 +1997,44 @@ module MakeWithConfig = (
|
|
|
2197
1997
|
)
|
|
2198
1998
|
Pulumi.Pulumi.export("domainApiRoleArn", domainApiRole->Pulumi.Output.flatMap(role => role.arn))
|
|
2199
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
|
+
|
|
2200
2038
|
// Events API exports — consumed by plugin stacks to wire Source B (StateTopic) Lambdas.
|
|
2201
2039
|
switch domainEventsApiOpt {
|
|
2202
2040
|
| Some(eventsApi) =>
|
|
@@ -2215,14 +2053,6 @@ module MakeWithConfig = (
|
|
|
2215
2053
|
| None => ()
|
|
2216
2054
|
}
|
|
2217
2055
|
|
|
2218
|
-
// Export the dedicated schema-persistence table name. preResolversSchemaHook
|
|
2219
|
-
// prefers this over pluginRmTableName so deploy-schema rows no longer share
|
|
2220
|
-
// the Plugin RM table.
|
|
2221
|
-
Pulumi.Pulumi.export(
|
|
2222
|
-
"pluginSchemaPersistenceTableName",
|
|
2223
|
-
pluginSchemaPersistenceTable.name,
|
|
2224
|
-
)
|
|
2225
|
-
|
|
2226
2056
|
// (No pluginAggrCmdTopicUrl export: the deploy-time retire hook that
|
|
2227
2057
|
// published Retire commands to the Plugin aggregate queue is gone —
|
|
2228
2058
|
// supersession is decided by the aggregate on connect.)
|
|
@@ -2241,11 +2071,22 @@ module MakeWithConfig = (
|
|
|
2241
2071
|
)
|
|
2242
2072
|
|
|
2243
2073
|
// Fire onPlatformDeployed hook with resolved platform metadata.
|
|
2244
|
-
|
|
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 =>
|
|
2245
2086
|
api.uris->Pulumi.Output.apply(uris => uris.graphQL)
|
|
2246
2087
|
)
|
|
2247
2088
|
let resolvedDomainApiRoleArn = domainApiRole->Pulumi.Output.flatMap(role => role.arn)
|
|
2248
|
-
let resolvedPlatformApiEndpoint =
|
|
2089
|
+
let resolvedPlatformApiEndpoint = clientPlatformApi->Pulumi.Output.flatMap(api =>
|
|
2249
2090
|
api.uris->Pulumi.Output.apply(uris => uris.graphQL)
|
|
2250
2091
|
)
|
|
2251
2092
|
let resolvedPlatformApiRoleArn = platformApiRole->Pulumi.Output.flatMap(role => role.arn)
|
|
@@ -2465,6 +2306,97 @@ module MakeWithConfig = (
|
|
|
2465
2306
|
let pluginOutputs = pluginComponent->ReventlessCore.Component.outputs
|
|
2466
2307
|
ReventlessCore.Plugin_Helpers.exportPluginOutputs(pluginOutputs)
|
|
2467
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
|
+
|
|
2468
2400
|
// B2.3d: provision the Postgres change-feed relay (plugin-stack mode — this
|
|
2469
2401
|
// plugin's Postgres DCB log(s) + collector queue were registered during P.make()).
|
|
2470
2402
|
provisionPgChangeFeedRelay()
|
|
@@ -2543,6 +2475,7 @@ module Make = (): (
|
|
|
2543
2475
|
) => {
|
|
2544
2476
|
include MakeWithConfig({
|
|
2545
2477
|
let splitApi = true
|
|
2478
|
+
let mergedApi = true
|
|
2546
2479
|
let cloner = false
|
|
2547
2480
|
let commandHandlerConfig: ReventlessCore.Runtime.commandHandlerConfigs = {}
|
|
2548
2481
|
let pgConnection = None
|