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 GlossaryProps = {
35 /**
36 * Glossary id (the `{glossary}` segment of
37 * `projects/{project}/locations/{location}/glossaries/{glossary}`).
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 * glossary.
42 */
43 glossaryId?: string;
44 /**
45 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
46 * replaces the glossary. `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 Glossary = Resource<
66 "GCP.Dataplex.Glossary",
67 GlossaryProps,
68 {
69 /** Full resource name. */
70 name: string;
71 /** Glossary id (last path segment). */
72 glossaryId: 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 terms. */
84 termCount: number | undefined;
85 /** Number of categories. */
86 categoryCount: 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 Glossary — a collection of GlossaryCategories and
102 * GlossaryTerms.
103 *
104 * Changing `glossaryId` or `location` replaces the glossary. Description,
105 * display name, and labels update in place. Nested categories and terms
106 * must be deleted before the glossary.
107 *
108 * ### Creating a Glossary
109 * **Example:** Generated name
110 * ```typescript
111 * const glossary = yield* GCP.Dataplex.Glossary("BusinessTerms", {});
112 * ```
113 *
114 * **Example:** Explicit id and labels
115 * ```typescript
116 * const glossary = yield* GCP.Dataplex.Glossary("BusinessTerms", {
117 * glossaryId: "app-glossary",
118 * displayName: "App glossary",
119 * labels: { env: "prod" },
120 * });
121 * ```
122 *
123 * ### Updating a Glossary
124 * **Example:** Description and labels
125 * ```typescript
126 * // Same logical id as before; only the changed props differ.
127 * const glossary = yield* GCP.Dataplex.Glossary("BusinessTerms", {
128 * glossaryId: "app-glossary",
129 * description: "glossary v2",
130 * labels: { env: "prod", team: "data" },
131 * });
132 * ```
133 *
134 * @resource
135 * @category Dataplex
136 */
137export const Glossary = Resource<Glossary>("GCP.Dataplex.Glossary");
138
139const resourceName = (project: string, location: string, glossaryId: string) =>
140 `projects/${project}/locations/${location}/glossaries/${glossaryId}`;
141
142const toAttrs = (
143 glossary: dataplex.GoogleCloudDataplexV1Glossary,
144 project: string,
145) => {
146 const name = glossary.name ?? "";
147 const parsed = parseName(name, "glossaries");
148 return {
149 name,
150 glossaryId: parsed.id,
151 project: parsed.project || project,
152 location: parsed.location,
153 description: glossary.description,
154 displayName: glossary.displayName,
155 labels: userLabels(glossary.labels),
156 termCount: glossary.termCount,
157 categoryCount: glossary.categoryCount,
158 etag: glossary.etag,
159 uid: glossary.uid,
160 createTime: glossary.createTime,
161 updateTime: glossary.updateTime,
162 };
163};
164
165const getByName = (name: string) =>
166 retryQuota(dataplex.getProjectsLocationsGlossaries({ name })).pipe(
167 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
168 );
169
170const listGlossaries = (project: string, region: string) => {
171 const collect = (parent: string) =>
172 collectPages(
173 dataplex.listProjectsLocationsGlossaries.pages({
174 parent,
175 pageSize: 1000,
176 }),
177 (page) => page.glossaries,
178 ).pipe(
179 Effect.map((items) =>
180 items.filter((item) => hasAlchemyLabelMap(item.labels)),
181 ),
182 );
183 return listAtLocation(project, region, collect);
184};
185
186export const listAlchemyGlossaries = (project: string, region: string) =>
187 listGlossaries(project, region);
188
189export const GlossaryProvider = () =>
190 Provider.succeed(Glossary, {
191 stables: ["name", "glossaryId", "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?.glossaryId ?? output?.glossaryId,
198 nextId: news.glossaryId ?? olds?.glossaryId ?? output?.glossaryId,
199 previousLocation: normalizeLocation(
200 olds?.location ?? output?.location,
201 env.region,
202 ),
203 nextLocation: normalizeLocation(
204 news.location ?? olds?.location ?? output?.location,
205 env.region,
206 ),
207 });
208 }),
209
210 read: Effect.fn(function* ({ id, olds, output }) {
211 const env = yield* GcpEnvironment.current;
212 const glossaryId = yield* toPhysicalId(
213 id,
214 olds?.glossaryId,
215 output?.glossaryId,
216 "glossary",
217 );
218 const location = normalizeLocation(
219 olds?.location ?? output?.location,
220 env.region,
221 );
222 const name =
223 output?.name ?? resourceName(env.project, location, glossaryId);
224 const existing = yield* getByName(name);
225 if (existing === undefined) return undefined;
226 const attrs = toAttrs(existing, env.project);
227 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
228 ? attrs
229 : Unowned(attrs);
230 }),
231
232 list: () =>
233 Effect.gen(function* () {
234 const env = yield* GcpEnvironment.current;
235 const items = yield* listGlossaries(env.project, env.region);
236 return items.map((item) => toAttrs(item, env.project));
237 }),
238
239 reconcile: Effect.fn(function* ({ id, news, output }) {
240 const env = yield* GcpEnvironment.current;
241 const glossaryId = yield* toPhysicalId(
242 id,
243 news.glossaryId,
244 output?.glossaryId,
245 "glossary",
246 );
247 const location = normalizeLocation(
248 news.location ?? output?.location,
249 env.region,
250 );
251 const name = resourceName(env.project, location, glossaryId);
252 const desiredLabels = {
253 ...toLabels(news.labels),
254 ...(yield* createInternalLabels(id)),
255 };
256
257 let current = yield* getByName(output?.name ?? name);
258
259 if (current === undefined) {
260 const created = yield* dataplex
261 .createProjectsLocationsGlossaries({
262 parent: parentOf(env.project, location),
263 glossaryId,
264 body: {
265 description: news.description,
266 displayName: news.displayName,
267 labels: desiredLabels,
268 },
269 })
270 .pipe(
271 retryQuota,
272 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
273 );
274 if (created !== undefined) {
275 yield* waitForOperation(created);
276 }
277 current = yield* waitUntilExists(getByName(name), name);
278 }
279
280 if (current === undefined) {
281 return yield* new DataplexNotResolved({ name });
282 }
283
284 const observedLabels = tagRecord(current.labels);
285 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
286 const labelsChanged = upsert.length > 0 || removed.length > 0;
287 const descriptionChanged =
288 (current.description ?? "") !== (news.description ?? "");
289 const displayNameChanged =
290 (current.displayName ?? "") !== (news.displayName ?? "");
291
292 if (labelsChanged || descriptionChanged || displayNameChanged) {
293 const operation = yield* retryQuota(
294 dataplex.patchProjectsLocationsGlossaries({
295 name: current.name ?? name,
296 updateMask: [
297 labelsChanged ? "labels" : undefined,
298 descriptionChanged ? "description" : undefined,
299 displayNameChanged ? "displayName" : undefined,
300 ]
301 .filter((field): field is string => field !== undefined)
302 .join(","),
303 body: {
304 name: current.name ?? name,
305 etag: current.etag,
306 labels: desiredLabels,
307 description: news.description,
308 displayName: news.displayName,
309 },
310 }),
311 );
312 yield* waitForOperation(operation);
313 current = yield* waitUntilExists(
314 getByName(current.name ?? name),
315 current.name ?? name,
316 );
317 }
318
319 return toAttrs(current, env.project);
320 }),
321
322 delete: Effect.fn(function* ({ output }) {
323 const operation = yield* dataplex
324 .deleteProjectsLocationsGlossaries({
325 name: output.name,
326 })
327 .pipe(
328 Effect.retry({
329 while: (error) =>
330 error._tag === "Conflict" || error._tag === "TooManyRequests",
331 times: 8,
332 schedule: Schedule.spaced("2 seconds"),
333 }),
334 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
335 );
336 if (operation !== undefined) {
337 yield* waitForOperation(operation, { notFoundOk: true });
338 }
339 yield* waitUntilGone(getByName(output.name), output.name);
340 }),
341 });