@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/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({let splitApi = false; let cloner = true})
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) = AppSync_Adapter.makeApiResource(~name="DomainApi", ~opts={})
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
- (phantomApi, phantomRole, phantomPlatformApi, phantomPlatformRole)
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
- // Empty base fragment no types, no mutations, no queries.
584
- // Used by the plugin Api in split mode so plugin schema has no core fields.
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
- // The admin-base SDL stitches AdminApi.baseFragment with an EMPTY plugin
843
- // list, and startSchemaCreation REPLACES the whole schema. In split mode
844
- // the admin schema belongs on the PlatformApi ONLY — the DomainApi carries
845
- // plugin fields (emptyBaseFragment). If we pushed the admin-base-only SDL
846
- // to the DomainApi it would wipe every plugin field, leaving exactly the
847
- // admin-base set (the alpha 2026-07-08 clobber). So in split mode we push
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
- | Some(targetApi) =>
864
- let adminBaseFragment = AppSync_Adapter.injectAwsAuthAll(
865
- ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
866
- ~group="Admin",
867
- // The ApiFragmentRegistry register/deregister mutations + the Platform_ApiFragments
868
- // status query are invoked by the plugin/standalone deploy as a SigV4 system caller, so
869
- // they carry the dual-auth (@aws_cognito_user_pools @aws_iam) directive.
870
- ~iamFieldNames=ReventlessCore.AdminApi.systemCallerFieldNames,
871
- )
872
- let sdl = AppSync_Adapter.stitchWithAwsDirectives(
873
- ~baseFragment=adminBaseFragment,
874
- ~pluginFragments=[],
875
- )
876
- (targetApi, adminBarrier)
877
- ->Pulumi.Output.all2
878
- ->Pulumi.Output.flatMap(((api, _)) =>
879
- api.id->Pulumi.Output.flatMap(apiId => {
880
- log.info(~comp="preAdminResolversSchemaHook", `Pushing admin schema to ${apiId}`)
881
- let client = AppSync_Adapter.getClient()
882
- client
883
- ->AppSync_Adapter.startSchemaCreationRetrying({apiId, definition: sdl})
884
- ->Promise.then(async _ => {
885
- log.info(
886
- ~comp="preAdminResolversSchemaHook",
887
- "startSchemaCreation called, waiting for ACTIVE",
888
- )
889
- await AppSync_Adapter.waitForSchemaActive(client, apiId)
890
- log.info(~comp="preAdminResolversSchemaHook", "schema is ACTIVE")
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
- ->Pulumi.Output.fromPromise
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
- AppSync_Adapter.makeApiResource(~name="PlatformApi", ~opts={})
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
- let resolvedDomainApiEndpoint = domainApi->Pulumi.Output.flatMap(api =>
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 = platformApi->Pulumi.Output.flatMap(api =>
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