1import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy";
2import * as Cloudflare from "@/Cloudflare";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import { findZoneByName } from "@/Cloudflare/Zone/lookup";
5import * as Provider from "@/Provider";
6import * as Test from "@/Test/Alchemy";
7import { noopSession } from "@/Report";
8import * as Result from "effect/Result";
9import * as dns from "@distilled.cloud/cloudflare/dns";
10import * as Retry from "@distilled.cloud/cloudflare/Retry";
11import { expect } from "alchemy-test";
12import * as Cause from "effect/Cause";
13import * as Effect from "effect/Effect";
14import { MinimumLogLevel } from "effect/References";
15import * as Schedule from "effect/Schedule";
16import * as Stream from "effect/Stream";
17
18const { test } = Test.make({ providers: Cloudflare.providers() });
19
20const logLevel = Effect.provideService(
21 MinimumLogLevel,
22 process.env.DEBUG ? "Debug" : "Info",
23);
24
25const zoneName =
26 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
27
28// Deterministic per-test record names. Each test owns a disjoint subdomain so
29// reruns and parallel runs never collide, and the same name is reused on every
30// run (never derive physical names from Date.now()/random).
31const NAME_DEFAULT = `alchemy-dnsrecord-default.${zoneName}`;
32const NAME_UPDATE = `alchemy-dnsrecord-update.${zoneName}`;
33const NAME_REPLACE = `alchemy-dnsrecord-replace.${zoneName}`;
34const NAME_ADOPT = `alchemy-dnsrecord-adopt.${zoneName}`;
35const NAME_ADOPT_RELATIVE = "alchemy-dnsrecord-relative-adopt";
36const NAME_ADOPT_RELATIVE_FQDN = `${NAME_ADOPT_RELATIVE}.${zoneName}`;
37const NAME_LIST = `alchemy-dnsrecord-list.${zoneName}`;
38const NAME_MX = `alchemy-dnsrecord-mx.${zoneName}`;
39const NAME_MX_AMBIG = `alchemy-dnsrecord-mx-ambig.${zoneName}`;
40const NAME_SVCB = `_alchemy-dnsrecord._tcp.${zoneName}`;
41const NAME_SVCB_ADOPT = `_alchemy-dnsrecord-adopt._tcp.${zoneName}`;
42const NAME_HTTPS = `alchemy-dnsrecord-https.${zoneName}`;
43
44const SVCB_DATA_V1 = {
45 priority: 1,
46 target: `svc.${zoneName}.`,
47 value: 'alpn="h2" port="443"',
48} satisfies Cloudflare.DNS.RecordData;
49
50const SVCB_DATA_V2 = {
51 priority: 1,
52 target: `svc.${zoneName}.`,
53 value: 'mandatory="alpn,port" alpn="h2,h3" port="8443"',
54} satisfies Cloudflare.DNS.RecordData;
55const resolveZoneId = Effect.gen(function* () {
56 const { accountId } = yield* yield* CloudflareEnvironment;
57 const zone = yield* findZoneByName({ accountId, name: zoneName });
58 if (!zone) {
59 return yield* Effect.die(
60 new Error(`zone "${zoneName}" not found in account`),
61 );
62 }
63 return zone.id;
64});
65
66// The scoped API token the test harness mints propagates eventually-
67// consistently across Cloudflare's edge — a fresh token intermittently 403s
68// with "Unable to authenticate request". Ride out the blips on the test's
69// own out-of-band verification calls by retrying the typed `Forbidden` error
70// (part of each dns operation's error union via distilled patches).
71const forbiddenRetrySchedule = Schedule.exponential("500 millis");
72
73// Server-side `(name, type)` filter (deepObject `name.exact=…`, needs
74// distilled >= 0.24.9) plus a client-side match as defense in depth.
75const listByNameType = (zoneId: string, name: string, type: string) =>
76 dns.listRecords
77 .items({
78 zoneId,
79 name: { exact: name },
80 type: type as dns.ListRecordsRequest["type"],
81 })
82 .pipe(
83 Stream.filter((r) => r.name === name && r.type === type),
84 Stream.runCollect,
85 Effect.map((chunk) => Array.from(chunk)),
86 Effect.retry({
87 while: (e) => e._tag === "Forbidden",
88 schedule: forbiddenRetrySchedule,
89 times: 8,
90 }),
91 );
92
93const findRecord = (zoneId: string, name: string, type: string) =>
94 listByNameType(zoneId, name, type).pipe(Effect.map((rs) => rs[0]));
95
96const getRecord = (zoneId: string, dnsRecordId: string) =>
97 dns.getRecord({ zoneId, dnsRecordId }).pipe(
98 Effect.retry({
99 while: (e) => e._tag === "Forbidden",
100 schedule: forbiddenRetrySchedule,
101 times: 8,
102 }),
103 );
104
105// Delete every record matching (name, type) — used to purge leftovers from
106// interrupted runs so the adoption test starts from a clean slate.
107const purgeRecords = (zoneId: string, name: string, type: string) =>
108 listByNameType(zoneId, name, type).pipe(
109 Effect.flatMap(
110 Effect.forEach((r) =>
111 dns
112 .deleteRecord({ zoneId, dnsRecordId: r.id })
113 .pipe(Effect.catch(() => Effect.void)),
114 ),
115 ),
116 );
117
118test.provider(
119 "create and delete an A record with default props",
120 (stack) =>
121 Effect.gen(function* () {
122 const zoneId = yield* resolveZoneId;
123
124 yield* stack.destroy();
125
126 const record = yield* stack.deploy(
127 Effect.gen(function* () {
128 return yield* Cloudflare.DNS.Record("DefaultA", {
129 zoneId,
130 name: NAME_DEFAULT,
131 type: "A",
132 content: "203.0.113.10",
133 }).pipe(adopt(true));
134 }),
135 );
136
137 expect(record.recordId).toBeDefined();
138 expect(record.zoneId).toEqual(zoneId);
139 expect(record.name).toEqual(NAME_DEFAULT);
140 expect(record.type).toEqual("A");
141 expect(record.content).toEqual("203.0.113.10");
142 // Cloudflare echoes ttl=1 for "automatic" (the default).
143 expect(record.ttl).toEqual(1);
144 expect(record.proxied).toEqual(false);
145
146 const live = yield* getRecord(zoneId, record.recordId);
147 expect(live.id).toEqual(record.recordId);
148 expect(live.name).toEqual(NAME_DEFAULT);
149 expect(live.type).toEqual("A");
150 expect(live.content).toEqual("203.0.113.10");
151
152 yield* stack.destroy();
153
154 const gone = yield* findRecord(zoneId, NAME_DEFAULT, "A");
155 expect(gone).toBeUndefined();
156 }).pipe(logLevel),
157 {
158 tags: [
159 "provider:cloudflare",
160 "provider:cloudflare:dns",
161 "provider:cloudflare:zone",
162 "live",
163 ],
164 },
165);
166
167test.provider(
168 "creates, lists, and updates an SVCB record from structured data",
169 (stack) =>
170 Effect.gen(function* () {
171 const zoneId = yield* resolveZoneId;
172
173 yield* stack.destroy();
174 yield* purgeRecords(zoneId, NAME_SVCB, "SVCB");
175
176 const initial = yield* stack.deploy(
177 Effect.gen(function* () {
178 return yield* Cloudflare.DNS.Record("StructuredSvcb", {
179 zoneId,
180 name: NAME_SVCB,
181 type: "SVCB",
182 content: SVCB_DATA_V1,
183 }).pipe(adopt(true));
184 }),
185 );
186
187 expect(initial.type).toEqual("SVCB");
188 expect(typeof initial.content).toEqual("string");
189 expect(initial.data).toEqual(SVCB_DATA_V1);
190
191 const created = yield* getRecord(zoneId, initial.recordId);
192 expect(created.type).toEqual("SVCB");
193 expect("data" in created ? created.data : undefined).toEqual(
194 SVCB_DATA_V1,
195 );
196
197 const provider = yield* Provider.findProvider(Cloudflare.DNS.Record);
198 const listed = (yield* provider.list()).find(
199 (record) => record.recordId === initial.recordId,
200 );
201 expect(listed?.data).toEqual(SVCB_DATA_V1);
202
203 // Introduce drift out of band, then change the desired data. Reconcile
204 // must compare against the observed components and patch in place.
205 yield* dns.updateRecord({
206 zoneId,
207 dnsRecordId: initial.recordId,
208 name: NAME_SVCB,
209 type: "SVCB",
210 ttl: 1,
211 data: { ...SVCB_DATA_V1, value: 'alpn="h3" port="9443"' },
212 });
213
214 const updated = yield* stack.deploy(
215 Effect.gen(function* () {
216 return yield* Cloudflare.DNS.Record("StructuredSvcb", {
217 zoneId,
218 name: NAME_SVCB,
219 type: "SVCB",
220 content: SVCB_DATA_V2,
221 }).pipe(adopt(true));
222 }),
223 );
224
225 expect(updated.recordId).toEqual(initial.recordId);
226 expect(updated.data).toEqual(SVCB_DATA_V2);
227
228 const live = yield* getRecord(zoneId, updated.recordId);
229 expect("data" in live ? live.data : undefined).toEqual(SVCB_DATA_V2);
230
231 yield* stack.destroy();
232
233 const gone = yield* findRecord(zoneId, NAME_SVCB, "SVCB");
234 expect(gone).toBeUndefined();
235 }).pipe(logLevel),
236 {
237 tags: [
238 "provider:cloudflare",
239 "provider:cloudflare:dns",
240 "provider:cloudflare:zone",
241 "live",
242 ],
243 timeout: 120_000,
244 },
245);
246
247test.provider(
248 "creates and deletes an HTTPS record from structured data",
249 (stack) =>
250 Effect.gen(function* () {
251 const zoneId = yield* resolveZoneId;
252 const data = {
253 priority: 1,
254 target: ".",
255 value: 'alpn="h2,h3"',
256 } satisfies Cloudflare.DNS.RecordData;
257
258 yield* stack.destroy();
259 yield* purgeRecords(zoneId, NAME_HTTPS, "HTTPS");
260
261 const record = yield* stack.deploy(
262 Effect.gen(function* () {
263 return yield* Cloudflare.DNS.Record("StructuredHttps", {
264 zoneId,
265 name: NAME_HTTPS,
266 type: "HTTPS",
267 content: data,
268 }).pipe(adopt(true));
269 }),
270 );
271
272 expect(record.type).toEqual("HTTPS");
273 expect(record.data).toEqual(data);
274
275 const live = yield* getRecord(zoneId, record.recordId);
276 expect(live.type).toEqual("HTTPS");
277 expect("data" in live ? live.data : undefined).toEqual(data);
278
279 yield* stack.destroy();
280
281 const gone = yield* findRecord(zoneId, NAME_HTTPS, "HTTPS");
282 expect(gone).toBeUndefined();
283 }).pipe(logLevel),
284 {
285 tags: [
286 "provider:cloudflare",
287 "provider:cloudflare:dns",
288 "provider:cloudflare:zone",
289 "live",
290 ],
291 timeout: 120_000,
292 },
293);
294
295test.provider(
296 "updating mutable fields patches in place",
297 (stack) =>
298 Effect.gen(function* () {
299 const zoneId = yield* resolveZoneId;
300
301 yield* stack.destroy();
302
303 const initial = yield* stack.deploy(
304 Effect.gen(function* () {
305 return yield* Cloudflare.DNS.Record("UpdateA", {
306 zoneId,
307 name: NAME_UPDATE,
308 type: "A",
309 content: "203.0.113.20",
310 ttl: 300,
311 comment: "v1",
312 }).pipe(adopt(true));
313 }),
314 );
315
316 expect(initial.content).toEqual("203.0.113.20");
317 expect(initial.ttl).toEqual(300);
318
319 const updated = yield* stack.deploy(
320 Effect.gen(function* () {
321 return yield* Cloudflare.DNS.Record("UpdateA", {
322 zoneId,
323 name: NAME_UPDATE,
324 type: "A",
325 content: "203.0.113.21",
326 ttl: 600,
327 comment: "v2",
328 }).pipe(adopt(true));
329 }),
330 );
331
332 // Same record patched in place — not a replacement.
333 expect(updated.recordId).toEqual(initial.recordId);
334 expect(updated.content).toEqual("203.0.113.21");
335 expect(updated.ttl).toEqual(600);
336
337 const live = yield* getRecord(zoneId, updated.recordId);
338 expect(live.content).toEqual("203.0.113.21");
339 expect(live.ttl).toEqual(600);
340 expect(live.comment).toEqual("v2");
341
342 yield* stack.destroy();
343 }).pipe(logLevel),
344 {
345 tags: [
346 "provider:cloudflare",
347 "provider:cloudflare:dns",
348 "provider:cloudflare:zone",
349 "live",
350 ],
351 },
352);
353
354test.provider(
355 "changing the record type triggers replacement",
356 (stack) =>
357 Effect.gen(function* () {
358 const zoneId = yield* resolveZoneId;
359
360 yield* stack.destroy();
361
362 const initial = yield* stack.deploy(
363 Effect.gen(function* () {
364 return yield* Cloudflare.DNS.Record("ReplaceRecord", {
365 zoneId,
366 name: NAME_REPLACE,
367 type: "A",
368 content: "203.0.113.30",
369 }).pipe(adopt(true));
370 }),
371 );
372
373 expect(initial.type).toEqual("A");
374
375 const replaced = yield* stack.deploy(
376 Effect.gen(function* () {
377 return yield* Cloudflare.DNS.Record("ReplaceRecord", {
378 zoneId,
379 name: NAME_REPLACE,
380 type: "TXT",
381 content: '"alchemy-replace-test"',
382 }).pipe(adopt(true));
383 }),
384 );
385
386 // (name, type) is the record's identity — a new physical record exists.
387 expect(replaced.recordId).not.toEqual(initial.recordId);
388 expect(replaced.type).toEqual("TXT");
389
390 // The old A record was deleted as part of the replacement.
391 const oldRecord = yield* findRecord(zoneId, NAME_REPLACE, "A");
392 expect(oldRecord).toBeUndefined();
393
394 const live = yield* getRecord(zoneId, replaced.recordId);
395 expect(live.type).toEqual("TXT");
396
397 yield* stack.destroy();
398 }).pipe(logLevel),
399 {
400 tags: [
401 "provider:cloudflare",
402 "provider:cloudflare:dns",
403 "provider:cloudflare:zone",
404 "live",
405 ],
406 },
407);
408
409test.provider(
410 "adoption — existing record errors without adopt, takes over with adopt(true)",
411 (stack) =>
412 Effect.gen(function* () {
413 const zoneId = yield* resolveZoneId;
414
415 yield* stack.destroy();
416 // Purge leftovers from interrupted runs (Cloudflare allows duplicate
417 // A records for the same name, so a leaked record would not surface
418 // as a create conflict).
419 yield* purgeRecords(zoneId, NAME_ADOPT, "A");
420
421 // Create the record out-of-band so the stack has no state of its own
422 // for it — exactly the "the record already exists" scenario.
423 const pre = yield* dns
424 .createRecord({
425 zoneId,
426 name: NAME_ADOPT,
427 type: "A",
428 content: "203.0.113.40",
429 ttl: 1,
430 comment: "pre-existing",
431 })
432 .pipe(
433 Effect.retry({
434 while: (e) => e._tag === "Forbidden",
435 schedule: forbiddenRetrySchedule,
436 times: 8,
437 }),
438 );
439 expect(pre.id).toBeDefined();
440
441 // Without `adopt`: DNS records carry no ownership markers, so the
442 // engine cannot prove we created it and refuses to take it over.
443 const error = yield* stack
444 .deploy(
445 Effect.gen(function* () {
446 return yield* Cloudflare.DNS.Record("AdoptedRecord", {
447 zoneId,
448 name: NAME_ADOPT,
449 type: "A",
450 content: "203.0.113.41",
451 });
452 }),
453 )
454 .pipe(
455 Effect.as(undefined),
456 Effect.catchCause((cause) => Effect.succeed(findOwnedError(cause))),
457 );
458 expect(error).toBeInstanceOf(OwnedBySomeoneElse);
459
460 // With `adopt(true)`: the engine takes over the pre-existing record
461 // (same physical id) and converges it to the desired content.
462 const adopted = yield* stack.deploy(
463 Effect.gen(function* () {
464 return yield* Cloudflare.DNS.Record("AdoptedRecord", {
465 zoneId,
466 name: NAME_ADOPT,
467 type: "A",
468 content: "203.0.113.41",
469 }).pipe(adopt(true));
470 }),
471 );
472
473 expect(adopted.recordId).toEqual(pre.id);
474 expect(adopted.content).toEqual("203.0.113.41");
475
476 const live = yield* getRecord(zoneId, adopted.recordId);
477 expect(live.content).toEqual("203.0.113.41");
478
479 yield* stack.destroy();
480
481 const gone = yield* findRecord(zoneId, NAME_ADOPT, "A");
482 expect(gone).toBeUndefined();
483 }).pipe(logLevel),
484 {
485 tags: [
486 "provider:cloudflare",
487 "provider:cloudflare:dns",
488 "provider:cloudflare:zone",
489 "live",
490 ],
491 },
492);
493
494test.provider(
495 "adoption converges an existing structured SVCB record",
496 (stack) =>
497 Effect.gen(function* () {
498 const zoneId = yield* resolveZoneId;
499
500 yield* stack.destroy();
501 yield* purgeRecords(zoneId, NAME_SVCB_ADOPT, "SVCB");
502
503 const pre = yield* dns.createRecord({
504 zoneId,
505 name: NAME_SVCB_ADOPT,
506 type: "SVCB",
507 ttl: 1,
508 data: SVCB_DATA_V1,
509 });
510
511 const error = yield* stack
512 .deploy(
513 Effect.gen(function* () {
514 return yield* Cloudflare.DNS.Record("AdoptedStructuredSvcb", {
515 zoneId,
516 name: NAME_SVCB_ADOPT,
517 type: "SVCB",
518 content: SVCB_DATA_V2,
519 });
520 }),
521 )
522 .pipe(
523 Effect.as(undefined),
524 Effect.catchCause((cause) => Effect.succeed(findOwnedError(cause))),
525 );
526 expect(error).toBeInstanceOf(OwnedBySomeoneElse);
527
528 const adopted = yield* stack.deploy(
529 Effect.gen(function* () {
530 return yield* Cloudflare.DNS.Record("AdoptedStructuredSvcb", {
531 zoneId,
532 name: NAME_SVCB_ADOPT,
533 type: "SVCB",
534 content: SVCB_DATA_V2,
535 }).pipe(adopt(true));
536 }),
537 );
538
539 expect(adopted.recordId).toEqual(pre.id);
540 expect(adopted.data).toEqual(SVCB_DATA_V2);
541
542 const live = yield* getRecord(zoneId, adopted.recordId);
543 expect("data" in live ? live.data : undefined).toEqual(SVCB_DATA_V2);
544
545 yield* stack.destroy();
546
547 const gone = yield* findRecord(zoneId, NAME_SVCB_ADOPT, "SVCB");
548 expect(gone).toBeUndefined();
549 }).pipe(logLevel),
550 {
551 tags: [
552 "provider:cloudflare",
553 "provider:cloudflare:dns",
554 "provider:cloudflare:zone",
555 "live",
556 ],
557 timeout: 120_000,
558 },
559);
560
561test.provider(
562 "adoption finds a normalized FQDN when given a relative record name",
563 (stack) =>
564 Effect.gen(function* () {
565 const zoneId = yield* resolveZoneId;
566
567 yield* stack.destroy();
568 yield* purgeRecords(zoneId, NAME_ADOPT_RELATIVE_FQDN, "TXT");
569
570 const pre = yield* dns.createRecord({
571 zoneId,
572 name: NAME_ADOPT_RELATIVE_FQDN,
573 type: "TXT",
574 content: "pre-existing",
575 ttl: 1,
576 });
577
578 const adopted = yield* stack.deploy(
579 Effect.gen(function* () {
580 return yield* Cloudflare.DNS.Record("RelativeAdoptedRecord", {
581 zoneId,
582 name: NAME_ADOPT_RELATIVE,
583 type: "TXT",
584 content: "adopted",
585 }).pipe(adopt(true));
586 }),
587 );
588
589 expect(adopted.recordId).toEqual(pre.id);
590 expect(adopted.name).toEqual(NAME_ADOPT_RELATIVE_FQDN);
591 expect(adopted.content).toEqual("adopted");
592
593 yield* stack.destroy();
594
595 const gone = yield* findRecord(zoneId, NAME_ADOPT_RELATIVE_FQDN, "TXT");
596 expect(gone).toBeUndefined();
597 }).pipe(logLevel),
598 {
599 tags: [
600 "provider:cloudflare",
601 "provider:cloudflare:dns",
602 "provider:cloudflare:zone",
603 "live",
604 ],
605 },
606);
607
608// Several records legitimately share `(name, type)` — e.g. a primary MX and
609// its fallback. Adoption must select the record whose `content`/`priority`
610// match the declaration exactly, never "the first match" (#1262).
611test.provider(
612 "adoption disambiguates records sharing (name, type) by content/priority",
613 (stack) =>
614 Effect.gen(function* () {
615 const zoneId = yield* resolveZoneId;
616
617 yield* stack.destroy();
618 yield* purgeRecords(zoneId, NAME_MX, "MX");
619
620 // Two pre-existing MX records for the same name — only content and
621 // priority tell them apart.
622 const prePrimary = yield* dns
623 .createRecord({
624 zoneId,
625 name: NAME_MX,
626 type: "MX",
627 content: `mx1.${zoneName}`,
628 priority: 1,
629 ttl: 1,
630 })
631 .pipe(
632 Effect.retry({
633 while: (e) => e._tag === "Forbidden",
634 schedule: forbiddenRetrySchedule,
635 times: 8,
636 }),
637 );
638 const preBackup = yield* dns.createRecord({
639 zoneId,
640 name: NAME_MX,
641 type: "MX",
642 content: `mx2.${zoneName}`,
643 priority: 10,
644 ttl: 1,
645 });
646
647 const { primary, backup } = yield* stack.deploy(
648 Effect.gen(function* () {
649 const primary = yield* Cloudflare.DNS.Record("PrimaryMx", {
650 zoneId,
651 name: NAME_MX,
652 type: "MX",
653 content: `mx1.${zoneName}`,
654 priority: 1,
655 }).pipe(adopt(true));
656 const backup = yield* Cloudflare.DNS.Record("BackupMx", {
657 zoneId,
658 name: NAME_MX,
659 type: "MX",
660 content: `mx2.${zoneName}`,
661 priority: 10,
662 }).pipe(adopt(true));
663 return { primary, backup };
664 }),
665 );
666
667 // Each logical resource adopted its own physical record.
668 expect(primary.recordId).toEqual(prePrimary.id);
669 expect(backup.recordId).toEqual(preBackup.id);
670 expect(primary.recordId).not.toEqual(backup.recordId);
671
672 // Adopt-then-modify: change the backup's priority; same physical id.
673 const changed = yield* stack.deploy(
674 Effect.gen(function* () {
675 const primary = yield* Cloudflare.DNS.Record("PrimaryMx", {
676 zoneId,
677 name: NAME_MX,
678 type: "MX",
679 content: `mx1.${zoneName}`,
680 priority: 1,
681 }).pipe(adopt(true));
682 const backup = yield* Cloudflare.DNS.Record("BackupMx", {
683 zoneId,
684 name: NAME_MX,
685 type: "MX",
686 content: `mx2.${zoneName}`,
687 priority: 20,
688 }).pipe(adopt(true));
689 return { primary, backup };
690 }),
691 );
692 expect(changed.backup.recordId).toEqual(preBackup.id);
693 const liveBackup = yield* getRecord(zoneId, changed.backup.recordId);
694 expect(
695 "priority" in liveBackup ? liveBackup.priority : undefined,
696 ).toEqual(20);
697 const livePrimary = yield* getRecord(zoneId, changed.primary.recordId);
698 expect(
699 "priority" in livePrimary ? livePrimary.priority : undefined,
700 ).toEqual(1);
701
702 yield* stack.destroy();
703
704 const leftovers = yield* listByNameType(zoneId, NAME_MX, "MX");
705 expect(leftovers).toEqual([]);
706 }).pipe(logLevel),
707 {
708 tags: [
709 "provider:cloudflare",
710 "provider:cloudflare:dns",
711 "provider:cloudflare:zone",
712 "live",
713 ],
714 },
715);
716
717// When multiple candidates survive the content/priority filter, adoption must
718// fail with an actionable error instead of picking one arbitrarily.
719test.provider(
720 "adoption fails with an actionable error when the match stays ambiguous",
721 (stack) =>
722 Effect.gen(function* () {
723 const zoneId = yield* resolveZoneId;
724
725 yield* stack.destroy();
726 yield* purgeRecords(zoneId, NAME_MX_AMBIG, "MX");
727
728 // Same content on both records — only priority differs. A declaration
729 // without a priority cannot select one.
730 yield* dns
731 .createRecord({
732 zoneId,
733 name: NAME_MX_AMBIG,
734 type: "MX",
735 content: `mx.${zoneName}`,
736 priority: 1,
737 ttl: 1,
738 })
739 .pipe(
740 Effect.retry({
741 while: (e) => e._tag === "Forbidden",
742 schedule: forbiddenRetrySchedule,
743 times: 8,
744 }),
745 );
746 yield* dns.createRecord({
747 zoneId,
748 name: NAME_MX_AMBIG,
749 type: "MX",
750 content: `mx.${zoneName}`,
751 priority: 10,
752 ttl: 1,
753 });
754
755 const error = yield* stack
756 .deploy(
757 Effect.gen(function* () {
758 return yield* Cloudflare.DNS.Record("AmbiguousMx", {
759 zoneId,
760 name: NAME_MX_AMBIG,
761 type: "MX",
762 content: `mx.${zoneName}`,
763 }).pipe(adopt(true));
764 }),
765 )
766 .pipe(
767 Effect.as(undefined),
768 Effect.catchCause((cause) => Effect.succeed(findError(cause))),
769 );
770 expect(error).toBeInstanceOf(Cloudflare.DNS.AmbiguousDnsRecordError);
771 expect(String(error)).toContain("Multiple DNS records");
772
773 yield* purgeRecords(zoneId, NAME_MX_AMBIG, "MX");
774 yield* stack.destroy();
775 }).pipe(logLevel),
776 {
777 tags: [
778 "provider:cloudflare",
779 "provider:cloudflare:dns",
780 "provider:cloudflare:zone",
781 "live",
782 ],
783 },
784);
785
786// Canonical `list()` test (zone-scoped collection): `list()` enumerates every
787// zone via `listAllZones`, exhaustively paginates each zone's DNS records, and
788// hydrates them into the `read` Attributes shape. Deploy a record and assert it
789// appears in the exhaustively-paginated result.
790test.provider(
791 "list enumerates the deployed DNS record",
792 (stack) =>
793 Effect.gen(function* () {
794 const zoneId = yield* resolveZoneId;
795
796 yield* stack.destroy();
797
798 const record = yield* stack.deploy(
799 Effect.gen(function* () {
800 return yield* Cloudflare.DNS.Record("ListedA", {
801 zoneId,
802 name: NAME_LIST,
803 type: "A",
804 content: "203.0.113.50",
805 }).pipe(adopt(true));
806 }),
807 );
808
809 const provider = yield* Provider.findProvider(Cloudflare.DNS.Record);
810 const all = yield* provider.list();
811
812 expect(all.length).toBeGreaterThan(0);
813 expect(all.some((r) => r.recordId === record.recordId)).toBe(true);
814 const found = all.find((r) => r.recordId === record.recordId);
815 expect(found?.zoneId).toEqual(zoneId);
816 expect(found?.name).toEqual(NAME_LIST);
817 expect(found?.type).toEqual("A");
818 expect(found?.content).toEqual("203.0.113.50");
819
820 yield* stack.destroy();
821 }).pipe(logLevel),
822 {
823 tags: [
824 "provider:cloudflare",
825 "provider:cloudflare:dns",
826 "provider:cloudflare:zone",
827 "live",
828 ],
829 },
830);
831
832/**
833 * Pull the first failure/defect value out of a Cause regardless of whether
834 * the engine raised it as a typed failure or a defect.
835 */
836const findError = (cause: Cause.Cause<unknown>): unknown =>
837 cause.reasons
838 .map((reason) =>
839 Cause.isFailReason(reason)
840 ? reason.error
841 : Cause.isDieReason(reason)
842 ? reason.defect
843 : undefined,
844 )
845 .find((value) => value !== undefined);
846
847/**
848 * Pull the {@link OwnedBySomeoneElse} value out of a Cause regardless of
849 * whether the engine raised it as a typed failure or a defect.
850 */
851const findOwnedError = (
852 cause: Cause.Cause<unknown>,
853): OwnedBySomeoneElse | undefined =>
854 cause.reasons
855 .map((reason) =>
856 Cause.isFailReason(reason)
857 ? reason.error
858 : Cause.isDieReason(reason)
859 ? reason.defect
860 : undefined,
861 )
862 .find(
863 (value): value is OwnedBySomeoneElse =>
864 value instanceof OwnedBySomeoneElse,
865 );
866
867test.provider(
868 "missing DNS records recover on read and delete while other API errors propagate",
869 (stack) =>
870 Effect.gen(function* () {
871 yield* stack.destroy();
872 const zoneId = yield* resolveZoneId;
873 const name = `alchemy-dnsrecord-missing.${zoneName}`;
874 const props = { zoneId, name, type: "A" as const, content: "192.0.2.1" };
875 const record = yield* stack.deploy(
876 Cloudflare.DNS.Record("MissingRecord", props).pipe(adopt(true)),
877 );
878 const provider = yield* Provider.findProvider(Cloudflare.DNS.Record);
879 const input = {
880 id: "MissingRecord",
881 fqn: "MissingRecord",
882 instanceId: "missing-record-test",
883 olds: props,
884 output: record,
885 bindings: [],
886 session: { ...noopSession, note: () => Effect.void },
887 };
888
889 // The real API must reject an inaccessible zone; neither lifecycle may
890 // hide that failure as if the record had simply been deleted.
891 const inaccessible = {
892 ...input,
893 output: { ...record, zoneId: "00000000000000000000000000000000" },
894 };
895 for (const operation of [
896 provider.read!(inaccessible),
897 provider.delete(inaccessible),
898 ]) {
899 const result = yield* operation.pipe(Retry.none, Effect.result);
900 expect(Result.isFailure(result)).toBe(true);
901 if (Result.isFailure(result))
902 expect(result.failure._tag).not.toBe("RecordNotFound");
903 }
904
905 // Delete out of band to exercise stale persisted state against Cloudflare.
906 yield* dns.deleteRecord({ zoneId, dnsRecordId: record.recordId });
907 const missing = yield* dns
908 .getRecord({ zoneId, dnsRecordId: record.recordId })
909 .pipe(Effect.flip);
910 expect(missing._tag).toBe("RecordNotFound");
911 expect(yield* provider.read!(input)).toBeUndefined();
912 yield* provider.delete(input);
913 yield* provider.delete(input);
914 yield* stack.destroy();
915 }).pipe(logLevel),
916 {
917 tags: [
918 "provider:cloudflare",
919 "provider:cloudflare:dns",
920 "provider:cloudflare:zone",
921 "live",
922 ],
923 timeout: 120000,
924 },
925);