1import * as appsync from "@distilled.cloud/aws/appsync";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../../Diff.ts";
5import type { Input } from "../../Input.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import { retryConcurrentModification } from "./common.ts";
10import type { AppSyncDomainName } from "./DomainName.ts";
11import type { GraphqlApi } from "./GraphqlApi.ts";
12
13export interface ApiAssociationProps {
14 /**
15 * The custom domain name to associate. Changing it triggers a
16 * replacement.
17 */
18 domainName: string;
19 /**
20 * ID of the GraphQL API served at the domain. Changing it triggers a
21 * replacement (a domain serves exactly one API).
22 */
23 apiId: string;
24}
25
26export interface AppSyncApiAssociation extends Resource<
27 "AWS.AppSync.ApiAssociation",
28 ApiAssociationProps,
29 {
30 /** The associated domain name. */
31 domainName: string;
32 /** The associated API. */
33 apiId: string;
34 },
35 never,
36 Providers
37> {}
38
39/**
40 * Associates a GraphQL API with a custom {@link DomainName}
41 * (existence-only resource — a domain serves exactly one API).
42 * ### Associating an API
43 * **Example:** Serve an API at a custom domain
44 * ```typescript
45 * yield* AppSync.ApiAssociation("Assoc", { domain, api });
46 * ```
47 *
48 * @resource
49 */
50export const ApiAssociationResource = Resource<AppSyncApiAssociation>(
51 "AWS.AppSync.ApiAssociation",
52);
53
54export interface ApiAssociationInputProps {
55 /** The `DomainName` resource (preferred). Alternatively pass `domainName`. */
56 domain?: AppSyncDomainName;
57 domainName?: Input<string>;
58 /** The `GraphqlApi` resource (preferred). Alternatively pass `apiId`. */
59 api?: GraphqlApi;
60 apiId?: Input<string>;
61}
62
63/**
64 * User-facing wrapper for the ApiAssociation resource. Accepts the
65 * `DomainName` and `GraphqlApi` resources directly.
66 */
67export const ApiAssociation = (id: string, props: ApiAssociationInputProps) =>
68 Effect.gen(function* () {
69 const domainName = props.domainName ?? props.domain?.domainName;
70 const apiId = props.apiId ?? props.api?.apiId;
71 if (!domainName || !apiId) {
72 return yield* Effect.die(
73 "ApiAssociation requires `domain`/`domainName` and `api`/`apiId`.",
74 );
75 }
76 return yield* ApiAssociationResource(id, { domainName, apiId } as any);
77 });
78
79export const ApiAssociationProvider = () =>
80 Provider.effect(
81 ApiAssociationResource,
82 Effect.gen(function* () {
83 const getAssociationSafe = (domainName: string) =>
84 appsync.getApiAssociation({ domainName }).pipe(
85 Effect.map((response) => response.apiAssociation),
86 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
87 );
88
89 /** Associations transition PROCESSING → SUCCESS/FAILED (~seconds). */
90 const waitForSettled = (domainName: string) =>
91 getAssociationSafe(domainName).pipe(
92 Effect.repeat({
93 schedule: Schedule.fixed("2 seconds"),
94 until: (association) =>
95 association === undefined ||
96 association.associationStatus !== "PROCESSING",
97 times: 30,
98 }),
99 );
100
101 return ApiAssociationResource.Provider.of({
102 stables: ["domainName", "apiId"],
103
104 // Sub-resource keyed entirely by its custom domain (domainName) with no global
105 // enumeration API of its own — nuke reaches it through the parent's
106 // deletion, so enumeration returns empty per the ProviderService
107 // doctrine.
108 list: () => Effect.succeed([]),
109
110 read: Effect.fn(function* ({ olds, output }) {
111 const domainName = output?.domainName ?? olds?.domainName;
112 if (domainName === undefined) return undefined;
113 const association = yield* getAssociationSafe(domainName);
114 if (association?.apiId == null) return undefined;
115 return { domainName, apiId: association.apiId };
116 }),
117
118 diff: Effect.fn(function* ({ news, olds }) {
119 if (!isResolved(news)) return undefined;
120 if (
121 news.domainName !== olds.domainName ||
122 news.apiId !== olds.apiId
123 ) {
124 return { action: "replace" } as const;
125 }
126 }),
127
128 reconcile: Effect.fn(function* ({ news, session }) {
129 // Existence-only: observe → associate when missing or pointing
130 // at a different API (associateApi is an upsert per domain).
131 const observed = yield* getAssociationSafe(news.domainName);
132 if (observed?.apiId !== news.apiId) {
133 yield* retryConcurrentModification(
134 appsync.associateApi({
135 domainName: news.domainName,
136 apiId: news.apiId,
137 }),
138 );
139 yield* waitForSettled(news.domainName);
140 yield* session.note(
141 `Associated ${news.apiId} with ${news.domainName}`,
142 );
143 }
144 return { domainName: news.domainName, apiId: news.apiId };
145 }),
146
147 delete: Effect.fn(function* ({ output }) {
148 yield* retryConcurrentModification(
149 appsync
150 .disassociateApi({ domainName: output.domainName })
151 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
152 );
153 // Wait until the association is gone so a dependent DomainName
154 // delete doesn't race the detach (bounded ~60s).
155 yield* getAssociationSafe(output.domainName).pipe(
156 Effect.repeat({
157 schedule: Schedule.fixed("2 seconds"),
158 until: (association) => association?.apiId == null,
159 times: 30,
160 }),
161 );
162 }),
163 });
164 }),
165 );