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 fingerprint,
21 hasAlchemyLabelMap,
22 listAtLocation,
23 normalizeLocation,
24 parentOf,
25 parseName,
26 replaceOnIdentity,
27 retryQuota,
28 toPhysicalId,
29 userLabels,
30 waitForOperation,
31 waitUntilExists,
32 waitUntilGone,
33} from "./internal.ts";
34
35export type DataAttributeBindingPath =
36 dataplex.GoogleCloudDataplexV1DataAttributeBindingPath;
37
38export type DataAttributeBindingProps = {
39 /**
40 * DataAttributeBinding id. If omitted, a unique name is generated.
41 * Must contain only lowercase letters, numbers and hyphens, start with
42 * a letter, end with a letter or number, and be 1-63 characters.
43 * Immutable — changing it replaces the binding.
44 */
45 dataAttributeBindingId?: string;
46 /**
47 * Region (`us-central1`, …). Immutable — changing it replaces the
48 * binding.
49 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
50 */
51 location?: string;
52 /**
53 * Human-readable description.
54 */
55 description?: string;
56 /**
57 * User-friendly display name.
58 */
59 displayName?: string;
60 /**
61 * User labels. Alchemy ownership labels are merged in automatically.
62 */
63 labels?: Record<string, string>;
64 /**
65 * Immutable Dataplex entity this binding attaches to. Changing it
66 * replaces the binding.
67 */
68 resource?: string;
69 /**
70 * DataAttribute resource names to associate with the resource.
71 */
72 attributes?: string[];
73 /**
74 * Path-level attribute bindings (columns, partitions).
75 */
76 paths?: DataAttributeBindingPath[];
77};
78
79export type DataAttributeBinding = Resource<
80 "GCP.Dataplex.DataAttributeBinding",
81 DataAttributeBindingProps,
82 {
83 /** Full resource name. */
84 name: string;
85 /** Binding id (last path segment). */
86 dataAttributeBindingId: string;
87 /** Project id. */
88 project: string;
89 /** Location id. */
90 location: string;
91 /** Description. */
92 description: string | undefined;
93 /** Display name. */
94 displayName: string | undefined;
95 /** User labels (Alchemy ownership labels stripped). */
96 labels: Record<string, string>;
97 /** Bound entity resource name. */
98 resource: string | undefined;
99 /** Bound attribute resource names. */
100 attributes: string[];
101 /** Path-level bindings. */
102 paths: DataAttributeBindingPath[];
103 /** Server etag. */
104 etag: string | undefined;
105 /** System uid. */
106 uid: string | 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 DataAttributeBinding that associates Data Taxonomy attributes
118 * with a Dataplex entity (and optional column/partition paths).
119 *
120 * Location, binding id, and `resource` are immutable. Description, display
121 * name, labels, attributes, and paths update in place.
122 *
123 * ### Creating a DataAttributeBinding
124 * **Example:** Bind attributes to an entity
125 * ```typescript
126 * const binding = yield* GCP.Dataplex.DataAttributeBinding("Pii", {
127 * resource: entity.name,
128 * attributes: [attribute.name],
129 * labels: { env: "test" },
130 * });
131 * ```
132 *
133 * @resource
134 * @category Dataplex
135 */
136export const DataAttributeBinding = Resource<DataAttributeBinding>(
137 "GCP.Dataplex.DataAttributeBinding",
138);
139
140const resourceName = (
141 project: string,
142 location: string,
143 dataAttributeBindingId: string,
144) =>
145 `projects/${project}/locations/${location}/dataAttributeBindings/${dataAttributeBindingId}`;
146
147const toAttrs = (
148 binding: dataplex.GoogleCloudDataplexV1DataAttributeBinding,
149 project: string,
150) => {
151 const name = binding.name ?? "";
152 const parsed = parseName(name, "dataAttributeBindings");
153 return {
154 name,
155 dataAttributeBindingId: parsed.id,
156 project: parsed.project || project,
157 location: parsed.location,
158 description: binding.description,
159 displayName: binding.displayName,
160 labels: userLabels(binding.labels),
161 resource: binding.resource,
162 attributes: [...(binding.attributes ?? [])],
163 paths: [...(binding.paths ?? [])],
164 etag: binding.etag,
165 uid: binding.uid,
166 createTime: binding.createTime,
167 updateTime: binding.updateTime,
168 };
169};
170
171const getByName = (name: string) =>
172 retryQuota(dataplex.getProjectsLocationsDataAttributeBindings({ name })).pipe(
173 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
174 );
175
176const listBindings = (project: string, region: string) => {
177 const collect = (parent: string) =>
178 collectPages(
179 dataplex.listProjectsLocationsDataAttributeBindings.pages({
180 parent,
181 pageSize: 1000,
182 }),
183 (page) => page.dataAttributeBindings,
184 ).pipe(
185 Effect.map((items) =>
186 items.filter((item) => hasAlchemyLabelMap(item.labels)),
187 ),
188 Effect.catchTag("NotFound", () => Effect.succeed([])),
189 );
190 return listAtLocation(project, region, collect);
191};
192
193export const DataAttributeBindingProvider = () =>
194 Provider.succeed(DataAttributeBinding, {
195 stables: [
196 "name",
197 "dataAttributeBindingId",
198 "project",
199 "location",
200 "resource",
201 "uid",
202 "createTime",
203 ],
204
205 diff: Effect.fn(function* ({ news, olds, output }) {
206 if (!isResolved(news)) return undefined;
207 const env = yield* GcpEnvironment.current;
208 return replaceOnIdentity({
209 previousId:
210 olds?.dataAttributeBindingId ?? output?.dataAttributeBindingId,
211 nextId:
212 news.dataAttributeBindingId ??
213 olds?.dataAttributeBindingId ??
214 output?.dataAttributeBindingId,
215 previousLocation: normalizeLocation(
216 olds?.location ?? output?.location,
217 env.region,
218 ),
219 nextLocation: normalizeLocation(
220 news.location ?? olds?.location ?? output?.location,
221 env.region,
222 ),
223 extra:
224 (news.resource ?? olds?.resource ?? output?.resource ?? "") !==
225 (olds?.resource ?? output?.resource ?? ""),
226 });
227 }),
228
229 read: Effect.fn(function* ({ id, olds, output }) {
230 const env = yield* GcpEnvironment.current;
231 const dataAttributeBindingId = yield* toPhysicalId(
232 id,
233 olds?.dataAttributeBindingId,
234 output?.dataAttributeBindingId,
235 "attrbinding",
236 );
237 const location = normalizeLocation(
238 olds?.location ?? output?.location,
239 env.region,
240 );
241 const name =
242 output?.name ??
243 resourceName(env.project, location, dataAttributeBindingId);
244 const existing = yield* getByName(name);
245 if (existing === undefined) return undefined;
246 const attrs = toAttrs(existing, env.project);
247 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
248 ? attrs
249 : Unowned(attrs);
250 }),
251
252 list: () =>
253 Effect.gen(function* () {
254 const env = yield* GcpEnvironment.current;
255 const items = yield* listBindings(env.project, env.region);
256 return items.map((item) => toAttrs(item, env.project));
257 }),
258
259 reconcile: Effect.fn(function* ({ id, news, output }) {
260 const env = yield* GcpEnvironment.current;
261 const dataAttributeBindingId = yield* toPhysicalId(
262 id,
263 news.dataAttributeBindingId,
264 output?.dataAttributeBindingId,
265 "attrbinding",
266 );
267 const location = normalizeLocation(
268 news.location ?? output?.location,
269 env.region,
270 );
271 const name = resourceName(env.project, location, dataAttributeBindingId);
272 const desiredLabels = {
273 ...toLabels(news.labels),
274 ...(yield* createInternalLabels(id)),
275 };
276
277 let current = yield* getByName(output?.name ?? name);
278
279 if (current === undefined) {
280 const created = yield* retryQuota(
281 dataplex.createProjectsLocationsDataAttributeBindings({
282 parent: parentOf(env.project, location),
283 dataAttributeBindingId,
284 body: {
285 description: news.description,
286 displayName: news.displayName,
287 labels: desiredLabels,
288 resource: news.resource,
289 attributes: news.attributes,
290 paths: news.paths,
291 },
292 }),
293 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
294 if (created !== undefined) {
295 yield* waitForOperation(created);
296 }
297 current = yield* waitUntilExists(getByName(name), name);
298 }
299
300 if (current === undefined) {
301 return yield* new DataplexNotResolved({ name });
302 }
303
304 const observedLabels = tagRecord(current.labels);
305 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
306 const labelsChanged = upsert.length > 0 || removed.length > 0;
307 const descriptionChanged =
308 (current.description ?? "") !== (news.description ?? "");
309 const displayNameChanged =
310 (current.displayName ?? "") !== (news.displayName ?? "");
311 const attributesChanged =
312 fingerprint([...(current.attributes ?? [])].sort()) !==
313 fingerprint([...(news.attributes ?? [])].sort());
314 const pathsChanged =
315 fingerprint(current.paths) !== fingerprint(news.paths);
316
317 if (
318 labelsChanged ||
319 descriptionChanged ||
320 displayNameChanged ||
321 attributesChanged ||
322 pathsChanged
323 ) {
324 const operation =
325 yield* dataplex.patchProjectsLocationsDataAttributeBindings({
326 name: current.name ?? name,
327 updateMask: [
328 labelsChanged ? "labels" : undefined,
329 descriptionChanged ? "description" : undefined,
330 displayNameChanged ? "displayName" : undefined,
331 attributesChanged ? "attributes" : undefined,
332 pathsChanged ? "paths" : undefined,
333 ]
334 .filter((field): field is string => field !== undefined)
335 .join(","),
336 body: {
337 name: current.name ?? name,
338 etag: current.etag,
339 labels: desiredLabels,
340 description: news.description,
341 displayName: news.displayName,
342 attributes: news.attributes,
343 paths: news.paths,
344 },
345 });
346 yield* waitForOperation(operation);
347 current = yield* waitUntilExists(
348 getByName(current.name ?? name),
349 current.name ?? name,
350 );
351 }
352
353 return toAttrs(current, env.project);
354 }),
355
356 delete: Effect.fn(function* ({ output }) {
357 const operation = yield* dataplex
358 .deleteProjectsLocationsDataAttributeBindings({
359 name: output.name,
360 etag: output.etag,
361 })
362 .pipe(
363 Effect.retry({
364 while: (error) => error._tag === "Conflict",
365 times: 8,
366 schedule: Schedule.spaced("2 seconds"),
367 }),
368 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
369 );
370 if (operation !== undefined) {
371 yield* waitForOperation(operation, { notFoundOk: true });
372 }
373 yield* waitUntilGone(getByName(output.name), output.name);
374 }),
375 });