1import * as dns from "@distilled.cloud/cloudflare/dns";
2import * as zones from "@distilled.cloud/cloudflare/zones";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Stream from "effect/Stream";
6
7import { Unowned } from "../../AdoptPolicy.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { arrayEqualsUnordered } from "../../Util/equal.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13import { listAllZones } from "../Zone/lookup.ts";
14
15/**
16 * DNS record type literal — every value Cloudflare recognises. Stable
17 * across reconciles; changing it triggers a replacement because record
18 * type is part of a record's identity.
19 */
20export type RecordType =
21 | "A"
22 | "AAAA"
23 | "CNAME"
24 | "MX"
25 | "NS"
26 | "OPENPGPKEY"
27 | "PTR"
28 | "TXT"
29 | "CAA"
30 | "CERT"
31 | "DNSKEY"
32 | "DS"
33 | "HTTPS"
34 | "LOC"
35 | "NAPTR"
36 | "SMIMEA"
37 | "SRV"
38 | "SSHFP"
39 | "SVCB"
40 | "TLSA"
41 | "URI"
42 | (string & {});
43
44/**
45 * Cloudflare's structured DNS record components, keyed by record type.
46 */
47export interface RecordDataByType {
48 CAA: dns.RecordsCreateRequestDataCAARecord;
49 CERT: dns.RecordsCreateRequestDataCERTRecord;
50 DNSKEY: dns.RecordsCreateRequestDataDNSKEYRecord;
51 DS: dns.RecordsCreateRequestDataDSRecord;
52 HTTPS: dns.RecordsCreateRequestDataHTTPSRecord;
53 LOC: dns.RecordsCreateRequestDataLOCRecord;
54 NAPTR: dns.RecordsCreateRequestDataNAPTRRecord;
55 SMIMEA: dns.RecordsCreateRequestDataSMIMEARecord;
56 SRV: dns.RecordsCreateRequestDataSRVRecord;
57 SSHFP: dns.RecordsCreateRequestDataSSHFPRecord;
58 SVCB: dns.RecordsCreateRequestDataHTTPSRecord;
59 TLSA: dns.RecordsCreateRequestDataSMIMEARecord;
60 URI: dns.RecordsCreateRequestDataURIRecord;
61}
62
63/** Structured DNS record components accepted by Cloudflare. */
64export type RecordData = RecordDataByType[keyof RecordDataByType];
65
66export interface RecordCommonProps {
67 /**
68 * Zone the record lives in. Stable — changing the zone triggers
69 * replacement.
70 */
71 zoneId: string;
72 /**
73 * Fully-qualified or zone-relative record name (e.g.
74 * `cluster-admin.microtrack.ai`, `_dmarc`, or `@` for the zone apex).
75 * Cloudflare normalizes relative names to their fully-qualified form.
76 *
77 * Stable — Cloudflare treats `(name, type)` as the record's identity,
78 * so a rename is a delete + create. Declared as plain `string` (not
79 * `string`) so it is statically knowable inside `diff`.
80 */
81 name: string;
82 /**
83 * TTL in seconds (`60`–`86400`), or `"1"` for Cloudflare's "automatic"
84 * setting. Must be `"1"` when `proxied` is `true`.
85 *
86 * @default "1"
87 */
88 ttl?: number | "1";
89 /**
90 * Whether to send the record through Cloudflare's proxy (orange-clouded
91 * in the dashboard). Only valid for proxiable record types
92 * (`A`, `AAAA`, `CNAME`).
93 *
94 * @default false
95 */
96 proxied?: boolean;
97 /**
98 * Free-form comment shown in the dashboard. No effect on DNS responses.
99 */
100 comment?: string;
101 /**
102 * Custom tags shown in the dashboard. No effect on DNS responses.
103 */
104 tags?: ReadonlyArray<string>;
105}
106
107type StringRecordType = Exclude<RecordType, keyof RecordDataByType | "MX">;
108
109type StringRecordProps = {
110 /** Record type. Stable — changing triggers replacement. */
111 type: StringRecordType;
112 /** Formatted record value. Mutable — patched in place. */
113 content: string;
114 priority?: never;
115};
116
117interface MxRecordProps {
118 /** Record type. Stable — changing triggers replacement. */
119 type: "MX";
120 /** Mail server hostname. Mutable — patched in place. */
121 content: string;
122 /** Mail server priority; lower values are preferred. */
123 priority?: number;
124}
125
126type StructuredRecordProps = {
127 [Type in keyof RecordDataByType]: {
128 /** Record type. Stable — changing triggers replacement. */
129 type: Type;
130 /**
131 * Record value as formatted DNS content or typed Cloudflare components.
132 * Mutable — patched in place.
133 */
134 content: string | RecordDataByType[Type];
135 /**
136 * Top-level priority, used by URI records. SRV, SVCB, and HTTPS put their
137 * priority inside structured `content`.
138 */
139 priority?: Type extends "URI" ? number : never;
140 };
141}[keyof RecordDataByType];
142
143/** Input properties for a Cloudflare DNS record. */
144export type RecordProps = RecordCommonProps &
145 (StringRecordProps | MxRecordProps | StructuredRecordProps);
146
147export interface RecordAttributes {
148 /** Cloudflare-assigned DNS record UUID. */
149 recordId: string;
150 /** Zone that owns this record. */
151 zoneId: string;
152 /** Record name (FQDN, as Cloudflare returns it). */
153 name: string;
154 /** Record type. */
155 type: RecordType;
156 /** Formatted record value returned by Cloudflare. */
157 content: string;
158 /** Structured record components, when Cloudflare returns them. */
159 data?: RecordData;
160 /** Resolved TTL (Cloudflare echoes `1` for "automatic"). */
161 ttl: number;
162 /** Whether the record is proxied. */
163 proxied: boolean;
164 /** ISO8601 creation timestamp. */
165 createdOn: string | undefined;
166 /** ISO8601 last-modified timestamp. */
167 modifiedOn: string | undefined;
168}
169
170export type Record = Resource<
171 "Cloudflare.DNS.Record",
172 RecordProps,
173 RecordAttributes,
174 never,
175 Providers
176>;
177
178/**
179 * A single DNS record on a Cloudflare-managed zone.
180 *
181 * Safety: when there is no prior state, `read` scans the zone for an
182 * existing `(name, type)` match. DNS records carry no ownership markers
183 * we can inspect, so an existing match is reported as `Unowned` and the
184 * engine refuses to take it over unless `--adopt` (or `adopt(true)`) is
185 * set. This protects hand-edited records (especially the apex `A`/`AAAA`
186 * and email DKIM/SPF records that the dashboard often manages) from
187 * being clobbered.
188 *
189 * Several records may legitimately share `(name, type)` — MX fallbacks,
190 * multiple TXT records, round-robin A records. When the scan finds more
191 * than one candidate, the declared `content` (and `priority`) must match
192 * exactly one record; a record with no exact match is treated as missing
193 * (a new sibling record is created), and a still-ambiguous match fails
194 * with an error listing the candidates. To adopt one record out of such
195 * a set, declare its current `content`/`priority` verbatim first, then
196 * change them in a follow-up deploy.
197 * ### Proxied CNAME pointing at a tunnel
198 * **Example:** Route a subdomain through a Cloudflare Tunnel
199 * ```typescript
200 * yield* Cloudflare.DNS.Record("AdminCname", {
201 * zoneId: zone.zoneId,
202 * name: "cluster-admin.example.com",
203 * type: "CNAME",
204 * content: `${tunnel.tunnelId}.cfargotunnel.com`,
205 * proxied: true,
206 * comment: "research admin UI",
207 * });
208 * ```
209 *
210 * ### Plain A record
211 * **Example:** Direct A record (not proxied)
212 * ```typescript
213 * yield* Cloudflare.DNS.Record("ApiA", {
214 * zoneId: zone.zoneId,
215 * name: "api.example.com",
216 * type: "A",
217 * content: "203.0.113.42",
218 * ttl: 300,
219 * });
220 * ```
221 *
222 * ### Structured service binding records
223 * **Example:** SVCB record
224 * ```typescript
225 * yield* Cloudflare.DNS.Record("McpSvcb", {
226 * zoneId: zone.zoneId,
227 * name: "_mcp._agents.example.com",
228 * type: "SVCB",
229 * content: {
230 * priority: 1,
231 * target: "mcp.example.com.",
232 * value: 'mandatory="alpn,port" alpn="h2,h3" port="443"',
233 * },
234 * });
235 * ```
236 *
237 * **Example:** HTTPS record
238 * ```typescript
239 * yield* Cloudflare.DNS.Record("WebsiteHttps", {
240 * zoneId: zone.zoneId,
241 * name: "example.com",
242 * type: "HTTPS",
243 * content: {
244 * priority: 1,
245 * target: ".",
246 * value: 'alpn="h2,h3"',
247 * },
248 * });
249 * ```
250 *
251 * @resource
252 * @product DNS
253 * @category Domains & DNS
254 */
255export const Record = Resource<Record>("Cloudflare.DNS.Record", {
256 aliases: ["Cloudflare.Dns.Record"],
257});
258
259export const RecordProvider = () =>
260 Provider.succeed(Record, {
261 stables: ["recordId", "zoneId", "type", "name"],
262
263 // Zone-scoped collection: DNS records live under `/zones/{id}/dns_records`
264 // with no account-wide enumeration API. Fan out over every zone in the
265 // account, exhaustively paginate each zone's records, and hydrate each into
266 // the same `Attributes` shape `read` produces. A fresh scoped token can 403
267 // a zone (eventual consistency) or a zone may be partially provisioned —
268 // skip those zones (-> []) rather than failing the whole enumeration.
269 list: Effect.fn(function* () {
270 const { accountId } = yield* yield* CloudflareEnvironment;
271 const zones = yield* listAllZones(accountId);
272 const rows = yield* Effect.forEach(
273 zones,
274 (zone) =>
275 dns.listRecords.pages({ zoneId: zone.id }).pipe(
276 Stream.runCollect,
277 Effect.map((chunk) =>
278 Array.from(chunk).flatMap((page) =>
279 (page.result ?? []).flatMap((r) => {
280 const attrs = toAttributes(
281 narrowRecord(r as Parameters<typeof narrowRecord>[0]),
282 zone.id,
283 );
284 return attrs ? [attrs] : [];
285 }),
286 ),
287 ),
288 Effect.catchTag("Forbidden", () =>
289 Effect.succeed([] as RecordAttributes[]),
290 ),
291 ),
292 { concurrency: 10 },
293 );
294 return rows.flat();
295 }),
296
297 diff: Effect.fn(function* ({ olds = {}, news }) {
298 const o = olds as RecordProps;
299 const n = news as RecordProps;
300 if (o.type !== undefined && o.type !== n.type) {
301 return { action: "replace" } as const;
302 }
303 if (o.name !== undefined && o.name !== n.name) {
304 return { action: "replace" } as const;
305 }
306 // zoneId is Input<string>; by reconcile time both sides are
307 // concrete strings.
308 if (
309 typeof o.zoneId === "string" &&
310 typeof n.zoneId === "string" &&
311 o.zoneId !== n.zoneId
312 ) {
313 return { action: "replace" } as const;
314 }
315 }),
316
317 reconcile: Effect.fn(function* ({ news, output }) {
318 // Inputs have been resolved to concrete strings by Plan.
319 const zoneId = news.zoneId as string;
320 const body = buildMutableBody(news);
321
322 // 1. Observe by cached id first.
323 let observed: ObservedRecord | undefined;
324 if (output?.recordId) {
325 observed = yield* observeById(zoneId, output.recordId);
326 }
327
328 // 2. Fall back to scanning the zone for a (name, type) match.
329 // Ownership has already been verified upstream — `read` reports
330 // existing records as `Unowned` and the engine gates takeover
331 // behind the adopt policy before reconcile ever runs.
332 let foundByScan = false;
333 if (!observed) {
334 const existing = yield* findByNameType(zoneId, news.name, news.type, {
335 content: body.content,
336 data: body.data,
337 priority: news.priority,
338 });
339 if (existing) {
340 foundByScan = true;
341 observed = existing;
342 }
343 }
344
345 // 3. Ensure.
346 if (!observed) {
347 const created = yield* dns.createRecord({ zoneId, ...body }).pipe(
348 Effect.map(
349 (r) =>
350 ({
351 record: narrowRecord(r as Parameters<typeof narrowRecord>[0]),
352 raced: false,
353 }) as const,
354 ),
355 // A record with this `(name, type)` can already exist that the
356 // scan above missed — a leftover from an interrupted run, or a
357 // concurrent reconcile that won the create race. Cloudflare
358 // answers `An identical record already exists.`
359 // (`DnsRecordAlreadyExists`). Self-heal: re-scan and adopt the
360 // existing record instead of failing the deploy. Ownership was
361 // already gated by `read`/the adopt policy upstream.
362 Effect.catchTag("DnsRecordAlreadyExists", () =>
363 findByNameType(zoneId, news.name, news.type, {
364 content: body.content,
365 data: body.data,
366 priority: news.priority,
367 }).pipe(
368 Effect.flatMap((existing) =>
369 existing
370 ? Effect.succeed({ record: existing, raced: true } as const)
371 : Effect.fail(
372 new Error(
373 `Cloudflare reported an identical DNS record for ` +
374 `(${news.name}, ${news.type}) but it could not be found`,
375 ),
376 ),
377 ),
378 ),
379 ),
380 );
381 observed = created.record;
382 // A raced/adopted record is treated like a scanned-existing one so
383 // the sync step converges its mutable fields; a genuine fresh create
384 // keeps `foundByScan` false so the no-op first-reconcile suppression
385 // below still applies.
386 if (created.raced) foundByScan = true;
387 }
388
389 // 4. Sync — Cloudflare's update endpoint is PUT-style; resend
390 // the full desired body when any mutable field differs.
391 if (!observed.id) {
392 return yield* Effect.fail(
393 new Error("Cloudflare did not return a record id for DNS record"),
394 );
395 }
396 if (!bodyEqualsObserved(body, observed)) {
397 // Suppress noise when we just created the record above — the
398 // server echo already matches and any diff is a CF-side
399 // normalisation we shouldn't fight on the very first reconcile.
400 const justCreated = !output?.recordId && !foundByScan;
401 if (!justCreated) {
402 const updated = yield* dns.updateRecord({
403 zoneId,
404 dnsRecordId: observed.id,
405 ...body,
406 });
407 observed = narrowRecord(
408 updated as Parameters<typeof narrowRecord>[0],
409 );
410 }
411 }
412
413 // 5. Return.
414 if (
415 !observed.id ||
416 !observed.type ||
417 observed.content === undefined ||
418 observed.ttl === undefined
419 ) {
420 return yield* Effect.fail(
421 new Error(
422 "Cloudflare returned a DNS record without id/type/value/ttl",
423 ),
424 );
425 }
426 return {
427 recordId: observed.id,
428 zoneId,
429 name: observed.name ?? body.name,
430 type: observed.type,
431 content: observed.content,
432 data: observed.data,
433 ttl: observed.ttl,
434 proxied: observed.proxied ?? false,
435 createdOn: observed.createdOn,
436 modifiedOn: observed.modifiedOn,
437 } satisfies RecordAttributes;
438 }),
439
440 delete: Effect.fn(function* ({ output }) {
441 yield* dns
442 .deleteRecord({
443 zoneId: output.zoneId,
444 dnsRecordId: output.recordId,
445 })
446 .pipe(Effect.catchTag("RecordNotFound", () => Effect.void));
447 }),
448
449 read: Effect.fn(function* ({ output, olds }) {
450 // Owned path: we have persisted state (our own recordId) — refresh it.
451 if (output?.recordId) {
452 const observed = yield* observeById(output.zoneId, output.recordId);
453 const attrs = toAttributes(observed, output.zoneId);
454 if (attrs) return attrs;
455 }
456 // Adoption path: no state of our own, but a record with this
457 // `(zoneId, name, type)` may already exist. DNS records carry no
458 // ownership markers we can inspect, so we cannot prove we created
459 // it — brand it `Unowned` so the engine refuses to take over
460 // unless `adopt` is set. Several records may legitimately share
461 // `(name, type)` (MX fallbacks, multiple TXT/A records) — the
462 // declared `content`/`priority` disambiguate which one this
463 // resource corresponds to.
464 const zoneId = output?.zoneId ?? olds?.zoneId;
465 const name = output?.name ?? olds?.name;
466 const type = output?.type ?? olds?.type;
467 if (zoneId && name && type) {
468 const observed = yield* findByNameType(zoneId, name, type, {
469 content:
470 typeof olds?.content === "string"
471 ? olds.content
472 : olds?.content === undefined
473 ? output?.content
474 : undefined,
475 data: typeof olds?.content === "object" ? olds.content : output?.data,
476 priority: olds?.priority,
477 });
478 const attrs = toAttributes(observed, zoneId);
479 if (attrs) return Unowned(attrs);
480 }
481 return undefined;
482 }),
483 });
484
485const observeById = (zoneId: string, dnsRecordId: string) =>
486 Effect.gen(function* () {
487 const r = yield* dns.getRecord({ zoneId, dnsRecordId }).pipe(
488 // Only a missing record should fall through to the find-by-name path.
489 Effect.catchTag("RecordNotFound", () => Effect.succeed(undefined)),
490 );
491 if (r === undefined) return undefined;
492 return narrowRecord(r as Parameters<typeof narrowRecord>[0]);
493 });
494
495/**
496 * Property match used to disambiguate between several records sharing
497 * `(name, type)` — MX fallbacks, multiple TXT records, round-robin A
498 * records are all legitimate Cloudflare configurations.
499 */
500interface RecordMatch {
501 readonly content?: string;
502 readonly data?: RecordData;
503 readonly priority?: number;
504}
505
506/**
507 * Raised when several DNS records share `(name, type)` and the declared
508 * `content`/`data`/`priority` do not select exactly one of them — adoption must
509 * never pick a record arbitrarily.
510 */
511export class AmbiguousDnsRecordError extends Data.TaggedError(
512 "AmbiguousDnsRecordError",
513)<{
514 readonly zoneId: string;
515 readonly name: string;
516 readonly type: RecordType;
517 readonly candidates: ReadonlyArray<{
518 readonly id?: string;
519 readonly content?: string;
520 readonly data?: RecordData;
521 readonly priority?: number;
522 }>;
523 readonly message: string;
524}> {}
525
526// Locate an existing record by `(zoneId, name, type)`. Cloudflare accepts
527// relative names on writes but returns FQDNs on reads, so check the supplied
528// name first and then its zone-qualified form. Used both for the adoption path
529// and to surface a conflict when the caller hasn't opted into adoption.
530//
531// `(name, type)` alone is NOT a unique identity — several records may share
532// it. A single candidate is returned as-is (preserving the adopt-then-modify
533// flow where the desired content differs from the live record). With multiple
534// candidates, the desired record value/priority must select exactly one:
535// - exactly one exact match -> that record
536// - no exact match -> `undefined` (a new sibling record is created)
537// - several exact matches -> fail with an actionable error
538const findByNameType = (
539 zoneId: string,
540 name: string,
541 type: RecordType,
542 match: RecordMatch,
543) =>
544 listExactByNameType(zoneId, name, type).pipe(
545 Effect.flatMap((found) => {
546 if (found.length > 0) return Effect.succeed(found);
547
548 return zones.getZone({ zoneId }).pipe(
549 Effect.flatMap((zone) => {
550 const normalizedName = normalizeRecordName(name, zone.name);
551 return normalizedName === name
552 ? Effect.succeed([] as ObservedRecord[])
553 : listExactByNameType(zoneId, normalizedName, type);
554 }),
555 );
556 }),
557 Effect.flatMap(
558 Effect.fn(function* (candidates) {
559 if (candidates.length === 0) return undefined;
560 if (candidates.length === 1) return candidates[0];
561 const narrowed = candidates.filter(
562 (r) =>
563 (match.content === undefined || r.content === match.content) &&
564 (match.data === undefined ||
565 recordDataEquals(match.data, r.data)) &&
566 (match.priority === undefined || r.priority === match.priority),
567 );
568 if (narrowed.length === 1) return narrowed[0];
569 if (narrowed.length === 0) return undefined;
570 return yield* new AmbiguousDnsRecordError({
571 zoneId,
572 name,
573 type,
574 candidates: candidates.map((r) => ({
575 id: r.id,
576 content: r.content,
577 data: r.data,
578 priority: r.priority,
579 })),
580 message:
581 `Multiple DNS records in zone ${zoneId} match (name=${name}, ` +
582 `type=${type}) and the desired record value/priority does not ` +
583 `select exactly one. Set \`content\`` +
584 (type === "MX" || type === "URI" ? " and `priority`" : "") +
585 ` to exactly match the record this resource should adopt ` +
586 `(you can change it afterwards). Candidates:\n` +
587 candidates
588 .map(
589 (r) =>
590 ` - id=${r.id} content=${JSON.stringify(r.content)}` +
591 (r.data === undefined
592 ? ""
593 : ` data=${JSON.stringify(r.data)}`) +
594 (r.priority === undefined ? "" : ` priority=${r.priority}`),
595 )
596 .join("\n"),
597 });
598 }),
599 ),
600 );
601
602const listExactByNameType = (zoneId: string, name: string, type: RecordType) =>
603 dns.listRecords
604 .items({
605 zoneId,
606 name: { exact: name },
607 type: type as dns.ListRecordsRequest["type"],
608 })
609 .pipe(
610 Stream.runCollect,
611 Effect.map((chunk) =>
612 Array.from(chunk)
613 .filter((r) => r.name === name && r.type === type)
614 .map((r) => narrowRecord(r as Parameters<typeof narrowRecord>[0])),
615 ),
616 );
617
618const normalizeRecordName = (name: string, zoneName: string): string => {
619 if (name === "@") return zoneName;
620 if (name === zoneName || name.endsWith(`.${zoneName}`)) return name;
621 return `${name}.${zoneName}`;
622};
623
624interface ObservedRecord {
625 readonly id?: string;
626 readonly name?: string;
627 readonly type?: RecordType;
628 readonly content?: string;
629 readonly ttl?: number;
630 readonly proxied?: boolean;
631 readonly comment?: string;
632 readonly tags?: ReadonlyArray<string>;
633 readonly priority?: number;
634 readonly data?: RecordData;
635 readonly createdOn?: string;
636 readonly modifiedOn?: string;
637}
638
639const undef = <T>(v: T | null | undefined): T | undefined =>
640 v == null ? undefined : v;
641
642const narrowRecord = (raw: {
643 id?: string | null;
644 name?: string | null;
645 type?: string | null;
646 content?: string | null;
647 ttl?: number | null;
648 proxied?: boolean | null;
649 comment?: string | null;
650 tags?: ReadonlyArray<string> | null;
651 priority?: number | null;
652 data?: Readonly<{ [key: string]: unknown }> | null;
653 createdOn?: string | null;
654 modifiedOn?: string | null;
655}): ObservedRecord => ({
656 id: undef(raw.id),
657 name: undef(raw.name),
658 type: raw.type == null ? undefined : (raw.type as RecordType),
659 content: undef(raw.content),
660 ttl: undef(raw.ttl),
661 proxied: undef(raw.proxied),
662 comment: undef(raw.comment),
663 tags: raw.tags == null ? undefined : (raw.tags as ReadonlyArray<string>),
664 priority: undef(raw.priority),
665 data: normalizeRecordData(raw.data),
666 createdOn: undef(raw.createdOn),
667 modifiedOn: undef(raw.modifiedOn),
668});
669
670const toAttributes = (
671 observed: ObservedRecord | undefined,
672 zoneId: string,
673): RecordAttributes | undefined => {
674 if (
675 !observed?.id ||
676 !observed.name ||
677 !observed.type ||
678 observed.content === undefined ||
679 observed.ttl === undefined
680 ) {
681 return undefined;
682 }
683 return {
684 recordId: observed.id,
685 zoneId,
686 name: observed.name,
687 type: observed.type,
688 content: observed.content,
689 data: observed.data,
690 ttl: observed.ttl,
691 proxied: observed.proxied ?? false,
692 createdOn: observed.createdOn,
693 modifiedOn: observed.modifiedOn,
694 };
695};
696
697// ---------------------------------------------------------------------------
698// Body construction
699// ---------------------------------------------------------------------------
700
701interface RecordMutableBodyCommon {
702 name: string;
703 type: RecordType;
704 ttl: number;
705 proxied?: boolean;
706 comment?: string;
707 tags?: string[];
708 priority?: number;
709}
710
711type RecordMutableBody = RecordMutableBodyCommon &
712 ({ content: string; data?: never } | { content?: never; data: RecordData });
713
714const buildMutableBody = (news: RecordProps): RecordMutableBody => {
715 const common: RecordMutableBodyCommon = {
716 name: news.name,
717 type: news.type,
718 // Cloudflare rejects the string `"1"` even though distilled types
719 // it as `number | "1"`; the API wants numeric 1 for "automatic".
720 ttl:
721 news.ttl === undefined
722 ? 1
723 : news.ttl === ("1" as unknown)
724 ? 1
725 : (news.ttl as number),
726 proxied: news.proxied,
727 comment: news.comment,
728 tags: news.tags === undefined ? undefined : Array.from(news.tags),
729 priority: news.priority,
730 };
731 return typeof news.content === "string"
732 ? { ...common, content: news.content }
733 : { ...common, data: news.content };
734};
735
736// ---------------------------------------------------------------------------
737// Drift detection
738// ---------------------------------------------------------------------------
739
740const bodyEqualsObserved = (
741 desired: RecordMutableBody,
742 observed: ObservedRecord,
743): boolean => {
744 if (desired.content !== undefined && desired.content !== observed.content) {
745 return false;
746 }
747 if (
748 desired.data !== undefined &&
749 !recordDataEquals(desired.data, observed.data)
750 ) {
751 return false;
752 }
753 // CF echoes ttl=1 for "automatic".
754 if (desired.ttl !== observed.ttl) return false;
755 if (
756 desired.proxied !== undefined &&
757 desired.proxied !== (observed.proxied ?? false)
758 ) {
759 return false;
760 }
761 if (
762 desired.comment !== undefined &&
763 desired.comment !== (observed.comment ?? "")
764 ) {
765 return false;
766 }
767 if (
768 desired.tags !== undefined &&
769 !arrayEqualsUnordered(desired.tags, observed.tags)
770 ) {
771 return false;
772 }
773 if (
774 desired.priority !== undefined &&
775 desired.priority !== observed.priority
776 ) {
777 return false;
778 }
779 return true;
780};
781
782const normalizeRecordData = (
783 data: Readonly<{ [key: string]: unknown }> | null | undefined,
784): RecordData | undefined => {
785 if (data == null) return undefined;
786 return Object.fromEntries(
787 Object.entries(data).filter(([, value]) => value != null),
788 ) as RecordData;
789};
790
791const recordDataEquals = (
792 desired: RecordData,
793 observed: RecordData | undefined,
794): boolean => {
795 if (observed === undefined) return false;
796 const desiredEntries = Object.entries(desired).filter(
797 ([, value]) => value != null,
798 );
799 const observedEntries = Object.entries(observed).filter(
800 ([, value]) => value != null,
801 );
802 return (
803 desiredEntries.length === observedEntries.length &&
804 desiredEntries.every(([key, value]) =>
805 observedEntries.some(
806 ([observedKey, observedValue]) =>
807 observedKey === key && observedValue === value,
808 ),
809 )
810 );
811};