1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import { tagRecord } from "../../Tags.ts";
7import { stripInternalLabels } from "../Labels.ts";
8
9export const MAX_NAME_LENGTH = 63;
10
11export class ResourceNotResolved extends Data.TaggedError(
12 "GCP.SaasServiceManagement.NotResolved",
13)<{
14 name: string;
15}> {}
16
17export class ResourceStillExists extends Data.TaggedError(
18 "GCP.SaasServiceManagement.StillExists",
19)<{
20 name: string;
21}> {}
22
23export const lastSegment = (value: string) => {
24 const trimmed = value.replace(/\/+$/, "");
25 const parts = trimmed.split("/");
26 return parts[parts.length - 1] || trimmed;
27};
28
29export const rfc1035 = (
30 name: string,
31 fallback = "saas",
32 maxLength = MAX_NAME_LENGTH,
33): string => {
34 let next = name
35 .toLowerCase()
36 .replace(/[^a-z0-9-]/g, "-")
37 .replace(/-+/g, "-")
38 .replace(/^-+/, "")
39 .replace(/-+$/, "");
40 if (!/^[a-z]/.test(next)) {
41 next = `${fallback[0] ?? "s"}${next}`;
42 }
43 next = next.slice(0, maxLength).replace(/-+$/, "");
44 if (next.length === 0) return fallback;
45 if (!/[a-z0-9]$/.test(next)) {
46 next = `${next.slice(0, maxLength - 1)}0`;
47 }
48 return next.slice(0, maxLength);
49};
50
51export const normalizeLocation = (location: string) =>
52 lastSegment(location).toLowerCase();
53
54export const parentOf = (project: string, location: string) =>
55 `projects/${project}/locations/${location}`;
56
57export const resourceName = (
58 project: string,
59 location: string,
60 collection: string,
61 id: string,
62) => `${parentOf(project, location)}/${collection}/${id}`;
63
64export const parseName = (name: string, collection: string) => {
65 const parts = name.split("/").filter((part) => part.length > 0);
66 const collectionAt = parts.lastIndexOf(collection);
67 const locationsAt = parts.lastIndexOf("locations");
68 const projectsAt = parts.lastIndexOf("projects");
69 return {
70 project:
71 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
72 location:
73 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
74 id:
75 collectionAt >= 0 && parts[collectionAt + 1]
76 ? parts[collectionAt + 1]!
77 : lastSegment(name),
78 };
79};
80
81export const toPhysicalId = (
82 id: string,
83 explicit: string | undefined,
84 existing: string | undefined,
85 fallback = "saas",
86) =>
87 Effect.gen(function* () {
88 if (explicit !== undefined) return rfc1035(explicit, fallback);
89 if (existing !== undefined) return existing;
90 return rfc1035(
91 yield* createPhysicalName({
92 id,
93 maxLength: MAX_NAME_LENGTH,
94 lowercase: true,
95 }),
96 fallback,
97 );
98 });
99
100export const expandName = (
101 value: string,
102 project: string,
103 location: string,
104 collection: string,
105) => {
106 const trimmed = value.replace(/\/+$/, "");
107 if (trimmed.includes("/")) return trimmed;
108 return resourceName(project, location, collection, trimmed);
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> =>
118 Object.fromEntries(
119 Object.entries(annotations ?? {}).filter(
120 (entry): entry is [string, string] =>
121 entry[1] !== undefined && entry[1].length > 0,
122 ),
123 );
124
125export const hasAlchemyLabelKeys = (
126 labels: Record<string, string | undefined> | null | undefined,
127) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
128
129export const canonical = (value: unknown): unknown => {
130 if (value === undefined || value === null) return undefined;
131 if (typeof value === "boolean" || typeof value === "number") return value;
132 if (typeof value === "string") {
133 return value.length === 0 ? undefined : value;
134 }
135 if (Array.isArray(value)) {
136 const items = value.map(canonical).filter((item) => item !== undefined);
137 return items.length === 0 ? undefined : items;
138 }
139 if (typeof value === "object") {
140 const entries = Object.entries(value as Record<string, unknown>)
141 .map(([key, item]) => [key, canonical(item)] as const)
142 .filter(([, item]) => item !== undefined)
143 .sort(([left], [right]) => left.localeCompare(right));
144 if (entries.length === 0) return undefined;
145 return Object.fromEntries(entries);
146 }
147 return undefined;
148};
149
150export const fingerprint = (value: unknown): string =>
151 JSON.stringify(canonical(value) ?? null);
152
153export const fieldMask = (fields: Array<string | false | undefined>) =>
154 fields
155 .filter((field): field is string => typeof field === "string")
156 .join(",");
157
158export const sameRef = (left: string | undefined, right: string | undefined) =>
159 lastSegment(left ?? "") === lastSegment(right ?? "");
160
161export const replaceOnIdentity = (input: {
162 previousId: string | undefined;
163 nextId: string | undefined;
164 previousLocation: string;
165 nextLocation: string;
166 extra?: boolean;
167}) => {
168 const replace =
169 (input.extra ?? false) ||
170 (input.previousId !== undefined &&
171 input.nextId !== undefined &&
172 input.nextId !== input.previousId) ||
173 input.previousLocation !== input.nextLocation;
174 if (!replace) return undefined;
175 const samePhysical =
176 input.previousLocation === input.nextLocation &&
177 input.previousId !== undefined &&
178 input.nextId === input.previousId;
179 return {
180 action: "replace" as const,
181 deleteFirst: samePhysical,
182 };
183};
184
185export const waitUntilGone = <A, E extends { readonly _tag: string }, R>(
186 get: Effect.Effect<A | undefined, E, R>,
187 name: string,
188): Effect.Effect<void, E | ResourceStillExists, R> =>
189 get.pipe(
190 Effect.filterOrFail(
191 (value) => value === undefined,
192 () => new ResourceStillExists({ name }),
193 ),
194 Effect.retry({
195 while: (error) => error._tag === "GCP.SaasServiceManagement.StillExists",
196 times: 10,
197 schedule: Schedule.spaced("2 seconds"),
198 }),
199 Effect.asVoid,
200 );
201
202export const collectPages = <
203 Page,
204 Item,
205 E extends { readonly _tag: string },
206 R,
207>(
208 stream: Stream.Stream<Page, E, R>,
209 pick: (page: Page) => readonly Item[] | undefined,
210) =>
211 stream.pipe(
212 Stream.flatMap((page) => Stream.fromIterable(pick(page) ?? [])),
213 Stream.runCollect,
214 Effect.map((chunk) => Array.from(chunk)),
215 Effect.catchIf(
216 (error): error is Extract<E, { readonly _tag: "NotFound" }> =>
217 error._tag === "NotFound",
218 () => Effect.succeed([] as Item[]),
219 ),
220 );