1import * as recommendationengine from "@distilled.cloud/gcp/recommendationengine_v1beta1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 type CatalogItem,
12 type CategoryHierarchy,
13 type FeatureMap,
14 type ProductCatalogItem,
15 DEFAULT_LOCATION,
16 defaultHierarchies,
17 expandCatalog,
18 getCatalogItem,
19 hierarchiesOf,
20 itemHasOwnership,
21 itemName,
22 listCatalogItems,
23 listProjectCatalogs,
24 normalizeLocation,
25 parseOwnership,
26 parseResourceName,
27 productMetadataOf,
28 replaceOnIdentity,
29 sameJson,
30 sameStringList,
31 sameText,
32 toPhysical,
33 updateMaskOf,
34 userTags,
35} from "./internal.ts";
36
37export type {
38 CatalogItemExactPrice,
39 CatalogItemImage,
40 CatalogItemPriceRange,
41 CategoryHierarchy,
42 FeatureMap,
43 ProductCatalogItem,
44} from "./internal.ts";
45
46export type CatalogsCatalogItemProps = {
47 /**
48 * Parent catalog resource name
49 * `projects/{project}/locations/{location}/catalogs/{catalog}` or a
50 * catalog id (combined with `location`). Immutable — changing it
51 * replaces the catalog item.
52 * @default "default_catalog"
53 */
54 catalog?: string;
55 /**
56 * Location used when `catalog` is a bare id. Immutable.
57 * Recommendations AI catalogs live at `global`.
58 * @default "global"
59 */
60 location?: string;
61 /**
62 * Catalog item id (max 128 bytes). If omitted, a unique id is
63 * generated. Immutable — changing it replaces the item.
64 */
65 catalogItemId?: string;
66 /**
67 * Catalog item title (max 1 KiB). Defaults to the catalog item id.
68 */
69 title?: string;
70 /**
71 * Catalog item description (max 5 KiB).
72 */
73 description?: string;
74 /**
75 * Parallel category hierarchies. Required by the API. Defaults to a
76 * single `Alchemy` category.
77 * @default [{ categories: ["Alchemy"] }]
78 */
79 categoryHierarchies?: CategoryHierarchy[];
80 /**
81 * Filtering tags (max 1 KiB each).
82 */
83 tags?: string[];
84 /**
85 * Variant group identifier for prediction results (max 128 bytes).
86 */
87 itemGroupId?: string;
88 /**
89 * Deprecated language hint. The model detects language automatically.
90 */
91 languageCode?: string;
92 /**
93 * Retail product metadata (price, stock, images).
94 */
95 productMetadata?: ProductCatalogItem;
96 /**
97 * Extra categorical and numerical features for the recommendation
98 * model.
99 */
100 itemAttributes?: FeatureMap;
101};
102
103export type CatalogsCatalogItem = Resource<
104 "GCP.RecommendationEngine.CatalogsCatalogItem",
105 CatalogsCatalogItemProps,
106 {
107 /** Full resource name. */
108 name: string;
109 /** Catalog item id. */
110 catalogItemId: string;
111 /** Parent catalog resource name. */
112 catalog: string;
113 /** Project id. */
114 project: string;
115 /** Location id. */
116 location: string;
117 /** Title. */
118 title: string | undefined;
119 /** Description (a legacy Alchemy ownership prefix is stripped). */
120 description: string | undefined;
121 /** Category hierarchies. */
122 categoryHierarchies: CategoryHierarchy[];
123 /** Tags (legacy Alchemy ownership markers are stripped). */
124 tags: string[];
125 /** Variant group id. */
126 itemGroupId: string | undefined;
127 /** Language code. */
128 languageCode: string | undefined;
129 /** Retail product metadata. */
130 productMetadata: ProductCatalogItem | undefined;
131 /** Extra item attributes. */
132 itemAttributes: FeatureMap | undefined;
133 },
134 never,
135 Providers
136>;
137
138/**
139 * A Recommendations AI catalog item.
140 *
141 * Catalog items have no labels field. Their description and tags feed
142 * recommendations, so Alchemy writes no ownership marker into them: a
143 * generated item id identifies the item, and `read` reports an explicit
144 * id it has no state for as unowned.
145 * Catalog, location, and item id are immutable. Title, description,
146 * categories, tags, and product metadata update in place.
147 *
148 * ### Creating a Catalog Item
149 * **Example:** Item on the default catalog
150 * ```typescript
151 * const item = yield* GCP.RecommendationEngine.CatalogsCatalogItem("Shirt", {
152 * title: "Cotton tee",
153 * categoryHierarchies: [{ categories: ["Apparel", "T-Shirts"] }],
154 * productMetadata: {
155 * currencyCode: "USD",
156 * exactPrice: { displayPrice: 20 },
157 * stockState: "IN_STOCK",
158 * },
159 * });
160 * ```
161 *
162 * **Example:** Explicit catalog and item id
163 * ```typescript
164 * const item = yield* GCP.RecommendationEngine.CatalogsCatalogItem("Shirt", {
165 * catalog: "default_catalog",
166 * location: "global",
167 * catalogItemId: "cotton-tee",
168 * title: "Cotton tee",
169 * description: "Soft cotton t-shirt",
170 * });
171 * ```
172 *
173 * ### Updating a Catalog Item
174 * **Example:** Change title and price
175 * ```typescript
176 * const item = yield* GCP.RecommendationEngine.CatalogsCatalogItem("Shirt", {
177 * title: "Linen tee",
178 * categoryHierarchies: [{ categories: ["Apparel", "T-Shirts"] }],
179 * productMetadata: {
180 * currencyCode: "USD",
181 * exactPrice: { displayPrice: 24 },
182 * },
183 * });
184 * ```
185 *
186 * @resource
187 * @category RecommendationEngine
188 */
189export const CatalogsCatalogItem = Resource<CatalogsCatalogItem>(
190 "GCP.RecommendationEngine.CatalogsCatalogItem",
191);
192
193export class CatalogsCatalogItemNotResolved extends Data.TaggedError(
194 "GCP.RecommendationEngine.CatalogsCatalogItemNotResolved",
195)<{
196 name: string;
197}> {}
198
199const toAttrs = (item: CatalogItem, project: string, nameHint?: string) => {
200 const hinted = nameHint && nameHint.includes("/") ? nameHint : "";
201 const parsed = parseResourceName(hinted);
202 const catalogItemId = item.id ?? parsed.id;
203 const location = parsed.location || DEFAULT_LOCATION;
204 const catalog =
205 parsed.catalog.length > 0
206 ? parsed.catalog
207 : expandCatalog(undefined, project, location);
208 const name =
209 hinted.includes("/catalogItems/") || hinted.includes("/catalogitems/")
210 ? hinted
211 : itemName(catalog, catalogItemId);
212 const ownership = parseOwnership(item.description);
213 return {
214 name,
215 catalogItemId,
216 catalog,
217 project: parsed.project || project,
218 location,
219 title: item.title,
220 description: ownership.text,
221 categoryHierarchies: hierarchiesOf(item.categoryHierarchies),
222 tags: userTags(item.tags),
223 itemGroupId: item.itemGroupId,
224 languageCode: item.languageCode,
225 productMetadata: productMetadataOf(item.productMetadata),
226 itemAttributes: item.itemAttributes,
227 };
228};
229
230const toBody = (
231 news: CatalogsCatalogItemProps,
232 catalogItemId: string,
233 description: string,
234 tags: string[],
235): CatalogItem => ({
236 id: catalogItemId,
237 title: news.title ?? catalogItemId,
238 description,
239 categoryHierarchies: news.categoryHierarchies ?? defaultHierarchies(),
240 tags,
241 itemGroupId: news.itemGroupId,
242 languageCode: news.languageCode,
243 productMetadata: news.productMetadata,
244 itemAttributes: news.itemAttributes,
245});
246
247export const CatalogsCatalogItemProvider = () =>
248 Provider.succeed(CatalogsCatalogItem, {
249 stables: ["name", "catalogItemId", "catalog", "project", "location"],
250
251 diff: Effect.fn(function* ({ news, olds, output }) {
252 if (!isResolved(news)) return undefined;
253 const env = yield* GcpEnvironment.current;
254 const previousParent = olds?.catalog ?? output?.catalog;
255 const nextParent = expandCatalog(
256 news.catalog,
257 env.project,
258 normalizeLocation(news.location ?? output?.location),
259 );
260 return replaceOnIdentity({
261 previousId: olds?.catalogItemId ?? output?.catalogItemId,
262 nextId: news.catalogItemId,
263 previousParent,
264 nextParent,
265 });
266 }),
267
268 read: Effect.fn(function* ({ id, olds, output }) {
269 const env = yield* GcpEnvironment.current;
270 const location = normalizeLocation(olds?.location ?? output?.location);
271 const catalog =
272 output?.catalog ?? expandCatalog(olds?.catalog, env.project, location);
273 const catalogItemId = yield* toPhysical(
274 id,
275 olds?.catalogItemId,
276 output?.catalogItemId,
277 );
278 const name = output?.name ?? itemName(catalog, catalogItemId);
279 const existing = yield* getCatalogItem(name);
280 if (existing === undefined) return undefined;
281 const attrs = toAttrs(existing, env.project, name);
282 // Description and tags feed recommendations, so they carry no
283 // ownership marker: a generated id derives from this stack, stage,
284 // logical id and instance; an explicit id is only ours when state
285 // has it.
286 return output !== undefined || olds?.catalogItemId === undefined
287 ? attrs
288 : Unowned(attrs);
289 }),
290
291 list: () =>
292 Effect.gen(function* () {
293 const env = yield* GcpEnvironment.current;
294 const catalogs = yield* listProjectCatalogs(env.project);
295 const pages = yield* Effect.forEach(
296 catalogs,
297 (catalog) =>
298 catalog.name
299 ? listCatalogItems(catalog.name).pipe(
300 Effect.map((items) =>
301 items
302 .filter(itemHasOwnership)
303 .map((item) => toAttrs(item, env.project, catalog.name)),
304 ),
305 )
306 : Effect.succeed([]),
307 { concurrency: 4 },
308 );
309 return pages.flat();
310 }),
311
312 reconcile: Effect.fn(function* ({ id, news, output }) {
313 const env = yield* GcpEnvironment.current;
314 const location = normalizeLocation(news.location ?? output?.location);
315 const catalog = expandCatalog(news.catalog, env.project, location);
316 const catalogItemId = yield* toPhysical(
317 id,
318 news.catalogItemId,
319 output?.catalogItemId,
320 );
321 const name = itemName(catalog, catalogItemId);
322 const description = news.description ?? "";
323 const tags = news.tags ?? [];
324 const body = toBody(news, catalogItemId, description, tags);
325
326 let current = yield* getCatalogItem(output?.name ?? name);
327
328 if (current === undefined) {
329 const created = yield* recommendationengine
330 .createProjectsLocationsCatalogsCatalogItems({
331 parent: catalog,
332 body,
333 })
334 .pipe(Effect.catchTag("Conflict", () => getCatalogItem(name)));
335 current = created ?? undefined;
336 }
337
338 if (current === undefined) {
339 return yield* new CatalogsCatalogItemNotResolved({ name });
340 }
341
342 const title = news.title ?? catalogItemId;
343 const desiredHierarchies =
344 news.categoryHierarchies ?? defaultHierarchies();
345 const mask = updateMaskOf(
346 sameText(current.title, title) ? undefined : "title",
347 sameText(current.description, description) ? undefined : "description",
348 sameJson(hierarchiesOf(current.categoryHierarchies), desiredHierarchies)
349 ? undefined
350 : "categoryHierarchies",
351 sameStringList(current.tags, tags) ? undefined : "tags",
352 sameText(current.itemGroupId, news.itemGroupId)
353 ? undefined
354 : "itemGroupId",
355 sameText(current.languageCode, news.languageCode)
356 ? undefined
357 : "languageCode",
358 sameJson(
359 productMetadataOf(current.productMetadata),
360 news.productMetadata,
361 )
362 ? undefined
363 : "productMetadata",
364 sameJson(current.itemAttributes, news.itemAttributes)
365 ? undefined
366 : "itemAttributes",
367 );
368
369 if (mask.length > 0) {
370 current =
371 yield* recommendationengine.patchProjectsLocationsCatalogsCatalogItems(
372 {
373 name,
374 updateMask: mask,
375 body: { ...body, id: catalogItemId },
376 },
377 );
378 }
379
380 return toAttrs(current, env.project, name);
381 }),
382
383 delete: Effect.fn(function* ({ output }) {
384 yield* recommendationengine
385 .deleteProjectsLocationsCatalogsCatalogItems({
386 name: output.name,
387 })
388 .pipe(Effect.catchTag("NotFound", () => Effect.void));
389 }),
390 });