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