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