1import * as dataplex from "@distilled.cloud/gcp/dataplex_v1";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { tagRecord } from "../../Tags.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import {
10 createInternalLabels,
11 diffLabels,
12 hasAlchemyLabels,
13 toLabels,
14} from "../Labels.ts";
15import type { Providers } from "../Providers.ts";
16import {
17 DataplexNotResolved,
18 collectPages,
19 expandParent,
20 hasAlchemyLabelMap,
21 lastSegment,
22 listAtLocation,
23 normalizeLocation,
24 parseName,
25 replaceOnIdentity,
26 retryQuota,
27 toPhysicalId,
28 userLabels,
29 waitUntilExists,
30 waitUntilGone,
31} from "./internal.ts";
32
33export type GlossariesCategoryProps = {
34 /**
35 * Parent Glossary. Full name
36 * `projects/{project}/locations/{location}/glossaries/{glossary}` or
37 * the glossary id (combined with `location`). Immutable — changing it
38 * replaces the category.
39 */
40 glossary: string;
41 /**
42 * Category id (the `{category}` segment of
43 * `.../glossaries/{glossary}/categories/{category}`). If omitted, a
44 * unique name is generated from the stack, stage, and logical id.
45 * Must be 1-63 characters, start with a letter, and match
46 * `[a-z]([a-z0-9-]*[a-z0-9])?`. Immutable — changing it replaces the
47 * category.
48 */
49 categoryId?: string;
50 /**
51 * Region used when `glossary` is a bare id. Immutable — changing it
52 * replaces the category.
53 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
54 */
55 location?: string;
56 /**
57 * Immediate parent in the glossary hierarchy — the glossary itself
58 * or another category. Defaults to the parent glossary.
59 */
60 parent?: string;
61 /**
62 * Human-readable description.
63 */
64 description?: string;
65 /**
66 * User-friendly display name.
67 */
68 displayName?: string;
69 /**
70 * User labels. Alchemy ownership labels are merged in automatically.
71 */
72 labels?: Record<string, string>;
73};
74
75export type GlossariesCategory = Resource<
76 "GCP.Dataplex.GlossariesCategory",
77 GlossariesCategoryProps,
78 {
79 /** Full resource name. */
80 name: string;
81 /** Category id (last path segment). */
82 categoryId: string;
83 /** Parent glossary resource name. */
84 glossary: string;
85 /** Parent glossary id. */
86 glossaryId: string;
87 /** Immediate hierarchy parent. */
88 parent: string | undefined;
89 /** Project id. */
90 project: string;
91 /** Location id. */
92 location: string;
93 /** User-provided description. */
94 description: string | undefined;
95 /** User-friendly display name. */
96 displayName: string | undefined;
97 /** User labels (Alchemy ownership labels stripped). */
98 labels: Record<string, string>;
99 /** System-generated uid. */
100 uid: string | undefined;
101 /** RFC3339 creation timestamp. */
102 createTime: string | undefined;
103 /** RFC3339 last-update timestamp. */
104 updateTime: string | undefined;
105 },
106 never,
107 Providers
108>;
109
110/**
111 * A Dataplex Glossary Category nested under a Glossary.
112 *
113 * Changing `categoryId`, `glossary`, or `location` replaces the
114 * category. Description, display name, labels, and hierarchy `parent`
115 * update in place.
116 *
117 * ### Creating a Glossary Category
118 * **Example:** Category under a glossary
119 * ```typescript
120 * const category = yield* GCP.Dataplex.GlossariesCategory("Finance", {
121 * glossary: glossary.name,
122 * displayName: "Finance",
123 * labels: { env: "prod" },
124 * });
125 * ```
126 *
127 * ### Updating a Glossary Category
128 * **Example:** Description and labels
129 * ```typescript
130 * // Same logical id as before; only the changed props differ.
131 * const category = yield* GCP.Dataplex.GlossariesCategory("Finance", {
132 * glossary: glossary.name,
133 * description: "finance terms",
134 * labels: { env: "prod", team: "data" },
135 * });
136 * ```
137 *
138 * @resource
139 * @category Dataplex
140 */
141export const GlossariesCategory = Resource<GlossariesCategory>(
142 "GCP.Dataplex.GlossariesCategory",
143);
144
145const resolveParent = (
146 project: string,
147 glossary: string,
148 location: string | undefined,
149 defaultLocation: string,
150) => {
151 const parent = expandParent(
152 glossary,
153 project,
154 normalizeLocation(location, defaultLocation),
155 "glossaries",
156 );
157 const parsed = parseName(`${parent}/categories/_`, "categories");
158 return {
159 parent: parsed.parent,
160 location: parsed.location,
161 project: parsed.project || project,
162 glossaryId: lastSegment(parsed.parent),
163 };
164};
165
166const resourceName = (parent: string, categoryId: string) =>
167 `${parent}/categories/${categoryId}`;
168
169const toAttrs = (
170 category: dataplex.GoogleCloudDataplexV1GlossaryCategory,
171 project: string,
172) => {
173 const name = category.name ?? "";
174 const parsed = parseName(name, "categories");
175 const glossary = parseName(parsed.parent, "glossaries");
176 return {
177 name,
178 categoryId: parsed.id,
179 glossary: parsed.parent,
180 glossaryId: glossary.id,
181 parent: category.parent,
182 project: parsed.project || project,
183 location: parsed.location,
184 description: category.description,
185 displayName: category.displayName,
186 labels: userLabels(category.labels),
187 uid: category.uid,
188 createTime: category.createTime,
189 updateTime: category.updateTime,
190 };
191};
192
193const getByName = (name: string) =>
194 retryQuota(dataplex.getProjectsLocationsGlossariesCategories({ name })).pipe(
195 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
196 );
197
198const listCategoriesUnder = (parent: string, project: string) =>
199 collectPages(
200 dataplex.listProjectsLocationsGlossariesCategories.pages({
201 parent,
202 pageSize: 1000,
203 }),
204 (page) => page.categories,
205 ).pipe(
206 Effect.map((items) =>
207 items
208 .filter((item) => hasAlchemyLabelMap(item.labels))
209 .map((item) => toAttrs(item, project)),
210 ),
211 Effect.catchTag("NotFound", () => Effect.succeed([])),
212 );
213
214export const GlossariesCategoryProvider = () =>
215 Provider.succeed(GlossariesCategory, {
216 stables: [
217 "name",
218 "categoryId",
219 "glossary",
220 "glossaryId",
221 "project",
222 "location",
223 "createTime",
224 ],
225
226 diff: Effect.fn(function* ({ news, olds, output }) {
227 if (!isResolved(news)) return undefined;
228 const env = yield* GcpEnvironment.current;
229 return replaceOnIdentity({
230 previousId: olds?.categoryId ?? output?.categoryId,
231 nextId: news.categoryId ?? olds?.categoryId ?? output?.categoryId,
232 previousLocation: normalizeLocation(
233 olds?.location ?? output?.location,
234 env.region,
235 ),
236 nextLocation: normalizeLocation(
237 news.location ?? olds?.location ?? output?.location,
238 env.region,
239 ),
240 previousParent: olds?.glossary ?? output?.glossary,
241 nextParent: news.glossary ?? olds?.glossary ?? output?.glossary,
242 });
243 }),
244
245 read: Effect.fn(function* ({ id, olds, output }) {
246 const env = yield* GcpEnvironment.current;
247 const resolved = resolveParent(
248 env.project,
249 olds?.glossary ?? output?.glossary ?? "",
250 olds?.location ?? output?.location,
251 env.region,
252 );
253 const categoryId = yield* toPhysicalId(
254 id,
255 olds?.categoryId,
256 output?.categoryId,
257 "category",
258 );
259 const name = output?.name ?? resourceName(resolved.parent, categoryId);
260 const existing = yield* getByName(name);
261 if (existing === undefined) return undefined;
262 const attrs = toAttrs(existing, env.project);
263 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
264 ? attrs
265 : Unowned(attrs);
266 }),
267
268 list: () =>
269 Effect.gen(function* () {
270 const env = yield* GcpEnvironment.current;
271 const glossaries = yield* listAtLocation(
272 env.project,
273 env.region,
274 (parent) =>
275 collectPages(
276 dataplex.listProjectsLocationsGlossaries.pages({
277 parent,
278 pageSize: 1000,
279 }),
280 (page) => page.glossaries,
281 ).pipe(
282 Effect.map((items) =>
283 items.filter((item) => hasAlchemyLabelMap(item.labels)),
284 ),
285 ),
286 );
287 const nested = yield* Effect.forEach(
288 glossaries,
289 (glossary) =>
290 glossary.name
291 ? listCategoriesUnder(glossary.name, env.project)
292 : Effect.succeed([]),
293 { concurrency: 4 },
294 );
295 return nested.flat();
296 }),
297
298 reconcile: Effect.fn(function* ({ id, news, output }) {
299 const env = yield* GcpEnvironment.current;
300 const resolved = resolveParent(
301 env.project,
302 news.glossary,
303 news.location ?? output?.location,
304 env.region,
305 );
306 const categoryId = yield* toPhysicalId(
307 id,
308 news.categoryId,
309 output?.categoryId,
310 "category",
311 );
312 const name = resourceName(resolved.parent, categoryId);
313 const hierarchyParent = news.parent ?? resolved.parent;
314 const desiredLabels = {
315 ...toLabels(news.labels),
316 ...(yield* createInternalLabels(id)),
317 };
318
319 let current = yield* getByName(output?.name ?? name);
320
321 if (current === undefined) {
322 const created = yield* dataplex
323 .createProjectsLocationsGlossariesCategories({
324 parent: resolved.parent,
325 categoryId,
326 body: {
327 parent: hierarchyParent,
328 description: news.description,
329 displayName: news.displayName,
330 labels: desiredLabels,
331 },
332 })
333 .pipe(
334 retryQuota,
335 Effect.catchTag("Conflict", () => getByName(name)),
336 );
337 current = created ?? undefined;
338 if (current === undefined) {
339 current = yield* waitUntilExists(getByName(name), name);
340 }
341 }
342
343 if (current === undefined) {
344 return yield* new DataplexNotResolved({ name });
345 }
346
347 const observedLabels = tagRecord(current.labels);
348 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
349 const labelsChanged = upsert.length > 0 || removed.length > 0;
350 const descriptionChanged =
351 (current.description ?? "") !== (news.description ?? "");
352 const displayNameChanged =
353 (current.displayName ?? "") !== (news.displayName ?? "");
354 const parentChanged = (current.parent ?? "") !== hierarchyParent;
355
356 if (
357 labelsChanged ||
358 descriptionChanged ||
359 displayNameChanged ||
360 parentChanged
361 ) {
362 current = yield* retryQuota(
363 dataplex.patchProjectsLocationsGlossariesCategories({
364 name: current.name ?? name,
365 updateMask: [
366 labelsChanged ? "labels" : undefined,
367 descriptionChanged ? "description" : undefined,
368 displayNameChanged ? "displayName" : undefined,
369 parentChanged ? "parent" : undefined,
370 ]
371 .filter((field): field is string => field !== undefined)
372 .join(","),
373 body: {
374 name: current.name ?? name,
375 parent: hierarchyParent,
376 labels: desiredLabels,
377 description: news.description,
378 displayName: news.displayName,
379 },
380 }),
381 );
382 }
383
384 return toAttrs(current, env.project);
385 }),
386
387 delete: Effect.fn(function* ({ output }) {
388 yield* retryQuota(
389 dataplex.deleteProjectsLocationsGlossariesCategories({
390 name: output.name,
391 }),
392 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
393 yield* waitUntilGone(getByName(output.name), output.name);
394 }),
395 });