1import * as route53 from "@distilled.cloud/aws/route-53";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import type { Input } from "../../Input.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { durationToSeconds } from "../IAM/common.ts";
11import { resolveHostedZoneId } from "./HostedZoneLookup.ts";
12import type { Providers } from "../Providers.ts";
13
14export interface RecordAliasTarget {
15 /**
16 * Hosted zone ID for the alias target.
17 */
18 hostedZoneId: Input<string>;
19 /**
20 * DNS name for the alias target.
21 */
22 dnsName: Input<string>;
23 /**
24 * Whether Route 53 should evaluate target health for the alias.
25 * @default false
26 */
27 evaluateTargetHealth?: boolean;
28}
29
30/**
31 * Fully-resolved alias target as stored in the record's attributes — a
32 * `RecordAliasTarget` with all `Input` values resolved.
33 */
34export interface ResolvedRecordAliasTarget {
35 /**
36 * Hosted zone ID for the alias target.
37 */
38 hostedZoneId: string;
39 /**
40 * DNS name for the alias target.
41 */
42 dnsName: string;
43 /**
44 * Whether Route 53 evaluates target health for the alias.
45 * @default false
46 */
47 evaluateTargetHealth?: boolean;
48}
49
50export interface RecordGeoLocation {
51 /**
52 * Two-letter continent code (e.g. `"NA"`, `"EU"`). Mutually exclusive with
53 * `countryCode`.
54 */
55 continentCode?: string;
56 /**
57 * Two-letter country code, or `"*"` for the default (catch-all) record.
58 */
59 countryCode?: string;
60 /**
61 * Subdivision code (e.g. a US state). Requires `countryCode`.
62 */
63 subdivisionCode?: string;
64}
65
66export interface RecordGeoProximityCoordinates {
67 /** Latitude as a string (e.g. `"49.22"`). */
68 latitude: string;
69 /** Longitude as a string (e.g. `"-122.41"`). */
70 longitude: string;
71}
72
73export interface RecordGeoProximityLocation {
74 /** AWS Region for the endpoint. Mutually exclusive with `coordinates`. */
75 awsRegion?: string;
76 /** Local Zone Group for the endpoint. */
77 localZoneGroup?: string;
78 /** Explicit latitude/longitude of the endpoint. */
79 coordinates?: RecordGeoProximityCoordinates;
80 /** Bias (-99 to 99) that expands or shrinks the geographic region. */
81 bias?: number;
82}
83
84export interface RecordCidrRoutingConfig {
85 /** ID of the CIDR collection. */
86 collectionId: string;
87 /** Name of the CIDR location within the collection. */
88 locationName: string;
89}
90
91export interface RecordProps {
92 /**
93 * Hosted zone that owns the record. When omitted, the most specific
94 * PUBLIC hosted zone in the account containing `name` is inferred by
95 * walking the name's parent domains (`svc.api.example.com` →
96 * `api.example.com` → `example.com`); the deploy fails actionably when
97 * no zone matches.
98 */
99 hostedZoneId?: string;
100 /**
101 * Record name.
102 */
103 name: string;
104 /**
105 * Record type.
106 */
107 type: route53.RRType;
108 /**
109 * TTL for non-alias records, e.g. `"60 seconds"` or `Duration.minutes(5)`
110 * (a bare number is milliseconds). Rounded to whole seconds on the wire.
111 */
112 ttl?: Duration.Input;
113 /**
114 * Record values for non-alias records.
115 */
116 records?: string[];
117 /**
118 * Alias target for alias records.
119 */
120 aliasTarget?: RecordAliasTarget;
121 /**
122 * Optional set identifier for weighted, latency, failover, and other routing
123 * policies that require unique record identities.
124 */
125 setIdentifier?: string;
126 /**
127 * Weight (0-255) for weighted routing. Requires `setIdentifier`.
128 */
129 weight?: number;
130 /**
131 * AWS Region for latency-based routing. Requires `setIdentifier`.
132 */
133 region?: route53.ResourceRecordSetRegion;
134 /**
135 * Failover role for failover routing. Requires `setIdentifier`.
136 */
137 failover?: "PRIMARY" | "SECONDARY";
138 /**
139 * Geolocation routing rule. Requires `setIdentifier`.
140 */
141 geoLocation?: RecordGeoLocation;
142 /**
143 * Geoproximity routing rule. Requires `setIdentifier`.
144 */
145 geoProximityLocation?: RecordGeoProximityLocation;
146 /**
147 * Whether this record participates in multivalue answer routing. Requires
148 * `setIdentifier`.
149 */
150 multiValueAnswer?: boolean;
151 /**
152 * IP-based (CIDR) routing rule. Requires `setIdentifier`.
153 */
154 cidrRoutingConfig?: RecordCidrRoutingConfig;
155 /**
156 * Health check that gates whether Route 53 returns this record. Typically a
157 * `HealthCheck.id`.
158 */
159 healthCheckId?: string;
160}
161
162export interface Record extends Resource<
163 "AWS.Route53.Record",
164 RecordProps,
165 {
166 /**
167 * Hosted zone that owns the record.
168 */
169 hostedZoneId: string;
170 /**
171 * Fully qualified record name.
172 */
173 name: string;
174 /**
175 * Record type.
176 */
177 type: route53.RRType;
178 /**
179 * Current TTL for non-alias records.
180 */
181 ttl: number | undefined;
182 /**
183 * Current non-alias record values.
184 */
185 records: string[] | undefined;
186 /**
187 * Current alias target, when this record is an alias.
188 */
189 aliasTarget: ResolvedRecordAliasTarget | undefined;
190 /**
191 * Optional routing set identifier.
192 */
193 setIdentifier: string | undefined;
194 /**
195 * Weight for weighted routing.
196 */
197 weight: number | undefined;
198 /**
199 * AWS Region for latency routing.
200 */
201 region: route53.ResourceRecordSetRegion | undefined;
202 /**
203 * Failover role for failover routing.
204 */
205 failover: "PRIMARY" | "SECONDARY" | undefined;
206 /**
207 * Geolocation routing rule.
208 */
209 geoLocation: RecordGeoLocation | undefined;
210 /**
211 * Geoproximity routing rule.
212 */
213 geoProximityLocation: RecordGeoProximityLocation | undefined;
214 /**
215 * Whether this record participates in multivalue answer routing.
216 */
217 multiValueAnswer: boolean | undefined;
218 /**
219 * IP-based (CIDR) routing rule.
220 */
221 cidrRoutingConfig: RecordCidrRoutingConfig | undefined;
222 /**
223 * Health check that gates this record.
224 */
225 healthCheckId: string | undefined;
226 },
227 never,
228 Providers
229> {}
230
231/**
232 * A Route 53 DNS record set.
233 *
234 * `Record` manages a single Route 53 record set using `UPSERT` for create and
235 * update operations, and waits for Route 53 change propagation before
236 * returning.
237 * ### Creating Records
238 * **Example:** A Record Alias To CloudFront
239 * ```typescript
240 * const record = yield* Record("WebsiteAlias", {
241 * hostedZoneId: "Z1234567890",
242 * name: "www.example.com",
243 * type: "A",
244 * aliasTarget: {
245 * hostedZoneId: distribution.hostedZoneId,
246 * dnsName: distribution.domainName,
247 * },
248 * });
249 * ```
250 *
251 * **Example:** TXT Record
252 * ```typescript
253 * const record = yield* Record("VerificationRecord", {
254 * hostedZoneId: "Z1234567890",
255 * name: "_acme-challenge.example.com",
256 * type: "TXT",
257 * ttl: "60 seconds",
258 * records: ["\"value\""],
259 * });
260 * ```
261 *
262 * ### Routing Policies
263 * **Example:** Weighted Routing
264 * ```typescript
265 * const blue = yield* Record("Blue", {
266 * hostedZoneId: zone.id,
267 * name: "api.example.com",
268 * type: "A",
269 * ttl: "60 seconds",
270 * records: ["1.2.3.4"],
271 * setIdentifier: "blue",
272 * weight: 90,
273 * });
274 * const green = yield* Record("Green", {
275 * hostedZoneId: zone.id,
276 * name: "api.example.com",
277 * type: "A",
278 * ttl: "60 seconds",
279 * records: ["5.6.7.8"],
280 * setIdentifier: "green",
281 * weight: 10,
282 * });
283 * ```
284 *
285 * **Example:** Failover Routing With Health Check
286 * ```typescript
287 * const primary = yield* Record("Primary", {
288 * hostedZoneId: zone.id,
289 * name: "app.example.com",
290 * type: "A",
291 * ttl: "60 seconds",
292 * records: ["1.2.3.4"],
293 * setIdentifier: "primary",
294 * failover: "PRIMARY",
295 * healthCheckId: healthCheck.id,
296 * });
297 * const secondary = yield* Record("Secondary", {
298 * hostedZoneId: zone.id,
299 * name: "app.example.com",
300 * type: "A",
301 * ttl: "60 seconds",
302 * records: ["5.6.7.8"],
303 * setIdentifier: "secondary",
304 * failover: "SECONDARY",
305 * });
306 * ```
307 *
308 * **Example:** Latency Routing
309 * ```typescript
310 * const record = yield* Record("UsEast", {
311 * hostedZoneId: zone.id,
312 * name: "api.example.com",
313 * type: "A",
314 * ttl: "60 seconds",
315 * records: ["1.2.3.4"],
316 * setIdentifier: "us-east-1",
317 * region: "us-east-1",
318 * });
319 * ```
320 *
321 * **Example:** Geolocation Routing
322 * ```typescript
323 * const record = yield* Record("Default", {
324 * hostedZoneId: zone.id,
325 * name: "www.example.com",
326 * type: "A",
327 * ttl: "60 seconds",
328 * records: ["1.2.3.4"],
329 * setIdentifier: "default",
330 * geoLocation: { countryCode: "*" },
331 * });
332 * ```
333 *
334 * @resource
335 */
336export const Record = Resource<Record>("AWS.Route53.Record");
337
338/** @internal shared with `Records.ts` — not exported from the barrel. */
339export const normalizeHostedZoneId = (hostedZoneId: string) =>
340 hostedZoneId.replace(/^\/hostedzone\//, "");
341
342/** @internal shared with `Records.ts` — not exported from the barrel. */
343export const normalizeName = (name: string) =>
344 name.endsWith(".") ? name : `${name}.`;
345
346/** @internal shared with `Records.ts` — not exported from the barrel. */
347export const toAliasTarget = (
348 aliasTarget: route53.AliasTarget | undefined,
349): ResolvedRecordAliasTarget | undefined =>
350 aliasTarget
351 ? {
352 hostedZoneId: aliasTarget.HostedZoneId,
353 dnsName: aliasTarget.DNSName,
354 evaluateTargetHealth: aliasTarget.EvaluateTargetHealth,
355 }
356 : undefined;
357
358const toGeoLocation = (
359 geo: RecordGeoLocation | undefined,
360): route53.GeoLocation | undefined =>
361 geo
362 ? {
363 ContinentCode: geo.continentCode,
364 CountryCode: geo.countryCode,
365 SubdivisionCode: geo.subdivisionCode,
366 }
367 : undefined;
368
369const fromGeoLocation = (
370 geo: route53.GeoLocation | undefined,
371): RecordGeoLocation | undefined =>
372 geo
373 ? {
374 continentCode: geo.ContinentCode,
375 countryCode: geo.CountryCode,
376 subdivisionCode: geo.SubdivisionCode,
377 }
378 : undefined;
379
380const toGeoProximity = (
381 geo: RecordGeoProximityLocation | undefined,
382): route53.GeoProximityLocation | undefined =>
383 geo
384 ? {
385 AWSRegion: geo.awsRegion,
386 LocalZoneGroup: geo.localZoneGroup,
387 Coordinates: geo.coordinates
388 ? {
389 Latitude: geo.coordinates.latitude,
390 Longitude: geo.coordinates.longitude,
391 }
392 : undefined,
393 Bias: geo.bias,
394 }
395 : undefined;
396
397const fromGeoProximity = (
398 geo: route53.GeoProximityLocation | undefined,
399): RecordGeoProximityLocation | undefined =>
400 geo
401 ? {
402 awsRegion: geo.AWSRegion,
403 localZoneGroup: geo.LocalZoneGroup,
404 coordinates: geo.Coordinates
405 ? {
406 latitude: geo.Coordinates.Latitude,
407 longitude: geo.Coordinates.Longitude,
408 }
409 : undefined,
410 bias: geo.Bias,
411 }
412 : undefined;
413
414const toCidrRouting = (
415 cidr: RecordCidrRoutingConfig | undefined,
416): route53.CidrRoutingConfig | undefined =>
417 cidr
418 ? { CollectionId: cidr.collectionId, LocationName: cidr.locationName }
419 : undefined;
420
421const fromCidrRouting = (
422 cidr: route53.CidrRoutingConfig | undefined,
423): RecordCidrRoutingConfig | undefined =>
424 cidr
425 ? { collectionId: cidr.CollectionId, locationName: cidr.LocationName }
426 : undefined;
427
428/**
429 * Build the full `ResourceRecordSet` wire shape from props. Used for both the
430 * UPSERT change batch and the DELETE change batch — DELETE requires an exact
431 * match of every policy field, so this must round-trip the entire surface.
432 * @internal shared with `Records.ts` — not exported from the barrel.
433 */
434export const toRecordSet = (
435 props: Pick<
436 RecordProps,
437 | "name"
438 | "type"
439 | "records"
440 | "aliasTarget"
441 | "setIdentifier"
442 | "weight"
443 | "region"
444 | "failover"
445 | "geoLocation"
446 | "geoProximityLocation"
447 | "multiValueAnswer"
448 | "cidrRoutingConfig"
449 | "healthCheckId"
450 > & {
451 /** TTL already converted to wire seconds. */
452 ttl?: number;
453 },
454): route53.ResourceRecordSet => ({
455 Name: normalizeName(props.name),
456 Type: props.type,
457 SetIdentifier: props.setIdentifier,
458 Weight: props.weight,
459 Region: props.region,
460 Failover: props.failover,
461 GeoLocation: toGeoLocation(props.geoLocation),
462 GeoProximityLocation: toGeoProximity(props.geoProximityLocation),
463 MultiValueAnswer: props.multiValueAnswer,
464 CidrRoutingConfig: toCidrRouting(props.cidrRoutingConfig),
465 HealthCheckId: props.healthCheckId,
466 TTL: props.aliasTarget ? undefined : props.ttl,
467 ResourceRecords: props.aliasTarget
468 ? undefined
469 : (props.records ?? []).map((Value) => ({ Value })),
470 AliasTarget: props.aliasTarget
471 ? {
472 HostedZoneId: normalizeHostedZoneId(
473 props.aliasTarget.hostedZoneId as string,
474 ),
475 DNSName: props.aliasTarget.dnsName as string,
476 EvaluateTargetHealth: props.aliasTarget.evaluateTargetHealth ?? false,
477 }
478 : undefined,
479});
480
481const toAttrs = (
482 recordSet: route53.ResourceRecordSet,
483 hostedZoneId: string,
484) => ({
485 hostedZoneId: normalizeHostedZoneId(hostedZoneId),
486 name: recordSet.Name,
487 type: recordSet.Type,
488 ttl: recordSet.TTL,
489 records: recordSet.ResourceRecords?.map((record) => record.Value),
490 aliasTarget: toAliasTarget(recordSet.AliasTarget),
491 setIdentifier: recordSet.SetIdentifier,
492 weight: recordSet.Weight,
493 region: recordSet.Region,
494 failover: recordSet.Failover as "PRIMARY" | "SECONDARY" | undefined,
495 geoLocation: fromGeoLocation(recordSet.GeoLocation),
496 geoProximityLocation: fromGeoProximity(recordSet.GeoProximityLocation),
497 multiValueAnswer: recordSet.MultiValueAnswer,
498 cidrRoutingConfig: fromCidrRouting(recordSet.CidrRoutingConfig),
499 healthCheckId: recordSet.HealthCheckId,
500});
501
502export const RecordProvider = () =>
503 Provider.effect(
504 Record,
505 Effect.gen(function* () {
506 // Poll `getChange` until the change reaches INSYNC. `getChange` is
507 // eventually consistent and can briefly return `NoSuchChange` right after
508 // submit, so coalesce that to a non-INSYNC status and keep polling.
509 // `ChangeInfo.Id` comes back as "/change/C..." but `getChange` only
510 // accepts the bare id — the prefixed form returns `NoSuchChange`
511 // forever, silently burning the full repeat cap on every change.
512 const waitForChange = Effect.fn(function* (changeId: string) {
513 return yield* route53
514 .getChange({ Id: changeId.replace(/^\/change\//, "") })
515 .pipe(
516 Effect.map((response) => response.ChangeInfo.Status),
517 Effect.catchTag("NoSuchChange", () => Effect.succeed("PENDING")),
518 Effect.repeat({
519 schedule: Schedule.max([
520 Schedule.fixed("2 seconds"),
521 Schedule.recurs(60),
522 ]),
523 until: (status) => status === "INSYNC",
524 }),
525 );
526 });
527
528 const findRecord = Effect.fn(function* (
529 hostedZoneId: string,
530 props: Pick<RecordProps, "name" | "type" | "setIdentifier">,
531 ) {
532 const response = yield* route53
533 .listResourceRecordSets({
534 HostedZoneId: normalizeHostedZoneId(hostedZoneId),
535 StartRecordName: normalizeName(props.name),
536 StartRecordType: props.type,
537 MaxItems: 100,
538 })
539 .pipe(
540 Effect.catchTag("NoSuchHostedZone", () =>
541 Effect.succeed(undefined),
542 ),
543 );
544
545 return (response?.ResourceRecordSets ?? []).find(
546 (recordSet) =>
547 recordSet.Name === normalizeName(props.name) &&
548 recordSet.Type === props.type &&
549 (recordSet.SetIdentifier ?? undefined) === props.setIdentifier,
550 );
551 });
552
553 const upsertRecord = Effect.fn(function* (
554 hostedZoneId: string,
555 props: RecordProps,
556 ) {
557 const response = yield* route53.changeResourceRecordSets({
558 HostedZoneId: normalizeHostedZoneId(hostedZoneId),
559 ChangeBatch: {
560 Comment: "Alchemy Route53 record upsert",
561 Changes: [
562 {
563 Action: "UPSERT",
564 ResourceRecordSet: toRecordSet({
565 ...props,
566 ttl: durationToSeconds(props.ttl),
567 }),
568 },
569 ],
570 },
571 });
572
573 yield* waitForChange(response.ChangeInfo.Id);
574 });
575
576 // `listResourceRecordSets` is not paginated by distilled, so manually
577 // walk the `IsTruncated` / `NextRecord*` cursor to collect every record
578 // set in a hosted zone.
579 const listAllRecordSets = Effect.fn(function* (hostedZoneId: string) {
580 const all: route53.ResourceRecordSet[] = [];
581 let request: route53.ListResourceRecordSetsRequest = {
582 HostedZoneId: normalizeHostedZoneId(hostedZoneId),
583 MaxItems: 300,
584 };
585 while (true) {
586 const response = yield* route53.listResourceRecordSets(request);
587 all.push(...(response.ResourceRecordSets ?? []));
588 if (!response.IsTruncated || response.NextRecordName === undefined) {
589 break;
590 }
591 request = {
592 HostedZoneId: normalizeHostedZoneId(hostedZoneId),
593 StartRecordName: response.NextRecordName,
594 StartRecordType: response.NextRecordType,
595 StartRecordIdentifier: response.NextRecordIdentifier,
596 MaxItems: 300,
597 };
598 }
599 return all;
600 });
601
602 return {
603 stables: ["hostedZoneId", "name", "type", "setIdentifier"],
604 list: () =>
605 Effect.gen(function* () {
606 // Records are hosted-zone-scoped: enumerate every hosted zone
607 // (paginated), then fan out one `listResourceRecordSets` walk per
608 // zone with bounded concurrency.
609 const zones = yield* route53.listHostedZones.pages({}).pipe(
610 Stream.runCollect,
611 Effect.map((chunk) =>
612 Array.from(chunk).flatMap((page) => page.HostedZones ?? []),
613 ),
614 );
615
616 const rows = yield* Effect.forEach(
617 zones,
618 (zone) =>
619 listAllRecordSets(zone.Id).pipe(
620 Effect.map((recordSets) =>
621 recordSets.map((recordSet) => toAttrs(recordSet, zone.Id)),
622 ),
623 // A zone may be deleted concurrently with enumeration; treat
624 // a vanished zone as contributing no records.
625 Effect.catchTag("NoSuchHostedZone", () => Effect.succeed([])),
626 ),
627 { concurrency: 10 },
628 );
629
630 return rows.flat();
631 }),
632 diff: Effect.fn(function* ({ olds, news }) {
633 if (!isResolved(news)) return undefined;
634 // Identity change → replace, but only when the old value is known —
635 // a half-created state row can't round-trip Output-valued props
636 // (they deserialize as `undefined`), and an unknown old identity
637 // must fall through to the create/update recovery path.
638 if (
639 // An undefined side means "inferred" — reconcile resolves it
640 // against the live account, so only two explicit, differing
641 // zones are a replacement.
642 (olds.hostedZoneId !== undefined &&
643 news.hostedZoneId !== undefined &&
644 normalizeHostedZoneId(olds.hostedZoneId) !==
645 normalizeHostedZoneId(news.hostedZoneId)) ||
646 (olds.name !== undefined &&
647 normalizeName(olds.name) !== normalizeName(news.name)) ||
648 olds.type !== news.type ||
649 olds.setIdentifier !== news.setIdentifier
650 ) {
651 return { action: "replace" } as const;
652 }
653 }),
654 read: Effect.fn(function* ({ olds, output }) {
655 const hostedZoneId = output?.hostedZoneId ?? olds?.hostedZoneId;
656 const name = output?.name ?? olds?.name;
657 const type = output?.type ?? olds?.type;
658 if (
659 hostedZoneId === undefined ||
660 name === undefined ||
661 type === undefined
662 ) {
663 // Output-valued props don't survive a `creating`-state round-trip
664 // — without the record's identity we can't look it up. Report
665 // "not found" so the engine re-drives the create (the UPSERT in
666 // reconcile converges on any half-created record).
667 return undefined;
668 }
669 const recordSet = yield* findRecord(hostedZoneId, {
670 name,
671 type,
672 setIdentifier: output?.setIdentifier ?? olds?.setIdentifier,
673 });
674
675 if (!recordSet) {
676 return undefined;
677 }
678
679 return toAttrs(recordSet, hostedZoneId);
680 }),
681 reconcile: Effect.fn(function* ({ news, output, session }) {
682 // Resolve the zone: explicit prop, else the zone this resource
683 // already resolved to (stable across reconciles), else infer the
684 // most specific public zone containing the record name.
685 const hostedZoneId = yield* resolveHostedZoneId(
686 news.hostedZoneId ?? output?.hostedZoneId,
687 news.name,
688 );
689
690 // Route 53 `changeResourceRecordSets` with `UPSERT` is naturally
691 // reconciler-friendly: it creates the record if missing and
692 // overwrites it if present. There's no separate ensure/sync split
693 // — one call converges to the desired record set.
694 yield* upsertRecord(hostedZoneId, news);
695
696 // Re-read so the returned attributes reflect the actual current
697 // record (including server-applied defaults).
698 const recordSet = yield* findRecord(hostedZoneId, news);
699
700 if (!recordSet) {
701 return yield* Effect.die(
702 new Error("Route53 record was not found after upsert"),
703 );
704 }
705
706 yield* session.note(`${news.type} ${normalizeName(news.name)}`);
707 return toAttrs(recordSet, hostedZoneId);
708 }),
709 delete: Effect.fn(function* ({ output }) {
710 yield* route53
711 .changeResourceRecordSets({
712 HostedZoneId: normalizeHostedZoneId(output.hostedZoneId),
713 ChangeBatch: {
714 Comment: "Alchemy Route53 record delete",
715 Changes: [
716 {
717 Action: "DELETE",
718 // Serialize the full record set — DELETE requires an exact
719 // match including routing-policy fields, so reuse the same
720 // builder as UPSERT against the stored attributes.
721 ResourceRecordSet: toRecordSet({
722 name: output.name,
723 type: output.type,
724 ttl: output.ttl,
725 records: output.records,
726 aliasTarget: output.aliasTarget,
727 setIdentifier: output.setIdentifier,
728 weight: output.weight,
729 region: output.region,
730 failover: output.failover,
731 geoLocation: output.geoLocation,
732 geoProximityLocation: output.geoProximityLocation,
733 multiValueAnswer: output.multiValueAnswer,
734 cidrRoutingConfig: output.cidrRoutingConfig,
735 healthCheckId: output.healthCheckId,
736 }),
737 },
738 ],
739 },
740 })
741 .pipe(
742 Effect.flatMap((response) =>
743 waitForChange(response.ChangeInfo.Id),
744 ),
745 Effect.catchTag("NoSuchHostedZone", () => Effect.void),
746 Effect.catchTag("InvalidChangeBatch", () => Effect.void),
747 );
748 }),
749 };
750 }),
751 );