1import * as dns from "@distilled.cloud/cloudflare/dns";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const DnsViewTypeId = "Cloudflare.DNS.View" as const;
14type DnsViewTypeId = typeof DnsViewTypeId;
15
16export interface ViewProps {
17 /**
18 * Name of the view. If omitted, a unique name is generated from the
19 * app, stage, and logical ID.
20 *
21 * Mutable — patched in place.
22 * @default ${app}-${stage}-${id}
23 */
24 name?: string;
25 /**
26 * Zones (by id) linked to this view. Internal DNS queries resolved
27 * through the view consult these zones.
28 *
29 * Mutable — patched in place.
30 */
31 zones: string[];
32}
33
34export interface ViewAttributes {
35 /** Identifier of the view. */
36 viewId: string;
37 /** The Cloudflare account the view belongs to. */
38 accountId: string;
39 /** Name of the view. */
40 name: string;
41 /** Zone ids linked to the view. */
42 zones: string[];
43 /** When the view was created. */
44 createdTime: string;
45 /** When the view was last modified. */
46 modifiedTime: string;
47}
48
49export type View = Resource<
50 DnsViewTypeId,
51 ViewProps,
52 ViewAttributes,
53 never,
54 Providers
55>;
56
57/**
58 * An Internal DNS view (`/accounts/{account_id}/dns_settings/views`) —
59 * a named set of internal zones that DNS queries can be resolved
60 * against, for split-horizon / internal DNS setups.
61 *
62 * Requires the Enterprise Internal DNS entitlement on the account
63 * (creation fails with `InternalDnsNotAvailable` otherwise). Both
64 * `name` and `zones` are mutable in place.
65 * ### Creating a View
66 * **Example:** View over internal zones
67 * ```typescript
68 * const view = yield* Cloudflare.DNS.View("Internal", {
69 * zones: [internalZone.zoneId],
70 * });
71 * ```
72 *
73 * **Example:** View with an explicit name
74 * ```typescript
75 * const view = yield* Cloudflare.DNS.View("Internal", {
76 * name: "datacenter-east",
77 * zones: [zoneA.zoneId, zoneB.zoneId],
78 * });
79 * ```
80 *
81 * @see https://developers.cloudflare.com/dns/internal-dns/
82 *
83 * @resource
84 * @product DNS
85 * @category Domains & DNS
86 */
87export const View = Resource<View>(DnsViewTypeId, {
88 aliases: ["Cloudflare.Dns.View"],
89});
90
91/**
92 * Returns true if the given value is a View resource.
93 */
94export const isView = (value: unknown): value is View =>
95 Predicate.hasProperty(value, "Type") && value.Type === DnsViewTypeId;
96
97export const ViewProvider = () =>
98 Provider.succeed(View, {
99 stables: ["viewId", "accountId", "createdTime"],
100
101 // Account collection — internal DNS views are enumerated per account
102 // via the paginated list endpoint. Each item already carries the full
103 // observed shape, so map straight into the `read` Attributes.
104 list: Effect.fn(function* () {
105 const { accountId } = yield* yield* CloudflareEnvironment;
106 return yield* dns.listSettingAccountViews.pages({ accountId }).pipe(
107 Stream.runCollect,
108 Effect.map((chunk) =>
109 Array.from(chunk).flatMap((page) =>
110 (page.result ?? []).map((view) => toAttributes(view, accountId)),
111 ),
112 ),
113 );
114 }),
115
116 diff: Effect.fn(function* ({ output }) {
117 const { accountId } = yield* yield* CloudflareEnvironment;
118 if (output !== undefined && output.accountId !== accountId) {
119 return { action: "replace" } as const;
120 }
121 return undefined;
122 }),
123
124 read: Effect.fn(function* ({ id, output, olds }) {
125 const { accountId } = yield* yield* CloudflareEnvironment;
126 const acct = output?.accountId ?? accountId;
127
128 if (output?.viewId) {
129 const observed = yield* getView(acct, output.viewId);
130 return observed ? toAttributes(observed, acct) : undefined;
131 }
132 // Cold read — recover from lost state by matching the
133 // deterministic physical name. Views carry no ownership markers,
134 // so gate takeover behind adoption.
135 const name = yield* createViewName(id, olds?.name);
136 const match = yield* findByName(acct, name);
137 return match ? Unowned(toAttributes(match, acct)) : undefined;
138 }),
139
140 reconcile: Effect.fn(function* ({ id, news, output }) {
141 const { accountId } = yield* yield* CloudflareEnvironment;
142 const name = yield* createViewName(id, news.name);
143 // Inputs are resolved to concrete values by Plan.
144 const zones = news.zones as string[];
145
146 // Observe — the id cached on `output` is a hint, not a guarantee.
147 const observed = output?.viewId
148 ? yield* getView(output.accountId ?? accountId, output.viewId)
149 : undefined;
150
151 if (!observed) {
152 // Ensure — greenfield (or out-of-band delete). Names are not
153 // unique, so there is no AlreadyExists race to tolerate.
154 const created = yield* dns.createSettingAccountView({
155 accountId,
156 name,
157 zones,
158 });
159 return toAttributes(created, accountId);
160 }
161
162 // Sync — patch only when the observed view differs.
163 if (observed.name === name && sameZones(observed.zones, zones)) {
164 return toAttributes(observed, output?.accountId ?? accountId);
165 }
166 const updated = yield* dns.patchSettingAccountView({
167 accountId: output?.accountId ?? accountId,
168 viewId: observed.id,
169 name,
170 zones,
171 });
172 return toAttributes(updated, output?.accountId ?? accountId);
173 }),
174
175 delete: Effect.fn(function* ({ output }) {
176 yield* dns
177 .deleteSettingAccountView({
178 accountId: output.accountId,
179 viewId: output.viewId,
180 })
181 .pipe(Effect.catchTag("ViewNotFound", () => Effect.void));
182 }),
183 });
184
185type ObservedView =
186 | dns.GetSettingAccountViewResponse
187 | dns.CreateSettingAccountViewResponse
188 | dns.PatchSettingAccountViewResponse;
189
190/** Read a view by id, mapping "gone" (code 1015) to `undefined`. */
191const getView = (accountId: string, viewId: string) =>
192 dns
193 .getSettingAccountView({ accountId, viewId })
194 .pipe(Effect.catchTag("ViewNotFound", () => Effect.succeed(undefined)));
195
196/**
197 * Find a view by exact name. The `name.exact` filter narrows
198 * server-side; re-check exactly client-side and pick the oldest for
199 * determinism.
200 */
201const findByName = (accountId: string, name: string) =>
202 dns.listSettingAccountViews({ accountId, name: { exact: name } }).pipe(
203 Effect.map((list) =>
204 list.result
205 .filter((v) => v.name === name)
206 .sort((a, b) => a.createdTime.localeCompare(b.createdTime))
207 .at(0),
208 ),
209 );
210
211const createViewName = (id: string, name: string | undefined) =>
212 Effect.gen(function* () {
213 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
214 });
215
216const sameZones = (observed: readonly string[], desired: readonly string[]) =>
217 observed.length === desired.length &&
218 [...observed].sort().join(",") === [...desired].sort().join(",");
219
220const toAttributes = (
221 view: ObservedView,
222 accountId: string,
223): ViewAttributes => ({
224 viewId: view.id,
225 accountId,
226 name: view.name,
227 zones: [...view.zones],
228 createdTime: view.createdTime,
229 modifiedTime: view.modifiedTime,
230});