1import * as retail from "@distilled.cloud/gcp/retail_v2";
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 MAX_ID_LENGTH,
12 expandCatalog,
13 normalizeLocation,
14 parseResourceName,
15 replaceOnIdentity,
16 sameStringList,
17 sameText,
18 slugNoDigits,
19 toPhysical,
20 updateMaskOf,
21} from "./internal.ts";
22
23export type CatalogsServingConfigProps = {
24 /**
25 * Parent catalog resource name
26 * `projects/{project}/locations/{location}/catalogs/{catalog}` or a
27 * catalog id (combined with `location`). Immutable — changing it
28 * replaces the serving config.
29 * @default "default_catalog"
30 */
31 catalog?: string;
32 /**
33 * Location used when `catalog` is a bare id. Immutable.
34 * @default "global"
35 */
36 location?: string;
37 /**
38 * Serving config id (`[a-z-_]`, 4-63 characters). If omitted, a unique
39 * id is generated. Immutable — changing it replaces the serving config.
40 */
41 servingConfigId?: string;
42 /**
43 * Human-readable name (max 128 characters).
44 */
45 displayName?: string;
46 /**
47 * Solution types. Immutable. Only one type may be set.
48 * @default ["SOLUTION_TYPE_SEARCH"]
49 */
50 solutionTypes?: Array<
51 | "SOLUTION_TYPE_UNSPECIFIED"
52 | "SOLUTION_TYPE_RECOMMENDATION"
53 | "SOLUTION_TYPE_SEARCH"
54 | (string & {})
55 >;
56 /**
57 * Recommendation model id. Required when `solutionTypes` is
58 * `SOLUTION_TYPE_RECOMMENDATION`.
59 */
60 modelId?: string;
61 /**
62 * Filter control ids.
63 */
64 filterControlIds?: string[];
65 /**
66 * Boost control ids.
67 */
68 boostControlIds?: string[];
69 /**
70 * Redirect control ids.
71 */
72 redirectControlIds?: string[];
73 /**
74 * Two-way synonym control ids.
75 */
76 twowaySynonymsControlIds?: string[];
77 /**
78 * One-way synonym control ids.
79 */
80 onewaySynonymsControlIds?: string[];
81 /**
82 * Replacement control ids.
83 */
84 replacementControlIds?: string[];
85 /**
86 * Ignore control ids.
87 */
88 ignoreControlIds?: string[];
89 /**
90 * Do-not-associate control ids.
91 */
92 doNotAssociateControlIds?: string[];
93 /**
94 * Facet control ids.
95 */
96 facetControlIds?: string[];
97 /**
98 * Recommendation diversity level (`no-diversity`, `low-diversity`, …).
99 */
100 diversityLevel?: string;
101 /**
102 * Price reranking level.
103 */
104 priceRerankingLevel?: string;
105};
106
107export type CatalogsServingConfig = Resource<
108 "GCP.Retail.CatalogsServingConfig",
109 CatalogsServingConfigProps,
110 {
111 /** Full resource name. */
112 name: string;
113 /** Serving config id. */
114 servingConfigId: string;
115 /** Parent catalog resource name. */
116 catalog: string;
117 /** Project id. */
118 project: string;
119 /** Location id. */
120 location: string;
121 /** Display name. */
122 displayName: string | undefined;
123 /** Solution types. */
124 solutionTypes: string[];
125 /** Recommendation model id. */
126 modelId: string | undefined;
127 /** Filter control ids. */
128 filterControlIds: string[];
129 /** Boost control ids. */
130 boostControlIds: string[];
131 /** Redirect control ids. */
132 redirectControlIds: string[];
133 /** Two-way synonym control ids. */
134 twowaySynonymsControlIds: string[];
135 /** One-way synonym control ids. */
136 onewaySynonymsControlIds: string[];
137 /** Replacement control ids. */
138 replacementControlIds: string[];
139 /** Ignore control ids. */
140 ignoreControlIds: string[];
141 /** Do-not-associate control ids. */
142 doNotAssociateControlIds: string[];
143 /** Facet control ids. */
144 facetControlIds: string[];
145 /** Diversity level. */
146 diversityLevel: string | undefined;
147 /** Price reranking level. */
148 priceRerankingLevel: string | undefined;
149 },
150 never,
151 Providers
152>;
153
154/**
155 * A Retail serving config on a catalog.
156 *
157 * Without labels, ownership rests on the deterministic id: `read` reports a
158 * resource it finds without prior state as unowned (adopt it with `--adopt`).
159 * Parent, serving config id, and solution types are immutable. Display name,
160 * model id, and control-id lists update in place.
161 *
162 * ### Creating a Serving Config
163 * **Example:** Extra search serving config
164 * ```typescript
165 * const serving = yield* GCP.Retail.CatalogsServingConfig("Preview", {
166 * displayName: "preview search",
167 * });
168 * ```
169 *
170 * @resource
171 * @category Retail
172 */
173export const CatalogsServingConfig = Resource<CatalogsServingConfig>(
174 "GCP.Retail.CatalogsServingConfig",
175);
176
177export class CatalogsServingConfigNotResolved extends Data.TaggedError(
178 "GCP.Retail.CatalogsServingConfigNotResolved",
179)<{
180 name: string;
181}> {}
182
183const defaultSolutionTypes = ["SOLUTION_TYPE_SEARCH"] as const;
184
185const toAttrs = (
186 config: retail.GoogleCloudRetailV2ServingConfig,
187 project: string,
188) => {
189 const name = config.name ?? "";
190 const parsed = parseResourceName(name, "servingConfigs");
191 return {
192 name,
193 servingConfigId: parsed.id,
194 catalog: parsed.catalog,
195 project: parsed.project || project,
196 location: parsed.location,
197 displayName: config.displayName,
198 solutionTypes: [...(config.solutionTypes ?? [])],
199 modelId: config.modelId,
200 filterControlIds: [...(config.filterControlIds ?? [])],
201 boostControlIds: [...(config.boostControlIds ?? [])],
202 redirectControlIds: [...(config.redirectControlIds ?? [])],
203 twowaySynonymsControlIds: [...(config.twowaySynonymsControlIds ?? [])],
204 onewaySynonymsControlIds: [...(config.onewaySynonymsControlIds ?? [])],
205 replacementControlIds: [...(config.replacementControlIds ?? [])],
206 ignoreControlIds: [...(config.ignoreControlIds ?? [])],
207 doNotAssociateControlIds: [...(config.doNotAssociateControlIds ?? [])],
208 facetControlIds: [...(config.facetControlIds ?? [])],
209 diversityLevel: config.diversityLevel,
210 priceRerankingLevel: config.priceRerankingLevel,
211 };
212};
213
214const resourceName = (catalog: string, servingConfigId: string) =>
215 `${catalog}/servingConfigs/${servingConfigId}`;
216
217const toBody = (
218 news: CatalogsServingConfigProps,
219 displayName: string,
220): retail.GoogleCloudRetailV2ServingConfig => ({
221 displayName,
222 solutionTypes: news.solutionTypes ?? [...defaultSolutionTypes],
223 modelId: news.modelId,
224 filterControlIds: news.filterControlIds,
225 boostControlIds: news.boostControlIds,
226 redirectControlIds: news.redirectControlIds,
227 twowaySynonymsControlIds: news.twowaySynonymsControlIds,
228 onewaySynonymsControlIds: news.onewaySynonymsControlIds,
229 replacementControlIds: news.replacementControlIds,
230 ignoreControlIds: news.ignoreControlIds,
231 doNotAssociateControlIds: news.doNotAssociateControlIds,
232 facetControlIds: news.facetControlIds,
233 diversityLevel: news.diversityLevel,
234 priceRerankingLevel: news.priceRerankingLevel,
235});
236
237const getByName = (name: string) =>
238 name.length === 0
239 ? Effect.succeed(undefined)
240 : retail
241 .getProjectsLocationsCatalogsServingConfigs({ name })
242 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
243
244export const CatalogsServingConfigProvider = () =>
245 Provider.succeed(CatalogsServingConfig, {
246 stables: ["name", "servingConfigId", "catalog", "project", "location"],
247
248 diff: Effect.fn(function* ({ news, olds, output }) {
249 if (!isResolved(news)) return undefined;
250 const env = yield* GcpEnvironment.current;
251 const previousParent = olds?.catalog ?? output?.catalog;
252 const nextParent = expandCatalog(
253 news.catalog,
254 env.project,
255 normalizeLocation(news.location ?? output?.location),
256 );
257 const previousTypes = olds?.solutionTypes ?? output?.solutionTypes;
258 const nextTypes = news.solutionTypes ?? previousTypes;
259 const identity = replaceOnIdentity({
260 previousId: olds?.servingConfigId ?? output?.servingConfigId,
261 nextId: news.servingConfigId,
262 previousParent,
263 nextParent,
264 });
265 if (
266 identity !== undefined ||
267 (previousTypes !== undefined &&
268 nextTypes !== undefined &&
269 !sameStringList(previousTypes, nextTypes))
270 ) {
271 return (
272 identity ?? {
273 action: "replace" as const,
274 deleteFirst: true,
275 }
276 );
277 }
278 return undefined;
279 }),
280
281 read: Effect.fn(function* ({ id, olds, output }) {
282 const env = yield* GcpEnvironment.current;
283 const catalog = olds?.catalog ?? output?.catalog;
284 const servingConfigId = yield* toPhysical(
285 id,
286 olds?.servingConfigId,
287 output?.servingConfigId,
288 (name) => slugNoDigits(name, MAX_ID_LENGTH),
289 MAX_ID_LENGTH,
290 );
291 const name =
292 output?.name ??
293 (catalog !== undefined
294 ? resourceName(
295 expandCatalog(
296 catalog,
297 env.project,
298 normalizeLocation(output?.location),
299 ),
300 servingConfigId,
301 )
302 : undefined);
303 if (name === undefined) return undefined;
304 const existing = yield* getByName(name);
305 if (existing === undefined) return undefined;
306 const attrs = toAttrs(existing, env.project);
307 // No labels field: without prior state it may not be ours.
308 return output !== undefined ? attrs : Unowned(attrs);
309 }),
310
311 reconcile: Effect.fn(function* ({ id, news, output }) {
312 const env = yield* GcpEnvironment.current;
313 const location = normalizeLocation(news.location ?? output?.location);
314 const catalog = expandCatalog(news.catalog, env.project, location);
315 const servingConfigId = yield* toPhysical(
316 id,
317 news.servingConfigId,
318 output?.servingConfigId,
319 (name) => slugNoDigits(name, MAX_ID_LENGTH),
320 MAX_ID_LENGTH,
321 );
322 const name = resourceName(catalog, servingConfigId);
323 const displayName = news.displayName ?? servingConfigId;
324 const body = toBody(news, displayName);
325
326 let current = yield* getByName(output?.name ?? name);
327
328 if (current === undefined) {
329 const created = yield* retail
330 .createProjectsLocationsCatalogsServingConfigs({
331 parent: catalog,
332 servingConfigId,
333 body,
334 })
335 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
336 current = created ?? undefined;
337 }
338
339 if (current === undefined) {
340 return yield* new CatalogsServingConfigNotResolved({ name });
341 }
342
343 const resource = current.name ?? name;
344 const mask = updateMaskOf(
345 (current.displayName ?? "") !== displayName
346 ? "display_name"
347 : undefined,
348 sameText(current.modelId, news.modelId) ? undefined : "model_id",
349 sameStringList(current.filterControlIds, news.filterControlIds)
350 ? undefined
351 : "filter_control_ids",
352 sameStringList(current.boostControlIds, news.boostControlIds)
353 ? undefined
354 : "boost_control_ids",
355 sameStringList(current.redirectControlIds, news.redirectControlIds)
356 ? undefined
357 : "redirect_control_ids",
358 sameStringList(
359 current.twowaySynonymsControlIds,
360 news.twowaySynonymsControlIds,
361 )
362 ? undefined
363 : "twoway_synonyms_control_ids",
364 sameStringList(
365 current.onewaySynonymsControlIds,
366 news.onewaySynonymsControlIds,
367 )
368 ? undefined
369 : "oneway_synonyms_control_ids",
370 sameStringList(
371 current.replacementControlIds,
372 news.replacementControlIds,
373 )
374 ? undefined
375 : "replacement_control_ids",
376 sameStringList(current.ignoreControlIds, news.ignoreControlIds)
377 ? undefined
378 : "ignore_control_ids",
379 sameStringList(
380 current.doNotAssociateControlIds,
381 news.doNotAssociateControlIds,
382 )
383 ? undefined
384 : "do_not_associate_control_ids",
385 sameStringList(current.facetControlIds, news.facetControlIds)
386 ? undefined
387 : "facet_control_ids",
388 sameText(current.diversityLevel, news.diversityLevel)
389 ? undefined
390 : "diversity_level",
391 sameText(current.priceRerankingLevel, news.priceRerankingLevel)
392 ? undefined
393 : "price_reranking_level",
394 );
395
396 if (mask.length > 0) {
397 current = yield* retail.patchProjectsLocationsCatalogsServingConfigs({
398 name: resource,
399 updateMask: mask,
400 body: { ...body, name: resource },
401 });
402 }
403
404 return toAttrs(current, env.project);
405 }),
406
407 delete: Effect.fn(function* ({ output }) {
408 yield* retail
409 .deleteProjectsLocationsCatalogsServingConfigs({
410 name: output.name,
411 })
412 .pipe(Effect.catchTag("NotFound", () => Effect.void));
413 }),
414 });