@reventlessdev/reventless-aws 3.0.0-alpha.197 → 3.0.0-alpha.198

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 (28) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +6 -6
  3. package/src/Platform.res +290 -44
  4. package/src/Platform.res.mjs +416 -90
  5. package/src/adapter/Api/ApiFragmentDeregistration.res +137 -0
  6. package/src/adapter/Api/ApiFragmentDeregistration.res.mjs +108 -0
  7. package/src/adapter/Api/CommandSubscriptionResolvers_AppSync.res +4 -3
  8. package/src/adapter/Api/Platform_ApiFragments_Lambda.res +212 -0
  9. package/src/adapter/Api/Platform_ApiFragments_Lambda.res.mjs +202 -0
  10. package/src/adapter/Api/Platform_UIFragments_Lambda.res +8 -8
  11. package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +11 -11
  12. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_AppSync.res +2 -1
  13. package/src/adapter/QueryDb/QueryDbBackend.res +1 -1
  14. package/src/adapter/Runtime/AdminEventCollectorEntryPoint.mjs +235 -9
  15. package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res +4 -0
  16. package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res.mjs +5 -0
  17. package/src/components/Api/AppSync_Adapter.res +36 -59
  18. package/src/components/Api/AppSync_Adapter.res.mjs +18 -74
  19. package/src/components/Api/AppSync_SdlDecorate.res +189 -0
  20. package/src/components/Api/AppSync_SdlDecorate.res.mjs +125 -0
  21. package/src/plugin/runtime/PluginRuntime_Builder.res +107 -2
  22. package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +70 -7
  23. package/src/util/Util_AppSync_Caller.res +12 -4
  24. package/src/util/Util_AppSync_Caller.res.mjs +9 -3
  25. package/tests/AppSync_AdapterTest.res +6 -0
  26. package/tests/AppSync_AdapterTest.res.mjs +12 -6
  27. package/tests/AppSync_SdlDecorateTest.res +157 -0
  28. package/tests/AppSync_SdlDecorateTest.res.mjs +147 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.198 (2026-07-12)
7
+
8
+ ### Features
9
+
10
+ * **admin:** event-source the API fragment registry as admin DCB slices (Phase 2b) ([3fc40d2](https://github.com/ReventlessDev/reventless-core/commit/3fc40d247c54bf66ed84d625959f08299aa04a98))
11
+ * **admin:** event-source the UI fragment registry as admin DCB slices ([711581e](https://github.com/ReventlessDev/reventless-core/commit/711581e77c626e7d0fc35db8ec351f62a70bd8f2))
12
+ * **admin:** expose the API fragment registry as system-callable mutations (Phase 2d) ([8b1ec49](https://github.com/ReventlessDev/reventless-core/commit/8b1ec49813e67f303bc86d60470c9b95d2a8408e))
13
+ * **admin:** react to ApiFragment* events with a schema single writer (Phase 2e) ([59f9e46](https://github.com/ReventlessDev/reventless-core/commit/59f9e46c154d8ca2a34537974722014fc279de3e))
14
+ * **admin:** retire the Plugin-aggregate UI-fragment path in favour of the registry slices ([1dbc708](https://github.com/ReventlessDev/reventless-core/commit/1dbc708e7439b34ff970cc3d963d7835a8c6fd48))
15
+ * **admin:** serve Platform_UIFragments from the new UiFragmentRegistry slices ([7a47533](https://github.com/ReventlessDev/reventless-core/commit/7a47533acc95977fbeeb6375e94449056b655abc))
16
+ * **api:** neutralize core-emitted SDL — [@aws](https://github.com/aws)_subscribe moves to the AWS adapter (Phase 2a) ([9b27ec8](https://github.com/ReventlessDev/reventless-core/commit/9b27ec8f6ab2b7453b1c50b9891e89781ec3b31a))
17
+ * **deploy:** deregister API fragments on plugin-stack destroy (Phase 3 tail) ([4a10614](https://github.com/ReventlessDev/reventless-core/commit/4a10614e785359ca4c8021da9813c3d485abe9ca))
18
+ * **deploy:** register API fragments via the Platform API on staged deploys (Phase 3) ([7ddb045](https://github.com/ReventlessDev/reventless-core/commit/7ddb045392380497494a0109712e7a10008f9207))
19
+
20
+
6
21
  # 3.0.0-alpha.197 (2026-07-11)
7
22
 
8
23
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.197",
3
+ "version": "3.0.0-alpha.198",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -9,16 +9,16 @@
9
9
  "sury": "11.0.0-alpha.4",
10
10
  "uuid": "^13.0.0",
11
11
  "@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.21",
12
- "@reventlessdev/rescript-effect": "0.1.0-alpha.27",
13
12
  "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.50",
14
13
  "@reventlessdev/rescript-jest": "1.0.0-alpha.7",
15
14
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.15",
15
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.27",
16
+ "@reventlessdev/reventless-core": "3.0.0-alpha.157",
16
17
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.15",
17
- "@reventlessdev/reventless-core": "3.0.0-alpha.156",
18
- "@reventlessdev/reventless-infra": "3.0.0-alpha.97",
18
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.98",
19
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.21",
19
20
  "@reventlessdev/reventless-interop": "3.0.0-alpha.26",
20
- "@reventlessdev/reventless-spec": "3.0.0-alpha.75",
21
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.20"
21
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.76"
22
22
  },
23
23
  "devDependencies": {
24
24
  "rescript": "^12.3.0",
package/src/Platform.res CHANGED
@@ -109,7 +109,13 @@ module MakeWithConfig = (
109
109
  subnetIds: pg.subnetIds,
110
110
  })
111
111
  QueryDbBackend.exempt(ReventlessCore.PluginsReadModelSpec.name)
112
- QueryDbBackend.exempt(ReventlessCore.UIFragmentRegistryReadModelSpec.name)
112
+ // The UiFragments StateViewSlice table is scanned by the Platform_UIFragments
113
+ // Lambda (DynamoDB scan) — keep it off the Postgres selection like the other
114
+ // admin stores.
115
+ QueryDbBackend.exempt(ReventlessCore.UiFragments.name)
116
+ // The ApiFragments StateViewSlice is the durable source of the schema-push
117
+ // automation and the runtime re-stitcher — admin store, off Postgres too.
118
+ QueryDbBackend.exempt(ReventlessCore.ApiFragments.name)
113
119
  })
114
120
  type api = Types.AppSync.api
115
121
  type role = Types.AppSync.role
@@ -567,6 +573,27 @@ module MakeWithConfig = (
567
573
  }
568
574
  module InboundTranslationSlice = InboundTranslationSlice_Builder.Make(ApiConfig)
569
575
 
576
+ // Admin UI-fragment registry slices (docs/plans/event-sourced-fragment-registries.md): the
577
+ // platform UI-fragment registry hosted as admin DCB slices sharing the admin DcbEventLog.
578
+ // Built once at platform-module level and passed into Admin.construct below (registers the
579
+ // slice's command handler exactly once).
580
+ module UiFragmentRegistrySlice = StateChangeSlice.Make(
581
+ ReventlessCore.UiFragmentRegistry,
582
+ ReventlessCore.UiFragmentRegistry_Behavior,
583
+ )
584
+ module UiFragmentsViewSlice = StateViewSlice.Make(
585
+ ReventlessCore.UiFragments,
586
+ ReventlessCore.UiFragments_Projection,
587
+ )
588
+ module ApiFragmentRegistrySlice = StateChangeSlice.Make(
589
+ ReventlessCore.ApiFragmentRegistry,
590
+ ReventlessCore.ApiFragmentRegistry_Behavior,
591
+ )
592
+ module ApiFragmentsViewSlice = StateViewSlice.Make(
593
+ ReventlessCore.ApiFragments,
594
+ ReventlessCore.ApiFragments_Projection,
595
+ )
596
+
570
597
  // Empty base fragment — no types, no mutations, no queries.
571
598
  // Used by the plugin Api in split mode so plugin schema has no core fields.
572
599
  let emptyBaseFragment = ReventlessCore.GraphQL_Stitcher.encode({
@@ -574,6 +601,7 @@ module MakeWithConfig = (
574
601
  mutations: [],
575
602
  queries: [],
576
603
  subscriptions: [],
604
+ subscriptionSources: [],
577
605
  })
578
606
 
579
607
  module Api = {
@@ -639,6 +667,11 @@ module MakeWithConfig = (
639
667
  // targetApi that was set before plugin.make() and is never reset.
640
668
  let hooksApiRef: ref<option<ReventlessCore.Plugin_Helpers.hookedValue<unknown>>> = ref(None)
641
669
  let hooksApiRoleRef: ref<option<ReventlessCore.Plugin_Helpers.hookedValue<unknown>>> = ref(None)
670
+ // Admin DCB mutation resolvers bind here (the Platform API in split mode). Set by
671
+ // makePlatform / deployPlatform once the Platform API resource exists. Distinct from hooksApiRef
672
+ // because both are read in *deferred* callbacks — admin resolvers need the Platform API while the
673
+ // plugins built afterwards read hooksApiRef (Domain/deploy-target). See platformHooks.adminApi.
674
+ let hooksAdminApiRef: ref<option<ReventlessCore.Plugin_Helpers.hookedValue<unknown>>> = ref(None)
642
675
 
643
676
  let resolveHookedApi = (): Types.AppSync.api =>
644
677
  switch hooksApiRef.contents {
@@ -646,6 +679,160 @@ module MakeWithConfig = (
646
679
  | None => resolveTargetApi()
647
680
  }
648
681
 
682
+ // Admin DCB resolvers resolve to the Platform API when it has been recorded; otherwise fall back
683
+ // to the same api plugins use (unified mode, or before the split Platform API exists).
684
+ let resolveAdminHookedApi = (): Types.AppSync.api =>
685
+ switch hooksAdminApiRef.contents {
686
+ | Some({val}) => Obj.magic(val)
687
+ | None => resolveHookedApi()
688
+ }
689
+
690
+ // ── Phase 3: staged-deploy API-fragment registration ────────────────────────
691
+ // When a plugin deploys against an ALREADY-RUNNING platform (plugin-stack mode),
692
+ // it registers its API-schema fragment with the platform via the Platform API
693
+ // (SigV4 system caller) instead of writing deploy-schema:* rows and pushing the
694
+ // schema itself. The platform-side reactive single writer (Plan 2e) stitches and
695
+ // pushes; this waits on the push status so resolver creation (gated on the
696
+ // returned Output) proceeds only once the plugin's fields are ACTIVE — or fails
697
+ // with the stitch error. Used only in plugin-stack mode; makePlatform (all-at-once)
698
+ // keeps the direct deploy-time push (the reactive writer is dormant there).
699
+ @val external registerWaiterSetTimeout: (unit => unit, int) => unit = "setTimeout"
700
+ let deployWaiterDelay = (ms: int): promise<unit> =>
701
+ Promise.make((resolve, _) => registerWaiterSetTimeout(() => resolve(), ms))
702
+
703
+ // Poll the platform's Platform_ApiFragments status query until the plugin's row
704
+ // shows a push that landed at/after our registration time (`sinceAt`) — a fresh
705
+ // "ok" means our fields are live; a fresh "error" surfaces the stitch failure. An
706
+ // older "ok" is a stale prior deploy's and is ignored (ISO strings sort lexically).
707
+ let waitForApiFragmentPush = async (~endpoint, ~region, ~pluginId, ~sinceAt) => {
708
+ let query = "query { Platform_ApiFragments { pluginId pushStatus pushMessage pushedAt } }"
709
+ let maxAttempts = 90 // ~3 min at 2s intervals
710
+ let rec loop = async attempt =>
711
+ if attempt >= maxAttempts {
712
+ JsError.throwWithMessage(
713
+ `Timed out (>3min) waiting for the platform to push ${pluginId}'s API fragment — check the AdminEventCollector Lambda logs.`,
714
+ )
715
+ } else {
716
+ let data = await Util_AppSync_Caller.sendQuery(~endpoint, ~region, ~queryString=query)
717
+ let row =
718
+ data
719
+ ->Option.flatMap(JSON.Decode.object)
720
+ ->Option.flatMap(d => d->Dict.get("Platform_ApiFragments"))
721
+ ->Option.flatMap(JSON.Decode.array)
722
+ ->Option.getOr([])
723
+ ->Array.find(entry =>
724
+ entry
725
+ ->JSON.Decode.object
726
+ ->Option.flatMap(e => e->Dict.get("pluginId"))
727
+ ->Option.flatMap(JSON.Decode.string) == Some(pluginId)
728
+ )
729
+ ->Option.flatMap(JSON.Decode.object)
730
+ switch row {
731
+ | Some(e) =>
732
+ let field = k => e->Dict.get(k)->Option.flatMap(JSON.Decode.string)->Option.getOr("")
733
+ let status = field("pushStatus")
734
+ let fresh = field("pushedAt") >= sinceAt
735
+ if fresh && status == "ok" {
736
+ log.info(~comp="registerFragmentViaApi", `${pluginId} schema push confirmed ACTIVE`)
737
+ } else if fresh && status == "error" {
738
+ JsError.throwWithMessage(`Schema push failed for ${pluginId}: ${field("pushMessage")}`)
739
+ } else {
740
+ await deployWaiterDelay(2000)
741
+ await loop(attempt + 1)
742
+ }
743
+ | None =>
744
+ await deployWaiterDelay(2000)
745
+ await loop(attempt + 1)
746
+ }
747
+ }
748
+ await loop(0)
749
+ }
750
+
751
+ let registerFragmentViaApi = (
752
+ ~name: string,
753
+ ~fragment: Reventless.Plugin.apiSchemaFragment,
754
+ ~apiTargetName: string,
755
+ ): Pulumi.Output.t<unit> => {
756
+ // Platform_RegisterApiFragment is an admin systemCallable mutation on the Platform
757
+ // API (regardless of the plugin's apiTarget, which is passed as the enum argument).
758
+ let platformApi = switch apiConfigRef.contents {
759
+ | Some({platformApi}) => platformApi
760
+ | None => domainApi
761
+ }
762
+ let region =
763
+ Pulumi.Config.make(Some("aws"))->Pulumi.Config.get("region")->Option.getOr("unknown")
764
+ let endpointOutput =
765
+ platformApi
766
+ ->Pulumi.Output.flatMap(api => api.uris)
767
+ ->Pulumi.Output.apply(uris => uris.graphQL)
768
+ // Deregister-on-destroy: a dynamic resource whose `delete` handler sends
769
+ // Platform_DeregisterApiFragment when the plugin stack is destroyed (final
770
+ // retirement). It never replaces on a version bump, so supersession does NOT
771
+ // deregister — only a genuine `pulumi destroy` removes the fields.
772
+ let _ = ApiFragmentDeregistration.make(
773
+ ~name=`${name}ApiFragmentRegistration`,
774
+ ~pluginId=name,
775
+ ~endpoint=endpointOutput->Pulumi.Output.asInput,
776
+ ~region,
777
+ )
778
+ endpointOutput
779
+ ->Pulumi.Output.flatMap(endpoint => {
780
+ let run = async () => {
781
+ let at = Date.make()->Date.toISOString
782
+ let variables = {
783
+ "id": name,
784
+ "pluginId": name,
785
+ "fragment": {"encoded": fragment.encoded, "protocol": fragment.protocol},
786
+ "apiTarget": Util_AppSync_Caller.graphqlEnum(apiTargetName),
787
+ "at": at,
788
+ }
789
+ let selection = "{ __typename ... on CommandAccepted { eventCount } ... on CommandRejected { errorCode errorDetail } }"
790
+ log.info(
791
+ ~comp="registerFragmentViaApi",
792
+ `Registering API fragment for ${name} (${apiTargetName}) via ${endpoint}`,
793
+ )
794
+ let result = await Util_AppSync_Caller.sendMutation(
795
+ ~endpoint,
796
+ ~region,
797
+ ~mutation="Platform_RegisterApiFragment",
798
+ ~selection,
799
+ ~variables,
800
+ )
801
+ let outcome =
802
+ result
803
+ ->Option.flatMap(JSON.Decode.object)
804
+ ->Option.flatMap(d => d->Dict.get("Platform_RegisterApiFragment"))
805
+ ->Option.flatMap(JSON.Decode.object)
806
+ switch outcome {
807
+ | Some(cmd) =>
808
+ let typename =
809
+ cmd->Dict.get("__typename")->Option.flatMap(JSON.Decode.string)->Option.getOr("")
810
+ switch typename {
811
+ | "CommandRejected" =>
812
+ let code =
813
+ cmd->Dict.get("errorCode")->Option.flatMap(JSON.Decode.string)->Option.getOr("unknown")
814
+ JsError.throwWithMessage(`RegisterApiFragment rejected for ${name}: ${code}`)
815
+ | "CommandAccepted" =>
816
+ // eventCount 0 ⇒ idempotent no-op (unchanged fragment) — fields already live,
817
+ // proceed without waiting; >0 ⇒ a real change, wait for the reactive push.
818
+ let eventCount =
819
+ cmd->Dict.get("eventCount")->Option.flatMap(JSON.Decode.float)->Option.getOr(0.0)
820
+ if eventCount > 0.0 {
821
+ await waitForApiFragmentPush(~endpoint, ~region, ~pluginId=name, ~sinceAt=at)
822
+ } else {
823
+ log.info(~comp="registerFragmentViaApi", `${name} fragment unchanged — no push needed`)
824
+ }
825
+ | _ => await waitForApiFragmentPush(~endpoint, ~region, ~pluginId=name, ~sinceAt=at)
826
+ }
827
+ | None =>
828
+ // Unparseable result (mutation may have failed) — fall back to polling.
829
+ await waitForApiFragmentPush(~endpoint, ~region, ~pluginId=name, ~sinceAt=at)
830
+ }
831
+ }
832
+ run()->Pulumi.Output.fromPromise
833
+ })
834
+ }
835
+
649
836
  let hooks: ReventlessCore.Plugin_Helpers.platformHooks = {
650
837
  // AWS uses Interstack for admin extension points — leave ref at empty dict.
651
838
  adminExtensionPoints: ref(Pulumi.Output.make(Dict.make())),
@@ -654,6 +841,7 @@ module MakeWithConfig = (
654
841
  schedulerRoleUrn: ref(Pulumi.Output.make("")),
655
842
  api: hooksApiRef,
656
843
  apiRole: hooksApiRoleRef,
844
+ adminApi: hooksAdminApiRef,
657
845
  deployTarget: ref("Domain"),
658
846
  inboundAppSyncResolverHook: ({runtime, fieldNames, externalInputSchemas: _, opts}) => {
659
847
  let runtimeTyped: ReventlessCore.Runtime.environment<Util.Lambda.runtimeParts> =
@@ -665,11 +853,12 @@ module MakeWithConfig = (
665
853
  ~opts,
666
854
  )
667
855
  },
668
- dcbAppSyncResolverHook: ({runtime, fieldNames, tags, opts}) => {
856
+ dcbAppSyncResolverHook: ({runtime, fieldNames, tags, onAdminApi, opts}) => {
669
857
  let runtimeTyped: ReventlessCore.Runtime.environment<Util.Lambda.runtimeParts> =
670
858
  runtime->asLambdaRuntime
671
859
  CommandGeneratorResolvers_AppSync.makeDcb(
672
- ~api=resolveHookedApi(),
860
+ // Admin slices bind on the Platform API (split mode); plugins on the deploy-target api.
861
+ ~api=onAdminApi ? resolveAdminHookedApi() : resolveHookedApi(),
673
862
  ~runtime=runtimeTyped,
674
863
  ~fieldNames,
675
864
  ~tags,
@@ -714,11 +903,15 @@ module MakeWithConfig = (
714
903
  let adminBaseFragment = AppSync_Adapter.injectAwsAuthAll(
715
904
  ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
716
905
  ~group="Admin",
906
+ // The ApiFragmentRegistry register/deregister mutations + the Platform_ApiFragments
907
+ // status query are invoked by the plugin/standalone deploy as a SigV4 system caller, so
908
+ // they carry the dual-auth (@aws_cognito_user_pools @aws_iam) directive.
909
+ ~iamFieldNames=ReventlessCore.AdminApi.systemCallerFieldNames,
717
910
  )
718
- let sdl = ReventlessCore.GraphQL_Stitcher.stitch(
911
+ let sdl = AppSync_Adapter.stitchWithAwsDirectives(
719
912
  ~baseFragment=adminBaseFragment,
720
913
  ~pluginFragments=[],
721
- )->AppSync_Adapter.stampSharedIamTypes
914
+ )
722
915
  (targetApi, adminBarrier)
723
916
  ->Pulumi.Output.all2
724
917
  ->Pulumi.Output.flatMap(((api, _)) =>
@@ -756,6 +949,19 @@ module MakeWithConfig = (
756
949
  // Domain after P.make() returns, before any Pulumi.Output async callbacks run.
757
950
  let capturedDeployTarget = currentDeployTarget.contents
758
951
 
952
+ switch platformStackRef {
953
+ | Some(_) =>
954
+ // Phase 3 — staged deploy against a running platform (deployPlugin): register the
955
+ // fragment via the Platform API; the reactive single writer (2e) stitches + pushes.
956
+ let apiTargetName = switch capturedDeployTarget {
957
+ | Domain => "Domain"
958
+ | Platform => "Platform"
959
+ }
960
+ registerFragmentViaApi(~name, ~fragment=pluginFragment, ~apiTargetName)
961
+ | None =>
962
+ // All-at-once (makePlatform): platform + plugins deploy in one stack, so the
963
+ // reactive writer is dormant — keep the direct deploy-time stitch + push below.
964
+
759
965
  // Select DynamoDB key prefix and target AppSync API based on the current deploy target.
760
966
  // Domain plugins use "deploy-schema:" and the Domain API (default behaviour).
761
967
  // Platform plugins use a separate "deploy-schema-platform:" namespace and the Core API,
@@ -933,6 +1139,7 @@ module MakeWithConfig = (
933
1139
  AppSync_Adapter.injectAwsAuthAll(
934
1140
  ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
935
1141
  ~group="Admin",
1142
+ ~iamFieldNames=ReventlessCore.AdminApi.systemCallerFieldNames,
936
1143
  )
937
1144
  | Domain =>
938
1145
  if Config.splitApi {
@@ -944,10 +1151,10 @@ module MakeWithConfig = (
944
1151
  )
945
1152
  }
946
1153
  }
947
- let sdl = ReventlessCore.GraphQL_Stitcher.stitch(
1154
+ let sdl = AppSync_Adapter.stitchWithAwsDirectives(
948
1155
  ~baseFragment,
949
1156
  ~pluginFragments=allPluginFragments,
950
- )->AppSync_Adapter.stampSharedIamTypes
1157
+ )
951
1158
  let currentHash = AppSync_Adapter.sha256Hex(sdl)
952
1159
  let storedHash = switch tableNameOpt {
953
1160
  | Some(tn) => await readSchemaHash(~tableName=tn, ~apiId)
@@ -1088,6 +1295,7 @@ module MakeWithConfig = (
1088
1295
  })
1089
1296
  )
1090
1297
  })
1298
+ }
1091
1299
  },
1092
1300
  // DCB EventLog created hook — extracts DynamoDB table name for DCB CommandTopic Lambda handler.
1093
1301
  // Postgres-backed DCB logs (B2.3c) create no table, so there is no resource to read:
@@ -1271,23 +1479,6 @@ module MakeWithConfig = (
1271
1479
  PluginReadModelMappings,
1272
1480
  )
1273
1481
 
1274
- // Admin-internal UIFragmentRegistry read model — projects UIFragmentRegistered/
1275
- // Updated/Deregistered events from the Plugin aggregate into a DynamoDB table the
1276
- // Platform_UIFragments admin query Lambda scans.
1277
- module UIFragmentRegistryReadModelMappings: Reventless.Projection.Mappings
1278
- with module Target := ReventlessCore.UIFragmentRegistryReadModelSpec = {
1279
- module M = ReventlessCore.UIFragmentRegistryProjection.Mappings
1280
- module type Mapping = M.Mapping
1281
- // See PluginReadModelMappings.moduleUrl note above.
1282
- let moduleUrl: string = ReventlessCore.UIFragmentRegistryProjection.moduleUrl
1283
- let mappings: array<module(Mapping)> = ReventlessCore.UIFragmentRegistryProjection.mappings
1284
- }
1285
-
1286
- module UIFragmentRegistryReadModel = ReadModel_Builder_NoResolver_Stream.Make(
1287
- ReventlessCore.UIFragmentRegistryReadModelSpec,
1288
- UIFragmentRegistryReadModelMappings,
1289
- )
1290
-
1291
1482
  module type PluginMaker = {
1292
1483
  let make: unit => Plugin.component
1293
1484
  }
@@ -1432,6 +1623,11 @@ module MakeWithConfig = (
1432
1623
  (domainApi, domainApiRole)
1433
1624
  }
1434
1625
 
1626
+ // Admin DCB mutation resolvers bind to the Platform API (split mode) or the shared api
1627
+ // (unified). Recorded now that the Platform API resource exists, so the admin's deferred
1628
+ // dcbConnectFn (via ~onAdminApi) targets it rather than the Domain/deploy-target hooksApiRef.
1629
+ hooks.adminApi := Some(platformApi->wrapHookedValue)
1630
+
1435
1631
  // Update splitApiOutputsRef and apiConfig with the now-known Platform API.
1436
1632
  if Config.splitApi {
1437
1633
  splitApiOutputsRef := Some({platformApi, platformApiRole})
@@ -1453,16 +1649,13 @@ module MakeWithConfig = (
1453
1649
  ~version,
1454
1650
  ~extensionPoints=[],
1455
1651
  ~aggregates=[module(PluginAggregate)],
1456
- ~readModels=[
1457
- module(PluginReadModel),
1458
- module(UIFragmentRegistryReadModel),
1459
- ],
1652
+ ~readModels=[module(PluginReadModel)],
1460
1653
  ~scheduler,
1461
1654
  ~resourceNaming=Util_ResourceNaming.operations,
1462
1655
  ~api=platformApi,
1463
1656
  ~apiRole=platformApiRole,
1464
- ~stateChangeSlices=[],
1465
- ~stateViewSlices=[],
1657
+ ~stateChangeSlices=[module(UiFragmentRegistrySlice), module(ApiFragmentRegistrySlice)],
1658
+ ~stateViewSlices=[module(UiFragmentsViewSlice), module(ApiFragmentsViewSlice)],
1466
1659
  ~automationSlices=[],
1467
1660
  ~outboundTranslationSlices=[],
1468
1661
  ~inboundTranslationSlices=[],
@@ -1488,9 +1681,10 @@ module MakeWithConfig = (
1488
1681
  | None => ()
1489
1682
  }
1490
1683
 
1491
- // Mount the Platform_UIFragments Lambda resolver — scans the UIFragmentRegistry
1492
- // table provisioned above and returns one entry per registered plugin UI.
1493
- switch admin.readModelsOutputs->Dict.get("UIFragmentRegistry") {
1684
+ // Mount the Platform_UIFragments Lambda resolver — scans the UiFragments
1685
+ // StateViewSlice table provisioned above and returns one entry per registered
1686
+ // plugin UI.
1687
+ switch admin.stateViewSlicesOutputs->Dict.get("UiFragments") {
1494
1688
  | Some(rm) =>
1495
1689
  switch rm.queryDb.resources->Array.get(0) {
1496
1690
  | Some(r) =>
@@ -1504,6 +1698,24 @@ module MakeWithConfig = (
1504
1698
  | None => ()
1505
1699
  }
1506
1700
 
1701
+ // Mount the Platform_ApiFragments Lambda resolver — scans the ApiFragments
1702
+ // StateViewSlice table and returns the push-status row per plugin (the deploy
1703
+ // waiter polls this). The query field is in the pushed admin base but was
1704
+ // unresolved on AWS until now.
1705
+ switch admin.stateViewSlicesOutputs->Dict.get("ApiFragments") {
1706
+ | Some(rm) =>
1707
+ switch rm.queryDb.resources->Array.get(0) {
1708
+ | Some(r) =>
1709
+ Platform_ApiFragments_Lambda.make(
1710
+ ~api=platformApi,
1711
+ ~apiFragmentRegistryTableName=r.name,
1712
+ ~opts={},
1713
+ )
1714
+ | None => ()
1715
+ }
1716
+ | None => ()
1717
+ }
1718
+
1507
1719
  // Build each plugin.
1508
1720
  let pluginComponents = plugins->Array.map(plugin => {
1509
1721
  module P = unpack(plugin)
@@ -1599,6 +1811,11 @@ module MakeWithConfig = (
1599
1811
  (domainApi, domainApiRole)
1600
1812
  }
1601
1813
 
1814
+ // Admin DCB mutation resolvers bind to the Platform API (split mode) or the shared api
1815
+ // (unified). Recorded now that the Platform API resource exists, so the admin's deferred
1816
+ // dcbConnectFn (via ~onAdminApi) targets it rather than the Domain/deploy-target hooksApiRef.
1817
+ hooks.adminApi := Some(platformApi->wrapHookedValue)
1818
+
1602
1819
  // Update splitApiOutputsRef and apiConfig with the now-known Platform API.
1603
1820
  if Config.splitApi {
1604
1821
  splitApiOutputsRef := Some({platformApi, platformApiRole})
@@ -1660,12 +1877,13 @@ module MakeWithConfig = (
1660
1877
  AppSync_Adapter.injectAwsAuthAll(
1661
1878
  ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
1662
1879
  ~group="Admin",
1880
+ ~iamFieldNames=ReventlessCore.AdminApi.systemCallerFieldNames,
1663
1881
  )
1664
1882
  }
1665
- let sdl = ReventlessCore.GraphQL_Stitcher.stitch(
1883
+ let sdl = AppSync_Adapter.stitchWithAwsDirectives(
1666
1884
  ~baseFragment,
1667
1885
  ~pluginFragments=fragments,
1668
- )->AppSync_Adapter.stampSharedIamTypes
1886
+ )
1669
1887
  await AppSync_Adapter.getClient()->AppSync_Adapter.startSchemaCreationRetrying({
1670
1888
  apiId,
1671
1889
  definition: sdl,
@@ -1679,16 +1897,13 @@ module MakeWithConfig = (
1679
1897
  ~version,
1680
1898
  ~extensionPoints=[module(PluginExtensionPoint)],
1681
1899
  ~aggregates=[module(PluginAggregate)],
1682
- ~readModels=[
1683
- module(PluginReadModel),
1684
- module(UIFragmentRegistryReadModel),
1685
- ],
1900
+ ~readModels=[module(PluginReadModel)],
1686
1901
  ~scheduler,
1687
1902
  ~resourceNaming=Util_ResourceNaming.operations,
1688
1903
  ~api=platformApi,
1689
1904
  ~apiRole=platformApiRole,
1690
- ~stateChangeSlices=[],
1691
- ~stateViewSlices=[],
1905
+ ~stateChangeSlices=[module(UiFragmentRegistrySlice), module(ApiFragmentRegistrySlice)],
1906
+ ~stateViewSlices=[module(UiFragmentsViewSlice), module(ApiFragmentsViewSlice)],
1692
1907
  ~automationSlices=[],
1693
1908
  ~outboundTranslationSlices=[],
1694
1909
  ~inboundTranslationSlices=[],
@@ -1758,6 +1973,18 @@ module MakeWithConfig = (
1758
1973
  ~pluginSchemaPersistenceTableName=pluginSchemaPersistenceTable.name,
1759
1974
  ~schedulerRoleArn=hooks.schedulerRoleUrn.contents,
1760
1975
  ~clonerEnabled=Config.cloner,
1976
+ // 2e: the reactive ApiFragmentRegistry single writer (admin EventCollector).
1977
+ // The Platform AppSync id it pushes Platform-target fragments to (unified →
1978
+ // domainApi, since platformApi == domainApi); the ApiFragments StateViewSlice
1979
+ // table it re-folds from; the admin DCB command-topic FIFO URL it dispatches
1980
+ // RecordApiFragmentPush to (captured during Admin.construct); and the mode flag.
1981
+ ~platformApiId=platformApi->Pulumi.Output.flatMap(api => api.id),
1982
+ ~apiFragmentRegistryTableName=?admin.stateViewSlicesOutputs
1983
+ ->Dict.get("ApiFragments")
1984
+ ->Option.flatMap(rm => rm.queryDb.resources->Array.get(0))
1985
+ ->Option.map(r => r.name),
1986
+ ~adminDcbCmdTopicUrl=?AutomationSliceRuntime_Builder_Single.getDcbQueueUrl(),
1987
+ ~splitApi=Config.splitApi,
1761
1988
  (),
1762
1989
  )
1763
1990
 
@@ -1777,9 +2004,10 @@ module MakeWithConfig = (
1777
2004
  | None => ()
1778
2005
  }
1779
2006
 
1780
- // Mount the Platform_UIFragments Lambda resolver — scans the UIFragmentRegistry
1781
- // table provisioned above and returns one entry per registered plugin UI.
1782
- switch admin.readModelsOutputs->Dict.get("UIFragmentRegistry") {
2007
+ // Mount the Platform_UIFragments Lambda resolver — scans the UiFragments
2008
+ // StateViewSlice table provisioned above and returns one entry per registered
2009
+ // plugin UI.
2010
+ switch admin.stateViewSlicesOutputs->Dict.get("UiFragments") {
1783
2011
  | Some(rm) =>
1784
2012
  switch rm.queryDb.resources->Array.get(0) {
1785
2013
  | Some(r) =>
@@ -1793,6 +2021,24 @@ module MakeWithConfig = (
1793
2021
  | None => ()
1794
2022
  }
1795
2023
 
2024
+ // Mount the Platform_ApiFragments Lambda resolver — scans the ApiFragments
2025
+ // StateViewSlice table and returns the push-status row per plugin (the deploy
2026
+ // waiter polls this). The query field is in the pushed admin base but was
2027
+ // unresolved on AWS until now.
2028
+ switch admin.stateViewSlicesOutputs->Dict.get("ApiFragments") {
2029
+ | Some(rm) =>
2030
+ switch rm.queryDb.resources->Array.get(0) {
2031
+ | Some(r) =>
2032
+ Platform_ApiFragments_Lambda.make(
2033
+ ~api=platformApi,
2034
+ ~apiFragmentRegistryTableName=r.name,
2035
+ ~opts={},
2036
+ )
2037
+ | None => ()
2038
+ }
2039
+ | None => ()
2040
+ }
2041
+
1796
2042
  // Note: StateTopic_AppSync.finish runs from inside subscriptionInfraHook
1797
2043
  // (Phase 4 wiring above), not here — see the note in deployPlatform.
1798
2044