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 hasAlchemyLabelMap,
21 listAtLocation,
22 normalizeLocation,
23 parentOf,
24 parseName,
25 replaceOnIdentity,
26 retryQuota,
27 toPhysicalId,
28 userLabels,
29 waitForOperation,
30 waitUntilExists,
31 waitUntilGone,
32} from "./internal.ts";
33
34export type DataTaxonomyProps = {
35 /**
36 * Data taxonomy id (the `{dataTaxonomy}` segment of
37 * `projects/{project}/locations/{location}/dataTaxonomies/{dataTaxonomy}`).
38 * If omitted, a unique name is generated from the stack, stage, and
39 * logical id. Must be 1-63 characters, start with a letter, and match
40 * `[a-z]([a-z0-9-]*[a-z0-9])?`. Immutable — changing it replaces the
41 * taxonomy.
42 */
43 dataTaxonomyId?: string;
44 /**
45 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
46 * replaces the taxonomy. `US-CENTRAL1` is accepted and normalized to
47 * `us-central1`.
48 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
49 */
50 location?: string;
51 /**
52 * Human-readable description.
53 */
54 description?: string;
55 /**
56 * User-friendly display name.
57 */
58 displayName?: string;
59 /**
60 * User labels. Alchemy ownership labels are merged in automatically.
61 */
62 labels?: Record<string, string>;
63};
64
65export type DataTaxonomy = Resource<
66 "GCP.Dataplex.DataTaxonomy",
67 DataTaxonomyProps,
68 {
69 /** Full resource name. */
70 name: string;
71 /** Data taxonomy id (last path segment). */
72 dataTaxonomyId: string;
73 /** Project id. */
74 project: string;
75 /** Location id. */
76 location: string;
77 /** User-provided description. */
78 description: string | undefined;
79 /** User-friendly display name. */
80 displayName: string | undefined;
81 /** User labels (Alchemy ownership labels stripped). */
82 labels: Record<string, string>;
83 /** Number of attributes in the taxonomy. */
84 attributeCount: number | undefined;
85 /** Number of classes in the taxonomy. */
86 classCount: number | undefined;
87 /** Server etag. */
88 etag: string | undefined;
89 /** System-generated uid. */
90 uid: string | undefined;
91 /** RFC3339 creation timestamp. */
92 createTime: string | undefined;
93 /** RFC3339 last-update timestamp. */
94 updateTime: string | undefined;
95 },
96 never,
97 Providers
98>;
99
100/**
101 * A Dataplex Data Taxonomy — a hierarchical grouping of DataAttributes
102 * (for example PII classes).
103 *
104 * Changing `dataTaxonomyId` or `location` replaces the taxonomy.
105 * Description, display name, and labels update in place.
106 *
107 * ### Creating a Data Taxonomy
108 * **Example:** Generated name
109 * ```typescript
110 * const taxonomy = yield* GCP.Dataplex.DataTaxonomy("Pii", {});
111 * ```
112 *
113 * **Example:** Explicit id and labels
114 * ```typescript
115 * const taxonomy = yield* GCP.Dataplex.DataTaxonomy("Pii", {
116 * dataTaxonomyId: "sensitive-data",
117 * displayName: "Sensitive data",
118 * labels: { env: "prod" },
119 * });
120 * ```
121 *
122 * ### Updating a Data Taxonomy
123 * **Example:** Description and labels
124 * ```typescript
125 * // Same logical id as before; only the changed props differ.
126 * const taxonomy = yield* GCP.Dataplex.DataTaxonomy("Pii", {
127 * dataTaxonomyId: "sensitive-data",
128 * description: "pii classes v2",
129 * labels: { env: "prod", team: "data" },
130 * });
131 * ```
132 *
133 * @resource
134 * @category Dataplex
135 */
136export const DataTaxonomy = Resource<DataTaxonomy>("GCP.Dataplex.DataTaxonomy");
137
138const resourceName = (
139 project: string,
140 location: string,
141 dataTaxonomyId: string,
142) =>
143 `projects/${project}/locations/${location}/dataTaxonomies/${dataTaxonomyId}`;
144
145const toAttrs = (
146 taxonomy: dataplex.GoogleCloudDataplexV1DataTaxonomy,
147 project: string,
148) => {
149 const name = taxonomy.name ?? "";
150 const parsed = parseName(name, "dataTaxonomies");
151 return {
152 name,
153 dataTaxonomyId: parsed.id,
154 project: parsed.project || project,
155 location: parsed.location,
156 description: taxonomy.description,
157 displayName: taxonomy.displayName,
158 labels: userLabels(taxonomy.labels),
159 attributeCount: taxonomy.attributeCount,
160 classCount: taxonomy.classCount,
161 etag: taxonomy.etag,
162 uid: taxonomy.uid,
163 createTime: taxonomy.createTime,
164 updateTime: taxonomy.updateTime,
165 };
166};
167
168const getByName = (name: string) =>
169 retryQuota(dataplex.getProjectsLocationsDataTaxonomies({ name })).pipe(
170 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
171 );
172
173const listTaxonomies = (project: string, region: string) => {
174 const collect = (parent: string) =>
175 collectPages(
176 dataplex.listProjectsLocationsDataTaxonomies.pages({
177 parent,
178 pageSize: 1000,
179 }),
180 (page) => page.dataTaxonomies,
181 ).pipe(
182 Effect.map((items) =>
183 items.filter((item) => hasAlchemyLabelMap(item.labels)),
184 ),
185 );
186 return listAtLocation(project, region, collect);
187};
188
189export const DataTaxonomyProvider = () =>
190 Provider.succeed(DataTaxonomy, {
191 stables: ["name", "dataTaxonomyId", "project", "location", "createTime"],
192
193 diff: Effect.fn(function* ({ news, olds, output }) {
194 if (!isResolved(news)) return undefined;
195 const env = yield* GcpEnvironment.current;
196 return replaceOnIdentity({
197 previousId: olds?.dataTaxonomyId ?? output?.dataTaxonomyId,
198 nextId:
199 news.dataTaxonomyId ?? olds?.dataTaxonomyId ?? output?.dataTaxonomyId,
200 previousLocation: normalizeLocation(
201 olds?.location ?? output?.location,
202 env.region,
203 ),
204 nextLocation: normalizeLocation(
205 news.location ?? olds?.location ?? output?.location,
206 env.region,
207 ),
208 });
209 }),
210
211 read: Effect.fn(function* ({ id, olds, output }) {
212 const env = yield* GcpEnvironment.current;
213 const dataTaxonomyId = yield* toPhysicalId(
214 id,
215 olds?.dataTaxonomyId,
216 output?.dataTaxonomyId,
217 "taxonomy",
218 );
219 const location = normalizeLocation(
220 olds?.location ?? output?.location,
221 env.region,
222 );
223 const name =
224 output?.name ?? resourceName(env.project, location, dataTaxonomyId);
225 const existing = yield* getByName(name);
226 if (existing === undefined) return undefined;
227 const attrs = toAttrs(existing, env.project);
228 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
229 ? attrs
230 : Unowned(attrs);
231 }),
232
233 list: () =>
234 Effect.gen(function* () {
235 const env = yield* GcpEnvironment.current;
236 const items = yield* listTaxonomies(env.project, env.region);
237 return items.map((item) => toAttrs(item, env.project));
238 }),
239
240 reconcile: Effect.fn(function* ({ id, news, output }) {
241 const env = yield* GcpEnvironment.current;
242 const dataTaxonomyId = yield* toPhysicalId(
243 id,
244 news.dataTaxonomyId,
245 output?.dataTaxonomyId,
246 "taxonomy",
247 );
248 const location = normalizeLocation(
249 news.location ?? output?.location,
250 env.region,
251 );
252 const name = resourceName(env.project, location, dataTaxonomyId);
253 const desiredLabels = {
254 ...toLabels(news.labels),
255 ...(yield* createInternalLabels(id)),
256 };
257
258 let current = yield* getByName(output?.name ?? name);
259
260 if (current === undefined) {
261 const created = yield* dataplex
262 .createProjectsLocationsDataTaxonomies({
263 parent: parentOf(env.project, location),
264 dataTaxonomyId,
265 body: {
266 description: news.description,
267 displayName: news.displayName,
268 labels: desiredLabels,
269 },
270 })
271 .pipe(
272 retryQuota,
273 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
274 );
275 if (created !== undefined) {
276 yield* waitForOperation(created);
277 }
278 current = yield* waitUntilExists(getByName(name), name);
279 }
280
281 if (current === undefined) {
282 return yield* new DataplexNotResolved({ name });
283 }
284
285 const observedLabels = tagRecord(current.labels);
286 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
287 const labelsChanged = upsert.length > 0 || removed.length > 0;
288 const descriptionChanged =
289 (current.description ?? "") !== (news.description ?? "");
290 const displayNameChanged =
291 (current.displayName ?? "") !== (news.displayName ?? "");
292
293 if (labelsChanged || descriptionChanged || displayNameChanged) {
294 const operation = yield* retryQuota(
295 dataplex.patchProjectsLocationsDataTaxonomies({
296 name: current.name ?? name,
297 updateMask: [
298 labelsChanged ? "labels" : undefined,
299 descriptionChanged ? "description" : undefined,
300 displayNameChanged ? "displayName" : undefined,
301 ]
302 .filter((field): field is string => field !== undefined)
303 .join(","),
304 body: {
305 name: current.name ?? name,
306 etag: current.etag,
307 labels: desiredLabels,
308 description: news.description,
309 displayName: news.displayName,
310 },
311 }),
312 );
313 yield* waitForOperation(operation);
314 current = yield* waitUntilExists(
315 getByName(current.name ?? name),
316 current.name ?? name,
317 );
318 }
319
320 return toAttrs(current, env.project);
321 }),
322
323 delete: Effect.fn(function* ({ output }) {
324 const operation = yield* dataplex
325 .deleteProjectsLocationsDataTaxonomies({
326 name: output.name,
327 })
328 .pipe(
329 Effect.retry({
330 while: (error) =>
331 error._tag === "Conflict" || error._tag === "TooManyRequests",
332 times: 8,
333 schedule: Schedule.spaced("2 seconds"),
334 }),
335 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
336 );
337 if (operation !== undefined) {
338 yield* waitForOperation(operation, { notFoundOk: true });
339 }
340 yield* waitUntilGone(getByName(output.name), output.name);
341 }),
342 });