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 { GcpEnvironment } from "../Environment.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 DataplexNotResolved,
11 RELATED_ENTRY_LINK_TYPE,
12 collectPages,
13 expandParent,
14 fingerprint,
15 hasAlchemyLabelMap,
16 lastSegment,
17 listAtLocation,
18 normalizeLocation,
19 parseName,
20 replaceOnIdentity,
21 retryQuota,
22 toPhysicalId,
23 waitUntilExists,
24 waitUntilGone,
25} from "./internal.ts";
26
27export type EntryLinkReference = {
28 /**
29 * Referenced Entry resource name
30 * `projects/{project}/locations/{location}/entryGroups/{entryGroup}/entries/{entry}`.
31 * Immutable.
32 */
33 name: string;
34 /**
35 * Path in the Entry. Empty means the Entry itself. Immutable.
36 */
37 path?: string;
38 /**
39 * Reference type (`SOURCE` or `TARGET`). Immutable.
40 */
41 type?:
42 | dataplex.GoogleCloudDataplexV1EntryLinkEntryReferenceTypeEnum
43 | (string & {});
44};
45
46export type EntryGroupsEntryLinkProps = {
47 /**
48 * Parent Entry Group. Full name
49 * `projects/{project}/locations/{location}/entryGroups/{entryGroup}`
50 * or the entry-group id (combined with `location`). Immutable —
51 * changing it replaces the link.
52 */
53 entryGroup: string;
54 /**
55 * Entry link id. If omitted, a unique name is generated from the
56 * stack, stage, and logical id. Must be 1-63 characters, start with
57 * a letter, and match `[a-z]([a-z0-9-]*[a-z0-9])?`. Immutable —
58 * changing it replaces the link.
59 */
60 entryLinkId?: string;
61 /**
62 * Region used when `entryGroup` is a bare id. Immutable — changing it
63 * replaces the link.
64 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
65 */
66 location?: string;
67 /**
68 * Entry link type resource name. Immutable — changing it replaces
69 * the link.
70 * @default "projects/dataplex-types/locations/global/entryLinkTypes/related"
71 */
72 entryLinkType?: string;
73 /**
74 * Exactly two entry references (`SOURCE` and `TARGET`). Immutable —
75 * changing them replaces the link.
76 */
77 entryReferences: EntryLinkReference[];
78 /**
79 * Aspects attached to the link, keyed by
80 * `{project}.{location}.{aspectType}`.
81 */
82 aspects?: dataplex.GoogleCloudDataplexV1AspectMap;
83};
84
85export type EntryGroupsEntryLink = Resource<
86 "GCP.Dataplex.EntryGroupsEntryLink",
87 EntryGroupsEntryLinkProps,
88 {
89 /** Full resource name. */
90 name: string;
91 /** Entry link id (last path segment). */
92 entryLinkId: string;
93 /** Parent entry group resource name. */
94 entryGroup: string;
95 /** Parent entry group id. */
96 entryGroupId: string;
97 /** Project id. */
98 project: string;
99 /** Location id. */
100 location: string;
101 /** Entry link type resource name. */
102 entryLinkType: string | undefined;
103 /** Referenced entries. */
104 entryReferences: EntryLinkReference[];
105 /** Aspects attached to the link. */
106 aspects: dataplex.GoogleCloudDataplexV1AspectMap | undefined;
107 /** RFC3339 creation timestamp. */
108 createTime: string | undefined;
109 /** RFC3339 last-update timestamp. */
110 updateTime: string | undefined;
111 },
112 never,
113 Providers
114>;
115
116/**
117 * A Dataplex Entry Link between two Catalog Entries.
118 *
119 * Entry links have no labels field. `list` finds links by looking up
120 * entries in alchemy-labeled entry groups. Changing `entryLinkId`,
121 * `entryGroup`, `location`, `entryLinkType`, or `entryReferences`
122 * replaces the link. Aspects update in place.
123 *
124 * ### Creating an Entry Link
125 * **Example:** Related entries
126 * ```typescript
127 * const link = yield* GCP.Dataplex.EntryGroupsEntryLink("Related", {
128 * entryGroup: group.name,
129 * entryReferences: [
130 * { name: left.name, type: "SOURCE" },
131 * { name: right.name, type: "TARGET" },
132 * ],
133 * });
134 * ```
135 *
136 * @resource
137 * @category Dataplex
138 */
139export const EntryGroupsEntryLink = Resource<EntryGroupsEntryLink>(
140 "GCP.Dataplex.EntryGroupsEntryLink",
141);
142
143const resolveParent = (
144 project: string,
145 entryGroup: string,
146 location: string | undefined,
147 defaultLocation: string,
148) => {
149 const parent = expandParent(
150 entryGroup,
151 project,
152 normalizeLocation(location, defaultLocation),
153 "entryGroups",
154 );
155 const parsed = parseName(`${parent}/entryLinks/_`, "entryLinks");
156 return {
157 parent: parsed.parent,
158 location: parsed.location,
159 project: parsed.project || project,
160 entryGroupId: lastSegment(parsed.parent),
161 };
162};
163
164const resourceName = (parent: string, entryLinkId: string) =>
165 `${parent}/entryLinks/${entryLinkId}`;
166
167const linkTypeOf = (value: string | undefined) =>
168 value && value.length > 0 ? value : RELATED_ENTRY_LINK_TYPE;
169
170const refsOf = (
171 refs:
172 | readonly dataplex.GoogleCloudDataplexV1EntryLinkEntryReference[]
173 | readonly EntryLinkReference[]
174 | undefined,
175): EntryLinkReference[] =>
176 (refs ?? []).flatMap((ref) =>
177 ref.name === undefined
178 ? []
179 : [
180 {
181 name: ref.name,
182 path: ref.path,
183 type: ref.type,
184 },
185 ],
186 );
187
188const refsKey = (refs: readonly EntryLinkReference[] | undefined) =>
189 fingerprint(
190 refsOf(refs)
191 .map((ref) => ({
192 name: ref.name,
193 path: ref.path ?? "",
194 type: (ref.type ?? "").toUpperCase(),
195 }))
196 .sort((left, right) => left.name.localeCompare(right.name)),
197 );
198
199const toAttrs = (
200 link: dataplex.GoogleCloudDataplexV1EntryLink,
201 project: string,
202) => {
203 const name = link.name ?? "";
204 const parsed = parseName(name, "entryLinks");
205 const group = parseName(parsed.parent, "entryGroups");
206 return {
207 name,
208 entryLinkId: parsed.id,
209 entryGroup: parsed.parent,
210 entryGroupId: group.id,
211 project: parsed.project || project,
212 location: parsed.location,
213 entryLinkType: link.entryLinkType,
214 entryReferences: refsOf(link.entryReferences),
215 aspects: link.aspects,
216 createTime: link.createTime,
217 updateTime: link.updateTime,
218 };
219};
220
221const getByName = (name: string) =>
222 retryQuota(dataplex.getProjectsLocationsEntryGroupsEntryLinks({ name })).pipe(
223 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
224 );
225
226const getGroup = (name: string) =>
227 dataplex
228 .getProjectsLocationsEntryGroups({ name })
229 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
230
231const lookupLinks = (locationParent: string, entry: string, project: string) =>
232 collectPages(
233 dataplex.lookupEntryLinksProjectsLocations.pages({
234 name: locationParent,
235 entry,
236 pageSize: 10,
237 }),
238 (page) => page.entryLinks,
239 ).pipe(
240 Effect.map((items) => items.map((item) => toAttrs(item, project))),
241 Effect.catchTag("NotFound", () => Effect.succeed([])),
242 );
243
244export const EntryGroupsEntryLinkProvider = () =>
245 Provider.succeed(EntryGroupsEntryLink, {
246 stables: [
247 "name",
248 "entryLinkId",
249 "entryGroup",
250 "entryGroupId",
251 "project",
252 "location",
253 "entryLinkType",
254 "createTime",
255 ],
256
257 diff: Effect.fn(function* ({ news, olds, output }) {
258 if (!isResolved(news)) return undefined;
259 const env = yield* GcpEnvironment.current;
260 const previousType = linkTypeOf(
261 olds?.entryLinkType ?? output?.entryLinkType,
262 );
263 const nextType = linkTypeOf(news.entryLinkType ?? previousType);
264 const previousRefs = refsKey(
265 olds?.entryReferences ?? output?.entryReferences,
266 );
267 const nextRefs = refsKey(news.entryReferences ?? olds?.entryReferences);
268 return replaceOnIdentity({
269 previousId: olds?.entryLinkId ?? output?.entryLinkId,
270 nextId: news.entryLinkId ?? olds?.entryLinkId ?? output?.entryLinkId,
271 previousLocation: normalizeLocation(
272 olds?.location ?? output?.location,
273 env.region,
274 ),
275 nextLocation: normalizeLocation(
276 news.location ?? olds?.location ?? output?.location,
277 env.region,
278 ),
279 previousParent: olds?.entryGroup ?? output?.entryGroup,
280 nextParent: news.entryGroup ?? olds?.entryGroup ?? output?.entryGroup,
281 extra: nextType !== previousType || nextRefs !== previousRefs,
282 });
283 }),
284
285 read: Effect.fn(function* ({ id, olds, output }) {
286 const env = yield* GcpEnvironment.current;
287 const resolved = resolveParent(
288 env.project,
289 olds?.entryGroup ?? output?.entryGroup ?? "",
290 olds?.location ?? output?.location,
291 env.region,
292 );
293 const entryLinkId = yield* toPhysicalId(
294 id,
295 olds?.entryLinkId,
296 output?.entryLinkId,
297 "entrylink",
298 );
299 const name = output?.name ?? resourceName(resolved.parent, entryLinkId);
300 const existing = yield* getByName(name);
301 if (existing === undefined) return undefined;
302 const attrs = toAttrs(existing, env.project);
303 const group = yield* getGroup(attrs.entryGroup);
304 if (group === undefined) return Unowned(attrs);
305 return hasAlchemyLabelMap(group.labels) ? attrs : Unowned(attrs);
306 }),
307
308 list: () =>
309 Effect.gen(function* () {
310 const env = yield* GcpEnvironment.current;
311 const groups = yield* listAtLocation(
312 env.project,
313 env.region,
314 (parent) =>
315 collectPages(
316 dataplex.listProjectsLocationsEntryGroups.pages({
317 parent,
318 pageSize: 1000,
319 }),
320 (page) => page.entryGroups,
321 ).pipe(
322 Effect.map((items) =>
323 items.filter((item) => hasAlchemyLabelMap(item.labels)),
324 ),
325 ),
326 );
327 const seen = new Set<string>();
328 const found: ReturnType<typeof toAttrs>[] = [];
329 yield* Effect.forEach(
330 groups,
331 (group) =>
332 Effect.gen(function* () {
333 if (group.name === undefined) return;
334 const entries = yield* collectPages(
335 dataplex.listProjectsLocationsEntryGroupsEntries.pages({
336 parent: group.name,
337 pageSize: 100,
338 }),
339 (page) => page.entries,
340 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed([])));
341 const locationParent = `projects/${env.project}/locations/${parseName(group.name, "entryGroups").location}`;
342 const pages = yield* Effect.forEach(
343 entries,
344 (entry) =>
345 entry.name
346 ? lookupLinks(locationParent, entry.name, env.project)
347 : Effect.succeed([]),
348 { concurrency: 4 },
349 );
350 for (const link of pages.flat()) {
351 if (seen.has(link.name)) continue;
352 seen.add(link.name);
353 found.push(link);
354 }
355 }),
356 { concurrency: 2 },
357 );
358 return found;
359 }),
360
361 reconcile: Effect.fn(function* ({ id, news, output }) {
362 const env = yield* GcpEnvironment.current;
363 const resolved = resolveParent(
364 env.project,
365 news.entryGroup,
366 news.location ?? output?.location,
367 env.region,
368 );
369 const entryLinkId = yield* toPhysicalId(
370 id,
371 news.entryLinkId,
372 output?.entryLinkId,
373 "entrylink",
374 );
375 const name = resourceName(resolved.parent, entryLinkId);
376 const entryLinkType = linkTypeOf(news.entryLinkType);
377 const entryReferences = refsOf(news.entryReferences);
378
379 let current = yield* getByName(output?.name ?? name);
380
381 if (current === undefined) {
382 const created = yield* dataplex
383 .createProjectsLocationsEntryGroupsEntryLinks({
384 parent: resolved.parent,
385 entryLinkId,
386 body: {
387 entryLinkType,
388 entryReferences,
389 aspects: news.aspects,
390 },
391 })
392 .pipe(
393 retryQuota,
394 Effect.catchTag("Conflict", () => getByName(name)),
395 );
396 current = created ?? undefined;
397 if (current === undefined) {
398 current = yield* waitUntilExists(getByName(name), name);
399 }
400 }
401
402 if (current === undefined) {
403 return yield* new DataplexNotResolved({ name });
404 }
405
406 const aspectsChanged =
407 news.aspects !== undefined &&
408 fingerprint(news.aspects) !== fingerprint(current.aspects);
409
410 if (aspectsChanged) {
411 current = yield* retryQuota(
412 dataplex.patchProjectsLocationsEntryGroupsEntryLinks({
413 name: current.name ?? name,
414 body: {
415 name: current.name ?? name,
416 aspects: news.aspects,
417 },
418 }),
419 );
420 }
421
422 return toAttrs(current, env.project);
423 }),
424
425 delete: Effect.fn(function* ({ output }) {
426 yield* retryQuota(
427 dataplex.deleteProjectsLocationsEntryGroupsEntryLinks({
428 name: output.name,
429 }),
430 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
431 yield* waitUntilGone(getByName(output.name), output.name);
432 }),
433 });