@reventlessdev/reventless-aws 3.0.0-alpha.210 → 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 (32) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/package.json +6 -6
  3. package/src/Platform.res +116 -849
  4. package/src/Platform.res.mjs +75 -710
  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 +13 -105
  11. package/src/components/Api/AppSync_Adapter.res.mjs +0 -52
  12. package/src/components/Api/AppSync_MergedApi.res +1 -1
  13. package/src/components/Api/AppSync_SdlDecorate.res +6 -65
  14. package/src/components/Api/AppSync_SdlDecorate.res.mjs +1 -31
  15. package/src/components/Plugin.res.mjs +1 -2
  16. package/tests/AppSync_AdapterTest.res +23 -15
  17. package/tests/AppSync_AdapterTest.res.mjs +18 -16
  18. package/tests/AppSync_SdlDecorateTest.res +9 -89
  19. package/tests/AppSync_SdlDecorateTest.res.mjs +7 -72
  20. package/tests/MCP_LambdaTest.res +4 -6
  21. package/tests/MCP_LambdaTest.res.mjs +2 -2
  22. package/src/adapter/Api/ApiFragmentDeregistration.res +0 -138
  23. package/src/adapter/Api/ApiFragmentDeregistration.res.mjs +0 -108
  24. package/src/adapter/Api/ApiSchemaPush.res +0 -79
  25. package/src/adapter/Api/ApiSchemaPush.res.mjs +0 -64
  26. package/src/adapter/Api/Platform_ApiFragments_Lambda.res +0 -222
  27. package/src/adapter/Api/Platform_ApiFragments_Lambda.res.mjs +0 -202
  28. package/src/adapter/QueryDb/NodeResolver_AppSync.res +0 -71
  29. package/src/adapter/QueryDb/NodeResolver_AppSync.res.mjs +0 -44
  30. package/src/adapter/Runtime/ApiSchemaPush_Runtime.mjs +0 -204
  31. package/tests/ApiSchemaPushTest.res +0 -32
  32. package/tests/ApiSchemaPushTest.res.mjs +0 -20
package/src/Platform.res CHANGED
@@ -9,16 +9,10 @@
9
9
  // module App = MyPlugin.Make(Platform)
10
10
  //
11
11
  // Custom config:
12
- // module Platform = Platform.MakeWithConfig({
13
- // let splitApi = false; let mergedApi = false; let cloner = true
14
- // })
12
+ // module Platform = Platform.MakeWithConfig({let splitApi = false; let cloner = true})
15
13
 
16
14
  let log = ReventlessCore.Logger.fromEnv()
17
15
 
18
- // Deploy-time schema-push shrink-guard threshold (deploy-time analogue of the
19
- // runtime RUNTIME_SCHEMA_SHRINK_THRESHOLD in AdminEventCollectorEntryPoint.mjs).
20
- // A push whose stitched SDL has fewer than (threshold × live) root fields is
21
- // refused as a likely stale concurrent-deploy stitch. Default 0.5; override via
22
16
  // API config ref — populated during MakeWithConfig so slice builders
23
17
  // can access api/apiRole outside the functor constraint.
24
18
  //
@@ -53,17 +47,6 @@ let getSplitApiOutputs = () => splitApiOutputsRef.contents
53
47
  module MakeWithConfig = (
54
48
  Config: {
55
49
  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
67
50
  let cloner: bool
68
51
  let commandHandlerConfig: ReventlessCore.Runtime.commandHandlerConfigs
69
52
  /** B2.3c platform toggle. When `Some`, every DCB EventLog is Postgres-backed
@@ -118,12 +101,7 @@ module MakeWithConfig = (
118
101
  // Lambda (DynamoDB scan) — keep it off the Postgres selection like the other
119
102
  // admin stores.
120
103
  QueryDbBackend.exempt(ReventlessCore.UiFragments.name)
121
- // The ApiFragments read model backs the Platform_ApiFragments status query
122
- // (DynamoDB scan) and the schema-push SideEffect — admin store, off Postgres too.
123
- QueryDbBackend.exempt(ReventlessCore.ApiFragmentsReadModelSpec.name)
124
104
  })
125
- let mergedApiMode = Config.mergedApi
126
-
127
105
  type api = Types.AppSync.api
128
106
  type role = Types.AppSync.role
129
107
  type apiTarget = Domain | Platform
@@ -141,228 +119,112 @@ module MakeWithConfig = (
141
119
  ->Pulumi.Config.get("stack")
142
120
  ->Option.map(stack => Pulumi.StackReference.make(stack))
143
121
 
144
- // Helper: build a phantom GraphQLApi.t from resolved id + endpoint strings.
145
- let makePhantomApi = (apiId, apiEndpoint): PulumiAws.AppSync.GraphQLApi.t => ({
146
- id: Pulumi.Output.make(apiId),
147
- arn: Pulumi.Output.make(""),
148
- name: Pulumi.Output.make(""),
149
- uris: Pulumi.Output.make(({graphQL: apiEndpoint, realtime: ""}: PulumiAws.AppSync.GraphQLApi.uris)),
150
- }: PulumiAws.AppSync.GraphQLApi.t)
151
-
152
- // Helper: build a phantom IAM.Role.t from a resolved role ARN.
153
- let makePhantomRole = (apiRoleArn): PulumiAws.IAM.Role.t => {
154
- let roleName =
155
- apiRoleArn->String.split("/")->Array.at(-1)->Option.getOr(apiRoleArn)
156
- ({
157
- arn: Pulumi.Output.make(apiRoleArn),
158
- id: Pulumi.Output.make(roleName),
159
- name: Pulumi.Output.make(roleName),
160
- }: PulumiAws.IAM.Role.t)
161
- }
162
-
163
122
  // ── Merged-mode source SDL assembly (merged-api plan, Phase 3) ────────────
164
123
  // Empty base fragment — no types, no mutations, no queries. Used by the
165
124
  // plugin Api in split mode (so plugin schema has no core fields) and as the
166
125
  // base of the split-mode Domain source document below.
167
- let emptyBaseFragment = ReventlessCore.GraphQL_Stitcher.encode({
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({
168
135
  types: [],
169
136
  mutations: [],
170
- queries: [],
137
+ queries: [" Platform_ping: String"],
171
138
  subscriptions: [],
172
139
  subscriptionSources: [],
173
140
  })
174
141
 
175
142
  // 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).
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).
181
147
  let assembleCanonicalSourceSdl = (~baseFragment): string =>
182
- AppSync_Adapter.stitchWithAwsDirectives(~baseFragment, ~pluginFragments=[])
148
+ AppSync_Adapter.stitchStandaloneWithAwsDirectives(~fragment=baseFragment)
183
149
  ->AppSync_SdlDecorate.stampCanonicalTypes
184
150
 
185
- // Admin base as a source-API document — same assembly as the push path's
186
- // preAdminResolversSchemaHook, plus the canonical stamp.
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.
187
154
  let adminSourceSdl = (): string =>
188
155
  assembleCanonicalSourceSdl(
189
156
  ~baseFragment=AppSync_Adapter.injectAwsAuthAll(
190
157
  ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
191
158
  ~group="Admin",
192
- ~iamFieldNames=ReventlessCore.AdminApi.systemCallerFieldNames,
193
159
  ),
194
160
  )
195
161
 
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)
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)
199
165
 
200
166
  let (domainApi, domainApiRole, platformApi, platformApiRole) = switch platformStackRef {
201
167
  | None =>
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={})
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()
213
174
  } else {
214
- AppSync_Adapter.makeApiResource(~name="DomainApi", ~opts={})
175
+ adminSourceSdl()
215
176
  }
177
+ let (api, role) = AppSync_Adapter.makeSourceApiResource(~name="DomainApi", ~schema, ~opts={})
216
178
  // In platform/monolithic mode the platform API is not yet known — it is created
217
179
  // during deployPlatform/makePlatform and the ref is updated there.
218
180
  (api, role, api, role)
219
181
  | Some(stackRef) =>
220
- // Plugin mode reconstruct phantom API/role from the platform's exported IDs.
221
- // 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.
222
189
  //
223
190
  // In ESM mode, Pulumi exports are inside the "default" output.
224
191
  // Try top-level field first (CJS), fall back to "default".<field> (ESM).
225
192
  let defaultOutput: Pulumi.Output.t<option<JSON.t>> =
226
193
  stackRef->Pulumi.StackReference.getOutput("default")
227
-
228
- // ── Domain API (application mutations) ──────────────────────────────────
229
- let domainApiIdOutput: Pulumi.Output.t<option<string>> =
230
- stackRef->Pulumi.StackReference.getOutput("domainApiId")
231
- let domainApiEndpointOutput: Pulumi.Output.t<option<string>> =
232
- stackRef->Pulumi.StackReference.getOutput("domainApiEndpoint")
233
- let domainApiRoleArnOutput: Pulumi.Output.t<option<string>> =
234
- stackRef->Pulumi.StackReference.getOutput("domainApiRoleArn")
235
-
236
- let phantomApi: Types.AppSync.api =
237
- (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)
238
200
  ->Pulumi.Output.all3
239
- ->Pulumi.Output.apply(((directId, directEndpoint, default)) => {
240
- let apiId = switch directId {
241
- | Some(id) => id
242
- | None =>
243
- default
244
- ->Option.flatMap(d => d->JSON.Decode.object)
245
- ->Option.flatMap(d => d->Dict.get("domainApiId"))
246
- ->Option.flatMap(v => v->JSON.Decode.string)
247
- ->Option.getOrThrow
248
- }
249
- let apiEndpoint = switch directEndpoint {
250
- | Some(ep) => ep
251
- | None =>
252
- default
253
- ->Option.flatMap(d => d->JSON.Decode.object)
254
- ->Option.flatMap(d => d->Dict.get("domainApiEndpoint"))
255
- ->Option.flatMap(v => v->JSON.Decode.string)
256
- ->Option.getOrThrow(
257
- ~message="Platform stack does not export 'domainApiEndpoint' — redeploy the platform stack first",
258
- )
259
- }
260
- makePhantomApi(apiId, apiEndpoint)
261
- })
262
- let phantomRole: Types.AppSync.role =
263
- (domainApiRoleArnOutput, defaultOutput)
264
- ->Pulumi.Output.all2
265
- ->Pulumi.Output.apply(((direct, default)) => {
266
- let domainApiRoleArn = switch direct {
267
- | Some(arn) => arn
268
- | None =>
269
- default
270
- ->Option.flatMap(d => d->JSON.Decode.object)
271
- ->Option.flatMap(d => d->Dict.get("domainApiRoleArn"))
272
- ->Option.flatMap(v => v->JSON.Decode.string)
273
- ->Option.getOrThrow
274
- }
275
- makePhantomRole(domainApiRoleArn)
276
- })
277
-
278
- // ── Platform API (admin / Platform_Sync* resolvers) ─────────────────────
279
- let platformApiIdOutput: Pulumi.Output.t<option<string>> =
280
- stackRef->Pulumi.StackReference.getOutput("platformApiId")
281
- let platformApiEndpointOutput: Pulumi.Output.t<option<string>> =
282
- stackRef->Pulumi.StackReference.getOutput("platformApiEndpoint")
283
- let platformApiRoleArnOutput: Pulumi.Output.t<option<string>> =
284
- stackRef->Pulumi.StackReference.getOutput("platformApiRoleArn")
285
-
286
- let phantomPlatformApi: Types.AppSync.api =
287
- (platformApiIdOutput, platformApiEndpointOutput, defaultOutput)
288
- ->Pulumi.Output.all3
289
- ->Pulumi.Output.apply(((directPlatId, directPlatEndpoint, default)) => {
290
- let getFromDefault = key =>
291
- default
292
- ->Option.flatMap(d => d->JSON.Decode.object)
293
- ->Option.flatMap(d => d->Dict.get(key))
294
- ->Option.flatMap(v => v->JSON.Decode.string)
295
- let platformApiId =
296
- directPlatId
297
- ->Option.orElse(getFromDefault("platformApiId"))
298
- ->Option.getOrThrow
299
- let platformApiEndpoint =
300
- directPlatEndpoint
301
- ->Option.orElse(getFromDefault("platformApiEndpoint"))
302
- ->Option.getOrThrow
303
- makePhantomApi(platformApiId, platformApiEndpoint)
304
- })
305
- let phantomPlatformRole: Types.AppSync.role =
306
- (platformApiRoleArnOutput, defaultOutput)
307
- ->Pulumi.Output.all2
308
- ->Pulumi.Output.apply(((directPlat, default)) => {
201
+ ->Pulumi.Output.apply(((directPoolId, directRegion, default)) => {
309
202
  let getFromDefault = key =>
310
203
  default
311
204
  ->Option.flatMap(d => d->JSON.Decode.object)
312
205
  ->Option.flatMap(d => d->Dict.get(key))
313
206
  ->Option.flatMap(v => v->JSON.Decode.string)
314
- let platformApiRoleArn =
315
- directPlat
316
- ->Option.orElse(getFromDefault("platformApiRoleArn"))
317
- ->Option.getOrThrow
318
- makePhantomRole(platformApiRoleArn)
319
- })
320
-
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
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",
355
212
  )
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
- }
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
+ )
221
+ })
222
+ let (api, role) = AppSync_Adapter.makePluginSourceApiResource(
223
+ ~name="PluginSourceApi",
224
+ ~userPoolConfig,
225
+ ~opts={},
226
+ )
227
+ (api, role, api, role)
366
228
  }
367
229
 
368
230
  // Expose api/apiRole as Platform.T value bindings so DCB slice builders
@@ -686,30 +548,8 @@ module MakeWithConfig = (
686
548
  ReventlessCore.UiFragments,
687
549
  ReventlessCore.UiFragments_Projection,
688
550
  )
689
- // The API-fragment registry is a SINGLETON AGGREGATE now (not a DCB slice) — see
690
- // ApiFragmentRegistryAggregate / ApiFragmentsReadModel below.
691
-
692
- // (emptyBaseFragment — the empty split-mode plugin base — is defined above,
693
- // next to the merged-mode source SDL assembly that shares it.)
694
-
695
- module Api = {
696
- module Make = (
697
- FragmentConfig: {
698
- let baseFragment: ReventlessInfra.Api.schemaFragment
699
- },
700
- ): ReventlessInfra.Api.T => {
701
- module Builder = ReventlessCore.Api_Builder.Make(AppSync_Adapter)
702
- // In split mode, the plugin API uses an empty base fragment so plugin schema
703
- // has no core fields. In unified mode, use the provided base fragment as-is.
704
- let effectiveBaseFragment = if Config.splitApi {
705
- emptyBaseFragment
706
- } else {
707
- FragmentConfig.baseFragment
708
- }
709
- let make = (~name, ~opts=?) =>
710
- Builder.make(~name, ~baseFragment=effectiveBaseFragment, ~opts?)
711
- }
712
- }
551
+ // (domainBaseFragment the split-mode Domain source base is defined above,
552
+ // next to the merged-mode source SDL assembly that uses it.)
713
553
 
714
554
  // ── Typed identity casts — see Platform_Casts.res for rationale ─────────
715
555
  open Platform_Casts
@@ -753,153 +593,6 @@ module MakeWithConfig = (
753
593
  | None => resolveHookedApi()
754
594
  }
755
595
 
756
- // ── Phase 3: staged-deploy API-fragment registration ────────────────────────
757
- // When a plugin deploys against an ALREADY-RUNNING platform (plugin-stack mode),
758
- // it registers its API-schema fragment with the platform via the Platform API
759
- // (SigV4 system caller) instead of writing deploy-schema:* rows and pushing the
760
- // schema itself. The platform-side reactive single writer (Plan 2e) stitches and
761
- // pushes; this waits on the push status so resolver creation (gated on the
762
- // returned Output) proceeds only once the plugin's fields are ACTIVE — or fails
763
- // with the stitch error. Used only in plugin-stack mode; makePlatform (all-at-once)
764
- // keeps the direct deploy-time push (the reactive writer is dormant there).
765
- @val external registerWaiterSetTimeout: (unit => unit, int) => unit = "setTimeout"
766
- let deployWaiterDelay = (ms: int): promise<unit> =>
767
- Promise.make((resolve, _) => registerWaiterSetTimeout(() => resolve(), ms))
768
-
769
- // Poll the platform's Platform_ApiFragments status query until the plugin's row
770
- // shows a push that landed at/after our registration time (`sinceAt`) — a fresh
771
- // "ok" means our fields are live; a fresh "error" surfaces the stitch failure. An
772
- // older "ok" is a stale prior deploy's and is ignored (ISO strings sort lexically).
773
- let waitForApiFragmentPush = async (~endpoint, ~region, ~pluginId, ~sinceAt) => {
774
- let query = "query { Platform_ApiFragments { pluginId pushStatus pushMessage pushedAt } }"
775
- let maxAttempts = 90 // ~3 min at 2s intervals
776
- let rec loop = async attempt =>
777
- if attempt >= maxAttempts {
778
- JsError.throwWithMessage(
779
- `Timed out (>3min) waiting for the platform to push ${pluginId}'s API fragment — check the AdminEventCollector Lambda logs.`,
780
- )
781
- } else {
782
- let data = await Util_AppSync_Caller.sendQuery(~endpoint, ~region, ~queryString=query)
783
- let row =
784
- data
785
- ->Option.flatMap(JSON.Decode.object)
786
- ->Option.flatMap(d => d->Dict.get("Platform_ApiFragments"))
787
- ->Option.flatMap(JSON.Decode.array)
788
- ->Option.getOr([])
789
- ->Array.find(entry =>
790
- entry
791
- ->JSON.Decode.object
792
- ->Option.flatMap(e => e->Dict.get("pluginId"))
793
- ->Option.flatMap(JSON.Decode.string) == Some(pluginId)
794
- )
795
- ->Option.flatMap(JSON.Decode.object)
796
- switch row {
797
- | Some(e) =>
798
- let field = k => e->Dict.get(k)->Option.flatMap(JSON.Decode.string)->Option.getOr("")
799
- let status = field("pushStatus")
800
- let fresh = field("pushedAt") >= sinceAt
801
- if fresh && status == "ok" {
802
- log.info(~comp="registerFragmentViaApi", `${pluginId} schema push confirmed ACTIVE`)
803
- } else if fresh && status == "error" {
804
- JsError.throwWithMessage(`Schema push failed for ${pluginId}: ${field("pushMessage")}`)
805
- } else {
806
- await deployWaiterDelay(2000)
807
- await loop(attempt + 1)
808
- }
809
- | None =>
810
- await deployWaiterDelay(2000)
811
- await loop(attempt + 1)
812
- }
813
- }
814
- await loop(0)
815
- }
816
-
817
- let registerFragmentViaApi = (
818
- ~name: string,
819
- ~fragment: Reventless.Plugin.apiSchemaFragment,
820
- ~apiTargetName: string,
821
- ): Pulumi.Output.t<unit> => {
822
- // Platform_RegisterApiFragment is an admin systemCallable mutation on the Platform
823
- // API (regardless of the plugin's apiTarget, which is passed as the enum argument).
824
- let platformApi = switch apiConfigRef.contents {
825
- | Some({platformApi}) => platformApi
826
- | None => domainApi
827
- }
828
- let region =
829
- Pulumi.Config.make(Some("aws"))->Pulumi.Config.get("region")->Option.getOr("unknown")
830
- let endpointOutput =
831
- platformApi
832
- ->Pulumi.Output.flatMap(api => api.uris)
833
- ->Pulumi.Output.apply(uris => uris.graphQL)
834
- // Deregister-on-destroy: a dynamic resource whose `delete` handler sends
835
- // Platform_DeregisterApiFragment when the plugin stack is destroyed (final
836
- // retirement). It never replaces on a version bump, so supersession does NOT
837
- // deregister — only a genuine `pulumi destroy` removes the fields.
838
- let _ = ApiFragmentDeregistration.make(
839
- ~name=`${name}ApiFragmentRegistration`,
840
- ~pluginId=name,
841
- ~endpoint=endpointOutput->Pulumi.Output.asInput,
842
- ~region,
843
- )
844
- endpointOutput
845
- ->Pulumi.Output.flatMap(endpoint => {
846
- let run = async () => {
847
- let at = Date.make()->Date.toISOString
848
- // The registry is a SINGLETON aggregate — the mutation `id` arg is the fixed
849
- // constant, `pluginId` (a payload field) carries the plugin name.
850
- let variables = {
851
- "id": "registry",
852
- "pluginId": name,
853
- "fragment": {"encoded": fragment.encoded, "protocol": fragment.protocol},
854
- "apiTarget": Util_AppSync_Caller.graphqlEnum(apiTargetName),
855
- "at": at,
856
- }
857
- let selection = "{ __typename ... on CommandAccepted { eventCount } ... on CommandRejected { errorCode errorDetail } }"
858
- log.info(
859
- ~comp="registerFragmentViaApi",
860
- `Registering API fragment for ${name} (${apiTargetName}) via ${endpoint}`,
861
- )
862
- let result = await Util_AppSync_Caller.sendMutation(
863
- ~endpoint,
864
- ~region,
865
- ~mutation="Platform_ApiFragmentRegistry_RegisterApiFragment",
866
- ~selection,
867
- ~variables,
868
- )
869
- let outcome =
870
- result
871
- ->Option.flatMap(JSON.Decode.object)
872
- ->Option.flatMap(d => d->Dict.get("Platform_ApiFragmentRegistry_RegisterApiFragment"))
873
- ->Option.flatMap(JSON.Decode.object)
874
- switch outcome {
875
- | Some(cmd) =>
876
- let typename =
877
- cmd->Dict.get("__typename")->Option.flatMap(JSON.Decode.string)->Option.getOr("")
878
- switch typename {
879
- | "CommandRejected" =>
880
- let code =
881
- cmd->Dict.get("errorCode")->Option.flatMap(JSON.Decode.string)->Option.getOr("unknown")
882
- JsError.throwWithMessage(`RegisterApiFragment rejected for ${name}: ${code}`)
883
- | "CommandAccepted" =>
884
- // eventCount 0 ⇒ idempotent no-op (unchanged fragment) — fields already live,
885
- // proceed without waiting; >0 ⇒ a real change, wait for the reactive push.
886
- let eventCount =
887
- cmd->Dict.get("eventCount")->Option.flatMap(JSON.Decode.float)->Option.getOr(0.0)
888
- if eventCount > 0.0 {
889
- await waitForApiFragmentPush(~endpoint, ~region, ~pluginId=name, ~sinceAt=at)
890
- } else {
891
- log.info(~comp="registerFragmentViaApi", `${name} fragment unchanged — no push needed`)
892
- }
893
- | _ => await waitForApiFragmentPush(~endpoint, ~region, ~pluginId=name, ~sinceAt=at)
894
- }
895
- | None =>
896
- // Unparseable result (mutation may have failed) — fall back to polling.
897
- await waitForApiFragmentPush(~endpoint, ~region, ~pluginId=name, ~sinceAt=at)
898
- }
899
- }
900
- run()->Pulumi.Output.fromPromise
901
- })
902
- }
903
596
 
904
597
  let hooks: ReventlessCore.Plugin_Helpers.platformHooks = {
905
598
  // AWS uses Interstack for admin extension points — leave ref at empty dict.
@@ -946,136 +639,40 @@ module MakeWithConfig = (
946
639
  // makePlatform / deployPlatform before Admin.construct fires, is visible):
947
640
  // - split mode: platformApi from splitApiOutputsRef
948
641
  // - unified mode / not-yet-populated: domainApi
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.
956
- adminBarrier
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 => {
1033
- log.info(
1034
- ~comp="preResolversSchemaHook",
1035
- `Pushing subgraph schema for ${name}@${version} to source API ${apiId}`,
1036
- )
1037
- let client = AppSync_Adapter.getClient()
1038
- client
1039
- ->AppSync_Adapter.startSchemaCreationRetrying({apiId, definition: sdl})
1040
- ->Promise.then(async _ => {
1041
- await AppSync_Adapter.waitForSchemaActive(client, apiId)
1042
- log.info(~comp="preResolversSchemaHook", "subgraph schema is ACTIVE")
1043
- })
1044
- ->Pulumi.Output.fromPromise
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 =>
658
+ api.id->Pulumi.Output.flatMap(apiId => {
659
+ log.info(
660
+ ~comp="preResolversSchemaHook",
661
+ `Pushing subgraph schema for ${name}@${version} to source API ${apiId}`,
662
+ )
663
+ let client = AppSync_Adapter.getClient()
664
+ client
665
+ ->AppSync_Adapter.startSchemaCreationRetrying({apiId, definition: sdl})
666
+ ->Promise.then(async _ => {
667
+ await AppSync_Adapter.waitForSchemaActive(client, apiId)
668
+ log.info(~comp="preResolversSchemaHook", "subgraph schema is ACTIVE")
1045
669
  })
1046
- )
1047
- mergedSchemaPushedRef := Some(pushed)
1048
- pushed
1049
- } else {
1050
- log.info(
1051
- ~comp="preResolversSchemaHook",
1052
- `Pushing schema for plugin ${name}@${version} to AppSync`,
1053
- )
1054
-
1055
- // Capture deploy target synchronously — deployPlugin resets currentDeployTarget to
1056
- // Domain after P.make() returns, before any Pulumi.Output async callbacks run.
1057
- let capturedDeployTarget = currentDeployTarget.contents
1058
-
1059
- switch platformStackRef {
1060
- | Some(_) =>
1061
- // Phase 3 — staged deploy against a running platform (deployPlugin): register the
1062
- // fragment via the Platform API; the reactive single writer (2e) stitches + pushes.
1063
- let apiTargetName = switch capturedDeployTarget {
1064
- | Domain => "Domain"
1065
- | Platform => "Platform"
1066
- }
1067
- registerFragmentViaApi(~name, ~fragment=pluginFragment, ~apiTargetName)
1068
- | None =>
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).",
670
+ ->Pulumi.Output.fromPromise
671
+ })
1076
672
  )
1077
- }
1078
- },
673
+ mergedSchemaPushedRef := Some(pushed)
674
+ pushed
675
+ },
1079
676
  // DCB EventLog created hook — extracts DynamoDB table name for DCB CommandTopic Lambda handler.
1080
677
  // Postgres-backed DCB logs (B2.3c) create no table, so there is no resource to read:
1081
678
  // the DCB command Lambda derives its `dcb_event.log_name` from the plugin name and
@@ -1258,49 +855,6 @@ module MakeWithConfig = (
1258
855
  PluginReadModelMappings,
1259
856
  )
1260
857
 
1261
- // Admin-internal ApiFragmentRegistry singleton aggregate — the platform API-schema
1262
- // fragment registry (docs/plans/event-sourced-fragment-registries.md). Threaded into
1263
- // Admin.construct's ~aggregates so registerAdminAggregateMutations wires
1264
- // Platform_ApiFragmentRegistry_RegisterApiFragment / _DeregisterApiFragment via the
1265
- // standard CommandGenerator auto-flow (RecordApiFragmentPush carries `@noApi`). IAM
1266
- // dual-auth for the SigV4 deploy caller comes from AdminApi.systemCallerFieldNames.
1267
- module ApiFragmentRegistryAggregate: (
1268
- ReventlessInfra.Aggregate.T with type api = Types.AppSync.api
1269
- ) = Aggregate_Builder_Single.Make(
1270
- ReventlessCore.ApiFragmentRegistrySpec,
1271
- ReventlessCore.ApiFragmentRegistryBehavior,
1272
- ReventlessInfra.NoEventMappings.Make(ReventlessCore.ApiFragmentRegistrySpec),
1273
- )
1274
-
1275
- // Admin ApiFragments read model — per-plugin status rows off the aggregate's event
1276
- // topic (DynamoDB stream), backing the Platform_ApiFragments status query.
1277
- module ApiFragmentsReadModelMappings: Reventless.Projection.Mappings
1278
- with module Target := ReventlessCore.ApiFragmentsReadModelSpec = {
1279
- module M = ReventlessCore.ApiFragmentsProjection.Mappings
1280
- module type Mapping = M.Mapping
1281
- let moduleUrl: string = ReventlessCore.ApiFragmentsProjection.moduleUrl
1282
- let mappings: array<module(Mapping)> = ReventlessCore.ApiFragmentsProjection.mappings
1283
- }
1284
- // NoResolver builder: the ApiFragments RM's query surface is the dedicated
1285
- // Platform_ApiFragments Lambda (declared in baseFragment), NOT an auto-generated
1286
- // Connection resolver — so it must not emit AppSync query resolvers (they'd orphan
1287
- // against the static pushed baseFragment: "No field named apiFragments on type Query").
1288
- // Still stream-projects the table from the aggregate's event stream. Mirrors local's
1289
- // MakeNoResolver.
1290
- module ApiFragmentsReadModel = ReadModel_Builder_NoResolver_Stream.Make(
1291
- ReventlessCore.ApiFragmentsReadModelSpec,
1292
- ApiFragmentsReadModelMappings,
1293
- )
1294
-
1295
- // Admin SideEffectHandler hosting the reactive schema-push side effect (ApiSchemaPush).
1296
- // Subscribes to the ApiFragmentRegistry aggregate's DynamoDB stream (single-shard for a
1297
- // singleton → naturally serialized, no concurrent StartSchemaCreation) and, on each
1298
- // ApiSchemaComputed, stitches + pushes the schema per target API, then records the
1299
- // outcome via RecordApiFragmentPush. Wired in deployPlatform (the staged path where the
1300
- // deploy caller fires RegisterApiFragment); makePlatform builds the schema directly and
1301
- // never triggers it.
1302
- module AdminApiSchemaPushHandler = SideEffectHandler_Single.Make()
1303
-
1304
858
  module type PluginMaker = {
1305
859
  let make: unit => Plugin.component
1306
860
  }
@@ -1429,193 +983,14 @@ module MakeWithConfig = (
1429
983
  // In unified mode, makePlatform is a no-op (schema stitching handled by events).
1430
984
  let makePlatform = (~version, ~plugins: array<module(PluginMaker)>) => {
1431
985
  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
- }
1440
- // Create scheduler and populate platform context refs so Plugin_Builder
1441
- // can read them without app plugins having to pass them through.
1442
- let scheduler = makeScheduler()
1443
- hooks.scheduler := Some(scheduler)
1444
- hooks.api := Some(domainApi->wrapHookedValue)
1445
- hooks.apiRole := Some(domainApiRole->wrapHookedValue)
1446
-
1447
- // Phase 2: create the Platform API resource early — before Admin.construct —
1448
- // so admin resolvers are attached to the correct API in split mode.
1449
- // In unified mode this is the same resource as the Domain API.
1450
- let (platformApi, platformApiRole) = if Config.splitApi {
1451
- AppSync_Adapter.makeApiResource(~name="PlatformApi", ~opts={})
1452
- } else {
1453
- (domainApi, domainApiRole)
1454
- }
1455
-
1456
- // Admin DCB mutation resolvers bind to the Platform API (split mode) or the shared api
1457
- // (unified). Recorded now that the Platform API resource exists, so the admin's deferred
1458
- // dcbConnectFn (via ~onAdminApi) targets it rather than the Domain/deploy-target hooksApiRef.
1459
- hooks.adminApi := Some(platformApi->wrapHookedValue)
1460
-
1461
- // Update splitApiOutputsRef and apiConfig with the now-known Platform API.
1462
- if Config.splitApi {
1463
- splitApiOutputsRef := Some({platformApi, platformApiRole})
1464
- switch apiConfigRef.contents {
1465
- | Some(c) =>
1466
- apiConfigRef := Some({
1467
- domainApi: c.domainApi,
1468
- domainApiRole: c.domainApiRole,
1469
- platformApi: platformApi,
1470
- platformApiRole: platformApiRole,
1471
- })
1472
- | None => ()
1473
- }
1474
- }
1475
-
1476
- // Phase 2: Admin resolvers go on the Platform API (platformApi) in split mode,
1477
- // on the shared Domain API in unified mode.
1478
- let admin = Admin.construct(
1479
- ~version,
1480
- ~extensionPoints=[],
1481
- ~aggregates=[module(PluginAggregate), module(ApiFragmentRegistryAggregate)],
1482
- ~readModels=[module(PluginReadModel), module(ApiFragmentsReadModel)],
1483
- ~scheduler,
1484
- ~resourceNaming=Util_ResourceNaming.operations,
1485
- ~api=platformApi,
1486
- ~apiRole=platformApiRole,
1487
- ~stateChangeSlices=[module(UiFragmentRegistrySlice)],
1488
- ~stateViewSlices=[module(UiFragmentsViewSlice)],
1489
- ~automationSlices=[],
1490
- ~outboundTranslationSlices=[],
1491
- ~inboundTranslationSlices=[],
1492
- )
1493
-
1494
- // Mount the Platform_ComponentDefinitions Lambda resolver on the Platform API
1495
- // (split mode) or Domain API (unified mode — platformApi == domainApi above).
1496
- // Also register the Plugin RM table with the AllAggregates Lambda runtime
1497
- // so its in-Lambda plugin status gate can read plugin status at command
1498
- // dispatch time (Part 2.3 of the resolver plan).
1499
- switch admin.readModelsOutputs->Dict.get("Plugins") {
1500
- | Some(pluginRm) =>
1501
- switch pluginRm.queryDb.resources->Array.get(0) {
1502
- | Some(r) =>
1503
- Platform_ComponentDefinitions_Lambda.make(
1504
- ~api=platformApi,
1505
- ~pluginReadModelTableName=r.name,
1506
- ~opts={},
1507
- )
1508
- AggregateRuntime_Builder_Single.setPluginReadModelTable(~name=r.name)
1509
- | None => ()
1510
- }
1511
- | None => ()
1512
- }
1513
-
1514
- // Mount the Platform_UIFragments Lambda resolver — scans the UiFragments
1515
- // StateViewSlice table provisioned above and returns one entry per registered
1516
- // plugin UI.
1517
- switch admin.stateViewSlicesOutputs->Dict.get("UiFragments") {
1518
- | Some(rm) =>
1519
- switch rm.queryDb.resources->Array.get(0) {
1520
- | Some(r) =>
1521
- Platform_UIFragments_Lambda.make(
1522
- ~api=platformApi,
1523
- ~uiFragmentRegistryTableName=r.name,
1524
- ~schemaReady=admin.adminSchemaPushed,
1525
- ~opts={},
1526
- )
1527
- | None => ()
1528
- }
1529
- | None => ()
1530
- }
1531
-
1532
- // Mount the Platform_ApiFragments Lambda resolver — scans the ApiFragments
1533
- // StateViewSlice table and returns the push-status row per plugin (the deploy
1534
- // waiter polls this). The query field is in the pushed admin base but was
1535
- // unresolved on AWS until now.
1536
- switch admin.readModelsOutputs->Dict.get("ApiFragments") {
1537
- | Some(rm) =>
1538
- switch rm.queryDb.resources->Array.get(0) {
1539
- | Some(r) =>
1540
- Platform_ApiFragments_Lambda.make(
1541
- ~api=platformApi,
1542
- ~apiFragmentRegistryTableName=r.name,
1543
- ~schemaReady=admin.adminSchemaPushed,
1544
- ~opts={},
1545
- )
1546
- | None => ()
1547
- }
1548
- | None => ()
1549
- }
1550
-
1551
- // Build each plugin.
1552
- let pluginComponents = plugins->Array.map(plugin => {
1553
- module P = unpack(plugin)
1554
- P.make()
1555
- })
1556
-
1557
- // Note: StateTopic_AppSync.finish runs from inside subscriptionInfraHook
1558
- // (Phase 4 wiring above), not here — Plugin_Builder fires the hook from
1559
- // within a Pulumi.Output.apply chain, so finishing synchronously here would
1560
- // see an empty registry.
1561
-
1562
- // Export first plugin's outputs (monolithic mode = typically single plugin).
1563
- switch pluginComponents->Array.get(0) {
1564
- | Some(pluginComponent) =>
1565
- let pluginOutputs = pluginComponent->ReventlessCore.Component.outputs
1566
- ReventlessCore.Plugin_Helpers.exportPluginOutputs(pluginOutputs)
1567
- | None => ()
1568
- }
1569
-
1570
- // B2.3d: provision the Postgres change-feed relay (monolithic mode — all plugins
1571
- // built above, so the DcbBackend relay registry is complete).
1572
- provisionPgChangeFeedRelay()
1573
-
1574
- // B3.2b: provision the shared PgQueryResolver Lambda + AppSync data source for
1575
- // Postgres-backed read models (monolithic mode). Runs after construct so the
1576
- // resolver-binding registry (QueryDbResolvers_Lambda.make) and the ReadModel
1577
- // spec-module registry are complete; it fulfils the deferred `dataSourceName`
1578
- // the Postgres storage maker handed to the (schema-pushed) resolvers. App read
1579
- // model resolvers attach to the Domain API. Plugin-stack mode is deferred —
1580
- // it needs a per-stack data-source name to avoid collisions on the shared API.
1581
- switch QueryDbBackend.get() {
1582
- | Some(sel) => PgQueryResolver_Builder.provision(~api=domainApi, ~selection=sel, ~opts={})
1583
- | None => ()
1584
- }
1585
-
1586
- // Admin schema push is fired by preAdminResolversSchemaHook from inside
1587
- // Admin.construct (with createResolvers gated on it). Only exports below.
1588
- if Config.splitApi {
1589
- // Platform API exports (split mode).
1590
- Pulumi.Pulumi.export("platformApiId", platformApi->Pulumi.Output.flatMap(api => api.id))
1591
- Pulumi.Pulumi.export(
1592
- "platformApiEndpoint",
1593
- platformApi->Pulumi.Output.flatMap(api =>
1594
- api.uris->Pulumi.Output.apply(uris => uris.graphQL)
1595
- ),
1596
- )
1597
- Pulumi.Pulumi.export("platformApiRoleArn", platformApiRole->Pulumi.Output.flatMap(role => role.arn))
1598
- } else {
1599
- // Platform API exports (unified mode — same resource as Domain API).
1600
- Pulumi.Pulumi.export("platformApiId", domainApi->Pulumi.Output.flatMap(api => api.id))
1601
- Pulumi.Pulumi.export(
1602
- "platformApiEndpoint",
1603
- domainApi->Pulumi.Output.flatMap(api =>
1604
- api.uris->Pulumi.Output.apply(uris => uris.graphQL)
1605
- ),
1606
- )
1607
- Pulumi.Pulumi.export("platformApiRoleArn", domainApiRole->Pulumi.Output.flatMap(role => role.arn))
1608
- }
1609
-
1610
- // Domain API exports.
1611
- Pulumi.Pulumi.export("domainApiId", domainApi->Pulumi.Output.flatMap(api => api.id))
1612
- Pulumi.Pulumi.export(
1613
- "domainApiEndpoint",
1614
- domainApi->Pulumi.Output.flatMap(api =>
1615
- api.uris->Pulumi.Output.apply(uris => uris.graphQL)
1616
- ),
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).",
1617
993
  )
1618
- Pulumi.Pulumi.export("domainApiRoleArn", domainApiRole->Pulumi.Output.flatMap(role => role.arn))
1619
994
  }
1620
995
 
1621
996
  // Optional host UI shell bundle: a static SPA (e.g. reventless-ui's host-shell)
@@ -1652,11 +1027,7 @@ module MakeWithConfig = (
1652
1027
  // On the merge path the Platform API is an ordinary GRAPHQL source API
1653
1028
  // carrying the admin canonical document declaratively.
1654
1029
  let (platformApi, platformApiRole) = if Config.splitApi {
1655
- if mergedApiMode {
1656
- AppSync_Adapter.makeSourceApiResource(~name="PlatformApi", ~schema=adminSourceSdl(), ~opts={})
1657
- } else {
1658
- AppSync_Adapter.makeApiResource(~name="PlatformApi", ~opts={})
1659
- }
1030
+ AppSync_Adapter.makeSourceApiResource(~name="PlatformApi", ~schema=adminSourceSdl(), ~opts={})
1660
1031
  } else {
1661
1032
  (domainApi, domainApiRole)
1662
1033
  }
@@ -1666,7 +1037,7 @@ module MakeWithConfig = (
1666
1037
  // Plugin stacks associate their own source APIs against the exported
1667
1038
  // merged-API ARN (Phase 4); `pulumi destroy` of a plugin stack deletes
1668
1039
  // its association — retirement by construction.
1669
- let mergedOutputs = if mergedApiMode {
1040
+ let mergedOutputs = {
1670
1041
  AppSync_MergedApi.assertCompatiblePrimaryAuth(
1671
1042
  ~sourceMode=AppSync_MergedApi.authenticationTypeName(
1672
1043
  AppSync_Adapter.primaryAuthenticationType,
@@ -1718,8 +1089,6 @@ module MakeWithConfig = (
1718
1089
  platformMergeGate: gate,
1719
1090
  })
1720
1091
  }
1721
- } else {
1722
- None
1723
1092
  }
1724
1093
 
1725
1094
  // Admin DCB mutation resolvers bind to the Platform API (split mode) or the shared api
@@ -1759,8 +1128,8 @@ module MakeWithConfig = (
1759
1128
  // (Heartbeat, ForwardCommand). None here keeps the deploy-time path
1760
1129
  // unchanged; the .mjs entry point supplies a real implementation.
1761
1130
  let manageSubscriptions = None
1762
- // Runtime connect-driven schema self-heal retired in Phase 4bthe reactive
1763
- // 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.
1764
1133
  let updateApiSchema = None
1765
1134
  })
1766
1135
 
@@ -1769,8 +1138,8 @@ module MakeWithConfig = (
1769
1138
  let admin = Admin.construct(
1770
1139
  ~version,
1771
1140
  ~extensionPoints=[module(PluginExtensionPoint)],
1772
- ~aggregates=[module(PluginAggregate), module(ApiFragmentRegistryAggregate)],
1773
- ~readModels=[module(PluginReadModel), module(ApiFragmentsReadModel)],
1141
+ ~aggregates=[module(PluginAggregate)],
1142
+ ~readModels=[module(PluginReadModel)],
1774
1143
  ~scheduler,
1775
1144
  ~resourceNaming=Util_ResourceNaming.operations,
1776
1145
  ~api=platformApi,
@@ -1782,79 +1151,6 @@ module MakeWithConfig = (
1782
1151
  ~inboundTranslationSlices=[],
1783
1152
  )
1784
1153
 
1785
- // Reactive schema-push SideEffect (docs/plans/event-sourced-fragment-registries.md §
1786
- // Reactive writer design). Host ApiSchemaPush on an admin SideEffectHandler subscribed to
1787
- // the ApiFragmentRegistry aggregate's event topic (its DynamoDB stream). Deploy-derived
1788
- // config the runtime-pure push reads at invocation time is injected as Lambda env
1789
- // (~extraEnvVars); ~targets grants SQS send to the aggregate command topic (for the
1790
- // RecordApiFragmentPush write-back). Only in deployPlatform (the staged path where the
1791
- // deploy caller fires RegisterApiFragment); makePlatform pushes the schema directly.
1792
- let apiSchemaPushEventTopics = ReventlessCore.Aggregate.allEventTopics(admin.aggregatesOutputs)
1793
- let apiSchemaPushCmdTopics = ReventlessCore.Aggregate.allCommandTopics(admin.aggregatesOutputs)
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
- }
1811
- )
1812
- | None => Pulumi.Output.make("")
1813
- }
1814
- let apiSchemaPushEnv = Dict.fromArray([
1815
- ("API_SCHEMA_PUSH_DOMAIN_API_ID", domainApiId->Pulumi.Output.asInput),
1816
- (
1817
- "API_SCHEMA_PUSH_PLATFORM_API_ID",
1818
- platformApi->Pulumi.Output.flatMap(api => api.id)->Pulumi.Output.asInput,
1819
- ),
1820
- (
1821
- "API_SCHEMA_PUSH_SPLIT_API",
1822
- Pulumi.Output.make(Config.splitApi ? "true" : "false")->Pulumi.Output.asInput,
1823
- ),
1824
- (
1825
- "API_SCHEMA_PUSH_CLONER",
1826
- Pulumi.Output.make(Config.cloner ? "true" : "false")->Pulumi.Output.asInput,
1827
- ),
1828
- ("API_SCHEMA_PUSH_CMD_TOPIC_URL", apiSchemaPushCmdTopicUrl->Pulumi.Output.asInput),
1829
- ])
1830
- // ApiSchemaPush ignores the injected queryEngine (it self-wires push + write-back from
1831
- // env); an empty one satisfies the SideEffectHandler contract. `scheduler`/`queryEngine`
1832
- // are Outputs, so make() runs inside their apply; finish() (which builds the shared
1833
- // Lambda from the registered handlers) is registered on apiSchemaPushCmdTopics
1834
- // immediately after make() so it runs AFTER make's own deferred forEventCollector apply
1835
- // on that same Output (Pulumi runs same-Output apply callbacks in registration order).
1836
- let apiSchemaPushQueryEngine = QueryEngine.DynamoDb.make(Dict.make())
1837
- let _ =
1838
- (scheduler, apiSchemaPushQueryEngine)
1839
- ->Pulumi.Output.all2
1840
- ->Pulumi.Output.apply(((sched, qe)) => {
1841
- let _ = AdminApiSchemaPushHandler.make(
1842
- ~name="AdminApiSchemaPush",
1843
- ~sideEffects=[module(ApiSchemaPush)],
1844
- ~allEventTopics=apiSchemaPushEventTopics,
1845
- ~allCommandTopics=apiSchemaPushCmdTopics,
1846
- ~targets=[ReventlessCore.ApiFragmentRegistrySpec.name],
1847
- ~queryEngine=qe,
1848
- ~scheduler=sched,
1849
- ~resourceNaming=Util_ResourceNaming.operations,
1850
- ~extraEnvVars=apiSchemaPushEnv,
1851
- ~opts={},
1852
- )
1853
- let _ = apiSchemaPushCmdTopics->Pulumi.Output.apply(_ =>
1854
- SideEffectHandlerRuntime_Builder_Single.finish()
1855
- )
1856
- })
1857
-
1858
1154
  // Extract Plugin RM table name as Output.t<string>.
1859
1155
  // IMPORTANT: Do NOT use option<Pulumi.Output.t<…>> — Pulumi Outputs use property
1860
1156
  // lifting, which breaks ReScript's internal option encoding (BS_PRIVATE_NESTED_SOME_NONE).
@@ -1940,25 +1236,6 @@ module MakeWithConfig = (
1940
1236
  | None => ()
1941
1237
  }
1942
1238
 
1943
- // Mount the Platform_ApiFragments Lambda resolver — scans the ApiFragments
1944
- // StateViewSlice table and returns the push-status row per plugin (the deploy
1945
- // waiter polls this). The query field is in the pushed admin base but was
1946
- // unresolved on AWS until now.
1947
- switch admin.readModelsOutputs->Dict.get("ApiFragments") {
1948
- | Some(rm) =>
1949
- switch rm.queryDb.resources->Array.get(0) {
1950
- | Some(r) =>
1951
- Platform_ApiFragments_Lambda.make(
1952
- ~api=platformApi,
1953
- ~apiFragmentRegistryTableName=r.name,
1954
- ~schemaReady=admin.adminSchemaPushed,
1955
- ~opts={},
1956
- )
1957
- | None => ()
1958
- }
1959
- | None => ()
1960
- }
1961
-
1962
1239
  // Note: StateTopic_AppSync.finish runs from inside subscriptionInfraHook
1963
1240
  // (Phase 4 wiring above), not here — see the note in deployPlatform.
1964
1241
 
@@ -2314,8 +1591,8 @@ module MakeWithConfig = (
2314
1591
  // 409s (AWS serializes association creates per merged API) surface as a
2315
1592
  // deploy failure — retry concurrent FIRST-TIME plugin deploys; steady-
2316
1593
  // state schema updates never re-create the association.
2317
- switch (mergedApiMode, platformStackRef) {
2318
- | (true, Some(stackRef)) =>
1594
+ switch platformStackRef {
1595
+ | Some(stackRef) =>
2319
1596
  let defaultOutput: Pulumi.Output.t<option<JSON.t>> =
2320
1597
  stackRef->Pulumi.StackReference.getOutput("default")
2321
1598
  let getMergedExport = (key: string): Pulumi.Output.t<string> => {
@@ -2402,19 +1679,10 @@ module MakeWithConfig = (
2402
1679
  provisionPgChangeFeedRelay()
2403
1680
 
2404
1681
  // B3.2b/c: provision this plugin stack's PgQueryResolver Lambda + AppSync data
2405
- // source for its Postgres-backed read models (registered during P.make()). App
2406
- // read model resolvers attach to the Domain API. Per-plugin Lambda + auto-named
2407
- // data source, so plugin stacks don't collide. The node(id) resolver is skipped
2408
- // in plugin-stack mode — it's a single shared Query.node field only one stack
2409
- // 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.
2410
1684
  switch QueryDbBackend.get() {
2411
- | Some(sel) =>
2412
- PgQueryResolver_Builder.provision(
2413
- ~api=domainApi,
2414
- ~selection=sel,
2415
- ~opts={},
2416
- ~createNodeResolver=false,
2417
- )
1685
+ | Some(sel) => PgQueryResolver_Builder.provision(~api=domainApi, ~selection=sel, ~opts={})
2418
1686
  | None => ()
2419
1687
  }
2420
1688
 
@@ -2475,7 +1743,6 @@ module Make = (): (
2475
1743
  ) => {
2476
1744
  include MakeWithConfig({
2477
1745
  let splitApi = true
2478
- let mergedApi = true
2479
1746
  let cloner = false
2480
1747
  let commandHandlerConfig: ReventlessCore.Runtime.commandHandlerConfigs = {}
2481
1748
  let pgConnection = None