1import * as dataplex from "@distilled.cloud/gcp/dataplex_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 DataplexNotResolved,
19 collectPages,
20 fingerprint,
21 hasAlchemyLabelMap,
22 listAtLocation,
23 normalizeLocation,
24 parentOf,
25 parseName,
26 replaceOnIdentity,
27 retryQuota,
28 toPhysicalId,
29 userLabels,
30 waitForOperation,
31 waitUntilExists,
32 waitUntilGone,
33} from "./internal.ts";
34
35export type DataDomainContact = dataplex.GoogleCloudDataplexV1ContactIdentity;
36export type DataDomainContacts = dataplex.GoogleCloudDataplexV1Contacts;
37
38export type DataDomainProps = {
39 /**
40 * DataDomain id. If omitted, a unique name is generated. Must contain
41 * only lowercase letters, numbers and hyphens, start with a letter,
42 * end with a letter or number, and be 1-63 characters. Immutable —
43 * changing it replaces the domain.
44 */
45 dataDomainId?: string;
46 /**
47 * Region (`us-central1`, …). Immutable — changing it replaces the
48 * domain.
49 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
50 */
51 location?: string;
52 /**
53 * User-friendly display name. Required.
54 */
55 displayName: string;
56 /**
57 * User-provided description.
58 */
59 description?: string;
60 /**
61 * Immutable parent DataDomain resource name. Empty for a top-level
62 * domain. Changing it replaces the domain.
63 */
64 parentDataDomain?: string;
65 /**
66 * User labels. Alchemy ownership labels are merged in automatically.
67 */
68 labels?: Record<string, string>;
69 /**
70 * Business contacts for the domain. Required.
71 */
72 contacts: DataDomainContacts;
73};
74
75export type DataDomain = Resource<
76 "GCP.Dataplex.DataDomain",
77 DataDomainProps,
78 {
79 /** Full resource name. */
80 name: string;
81 /** DataDomain id (last path segment). */
82 dataDomainId: string;
83 /** Project id. */
84 project: string;
85 /** Location id. */
86 location: string;
87 /** Display name. */
88 displayName: string | undefined;
89 /** Description. */
90 description: string | undefined;
91 /** Parent DataDomain resource name, if nested. */
92 parentDataDomain: string | undefined;
93 /** User labels (Alchemy ownership labels stripped). */
94 labels: Record<string, string>;
95 /** Business contacts. */
96 contacts: DataDomainContacts | undefined;
97 /** System uid. */
98 uid: string | undefined;
99 /** RFC3339 creation timestamp. */
100 createTime: string | undefined;
101 /** RFC3339 last-update timestamp. */
102 updateTime: string | undefined;
103 },
104 never,
105 Providers
106>;
107
108/**
109 * A Dataplex DataDomain — a logical grouping of data resources for
110 * governance, discovery, and management.
111 *
112 * Location, domain id, and parent domain are immutable. Display name,
113 * description, contacts, and labels update in place.
114 *
115 * ### Creating a DataDomain
116 * **Example:** Top-level domain
117 * ```typescript
118 * const domain = yield* GCP.Dataplex.DataDomain("Finance", {
119 * displayName: "Finance",
120 * labels: { env: "test" },
121 * contacts: {
122 * identities: [
123 * {
124 * contactName: "steward",
125 * contactRole: "steward",
126 * contactId: "steward@example.com",
127 * },
128 * ],
129 * },
130 * });
131 * ```
132 *
133 * @resource
134 * @category Dataplex
135 */
136export const DataDomain = Resource<DataDomain>("GCP.Dataplex.DataDomain");
137
138const resourceName = (
139 project: string,
140 location: string,
141 dataDomainId: string,
142) => `projects/${project}/locations/${location}/dataDomains/${dataDomainId}`;
143
144const toAttrs = (
145 domain: dataplex.GoogleCloudDataplexV1DataDomain,
146 project: string,
147) => {
148 const name = domain.name ?? "";
149 const parsed = parseName(name, "dataDomains");
150 return {
151 name,
152 dataDomainId: parsed.id,
153 project: parsed.project || project,
154 location: parsed.location,
155 displayName: domain.displayName,
156 description: domain.description,
157 parentDataDomain: domain.parentDataDomain,
158 labels: userLabels(domain.labels),
159 contacts: domain.contacts,
160 uid: domain.uid,
161 createTime: domain.createTime,
162 updateTime: domain.updateTime,
163 };
164};
165
166const getByName = (name: string) =>
167 retryQuota(dataplex.getProjectsLocationsDataDomains({ name })).pipe(
168 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
169 );
170
171const listDomains = (project: string, region: string) => {
172 const collect = (parent: string) =>
173 collectPages(
174 dataplex.listProjectsLocationsDataDomains.pages({
175 parent,
176 pageSize: 100,
177 }),
178 (page) => page.dataDomains,
179 ).pipe(
180 Effect.map((items) =>
181 items.filter((item) => hasAlchemyLabelMap(item.labels)),
182 ),
183 Effect.catchTag("NotFound", () => Effect.succeed([])),
184 );
185 return listAtLocation(project, region, collect);
186};
187
188export const listAlchemyDataDomains = (project: string, region: string) =>
189 listDomains(project, region);
190
191export const DataDomainProvider = () =>
192 Provider.succeed(DataDomain, {
193 stables: [
194 "name",
195 "dataDomainId",
196 "project",
197 "location",
198 "parentDataDomain",
199 "uid",
200 "createTime",
201 ],
202
203 diff: Effect.fn(function* ({ news, olds, output }) {
204 if (!isResolved(news)) return undefined;
205 const env = yield* GcpEnvironment.current;
206 return replaceOnIdentity({
207 previousId: olds?.dataDomainId ?? output?.dataDomainId,
208 nextId: news.dataDomainId ?? olds?.dataDomainId ?? output?.dataDomainId,
209 previousLocation: normalizeLocation(
210 olds?.location ?? output?.location,
211 env.region,
212 ),
213 nextLocation: normalizeLocation(
214 news.location ?? olds?.location ?? output?.location,
215 env.region,
216 ),
217 previousParent: olds?.parentDataDomain ?? output?.parentDataDomain,
218 nextParent: news.parentDataDomain ?? olds?.parentDataDomain,
219 });
220 }),
221
222 read: Effect.fn(function* ({ id, olds, output }) {
223 const env = yield* GcpEnvironment.current;
224 const dataDomainId = yield* toPhysicalId(
225 id,
226 olds?.dataDomainId,
227 output?.dataDomainId,
228 "datadomain",
229 );
230 const location = normalizeLocation(
231 olds?.location ?? output?.location,
232 env.region,
233 );
234 const name =
235 output?.name ?? resourceName(env.project, location, dataDomainId);
236 const existing = yield* getByName(name);
237 if (existing === undefined) return undefined;
238 const attrs = toAttrs(existing, env.project);
239 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
240 ? attrs
241 : Unowned(attrs);
242 }),
243
244 list: () =>
245 Effect.gen(function* () {
246 const env = yield* GcpEnvironment.current;
247 const items = yield* listDomains(env.project, env.region);
248 return items.map((item) => toAttrs(item, env.project));
249 }),
250
251 reconcile: Effect.fn(function* ({ id, news, output }) {
252 const env = yield* GcpEnvironment.current;
253 const dataDomainId = yield* toPhysicalId(
254 id,
255 news.dataDomainId,
256 output?.dataDomainId,
257 "datadomain",
258 );
259 const location = normalizeLocation(
260 news.location ?? output?.location,
261 env.region,
262 );
263 const name = resourceName(env.project, location, dataDomainId);
264 const desiredLabels = {
265 ...toLabels(news.labels),
266 ...(yield* createInternalLabels(id)),
267 };
268
269 let current = yield* getByName(output?.name ?? name);
270
271 if (current === undefined) {
272 const created = yield* retryQuota(
273 dataplex.createProjectsLocationsDataDomains({
274 parent: parentOf(env.project, location),
275 dataDomainId,
276 body: {
277 displayName: news.displayName,
278 description: news.description,
279 parentDataDomain: news.parentDataDomain,
280 labels: desiredLabels,
281 contacts: news.contacts,
282 },
283 }),
284 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
285 if (created !== undefined) {
286 yield* waitForOperation(created);
287 }
288 current = yield* waitUntilExists(getByName(name), name);
289 }
290
291 if (current === undefined) {
292 return yield* new DataplexNotResolved({ name });
293 }
294
295 const observedLabels = tagRecord(current.labels);
296 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
297 const labelsChanged = upsert.length > 0 || removed.length > 0;
298 const descriptionChanged =
299 (current.description ?? "") !== (news.description ?? "");
300 const displayNameChanged =
301 (current.displayName ?? "") !== news.displayName;
302 const contactsChanged =
303 fingerprint(current.contacts) !== fingerprint(news.contacts);
304
305 if (
306 labelsChanged ||
307 descriptionChanged ||
308 displayNameChanged ||
309 contactsChanged
310 ) {
311 const operation = yield* dataplex.patchProjectsLocationsDataDomains({
312 name: current.name ?? name,
313 updateMask: [
314 labelsChanged ? "labels" : undefined,
315 descriptionChanged ? "description" : undefined,
316 displayNameChanged ? "displayName" : undefined,
317 contactsChanged ? "contacts" : undefined,
318 ]
319 .filter((field): field is string => field !== undefined)
320 .join(","),
321 body: {
322 name: current.name ?? name,
323 displayName: news.displayName,
324 description: news.description,
325 labels: desiredLabels,
326 contacts: news.contacts,
327 },
328 });
329 yield* waitForOperation(operation);
330 current = yield* waitUntilExists(
331 getByName(current.name ?? name),
332 current.name ?? name,
333 );
334 }
335
336 return toAttrs(current, env.project);
337 }),
338
339 delete: Effect.fn(function* ({ output }) {
340 const operation = yield* dataplex
341 .deleteProjectsLocationsDataDomains({ name: output.name })
342 .pipe(
343 Effect.retry({
344 while: (error) => error._tag === "Conflict",
345 times: 8,
346 schedule: Schedule.spaced("2 seconds"),
347 }),
348 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
349 );
350 if (operation !== undefined) {
351 yield* waitForOperation(operation, { notFoundOk: true });
352 }
353 yield* waitUntilGone(getByName(output.name), output.name);
354 }),
355 });