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 { createPhysicalName } from "../../PhysicalName.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 MAX_NAME_LENGTH,
20 collectPages,
21 expandParent,
22 fingerprint,
23 hasAlchemyLabelMap,
24 lastSegment,
25 listAtLocation,
26 normalizeLocation,
27 parseName,
28 replaceOnIdentity,
29 retryQuota,
30 rfc1035,
31 userLabels,
32 waitUntilExists,
33 waitUntilGone,
34} from "./internal.ts";
35
36export type EntrySource = {
37 /** Name of the resource in the source system. */
38 resource?: string;
39 /** Name of the source system. */
40 system?: string;
41 /** Platform containing the source system. */
42 platform?: string;
43 /** User-friendly display name. */
44 displayName?: string;
45 /** Description of the data resource. */
46 description?: string;
47 /**
48 * User-defined source labels. Alchemy ownership labels are merged in
49 * automatically — Entries have no top-level labels field.
50 */
51 labels?: Record<string, string>;
52 /** Ancestor entries in the source system. */
53 ancestors?: Array<{ name?: string; type?: string }>;
54 /** Time the resource was created in the source system. */
55 createTime?: string;
56 /** Time the resource was last updated in the source system. */
57 updateTime?: string;
58};
59
60export type EntryGroupsEntryProps = {
61 /**
62 * Parent Entry Group. Full name
63 * `projects/{project}/locations/{location}/entryGroups/{entryGroup}`
64 * or the entry-group id (combined with `location`). Immutable —
65 * changing it replaces the entry.
66 */
67 entryGroup: string;
68 /**
69 * Entry id (the `{entry}` segment of
70 * `.../entryGroups/{entryGroup}/entries/{entry}`). May contain slashes
71 * (up to 4000 characters). If omitted, a unique name is generated from
72 * the stack, stage, and logical id. Immutable — changing it replaces
73 * the entry.
74 */
75 entryId?: string;
76 /**
77 * Region used when `entryGroup` is a bare id. Immutable — changing it
78 * replaces the entry.
79 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
80 */
81 location?: string;
82 /**
83 * Entry type resource name
84 * `projects/{project}/locations/{location}/entryTypes/{entryType}`.
85 * Immutable — changing it replaces the entry.
86 */
87 entryType: string;
88 /**
89 * Parent entry resource name. Immutable — changing it replaces the
90 * entry.
91 */
92 parentEntry?: string;
93 /**
94 * Fully qualified name used by an external system.
95 */
96 fullyQualifiedName?: string;
97 /**
98 * Aspects attached to the entry, keyed by
99 * `{project}.{location}.{aspectType}` (or with `@{path}`).
100 */
101 aspects?: dataplex.GoogleCloudDataplexV1AspectMap;
102 /**
103 * Source-system metadata. Labels on `entrySource` receive Alchemy
104 * ownership stamps.
105 */
106 entrySource?: EntrySource;
107 /**
108 * Convenience labels merged into `entrySource.labels`.
109 */
110 labels?: Record<string, string>;
111};
112
113export type EntryGroupsEntry = Resource<
114 "GCP.Dataplex.EntryGroupsEntry",
115 EntryGroupsEntryProps,
116 {
117 /** Full resource name. */
118 name: string;
119 /** Entry id (path after `/entries/`). */
120 entryId: string;
121 /** Parent entry group resource name. */
122 entryGroup: string;
123 /** Parent entry group id. */
124 entryGroupId: string;
125 /** Project id. */
126 project: string;
127 /** Location id. */
128 location: string;
129 /** Entry type resource name. */
130 entryType: string | undefined;
131 /** Parent entry resource name. */
132 parentEntry: string | undefined;
133 /** Fully qualified name. */
134 fullyQualifiedName: string | undefined;
135 /** Aspects attached to the entry. */
136 aspects: dataplex.GoogleCloudDataplexV1AspectMap | undefined;
137 /** Source-system metadata. */
138 entrySource: EntrySource | undefined;
139 /** User labels from `entrySource` (Alchemy ownership labels stripped). */
140 labels: Record<string, string>;
141 /** RFC3339 creation timestamp. */
142 createTime: string | undefined;
143 /** RFC3339 last-update timestamp. */
144 updateTime: string | undefined;
145 },
146 never,
147 Providers
148>;
149
150/**
151 * A Dataplex Universal Catalog Entry — a metadata record for a data
152 * resource.
153 *
154 * Entries have no top-level labels field, so Alchemy stamps ownership
155 * into `entrySource.labels`. Changing `entryId`, `entryGroup`,
156 * `location`, `entryType`, or `parentEntry` replaces the entry. FQN,
157 * aspects, and source metadata update in place.
158 *
159 * ### Creating an Entry
160 * **Example:** Entry in a group
161 * ```typescript
162 * const entry = yield* GCP.Dataplex.EntryGroupsEntry("Orders", {
163 * entryGroup: group.name,
164 * entryType:
165 * "projects/dataplex-types/locations/global/entryTypes/generic",
166 * labels: { env: "prod" },
167 * });
168 * ```
169 *
170 * ### Updating an Entry
171 * **Example:** Fully qualified name and labels
172 * ```typescript
173 * // Same logical id as before; only the changed props differ.
174 * const entry = yield* GCP.Dataplex.EntryGroupsEntry("Orders", {
175 * entryGroup: group.name,
176 * entryType:
177 * "projects/dataplex-types/locations/global/entryTypes/generic",
178 * fullyQualifiedName: "app.orders",
179 * labels: { env: "prod", team: "data" },
180 * });
181 * ```
182 *
183 * @resource
184 * @category Dataplex
185 */
186export const EntryGroupsEntry = Resource<EntryGroupsEntry>(
187 "GCP.Dataplex.EntryGroupsEntry",
188);
189
190const resolveParent = (
191 project: string,
192 entryGroup: string,
193 location: string | undefined,
194 defaultLocation: string,
195) => {
196 const parent = expandParent(
197 entryGroup,
198 project,
199 normalizeLocation(location, defaultLocation),
200 "entryGroups",
201 );
202 const parsed = parseName(`${parent}/entries/_`, "entries");
203 return {
204 parent: parsed.parent,
205 location: parsed.location,
206 project: parsed.project || project,
207 entryGroupId: lastSegment(parsed.parent),
208 };
209};
210
211const resourceName = (parent: string, entryId: string) =>
212 `${parent}/entries/${entryId}`;
213
214const toEntryId = (
215 id: string,
216 explicit: string | undefined,
217 existing: string | undefined,
218) =>
219 Effect.gen(function* () {
220 if (explicit !== undefined) return explicit;
221 if (existing !== undefined) return existing;
222 return rfc1035(
223 yield* createPhysicalName({
224 id,
225 maxLength: MAX_NAME_LENGTH,
226 lowercase: true,
227 }),
228 "entry",
229 );
230 });
231
232const sourceLabels = (
233 news: EntryGroupsEntryProps,
234 ownership: Record<string, string>,
235) => ({
236 ...toLabels(news.entrySource?.labels),
237 ...toLabels(news.labels),
238 ...ownership,
239});
240
241const toSource = (
242 source: dataplex.GoogleCloudDataplexV1EntrySource | undefined,
243): EntrySource | undefined => {
244 if (source === undefined) return undefined;
245 return {
246 resource: source.resource,
247 system: source.system,
248 platform: source.platform,
249 displayName: source.displayName,
250 description: source.description,
251 labels: userLabels(source.labels),
252 ancestors: source.ancestors,
253 createTime: source.createTime,
254 updateTime: source.updateTime,
255 };
256};
257
258const toAttrs = (
259 entry: dataplex.GoogleCloudDataplexV1Entry,
260 project: string,
261) => {
262 const name = entry.name ?? "";
263 const parsed = parseName(name, "entries");
264 const group = parseName(parsed.parent, "entryGroups");
265 return {
266 name,
267 entryId: parsed.id,
268 entryGroup: parsed.parent,
269 entryGroupId: group.id,
270 project: parsed.project || project,
271 location: parsed.location,
272 entryType: entry.entryType,
273 parentEntry: entry.parentEntry,
274 fullyQualifiedName: entry.fullyQualifiedName,
275 aspects: entry.aspects,
276 entrySource: toSource(entry.entrySource),
277 labels: userLabels(entry.entrySource?.labels),
278 createTime: entry.createTime,
279 updateTime: entry.updateTime,
280 };
281};
282
283const getByName = (name: string) =>
284 retryQuota(
285 dataplex.getProjectsLocationsEntryGroupsEntries({ name, view: "ALL" }),
286 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
287
288const listEntriesUnder = (parent: string, project: string) =>
289 collectPages(
290 dataplex.listProjectsLocationsEntryGroupsEntries.pages({
291 parent,
292 pageSize: 100,
293 }),
294 (page) => page.entries,
295 ).pipe(
296 Effect.map((items) =>
297 items
298 .filter((item) => hasAlchemyLabelMap(item.entrySource?.labels))
299 .map((item) => toAttrs(item, project)),
300 ),
301 Effect.catchTag("NotFound", () => Effect.succeed([])),
302 );
303
304export const EntryGroupsEntryProvider = () =>
305 Provider.succeed(EntryGroupsEntry, {
306 stables: [
307 "name",
308 "entryId",
309 "entryGroup",
310 "entryGroupId",
311 "project",
312 "location",
313 "entryType",
314 "parentEntry",
315 "createTime",
316 ],
317
318 diff: Effect.fn(function* ({ news, olds, output }) {
319 if (!isResolved(news)) return undefined;
320 const env = yield* GcpEnvironment.current;
321 const previousType = olds?.entryType ?? output?.entryType ?? "";
322 const nextType = news.entryType ?? previousType;
323 const previousParentEntry =
324 olds?.parentEntry ?? output?.parentEntry ?? "";
325 const nextParentEntry = news.parentEntry ?? previousParentEntry;
326 return replaceOnIdentity({
327 previousId: olds?.entryId ?? output?.entryId,
328 nextId: news.entryId ?? olds?.entryId ?? output?.entryId,
329 previousLocation: normalizeLocation(
330 olds?.location ?? output?.location,
331 env.region,
332 ),
333 nextLocation: normalizeLocation(
334 news.location ?? olds?.location ?? output?.location,
335 env.region,
336 ),
337 previousParent: olds?.entryGroup ?? output?.entryGroup,
338 nextParent: news.entryGroup ?? olds?.entryGroup ?? output?.entryGroup,
339 extra:
340 nextType !== previousType || nextParentEntry !== previousParentEntry,
341 });
342 }),
343
344 read: Effect.fn(function* ({ id, olds, output }) {
345 const env = yield* GcpEnvironment.current;
346 const resolved = resolveParent(
347 env.project,
348 olds?.entryGroup ?? output?.entryGroup ?? "",
349 olds?.location ?? output?.location,
350 env.region,
351 );
352 const entryId = yield* toEntryId(id, olds?.entryId, output?.entryId);
353 const name = output?.name ?? resourceName(resolved.parent, entryId);
354 const existing = yield* getByName(name);
355 if (existing === undefined) return undefined;
356 const attrs = toAttrs(existing, env.project);
357 return (yield* hasAlchemyLabels(
358 id,
359 tagRecord(existing.entrySource?.labels),
360 ))
361 ? attrs
362 : Unowned(attrs);
363 }),
364
365 list: () =>
366 Effect.gen(function* () {
367 const env = yield* GcpEnvironment.current;
368 const groups = yield* listAtLocation(
369 env.project,
370 env.region,
371 (parent) =>
372 collectPages(
373 dataplex.listProjectsLocationsEntryGroups.pages({
374 parent,
375 pageSize: 1000,
376 }),
377 (page) => page.entryGroups,
378 ).pipe(
379 Effect.map((items) =>
380 items.filter((item) => hasAlchemyLabelMap(item.labels)),
381 ),
382 ),
383 );
384 const nested = yield* Effect.forEach(
385 groups,
386 (group) =>
387 group.name
388 ? listEntriesUnder(group.name, env.project)
389 : Effect.succeed([]),
390 { concurrency: 4 },
391 );
392 return nested.flat();
393 }),
394
395 reconcile: Effect.fn(function* ({ id, news, output }) {
396 const env = yield* GcpEnvironment.current;
397 const resolved = resolveParent(
398 env.project,
399 news.entryGroup,
400 news.location ?? output?.location,
401 env.region,
402 );
403 const entryId = yield* toEntryId(id, news.entryId, output?.entryId);
404 const name = resourceName(resolved.parent, entryId);
405 const ownership = yield* createInternalLabels(id);
406 const desiredSourceLabels = sourceLabels(news, ownership);
407 const desiredSource: dataplex.GoogleCloudDataplexV1EntrySource = {
408 resource: news.entrySource?.resource,
409 system: news.entrySource?.system,
410 platform: news.entrySource?.platform,
411 displayName: news.entrySource?.displayName,
412 description: news.entrySource?.description,
413 labels: desiredSourceLabels,
414 ancestors: news.entrySource?.ancestors,
415 createTime: news.entrySource?.createTime,
416 updateTime: news.entrySource?.updateTime,
417 };
418
419 let current = yield* getByName(output?.name ?? name);
420
421 if (current === undefined) {
422 const created = yield* dataplex
423 .createProjectsLocationsEntryGroupsEntries({
424 parent: resolved.parent,
425 entryId,
426 body: {
427 entryType: news.entryType,
428 parentEntry: news.parentEntry,
429 fullyQualifiedName: news.fullyQualifiedName,
430 aspects: news.aspects,
431 entrySource: desiredSource,
432 },
433 })
434 .pipe(
435 retryQuota,
436 Effect.catchTag("Conflict", () => getByName(name)),
437 );
438 current = created ?? undefined;
439 if (current === undefined) {
440 current = yield* waitUntilExists(getByName(name), name);
441 }
442 }
443
444 if (current === undefined) {
445 return yield* new DataplexNotResolved({ name });
446 }
447
448 const observedLabels = tagRecord(current.entrySource?.labels);
449 const { upsert, removed } = diffLabels(
450 observedLabels,
451 desiredSourceLabels,
452 );
453 const labelsChanged = upsert.length > 0 || removed.length > 0;
454 const fqnChanged =
455 (current.fullyQualifiedName ?? "") !== (news.fullyQualifiedName ?? "");
456 const aspectsChanged =
457 news.aspects !== undefined &&
458 fingerprint(news.aspects) !== fingerprint(current.aspects);
459 const sourceChanged =
460 (current.entrySource?.resource ?? "") !==
461 (news.entrySource?.resource ?? "") ||
462 (current.entrySource?.system ?? "") !==
463 (news.entrySource?.system ?? "") ||
464 (current.entrySource?.platform ?? "") !==
465 (news.entrySource?.platform ?? "") ||
466 (current.entrySource?.displayName ?? "") !==
467 (news.entrySource?.displayName ?? "") ||
468 (current.entrySource?.description ?? "") !==
469 (news.entrySource?.description ?? "");
470
471 if (labelsChanged || fqnChanged || aspectsChanged || sourceChanged) {
472 current = yield* retryQuota(
473 dataplex.patchProjectsLocationsEntryGroupsEntries({
474 name: current.name ?? name,
475 updateMask: [
476 fqnChanged ? "fullyQualifiedName" : undefined,
477 aspectsChanged ? "aspects" : undefined,
478 labelsChanged || sourceChanged ? "entrySource" : undefined,
479 ]
480 .filter((field): field is string => field !== undefined)
481 .join(","),
482 body: {
483 name: current.name ?? name,
484 fullyQualifiedName: news.fullyQualifiedName,
485 aspects: news.aspects ?? current.aspects,
486 entrySource: desiredSource,
487 },
488 }),
489 );
490 }
491
492 return toAttrs(current, env.project);
493 }),
494
495 delete: Effect.fn(function* ({ output }) {
496 yield* retryQuota(
497 dataplex.deleteProjectsLocationsEntryGroupsEntries({
498 name: output.name,
499 }),
500 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
501 yield* waitUntilGone(getByName(output.name), output.name);
502 }),
503 });