@reventlessdev/reventless-aws 3.0.0-alpha.208 → 3.0.0-alpha.210

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -17,14 +19,6 @@ let log = ReventlessCore.Logger.fromEnv()
17
19
  // runtime RUNTIME_SCHEMA_SHRINK_THRESHOLD in AdminEventCollectorEntryPoint.mjs).
18
20
  // A push whose stitched SDL has fewer than (threshold × live) root fields is
19
21
  // refused as a likely stale concurrent-deploy stitch. Default 0.5; override via
20
- // DEPLOY_SCHEMA_SHRINK_THRESHOLD; values outside (0, 1) fall back to the default.
21
- @val @scope("process") external processEnv: Dict.t<string> = "env"
22
- let deploySchemaShrinkThreshold: float =
23
- switch processEnv->Dict.get("DEPLOY_SCHEMA_SHRINK_THRESHOLD")->Option.flatMap(Float.fromString) {
24
- | Some(n) if n > 0. && n < 1. => n
25
- | _ => 0.5
26
- }
27
-
28
22
  // API config ref — populated during MakeWithConfig so slice builders
29
23
  // can access api/apiRole outside the functor constraint.
30
24
  //
@@ -59,6 +53,17 @@ let getSplitApiOutputs = () => splitApiOutputsRef.contents
59
53
  module MakeWithConfig = (
60
54
  Config: {
61
55
  let splitApi: bool
56
+ /** Merged-API composition (docs/plans/merged-api-push-free-composition.md).
57
+ `true` (the default — `Make()` sets it): the platform creates AppSync
58
+ MERGED APIs and the platform-owned APIs are ordinary SOURCE APIs with
59
+ declarative schemas; plugin stacks (deployPlugin) create their own
60
+ source API and associate it against the platform's exported merged-API
61
+ ARN. No fragment registration, no reactive schema push. `false` keeps
62
+ the legacy push path until its Phase-5 retirement — switching an
63
+ already-deployed platform in either direction requires wiping its
64
+ stacks. Supported with deployPlatform + deployPlugin (makePlatform
65
+ predates the merge path); all stacks of one platform must agree. */
66
+ let mergedApi: bool
62
67
  let cloner: bool
63
68
  let commandHandlerConfig: ReventlessCore.Runtime.commandHandlerConfigs
64
69
  /** B2.3c platform toggle. When `Some`, every DCB EventLog is Postgres-backed
@@ -117,6 +122,8 @@ module MakeWithConfig = (
117
122
  // (DynamoDB scan) and the schema-push SideEffect — admin store, off Postgres too.
118
123
  QueryDbBackend.exempt(ReventlessCore.ApiFragmentsReadModelSpec.name)
119
124
  })
125
+ let mergedApiMode = Config.mergedApi
126
+
120
127
  type api = Types.AppSync.api
121
128
  type role = Types.AppSync.role
122
129
  type apiTarget = Domain | Platform
@@ -153,9 +160,59 @@ module MakeWithConfig = (
153
160
  }: PulumiAws.IAM.Role.t)
154
161
  }
155
162
 
163
+ // ── Merged-mode source SDL assembly (merged-api plan, Phase 3) ────────────
164
+ // Empty base fragment — no types, no mutations, no queries. Used by the
165
+ // plugin Api in split mode (so plugin schema has no core fields) and as the
166
+ // base of the split-mode Domain source document below.
167
+ let emptyBaseFragment = ReventlessCore.GraphQL_Stitcher.encode({
168
+ types: [],
169
+ mutations: [],
170
+ queries: [],
171
+ subscriptions: [],
172
+ subscriptionSources: [],
173
+ })
174
+
175
+ // The canonical base document for a merged API's platform-owned source:
176
+ // stitched with an EMPTY plugin list (the stitch injects the relay base
177
+ // types and the global `node` query — only these canonical documents carry
178
+ // `node`), AWS-dialect decorated, and `@canonical`-stamped so the platform-
179
+ // owned shared types win over every plugin source's standalone copy
180
+ // (divergence is shadowed, not MERGE_FAILED — Phase-0 finding 1).
181
+ let assembleCanonicalSourceSdl = (~baseFragment): string =>
182
+ AppSync_Adapter.stitchWithAwsDirectives(~baseFragment, ~pluginFragments=[])
183
+ ->AppSync_SdlDecorate.stampCanonicalTypes
184
+
185
+ // Admin base as a source-API document — same assembly as the push path's
186
+ // preAdminResolversSchemaHook, plus the canonical stamp.
187
+ let adminSourceSdl = (): string =>
188
+ assembleCanonicalSourceSdl(
189
+ ~baseFragment=AppSync_Adapter.injectAwsAuthAll(
190
+ ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
191
+ ~group="Admin",
192
+ ~iamFieldNames=ReventlessCore.AdminApi.systemCallerFieldNames,
193
+ ),
194
+ )
195
+
196
+ // Split-mode Domain source document: relay base + `node` only — the Domain
197
+ // merged API's canonical owner (plugin fields come from plugin sources).
198
+ let relayBaseSourceSdl = (): string => assembleCanonicalSourceSdl(~baseFragment=emptyBaseFragment)
199
+
156
200
  let (domainApi, domainApiRole, platformApi, platformApiRole) = switch platformStackRef {
157
201
  | None =>
158
- let (api, role) = 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
+ }
159
216
  // In platform/monolithic mode the platform API is not yet known — it is created
160
217
  // during deployPlatform/makePlatform and the ref is updated there.
161
218
  (api, role, api, role)
@@ -261,7 +318,51 @@ module MakeWithConfig = (
261
318
  makePhantomRole(platformApiRoleArn)
262
319
  })
263
320
 
264
- (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
+ }
265
366
  }
266
367
 
267
368
  // Expose api/apiRole as Platform.T value bindings so DCB slice builders
@@ -588,15 +689,8 @@ module MakeWithConfig = (
588
689
  // The API-fragment registry is a SINGLETON AGGREGATE now (not a DCB slice) — see
589
690
  // ApiFragmentRegistryAggregate / ApiFragmentsReadModel below.
590
691
 
591
- // Empty base fragment no types, no mutations, no queries.
592
- // Used by the plugin Api in split mode so plugin schema has no core fields.
593
- let emptyBaseFragment = ReventlessCore.GraphQL_Stitcher.encode({
594
- types: [],
595
- mutations: [],
596
- queries: [],
597
- subscriptions: [],
598
- subscriptionSources: [],
599
- })
692
+ // (emptyBaseFragment the empty split-mode plugin base is defined above,
693
+ // next to the merged-mode source SDL assembly that shares it.)
600
694
 
601
695
  module Api = {
602
696
  module Make = (
@@ -622,34 +716,6 @@ module MakeWithConfig = (
622
716
 
623
717
  // AWS platform hooks — all AWS-specific callbacks defined as a record.
624
718
  // In-memory hooks (mutationResolverHook etc.) are absent (optional = None).
625
- let deploySchemaPrefix = "deploy-schema:"
626
- let deploySchemaPlatformPrefix = "deploy-schema-platform:"
627
- let deploySchemaHashPrefix = "deploy-schema-hash:"
628
-
629
- let readSchemaHash = async (~tableName: string, ~apiId: string): option<string> => {
630
- open AwsSdk.DynamoDb.DocumentClient
631
- let key = Dict.fromArray([("id", `${deploySchemaHashPrefix}${apiId}`->JSON.Encode.string)])
632
- try {
633
- let result = await GetCommand.send(GetCommand.make({GetCommand.tableName, key}))
634
- result.item
635
- ->Option.flatMap(item => item->JSON.Decode.object)
636
- ->Option.flatMap(d => d->Dict.get("hash"))
637
- ->Option.flatMap(v => v->JSON.Decode.string)
638
- } catch {
639
- | _ => None
640
- }
641
- }
642
-
643
- let writeSchemaHash = async (~tableName: string, ~apiId: string, ~hash: string): unit => {
644
- open AwsSdk.DynamoDb.DocumentClient
645
- let item =
646
- Dict.fromArray([
647
- ("id", `${deploySchemaHashPrefix}${apiId}`->JSON.Encode.string),
648
- ("hash", hash->JSON.Encode.string),
649
- ])->JSON.Encode.object
650
- let _ = await PutCommand.send(PutCommand.make({PutCommand.tableName, item}))
651
- }
652
-
653
719
  // (Deploy-time retire hook removed: supersession is now decided by the
654
720
  // name-keyed Plugin aggregate (VersionSuperseded) — no RM scan drives a command.)
655
721
 
@@ -667,6 +733,12 @@ module MakeWithConfig = (
667
733
  // plugins built afterwards read hooksApiRef (Domain/deploy-target). See platformHooks.adminApi.
668
734
  let hooksAdminApiRef: ref<option<ReventlessCore.Plugin_Helpers.hookedValue<unknown>>> = ref(None)
669
735
 
736
+ // Merged mode (plugin stack): the subgraph schema-push Output produced by
737
+ // preResolversSchemaHook, captured so deployPlugin can sequence the
738
+ // SourceApiAssociation behind it (the association's initial merge needs the
739
+ // source schema to exist on the plugin's source API).
740
+ let mergedSchemaPushedRef: ref<option<Pulumi.Output.t<unit>>> = ref(None)
741
+
670
742
  let resolveHookedApi = (): Types.AppSync.api =>
671
743
  switch hooksApiRef.contents {
672
744
  | Some({val}) => Obj.magic(val)
@@ -874,69 +946,107 @@ module MakeWithConfig = (
874
946
  // makePlatform / deployPlatform before Admin.construct fires, is visible):
875
947
  // - split mode: platformApi from splitApiOutputsRef
876
948
  // - unified mode / not-yet-populated: domainApi
877
- preAdminResolversSchemaHook: (~adminBarrier) => {
878
- // The admin-base SDL stitches AdminApi.baseFragment with an EMPTY plugin
879
- // list, and startSchemaCreation REPLACES the whole schema. In split mode
880
- // the admin schema belongs on the PlatformApi ONLY — the DomainApi carries
881
- // plugin fields (emptyBaseFragment). If we pushed the admin-base-only SDL
882
- // to the DomainApi it would wipe every plugin field, leaving exactly the
883
- // admin-base set (the alpha 2026-07-08 clobber). So in split mode we push
884
- // ONLY when the PlatformApi is known; if the ref is not yet populated we
885
- // SKIP (never fall back to domainApi). Unified mode legitimately shares one
886
- // API, so an unpopulated ref there means domainApi.
887
- let targetApiOpt = switch (Config.splitApi, splitApiOutputsRef.contents) {
888
- | (_, Some({platformApi})) => Some(platformApi)
889
- | (false, None) => Some(domainApi)
890
- | (true, None) => None
891
- }
892
- switch targetApiOpt {
893
- | None =>
894
- log.error(
895
- ~comp="preAdminResolversSchemaHook",
896
- "split mode but the PlatformApi is not available at hook time — SKIPPING the admin schema push to avoid clobbering the DomainApi (pushing admin-base here would wipe every plugin field)",
897
- )
949
+ preAdminResolversSchemaHook: (~adminBarrier) =>
950
+ if mergedApiMode {
951
+ // Merged mode: the admin base SDL is DECLARATIVE on the source API
952
+ // resource (makeSourceApiResource) — the provider runs
953
+ // StartSchemaCreation + poll before the resource resolves, so admin
954
+ // resolvers chained on the API are already ordered after the schema
955
+ // is ACTIVE. No push.
898
956
  adminBarrier
899
- | Some(targetApi) =>
900
- let adminBaseFragment = AppSync_Adapter.injectAwsAuthAll(
901
- ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
902
- ~group="Admin",
903
- // The ApiFragmentRegistry register/deregister mutations + the Platform_ApiFragments
904
- // status query are invoked by the plugin/standalone deploy as a SigV4 system caller, so
905
- // they carry the dual-auth (@aws_cognito_user_pools @aws_iam) directive.
906
- ~iamFieldNames=ReventlessCore.AdminApi.systemCallerFieldNames,
907
- )
908
- let sdl = AppSync_Adapter.stitchWithAwsDirectives(
909
- ~baseFragment=adminBaseFragment,
910
- ~pluginFragments=[],
911
- )
912
- (targetApi, adminBarrier)
913
- ->Pulumi.Output.all2
914
- ->Pulumi.Output.flatMap(((api, _)) =>
915
- api.id->Pulumi.Output.flatMap(apiId => {
916
- log.info(~comp="preAdminResolversSchemaHook", `Pushing admin schema to ${apiId}`)
917
- let client = AppSync_Adapter.getClient()
918
- client
919
- ->AppSync_Adapter.startSchemaCreationRetrying({apiId, definition: sdl})
920
- ->Promise.then(async _ => {
957
+ } else {
958
+ // The admin-base SDL stitches AdminApi.baseFragment with an EMPTY plugin
959
+ // list, and startSchemaCreation REPLACES the whole schema. In split mode
960
+ // the admin schema belongs on the PlatformApi ONLY — the DomainApi carries
961
+ // plugin fields (emptyBaseFragment). If we pushed the admin-base-only SDL
962
+ // to the DomainApi it would wipe every plugin field, leaving exactly the
963
+ // admin-base set (the alpha 2026-07-08 clobber). So in split mode we push
964
+ // ONLY when the PlatformApi is known; if the ref is not yet populated we
965
+ // SKIP (never fall back to domainApi). Unified mode legitimately shares one
966
+ // API, so an unpopulated ref there means domainApi.
967
+ let targetApiOpt = switch (Config.splitApi, splitApiOutputsRef.contents) {
968
+ | (_, Some({platformApi})) => Some(platformApi)
969
+ | (false, None) => Some(domainApi)
970
+ | (true, None) => None
971
+ }
972
+ switch targetApiOpt {
973
+ | None =>
974
+ log.error(
975
+ ~comp="preAdminResolversSchemaHook",
976
+ "split mode but the PlatformApi is not available at hook time — SKIPPING the admin schema push to avoid clobbering the DomainApi (pushing admin-base here would wipe every plugin field)",
977
+ )
978
+ adminBarrier
979
+ | Some(targetApi) =>
980
+ let adminBaseFragment = AppSync_Adapter.injectAwsAuthAll(
981
+ ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
982
+ ~group="Admin",
983
+ // The ApiFragmentRegistry register/deregister mutations + the Platform_ApiFragments
984
+ // status query are invoked by the plugin/standalone deploy as a SigV4 system caller, so
985
+ // they carry the dual-auth (@aws_cognito_user_pools @aws_iam) directive.
986
+ ~iamFieldNames=ReventlessCore.AdminApi.systemCallerFieldNames,
987
+ )
988
+ let sdl = AppSync_Adapter.stitchWithAwsDirectives(
989
+ ~baseFragment=adminBaseFragment,
990
+ ~pluginFragments=[],
991
+ )
992
+ (targetApi, adminBarrier)
993
+ ->Pulumi.Output.all2
994
+ ->Pulumi.Output.flatMap(((api, _)) =>
995
+ api.id->Pulumi.Output.flatMap(apiId => {
996
+ log.info(~comp="preAdminResolversSchemaHook", `Pushing admin schema to ${apiId}`)
997
+ let client = AppSync_Adapter.getClient()
998
+ client
999
+ ->AppSync_Adapter.startSchemaCreationRetrying({apiId, definition: sdl})
1000
+ ->Promise.then(async _ => {
1001
+ log.info(
1002
+ ~comp="preAdminResolversSchemaHook",
1003
+ "startSchemaCreation called, waiting for ACTIVE",
1004
+ )
1005
+ await AppSync_Adapter.waitForSchemaActive(client, apiId)
1006
+ log.info(~comp="preAdminResolversSchemaHook", "schema is ACTIVE")
1007
+ })
1008
+ ->Pulumi.Output.fromPromise
1009
+ })
1010
+ )
1011
+ }
1012
+ },
1013
+
1014
+ // Staged deploy (deployPlugin against a running platform): register the
1015
+ // plugin's fragment via the Platform API (SigV4); the reactive ApiSchemaPush
1016
+ // SideEffect stitches + pushes cumulatively. The legacy all-at-once
1017
+ // deploy-schema:* write+scan+push path was retired in Phase 4b (makePlatform
1018
+ // with plugins is no longer supported on AWS).
1019
+ preResolversSchemaHook: (~name, ~version, pluginFragment) =>
1020
+ if mergedApiMode {
1021
+ // Merged mode (merged-api plan, Phase 4): push the plugin's standalone
1022
+ // subgraph document to the plugin's OWN source API — a single writer
1023
+ // by construction, so no SigV4 registration, no reactive stitcher, no
1024
+ // waiter, no drift check, no destroy-path deregistration. The returned
1025
+ // Output gates resolver creation (as on the push path) and deployPlugin
1026
+ // additionally sequences the SourceApiAssociation behind it (the
1027
+ // initial merge needs the source schema present). Under AUTO_MERGE
1028
+ // every later schema update here re-merges automatically.
1029
+ let sdl = AppSync_Adapter.stitchStandaloneWithAwsDirectives(~fragment=pluginFragment)
1030
+ let pushed =
1031
+ domainApi->Pulumi.Output.flatMap(api =>
1032
+ api.id->Pulumi.Output.flatMap(apiId => {
921
1033
  log.info(
922
- ~comp="preAdminResolversSchemaHook",
923
- "startSchemaCreation called, waiting for ACTIVE",
1034
+ ~comp="preResolversSchemaHook",
1035
+ `Pushing subgraph schema for ${name}@${version} to source API ${apiId}`,
924
1036
  )
925
- await AppSync_Adapter.waitForSchemaActive(client, apiId)
926
- log.info(~comp="preAdminResolversSchemaHook", "schema is ACTIVE")
1037
+ let client = AppSync_Adapter.getClient()
1038
+ client
1039
+ ->AppSync_Adapter.startSchemaCreationRetrying({apiId, definition: sdl})
1040
+ ->Promise.then(async _ => {
1041
+ await AppSync_Adapter.waitForSchemaActive(client, apiId)
1042
+ log.info(~comp="preResolversSchemaHook", "subgraph schema is ACTIVE")
1043
+ })
1044
+ ->Pulumi.Output.fromPromise
927
1045
  })
928
- ->Pulumi.Output.fromPromise
929
- })
930
- )
931
- }
932
- },
933
-
934
- // Accumulate fragments across independent plugin deployments: each plugin
935
- // writes its fragment to the Plugin RM table (keyed "deploy-schema:<name>")
936
- // at deploy time. The hook then scans for ALL deploy-schema entries and
937
- // stitches them together — ensuring the schema is cumulative rather than
938
- // overwritten by each plugin deployment.
939
- preResolversSchemaHook: (~name, ~version, pluginFragment) => {
1046
+ )
1047
+ mergedSchemaPushedRef := Some(pushed)
1048
+ pushed
1049
+ } else {
940
1050
  log.info(
941
1051
  ~comp="preResolversSchemaHook",
942
1052
  `Pushing schema for plugin ${name}@${version} to AppSync`,
@@ -956,344 +1066,16 @@ module MakeWithConfig = (
956
1066
  }
957
1067
  registerFragmentViaApi(~name, ~fragment=pluginFragment, ~apiTargetName)
958
1068
  | None =>
959
- // All-at-once (makePlatform): platform + plugins deploy in one stack, so the
960
- // reactive writer is dormant keep the direct deploy-time stitch + push below.
961
-
962
- // Select DynamoDB key prefix and target AppSync API based on the current deploy target.
963
- // Domain plugins use "deploy-schema:" and the Domain API (default behaviour).
964
- // Platform plugins use a separate "deploy-schema-platform:" namespace and the Core API,
965
- // so their cumulative schema is kept independent from the Domain API's schema.
966
- let (schemaPrefix, targetApi) = switch capturedDeployTarget {
967
- | Domain => (deploySchemaPrefix, domainApi)
968
- | Platform =>
969
- let api = switch apiConfigRef.contents {
970
- | Some({platformApi}) => platformApi
971
- | None => domainApi // fallback: Core API not yet constructed
972
- }
973
- (deploySchemaPlatformPrefix, api)
974
- }
975
-
976
- // Read a string output from the platform StackReference, falling back to
977
- // the bundled "default" output object if the named export is not present
978
- // (matches the layout Pulumi emits when a stack uses a single default
979
- // export rather than per-key exports).
980
- let readStackRefString = (stackRef, key) => {
981
- let direct: Pulumi.Output.t<option<JSON.t>> =
982
- stackRef->Pulumi.StackReference.getOutput(key)
983
- let defaultOutput: Pulumi.Output.t<option<JSON.t>> =
984
- stackRef->Pulumi.StackReference.getOutput("default")
985
- (direct, defaultOutput)
986
- ->Pulumi.Output.all2
987
- ->Pulumi.Output.apply(((direct, default)) =>
988
- switch direct->Option.flatMap(v => v->JSON.Decode.string) {
989
- | Some(name) => Some(name)
990
- | None =>
991
- default
992
- ->Option.flatMap(d => d->JSON.Decode.object)
993
- ->Option.flatMap(d => d->Dict.get(key))
994
- ->Option.flatMap(v => v->JSON.Decode.string)
995
- }
1069
+ // makePlatform(~plugins=[…]) all-at-once deploy-schema push retired in Phase 4b.
1070
+ // Staged deployPlatform + deployPlugin (register + reactive SideEffect push) is the
1071
+ // sole supported AWS deploy path. deployPlatform deploys no plugins, so this hook only
1072
+ // fires for makePlatform-with-plugins no longer supported on AWS.
1073
+ failwith(
1074
+ "makePlatform(~plugins=[…]) is no longer supported on AWS deploy the platform with " ++
1075
+ "deployPlatform and each plugin with deployPlugin (staged register + reactive push).",
996
1076
  )
997
1077
  }
998
-
999
- // Prefer the dedicated PluginSchemaPersistence table (post-platform-fix);
1000
- // fall back to the Plugin RM table for backward compatibility with
1001
- // platforms deployed before the schema-persistence table existed. The
1002
- // Plugin RM table must not be reused for new schema-fragment writes —
1003
- // doing so leaks deploy-schema rows through the Platform_Plugins AppSync
1004
- // Connection resolver.
1005
- let schemaPersistenceTableNameOutput: Pulumi.Output.t<option<string>> = switch platformStackRef {
1006
- | Some(stackRef) =>
1007
- (
1008
- readStackRefString(stackRef, "pluginSchemaPersistenceTableName"),
1009
- readStackRefString(stackRef, "pluginRmTableName"),
1010
- )
1011
- ->Pulumi.Output.all2
1012
- ->Pulumi.Output.apply(((dedicated, legacy)) =>
1013
- switch dedicated {
1014
- | Some(_) as s => s
1015
- | None => legacy
1016
- }
1017
- )
1018
- | None => Pulumi.Output.make(None)
1019
- }
1020
-
1021
- schemaPersistenceTableNameOutput
1022
- ->Pulumi.Output.flatMap(tableNameOpt => {
1023
- // Write this plugin's fragment to DynamoDB, then scan all deploy-schema
1024
- // entries to collect every deployed plugin's fragment.
1025
- let writeAndScanFragments = () =>
1026
- switch tableNameOpt {
1027
- | None =>
1028
- log.info(
1029
- ~comp="preResolversSchemaHook",
1030
- "No pluginSchemaPersistenceTableName / pluginRmTableName — skipping fragment persistence",
1031
- )
1032
- Promise.resolve([pluginFragment])
1033
- | Some(tableName) =>
1034
- open AwsSdk.DynamoDb.DocumentClient
1035
- // Write this plugin's fragment so subsequent plugin deployments find it.
1036
- let deployItem =
1037
- Dict.fromArray([
1038
- ("id", `${schemaPrefix}${name}`->JSON.Encode.string),
1039
- ("fragment", pluginFragment.encoded->JSON.Encode.string),
1040
- ])->JSON.Encode.object
1041
- log.info(
1042
- ~comp="preResolversSchemaHook",
1043
- `Writing deploy-schema entry for ${name} to ${tableName}`,
1044
- )
1045
- // Paginated scan — accumulate every deploy-schema entry across pages.
1046
- // A single ScanCommand returns at most 1 MB before yielding a
1047
- // LastEvaluatedKey; loop until the table is exhausted so a platform
1048
- // with many plugin fragments never stitches a partial schema.
1049
- let scanAllDeploySchemaItems = async () => {
1050
- let allItems = []
1051
- let startKey = ref(None)
1052
- let more = ref(true)
1053
- while more.contents {
1054
- let result = await ScanCommand.send(
1055
- ScanCommand.make({
1056
- ScanCommand.tableName: tableName,
1057
- filterExpression: "begins_with(#id, :prefix)",
1058
- expressionAttributeNames: Dict.fromArray([("#id", "id")]),
1059
- expressionAttributeValues: Dict.fromArray([
1060
- (":prefix", schemaPrefix->JSON.Encode.string),
1061
- ]),
1062
- exclusiveStartKey: ?startKey.contents,
1063
- }),
1064
- )
1065
- result.items->Option.getOr([])->Array.forEach(item => allItems->Array.push(item))
1066
- switch result.lastEvaluatedKey {
1067
- | Some(_) as k => startKey := k
1068
- | None => more := false
1069
- }
1070
- }
1071
- allItems
1072
- }
1073
-
1074
- PutCommand.send(PutCommand.make({PutCommand.tableName: tableName, item: deployItem}))
1075
- ->Promise.then(_ => {
1076
- // Scan for all deploy-schema entries from previously deployed plugins.
1077
- log.info(
1078
- ~comp="preResolversSchemaHook",
1079
- `Scanning ${tableName} for deploy-schema entries`,
1080
- )
1081
- scanAllDeploySchemaItems()
1082
- })
1083
- ->Promise.then(items => {
1084
- let fragments = items->Array.filterMap(item => {
1085
- try {
1086
- let obj = item->JSON.stringify->JSON.parseOrThrow
1087
- switch obj->JSON.Decode.object->Option.flatMap(d => d->Dict.get("fragment")) {
1088
- | Some(fragmentJson) =>
1089
- switch fragmentJson->JSON.Decode.string {
1090
- | Some(encoded) =>
1091
- Some({Reventless.Plugin.encoded, protocol: "graphql"})
1092
- | None => None
1093
- }
1094
- | None => None
1095
- }
1096
- } catch {
1097
- | _ => None
1098
- }
1099
- })
1100
- log.info(
1101
- ~comp="preResolversSchemaHook",
1102
- `Found ${fragments->Array.length->Int.toString} deploy-schema entries`,
1103
- )
1104
- Promise.resolve(fragments)
1105
- })
1106
- ->Promise.catch(err => {
1107
- let msg =
1108
- err
1109
- ->JsExn.fromException
1110
- ->Option.flatMap(JsExn.message)
1111
- ->Option.getOr("unknown")
1112
- log.info(
1113
- ~comp="preResolversSchemaHook",
1114
- `DynamoDB write/scan failed (${msg}) — using current plugin only`,
1115
- )
1116
- Promise.resolve([pluginFragment])
1117
- })
1118
- }
1119
-
1120
- targetApi->Pulumi.Output.flatMap(api =>
1121
- api.id->Pulumi.Output.flatMap(apiId => {
1122
- // Serialise write-row → scan → stitch → push under the shared
1123
- // schema-push lease so a concurrent peer's stale scan can't clobber
1124
- // this stack's fields (see AppSync_Adapter.withSchemaPushLock).
1125
- let runSchemaPush = () => {
1126
- writeAndScanFragments()
1127
- ->Promise.then(async allPluginFragments => {
1128
- // Base fragment selection:
1129
- // - Platform target: always include admin base (the Core API owns admin ops).
1130
- // - Domain target, split mode: empty base (admin lives on Core API).
1131
- // - Domain target, unified mode: include admin base (single API has everything).
1132
- // Use capturedDeployTarget (set synchronously above) — currentDeployTarget has
1133
- // been reset to Domain by deployPlugin before this async callback runs.
1134
- let baseFragment = switch capturedDeployTarget {
1135
- | Platform =>
1136
- AppSync_Adapter.injectAwsAuthAll(
1137
- ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
1138
- ~group="Admin",
1139
- ~iamFieldNames=ReventlessCore.AdminApi.systemCallerFieldNames,
1140
- )
1141
- | Domain =>
1142
- if Config.splitApi {
1143
- emptyBaseFragment
1144
- } else {
1145
- AppSync_Adapter.injectAwsAuthAll(
1146
- ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
1147
- ~group="Admin",
1148
- )
1149
- }
1150
- }
1151
- let sdl = AppSync_Adapter.stitchWithAwsDirectives(
1152
- ~baseFragment,
1153
- ~pluginFragments=allPluginFragments,
1154
- )
1155
- let currentHash = AppSync_Adapter.sha256Hex(sdl)
1156
- let storedHash = switch tableNameOpt {
1157
- | Some(tn) => await readSchemaHash(~tableName=tn, ~apiId)
1158
- | None => None
1159
- }
1160
- let client = AppSync_Adapter.getClient()
1161
-
1162
- // Introspect the live schema once — reused for the hash-match
1163
- // drift/repair check and the catastrophic-shrink guard on the push.
1164
- let liveSdl = await AppSync_Adapter.getIntrospectionSdl(client, apiId)
1165
-
1166
- // The stored hash records what the DEPLOY last pushed. A runtime
1167
- // re-stitch (mkUpdateApiSchema) can clobber the live schema
1168
- // out-of-band WITHOUT updating this hash, so a matching hash does
1169
- // not guarantee the live schema is intact. Before trusting the
1170
- // hash to skip the push, introspect the live schema and confirm it
1171
- // still carries at least as many root-type (Mutation + Query +
1172
- // Subscription) fields as the SDL we would push. If it has drifted
1173
- // (shrunk) — or cannot be introspected despite a stored hash, which
1174
- // means a real failure rather than a first deploy — force the
1175
- // repair push so a clobbered schema heals on the next deploy.
1176
- let countRoots = s =>
1177
- ReventlessCore.GraphQL_Stitcher.countRootTypeFields(~sdl=s, ~typeName="Mutation") +
1178
- ReventlessCore.GraphQL_Stitcher.countRootTypeFields(~sdl=s, ~typeName="Query") +
1179
- ReventlessCore.GraphQL_Stitcher.countRootTypeFields(
1180
- ~sdl=s,
1181
- ~typeName="Subscription",
1182
- )
1183
- // Identity-aware drift check (not a bare count): the live schema is
1184
- // "intact" only when it is a SUPERSET of every root field we would
1185
- // push. Comparing name SETS heals equal-cardinality drift and field
1186
- // *swaps* — an admin-base clobber that leaves the DomainApi with the
1187
- // SAME number of root fields but the WRONG ones (admin-base instead
1188
- // of plugin fields) has a matching count yet is missing every
1189
- // expected plugin field, so a count test would wrongly skip.
1190
- let missingFields = ReventlessCore.GraphQL_Stitcher.missingRootFields(
1191
- ~expectedSdl=sdl,
1192
- ~liveSdl,
1193
- )
1194
- let skipPush = switch storedHash {
1195
- | Some(prev) if prev == currentHash =>
1196
- if liveSdl == "" {
1197
- log.info(
1198
- ~comp="preResolversSchemaHook",
1199
- `hash matches but live schema could not be introspected — forcing repair push (check appsync:GetIntrospectionSchema permission)`,
1200
- )
1201
- false
1202
- } else if missingFields->Array.length > 0 {
1203
- log.info(
1204
- ~comp="preResolversSchemaHook",
1205
- `hash matches but live schema is missing ${missingFields
1206
- ->Array.length
1207
- ->Int.toString} expected root field(s) (e.g. ${missingFields
1208
- ->Array.slice(~start=0, ~end=5)
1209
- ->Array.join(", ")}) — forcing repair push`,
1210
- )
1211
- false
1212
- } else {
1213
- log.info(
1214
- ~comp="preResolversSchemaHook",
1215
- `SDL unchanged (hash ${currentHash->String.slice(
1216
- ~start=0,
1217
- ~end=12,
1218
- )}…) and live schema is a superset of the expected root fields (${countRoots(
1219
- liveSdl,
1220
- )->Int.toString} live); skipping push`,
1221
- )
1222
- true
1223
- }
1224
- | _ => false
1225
- }
1226
- if !skipPush {
1227
- // Shrink guard — deploy-time counterpart of the runtime
1228
- // mkUpdateApiSchema guard (AdminEventCollectorEntryPoint.mjs).
1229
- // Plugin/service stacks share one AppSync API and StartSchemaCreation
1230
- // REPLACES the whole schema. A concurrent peer that scanned the
1231
- // deploy-schema table before this stack wrote its fragment row
1232
- // stitches an SDL missing this stack's fields; pushing it would drop
1233
- // the live fields and orphan their resolvers (NotFoundException: No
1234
- // field named X). Refuse a push that would catastrophically shrink
1235
- // the live schema — the field-owner's own deploy (whose scan includes
1236
- // its freshly-written row) pushes the complete set.
1237
- // isCatastrophicSchemaShrink returns false when the live schema is
1238
- // empty (first deploy / introspection unavailable), so the initial
1239
- // push still proceeds.
1240
- if (
1241
- ReventlessCore.GraphQL_Stitcher.isCatastrophicSchemaShrink(
1242
- ~currentSdl=liveSdl,
1243
- ~newSdl=sdl,
1244
- ~threshold=deploySchemaShrinkThreshold,
1245
- )
1246
- ) {
1247
- log.error(
1248
- ~comp="preResolversSchemaHook",
1249
- `ABORTED schema push for ${apiId}: stitched SDL (${countRoots(
1250
- sdl,
1251
- )->Int.toString} root field(s)) would catastrophically shrink the live schema (${countRoots(
1252
- liveSdl,
1253
- )->Int.toString} root field(s), threshold ${deploySchemaShrinkThreshold->Float.toString}) — refusing to clobber resolvers (likely a stale concurrent-deploy scan)`,
1254
- )
1255
- } else {
1256
- log.info(
1257
- ~comp="preResolversSchemaHook",
1258
- `Pushing schema to API ${apiId} (${allPluginFragments->Array.length->Int.toString} plugin fragments, new hash: ${currentHash->String.slice(~start=0, ~end=12)}…)`,
1259
- )
1260
- await client->AppSync_Adapter.startSchemaCreationRetrying({
1261
- apiId,
1262
- definition: sdl,
1263
- })
1264
- log.info(
1265
- ~comp="preResolversSchemaHook",
1266
- "startSchemaCreation called, waiting for ACTIVE",
1267
- )
1268
- await AppSync_Adapter.waitForSchemaActive(client, apiId)
1269
- log.info(~comp="preResolversSchemaHook", "Schema is ACTIVE")
1270
- switch tableNameOpt {
1271
- | Some(tn) =>
1272
- await writeSchemaHash(~tableName=tn, ~apiId, ~hash=currentHash)
1273
- | None => ()
1274
- }
1275
- }
1276
- }
1277
-
1278
- // No deploy-time retire scan: the name-keyed Plugin aggregate
1279
- // decides supersession itself (VersionSuperseded) when the new
1280
- // version connects, so the manifest carries only the current
1281
- // version without any RM-read-driven command.
1282
- })
1283
- }
1284
-
1285
- (
1286
- switch tableNameOpt {
1287
- | Some(tableName) =>
1288
- AppSync_Adapter.withSchemaPushLock(~tableName, ~apiId, runSchemaPush)
1289
- | None => runSchemaPush()
1290
- }
1291
- )->Pulumi.Output.fromPromise
1292
- })
1293
- )
1294
- })
1295
- }
1296
- },
1078
+ },
1297
1079
  // DCB EventLog created hook — extracts DynamoDB table name for DCB CommandTopic Lambda handler.
1298
1080
  // Postgres-backed DCB logs (B2.3c) create no table, so there is no resource to read:
1299
1081
  // the DCB command Lambda derives its `dcb_event.log_name` from the plugin name and
@@ -1647,6 +1429,14 @@ module MakeWithConfig = (
1647
1429
  // In unified mode, makePlatform is a no-op (schema stitching handled by events).
1648
1430
  let makePlatform = (~version, ~plugins: array<module(PluginMaker)>) => {
1649
1431
  log.info(~comp="Platform", `v${version}`)
1432
+ if mergedApiMode {
1433
+ // Merged-API composition is wired in deployPlatform only (the sole
1434
+ // supported staged AWS deploy path) — makePlatform predates it.
1435
+ failwith(
1436
+ "mergedApi mode is supported with deployPlatform only — deploy the platform with " ++
1437
+ "deployPlatform and each plugin with deployPlugin.",
1438
+ )
1439
+ }
1650
1440
  // Create scheduler and populate platform context refs so Plugin_Builder
1651
1441
  // can read them without app plugins having to pass them through.
1652
1442
  let scheduler = makeScheduler()
@@ -1837,6 +1627,18 @@ module MakeWithConfig = (
1837
1627
  bundleVersion: string,
1838
1628
  }
1839
1629
 
1630
+ // Merged-mode outputs of deployPlatform — the merged API(s), plus the
1631
+ // deploy-time merge gates (Outputs that resolve on MERGE_SUCCESS and fail
1632
+ // the deploy on MERGE_FAILED; folded into the ARN exports so they are
1633
+ // consumed). platformMerged == domainMerged in unified mode, mirroring the
1634
+ // platformApi/domainApi convention.
1635
+ type mergedApiOutputs = {
1636
+ domainMerged: AppSync_MergedApi.t,
1637
+ platformMerged: AppSync_MergedApi.t,
1638
+ domainMergeGate: Pulumi.Output.t<unit>,
1639
+ platformMergeGate: Pulumi.Output.t<unit>,
1640
+ }
1641
+
1840
1642
  let deployPlatform = (~version, ~hostUiBundle: option<hostUiBundleConfig>=?) => {
1841
1643
  log.info(~comp="Platform:deployPlatform", `v${version}`)
1842
1644
  let scheduler = makeScheduler()
@@ -1847,12 +1649,79 @@ module MakeWithConfig = (
1847
1649
  // Phase 2: create the Platform API resource early — before Admin.construct —
1848
1650
  // so admin resolvers are attached to the correct API in split mode.
1849
1651
  // In unified mode this is the same resource as the Domain API.
1652
+ // On the merge path the Platform API is an ordinary GRAPHQL source API
1653
+ // carrying the admin canonical document declaratively.
1850
1654
  let (platformApi, platformApiRole) = if Config.splitApi {
1851
- 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
+ }
1852
1660
  } else {
1853
1661
  (domainApi, domainApiRole)
1854
1662
  }
1855
1663
 
1664
+ // ── Merged-API composition (merged-api plan, Phase 3) ──────────────────
1665
+ // Create the merged API(s) and associate the platform-owned source(s).
1666
+ // Plugin stacks associate their own source APIs against the exported
1667
+ // merged-API ARN (Phase 4); `pulumi destroy` of a plugin stack deletes
1668
+ // its association — retirement by construction.
1669
+ let mergedOutputs = if mergedApiMode {
1670
+ AppSync_MergedApi.assertCompatiblePrimaryAuth(
1671
+ ~sourceMode=AppSync_MergedApi.authenticationTypeName(
1672
+ AppSync_Adapter.primaryAuthenticationType,
1673
+ ),
1674
+ ~mergedMode=AppSync_MergedApi.primaryAuthMode,
1675
+ )
1676
+ let domainMerged = AppSync_MergedApi.make(~name="DomainMergedApi", ~opts={})
1677
+ if Config.splitApi {
1678
+ // Split: admin source → Platform merged API; the relay-base Domain
1679
+ // source (the Domain merged API's canonical owner) → Domain merged.
1680
+ let platformMerged = AppSync_MergedApi.make(~name="PlatformMergedApi", ~opts={})
1681
+ let platformAssoc = AppSync_MergedApi.associateSource(
1682
+ ~name="PlatformAdminSourceAssociation",
1683
+ ~mergedApi=platformMerged,
1684
+ ~sourceApi=platformApi,
1685
+ ~opts={},
1686
+ )
1687
+ let domainAssoc = AppSync_MergedApi.associateSource(
1688
+ ~name="DomainBaseSourceAssociation",
1689
+ ~mergedApi=domainMerged,
1690
+ ~sourceApi=domainApi,
1691
+ ~opts={},
1692
+ )
1693
+ Some({
1694
+ domainMerged,
1695
+ platformMerged,
1696
+ domainMergeGate: AppSync_MergedApi.mergeStatusGate(
1697
+ ~mergedApi=domainMerged,
1698
+ ~association=domainAssoc,
1699
+ ),
1700
+ platformMergeGate: AppSync_MergedApi.mergeStatusGate(
1701
+ ~mergedApi=platformMerged,
1702
+ ~association=platformAssoc,
1703
+ ),
1704
+ })
1705
+ } else {
1706
+ // Unified: the single source API carries the admin canonical document.
1707
+ let assoc = AppSync_MergedApi.associateSource(
1708
+ ~name="DomainAdminSourceAssociation",
1709
+ ~mergedApi=domainMerged,
1710
+ ~sourceApi=domainApi,
1711
+ ~opts={},
1712
+ )
1713
+ let gate = AppSync_MergedApi.mergeStatusGate(~mergedApi=domainMerged, ~association=assoc)
1714
+ Some({
1715
+ domainMerged,
1716
+ platformMerged: domainMerged,
1717
+ domainMergeGate: gate,
1718
+ platformMergeGate: gate,
1719
+ })
1720
+ }
1721
+ } else {
1722
+ None
1723
+ }
1724
+
1856
1725
  // Admin DCB mutation resolvers bind to the Platform API (split mode) or the shared api
1857
1726
  // (unified). Recorded now that the Platform API resource exists, so the admin's deferred
1858
1727
  // dcbConnectFn (via ~onAdminApi) targets it rather than the Domain/deploy-target hooksApiRef.
@@ -1890,47 +1759,9 @@ module MakeWithConfig = (
1890
1759
  // (Heartbeat, ForwardCommand). None here keeps the deploy-time path
1891
1760
  // unchanged; the .mjs entry point supplies a real implementation.
1892
1761
  let manageSubscriptions = None
1893
- let updateApiSchema = Some(async (queryEngine: Reventless.QueryEngine.operations) => {
1894
- open Reventless.QueryEngine.Filter
1895
- let apiId = domainApiId->Pulumi.Output.get
1896
- let plugins = await queryEngine.scan(
1897
- ~readModelName="Plugins",
1898
- ~filterConfigs=[("status", Contains, String("Connected"))],
1899
- ~limit=1000,
1900
- )
1901
- let fragments = plugins->Array.filterMap(json =>
1902
- try {
1903
- let state = json->S.parseOrThrow(ReventlessCore.PluginsReadModelSpec.stateSchema)
1904
- // Exclude Platform-target plugins — their schema belongs on the PlatformApi,
1905
- // not the DomainApi. Absent apiTarget defaults to "Domain".
1906
- switch state.apiTarget {
1907
- | Some("Platform") => None
1908
- | _ => state.apiSchemaFragment
1909
- }
1910
- } catch {
1911
- | _ => None
1912
- }
1913
- )
1914
- // In split mode, the plugin API only has plugin schema (admin is on the core API).
1915
- // In unified mode, stitch admin + plugins into the single shared API.
1916
- let baseFragment = if Config.splitApi {
1917
- emptyBaseFragment
1918
- } else {
1919
- AppSync_Adapter.injectAwsAuthAll(
1920
- ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
1921
- ~group="Admin",
1922
- ~iamFieldNames=ReventlessCore.AdminApi.systemCallerFieldNames,
1923
- )
1924
- }
1925
- let sdl = AppSync_Adapter.stitchWithAwsDirectives(
1926
- ~baseFragment,
1927
- ~pluginFragments=fragments,
1928
- )
1929
- await AppSync_Adapter.getClient()->AppSync_Adapter.startSchemaCreationRetrying({
1930
- apiId,
1931
- definition: sdl,
1932
- })
1933
- })
1762
+ // Runtime connect-driven schema self-heal retired in Phase 4b — the reactive
1763
+ // ApiSchemaPush SideEffect (on ApiFragmentRegistry events) is the single writer.
1764
+ let updateApiSchema = None
1934
1765
  })
1935
1766
 
1936
1767
  // Phase 2: Admin resolvers go on the Platform API (platformApi) in split mode,
@@ -1960,18 +1791,26 @@ module MakeWithConfig = (
1960
1791
  // deploy caller fires RegisterApiFragment); makePlatform pushes the schema directly.
1961
1792
  let apiSchemaPushEventTopics = ReventlessCore.Aggregate.allEventTopics(admin.aggregatesOutputs)
1962
1793
  let apiSchemaPushCmdTopics = ReventlessCore.Aggregate.allCommandTopics(admin.aggregatesOutputs)
1963
- let apiSchemaPushCmdTopicUrl =
1964
- admin.aggregatesOutputs
1965
- ->Dict.get(ReventlessCore.ApiFragmentRegistrySpec.name)
1966
- ->Option.map(agg =>
1967
- agg.commandTopic->Pulumi.Output.flatMap(ct =>
1968
- switch ct.resources->Array.get(0) {
1969
- | Some(r) => r.id
1970
- | None => Pulumi.Output.make("")
1971
- }
1972
- )
1794
+ // MUST be a `switch`, NOT `->Option.map(...)->Option.getOr(...)`. The map/getOr form
1795
+ // materialises an `option<Pulumi.Output.t<string>>`, and wrapping a Pulumi Output in a
1796
+ // ReScript option collapses the nested Output to `undefined` at runtime (the documented
1797
+ // "option(Pulumi.Output.t) doesn't work" pitfall). That made API_SCHEMA_PUSH_CMD_TOPIC_URL
1798
+ // resolve to undefined → Pulumi dropped the env var → the ApiSchemaPush runtime logged
1799
+ // "no command-topic URL configured — skipping" and never pushed/recorded, so the deploy
1800
+ // waiter timed out. Verified via local `pulumi preview`: map/getOr → isValidOutput=false;
1801
+ // switch isValidOutput=true.
1802
+ let apiSchemaPushCmdTopicUrl = switch admin.aggregatesOutputs->Dict.get(
1803
+ ReventlessCore.ApiFragmentRegistrySpec.name,
1804
+ ) {
1805
+ | Some(agg) =>
1806
+ agg.commandTopic->Pulumi.Output.flatMap(ct =>
1807
+ switch ct.resources->Array.get(0) {
1808
+ | Some(r) => r.id
1809
+ | None => Pulumi.Output.make("")
1810
+ }
1973
1811
  )
1974
- ->Option.getOr(Pulumi.Output.make(""))
1812
+ | None => Pulumi.Output.make("")
1813
+ }
1975
1814
  let apiSchemaPushEnv = Dict.fromArray([
1976
1815
  ("API_SCHEMA_PUSH_DOMAIN_API_ID", domainApiId->Pulumi.Output.asInput),
1977
1816
  (
@@ -2029,19 +1868,6 @@ module MakeWithConfig = (
2029
1868
  | None => None
2030
1869
  }
2031
1870
 
2032
- // Dedicated DynamoDB table for deploy-time schema-fragment persistence
2033
- // (deploy-schema:<name>, deploy-schema-platform:<name>, deploy-schema-hash:<apiId>).
2034
- // Previously these infrastructure rows shared the Plugin RM table, which
2035
- // caused them to leak through Platform_Plugins' auto-generated AppSync
2036
- // Connection resolver (an unfiltered Scan). Hosting them on their own
2037
- // table keeps Plugin RM = Plugin aggregate entities only and restores
2038
- // parity with the in-memory adapter (which has no preResolversSchemaHook).
2039
- let pluginSchemaPersistenceTable = Util.DynamoDb.makeTable(
2040
- "PluginSchemaPersistence",
2041
- ~attributes=[{name: "id", type_: "S"}],
2042
- ~opts={},
2043
- )
2044
-
2045
1871
  PluginExtensionPointRuntime_Builder.registerPluginExtensionPoint(
2046
1872
  ~pluginReadModelTableName?,
2047
1873
  ~schedulerRoleArn=hooks.schedulerRoleUrn.contents,
@@ -2075,34 +1901,8 @@ module MakeWithConfig = (
2075
1901
  ~eventTopicArn=pluginEpEventTopicArn,
2076
1902
  ~appSyncApiId=domainApiId,
2077
1903
  ~pluginReadModelTableName?,
2078
- // Runtime schema stitch reads deploy-time fragments from this durable table
2079
- // rather than the lifecycle-volatile Plugin RM Connected rows.
2080
- ~pluginSchemaPersistenceTableName=pluginSchemaPersistenceTable.name,
2081
1904
  ~schedulerRoleArn=hooks.schedulerRoleUrn.contents,
2082
1905
  ~clonerEnabled=Config.cloner,
2083
- // 2e: the reactive ApiFragmentRegistry single writer (admin EventCollector).
2084
- // The Platform AppSync id it pushes Platform-target fragments to (unified →
2085
- // domainApi, since platformApi == domainApi); the ApiFragments StateViewSlice
2086
- // table it re-folds from; the admin DCB command-topic FIFO URL it dispatches
2087
- // RecordApiFragmentPush to (captured during Admin.construct); and the mode flag.
2088
- ~platformApiId=platformApi->Pulumi.Output.flatMap(api => api.id),
2089
- // NB: must NOT be `->Option.map(r => r.name)`. `apiFragmentRegistryTableName`
2090
- // is `option<Pulumi.Output.t<string>>` (the forbidden pattern, CLAUDE.md code
2091
- // smells). The generic `Option.map` body runs `Primitive_option.some(r.name)`,
2092
- // and because a Pulumi Output lifts arbitrary property access, `some` inspects
2093
- // `.BS_PRIVATE_NESTED_SOME_NONE`, mis-reads the Output as a nested option, and
2094
- // stores the sentinel `{BS_PRIVATE_NESTED_SOME_NONE: 0}` instead of the Output —
2095
- // so the consumer's `tableOutput->Pulumi.Output.apply` crashes with
2096
- // "apply is not a function". A `Some(r.name)` LITERAL compiles unboxed (bare
2097
- // r.name), preserving the Output — the same dodge `pluginReadModelTableName` uses.
2098
- ~apiFragmentRegistryTableName=?switch admin.readModelsOutputs
2099
- ->Dict.get("ApiFragments")
2100
- ->Option.flatMap(rm => rm.queryDb.resources->Array.get(0)) {
2101
- | Some(r) => Some(r.name)
2102
- | None => None
2103
- },
2104
- ~adminDcbCmdTopicUrl=?AutomationSliceRuntime_Builder_Single.getDcbQueueUrl(),
2105
- ~splitApi=Config.splitApi,
2106
1906
  (),
2107
1907
  )
2108
1908
 
@@ -2197,6 +1997,44 @@ module MakeWithConfig = (
2197
1997
  )
2198
1998
  Pulumi.Pulumi.export("domainApiRoleArn", domainApiRole->Pulumi.Output.flatMap(role => role.arn))
2199
1999
 
2000
+ // Merged-API exports — plugin stacks associate their source APIs against
2001
+ // these ARNs (this replaces the SigV4 RegisterApiFragment handshake as the
2002
+ // cross-stack wiring on the merge path), and `mergedApiPrimaryAuth` is the
2003
+ // primary-auth contract source APIs must match. The ARN exports are gated
2004
+ // on the merge-status poll so a MERGE_FAILED fails the deploy loudly
2005
+ // instead of silently serving the last-good merged schema.
2006
+ switch mergedOutputs {
2007
+ | Some({domainMerged, platformMerged, domainMergeGate, platformMergeGate}) =>
2008
+ let mergeGatedArn = (merged: AppSync_MergedApi.t, gate: Pulumi.Output.t<unit>) =>
2009
+ (
2010
+ merged.api->Pulumi.Output.flatMap((api: PulumiAws.AppSync.GraphQLApi.t) => api.arn),
2011
+ gate,
2012
+ )
2013
+ ->Pulumi.Output.all2
2014
+ ->Pulumi.Output.apply(((arn, _)) => arn)
2015
+ let mergedEndpoint = (merged: AppSync_MergedApi.t) =>
2016
+ merged.api->Pulumi.Output.flatMap((api: PulumiAws.AppSync.GraphQLApi.t) =>
2017
+ api.uris->Pulumi.Output.apply(uris => uris.graphQL)
2018
+ )
2019
+ Pulumi.Pulumi.export("domainMergedApiArn", mergeGatedArn(domainMerged, domainMergeGate))
2020
+ Pulumi.Pulumi.export(
2021
+ "domainMergedApiId",
2022
+ domainMerged.api->Pulumi.Output.flatMap((api: PulumiAws.AppSync.GraphQLApi.t) => api.id),
2023
+ )
2024
+ Pulumi.Pulumi.export("domainMergedApiEndpoint", mergedEndpoint(domainMerged))
2025
+ Pulumi.Pulumi.export("platformMergedApiArn", mergeGatedArn(platformMerged, platformMergeGate))
2026
+ Pulumi.Pulumi.export(
2027
+ "platformMergedApiId",
2028
+ platformMerged.api->Pulumi.Output.flatMap((api: PulumiAws.AppSync.GraphQLApi.t) => api.id),
2029
+ )
2030
+ Pulumi.Pulumi.export("platformMergedApiEndpoint", mergedEndpoint(platformMerged))
2031
+ Pulumi.Pulumi.export(
2032
+ "mergedApiPrimaryAuth",
2033
+ Pulumi.Output.make(AppSync_MergedApi.primaryAuthMode),
2034
+ )
2035
+ | None => ()
2036
+ }
2037
+
2200
2038
  // Events API exports — consumed by plugin stacks to wire Source B (StateTopic) Lambdas.
2201
2039
  switch domainEventsApiOpt {
2202
2040
  | Some(eventsApi) =>
@@ -2215,14 +2053,6 @@ module MakeWithConfig = (
2215
2053
  | None => ()
2216
2054
  }
2217
2055
 
2218
- // Export the dedicated schema-persistence table name. preResolversSchemaHook
2219
- // prefers this over pluginRmTableName so deploy-schema rows no longer share
2220
- // the Plugin RM table.
2221
- Pulumi.Pulumi.export(
2222
- "pluginSchemaPersistenceTableName",
2223
- pluginSchemaPersistenceTable.name,
2224
- )
2225
-
2226
2056
  // (No pluginAggrCmdTopicUrl export: the deploy-time retire hook that
2227
2057
  // published Retire commands to the Plugin aggregate queue is gone —
2228
2058
  // supersession is decided by the aggregate on connect.)
@@ -2241,11 +2071,22 @@ module MakeWithConfig = (
2241
2071
  )
2242
2072
 
2243
2073
  // Fire onPlatformDeployed hook with resolved platform metadata.
2244
- 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 =>
2245
2086
  api.uris->Pulumi.Output.apply(uris => uris.graphQL)
2246
2087
  )
2247
2088
  let resolvedDomainApiRoleArn = domainApiRole->Pulumi.Output.flatMap(role => role.arn)
2248
- let resolvedPlatformApiEndpoint = platformApi->Pulumi.Output.flatMap(api =>
2089
+ let resolvedPlatformApiEndpoint = clientPlatformApi->Pulumi.Output.flatMap(api =>
2249
2090
  api.uris->Pulumi.Output.apply(uris => uris.graphQL)
2250
2091
  )
2251
2092
  let resolvedPlatformApiRoleArn = platformApiRole->Pulumi.Output.flatMap(role => role.arn)
@@ -2465,6 +2306,97 @@ module MakeWithConfig = (
2465
2306
  let pluginOutputs = pluginComponent->ReventlessCore.Component.outputs
2466
2307
  ReventlessCore.Plugin_Helpers.exportPluginOutputs(pluginOutputs)
2467
2308
 
2309
+ // ── Merged-API association (merged-api plan, Phase 4) ───────────────────
2310
+ // Associate this plugin's source API with the platform's merged API —
2311
+ // this replaces the SigV4 RegisterApiFragment handshake + reactive push +
2312
+ // waiter as the schema-composition mechanism. `pulumi destroy` deletes
2313
+ // the association + source API: retirement by construction. Create-time
2314
+ // 409s (AWS serializes association creates per merged API) surface as a
2315
+ // deploy failure — retry concurrent FIRST-TIME plugin deploys; steady-
2316
+ // state schema updates never re-create the association.
2317
+ switch (mergedApiMode, platformStackRef) {
2318
+ | (true, Some(stackRef)) =>
2319
+ let defaultOutput: Pulumi.Output.t<option<JSON.t>> =
2320
+ stackRef->Pulumi.StackReference.getOutput("default")
2321
+ let getMergedExport = (key: string): Pulumi.Output.t<string> => {
2322
+ let direct: Pulumi.Output.t<option<string>> =
2323
+ stackRef->Pulumi.StackReference.getOutput(key)
2324
+ (direct, defaultOutput)
2325
+ ->Pulumi.Output.all2
2326
+ ->Pulumi.Output.apply(((direct, default)) =>
2327
+ switch direct {
2328
+ | Some(v) => v
2329
+ | None =>
2330
+ default
2331
+ ->Option.flatMap(d => d->JSON.Decode.object)
2332
+ ->Option.flatMap(d => d->Dict.get(key))
2333
+ ->Option.flatMap(v => v->JSON.Decode.string)
2334
+ ->Option.getOrThrow(
2335
+ ~message=`Platform stack does not export '${key}' — deploy the platform with mergedApi=true first`,
2336
+ )
2337
+ }
2338
+ )
2339
+ }
2340
+ // apiTarget collapses to "which merged API ARN the association points at".
2341
+ let mergedApiArn = switch apiTarget {
2342
+ | Domain => getMergedExport("domainMergedApiArn")
2343
+ | Platform => getMergedExport("platformMergedApiArn")
2344
+ }
2345
+ // Primary-auth contract check (checked invariant, not a convention) —
2346
+ // folded into the ARN the association consumes so it always runs.
2347
+ let checkedMergedApiArn =
2348
+ (mergedApiArn, getMergedExport("mergedApiPrimaryAuth"))
2349
+ ->Pulumi.Output.all2
2350
+ ->Pulumi.Output.apply(((arn, mergedMode)) => {
2351
+ AppSync_MergedApi.assertCompatiblePrimaryAuth(
2352
+ ~sourceMode=AppSync_MergedApi.authenticationTypeName(
2353
+ AppSync_Adapter.primaryAuthenticationType,
2354
+ ),
2355
+ ~mergedMode,
2356
+ )
2357
+ arn
2358
+ })
2359
+ // Sequence the association behind the subgraph schema push (the
2360
+ // intra-stack replacement of the schemaPushed cross-stack gate).
2361
+ let schemaPushed = switch mergedSchemaPushedRef.contents {
2362
+ | Some(pushed) => pushed
2363
+ | None => Pulumi.Output.make()
2364
+ }
2365
+ let arnAfterSchemaPush =
2366
+ (checkedMergedApiArn, schemaPushed)
2367
+ ->Pulumi.Output.all2
2368
+ ->Pulumi.Output.apply(((arn, _)) => arn)
2369
+ let association = AppSync_MergedApi.associateSourceWithMergedArn(
2370
+ ~name="PluginSourceAssociation",
2371
+ ~mergedApiArn=arnAfterSchemaPush,
2372
+ ~sourceApi=domainApi,
2373
+ ~opts={},
2374
+ )
2375
+ // Fail the deploy loudly on MERGE_FAILED — the gate is folded into the
2376
+ // exported association id so it is always consumed.
2377
+ let mergeGate = AppSync_MergedApi.mergeStatusGateWith(
2378
+ ~mergedApiIdentifier=checkedMergedApiArn,
2379
+ ~association,
2380
+ )
2381
+ Pulumi.Pulumi.export(
2382
+ "sourceApiAssociationId",
2383
+ (association.associationId, mergeGate)
2384
+ ->Pulumi.Output.all2
2385
+ ->Pulumi.Output.apply(((id, _)) => id),
2386
+ )
2387
+ Pulumi.Pulumi.export(
2388
+ "pluginSourceApiId",
2389
+ domainApi->Pulumi.Output.flatMap(api => api.id),
2390
+ )
2391
+ Pulumi.Pulumi.export(
2392
+ "pluginSourceApiEndpoint",
2393
+ domainApi->Pulumi.Output.flatMap(api =>
2394
+ api.uris->Pulumi.Output.apply(uris => uris.graphQL)
2395
+ ),
2396
+ )
2397
+ | _ => ()
2398
+ }
2399
+
2468
2400
  // B2.3d: provision the Postgres change-feed relay (plugin-stack mode — this
2469
2401
  // plugin's Postgres DCB log(s) + collector queue were registered during P.make()).
2470
2402
  provisionPgChangeFeedRelay()
@@ -2543,6 +2475,7 @@ module Make = (): (
2543
2475
  ) => {
2544
2476
  include MakeWithConfig({
2545
2477
  let splitApi = true
2478
+ let mergedApi = true
2546
2479
  let cloner = false
2547
2480
  let commandHandlerConfig: ReventlessCore.Runtime.commandHandlerConfigs = {}
2548
2481
  let pgConnection = None