@reventlessdev/reventless-aws 3.0.0-alpha.209 → 3.0.0-alpha.211

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +8 -8
  3. package/src/Platform.res +348 -707
  4. package/src/Platform.res.mjs +271 -739
  5. package/src/adapter/QueryDb/PgQueryResolver_Builder.res +4 -47
  6. package/src/adapter/QueryDb/PgQueryResolver_Builder.res.mjs +1 -30
  7. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +0 -11
  8. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +0 -5
  9. package/src/adapter/Runtime/SideEffectHandlerRuntime_Builder_Single.res +6 -7
  10. package/src/components/Api/AppSync_Adapter.res +143 -101
  11. package/src/components/Api/AppSync_Adapter.res.mjs +60 -57
  12. package/src/components/Api/AppSync_MergedApi.res +218 -0
  13. package/src/components/Api/AppSync_MergedApi.res.mjs +122 -0
  14. package/src/components/Api/AppSync_SdlDecorate.res +49 -61
  15. package/src/components/Api/AppSync_SdlDecorate.res.mjs +38 -29
  16. package/src/components/Plugin.res.mjs +1 -2
  17. package/tests/AppSync_AdapterTest.res +184 -0
  18. package/tests/AppSync_AdapterTest.res.mjs +136 -0
  19. package/tests/AppSync_SdlDecorateTest.res +33 -86
  20. package/tests/AppSync_SdlDecorateTest.res.mjs +26 -71
  21. package/tests/MCP_LambdaTest.res +4 -6
  22. package/tests/MCP_LambdaTest.res.mjs +2 -2
  23. package/src/adapter/Api/ApiFragmentDeregistration.res +0 -138
  24. package/src/adapter/Api/ApiFragmentDeregistration.res.mjs +0 -108
  25. package/src/adapter/Api/ApiSchemaPush.res +0 -79
  26. package/src/adapter/Api/ApiSchemaPush.res.mjs +0 -64
  27. package/src/adapter/Api/Platform_ApiFragments_Lambda.res +0 -222
  28. package/src/adapter/Api/Platform_ApiFragments_Lambda.res.mjs +0 -202
  29. package/src/adapter/QueryDb/NodeResolver_AppSync.res +0 -71
  30. package/src/adapter/QueryDb/NodeResolver_AppSync.res.mjs +0 -44
  31. package/src/adapter/Runtime/ApiSchemaPush_Runtime.mjs +0 -204
  32. package/tests/ApiSchemaPushTest.res +0 -32
  33. package/tests/ApiSchemaPushTest.res.mjs +0 -20
package/src/Platform.res CHANGED
@@ -13,10 +13,6 @@
13
13
 
14
14
  let log = ReventlessCore.Logger.fromEnv()
15
15
 
16
- // Deploy-time schema-push shrink-guard threshold (deploy-time analogue of the
17
- // runtime RUNTIME_SCHEMA_SHRINK_THRESHOLD in AdminEventCollectorEntryPoint.mjs).
18
- // A push whose stitched SDL has fewer than (threshold × live) root fields is
19
- // refused as a likely stale concurrent-deploy stitch. Default 0.5; override via
20
16
  // API config ref — populated during MakeWithConfig so slice builders
21
17
  // can access api/apiRole outside the functor constraint.
22
18
  //
@@ -105,9 +101,6 @@ module MakeWithConfig = (
105
101
  // Lambda (DynamoDB scan) — keep it off the Postgres selection like the other
106
102
  // admin stores.
107
103
  QueryDbBackend.exempt(ReventlessCore.UiFragments.name)
108
- // The ApiFragments read model backs the Platform_ApiFragments status query
109
- // (DynamoDB scan) and the schema-push SideEffect — admin store, off Postgres too.
110
- QueryDbBackend.exempt(ReventlessCore.ApiFragmentsReadModelSpec.name)
111
104
  })
112
105
  type api = Types.AppSync.api
113
106
  type role = Types.AppSync.role
@@ -126,134 +119,112 @@ module MakeWithConfig = (
126
119
  ->Pulumi.Config.get("stack")
127
120
  ->Option.map(stack => Pulumi.StackReference.make(stack))
128
121
 
129
- // Helper: build a phantom GraphQLApi.t from resolved id + endpoint strings.
130
- let makePhantomApi = (apiId, apiEndpoint): PulumiAws.AppSync.GraphQLApi.t => ({
131
- id: Pulumi.Output.make(apiId),
132
- arn: Pulumi.Output.make(""),
133
- name: Pulumi.Output.make(""),
134
- uris: Pulumi.Output.make(({graphQL: apiEndpoint, realtime: ""}: PulumiAws.AppSync.GraphQLApi.uris)),
135
- }: PulumiAws.AppSync.GraphQLApi.t)
136
-
137
- // Helper: build a phantom IAM.Role.t from a resolved role ARN.
138
- let makePhantomRole = (apiRoleArn): PulumiAws.IAM.Role.t => {
139
- let roleName =
140
- apiRoleArn->String.split("/")->Array.at(-1)->Option.getOr(apiRoleArn)
141
- ({
142
- arn: Pulumi.Output.make(apiRoleArn),
143
- id: Pulumi.Output.make(roleName),
144
- name: Pulumi.Output.make(roleName),
145
- }: PulumiAws.IAM.Role.t)
146
- }
122
+ // ── Merged-mode source SDL assembly (merged-api plan, Phase 3) ────────────
123
+ // Empty base fragment — no types, no mutations, no queries. Used by the
124
+ // plugin Api in split mode (so plugin schema has no core fields) and as the
125
+ // base of the split-mode Domain source document below.
126
+ // Split-mode Domain base fragment. No component fields — the Domain merged
127
+ // API's fields come from plugin sources; this document only anchors the
128
+ // canonical relay base types. `Platform_ping` exists because a GraphQL
129
+ // schema cannot have an empty Query type (unresolved → null; spike
130
+ // precedent). The global `node` query is NOT emitted on AWS — see the
131
+ // "Relay node resolution" section of the merged-api plan (never resolved on
132
+ // any deployed AWS platform, zero consumers; the Node interface and global
133
+ // IDs stay).
134
+ let domainBaseFragment = ReventlessCore.GraphQL_Stitcher.encode({
135
+ types: [],
136
+ mutations: [],
137
+ queries: [" Platform_ping: String"],
138
+ subscriptions: [],
139
+ subscriptionSources: [],
140
+ })
141
+
142
+ // The canonical base document for a merged API's platform-owned source:
143
+ // rendered standalone (relay base types included, no global `node` query),
144
+ // AWS-dialect decorated, and `@canonical`-stamped so the platform-owned
145
+ // shared types win over every plugin source's standalone copy (divergence
146
+ // is shadowed, not MERGE_FAILED — Phase-0 finding 1).
147
+ let assembleCanonicalSourceSdl = (~baseFragment): string =>
148
+ AppSync_Adapter.stitchStandaloneWithAwsDirectives(~fragment=baseFragment)
149
+ ->AppSync_SdlDecorate.stampCanonicalTypes
150
+
151
+ // Admin base as a source-API document — auth-decorated (all fields Admin,
152
+ // Cognito-only; the deploy-time SigV4 system-caller fields died with the
153
+ // fragment registry) plus the canonical stamp.
154
+ let adminSourceSdl = (): string =>
155
+ assembleCanonicalSourceSdl(
156
+ ~baseFragment=AppSync_Adapter.injectAwsAuthAll(
157
+ ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
158
+ ~group="Admin",
159
+ ),
160
+ )
161
+
162
+ // Split-mode Domain source document: relay base types + Platform_ping — the
163
+ // Domain merged API's canonical owner (plugin fields come from plugin sources).
164
+ let domainBaseSourceSdl = (): string => assembleCanonicalSourceSdl(~baseFragment=domainBaseFragment)
147
165
 
148
166
  let (domainApi, domainApiRole, platformApi, platformApiRole) = switch platformStackRef {
149
167
  | None =>
150
- let (api, role) = AppSync_Adapter.makeApiResource(~name="DomainApi", ~opts={})
168
+ // The Domain API is an ordinary GRAPHQL source API with a DECLARATIVE
169
+ // schema. Unified: it carries the admin base (the canonical document).
170
+ // Split: it carries only the relay base document; the admin base lives on
171
+ // the Platform source API created in deployPlatform.
172
+ let schema = if Config.splitApi {
173
+ domainBaseSourceSdl()
174
+ } else {
175
+ adminSourceSdl()
176
+ }
177
+ let (api, role) = AppSync_Adapter.makeSourceApiResource(~name="DomainApi", ~schema, ~opts={})
151
178
  // In platform/monolithic mode the platform API is not yet known — it is created
152
179
  // during deployPlatform/makePlatform and the ref is updated there.
153
180
  (api, role, api, role)
154
181
  | Some(stackRef) =>
155
- // Plugin mode reconstruct phantom API/role from the platform's exported IDs.
156
- // Consumers only access api.id and role.arn, so other fields are unused.
182
+ // Plugin mode (merged-api plan, Phase 4): the plugin stack owns a real
183
+ // SOURCE API the single writer for its subgraph schema and resolvers.
184
+ // It fills all four API slots (resolver wiring is target-agnostic here;
185
+ // apiTarget only decides WHICH merged API the association in deployPlugin
186
+ // points at). The user pool comes from the platform's exports so Cognito
187
+ // primary auth matches across all sources of the merged endpoint — the
188
+ // plugin stack provisions no pool/client.
157
189
  //
158
190
  // In ESM mode, Pulumi exports are inside the "default" output.
159
191
  // Try top-level field first (CJS), fall back to "default".<field> (ESM).
160
192
  let defaultOutput: Pulumi.Output.t<option<JSON.t>> =
161
193
  stackRef->Pulumi.StackReference.getOutput("default")
162
-
163
- // ── Domain API (application mutations) ──────────────────────────────────
164
- let domainApiIdOutput: Pulumi.Output.t<option<string>> =
165
- stackRef->Pulumi.StackReference.getOutput("domainApiId")
166
- let domainApiEndpointOutput: Pulumi.Output.t<option<string>> =
167
- stackRef->Pulumi.StackReference.getOutput("domainApiEndpoint")
168
- let domainApiRoleArnOutput: Pulumi.Output.t<option<string>> =
169
- stackRef->Pulumi.StackReference.getOutput("domainApiRoleArn")
170
-
171
- let phantomApi: Types.AppSync.api =
172
- (domainApiIdOutput, domainApiEndpointOutput, defaultOutput)
194
+ let cognitoPoolIdOutput: Pulumi.Output.t<option<string>> =
195
+ stackRef->Pulumi.StackReference.getOutput("cognitoUserPoolId")
196
+ let cognitoRegionOutput: Pulumi.Output.t<option<string>> =
197
+ stackRef->Pulumi.StackReference.getOutput("cognitoRegion")
198
+ let userPoolConfig =
199
+ (cognitoPoolIdOutput, cognitoRegionOutput, defaultOutput)
173
200
  ->Pulumi.Output.all3
174
- ->Pulumi.Output.apply(((directId, directEndpoint, default)) => {
175
- let apiId = switch directId {
176
- | Some(id) => id
177
- | None =>
178
- default
179
- ->Option.flatMap(d => d->JSON.Decode.object)
180
- ->Option.flatMap(d => d->Dict.get("domainApiId"))
181
- ->Option.flatMap(v => v->JSON.Decode.string)
182
- ->Option.getOrThrow
183
- }
184
- let apiEndpoint = switch directEndpoint {
185
- | Some(ep) => ep
186
- | None =>
187
- default
188
- ->Option.flatMap(d => d->JSON.Decode.object)
189
- ->Option.flatMap(d => d->Dict.get("domainApiEndpoint"))
190
- ->Option.flatMap(v => v->JSON.Decode.string)
191
- ->Option.getOrThrow(
192
- ~message="Platform stack does not export 'domainApiEndpoint' — redeploy the platform stack first",
193
- )
194
- }
195
- makePhantomApi(apiId, apiEndpoint)
196
- })
197
- let phantomRole: Types.AppSync.role =
198
- (domainApiRoleArnOutput, defaultOutput)
199
- ->Pulumi.Output.all2
200
- ->Pulumi.Output.apply(((direct, default)) => {
201
- let domainApiRoleArn = switch direct {
202
- | Some(arn) => arn
203
- | None =>
204
- default
205
- ->Option.flatMap(d => d->JSON.Decode.object)
206
- ->Option.flatMap(d => d->Dict.get("domainApiRoleArn"))
207
- ->Option.flatMap(v => v->JSON.Decode.string)
208
- ->Option.getOrThrow
209
- }
210
- makePhantomRole(domainApiRoleArn)
211
- })
212
-
213
- // ── Platform API (admin / Platform_Sync* resolvers) ─────────────────────
214
- let platformApiIdOutput: Pulumi.Output.t<option<string>> =
215
- stackRef->Pulumi.StackReference.getOutput("platformApiId")
216
- let platformApiEndpointOutput: Pulumi.Output.t<option<string>> =
217
- stackRef->Pulumi.StackReference.getOutput("platformApiEndpoint")
218
- let platformApiRoleArnOutput: Pulumi.Output.t<option<string>> =
219
- stackRef->Pulumi.StackReference.getOutput("platformApiRoleArn")
220
-
221
- let phantomPlatformApi: Types.AppSync.api =
222
- (platformApiIdOutput, platformApiEndpointOutput, defaultOutput)
223
- ->Pulumi.Output.all3
224
- ->Pulumi.Output.apply(((directPlatId, directPlatEndpoint, default)) => {
201
+ ->Pulumi.Output.apply(((directPoolId, directRegion, default)) => {
225
202
  let getFromDefault = key =>
226
203
  default
227
204
  ->Option.flatMap(d => d->JSON.Decode.object)
228
205
  ->Option.flatMap(d => d->Dict.get(key))
229
206
  ->Option.flatMap(v => v->JSON.Decode.string)
230
- let platformApiId =
231
- directPlatId
232
- ->Option.orElse(getFromDefault("platformApiId"))
233
- ->Option.getOrThrow
234
- let platformApiEndpoint =
235
- directPlatEndpoint
236
- ->Option.orElse(getFromDefault("platformApiEndpoint"))
237
- ->Option.getOrThrow
238
- makePhantomApi(platformApiId, platformApiEndpoint)
239
- })
240
- let phantomPlatformRole: Types.AppSync.role =
241
- (platformApiRoleArnOutput, defaultOutput)
242
- ->Pulumi.Output.all2
243
- ->Pulumi.Output.apply(((directPlat, default)) => {
244
- let getFromDefault = key =>
245
- default
246
- ->Option.flatMap(d => d->JSON.Decode.object)
247
- ->Option.flatMap(d => d->Dict.get(key))
248
- ->Option.flatMap(v => v->JSON.Decode.string)
249
- let platformApiRoleArn =
250
- directPlat
251
- ->Option.orElse(getFromDefault("platformApiRoleArn"))
252
- ->Option.getOrThrow
253
- makePhantomRole(platformApiRoleArn)
207
+ let userPoolId =
208
+ directPoolId
209
+ ->Option.orElse(getFromDefault("cognitoUserPoolId"))
210
+ ->Option.getOrThrow(
211
+ ~message="Platform stack does not export 'cognitoUserPoolId' — redeploy the platform stack first",
212
+ )
213
+ let awsRegion = directRegion->Option.orElse(getFromDefault("cognitoRegion"))
214
+ (
215
+ {
216
+ userPoolId,
217
+ ?awsRegion,
218
+ defaultAction: PulumiAws.AppSync.GraphQLApi.ALLOW,
219
+ }: PulumiAws.AppSync.GraphQLApi.userPoolConfig
220
+ )
254
221
  })
255
-
256
- (phantomApi, phantomRole, phantomPlatformApi, phantomPlatformRole)
222
+ let (api, role) = AppSync_Adapter.makePluginSourceApiResource(
223
+ ~name="PluginSourceApi",
224
+ ~userPoolConfig,
225
+ ~opts={},
226
+ )
227
+ (api, role, api, role)
257
228
  }
258
229
 
259
230
  // Expose api/apiRole as Platform.T value bindings so DCB slice builders
@@ -577,37 +548,8 @@ module MakeWithConfig = (
577
548
  ReventlessCore.UiFragments,
578
549
  ReventlessCore.UiFragments_Projection,
579
550
  )
580
- // The API-fragment registry is a SINGLETON AGGREGATE now (not a DCB slice) — see
581
- // ApiFragmentRegistryAggregate / ApiFragmentsReadModel below.
582
-
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
- })
592
-
593
- module Api = {
594
- module Make = (
595
- FragmentConfig: {
596
- let baseFragment: ReventlessInfra.Api.schemaFragment
597
- },
598
- ): ReventlessInfra.Api.T => {
599
- module Builder = ReventlessCore.Api_Builder.Make(AppSync_Adapter)
600
- // In split mode, the plugin API uses an empty base fragment so plugin schema
601
- // has no core fields. In unified mode, use the provided base fragment as-is.
602
- let effectiveBaseFragment = if Config.splitApi {
603
- emptyBaseFragment
604
- } else {
605
- FragmentConfig.baseFragment
606
- }
607
- let make = (~name, ~opts=?) =>
608
- Builder.make(~name, ~baseFragment=effectiveBaseFragment, ~opts?)
609
- }
610
- }
551
+ // (domainBaseFragment the split-mode Domain source base is defined above,
552
+ // next to the merged-mode source SDL assembly that uses it.)
611
553
 
612
554
  // ── Typed identity casts — see Platform_Casts.res for rationale ─────────
613
555
  open Platform_Casts
@@ -631,6 +573,12 @@ module MakeWithConfig = (
631
573
  // plugins built afterwards read hooksApiRef (Domain/deploy-target). See platformHooks.adminApi.
632
574
  let hooksAdminApiRef: ref<option<ReventlessCore.Plugin_Helpers.hookedValue<unknown>>> = ref(None)
633
575
 
576
+ // Merged mode (plugin stack): the subgraph schema-push Output produced by
577
+ // preResolversSchemaHook, captured so deployPlugin can sequence the
578
+ // SourceApiAssociation behind it (the association's initial merge needs the
579
+ // source schema to exist on the plugin's source API).
580
+ let mergedSchemaPushedRef: ref<option<Pulumi.Output.t<unit>>> = ref(None)
581
+
634
582
  let resolveHookedApi = (): Types.AppSync.api =>
635
583
  switch hooksApiRef.contents {
636
584
  | Some({val}) => Obj.magic(val)
@@ -645,153 +593,6 @@ module MakeWithConfig = (
645
593
  | None => resolveHookedApi()
646
594
  }
647
595
 
648
- // ── Phase 3: staged-deploy API-fragment registration ────────────────────────
649
- // When a plugin deploys against an ALREADY-RUNNING platform (plugin-stack mode),
650
- // it registers its API-schema fragment with the platform via the Platform API
651
- // (SigV4 system caller) instead of writing deploy-schema:* rows and pushing the
652
- // schema itself. The platform-side reactive single writer (Plan 2e) stitches and
653
- // pushes; this waits on the push status so resolver creation (gated on the
654
- // returned Output) proceeds only once the plugin's fields are ACTIVE — or fails
655
- // with the stitch error. Used only in plugin-stack mode; makePlatform (all-at-once)
656
- // keeps the direct deploy-time push (the reactive writer is dormant there).
657
- @val external registerWaiterSetTimeout: (unit => unit, int) => unit = "setTimeout"
658
- let deployWaiterDelay = (ms: int): promise<unit> =>
659
- Promise.make((resolve, _) => registerWaiterSetTimeout(() => resolve(), ms))
660
-
661
- // Poll the platform's Platform_ApiFragments status query until the plugin's row
662
- // shows a push that landed at/after our registration time (`sinceAt`) — a fresh
663
- // "ok" means our fields are live; a fresh "error" surfaces the stitch failure. An
664
- // older "ok" is a stale prior deploy's and is ignored (ISO strings sort lexically).
665
- let waitForApiFragmentPush = async (~endpoint, ~region, ~pluginId, ~sinceAt) => {
666
- let query = "query { Platform_ApiFragments { pluginId pushStatus pushMessage pushedAt } }"
667
- let maxAttempts = 90 // ~3 min at 2s intervals
668
- let rec loop = async attempt =>
669
- if attempt >= maxAttempts {
670
- JsError.throwWithMessage(
671
- `Timed out (>3min) waiting for the platform to push ${pluginId}'s API fragment — check the AdminEventCollector Lambda logs.`,
672
- )
673
- } else {
674
- let data = await Util_AppSync_Caller.sendQuery(~endpoint, ~region, ~queryString=query)
675
- let row =
676
- data
677
- ->Option.flatMap(JSON.Decode.object)
678
- ->Option.flatMap(d => d->Dict.get("Platform_ApiFragments"))
679
- ->Option.flatMap(JSON.Decode.array)
680
- ->Option.getOr([])
681
- ->Array.find(entry =>
682
- entry
683
- ->JSON.Decode.object
684
- ->Option.flatMap(e => e->Dict.get("pluginId"))
685
- ->Option.flatMap(JSON.Decode.string) == Some(pluginId)
686
- )
687
- ->Option.flatMap(JSON.Decode.object)
688
- switch row {
689
- | Some(e) =>
690
- let field = k => e->Dict.get(k)->Option.flatMap(JSON.Decode.string)->Option.getOr("")
691
- let status = field("pushStatus")
692
- let fresh = field("pushedAt") >= sinceAt
693
- if fresh && status == "ok" {
694
- log.info(~comp="registerFragmentViaApi", `${pluginId} schema push confirmed ACTIVE`)
695
- } else if fresh && status == "error" {
696
- JsError.throwWithMessage(`Schema push failed for ${pluginId}: ${field("pushMessage")}`)
697
- } else {
698
- await deployWaiterDelay(2000)
699
- await loop(attempt + 1)
700
- }
701
- | None =>
702
- await deployWaiterDelay(2000)
703
- await loop(attempt + 1)
704
- }
705
- }
706
- await loop(0)
707
- }
708
-
709
- let registerFragmentViaApi = (
710
- ~name: string,
711
- ~fragment: Reventless.Plugin.apiSchemaFragment,
712
- ~apiTargetName: string,
713
- ): Pulumi.Output.t<unit> => {
714
- // Platform_RegisterApiFragment is an admin systemCallable mutation on the Platform
715
- // API (regardless of the plugin's apiTarget, which is passed as the enum argument).
716
- let platformApi = switch apiConfigRef.contents {
717
- | Some({platformApi}) => platformApi
718
- | None => domainApi
719
- }
720
- let region =
721
- Pulumi.Config.make(Some("aws"))->Pulumi.Config.get("region")->Option.getOr("unknown")
722
- let endpointOutput =
723
- platformApi
724
- ->Pulumi.Output.flatMap(api => api.uris)
725
- ->Pulumi.Output.apply(uris => uris.graphQL)
726
- // Deregister-on-destroy: a dynamic resource whose `delete` handler sends
727
- // Platform_DeregisterApiFragment when the plugin stack is destroyed (final
728
- // retirement). It never replaces on a version bump, so supersession does NOT
729
- // deregister — only a genuine `pulumi destroy` removes the fields.
730
- let _ = ApiFragmentDeregistration.make(
731
- ~name=`${name}ApiFragmentRegistration`,
732
- ~pluginId=name,
733
- ~endpoint=endpointOutput->Pulumi.Output.asInput,
734
- ~region,
735
- )
736
- endpointOutput
737
- ->Pulumi.Output.flatMap(endpoint => {
738
- let run = async () => {
739
- let at = Date.make()->Date.toISOString
740
- // The registry is a SINGLETON aggregate — the mutation `id` arg is the fixed
741
- // constant, `pluginId` (a payload field) carries the plugin name.
742
- let variables = {
743
- "id": "registry",
744
- "pluginId": name,
745
- "fragment": {"encoded": fragment.encoded, "protocol": fragment.protocol},
746
- "apiTarget": Util_AppSync_Caller.graphqlEnum(apiTargetName),
747
- "at": at,
748
- }
749
- let selection = "{ __typename ... on CommandAccepted { eventCount } ... on CommandRejected { errorCode errorDetail } }"
750
- log.info(
751
- ~comp="registerFragmentViaApi",
752
- `Registering API fragment for ${name} (${apiTargetName}) via ${endpoint}`,
753
- )
754
- let result = await Util_AppSync_Caller.sendMutation(
755
- ~endpoint,
756
- ~region,
757
- ~mutation="Platform_ApiFragmentRegistry_RegisterApiFragment",
758
- ~selection,
759
- ~variables,
760
- )
761
- let outcome =
762
- result
763
- ->Option.flatMap(JSON.Decode.object)
764
- ->Option.flatMap(d => d->Dict.get("Platform_ApiFragmentRegistry_RegisterApiFragment"))
765
- ->Option.flatMap(JSON.Decode.object)
766
- switch outcome {
767
- | Some(cmd) =>
768
- let typename =
769
- cmd->Dict.get("__typename")->Option.flatMap(JSON.Decode.string)->Option.getOr("")
770
- switch typename {
771
- | "CommandRejected" =>
772
- let code =
773
- cmd->Dict.get("errorCode")->Option.flatMap(JSON.Decode.string)->Option.getOr("unknown")
774
- JsError.throwWithMessage(`RegisterApiFragment rejected for ${name}: ${code}`)
775
- | "CommandAccepted" =>
776
- // eventCount 0 ⇒ idempotent no-op (unchanged fragment) — fields already live,
777
- // proceed without waiting; >0 ⇒ a real change, wait for the reactive push.
778
- let eventCount =
779
- cmd->Dict.get("eventCount")->Option.flatMap(JSON.Decode.float)->Option.getOr(0.0)
780
- if eventCount > 0.0 {
781
- await waitForApiFragmentPush(~endpoint, ~region, ~pluginId=name, ~sinceAt=at)
782
- } else {
783
- log.info(~comp="registerFragmentViaApi", `${name} fragment unchanged — no push needed`)
784
- }
785
- | _ => await waitForApiFragmentPush(~endpoint, ~region, ~pluginId=name, ~sinceAt=at)
786
- }
787
- | None =>
788
- // Unparseable result (mutation may have failed) — fall back to polling.
789
- await waitForApiFragmentPush(~endpoint, ~region, ~pluginId=name, ~sinceAt=at)
790
- }
791
- }
792
- run()->Pulumi.Output.fromPromise
793
- })
794
- }
795
596
 
796
597
  let hooks: ReventlessCore.Plugin_Helpers.platformHooks = {
797
598
  // AWS uses Interstack for admin extension points — leave ref at empty dict.
@@ -838,97 +639,39 @@ module MakeWithConfig = (
838
639
  // makePlatform / deployPlatform before Admin.construct fires, is visible):
839
640
  // - split mode: platformApi from splitApiOutputsRef
840
641
  // - 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
- )
862
- 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, _)) =>
642
+ // Admin base SDL is DECLARATIVE on the source API resource
643
+ // (makeSourceApiResource) the provider runs StartSchemaCreation + poll
644
+ // before the resource resolves, so admin resolvers chained on the API are
645
+ // already ordered after the schema is ACTIVE. No push.
646
+ preAdminResolversSchemaHook: (~adminBarrier) => adminBarrier,
647
+
648
+ // Push the plugin's standalone subgraph document to the plugin's OWN
649
+ // source API a single writer by construction. The returned Output gates
650
+ // resolver creation and deployPlugin additionally sequences the
651
+ // SourceApiAssociation behind it (the initial merge needs the source
652
+ // schema present). Under AUTO_MERGE every later schema update here
653
+ // re-merges automatically.
654
+ preResolversSchemaHook: (~name, ~version, pluginFragment) => {
655
+ let sdl = AppSync_Adapter.stitchStandaloneWithAwsDirectives(~fragment=pluginFragment)
656
+ let pushed =
657
+ domainApi->Pulumi.Output.flatMap(api =>
879
658
  api.id->Pulumi.Output.flatMap(apiId => {
880
- log.info(~comp="preAdminResolversSchemaHook", `Pushing admin schema to ${apiId}`)
659
+ log.info(
660
+ ~comp="preResolversSchemaHook",
661
+ `Pushing subgraph schema for ${name}@${version} to source API ${apiId}`,
662
+ )
881
663
  let client = AppSync_Adapter.getClient()
882
664
  client
883
665
  ->AppSync_Adapter.startSchemaCreationRetrying({apiId, definition: sdl})
884
666
  ->Promise.then(async _ => {
885
- log.info(
886
- ~comp="preAdminResolversSchemaHook",
887
- "startSchemaCreation called, waiting for ACTIVE",
888
- )
889
667
  await AppSync_Adapter.waitForSchemaActive(client, apiId)
890
- log.info(~comp="preAdminResolversSchemaHook", "schema is ACTIVE")
668
+ log.info(~comp="preResolversSchemaHook", "subgraph schema is ACTIVE")
891
669
  })
892
670
  ->Pulumi.Output.fromPromise
893
671
  })
894
672
  )
895
- }
896
- },
897
-
898
- // Staged deploy (deployPlugin against a running platform): register the
899
- // plugin's fragment via the Platform API (SigV4); the reactive ApiSchemaPush
900
- // SideEffect stitches + pushes cumulatively. The legacy all-at-once
901
- // deploy-schema:* write+scan+push path was retired in Phase 4b (makePlatform
902
- // with plugins is no longer supported on AWS).
903
- preResolversSchemaHook: (~name, ~version, pluginFragment) => {
904
- log.info(
905
- ~comp="preResolversSchemaHook",
906
- `Pushing schema for plugin ${name}@${version} to AppSync`,
907
- )
908
-
909
- // Capture deploy target synchronously — deployPlugin resets currentDeployTarget to
910
- // Domain after P.make() returns, before any Pulumi.Output async callbacks run.
911
- let capturedDeployTarget = currentDeployTarget.contents
912
-
913
- switch platformStackRef {
914
- | Some(_) =>
915
- // Phase 3 — staged deploy against a running platform (deployPlugin): register the
916
- // fragment via the Platform API; the reactive single writer (2e) stitches + pushes.
917
- let apiTargetName = switch capturedDeployTarget {
918
- | Domain => "Domain"
919
- | Platform => "Platform"
920
- }
921
- registerFragmentViaApi(~name, ~fragment=pluginFragment, ~apiTargetName)
922
- | None =>
923
- // makePlatform(~plugins=[…]) all-at-once deploy-schema push retired in Phase 4b.
924
- // Staged deployPlatform + deployPlugin (register + reactive SideEffect push) is the
925
- // sole supported AWS deploy path. deployPlatform deploys no plugins, so this hook only
926
- // fires for makePlatform-with-plugins — no longer supported on AWS.
927
- failwith(
928
- "makePlatform(~plugins=[…]) is no longer supported on AWS — deploy the platform with " ++
929
- "deployPlatform and each plugin with deployPlugin (staged register + reactive push).",
930
- )
931
- }
673
+ mergedSchemaPushedRef := Some(pushed)
674
+ pushed
932
675
  },
933
676
  // DCB EventLog created hook — extracts DynamoDB table name for DCB CommandTopic Lambda handler.
934
677
  // Postgres-backed DCB logs (B2.3c) create no table, so there is no resource to read:
@@ -1112,49 +855,6 @@ module MakeWithConfig = (
1112
855
  PluginReadModelMappings,
1113
856
  )
1114
857
 
1115
- // Admin-internal ApiFragmentRegistry singleton aggregate — the platform API-schema
1116
- // fragment registry (docs/plans/event-sourced-fragment-registries.md). Threaded into
1117
- // Admin.construct's ~aggregates so registerAdminAggregateMutations wires
1118
- // Platform_ApiFragmentRegistry_RegisterApiFragment / _DeregisterApiFragment via the
1119
- // standard CommandGenerator auto-flow (RecordApiFragmentPush carries `@noApi`). IAM
1120
- // dual-auth for the SigV4 deploy caller comes from AdminApi.systemCallerFieldNames.
1121
- module ApiFragmentRegistryAggregate: (
1122
- ReventlessInfra.Aggregate.T with type api = Types.AppSync.api
1123
- ) = Aggregate_Builder_Single.Make(
1124
- ReventlessCore.ApiFragmentRegistrySpec,
1125
- ReventlessCore.ApiFragmentRegistryBehavior,
1126
- ReventlessInfra.NoEventMappings.Make(ReventlessCore.ApiFragmentRegistrySpec),
1127
- )
1128
-
1129
- // Admin ApiFragments read model — per-plugin status rows off the aggregate's event
1130
- // topic (DynamoDB stream), backing the Platform_ApiFragments status query.
1131
- module ApiFragmentsReadModelMappings: Reventless.Projection.Mappings
1132
- with module Target := ReventlessCore.ApiFragmentsReadModelSpec = {
1133
- module M = ReventlessCore.ApiFragmentsProjection.Mappings
1134
- module type Mapping = M.Mapping
1135
- let moduleUrl: string = ReventlessCore.ApiFragmentsProjection.moduleUrl
1136
- let mappings: array<module(Mapping)> = ReventlessCore.ApiFragmentsProjection.mappings
1137
- }
1138
- // NoResolver builder: the ApiFragments RM's query surface is the dedicated
1139
- // Platform_ApiFragments Lambda (declared in baseFragment), NOT an auto-generated
1140
- // Connection resolver — so it must not emit AppSync query resolvers (they'd orphan
1141
- // against the static pushed baseFragment: "No field named apiFragments on type Query").
1142
- // Still stream-projects the table from the aggregate's event stream. Mirrors local's
1143
- // MakeNoResolver.
1144
- module ApiFragmentsReadModel = ReadModel_Builder_NoResolver_Stream.Make(
1145
- ReventlessCore.ApiFragmentsReadModelSpec,
1146
- ApiFragmentsReadModelMappings,
1147
- )
1148
-
1149
- // Admin SideEffectHandler hosting the reactive schema-push side effect (ApiSchemaPush).
1150
- // Subscribes to the ApiFragmentRegistry aggregate's DynamoDB stream (single-shard for a
1151
- // singleton → naturally serialized, no concurrent StartSchemaCreation) and, on each
1152
- // ApiSchemaComputed, stitches + pushes the schema per target API, then records the
1153
- // outcome via RecordApiFragmentPush. Wired in deployPlatform (the staged path where the
1154
- // deploy caller fires RegisterApiFragment); makePlatform builds the schema directly and
1155
- // never triggers it.
1156
- module AdminApiSchemaPushHandler = SideEffectHandler_Single.Make()
1157
-
1158
858
  module type PluginMaker = {
1159
859
  let make: unit => Plugin.component
1160
860
  }
@@ -1283,185 +983,14 @@ module MakeWithConfig = (
1283
983
  // In unified mode, makePlatform is a no-op (schema stitching handled by events).
1284
984
  let makePlatform = (~version, ~plugins: array<module(PluginMaker)>) => {
1285
985
  log.info(~comp="Platform", `v${version}`)
1286
- // Create scheduler and populate platform context refs so Plugin_Builder
1287
- // can read them without app plugins having to pass them through.
1288
- let scheduler = makeScheduler()
1289
- hooks.scheduler := Some(scheduler)
1290
- hooks.api := Some(domainApi->wrapHookedValue)
1291
- hooks.apiRole := Some(domainApiRole->wrapHookedValue)
1292
-
1293
- // Phase 2: create the Platform API resource early — before Admin.construct —
1294
- // so admin resolvers are attached to the correct API in split mode.
1295
- // In unified mode this is the same resource as the Domain API.
1296
- let (platformApi, platformApiRole) = if Config.splitApi {
1297
- AppSync_Adapter.makeApiResource(~name="PlatformApi", ~opts={})
1298
- } else {
1299
- (domainApi, domainApiRole)
1300
- }
1301
-
1302
- // Admin DCB mutation resolvers bind to the Platform API (split mode) or the shared api
1303
- // (unified). Recorded now that the Platform API resource exists, so the admin's deferred
1304
- // dcbConnectFn (via ~onAdminApi) targets it rather than the Domain/deploy-target hooksApiRef.
1305
- hooks.adminApi := Some(platformApi->wrapHookedValue)
1306
-
1307
- // Update splitApiOutputsRef and apiConfig with the now-known Platform API.
1308
- if Config.splitApi {
1309
- splitApiOutputsRef := Some({platformApi, platformApiRole})
1310
- switch apiConfigRef.contents {
1311
- | Some(c) =>
1312
- apiConfigRef := Some({
1313
- domainApi: c.domainApi,
1314
- domainApiRole: c.domainApiRole,
1315
- platformApi: platformApi,
1316
- platformApiRole: platformApiRole,
1317
- })
1318
- | None => ()
1319
- }
1320
- }
1321
-
1322
- // Phase 2: Admin resolvers go on the Platform API (platformApi) in split mode,
1323
- // on the shared Domain API in unified mode.
1324
- let admin = Admin.construct(
1325
- ~version,
1326
- ~extensionPoints=[],
1327
- ~aggregates=[module(PluginAggregate), module(ApiFragmentRegistryAggregate)],
1328
- ~readModels=[module(PluginReadModel), module(ApiFragmentsReadModel)],
1329
- ~scheduler,
1330
- ~resourceNaming=Util_ResourceNaming.operations,
1331
- ~api=platformApi,
1332
- ~apiRole=platformApiRole,
1333
- ~stateChangeSlices=[module(UiFragmentRegistrySlice)],
1334
- ~stateViewSlices=[module(UiFragmentsViewSlice)],
1335
- ~automationSlices=[],
1336
- ~outboundTranslationSlices=[],
1337
- ~inboundTranslationSlices=[],
1338
- )
1339
-
1340
- // Mount the Platform_ComponentDefinitions Lambda resolver on the Platform API
1341
- // (split mode) or Domain API (unified mode — platformApi == domainApi above).
1342
- // Also register the Plugin RM table with the AllAggregates Lambda runtime
1343
- // so its in-Lambda plugin status gate can read plugin status at command
1344
- // dispatch time (Part 2.3 of the resolver plan).
1345
- switch admin.readModelsOutputs->Dict.get("Plugins") {
1346
- | Some(pluginRm) =>
1347
- switch pluginRm.queryDb.resources->Array.get(0) {
1348
- | Some(r) =>
1349
- Platform_ComponentDefinitions_Lambda.make(
1350
- ~api=platformApi,
1351
- ~pluginReadModelTableName=r.name,
1352
- ~opts={},
1353
- )
1354
- AggregateRuntime_Builder_Single.setPluginReadModelTable(~name=r.name)
1355
- | None => ()
1356
- }
1357
- | None => ()
1358
- }
1359
-
1360
- // Mount the Platform_UIFragments Lambda resolver — scans the UiFragments
1361
- // StateViewSlice table provisioned above and returns one entry per registered
1362
- // plugin UI.
1363
- switch admin.stateViewSlicesOutputs->Dict.get("UiFragments") {
1364
- | Some(rm) =>
1365
- switch rm.queryDb.resources->Array.get(0) {
1366
- | Some(r) =>
1367
- Platform_UIFragments_Lambda.make(
1368
- ~api=platformApi,
1369
- ~uiFragmentRegistryTableName=r.name,
1370
- ~schemaReady=admin.adminSchemaPushed,
1371
- ~opts={},
1372
- )
1373
- | None => ()
1374
- }
1375
- | None => ()
1376
- }
1377
-
1378
- // Mount the Platform_ApiFragments Lambda resolver — scans the ApiFragments
1379
- // StateViewSlice table and returns the push-status row per plugin (the deploy
1380
- // waiter polls this). The query field is in the pushed admin base but was
1381
- // unresolved on AWS until now.
1382
- switch admin.readModelsOutputs->Dict.get("ApiFragments") {
1383
- | Some(rm) =>
1384
- switch rm.queryDb.resources->Array.get(0) {
1385
- | Some(r) =>
1386
- Platform_ApiFragments_Lambda.make(
1387
- ~api=platformApi,
1388
- ~apiFragmentRegistryTableName=r.name,
1389
- ~schemaReady=admin.adminSchemaPushed,
1390
- ~opts={},
1391
- )
1392
- | None => ()
1393
- }
1394
- | None => ()
1395
- }
1396
-
1397
- // Build each plugin.
1398
- let pluginComponents = plugins->Array.map(plugin => {
1399
- module P = unpack(plugin)
1400
- P.make()
1401
- })
1402
-
1403
- // Note: StateTopic_AppSync.finish runs from inside subscriptionInfraHook
1404
- // (Phase 4 wiring above), not here — Plugin_Builder fires the hook from
1405
- // within a Pulumi.Output.apply chain, so finishing synchronously here would
1406
- // see an empty registry.
1407
-
1408
- // Export first plugin's outputs (monolithic mode = typically single plugin).
1409
- switch pluginComponents->Array.get(0) {
1410
- | Some(pluginComponent) =>
1411
- let pluginOutputs = pluginComponent->ReventlessCore.Component.outputs
1412
- ReventlessCore.Plugin_Helpers.exportPluginOutputs(pluginOutputs)
1413
- | None => ()
1414
- }
1415
-
1416
- // B2.3d: provision the Postgres change-feed relay (monolithic mode — all plugins
1417
- // built above, so the DcbBackend relay registry is complete).
1418
- provisionPgChangeFeedRelay()
1419
-
1420
- // B3.2b: provision the shared PgQueryResolver Lambda + AppSync data source for
1421
- // Postgres-backed read models (monolithic mode). Runs after construct so the
1422
- // resolver-binding registry (QueryDbResolvers_Lambda.make) and the ReadModel
1423
- // spec-module registry are complete; it fulfils the deferred `dataSourceName`
1424
- // the Postgres storage maker handed to the (schema-pushed) resolvers. App read
1425
- // model resolvers attach to the Domain API. Plugin-stack mode is deferred —
1426
- // it needs a per-stack data-source name to avoid collisions on the shared API.
1427
- switch QueryDbBackend.get() {
1428
- | Some(sel) => PgQueryResolver_Builder.provision(~api=domainApi, ~selection=sel, ~opts={})
1429
- | None => ()
1430
- }
1431
-
1432
- // Admin schema push is fired by preAdminResolversSchemaHook from inside
1433
- // Admin.construct (with createResolvers gated on it). Only exports below.
1434
- if Config.splitApi {
1435
- // Platform API exports (split mode).
1436
- Pulumi.Pulumi.export("platformApiId", platformApi->Pulumi.Output.flatMap(api => api.id))
1437
- Pulumi.Pulumi.export(
1438
- "platformApiEndpoint",
1439
- platformApi->Pulumi.Output.flatMap(api =>
1440
- api.uris->Pulumi.Output.apply(uris => uris.graphQL)
1441
- ),
1442
- )
1443
- Pulumi.Pulumi.export("platformApiRoleArn", platformApiRole->Pulumi.Output.flatMap(role => role.arn))
1444
- } else {
1445
- // Platform API exports (unified mode — same resource as Domain API).
1446
- Pulumi.Pulumi.export("platformApiId", domainApi->Pulumi.Output.flatMap(api => api.id))
1447
- Pulumi.Pulumi.export(
1448
- "platformApiEndpoint",
1449
- domainApi->Pulumi.Output.flatMap(api =>
1450
- api.uris->Pulumi.Output.apply(uris => uris.graphQL)
1451
- ),
1452
- )
1453
- Pulumi.Pulumi.export("platformApiRoleArn", domainApiRole->Pulumi.Output.flatMap(role => role.arn))
1454
- }
1455
-
1456
- // Domain API exports.
1457
- Pulumi.Pulumi.export("domainApiId", domainApi->Pulumi.Output.flatMap(api => api.id))
1458
- Pulumi.Pulumi.export(
1459
- "domainApiEndpoint",
1460
- domainApi->Pulumi.Output.flatMap(api =>
1461
- api.uris->Pulumi.Output.apply(uris => uris.graphQL)
1462
- ),
986
+ let _ = plugins
987
+ // Retired with the merged-API cutover: on AWS the platform deploys with
988
+ // deployPlatform (staged) and each plugin with deployPlugin — makePlatform
989
+ // predates the merge path and never gained merged wiring.
990
+ failwith(
991
+ "makePlatform is not supported on AWS — deploy the platform with deployPlatform and " ++
992
+ "each plugin with deployPlugin (merged-API composition).",
1463
993
  )
1464
- Pulumi.Pulumi.export("domainApiRoleArn", domainApiRole->Pulumi.Output.flatMap(role => role.arn))
1465
994
  }
1466
995
 
1467
996
  // Optional host UI shell bundle: a static SPA (e.g. reventless-ui's host-shell)
@@ -1473,6 +1002,18 @@ module MakeWithConfig = (
1473
1002
  bundleVersion: string,
1474
1003
  }
1475
1004
 
1005
+ // Merged-mode outputs of deployPlatform — the merged API(s), plus the
1006
+ // deploy-time merge gates (Outputs that resolve on MERGE_SUCCESS and fail
1007
+ // the deploy on MERGE_FAILED; folded into the ARN exports so they are
1008
+ // consumed). platformMerged == domainMerged in unified mode, mirroring the
1009
+ // platformApi/domainApi convention.
1010
+ type mergedApiOutputs = {
1011
+ domainMerged: AppSync_MergedApi.t,
1012
+ platformMerged: AppSync_MergedApi.t,
1013
+ domainMergeGate: Pulumi.Output.t<unit>,
1014
+ platformMergeGate: Pulumi.Output.t<unit>,
1015
+ }
1016
+
1476
1017
  let deployPlatform = (~version, ~hostUiBundle: option<hostUiBundleConfig>=?) => {
1477
1018
  log.info(~comp="Platform:deployPlatform", `v${version}`)
1478
1019
  let scheduler = makeScheduler()
@@ -1483,12 +1024,73 @@ module MakeWithConfig = (
1483
1024
  // Phase 2: create the Platform API resource early — before Admin.construct —
1484
1025
  // so admin resolvers are attached to the correct API in split mode.
1485
1026
  // In unified mode this is the same resource as the Domain API.
1027
+ // On the merge path the Platform API is an ordinary GRAPHQL source API
1028
+ // carrying the admin canonical document declaratively.
1486
1029
  let (platformApi, platformApiRole) = if Config.splitApi {
1487
- AppSync_Adapter.makeApiResource(~name="PlatformApi", ~opts={})
1030
+ AppSync_Adapter.makeSourceApiResource(~name="PlatformApi", ~schema=adminSourceSdl(), ~opts={})
1488
1031
  } else {
1489
1032
  (domainApi, domainApiRole)
1490
1033
  }
1491
1034
 
1035
+ // ── Merged-API composition (merged-api plan, Phase 3) ──────────────────
1036
+ // Create the merged API(s) and associate the platform-owned source(s).
1037
+ // Plugin stacks associate their own source APIs against the exported
1038
+ // merged-API ARN (Phase 4); `pulumi destroy` of a plugin stack deletes
1039
+ // its association — retirement by construction.
1040
+ let mergedOutputs = {
1041
+ AppSync_MergedApi.assertCompatiblePrimaryAuth(
1042
+ ~sourceMode=AppSync_MergedApi.authenticationTypeName(
1043
+ AppSync_Adapter.primaryAuthenticationType,
1044
+ ),
1045
+ ~mergedMode=AppSync_MergedApi.primaryAuthMode,
1046
+ )
1047
+ let domainMerged = AppSync_MergedApi.make(~name="DomainMergedApi", ~opts={})
1048
+ if Config.splitApi {
1049
+ // Split: admin source → Platform merged API; the relay-base Domain
1050
+ // source (the Domain merged API's canonical owner) → Domain merged.
1051
+ let platformMerged = AppSync_MergedApi.make(~name="PlatformMergedApi", ~opts={})
1052
+ let platformAssoc = AppSync_MergedApi.associateSource(
1053
+ ~name="PlatformAdminSourceAssociation",
1054
+ ~mergedApi=platformMerged,
1055
+ ~sourceApi=platformApi,
1056
+ ~opts={},
1057
+ )
1058
+ let domainAssoc = AppSync_MergedApi.associateSource(
1059
+ ~name="DomainBaseSourceAssociation",
1060
+ ~mergedApi=domainMerged,
1061
+ ~sourceApi=domainApi,
1062
+ ~opts={},
1063
+ )
1064
+ Some({
1065
+ domainMerged,
1066
+ platformMerged,
1067
+ domainMergeGate: AppSync_MergedApi.mergeStatusGate(
1068
+ ~mergedApi=domainMerged,
1069
+ ~association=domainAssoc,
1070
+ ),
1071
+ platformMergeGate: AppSync_MergedApi.mergeStatusGate(
1072
+ ~mergedApi=platformMerged,
1073
+ ~association=platformAssoc,
1074
+ ),
1075
+ })
1076
+ } else {
1077
+ // Unified: the single source API carries the admin canonical document.
1078
+ let assoc = AppSync_MergedApi.associateSource(
1079
+ ~name="DomainAdminSourceAssociation",
1080
+ ~mergedApi=domainMerged,
1081
+ ~sourceApi=domainApi,
1082
+ ~opts={},
1083
+ )
1084
+ let gate = AppSync_MergedApi.mergeStatusGate(~mergedApi=domainMerged, ~association=assoc)
1085
+ Some({
1086
+ domainMerged,
1087
+ platformMerged: domainMerged,
1088
+ domainMergeGate: gate,
1089
+ platformMergeGate: gate,
1090
+ })
1091
+ }
1092
+ }
1093
+
1492
1094
  // Admin DCB mutation resolvers bind to the Platform API (split mode) or the shared api
1493
1095
  // (unified). Recorded now that the Platform API resource exists, so the admin's deferred
1494
1096
  // dcbConnectFn (via ~onAdminApi) targets it rather than the Domain/deploy-target hooksApiRef.
@@ -1526,8 +1128,8 @@ module MakeWithConfig = (
1526
1128
  // (Heartbeat, ForwardCommand). None here keeps the deploy-time path
1527
1129
  // unchanged; the .mjs entry point supplies a real implementation.
1528
1130
  let manageSubscriptions = None
1529
- // Runtime connect-driven schema self-heal retired in Phase 4bthe reactive
1530
- // ApiSchemaPush SideEffect (on ApiFragmentRegistry events) is the single writer.
1131
+ // No runtime schema writer exists under merged-API compositionevery
1132
+ // source API is its own single writer.
1531
1133
  let updateApiSchema = None
1532
1134
  })
1533
1135
 
@@ -1536,8 +1138,8 @@ module MakeWithConfig = (
1536
1138
  let admin = Admin.construct(
1537
1139
  ~version,
1538
1140
  ~extensionPoints=[module(PluginExtensionPoint)],
1539
- ~aggregates=[module(PluginAggregate), module(ApiFragmentRegistryAggregate)],
1540
- ~readModels=[module(PluginReadModel), module(ApiFragmentsReadModel)],
1141
+ ~aggregates=[module(PluginAggregate)],
1142
+ ~readModels=[module(PluginReadModel)],
1541
1143
  ~scheduler,
1542
1144
  ~resourceNaming=Util_ResourceNaming.operations,
1543
1145
  ~api=platformApi,
@@ -1549,79 +1151,6 @@ module MakeWithConfig = (
1549
1151
  ~inboundTranslationSlices=[],
1550
1152
  )
1551
1153
 
1552
- // Reactive schema-push SideEffect (docs/plans/event-sourced-fragment-registries.md §
1553
- // Reactive writer design). Host ApiSchemaPush on an admin SideEffectHandler subscribed to
1554
- // the ApiFragmentRegistry aggregate's event topic (its DynamoDB stream). Deploy-derived
1555
- // config the runtime-pure push reads at invocation time is injected as Lambda env
1556
- // (~extraEnvVars); ~targets grants SQS send to the aggregate command topic (for the
1557
- // RecordApiFragmentPush write-back). Only in deployPlatform (the staged path where the
1558
- // deploy caller fires RegisterApiFragment); makePlatform pushes the schema directly.
1559
- let apiSchemaPushEventTopics = ReventlessCore.Aggregate.allEventTopics(admin.aggregatesOutputs)
1560
- let apiSchemaPushCmdTopics = ReventlessCore.Aggregate.allCommandTopics(admin.aggregatesOutputs)
1561
- // MUST be a `switch`, NOT `->Option.map(...)->Option.getOr(...)`. The map/getOr form
1562
- // materialises an `option<Pulumi.Output.t<string>>`, and wrapping a Pulumi Output in a
1563
- // ReScript option collapses the nested Output to `undefined` at runtime (the documented
1564
- // "option(Pulumi.Output.t) doesn't work" pitfall). That made API_SCHEMA_PUSH_CMD_TOPIC_URL
1565
- // resolve to undefined → Pulumi dropped the env var → the ApiSchemaPush runtime logged
1566
- // "no command-topic URL configured — skipping" and never pushed/recorded, so the deploy
1567
- // waiter timed out. Verified via local `pulumi preview`: map/getOr → isValidOutput=false;
1568
- // switch → isValidOutput=true.
1569
- let apiSchemaPushCmdTopicUrl = switch admin.aggregatesOutputs->Dict.get(
1570
- ReventlessCore.ApiFragmentRegistrySpec.name,
1571
- ) {
1572
- | Some(agg) =>
1573
- agg.commandTopic->Pulumi.Output.flatMap(ct =>
1574
- switch ct.resources->Array.get(0) {
1575
- | Some(r) => r.id
1576
- | None => Pulumi.Output.make("")
1577
- }
1578
- )
1579
- | None => Pulumi.Output.make("")
1580
- }
1581
- let apiSchemaPushEnv = Dict.fromArray([
1582
- ("API_SCHEMA_PUSH_DOMAIN_API_ID", domainApiId->Pulumi.Output.asInput),
1583
- (
1584
- "API_SCHEMA_PUSH_PLATFORM_API_ID",
1585
- platformApi->Pulumi.Output.flatMap(api => api.id)->Pulumi.Output.asInput,
1586
- ),
1587
- (
1588
- "API_SCHEMA_PUSH_SPLIT_API",
1589
- Pulumi.Output.make(Config.splitApi ? "true" : "false")->Pulumi.Output.asInput,
1590
- ),
1591
- (
1592
- "API_SCHEMA_PUSH_CLONER",
1593
- Pulumi.Output.make(Config.cloner ? "true" : "false")->Pulumi.Output.asInput,
1594
- ),
1595
- ("API_SCHEMA_PUSH_CMD_TOPIC_URL", apiSchemaPushCmdTopicUrl->Pulumi.Output.asInput),
1596
- ])
1597
- // ApiSchemaPush ignores the injected queryEngine (it self-wires push + write-back from
1598
- // env); an empty one satisfies the SideEffectHandler contract. `scheduler`/`queryEngine`
1599
- // are Outputs, so make() runs inside their apply; finish() (which builds the shared
1600
- // Lambda from the registered handlers) is registered on apiSchemaPushCmdTopics
1601
- // immediately after make() so it runs AFTER make's own deferred forEventCollector apply
1602
- // on that same Output (Pulumi runs same-Output apply callbacks in registration order).
1603
- let apiSchemaPushQueryEngine = QueryEngine.DynamoDb.make(Dict.make())
1604
- let _ =
1605
- (scheduler, apiSchemaPushQueryEngine)
1606
- ->Pulumi.Output.all2
1607
- ->Pulumi.Output.apply(((sched, qe)) => {
1608
- let _ = AdminApiSchemaPushHandler.make(
1609
- ~name="AdminApiSchemaPush",
1610
- ~sideEffects=[module(ApiSchemaPush)],
1611
- ~allEventTopics=apiSchemaPushEventTopics,
1612
- ~allCommandTopics=apiSchemaPushCmdTopics,
1613
- ~targets=[ReventlessCore.ApiFragmentRegistrySpec.name],
1614
- ~queryEngine=qe,
1615
- ~scheduler=sched,
1616
- ~resourceNaming=Util_ResourceNaming.operations,
1617
- ~extraEnvVars=apiSchemaPushEnv,
1618
- ~opts={},
1619
- )
1620
- let _ = apiSchemaPushCmdTopics->Pulumi.Output.apply(_ =>
1621
- SideEffectHandlerRuntime_Builder_Single.finish()
1622
- )
1623
- })
1624
-
1625
1154
  // Extract Plugin RM table name as Output.t<string>.
1626
1155
  // IMPORTANT: Do NOT use option<Pulumi.Output.t<…>> — Pulumi Outputs use property
1627
1156
  // lifting, which breaks ReScript's internal option encoding (BS_PRIVATE_NESTED_SOME_NONE).
@@ -1707,25 +1236,6 @@ module MakeWithConfig = (
1707
1236
  | None => ()
1708
1237
  }
1709
1238
 
1710
- // Mount the Platform_ApiFragments Lambda resolver — scans the ApiFragments
1711
- // StateViewSlice table and returns the push-status row per plugin (the deploy
1712
- // waiter polls this). The query field is in the pushed admin base but was
1713
- // unresolved on AWS until now.
1714
- switch admin.readModelsOutputs->Dict.get("ApiFragments") {
1715
- | Some(rm) =>
1716
- switch rm.queryDb.resources->Array.get(0) {
1717
- | Some(r) =>
1718
- Platform_ApiFragments_Lambda.make(
1719
- ~api=platformApi,
1720
- ~apiFragmentRegistryTableName=r.name,
1721
- ~schemaReady=admin.adminSchemaPushed,
1722
- ~opts={},
1723
- )
1724
- | None => ()
1725
- }
1726
- | None => ()
1727
- }
1728
-
1729
1239
  // Note: StateTopic_AppSync.finish runs from inside subscriptionInfraHook
1730
1240
  // (Phase 4 wiring above), not here — see the note in deployPlatform.
1731
1241
 
@@ -1764,6 +1274,44 @@ module MakeWithConfig = (
1764
1274
  )
1765
1275
  Pulumi.Pulumi.export("domainApiRoleArn", domainApiRole->Pulumi.Output.flatMap(role => role.arn))
1766
1276
 
1277
+ // Merged-API exports — plugin stacks associate their source APIs against
1278
+ // these ARNs (this replaces the SigV4 RegisterApiFragment handshake as the
1279
+ // cross-stack wiring on the merge path), and `mergedApiPrimaryAuth` is the
1280
+ // primary-auth contract source APIs must match. The ARN exports are gated
1281
+ // on the merge-status poll so a MERGE_FAILED fails the deploy loudly
1282
+ // instead of silently serving the last-good merged schema.
1283
+ switch mergedOutputs {
1284
+ | Some({domainMerged, platformMerged, domainMergeGate, platformMergeGate}) =>
1285
+ let mergeGatedArn = (merged: AppSync_MergedApi.t, gate: Pulumi.Output.t<unit>) =>
1286
+ (
1287
+ merged.api->Pulumi.Output.flatMap((api: PulumiAws.AppSync.GraphQLApi.t) => api.arn),
1288
+ gate,
1289
+ )
1290
+ ->Pulumi.Output.all2
1291
+ ->Pulumi.Output.apply(((arn, _)) => arn)
1292
+ let mergedEndpoint = (merged: AppSync_MergedApi.t) =>
1293
+ merged.api->Pulumi.Output.flatMap((api: PulumiAws.AppSync.GraphQLApi.t) =>
1294
+ api.uris->Pulumi.Output.apply(uris => uris.graphQL)
1295
+ )
1296
+ Pulumi.Pulumi.export("domainMergedApiArn", mergeGatedArn(domainMerged, domainMergeGate))
1297
+ Pulumi.Pulumi.export(
1298
+ "domainMergedApiId",
1299
+ domainMerged.api->Pulumi.Output.flatMap((api: PulumiAws.AppSync.GraphQLApi.t) => api.id),
1300
+ )
1301
+ Pulumi.Pulumi.export("domainMergedApiEndpoint", mergedEndpoint(domainMerged))
1302
+ Pulumi.Pulumi.export("platformMergedApiArn", mergeGatedArn(platformMerged, platformMergeGate))
1303
+ Pulumi.Pulumi.export(
1304
+ "platformMergedApiId",
1305
+ platformMerged.api->Pulumi.Output.flatMap((api: PulumiAws.AppSync.GraphQLApi.t) => api.id),
1306
+ )
1307
+ Pulumi.Pulumi.export("platformMergedApiEndpoint", mergedEndpoint(platformMerged))
1308
+ Pulumi.Pulumi.export(
1309
+ "mergedApiPrimaryAuth",
1310
+ Pulumi.Output.make(AppSync_MergedApi.primaryAuthMode),
1311
+ )
1312
+ | None => ()
1313
+ }
1314
+
1767
1315
  // Events API exports — consumed by plugin stacks to wire Source B (StateTopic) Lambdas.
1768
1316
  switch domainEventsApiOpt {
1769
1317
  | Some(eventsApi) =>
@@ -1800,11 +1348,22 @@ module MakeWithConfig = (
1800
1348
  )
1801
1349
 
1802
1350
  // Fire onPlatformDeployed hook with resolved platform metadata.
1803
- let resolvedDomainApiEndpoint = domainApi->Pulumi.Output.flatMap(api =>
1351
+ // Client-facing endpoints: on the merge path clients query the MERGED
1352
+ // endpoints (the source-API endpoints stay exported for coexisting
1353
+ // push-path stacks) — these also feed the host-UI config.json below.
1354
+ let clientDomainApi = switch mergedOutputs {
1355
+ | Some({domainMerged}) => domainMerged.api
1356
+ | None => domainApi
1357
+ }
1358
+ let clientPlatformApi = switch mergedOutputs {
1359
+ | Some({platformMerged}) => platformMerged.api
1360
+ | None => platformApi
1361
+ }
1362
+ let resolvedDomainApiEndpoint = clientDomainApi->Pulumi.Output.flatMap(api =>
1804
1363
  api.uris->Pulumi.Output.apply(uris => uris.graphQL)
1805
1364
  )
1806
1365
  let resolvedDomainApiRoleArn = domainApiRole->Pulumi.Output.flatMap(role => role.arn)
1807
- let resolvedPlatformApiEndpoint = platformApi->Pulumi.Output.flatMap(api =>
1366
+ let resolvedPlatformApiEndpoint = clientPlatformApi->Pulumi.Output.flatMap(api =>
1808
1367
  api.uris->Pulumi.Output.apply(uris => uris.graphQL)
1809
1368
  )
1810
1369
  let resolvedPlatformApiRoleArn = platformApiRole->Pulumi.Output.flatMap(role => role.arn)
@@ -2024,24 +1583,106 @@ module MakeWithConfig = (
2024
1583
  let pluginOutputs = pluginComponent->ReventlessCore.Component.outputs
2025
1584
  ReventlessCore.Plugin_Helpers.exportPluginOutputs(pluginOutputs)
2026
1585
 
1586
+ // ── Merged-API association (merged-api plan, Phase 4) ───────────────────
1587
+ // Associate this plugin's source API with the platform's merged API —
1588
+ // this replaces the SigV4 RegisterApiFragment handshake + reactive push +
1589
+ // waiter as the schema-composition mechanism. `pulumi destroy` deletes
1590
+ // the association + source API: retirement by construction. Create-time
1591
+ // 409s (AWS serializes association creates per merged API) surface as a
1592
+ // deploy failure — retry concurrent FIRST-TIME plugin deploys; steady-
1593
+ // state schema updates never re-create the association.
1594
+ switch platformStackRef {
1595
+ | Some(stackRef) =>
1596
+ let defaultOutput: Pulumi.Output.t<option<JSON.t>> =
1597
+ stackRef->Pulumi.StackReference.getOutput("default")
1598
+ let getMergedExport = (key: string): Pulumi.Output.t<string> => {
1599
+ let direct: Pulumi.Output.t<option<string>> =
1600
+ stackRef->Pulumi.StackReference.getOutput(key)
1601
+ (direct, defaultOutput)
1602
+ ->Pulumi.Output.all2
1603
+ ->Pulumi.Output.apply(((direct, default)) =>
1604
+ switch direct {
1605
+ | Some(v) => v
1606
+ | None =>
1607
+ default
1608
+ ->Option.flatMap(d => d->JSON.Decode.object)
1609
+ ->Option.flatMap(d => d->Dict.get(key))
1610
+ ->Option.flatMap(v => v->JSON.Decode.string)
1611
+ ->Option.getOrThrow(
1612
+ ~message=`Platform stack does not export '${key}' — deploy the platform with mergedApi=true first`,
1613
+ )
1614
+ }
1615
+ )
1616
+ }
1617
+ // apiTarget collapses to "which merged API ARN the association points at".
1618
+ let mergedApiArn = switch apiTarget {
1619
+ | Domain => getMergedExport("domainMergedApiArn")
1620
+ | Platform => getMergedExport("platformMergedApiArn")
1621
+ }
1622
+ // Primary-auth contract check (checked invariant, not a convention) —
1623
+ // folded into the ARN the association consumes so it always runs.
1624
+ let checkedMergedApiArn =
1625
+ (mergedApiArn, getMergedExport("mergedApiPrimaryAuth"))
1626
+ ->Pulumi.Output.all2
1627
+ ->Pulumi.Output.apply(((arn, mergedMode)) => {
1628
+ AppSync_MergedApi.assertCompatiblePrimaryAuth(
1629
+ ~sourceMode=AppSync_MergedApi.authenticationTypeName(
1630
+ AppSync_Adapter.primaryAuthenticationType,
1631
+ ),
1632
+ ~mergedMode,
1633
+ )
1634
+ arn
1635
+ })
1636
+ // Sequence the association behind the subgraph schema push (the
1637
+ // intra-stack replacement of the schemaPushed cross-stack gate).
1638
+ let schemaPushed = switch mergedSchemaPushedRef.contents {
1639
+ | Some(pushed) => pushed
1640
+ | None => Pulumi.Output.make()
1641
+ }
1642
+ let arnAfterSchemaPush =
1643
+ (checkedMergedApiArn, schemaPushed)
1644
+ ->Pulumi.Output.all2
1645
+ ->Pulumi.Output.apply(((arn, _)) => arn)
1646
+ let association = AppSync_MergedApi.associateSourceWithMergedArn(
1647
+ ~name="PluginSourceAssociation",
1648
+ ~mergedApiArn=arnAfterSchemaPush,
1649
+ ~sourceApi=domainApi,
1650
+ ~opts={},
1651
+ )
1652
+ // Fail the deploy loudly on MERGE_FAILED — the gate is folded into the
1653
+ // exported association id so it is always consumed.
1654
+ let mergeGate = AppSync_MergedApi.mergeStatusGateWith(
1655
+ ~mergedApiIdentifier=checkedMergedApiArn,
1656
+ ~association,
1657
+ )
1658
+ Pulumi.Pulumi.export(
1659
+ "sourceApiAssociationId",
1660
+ (association.associationId, mergeGate)
1661
+ ->Pulumi.Output.all2
1662
+ ->Pulumi.Output.apply(((id, _)) => id),
1663
+ )
1664
+ Pulumi.Pulumi.export(
1665
+ "pluginSourceApiId",
1666
+ domainApi->Pulumi.Output.flatMap(api => api.id),
1667
+ )
1668
+ Pulumi.Pulumi.export(
1669
+ "pluginSourceApiEndpoint",
1670
+ domainApi->Pulumi.Output.flatMap(api =>
1671
+ api.uris->Pulumi.Output.apply(uris => uris.graphQL)
1672
+ ),
1673
+ )
1674
+ | _ => ()
1675
+ }
1676
+
2027
1677
  // B2.3d: provision the Postgres change-feed relay (plugin-stack mode — this
2028
1678
  // plugin's Postgres DCB log(s) + collector queue were registered during P.make()).
2029
1679
  provisionPgChangeFeedRelay()
2030
1680
 
2031
1681
  // B3.2b/c: provision this plugin stack's PgQueryResolver Lambda + AppSync data
2032
- // source for its Postgres-backed read models (registered during P.make()). App
2033
- // read model resolvers attach to the Domain API. Per-plugin Lambda + auto-named
2034
- // data source, so plugin stacks don't collide. The node(id) resolver is skipped
2035
- // in plugin-stack mode — it's a single shared Query.node field only one stack
2036
- // may own (monolithic-only; see PgQueryResolver_Builder.provision).
1682
+ // source for its Postgres-backed read models (registered during P.make()).
1683
+ // Resolvers attach to this plugin's own source API.
2037
1684
  switch QueryDbBackend.get() {
2038
- | Some(sel) =>
2039
- PgQueryResolver_Builder.provision(
2040
- ~api=domainApi,
2041
- ~selection=sel,
2042
- ~opts={},
2043
- ~createNodeResolver=false,
2044
- )
1685
+ | Some(sel) => PgQueryResolver_Builder.provision(~api=domainApi, ~selection=sel, ~opts={})
2045
1686
  | None => ()
2046
1687
  }
2047
1688