| 1 | import * as recommendationengine from "@distilled.cloud/gcp/recommendationengine_v1beta1"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Stream from "effect/Stream"; |
| 4 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 5 | import { alchemyLabelKeys } from "../Labels.ts"; |
| 6 | |
| 7 | export const DEFAULT_LOCATION = "global"; |
| 8 | export const DEFAULT_CATALOG = "default_catalog"; |
| 9 | export const DEFAULT_CATEGORY = "Alchemy"; |
| 10 | export const MAX_ITEM_ID_LENGTH = 128; |
| 11 | export const LIST_LOCATIONS = ["global"] as const; |
| 12 | |
| 13 | export type CategoryHierarchy = { |
| 14 | /** Category path from least to most specific. */ |
| 15 | categories?: string[]; |
| 16 | }; |
| 17 | |
| 18 | export type CatalogItemImage = { |
| 19 | /** Image URI. */ |
| 20 | uri: string; |
| 21 | /** Height in pixels. */ |
| 22 | height?: number; |
| 23 | /** Width in pixels. */ |
| 24 | width?: number; |
| 25 | }; |
| 26 | |
| 27 | export type CatalogItemExactPrice = { |
| 28 | /** Display price. */ |
| 29 | displayPrice?: number; |
| 30 | /** Price before discount. */ |
| 31 | originalPrice?: number; |
| 32 | }; |
| 33 | |
| 34 | export type CatalogItemPriceRange = { |
| 35 | /** Minimum price. */ |
| 36 | min?: number; |
| 37 | /** Maximum price. */ |
| 38 | max?: number; |
| 39 | }; |
| 40 | |
| 41 | export 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 | |
| 66 | export 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 | |
| 73 | export type CatalogItem = |
| 74 | recommendationengine.GoogleCloudRecommendationengineV1beta1CatalogItem; |
| 75 | |
| 76 | const emptyList = <A>() => Effect.succeed([] as A[]); |
| 77 | |
| 78 | export const lastSegment = (value: string) => { |
| 79 | const trimmed = value.replace(/\/+$/, ""); |
| 80 | const parts = trimmed.split("/"); |
| 81 | return parts[parts.length - 1] || trimmed; |
| 82 | }; |
| 83 | |
| 84 | export 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 | |
| 94 | export const normalizeLocation = (location: string | undefined) => |
| 95 | lastSegment(location ?? DEFAULT_LOCATION).toLowerCase(); |
| 96 | |
| 97 | export const normalizeCatalog = (catalog: string | undefined) => |
| 98 | lastSegment(catalog ?? DEFAULT_CATALOG); |
| 99 | |
| 100 | export const locationParent = (project: string, location: string) => |
| 101 | `projects/${project}/locations/${location}`; |
| 102 | |
| 103 | export const catalogName = ( |
| 104 | project: string, |
| 105 | location: string, |
| 106 | catalogId: string, |
| 107 | ) => `${locationParent(project, location)}/catalogs/${catalogId}`; |
| 108 | |
| 109 | export 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 | |
| 119 | export const itemName = (catalog: string, catalogItemId: string) => |
| 120 | `${catalog}/catalogItems/${catalogItemId}`; |
| 121 | |
| 122 | export 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 | |
| 157 | export 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 | |
| 171 | export 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 | |
| 188 | const 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 | |
| 201 | const 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 | |
| 214 | export 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 | |
| 250 | export const hasOwnershipMarker = (text: string | undefined) => |
| 251 | Object.keys(parseOwnership(text).labels).some((key) => |
| 252 | key.startsWith("alchemy-"), |
| 253 | ); |
| 254 | |
| 255 | export 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 | |
| 276 | export const fingerprint = (value: unknown): string => |
| 277 | JSON.stringify(canonical(value) ?? null); |
| 278 | |
| 279 | export const sameJson = (left: unknown, right: unknown) => |
| 280 | fingerprint(left) === fingerprint(right); |
| 281 | |
| 282 | export const sameStringList = ( |
| 283 | left: readonly string[] | undefined, |
| 284 | right: readonly string[] | undefined, |
| 285 | ) => |
| 286 | fingerprint([...(left ?? [])].sort()) === |
| 287 | fingerprint([...(right ?? [])].sort()); |
| 288 | |
| 289 | export const sameText = (left: string | undefined, right: string | undefined) => |
| 290 | (left ?? "") === (right ?? ""); |
| 291 | |
| 292 | export const updateMaskOf = (...fields: Array<string | undefined>) => |
| 293 | fields.filter((field): field is string => field !== undefined).join(","); |
| 294 | |
| 295 | export 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 | |
| 316 | export 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 | |
| 326 | export 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 | |
| 339 | export 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 | |
| 359 | export 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 | |
| 370 | export const itemHasOwnership = (item: CatalogItem) => |
| 371 | hasOwnershipMarker(item.description) || |
| 372 | (item.tags ?? []).some((tag) => hasOwnershipMarker(tag)); |
| 373 | |
| 374 | export 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 | |
| 381 | export const userTags = (tags: readonly string[] | undefined) => |
| 382 | (tags ?? []).filter( |
| 383 | (tag) => !tag.includes("[alc ") && !tag.includes("[alchemy "), |
| 384 | ); |
| 385 | |
| 386 | export 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 | |
| 398 | export const defaultHierarchies = (): CategoryHierarchy[] => [ |
| 399 | { categories: [DEFAULT_CATEGORY] }, |
| 400 | ]; |
| 401 | |
| 402 | export 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 | |
| 416 | export 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 | }; |