1import * as developerconnect from "@distilled.cloud/gcp/developerconnect_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 { waitForOperation as waitForGcpOperation } from "../Operation.ts";
8import { tagRecord } from "../../Tags.ts";
9import { stripInternalLabels } from "../Labels.ts";
10
11export const MAX_NAME_LENGTH = 63;
12
13export class ResourceNotResolved extends Data.TaggedError(
14 "GCP.DeveloperConnect.ResourceNotResolved",
15)<{
16 name: string;
17}> {}
18
19export class ResourceStillExists extends Data.TaggedError(
20 "GCP.DeveloperConnect.ResourceStillExists",
21)<{
22 name: string;
23}> {}
24
25export const lastSegment = (value: string) => {
26 const trimmed = value.replace(/\/+$/, "");
27 const parts = trimmed.split("/");
28 return parts[parts.length - 1] || trimmed;
29};
30
31export const rfc1035 = (
32 name: string,
33 fallback = "developerconnect",
34): string => {
35 let next = name
36 .toLowerCase()
37 .replace(/[^a-z0-9-]/g, "-")
38 .replace(/-+/g, "-")
39 .replace(/^-+|-+$/g, "");
40 if (!/^[a-z]/.test(next)) next = `d${next}`;
41 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
42 if (next.length === 0) return fallback;
43 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
44 return next.slice(0, MAX_NAME_LENGTH);
45};
46
47export const normalizeLocation = (
48 location: string | undefined,
49 defaultLocation: string,
50) => lastSegment(location ?? defaultLocation).toLowerCase();
51
52export const parentOf = (project: string, location: string) =>
53 `projects/${project}/locations/${lastSegment(location).toLowerCase()}`;
54
55export const toPhysicalId = (
56 id: string,
57 explicit: string | undefined,
58 existing: string | undefined,
59 fallback = "developerconnect",
60) =>
61 Effect.gen(function* () {
62 if (explicit !== undefined) return rfc1035(explicit, fallback);
63 if (existing !== undefined) return existing;
64 return rfc1035(
65 yield* createPhysicalName({
66 id,
67 maxLength: MAX_NAME_LENGTH,
68 lowercase: true,
69 }),
70 fallback,
71 );
72 });
73
74export const parseName = (
75 name: string,
76 collection: string,
77 defaultLocation: string,
78) => {
79 const parts = name.split("/").filter((part) => part.length > 0);
80 const collectionAt = parts.lastIndexOf(collection);
81 const locationsAt = parts.lastIndexOf("locations");
82 const projectsAt = parts.lastIndexOf("projects");
83 return {
84 project:
85 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
86 location:
87 locationsAt >= 0 && parts[locationsAt + 1]
88 ? parts[locationsAt + 1]!
89 : defaultLocation,
90 id:
91 collectionAt >= 0 && parts[collectionAt + 1]
92 ? parts[collectionAt + 1]!
93 : lastSegment(name),
94 parent:
95 collectionAt > 0
96 ? parts.slice(0, collectionAt).join("/")
97 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
98 };
99};
100
101export const expandParent = (
102 value: string,
103 project: string,
104 location: string,
105 collection: string,
106) => {
107 if (value.includes("/")) return value.replace(/\/+$/, "");
108 return `projects/${project}/locations/${location}/${collection}/${value}`;
109};
110
111export const userLabels = (
112 labels: Record<string, string | undefined> | null | undefined,
113): Record<string, string> => stripInternalLabels(tagRecord(labels));
114
115export const userAnnotations = (
116 annotations: Record<string, string | undefined> | null | undefined,
117): Record<string, string> => tagRecord(annotations);
118
119export const hasAlchemyLabelMap = (
120 labels: Record<string, string | undefined> | null | undefined,
121) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
122
123export const compact = <T extends Record<string, unknown>>(value: T): T =>
124 Object.fromEntries(
125 Object.entries(value).filter(([, item]) => item !== undefined),
126 ) as T;
127
128export const canonical = (value: unknown): unknown => {
129 if (value === undefined || value === null) return undefined;
130 if (typeof value === "boolean" || typeof value === "number") return value;
131 if (typeof value === "string") {
132 return value.length === 0 ? undefined : value;
133 }
134 if (Array.isArray(value)) {
135 const items = value.map(canonical).filter((item) => item !== undefined);
136 return items.length === 0 ? undefined : items;
137 }
138 if (typeof value === "object") {
139 const entries = Object.entries(value as Record<string, unknown>)
140 .map(([key, item]) => [key, canonical(item)] as const)
141 .filter(([, item]) => item !== undefined)
142 .sort(([left], [right]) => left.localeCompare(right));
143 if (entries.length === 0) return undefined;
144 return Object.fromEntries(entries);
145 }
146 return undefined;
147};
148
149export const fingerprint = (value: unknown): string =>
150 JSON.stringify(canonical(value) ?? null);
151
152export const fieldMask = (fields: Array<string | false | undefined>) =>
153 fields
154 .filter((field): field is string => typeof field === "string")
155 .join(",");
156
157export const sameText = (left: string | undefined, right: string | undefined) =>
158 (left ?? "") === (right ?? "");
159
160export const sameBool = (
161 left: boolean | undefined,
162 right: boolean | undefined,
163) => (left ?? false) === (right ?? false);
164
165export const replaceOnIdentity = (input: {
166 previousId: string | undefined;
167 nextId: string | undefined;
168 previousLocation: string;
169 nextLocation: string;
170 extra?: boolean;
171 previousParent?: string;
172 nextParent?: string;
173}) => {
174 const parentChanged =
175 (input.previousParent ?? "") !== "" &&
176 (input.nextParent ?? "") !== "" &&
177 (input.previousParent ?? "") !== (input.nextParent ?? "");
178 const replace =
179 (input.extra ?? false) ||
180 parentChanged ||
181 (input.previousId !== undefined &&
182 input.nextId !== undefined &&
183 input.nextId !== input.previousId) ||
184 input.previousLocation !== input.nextLocation;
185 if (!replace) return undefined;
186 const samePhysical =
187 input.previousLocation === input.nextLocation &&
188 !parentChanged &&
189 input.previousId !== undefined &&
190 input.nextId === input.previousId;
191 return {
192 action: "replace" as const,
193 deleteFirst: samePhysical,
194 };
195};
196
197/**
198 * Wait for a long-running operation. Connections and links settle within a few minutes. ALREADY_EXISTS (code 6)
199 * counts as success (create race); with `notFoundOk`, so does NOT_FOUND
200 * (code 5, delete race) and an operation that is already gone.
201 */
202export const waitForOperation = (
203 operation: developerconnect.Operation,
204 options?: { notFoundOk?: boolean },
205) =>
206 waitForGcpOperation(
207 operation,
208 (name) => developerconnect.getProjectsLocationsOperations({ name }),
209 { budget: "10 minutes" },
210 ).pipe(
211 Effect.catchIf(
212 (error) =>
213 (error._tag === "GCP.OperationFailed" &&
214 (error.code === 6 ||
215 (options?.notFoundOk === true && error.code === 5))) ||
216 (options?.notFoundOk === true && error._tag === "NotFound"),
217 () => Effect.succeed(operation),
218 ),
219 );
220
221export const waitUntilExists = <A, E, R>(
222 get: Effect.Effect<A | undefined, E, R>,
223 name: string,
224): Effect.Effect<NonNullable<A>, E | ResourceNotResolved, R> =>
225 get.pipe(
226 Effect.filterOrFail(
227 (value): value is NonNullable<A> => value != null,
228 () => new ResourceNotResolved({ name }),
229 ),
230 Effect.retry({
231 while: (error) => error instanceof ResourceNotResolved,
232 times: 8,
233 schedule: Schedule.spaced("1 second"),
234 }),
235 );
236
237export const waitUntilGone = <A, E, R>(
238 get: Effect.Effect<A | undefined, E, R>,
239 name: string,
240): Effect.Effect<void, E | ResourceStillExists, R> =>
241 get.pipe(
242 Effect.filterOrFail(
243 (value) => value === undefined,
244 () => new ResourceStillExists({ name }),
245 ),
246 Effect.retry({
247 while: (error) => error instanceof ResourceStillExists,
248 times: 10,
249 schedule: Schedule.spaced("2 seconds"),
250 }),
251 Effect.asVoid,
252 );
253
254export const listAtLocation = <A, E, R>(
255 project: string,
256 region: string,
257 list: (parent: string) => Effect.Effect<A[], E, R>,
258): Effect.Effect<A[], E, R> =>
259 // Prefer the all-locations wildcard; fall back to the default region
260 // (whose error, if any, propagates).
261 list(`projects/${project}/locations/-`).pipe(
262 Effect.catch(() => list(`projects/${project}/locations/${region}`)),
263 );
264
265export const listAtNested = <A, E, R>(
266 project: string,
267 nested: string,
268 region: string,
269 list: (parent: string) => Effect.Effect<A[], E, R>,
270): Effect.Effect<A[], E, R> =>
271 // Prefer the all-locations wildcard; fall back to the default region
272 // (whose error, if any, propagates).
273 list(`projects/${project}/locations/-/${nested}`).pipe(
274 Effect.catch(() =>
275 list(`projects/${project}/locations/${region}/${nested}`),
276 ),
277 );
278
279export const listLabeledPages = <Page, A, E, R>(
280 pages: Stream.Stream<Page, E, R>,
281 items: (page: Page) => readonly A[] | undefined,
282 labelsOf: (item: A) => Record<string, string | undefined> | null | undefined,
283): Effect.Effect<A[], E, R> =>
284 pages.pipe(
285 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
286 Stream.filter((item) => hasAlchemyLabelMap(labelsOf(item))),
287 Stream.runCollect,
288 Effect.map((chunk) => Array.from(chunk)),
289 );
290
291export const collectPages = <Page, Item, E, R>(
292 stream: Stream.Stream<Page, E, R>,
293 pick: (page: Page) => readonly Item[] | undefined,
294): Effect.Effect<Item[], E, R> =>
295 stream.pipe(
296 Stream.flatMap((page) => Stream.fromIterable(pick(page) ?? [])),
297 Stream.runCollect,
298 Effect.map((chunk) => Array.from(chunk)),
299 );