1import * as appsync from "@distilled.cloud/aws/appsync";
2import * as Data from "effect/Data";
3import type * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { deepEqual, isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
13import { toWireMillis, toWireSeconds } from "../../Util/Duration.ts";
14import { AWSEnvironment } from "../Environment.ts";
15import type { Providers } from "../Providers.ts";
16import {
17 retryConcurrentModification,
18 syncAppSyncTags,
19 tagRecord,
20} from "./common.ts";
21
22/**
23 * The primary authentication mode of a GraphQL API.
24 */
25export type AuthenticationType =
26 | "API_KEY"
27 | "AWS_IAM"
28 | "AMAZON_COGNITO_USER_POOLS"
29 | "OPENID_CONNECT"
30 | "AWS_LAMBDA";
31
32export interface UserPoolAuthConfig {
33 /** The Cognito user pool ID. */
34 userPoolId: string;
35 /**
36 * The region the user pool lives in.
37 * @default the ambient AWS region
38 */
39 awsRegion?: string;
40 /**
41 * What to do for requests that don't match an `appIdClientRegex`.
42 * @default "ALLOW"
43 */
44 defaultAction?: "ALLOW" | "DENY";
45 /** Regular expression matching allowed user pool app client IDs. */
46 appIdClientRegex?: string;
47}
48
49export interface LambdaAuthorizerConfig {
50 /**
51 * The ARN of the Lambda authorizer function (or an alias/version
52 * qualified ARN). AppSync must be allowed to invoke it — attach a
53 * `Lambda.Permission` with principal `appsync.amazonaws.com`.
54 */
55 authorizerUri: string;
56 /**
57 * How long AppSync caches an authorizer response, e.g. `"5 minutes"` or
58 * `Duration.seconds(300)` (0 disables caching, max 1 hour). Sent to AWS
59 * as whole seconds.
60 * @default "300 seconds"
61 */
62 authorizerResultTtl?: Duration.Input;
63 /** Regular expression the authorization token must match before invoking. */
64 identityValidationExpression?: string;
65}
66
67export interface OpenIDConnectAuthConfig {
68 /** The OIDC issuer URL. */
69 issuer: string;
70 /** The client identifier the token audience must match. */
71 clientId?: string;
72 /** How long a token is valid after `iat`, e.g. `"1 hour"`. */
73 iatTTL?: Duration.Input;
74 /** How long a token is valid after `auth_time`, e.g. `"1 hour"`. */
75 authTTL?: Duration.Input;
76}
77
78export interface AdditionalAuthProvider {
79 /** The authentication mode of this additional provider. */
80 authenticationType: AuthenticationType;
81 /** Cognito user pool config (for `AMAZON_COGNITO_USER_POOLS`). */
82 userPoolConfig?: Omit<UserPoolAuthConfig, "defaultAction">;
83 /** OIDC config (for `OPENID_CONNECT`). */
84 openIDConnectConfig?: OpenIDConnectAuthConfig;
85 /** Lambda authorizer config (for `AWS_LAMBDA`). */
86 lambdaAuthorizerConfig?: LambdaAuthorizerConfig;
87}
88
89export interface ApiCacheConfig {
90 /**
91 * The cache instance type, e.g. `"SMALL"`, `"MEDIUM"`, `"LARGE"` (or the
92 * legacy `T2_SMALL`-style types).
93 */
94 type: appsync.ApiCacheType;
95 /**
96 * Caching behavior: `"FULL_REQUEST_CACHING"` caches every resolver,
97 * `"PER_RESOLVER_CACHING"` caches only resolvers that opt in.
98 */
99 behavior: appsync.ApiCachingBehavior;
100 /** TTL for cache entries, e.g. `"60 seconds"` (1 second–1 hour). */
101 ttl: Duration.Input;
102 /** Encrypt cache entries in transit. Immutable after creation. */
103 transitEncryptionEnabled?: boolean;
104 /** Encrypt cache entries at rest. Immutable after creation. */
105 atRestEncryptionEnabled?: boolean;
106 /**
107 * Whether cache health metrics are emitted to CloudWatch.
108 * @default "DISABLED"
109 */
110 healthMetricsConfig?: appsync.CacheHealthMetricsConfig;
111}
112
113export interface GraphqlApiProps {
114 /**
115 * Name of the API. If omitted, a deterministic physical name is
116 * generated from the app, stage, and logical ID. Names are mutable.
117 */
118 name?: string;
119 /**
120 * The primary authentication mode.
121 * @default "API_KEY"
122 */
123 authenticationType?: AuthenticationType;
124 /**
125 * The GraphQL schema (SDL string). Applied via `startSchemaCreation`
126 * and awaited until the schema is active. Resolvers can only be
127 * attached to types/fields that exist in this schema.
128 */
129 schema?: string;
130 /**
131 * Cognito user pool config — required when `authenticationType` is
132 * `AMAZON_COGNITO_USER_POOLS`.
133 */
134 userPoolConfig?: UserPoolAuthConfig;
135 /**
136 * OIDC config — required when `authenticationType` is `OPENID_CONNECT`.
137 */
138 openIDConnectConfig?: OpenIDConnectAuthConfig;
139 /**
140 * Lambda authorizer config — required when `authenticationType` is
141 * `AWS_LAMBDA`.
142 */
143 lambdaAuthorizerConfig?: LambdaAuthorizerConfig;
144 /**
145 * Additional authentication modes beyond the primary one.
146 */
147 additionalAuthenticationProviders?: AdditionalAuthProvider[];
148 /**
149 * CloudWatch Logs configuration. Requires a role AppSync can assume
150 * with log-delivery permissions.
151 */
152 logConfig?: appsync.LogConfig;
153 /**
154 * Whether AWS X-Ray tracing is enabled.
155 * @default false
156 */
157 xrayEnabled?: boolean;
158 /**
159 * `GLOBAL` (public endpoint) or `PRIVATE` (VPC-only). Immutable —
160 * changing it triggers a replacement.
161 * @default "GLOBAL"
162 */
163 visibility?: "GLOBAL" | "PRIVATE";
164 /**
165 * `GRAPHQL` (standard) or `MERGED` (merged API). Immutable — changing
166 * it triggers a replacement.
167 * @default "GRAPHQL"
168 */
169 apiType?: "GRAPHQL" | "MERGED";
170 /**
171 * Whether introspection queries are allowed.
172 * @default "ENABLED"
173 */
174 introspectionConfig?: "ENABLED" | "DISABLED";
175 /** Maximum depth a query is allowed to reach (0 disables the limit). */
176 queryDepthLimit?: number;
177 /** Maximum number of resolvers a query may invoke (0 disables the limit). */
178 resolverCountLimit?: number;
179 /**
180 * Server-side API cache. **Cache instances bill hourly** while
181 * provisioned; omit to run without a cache. Provisioning is
182 * asynchronous (~10–20 minutes to reach `AVAILABLE`); reconcile does
183 * not wait for it.
184 */
185 cache?: ApiCacheConfig;
186 /**
187 * Environment variables exposed to `APPSYNC_JS` resolver and function
188 * code via `ctx.env`. The whole map is replaced on each change; pass
189 * `{}` to clear all variables. When omitted, environment variables are
190 * left unmanaged.
191 */
192 environmentVariables?: Record<string, string>;
193 /**
194 * Tags to apply to the API. Merged with internal Alchemy tags.
195 */
196 tags?: Record<string, string>;
197}
198
199export interface GraphqlApi extends Resource<
200 "AWS.AppSync.GraphqlApi",
201 GraphqlApiProps,
202 {
203 /** The unique API ID. */
204 apiId: string;
205 /** The API's ARN. */
206 apiArn: string;
207 /** The API name. */
208 name: string;
209 /** The GraphQL endpoint (`https://{id}.appsync-api.{region}.amazonaws.com/graphql`). */
210 graphqlUrl: string;
211 /** The real-time (WebSocket subscriptions) endpoint. */
212 realtimeUrl: string | undefined;
213 /** The primary authentication mode. */
214 authenticationType: AuthenticationType;
215 },
216 never,
217 Providers
218> {}
219
220/**
221 * An AWS AppSync GraphQL API.
222 *
223 * Owns the API, its SDL schema (applied via `startSchemaCreation` and
224 * awaited until active), its authentication modes, and an optional
225 * server-side cache. Pair with {@link DataSource}, {@link Resolver},
226 * and {@link ApiKey} to serve GraphQL over Lambda or DynamoDB.
227 * ### Creating a GraphQL API
228 * **Example:** API-key authenticated API with a schema
229 * ```typescript
230 * import * as AppSync from "alchemy/AWS/AppSync";
231 *
232 * const api = yield* AppSync.GraphqlApi("Api", {
233 * schema: `
234 * type Query { hello: String! }
235 * schema { query: Query }
236 * `,
237 * });
238 * const key = yield* AppSync.ApiKey("Key", { api });
239 * ```
240 *
241 * ### Authentication Modes
242 * **Example:** Lambda authorizer
243 * ```typescript
244 * const api = yield* AppSync.GraphqlApi("Api", {
245 * authenticationType: "AWS_LAMBDA",
246 * lambdaAuthorizerConfig: { authorizerUri: authorizer.functionArn },
247 * schema,
248 * });
249 * // AppSync must be allowed to invoke the authorizer:
250 * yield* AWS.Lambda.Permission("AppSyncInvoke", {
251 * functionName: authorizer.functionName,
252 * principal: "appsync.amazonaws.com",
253 * action: "lambda:InvokeFunction",
254 * sourceArn: api.apiArn,
255 * });
256 * ```
257 *
258 * **Example:** Cognito user pools as an additional auth mode
259 * ```typescript
260 * const api = yield* AppSync.GraphqlApi("Api", {
261 * authenticationType: "API_KEY",
262 * additionalAuthenticationProviders: [
263 * {
264 * authenticationType: "AMAZON_COGNITO_USER_POOLS",
265 * userPoolConfig: { userPoolId: pool.userPoolId },
266 * },
267 * ],
268 * schema,
269 * });
270 * ```
271 *
272 * ### Caching
273 * **Example:** Full-request server-side cache (bills hourly!)
274 * ```typescript
275 * const api = yield* AppSync.GraphqlApi("Api", {
276 * schema,
277 * cache: { type: "SMALL", behavior: "FULL_REQUEST_CACHING", ttl: "60 seconds" },
278 * });
279 * ```
280 *
281 * ### Environment Variables
282 * **Example:** Expose variables to resolver code via ctx.env
283 * ```typescript
284 * const api = yield* AppSync.GraphqlApi("Api", {
285 * schema,
286 * environmentVariables: { STAGE: "prod" },
287 * });
288 * // in APPSYNC_JS resolver code:
289 * // export function response(ctx) { return ctx.env.STAGE; }
290 * ```
291 *
292 * @resource
293 */
294export const GraphqlApi = Resource<GraphqlApi>("AWS.AppSync.GraphqlApi");
295
296/** Schema creation finished in the `FAILED` state. */
297export class SchemaCreationFailed extends Data.TaggedError(
298 "SchemaCreationFailed",
299)<{
300 readonly apiId: string;
301 readonly details: string | undefined;
302}> {}
303
304/** Schema creation did not settle within the bounded polling window. */
305export class SchemaCreationTimedOut extends Data.TaggedError(
306 "SchemaCreationTimedOut",
307)<{
308 readonly apiId: string;
309 readonly status: string | undefined;
310}> {}
311
312const toWireOidcConfig = (
313 config: OpenIDConnectAuthConfig | undefined,
314): appsync.OpenIDConnectConfig | undefined =>
315 config === undefined
316 ? undefined
317 : {
318 issuer: config.issuer,
319 clientId: config.clientId,
320 // The AppSync API expresses both token TTLs in milliseconds.
321 iatTTL: toWireMillis(config.iatTTL),
322 authTTL: toWireMillis(config.authTTL),
323 };
324
325const toWireLambdaAuthorizerConfig = (
326 config: LambdaAuthorizerConfig | undefined,
327): appsync.LambdaAuthorizerConfig | undefined =>
328 config === undefined
329 ? undefined
330 : {
331 authorizerUri: config.authorizerUri,
332 // The wire field carries the unit; the prop is a Duration.Input.
333 authorizerResultTtlInSeconds: toWireSeconds(config.authorizerResultTtl),
334 identityValidationExpression: config.identityValidationExpression,
335 };
336
337const toWireUserPoolConfig = (
338 config: UserPoolAuthConfig | undefined,
339 region: string,
340): appsync.UserPoolConfig | undefined =>
341 config === undefined
342 ? undefined
343 : {
344 userPoolId: config.userPoolId,
345 awsRegion: config.awsRegion ?? region,
346 defaultAction: config.defaultAction ?? "ALLOW",
347 appIdClientRegex: config.appIdClientRegex,
348 };
349
350const toWireAdditionalProviders = (
351 providers: AdditionalAuthProvider[] | undefined,
352 region: string,
353): appsync.AdditionalAuthenticationProvider[] | undefined =>
354 providers === undefined
355 ? undefined
356 : providers.map((provider) => ({
357 authenticationType: provider.authenticationType,
358 userPoolConfig:
359 provider.userPoolConfig === undefined
360 ? undefined
361 : {
362 userPoolId: provider.userPoolConfig.userPoolId,
363 awsRegion: provider.userPoolConfig.awsRegion ?? region,
364 appIdClientRegex: provider.userPoolConfig.appIdClientRegex,
365 },
366 openIDConnectConfig: toWireOidcConfig(provider.openIDConnectConfig),
367 lambdaAuthorizerConfig: toWireLambdaAuthorizerConfig(
368 provider.lambdaAuthorizerConfig,
369 ),
370 }));
371
372/**
373 * Canonicalize the wire-facing auth/limit surface of an API so observed
374 * and desired states compare structurally (dropping undefined members).
375 */
376const authSurface = (api: {
377 name?: string;
378 authenticationType?: string;
379 userPoolConfig?: appsync.UserPoolConfig;
380 openIDConnectConfig?: appsync.OpenIDConnectConfig;
381 lambdaAuthorizerConfig?: appsync.LambdaAuthorizerConfig;
382 additionalAuthenticationProviders?: appsync.AdditionalAuthenticationProvider[];
383 xrayEnabled?: boolean;
384 introspectionConfig?: string;
385 queryDepthLimit?: number;
386 resolverCountLimit?: number;
387 logConfig?: appsync.LogConfig;
388}) =>
389 JSON.parse(
390 JSON.stringify({
391 name: api.name,
392 authenticationType: api.authenticationType,
393 userPoolConfig: api.userPoolConfig,
394 openIDConnectConfig: api.openIDConnectConfig,
395 lambdaAuthorizerConfig: {
396 ...api.lambdaAuthorizerConfig,
397 // AWS normalizes the TTL default (300) into the response
398 authorizerResultTtlInSeconds:
399 api.lambdaAuthorizerConfig === undefined
400 ? undefined
401 : (api.lambdaAuthorizerConfig.authorizerResultTtlInSeconds ?? 300),
402 },
403 additionalAuthenticationProviders:
404 api.additionalAuthenticationProviders?.map((p) => ({
405 ...p,
406 lambdaAuthorizerConfig:
407 p.lambdaAuthorizerConfig === undefined
408 ? undefined
409 : {
410 ...p.lambdaAuthorizerConfig,
411 authorizerResultTtlInSeconds:
412 p.lambdaAuthorizerConfig.authorizerResultTtlInSeconds ??
413 300,
414 },
415 })),
416 xrayEnabled: api.xrayEnabled ?? false,
417 introspectionConfig: api.introspectionConfig ?? "ENABLED",
418 queryDepthLimit: api.queryDepthLimit ?? 0,
419 resolverCountLimit: api.resolverCountLimit ?? 0,
420 logConfig: api.logConfig,
421 }),
422 );
423
424export const GraphqlApiProvider = () =>
425 Provider.effect(
426 GraphqlApi,
427 Effect.gen(function* () {
428 const createName = Effect.fn(function* (
429 id: string,
430 props: Pick<GraphqlApiProps, "name">,
431 ) {
432 return props.name ?? (yield* createPhysicalName({ id, maxLength: 64 }));
433 });
434
435 const getApiSafe = (apiId: string) =>
436 appsync.getGraphqlApi({ apiId }).pipe(
437 Effect.map((response) => response.graphqlApi),
438 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
439 );
440
441 /** Find an API by exact name (fallback when no apiId is cached). */
442 const findApiByName = Effect.fn(function* (name: string) {
443 const pages = yield* appsync.listGraphqlApis
444 .pages({})
445 .pipe(Stream.runCollect);
446 return Array.from(pages)
447 .flatMap((page) => page.graphqlApis ?? [])
448 .find((api) => api.name === name);
449 });
450
451 const toAttributes = (
452 api: appsync.GraphqlApi,
453 ): GraphqlApi["Attributes"] => ({
454 apiId: api.apiId!,
455 apiArn: api.arn!,
456 name: api.name!,
457 graphqlUrl: api.uris?.GRAPHQL ?? "",
458 realtimeUrl: api.uris?.REALTIME,
459 authenticationType: api.authenticationType as AuthenticationType,
460 });
461
462 /**
463 * Apply the SDL schema and poll until the creation settles.
464 * `startSchemaCreation` is asynchronous; polling is bounded (~60s —
465 * schema creation is normally seconds).
466 */
467 const applySchema = Effect.fn(function* (apiId: string, schema: string) {
468 const definition = yield* Effect.sync(() =>
469 new TextEncoder().encode(schema),
470 );
471 yield* retryConcurrentModification(
472 appsync.startSchemaCreation({ apiId, definition }),
473 );
474 const final = yield* appsync.getSchemaCreationStatus({ apiId }).pipe(
475 Effect.repeat({
476 schedule: Schedule.fixed("1 second"),
477 until: (response) => response.status !== "PROCESSING",
478 times: 60,
479 }),
480 );
481 if (final.status === "FAILED") {
482 return yield* new SchemaCreationFailed({
483 apiId,
484 details: final.details,
485 });
486 }
487 if (final.status === "PROCESSING") {
488 return yield* new SchemaCreationTimedOut({
489 apiId,
490 status: final.status,
491 });
492 }
493 });
494
495 /**
496 * Converge the optional server-side cache: create when desired and
497 * missing, update on drift, delete when no longer desired. Creation
498 * is asynchronous and NOT awaited (10–20 minutes to AVAILABLE).
499 */
500 const syncCache = Effect.fn(function* (
501 apiId: string,
502 desired: ApiCacheConfig | undefined,
503 ) {
504 const observed = yield* appsync.getApiCache({ apiId }).pipe(
505 Effect.map((response) => response.apiCache),
506 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
507 );
508 if (desired === undefined) {
509 if (observed !== undefined) {
510 yield* retryConcurrentModification(
511 appsync
512 .deleteApiCache({ apiId })
513 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
514 );
515 }
516 return;
517 }
518 // The wire TTL is whole seconds.
519 const desiredTtl = toWireSeconds(desired.ttl)!;
520 if (observed === undefined) {
521 yield* retryConcurrentModification(
522 appsync.createApiCache({
523 apiId,
524 type: desired.type,
525 apiCachingBehavior: desired.behavior,
526 ttl: desiredTtl,
527 transitEncryptionEnabled: desired.transitEncryptionEnabled,
528 atRestEncryptionEnabled: desired.atRestEncryptionEnabled,
529 healthMetricsConfig: desired.healthMetricsConfig,
530 }),
531 );
532 return;
533 }
534 const drifted =
535 observed.type !== desired.type ||
536 observed.apiCachingBehavior !== desired.behavior ||
537 observed.ttl !== desiredTtl ||
538 (desired.healthMetricsConfig !== undefined &&
539 observed.healthMetricsConfig !== desired.healthMetricsConfig);
540 if (drifted) {
541 yield* retryConcurrentModification(
542 appsync.updateApiCache({
543 apiId,
544 type: desired.type,
545 apiCachingBehavior: desired.behavior,
546 ttl: desiredTtl,
547 healthMetricsConfig: desired.healthMetricsConfig,
548 }),
549 );
550 }
551 });
552
553 /**
554 * Converge resolver environment variables (`ctx.env`). The AWS API
555 * replaces the whole map on PUT, so a single drift-guarded call
556 * suffices. Skipped entirely when the prop is unmanaged.
557 */
558 const syncEnvironmentVariables = Effect.fn(function* (
559 apiId: string,
560 desired: Record<string, string> | undefined,
561 ) {
562 if (desired === undefined) return;
563 const observed = yield* appsync
564 .getGraphqlApiEnvironmentVariables({ apiId })
565 .pipe(Effect.map((response) => response.environmentVariables ?? {}));
566 if (!deepEqual(tagRecord(observed), desired)) {
567 yield* retryConcurrentModification(
568 appsync.putGraphqlApiEnvironmentVariables({
569 apiId,
570 environmentVariables: desired,
571 }),
572 );
573 }
574 });
575
576 return GraphqlApi.Provider.of({
577 stables: ["apiId", "apiArn", "graphqlUrl", "realtimeUrl"],
578
579 list: () =>
580 Effect.gen(function* () {
581 const pages = yield* appsync.listGraphqlApis
582 .pages({})
583 .pipe(Stream.runCollect);
584 return Array.from(pages)
585 .flatMap((page) => page.graphqlApis ?? [])
586 .filter((api) => api.apiId != null)
587 .map(toAttributes);
588 }),
589
590 read: Effect.fn(function* ({ id, olds, output }) {
591 const api =
592 output?.apiId !== undefined
593 ? yield* getApiSafe(output.apiId)
594 : yield* findApiByName(yield* createName(id, olds ?? {}));
595 if (api?.apiId == null) return undefined;
596 const attrs = toAttributes(api);
597 const tags = tagRecord(api.tags);
598 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
599 }),
600
601 diff: Effect.fn(function* ({ news, olds }) {
602 if (!isResolved(news)) return undefined;
603 if (
604 (news.visibility ?? "GLOBAL") !== (olds.visibility ?? "GLOBAL") ||
605 (news.apiType ?? "GRAPHQL") !== (olds.apiType ?? "GRAPHQL")
606 ) {
607 return { action: "replace" } as const;
608 }
609 // name/auth/schema/cache/limits/tags converge via update
610 }),
611
612 reconcile: Effect.fn(function* ({ id, news, olds, output, session }) {
613 const { region } = yield* AWSEnvironment.current;
614 const name = news.name ?? (yield* createName(id, news));
615 const internalTags = yield* createInternalTags(id);
616 const desiredTags = { ...news.tags, ...internalTags };
617 const authenticationType = news.authenticationType ?? "API_KEY";
618
619 const desired = {
620 name,
621 authenticationType,
622 userPoolConfig: toWireUserPoolConfig(news.userPoolConfig, region),
623 openIDConnectConfig: toWireOidcConfig(news.openIDConnectConfig),
624 lambdaAuthorizerConfig: toWireLambdaAuthorizerConfig(
625 news.lambdaAuthorizerConfig,
626 ),
627 additionalAuthenticationProviders: toWireAdditionalProviders(
628 news.additionalAuthenticationProviders,
629 region,
630 ),
631 xrayEnabled: news.xrayEnabled,
632 introspectionConfig: news.introspectionConfig,
633 queryDepthLimit: news.queryDepthLimit,
634 resolverCountLimit: news.resolverCountLimit,
635 logConfig: news.logConfig,
636 };
637
638 // 1. OBSERVE — the cached apiId is only a hint; fall back to a
639 // name scan so interrupted deploys converge.
640 let observed =
641 output?.apiId !== undefined
642 ? yield* getApiSafe(output.apiId)
643 : yield* findApiByName(name);
644
645 // 2. ENSURE
646 let justCreated = false;
647 if (observed?.apiId == null) {
648 const created = yield* retryConcurrentModification(
649 appsync.createGraphqlApi({
650 ...desired,
651 visibility: news.visibility,
652 apiType: news.apiType,
653 tags: desiredTags,
654 }),
655 );
656 observed = created.graphqlApi!;
657 justCreated = true;
658 yield* session.note(`Created GraphQL API ${observed.apiId}`);
659 }
660 const apiId = observed.apiId!;
661
662 // 3. SYNC — one update call when the observed auth/limit surface
663 // drifted from the desired one.
664 if (
665 !justCreated &&
666 !deepEqual(authSurface(observed), authSurface(desired))
667 ) {
668 const updated = yield* retryConcurrentModification(
669 appsync.updateGraphqlApi({ apiId, ...desired }),
670 );
671 observed = updated.graphqlApi ?? observed;
672 yield* session.note(`Updated GraphQL API ${apiId}`);
673 }
674
675 // 3b. SYNC SCHEMA — `startSchemaCreation` replaces the schema
676 // wholesale (idempotent). Apply on first provision, on
677 // adoption, and whenever the desired SDL changed; the
678 // previous props are only a hint to skip the no-op call.
679 if (
680 news.schema !== undefined &&
681 (justCreated ||
682 output === undefined ||
683 olds?.schema !== news.schema)
684 ) {
685 yield* applySchema(apiId, news.schema);
686 yield* session.note(`Applied schema to ${apiId}`);
687 }
688
689 // 3c. SYNC CACHE
690 yield* syncCache(apiId, news.cache);
691
692 // 3d. SYNC ENVIRONMENT VARIABLES (ctx.env in resolver code).
693 yield* syncEnvironmentVariables(apiId, news.environmentVariables);
694
695 // 3e. SYNC TAGS — against OBSERVED cloud tags.
696 if (observed.arn !== undefined) {
697 yield* syncAppSyncTags({
698 resourceArn: observed.arn,
699 oldTags: tagRecord(observed.tags),
700 newTags: desiredTags,
701 });
702 }
703
704 yield* session.note(apiId);
705 return toAttributes(observed);
706 }),
707
708 delete: Effect.fn(function* ({ output }) {
709 yield* retryConcurrentModification(
710 appsync
711 .deleteGraphqlApi({ apiId: output.apiId })
712 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
713 );
714 }),
715 });
716 }),
717 );