1import * as recommendationengine from "@distilled.cloud/gcp/recommendationengine_v1beta1";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import { alchemyLabelKeys } from "../Labels.ts";
6
7export const DEFAULT_LOCATION = "global";
8export const DEFAULT_CATALOG = "default_catalog";
9export const DEFAULT_CATEGORY = "Alchemy";
10export const MAX_ITEM_ID_LENGTH = 128;
11export const LIST_LOCATIONS = ["global"] as const;
12
13export type CategoryHierarchy = {
14 /** Category path from least to most specific. */
15 categories?: string[];
16};
17
18export type CatalogItemImage = {
19 /** Image URI. */
20 uri: string;
21 /** Height in pixels. */
22 height?: number;
23 /** Width in pixels. */
24 width?: number;
25};
26
27export type CatalogItemExactPrice = {
28 /** Display price. */
29 displayPrice?: number;
30 /** Price before discount. */
31 originalPrice?: number;
32};
33
34export type CatalogItemPriceRange = {
35 /** Minimum price. */
36 min?: number;
37 /** Maximum price. */
38 max?: number;
39};
40
41export type ProductCatalogItem = {
42 /** Cost map, e.g. `{ manufacturing: 45.5 }`. */
43 costs?: Record<string, number | undefined>;
44 /** Online stock state. */
45 stockState?:
46 | "STOCK_STATE_UNSPECIFIED"
47 | "IN_STOCK"
48 | "OUT_OF_STOCK"
49 | "PREORDER"
50 | "BACKORDER"
51 | (string & {});
52 /** Product images. */
53 images?: CatalogItemImage[];
54 /** Exact product price. */
55 exactPrice?: CatalogItemExactPrice;
56 /** Canonical product detail URI. */
57 canonicalProductUri?: string;
58 /** Price range for variants. */
59 priceRange?: CatalogItemPriceRange;
60 /** ISO-4217 currency code. Required when a price is set. */
61 currencyCode?: string;
62 /** Available quantity (int64 as a decimal string). */
63 availableQuantity?: string;
64};
65
66export type FeatureMap = {
67 /** Categorical features (`{ colors: { value: ["yellow"] } }`). */
68 categoricalFeatures?: Record<string, { value?: string[] } | undefined>;
69 /** Numerical features (`{ lengths_cm: { value: [2.3] } }`). */
70 numericalFeatures?: Record<string, { value?: number[] } | undefined>;
71};
72
73export type CatalogItem =
74 recommendationengine.GoogleCloudRecommendationengineV1beta1CatalogItem;
75
76const emptyList = <A>() => Effect.succeed([] as A[]);
77
78export const lastSegment = (value: string) => {
79 const trimmed = value.replace(/\/+$/, "");
80 const parts = trimmed.split("/");
81 return parts[parts.length - 1] || trimmed;
82};
83
84export const parentOf = (name: string, collection?: string) => {
85 if (collection !== undefined) {
86 const marker = `/${collection}/`;
87 const index = name.lastIndexOf(marker);
88 return index >= 0 ? name.slice(0, index) : name;
89 }
90 const parts = name.split("/").filter((part) => part.length > 0);
91 return parts.slice(0, -2).join("/");
92};
93
94export const normalizeLocation = (location: string | undefined) =>
95 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
96
97export const normalizeCatalog = (catalog: string | undefined) =>
98 lastSegment(catalog ?? DEFAULT_CATALOG);
99
100export const locationParent = (project: string, location: string) =>
101 `projects/${project}/locations/${location}`;
102
103export const catalogName = (
104 project: string,
105 location: string,
106 catalogId: string,
107) => `${locationParent(project, location)}/catalogs/${catalogId}`;
108
109export const expandCatalog = (
110 value: string | undefined,
111 project: string,
112 location: string,
113) => {
114 const raw = (value ?? DEFAULT_CATALOG).replace(/\/+$/, "");
115 if (raw.includes("/")) return raw;
116 return catalogName(project, location, raw);
117};
118
119export const itemName = (catalog: string, catalogItemId: string) =>
120 `${catalog}/catalogItems/${catalogItemId}`;
121
122export const parseResourceName = (name: string) => {
123 const parts = name.split("/").filter((part) => part.length > 0);
124 const itemsAt = Math.max(
125 parts.lastIndexOf("catalogItems"),
126 parts.lastIndexOf("catalogitems"),
127 );
128 const locationsAt = parts.lastIndexOf("locations");
129 const projectsAt = parts.lastIndexOf("projects");
130 const catalogsAt = parts.lastIndexOf("catalogs");
131 return {
132 project:
133 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
134 location:
135 locationsAt >= 0 && parts[locationsAt + 1]
136 ? parts[locationsAt + 1]!
137 : DEFAULT_LOCATION,
138 catalogId:
139 catalogsAt >= 0 && parts[catalogsAt + 1]
140 ? parts[catalogsAt + 1]!
141 : DEFAULT_CATALOG,
142 catalog:
143 catalogsAt >= 0
144 ? parts.slice(0, catalogsAt + 2).join("/")
145 : parentOf(name, "catalogItems"),
146 id:
147 itemsAt >= 0 && parts[itemsAt + 1]
148 ? parts[itemsAt + 1]!
149 : lastSegment(name),
150 parent:
151 itemsAt > 0
152 ? parts.slice(0, itemsAt).join("/")
153 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
154 };
155};
156
157export const catalogItemIdOf = (
158 name: string,
159 maxLength = MAX_ITEM_ID_LENGTH,
160) => {
161 let next = name
162 .toLowerCase()
163 .replace(/[^a-z0-9_-]/g, "-")
164 .replace(/-+/g, "-")
165 .replace(/^-+|-+$/g, "");
166 if (!/^[a-z]/.test(next)) next = `c${next}`;
167 next = next.slice(0, maxLength).replace(/[-_]+$/g, "");
168 return next.length > 0 ? next : "catalogitem";
169};
170
171export const toPhysical = (
172 id: string,
173 explicit: string | undefined,
174 existing: string | undefined,
175) =>
176 Effect.gen(function* () {
177 if (explicit !== undefined && explicit.length > 0) return explicit;
178 if (existing !== undefined && existing.length > 0) return existing;
179 return catalogItemIdOf(
180 yield* createPhysicalName({
181 id,
182 maxLength: MAX_ITEM_ID_LENGTH,
183 lowercase: true,
184 }),
185 );
186 });
187
188const parseOwnershipMarker = (text: string) => {
189 const labels: Record<string, string> = {};
190 const end = text.indexOf("]");
191 if (end < 0) return { labels, rest: text };
192 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
193 const eq = part.indexOf("=");
194 if (eq > 0) {
195 labels[part.slice(0, eq)] = part.slice(eq + 1);
196 }
197 }
198 return { labels, rest: text.slice(end + 1) };
199};
200
201const parseCompactMarker = (text: string) => {
202 const end = text.indexOf("]");
203 if (!text.startsWith("[alc ") || end < 0) {
204 return { labels: {} as Record<string, string>, rest: text };
205 }
206 const parts = text.slice("[alc ".length, end).split("|");
207 const labels: Record<string, string> = {};
208 if (parts[0]) labels[alchemyLabelKeys.stack] = parts[0];
209 if (parts[1]) labels[alchemyLabelKeys.stage] = parts[1];
210 if (parts[2]) labels[alchemyLabelKeys.id] = parts[2];
211 return { labels, rest: text.slice(end + 1) };
212};
213
214export const parseOwnership = (
215 text: string | undefined,
216): {
217 labels: Record<string, string>;
218 text: string | undefined;
219} => {
220 if (!text) return { labels: {}, text };
221 const compactAt = text.indexOf("[alc ");
222 const verboseAt = text.indexOf("[alchemy ");
223 if (verboseAt >= 0 && (compactAt < 0 || verboseAt <= compactAt)) {
224 const before = text.slice(0, verboseAt).trim();
225 const parsed = parseOwnershipMarker(text.slice(verboseAt));
226 const after = parsed.rest.replace(/^[\s\n]+/, "");
227 const combined = [before, after]
228 .filter((part) => part.length > 0)
229 .join(" ");
230 return {
231 labels: parsed.labels,
232 text: combined.length > 0 ? combined : undefined,
233 };
234 }
235 if (compactAt >= 0) {
236 const before = text.slice(0, compactAt).trim();
237 const parsed = parseCompactMarker(text.slice(compactAt));
238 const after = parsed.rest.replace(/^[\s\n]+/, "");
239 const combined = [before, after]
240 .filter((part) => part.length > 0)
241 .join(" ");
242 return {
243 labels: parsed.labels,
244 text: combined.length > 0 ? combined : undefined,
245 };
246 }
247 return { labels: {}, text };
248};
249
250export const hasOwnershipMarker = (text: string | undefined) =>
251 Object.keys(parseOwnership(text).labels).some((key) =>
252 key.startsWith("alchemy-"),
253 );
254
255export const canonical = (value: unknown): unknown => {
256 if (value === undefined || value === null) return undefined;
257 if (typeof value === "boolean" || typeof value === "number") return value;
258 if (typeof value === "string") {
259 return value.length === 0 ? undefined : value;
260 }
261 if (Array.isArray(value)) {
262 const items = value.map(canonical).filter((item) => item !== undefined);
263 return items.length === 0 ? undefined : items;
264 }
265 if (typeof value === "object") {
266 const entries = Object.entries(value as Record<string, unknown>)
267 .map(([key, item]) => [key, canonical(item)] as const)
268 .filter(([, item]) => item !== undefined)
269 .sort(([left], [right]) => left.localeCompare(right));
270 if (entries.length === 0) return undefined;
271 return Object.fromEntries(entries);
272 }
273 return undefined;
274};
275
276export const fingerprint = (value: unknown): string =>
277 JSON.stringify(canonical(value) ?? null);
278
279export const sameJson = (left: unknown, right: unknown) =>
280 fingerprint(left) === fingerprint(right);
281
282export const sameStringList = (
283 left: readonly string[] | undefined,
284 right: readonly string[] | undefined,
285) =>
286 fingerprint([...(left ?? [])].sort()) ===
287 fingerprint([...(right ?? [])].sort());
288
289export const sameText = (left: string | undefined, right: string | undefined) =>
290 (left ?? "") === (right ?? "");
291
292export const updateMaskOf = (...fields: Array<string | undefined>) =>
293 fields.filter((field): field is string => field !== undefined).join(",");
294
295export const replaceOnIdentity = (input: {
296 previousId?: string;
297 nextId?: string;
298 previousParent?: string;
299 nextParent?: string;
300}) => {
301 const idChanged =
302 input.previousId !== undefined &&
303 input.nextId !== undefined &&
304 input.previousId !== input.nextId;
305 const parentChanged =
306 (input.previousParent ?? "") !== "" &&
307 (input.nextParent ?? "") !== "" &&
308 (input.previousParent ?? "") !== (input.nextParent ?? "");
309 if (!idChanged && !parentChanged) return undefined;
310 return {
311 action: "replace" as const,
312 deleteFirst: false,
313 };
314};
315
316export const collectPages = <Page, Item, E, R>(
317 pages: Stream.Stream<Page, E, R>,
318 items: (page: Page) => readonly Item[] | null | undefined,
319) =>
320 pages.pipe(
321 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
322 Stream.runCollect,
323 Effect.map((chunk) => Array.from(chunk) as Item[]),
324 );
325
326export const listCatalogs = (project: string, location: string) =>
327 collectPages(
328 recommendationengine.listProjectsLocationsCatalogs.pages({
329 parent: locationParent(project, location),
330 pageSize: 100,
331 }),
332 (page) => page.catalogs,
333 ).pipe(
334 Effect.catchTag("NotFound", () =>
335 emptyList<recommendationengine.GoogleCloudRecommendationengineV1beta1Catalog>(),
336 ),
337 );
338
339export const listProjectCatalogs = (project: string) =>
340 Effect.forEach(
341 LIST_LOCATIONS,
342 (location) => listCatalogs(project, location),
343 { concurrency: 2 },
344 ).pipe(
345 Effect.map((groups) => {
346 const seen = new Set<string>();
347 const catalogs: recommendationengine.GoogleCloudRecommendationengineV1beta1Catalog[] =
348 [];
349 for (const catalog of groups.flat()) {
350 const name = catalog.name ?? "";
351 if (name.length === 0 || seen.has(name)) continue;
352 seen.add(name);
353 catalogs.push(catalog);
354 }
355 return catalogs;
356 }),
357 );
358
359export const listCatalogItems = (parent: string) =>
360 parent.length === 0
361 ? emptyList<CatalogItem>()
362 : collectPages(
363 recommendationengine.listProjectsLocationsCatalogsCatalogItems.pages({
364 parent,
365 pageSize: 100,
366 }),
367 (page) => page.catalogItems,
368 ).pipe(Effect.catchTag("NotFound", () => emptyList<CatalogItem>()));
369
370export const itemHasOwnership = (item: CatalogItem) =>
371 hasOwnershipMarker(item.description) ||
372 (item.tags ?? []).some((tag) => hasOwnershipMarker(tag));
373
374export const getCatalogItem = (name: string) =>
375 name.length === 0
376 ? Effect.succeed(undefined)
377 : recommendationengine
378 .getProjectsLocationsCatalogsCatalogItems({ name })
379 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
380
381export const userTags = (tags: readonly string[] | undefined) =>
382 (tags ?? []).filter(
383 (tag) => !tag.includes("[alc ") && !tag.includes("[alchemy "),
384 );
385
386export const hierarchiesOf = (
387 hierarchies:
388 | readonly recommendationengine.GoogleCloudRecommendationengineV1beta1CatalogItemCategoryHierarchy[]
389 | readonly CategoryHierarchy[]
390 | undefined,
391): CategoryHierarchy[] =>
392 (hierarchies ?? [])
393 .map((hierarchy) => ({
394 categories: [...(hierarchy.categories ?? [])],
395 }))
396 .filter((hierarchy) => (hierarchy.categories?.length ?? 0) > 0);
397
398export const defaultHierarchies = (): CategoryHierarchy[] => [
399 { categories: [DEFAULT_CATEGORY] },
400];
401
402export const imagesOf = (
403 images:
404 | readonly recommendationengine.GoogleCloudRecommendationengineV1beta1Image[]
405 | readonly CatalogItemImage[]
406 | undefined,
407): CatalogItemImage[] =>
408 (images ?? [])
409 .filter((image) => (image.uri ?? "").length > 0)
410 .map((image) => ({
411 uri: image.uri ?? "",
412 height: image.height,
413 width: image.width,
414 }));
415
416export const productMetadataOf = (
417 meta:
418 | recommendationengine.GoogleCloudRecommendationengineV1beta1ProductCatalogItem
419 | ProductCatalogItem
420 | undefined,
421): ProductCatalogItem | undefined =>
422 meta === undefined
423 ? undefined
424 : {
425 costs: meta.costs,
426 stockState: meta.stockState,
427 images: imagesOf(meta.images),
428 exactPrice: meta.exactPrice
429 ? {
430 displayPrice: meta.exactPrice.displayPrice,
431 originalPrice: meta.exactPrice.originalPrice,
432 }
433 : undefined,
434 canonicalProductUri: meta.canonicalProductUri,
435 priceRange: meta.priceRange
436 ? { min: meta.priceRange.min, max: meta.priceRange.max }
437 : undefined,
438 currencyCode: meta.currencyCode,
439 availableQuantity: meta.availableQuantity,
440 };