1import type * as datacatalog from "@distilled.cloud/gcp/datacatalog_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import { alchemyLabelKeys, hasAlchemyLabels } from "../Labels.ts";
8import { isTransientGcpError } from "../Errors.ts";
9
10export const MAX_TEMPLATE_ID_LENGTH = 64;
11export const MAX_DISPLAY_NAME_LENGTH = 200;
12export const MAX_DESCRIPTION_LENGTH = 2000;
13export const OWNERSHIP_FIELD_ID = "alchemy_ownership";
14
15export type FieldType = datacatalog.GoogleCloudDatacatalogV1FieldType;
16export type TagTemplateField =
17 datacatalog.GoogleCloudDatacatalogV1TagTemplateField;
18export type TagTemplateFieldMap =
19 datacatalog.GoogleCloudDatacatalogV1TagTemplateFieldMap;
20export type TaxonomyActivatedPolicyType =
21 | datacatalog.GoogleCloudDatacatalogV1TaxonomyActivatedPolicyTypesItemEnum
22 | (string & {});
23
24export class DatacatalogNotResolved extends Data.TaggedError(
25 "GCP.DataCatalog.ResourceNotResolved",
26)<{
27 name: string;
28}> {}
29
30export const lastSegment = (value: string) => {
31 const trimmed = value.replace(/\/+$/, "");
32 const parts = trimmed.split("/");
33 return parts[parts.length - 1] || trimmed;
34};
35
36export const normalizeLocation = (
37 location: string | undefined,
38 fallback: string,
39) => lastSegment(location ?? fallback).toLowerCase();
40
41export const locationParent = (project: string, location: string) =>
42 `projects/${project}/locations/${lastSegment(location).toLowerCase()}`;
43
44export const parseName = (
45 name: string,
46 collection: string,
47 fallbackLocation: string,
48) => {
49 const parts = name.split("/").filter((part) => part.length > 0);
50 const collectionAt = parts.lastIndexOf(collection);
51 const locationsAt = parts.lastIndexOf("locations");
52 const projectsAt = parts.lastIndexOf("projects");
53 return {
54 project:
55 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
56 location:
57 locationsAt >= 0 && parts[locationsAt + 1]
58 ? parts[locationsAt + 1]!
59 : fallbackLocation,
60 id:
61 collectionAt >= 0 && parts[collectionAt + 1]
62 ? parts[collectionAt + 1]!
63 : lastSegment(name),
64 parent:
65 collectionAt > 0
66 ? parts.slice(0, collectionAt).join("/")
67 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
68 };
69};
70
71export const expandParent = (
72 value: string,
73 project: string,
74 location: string,
75 collection: string,
76) => {
77 if (value.includes("/")) return value.replace(/\/+$/, "");
78 return `projects/${project}/locations/${location}/${collection}/${value}`;
79};
80
81const sanitizeTemplateId = (name: string) => {
82 let next = name
83 .toLowerCase()
84 .replace(/[^a-z0-9_]/g, "_")
85 .replace(/_+/g, "_");
86 if (!/^[a-z_]/.test(next)) next = `t${next}`;
87 next = next.slice(0, MAX_TEMPLATE_ID_LENGTH).replace(/_+$/g, "");
88 return next.length > 0 ? next : "template";
89};
90
91export const toTagTemplateId = (
92 id: string,
93 explicit: string | undefined,
94 existing: string | undefined,
95) =>
96 Effect.gen(function* () {
97 if (explicit !== undefined) return sanitizeTemplateId(explicit);
98 if (existing !== undefined) return existing;
99 return sanitizeTemplateId(
100 yield* createPhysicalName({
101 id,
102 maxLength: MAX_TEMPLATE_ID_LENGTH,
103 lowercase: true,
104 delimiter: "_",
105 }),
106 );
107 });
108
109export const toDisplayName = (
110 id: string,
111 explicit: string | undefined,
112 existing: string | undefined,
113) =>
114 Effect.gen(function* () {
115 if (explicit !== undefined) {
116 return explicit.trim().slice(0, MAX_DISPLAY_NAME_LENGTH);
117 }
118 if (existing !== undefined) return existing;
119 const generated = yield* createPhysicalName({
120 id,
121 maxLength: MAX_DISPLAY_NAME_LENGTH,
122 lowercase: true,
123 });
124 const next = generated
125 .replace(/[^a-zA-Z0-9_ -]/g, "-")
126 .replace(/\s+/g, " ")
127 .trim()
128 .slice(0, MAX_DISPLAY_NAME_LENGTH);
129 return next.length > 0 ? next : "alchemy";
130 });
131
132const markerOf = (labels: Record<string, string>) =>
133 `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
134
135export const encodeOwnership = (
136 labels: Record<string, string>,
137 text: string | undefined,
138 maxLength = MAX_DESCRIPTION_LENGTH,
139): string => {
140 const marker = markerOf(labels);
141 const trimmed = text?.trim();
142 if (!trimmed) return marker.slice(0, maxLength);
143 return `${marker}\n${trimmed}`.slice(0, maxLength);
144};
145
146export const parseOwnership = (
147 text: string | undefined,
148): {
149 labels: Record<string, string>;
150 text: string | undefined;
151} => {
152 if (!text?.startsWith("[alchemy ")) {
153 return { labels: {}, text };
154 }
155 const end = text.indexOf("]");
156 if (end < 0) return { labels: {}, text };
157 const labels: Record<string, string> = {};
158 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
159 const eq = part.indexOf("=");
160 if (eq > 0) {
161 labels[part.slice(0, eq)] = part.slice(eq + 1);
162 }
163 }
164 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
165 return { labels, text: rest.length > 0 ? rest : undefined };
166};
167
168export const hasOwnershipMarker = (text: string | undefined) =>
169 Object.keys(parseOwnership(text).labels).some((key) =>
170 key.startsWith("alchemy-"),
171 );
172
173export const ownedByAlchemy = (id: string, text: string | undefined) =>
174 Effect.gen(function* () {
175 const { labels } = parseOwnership(text);
176 return yield* hasAlchemyLabels(id, labels);
177 });
178
179export const sameText = (left: string | undefined, right: string | undefined) =>
180 (left ?? "") === (right ?? "");
181
182export const sameBool = (
183 left: boolean | undefined,
184 right: boolean | undefined,
185) => (left === true) === (right === true);
186
187export const sameStringList = (
188 left: readonly string[] | undefined,
189 right: readonly string[] | undefined,
190) =>
191 JSON.stringify([...(left ?? [])].sort()) ===
192 JSON.stringify([...(right ?? [])].sort());
193
194const canonical = (value: unknown): unknown => {
195 if (value === undefined || value === null) return undefined;
196 if (typeof value === "boolean" || typeof value === "number") return value;
197 if (typeof value === "string") return value.length === 0 ? undefined : value;
198 if (Array.isArray(value)) {
199 const items = value.map(canonical).filter((item) => item !== undefined);
200 return items.length === 0 ? undefined : items;
201 }
202 if (typeof value === "object") {
203 const entries = Object.entries(value as Record<string, unknown>)
204 .map(([key, item]) => [key, canonical(item)] as const)
205 .filter(([, item]) => item !== undefined)
206 .sort(([left], [right]) => left.localeCompare(right));
207 if (entries.length === 0) return undefined;
208 return Object.fromEntries(entries);
209 }
210 return undefined;
211};
212
213export const fingerprint = (value: unknown): string =>
214 JSON.stringify(canonical(value) ?? null);
215
216export const sameJson = (left: unknown, right: unknown) =>
217 fingerprint(left) === fingerprint(right);
218
219export const updateMaskOf = (...fields: Array<string | undefined>) =>
220 fields.filter((field): field is string => field !== undefined).join(",");
221
222export const replaceOnIdentity = (input: {
223 previousId?: string;
224 nextId?: string;
225 previousParent?: string;
226 nextParent?: string;
227 previousLocation?: string;
228 nextLocation?: string;
229 extra?: boolean;
230}) => {
231 const idChanged =
232 input.previousId !== undefined &&
233 input.nextId !== undefined &&
234 input.previousId !== input.nextId;
235 const parentChanged =
236 (input.previousParent ?? "") !== "" &&
237 (input.nextParent ?? "") !== "" &&
238 (input.previousParent ?? "") !== (input.nextParent ?? "");
239 const locationChanged =
240 (input.previousLocation ?? "") !== "" &&
241 (input.nextLocation ?? "") !== "" &&
242 (input.previousLocation ?? "").toLowerCase() !==
243 (input.nextLocation ?? "").toLowerCase();
244 if (!idChanged && !parentChanged && !locationChanged && !input.extra) {
245 return undefined;
246 }
247 const samePhysical =
248 !parentChanged &&
249 !locationChanged &&
250 input.previousId !== undefined &&
251 input.nextId === input.previousId;
252 return { action: "replace" as const, deleteFirst: samePhysical };
253};
254
255export const retryTransient = <A, E extends { readonly _tag: string }, R>(
256 effect: Effect.Effect<A, E, R>,
257) =>
258 effect.pipe(
259 Effect.retry({
260 while: isTransientGcpError,
261 times: 8,
262 schedule: Schedule.exponential("250 millis"),
263 }),
264 );
265
266export const ignoreGone = <A, E extends { readonly _tag: string }, R>(
267 effect: Effect.Effect<A, E, R>,
268) =>
269 effect.pipe(
270 Effect.catchIf(
271 (error) => error._tag === "NotFound",
272 () => Effect.void,
273 ),
274 );
275
276export const missingGet =
277 <A, E extends { readonly _tag: string }, R>(
278 effect: (input: { name: string }) => Effect.Effect<A, E, R>,
279 ) =>
280 (name: string) =>
281 name.length === 0
282 ? Effect.succeed(undefined)
283 : effect({ name }).pipe(
284 Effect.catchIf(
285 (error) => error._tag === "NotFound",
286 () => Effect.succeed(undefined),
287 ),
288 );
289
290export const collectPages = <Page, A, E, R>(
291 pages: Stream.Stream<Page, E, R>,
292 items: (page: Page) => readonly A[] | undefined,
293): Effect.Effect<A[], E, R> =>
294 pages.pipe(
295 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
296 Stream.runCollect,
297 Effect.map((chunk): A[] => Array.from(chunk)),
298 );
299
300export const emptyOnMissing = <A, E extends { readonly _tag: string }, R>(
301 effect: Effect.Effect<A[], E, R>,
302) =>
303 effect.pipe(
304 Effect.catchIf(
305 (error) => error._tag === "NotFound",
306 () => Effect.succeed([] as A[]),
307 ),
308 );
309
310/**
311 * Data Catalog rejects the `locations/-` wildcard (404), so owned rows are
312 * listed in the default region only.
313 */
314export const listAtLocation = <A, E, R>(
315 project: string,
316 region: string,
317 list: (parent: string) => Effect.Effect<A[], E, R>,
318): Effect.Effect<A[], E, R> => list(locationParent(project, region));
319
320export const findOwned = <A, E, R>(
321 items: readonly A[],
322 id: string,
323 descriptionOf: (item: A) => string | undefined,
324) =>
325 Effect.gen(function* () {
326 for (const item of items) {
327 if (yield* ownedByAlchemy(id, descriptionOf(item))) {
328 return item;
329 }
330 }
331 return undefined;
332 });
333
334export const ownershipField = (
335 labels: Record<string, string>,
336): TagTemplateField => ({
337 displayName: "Alchemy ownership",
338 type: { primitiveType: "STRING" },
339 isRequired: false,
340 description: encodeOwnership(labels, undefined),
341 order: 0,
342});
343
344export const userFields = (
345 fields: TagTemplateFieldMap | undefined,
346): TagTemplateFieldMap => {
347 const next: TagTemplateFieldMap = {};
348 for (const [key, field] of Object.entries(fields ?? {})) {
349 if (key === OWNERSHIP_FIELD_ID || field === undefined) continue;
350 next[key] = {
351 displayName: field.displayName,
352 type: field.type,
353 isRequired: field.isRequired,
354 description: field.description,
355 order: field.order,
356 };
357 }
358 return next;
359};
360
361export const desiredFields = (
362 labels: Record<string, string>,
363 fields: TagTemplateFieldMap | undefined,
364): TagTemplateFieldMap => ({
365 ...userFields(fields),
366 [OWNERSHIP_FIELD_ID]: ownershipField(labels),
367});
368
369export const fieldBody = (field: TagTemplateField): TagTemplateField => ({
370 displayName: field.displayName,
371 type: field.type,
372 isRequired: field.isRequired === true ? true : undefined,
373 description: field.description,
374 order: field.order,
375});
376
377export const primitiveOf = (type: FieldType | undefined) =>
378 type?.primitiveType ?? "";
379
380export const enumValuesOf = (type: FieldType | undefined) =>
381 (type?.enumType?.allowedValues ?? [])
382 .map((value) => value.displayName ?? "")
383 .filter((value) => value.length > 0);
384
385export const fieldNeedsReplace = (
386 observed: TagTemplateField | undefined,
387 desired: TagTemplateField,
388) => {
389 if (observed === undefined) return false;
390 const observedPrimitive = primitiveOf(observed.type);
391 const desiredPrimitive = primitiveOf(desired.type);
392 if (observedPrimitive !== desiredPrimitive) return true;
393 const observedHasEnum =
394 (observed.type?.enumType?.allowedValues ?? []).length > 0;
395 const desiredHasEnum =
396 (desired.type?.enumType?.allowedValues ?? []).length > 0;
397 if (observedHasEnum !== desiredHasEnum) return true;
398 if (desiredHasEnum) {
399 const observedValues = new Set(enumValuesOf(observed.type));
400 const desiredValues = enumValuesOf(desired.type);
401 if (desiredValues.some((value) => !observedValues.has(value))) {
402 return false;
403 }
404 if (observedValues.size > desiredValues.length) return true;
405 }
406 return false;
407};