1import * as retail from "@distilled.cloud/gcp/retail_v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { createPhysicalName } from "../../PhysicalName.ts";
6
7export const DEFAULT_LOCATION = "global";
8export const DEFAULT_CATALOG = "default_catalog";
9export const DEFAULT_BRANCH = "default_branch";
10export const MAX_ID_LENGTH = 63;
11export const MAX_MODEL_ID_LENGTH = 40;
12export const MAX_PRODUCT_ID_LENGTH = 128;
13export const MAX_DISPLAY_NAME_LENGTH = 128;
14export const MAX_MODEL_DISPLAY_NAME_LENGTH = 1024;
15
16export class RetailNotResolved extends Data.TaggedError(
17 "GCP.Retail.ResourceNotResolved",
18)<{
19 name: string;
20}> {}
21
22export const lastSegment = (value: string) => {
23 const trimmed = value.replace(/\/+$/, "");
24 const parts = trimmed.split("/");
25 return parts[parts.length - 1] || trimmed;
26};
27
28export const parentOf = (name: string, collection?: string) => {
29 if (collection !== undefined) {
30 const marker = `/${collection}/`;
31 const index = name.lastIndexOf(marker);
32 return index >= 0 ? name.slice(0, index) : name;
33 }
34 const parts = name.split("/").filter((part) => part.length > 0);
35 return parts.slice(0, -2).join("/");
36};
37
38export const normalizeLocation = (location: string | undefined) =>
39 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
40
41export const normalizeCatalog = (catalog: string | undefined) =>
42 lastSegment(catalog ?? DEFAULT_CATALOG);
43
44export const normalizeBranch = (branch: string | undefined) =>
45 lastSegment(branch ?? DEFAULT_BRANCH);
46
47export const locationParent = (project: string, location: string) =>
48 `projects/${project}/locations/${location}`;
49
50export const catalogName = (
51 project: string,
52 location: string,
53 catalogId: string,
54) => `${locationParent(project, location)}/catalogs/${catalogId}`;
55
56export const expandCatalog = (
57 value: string | undefined,
58 project: string,
59 location: string,
60) => {
61 const raw = (value ?? DEFAULT_CATALOG).replace(/\/+$/, "");
62 if (raw.includes("/")) return raw;
63 return catalogName(project, location, raw);
64};
65
66export const branchName = (catalog: string, branchId: string) =>
67 `${catalog}/branches/${branchId}`;
68
69export const parseResourceName = (name: string, collection: string) => {
70 const parts = name.split("/").filter((part) => part.length > 0);
71 const collectionAt = parts.lastIndexOf(collection);
72 const locationsAt = parts.lastIndexOf("locations");
73 const projectsAt = parts.lastIndexOf("projects");
74 const catalogsAt = parts.lastIndexOf("catalogs");
75 const branchesAt = parts.lastIndexOf("branches");
76 return {
77 project:
78 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
79 location:
80 locationsAt >= 0 && parts[locationsAt + 1]
81 ? parts[locationsAt + 1]!
82 : DEFAULT_LOCATION,
83 catalogId:
84 catalogsAt >= 0 && parts[catalogsAt + 1]
85 ? parts[catalogsAt + 1]!
86 : DEFAULT_CATALOG,
87 catalog:
88 catalogsAt >= 0
89 ? parts.slice(0, catalogsAt + 2).join("/")
90 : parentOf(name),
91 branchId:
92 branchesAt >= 0 && parts[branchesAt + 1]
93 ? parts[branchesAt + 1]!
94 : DEFAULT_BRANCH,
95 id:
96 collectionAt >= 0 && parts[collectionAt + 1]
97 ? parts[collectionAt + 1]!
98 : lastSegment(name),
99 parent:
100 collectionAt > 0
101 ? parts.slice(0, collectionAt).join("/")
102 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
103 };
104};
105
106export const slugNoDigits = (
107 name: string,
108 maxLength: number,
109 minLength = 4,
110): string => {
111 let next = name
112 .toLowerCase()
113 .replace(/[0-9]/g, (digit) => "abcdefghij"[Number(digit)]!)
114 .replace(/[^a-z_-]/g, "-")
115 .replace(/-+/g, "-")
116 .replace(/^-+|-+$/g, "");
117 if (!/^[a-z]/.test(next)) next = `r${next}`;
118 next = next.slice(0, maxLength).replace(/[-_]+$/g, "");
119 if (next.length < minLength) next = `${next}xxxx`.slice(0, minLength);
120 return next.length > 0 ? next : "resource";
121};
122
123export const productIdOf = (
124 name: string,
125 maxLength = MAX_PRODUCT_ID_LENGTH,
126) => {
127 let next = name
128 .toLowerCase()
129 .replace(/[^a-z0-9_-]/g, "-")
130 .replace(/-+/g, "-")
131 .replace(/^-+|-+$/g, "");
132 if (!/^[a-z]/.test(next)) next = `p${next}`;
133 next = next.slice(0, maxLength).replace(/[-_]+$/g, "");
134 return next.length > 0 ? next : "product";
135};
136
137export const toPhysical = (
138 id: string,
139 explicit: string | undefined,
140 existing: string | undefined,
141 format: (name: string) => string,
142 maxLength: number,
143) =>
144 Effect.gen(function* () {
145 if (explicit !== undefined) return explicit;
146 if (existing !== undefined) return existing;
147 return format(
148 yield* createPhysicalName({
149 id,
150 maxLength,
151 lowercase: true,
152 }),
153 );
154 });
155
156export const canonical = (value: unknown): unknown => {
157 if (value === undefined || value === null) return undefined;
158 if (typeof value === "boolean" || typeof value === "number") return value;
159 if (typeof value === "string") {
160 return value.length === 0 ? undefined : value;
161 }
162 if (Array.isArray(value)) {
163 const items = value.map(canonical).filter((item) => item !== undefined);
164 return items.length === 0 ? undefined : items;
165 }
166 if (typeof value === "object") {
167 const entries = Object.entries(value as Record<string, unknown>)
168 .map(([key, item]) => [key, canonical(item)] as const)
169 .filter(([, item]) => item !== undefined)
170 .sort(([left], [right]) => left.localeCompare(right));
171 if (entries.length === 0) return undefined;
172 return Object.fromEntries(entries);
173 }
174 return undefined;
175};
176
177export const fingerprint = (value: unknown): string =>
178 JSON.stringify(canonical(value) ?? null);
179
180export const sameJson = (left: unknown, right: unknown) =>
181 fingerprint(left) === fingerprint(right);
182
183export const sameStringList = (
184 left: readonly string[] | undefined,
185 right: readonly string[] | undefined,
186) =>
187 fingerprint([...(left ?? [])].sort()) ===
188 fingerprint([...(right ?? [])].sort());
189
190export const sameText = (left: string | undefined, right: string | undefined) =>
191 (left ?? "") === (right ?? "");
192
193export const updateMaskOf = (...fields: Array<string | undefined>) =>
194 fields.filter((field): field is string => field !== undefined).join(",");
195
196export const replaceOnIdentity = (input: {
197 previousId?: string;
198 nextId?: string;
199 previousParent?: string;
200 nextParent?: string;
201}) => {
202 const idChanged =
203 input.previousId !== undefined &&
204 input.nextId !== undefined &&
205 input.previousId !== input.nextId;
206 const parentChanged =
207 (input.previousParent ?? "") !== "" &&
208 (input.nextParent ?? "") !== "" &&
209 (input.previousParent ?? "") !== (input.nextParent ?? "");
210 if (!idChanged && !parentChanged) return undefined;
211 return {
212 action: "replace" as const,
213 deleteFirst:
214 !parentChanged &&
215 input.previousId !== undefined &&
216 input.nextId === input.previousId,
217 };
218};
219
220const emptyList = <A>() => Effect.succeed<A[]>([]);
221
222export const collectPages = <Page, Item, E, R>(
223 pages: Stream.Stream<Page, E, R>,
224 items: (page: Page) => readonly Item[] | null | undefined,
225) =>
226 pages.pipe(
227 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
228 Stream.runCollect,
229 Effect.map((chunk) => Array.from(chunk) as Item[]),
230 );
231
232/** Locations `list` scans: `global`, the pre-`GCP.Region` default, and the stack region. */
233export const listLocations = (region: string): string[] => [
234 ...new Set(["global", "us-central1", region]),
235];
236
237export const listCatalogs = (project: string, location: string) =>
238 collectPages(
239 retail.listProjectsLocationsCatalogs.pages({
240 parent: locationParent(project, location),
241 pageSize: 100,
242 }),
243 (page) => page.catalogs,
244 ).pipe(
245 Effect.catchTag("NotFound", () =>
246 emptyList<retail.GoogleCloudRetailV2Catalog>(),
247 ),
248 );
249
250export const listProjectCatalogs = (project: string, region: string) =>
251 Effect.forEach(
252 listLocations(region),
253 (location) => listCatalogs(project, location),
254 { concurrency: 2 },
255 ).pipe(
256 Effect.map((groups) => {
257 const seen = new Set<string>();
258 const catalogs: retail.GoogleCloudRetailV2Catalog[] = [];
259 for (const catalog of groups.flat()) {
260 const name = catalog.name ?? "";
261 if (name.length === 0 || seen.has(name)) continue;
262 seen.add(name);
263 catalogs.push(catalog);
264 }
265 return catalogs;
266 }),
267 );
268
269export const listControls = (parent: string) =>
270 parent.length === 0
271 ? emptyList<retail.GoogleCloudRetailV2Control>()
272 : collectPages(
273 retail.listProjectsLocationsCatalogsControls.pages({
274 parent,
275 pageSize: 100,
276 }),
277 (page) => page.controls,
278 ).pipe(
279 Effect.catchTag("NotFound", () =>
280 emptyList<retail.GoogleCloudRetailV2Control>(),
281 ),
282 );
283
284export const listServingConfigs = (parent: string) =>
285 parent.length === 0
286 ? emptyList<retail.GoogleCloudRetailV2ServingConfig>()
287 : collectPages(
288 retail.listProjectsLocationsCatalogsServingConfigs.pages({
289 parent,
290 pageSize: 100,
291 }),
292 (page) => page.servingConfigs,
293 ).pipe(
294 Effect.catchTag("NotFound", () =>
295 emptyList<retail.GoogleCloudRetailV2ServingConfig>(),
296 ),
297 );
298
299export const listModels = (parent: string) =>
300 parent.length === 0
301 ? emptyList<retail.GoogleCloudRetailV2Model>()
302 : collectPages(
303 retail.listProjectsLocationsCatalogsModels.pages({
304 parent,
305 pageSize: 100,
306 }),
307 (page) => page.models,
308 ).pipe(
309 Effect.catchTag("NotFound", () =>
310 emptyList<retail.GoogleCloudRetailV2Model>(),
311 ),
312 );
313
314export const listProducts = (parent: string) =>
315 parent.length === 0
316 ? emptyList<retail.GoogleCloudRetailV2Product>()
317 : collectPages(
318 retail.listProjectsLocationsCatalogsBranchesProducts.pages({
319 parent,
320 pageSize: 100,
321 readMask: "*",
322 }),
323 (page) => page.products,
324 ).pipe(
325 Effect.catchTag("NotFound", () =>
326 emptyList<retail.GoogleCloudRetailV2Product>(),
327 ),
328 );