1import * as dataplex from "@distilled.cloud/gcp/dataplex_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
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 expandParent,
19 hasAlchemyLabelMap,
20 listGlossaries,
21 listTerms,
22 normalizeLocation,
23 parseResourceName,
24 replaceIfChanged,
25 toPhysicalRfc1035,
26 userLabels,
27} from "./shared.ts";
28
29export type GlossariesTermProps = {
30 /**
31 * Parent glossary. Full name
32 * `projects/{project}/locations/{location}/glossaries/{glossary}` or the
33 * glossary id (combined with `location`). Immutable — changing it
34 * replaces the term.
35 */
36 glossary: string;
37 /**
38 * Immediate parent in the glossary tree — the glossary itself or a
39 * category
40 * `.../glossaries/{glossary}/categories/{category}`. Defaults to
41 * `glossary`.
42 */
43 parent?: string;
44 /**
45 * Region used when `glossary` is a bare id.
46 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
47 */
48 location?: string;
49 /**
50 * Term id (the `{term}` segment of
51 * `.../glossaries/{glossary}/terms/{term}`). If omitted, a unique name
52 * is generated from the stack, stage, and logical id. Immutable —
53 * changing it replaces the term.
54 */
55 termId?: string;
56 /**
57 * User-friendly display name. Defaults to the term id.
58 */
59 displayName?: string;
60 /**
61 * Rich-text description attached to catalog entries.
62 */
63 description?: string;
64 /**
65 * User labels. Alchemy ownership labels are merged in automatically.
66 */
67 labels?: Record<string, string>;
68};
69
70export type GlossariesTerm = Resource<
71 "GCP.Dataplex.GlossariesTerm",
72 GlossariesTermProps,
73 {
74 /** Full resource name `.../glossaries/{glossary}/terms/{term}`. */
75 name: string;
76 /** Term id (last path segment). */
77 termId: string;
78 /** Parent glossary resource name. */
79 glossary: string;
80 /** Immediate parent (glossary or category). */
81 parent: string;
82 /** Project id. */
83 project: string;
84 /** Location id. */
85 location: string;
86 /** User-friendly display name. */
87 displayName: string | undefined;
88 /** Description. */
89 description: string | undefined;
90 /** User labels (Alchemy ownership labels stripped). */
91 labels: Record<string, string>;
92 /** Server-assigned uid. */
93 uid: string | undefined;
94 /** RFC3339 creation timestamp. */
95 createTime: string | undefined;
96 /** RFC3339 last-update timestamp. */
97 updateTime: string | undefined;
98 },
99 never,
100 Providers
101>;
102
103/**
104 * A Dataplex glossary term — a defined business concept that can be
105 * attached to catalog entries.
106 *
107 * Changing `glossary`, `termId`, or `location` replaces the term. Display
108 * name, description, labels, and immediate parent update in place.
109 *
110 * ### Creating a Term
111 * **Example:** Term under a glossary
112 * ```typescript
113 * const term = yield* GCP.Dataplex.GlossariesTerm("Customer", {
114 * glossary: glossary.name,
115 * displayName: "Customer",
116 * description: "a paying account",
117 * labels: { env: "dev" },
118 * });
119 * ```
120 *
121 * **Example:** Named term
122 * ```typescript
123 * const term = yield* GCP.Dataplex.GlossariesTerm("Customer", {
124 * glossary: glossary.name,
125 * termId: "customer",
126 * labels: { env: "prod" },
127 * });
128 * ```
129 *
130 * @resource
131 * @category Dataplex
132 */
133export const GlossariesTerm = Resource<GlossariesTerm>(
134 "GCP.Dataplex.GlossariesTerm",
135);
136
137export class GlossariesTermNotResolved extends Data.TaggedError(
138 "GCP.Dataplex.GlossariesTermNotResolved",
139)<{
140 name: string;
141}> {}
142
143const glossaryOf = (glossary: string, project: string, location: string) =>
144 expandParent(glossary, project, location, "glossaries");
145
146const resourceName = (glossary: string, termId: string) =>
147 `${glossary}/terms/${termId}`;
148
149const toAttrs = (
150 term: dataplex.GoogleCloudDataplexV1GlossaryTerm,
151 project: string,
152) => {
153 const name = term.name ?? "";
154 const parsed = parseResourceName(name, "terms");
155 const glossary = parseResourceName(name, "glossaries");
156 return {
157 name,
158 termId: parsed.id,
159 glossary: glossary.parent
160 ? `${glossary.parent}/glossaries/${glossary.id}`
161 : parsed.parent,
162 parent: term.parent ?? parsed.parent,
163 project: parsed.project || project,
164 location: parsed.location,
165 displayName: term.displayName,
166 description: term.description,
167 labels: userLabels(term.labels),
168 uid: term.uid,
169 createTime: term.createTime,
170 updateTime: term.updateTime,
171 };
172};
173
174const getByName = (name: string) =>
175 dataplex
176 .getProjectsLocationsGlossariesTerms({ name })
177 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
178
179export const GlossariesTermProvider = () =>
180 Provider.succeed(GlossariesTerm, {
181 stables: [
182 "name",
183 "termId",
184 "glossary",
185 "project",
186 "location",
187 "uid",
188 "createTime",
189 ],
190
191 diff: Effect.fn(function* ({ news, olds, output }) {
192 if (!isResolved(news)) return undefined;
193 const env = yield* GcpEnvironment.current;
194 const previousId = olds?.termId ?? output?.termId;
195 const nextId = news.termId ?? previousId;
196 const previousGlossary = olds?.glossary ?? output?.glossary;
197 const nextGlossary = news.glossary ?? previousGlossary;
198 const previousLocation = normalizeLocation(
199 olds?.location ?? output?.location,
200 env.region,
201 );
202 const nextLocation = normalizeLocation(
203 news.location ?? olds?.location ?? output?.location,
204 env.region,
205 );
206 if (
207 replaceIfChanged(previousId, nextId) ||
208 replaceIfChanged(previousGlossary, nextGlossary) ||
209 (output !== undefined && previousLocation !== nextLocation)
210 ) {
211 return {
212 action: "replace" as const,
213 deleteFirst:
214 previousGlossary === nextGlossary &&
215 previousLocation === nextLocation &&
216 previousId !== undefined &&
217 nextId === previousId,
218 };
219 }
220 return undefined;
221 }),
222
223 read: Effect.fn(function* ({ id, olds, output }) {
224 const env = yield* GcpEnvironment.current;
225 const location = normalizeLocation(
226 olds?.location ?? output?.location,
227 env.region,
228 );
229 const glossary = glossaryOf(
230 olds?.glossary ?? output?.glossary ?? "",
231 env.project,
232 location,
233 );
234 const termId = yield* toPhysicalRfc1035(id, olds?.termId, output?.termId);
235 const name = output?.name ?? resourceName(glossary, termId);
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 glossaries = yield* listGlossaries(env.project, env.region);
248 const terms = yield* Effect.forEach(
249 glossaries.filter((glossary) => (glossary.name ?? "").length > 0),
250 (glossary) => listTerms(glossary.name!),
251 { concurrency: 4 },
252 );
253 return terms
254 .flat()
255 .filter((term) => hasAlchemyLabelMap(term.labels))
256 .map((term) => toAttrs(term, env.project));
257 }),
258
259 reconcile: Effect.fn(function* ({ id, news, output }) {
260 const env = yield* GcpEnvironment.current;
261 const location = normalizeLocation(
262 news.location ?? output?.location,
263 env.region,
264 );
265 const glossary = glossaryOf(news.glossary, env.project, location);
266 const termId = yield* toPhysicalRfc1035(id, news.termId, output?.termId);
267 const name = output?.name ?? resourceName(glossary, termId);
268 const immediateParent = news.parent ?? glossary;
269 const desiredLabels = {
270 ...toLabels(news.labels),
271 ...(yield* createInternalLabels(id)),
272 };
273
274 let current = yield* getByName(name);
275
276 if (current === undefined) {
277 const created = yield* dataplex
278 .createProjectsLocationsGlossariesTerms({
279 parent: glossary,
280 termId,
281 body: {
282 parent: immediateParent,
283 displayName: news.displayName,
284 description: news.description,
285 labels: desiredLabels,
286 },
287 })
288 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
289 current = created ?? undefined;
290 }
291
292 if (current === undefined) {
293 return yield* new GlossariesTermNotResolved({ name });
294 }
295
296 const observedLabels = tagRecord(current.labels);
297 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
298 const labelsChanged = upsert.length > 0 || removed.length > 0;
299 const displayNameChanged =
300 (current.displayName ?? "") !== (news.displayName ?? "");
301 const descriptionChanged =
302 (current.description ?? "") !== (news.description ?? "");
303 const parentChanged = (current.parent ?? "") !== immediateParent;
304
305 if (
306 labelsChanged ||
307 displayNameChanged ||
308 descriptionChanged ||
309 parentChanged
310 ) {
311 const updateMask = [
312 labelsChanged ? "labels" : undefined,
313 displayNameChanged ? "display_name" : undefined,
314 descriptionChanged ? "description" : undefined,
315 parentChanged ? "parent" : undefined,
316 ].filter((field): field is string => field !== undefined);
317 current = yield* dataplex.patchProjectsLocationsGlossariesTerms({
318 name: current.name ?? name,
319 updateMask: updateMask.join(","),
320 body: {
321 parent: immediateParent,
322 displayName: news.displayName,
323 description: news.description,
324 labels: desiredLabels,
325 },
326 });
327 }
328
329 return toAttrs(current, env.project);
330 }),
331
332 delete: Effect.fn(function* ({ output }) {
333 yield* dataplex
334 .deleteProjectsLocationsGlossariesTerms({ name: output.name })
335 .pipe(Effect.catchTag("NotFound", () => Effect.void));
336 }),
337 });