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 sameJson,
17 sameStringList,
18 slugNoDigits,
19 toPhysical,
20 updateMaskOf,
21} from "./internal.ts";
22
23export type ControlCondition = {
24 /** Query terms to match. */
25 queryTerms?: Array<{
26 value: string;
27 fullMatch?: boolean;
28 }>;
29 /** Browse page categories. */
30 pageCategories?: string[];
31};
32
33export type CatalogsControlProps = {
34 /**
35 * Parent catalog resource name
36 * `projects/{project}/locations/{location}/catalogs/{catalog}` or a
37 * catalog id (combined with `location`). Immutable — changing it
38 * replaces the control.
39 * @default "default_catalog"
40 */
41 catalog?: string;
42 /**
43 * Location used when `catalog` is a bare id. Immutable.
44 * @default "global"
45 */
46 location?: string;
47 /**
48 * Control id (`[a-z-_]`, 4-63 characters). If omitted, a unique id is
49 * generated. Immutable — changing it replaces the control.
50 */
51 controlId?: string;
52 /**
53 * Human-readable name (max 128 characters).
54 */
55 displayName?: string;
56 /**
57 * Solution types. Immutable. Only `SOLUTION_TYPE_SEARCH` is supported.
58 * @default ["SOLUTION_TYPE_SEARCH"]
59 */
60 solutionTypes?: Array<
61 | "SOLUTION_TYPE_UNSPECIFIED"
62 | "SOLUTION_TYPE_RECOMMENDATION"
63 | "SOLUTION_TYPE_SEARCH"
64 | (string & {})
65 >;
66 /**
67 * Search use cases.
68 * @default ["SEARCH_SOLUTION_USE_CASE_SEARCH"]
69 */
70 searchSolutionUseCase?: Array<
71 | "SEARCH_SOLUTION_USE_CASE_UNSPECIFIED"
72 | "SEARCH_SOLUTION_USE_CASE_SEARCH"
73 | "SEARCH_SOLUTION_USE_CASE_BROWSE"
74 | (string & {})
75 >;
76 /**
77 * Two-way synonym set (at least two terms).
78 */
79 synonyms?: string[];
80 /**
81 * Redirect URI.
82 */
83 redirectUri?: string;
84 /**
85 * Filter expression.
86 */
87 filter?: string;
88 /**
89 * Boost strength in `[-1, 1]`.
90 */
91 boost?: number;
92 /**
93 * Filter selecting products to boost. Used with `boost`.
94 */
95 productsFilter?: string;
96 /**
97 * Terms to drop from the query.
98 */
99 ignoreTerms?: string[];
100 /**
101 * Trigger conditions. Omit to always apply the action.
102 */
103 condition?: ControlCondition;
104};
105
106export type CatalogsControl = Resource<
107 "GCP.Retail.CatalogsControl",
108 CatalogsControlProps,
109 {
110 /** Full resource name. */
111 name: string;
112 /** Control id. */
113 controlId: string;
114 /** Parent catalog resource name. */
115 catalog: string;
116 /** Project id. */
117 project: string;
118 /** Location id. */
119 location: string;
120 /** Display name. */
121 displayName: string | undefined;
122 /** Solution types. */
123 solutionTypes: string[];
124 /** Search use cases. */
125 searchSolutionUseCase: string[];
126 /** Two-way synonyms. */
127 synonyms: string[];
128 /** Redirect URI. */
129 redirectUri: string | undefined;
130 /** Filter expression. */
131 filter: string | undefined;
132 /** Boost strength. */
133 boost: number | undefined;
134 /** Serving configs this control is attached to. */
135 associatedServingConfigIds: string[];
136 },
137 never,
138 Providers
139>;
140
141/**
142 * A Retail serving control on a catalog.
143 *
144 * Without labels, ownership rests on the deterministic id: `read` reports a
145 * resource it finds without prior state as unowned (adopt it with `--adopt`).
146 * Parent, control id, and solution types are immutable. Display name and rule
147 * actions update in place unless the action oneof changes, which replaces the
148 * control.
149 *
150 * ### Creating a Control
151 * **Example:** Two-way synonym control
152 * ```typescript
153 * const control = yield* GCP.Retail.CatalogsControl("Greetings", {
154 * displayName: "greetings",
155 * synonyms: ["hello", "hi"],
156 * });
157 * ```
158 *
159 * @resource
160 * @category Retail
161 */
162export const CatalogsControl = Resource<CatalogsControl>(
163 "GCP.Retail.CatalogsControl",
164);
165
166export class CatalogsControlNotResolved extends Data.TaggedError(
167 "GCP.Retail.CatalogsControlNotResolved",
168)<{
169 name: string;
170}> {}
171
172const defaultSolutionTypes = ["SOLUTION_TYPE_SEARCH"] as const;
173const defaultUseCase = ["SEARCH_SOLUTION_USE_CASE_SEARCH"] as const;
174
175const actionKind = (input: {
176 synonyms?: string[];
177 redirectUri?: string;
178 filter?: string;
179 boost?: number;
180 ignoreTerms?: string[];
181}) => {
182 if (input.redirectUri) return "redirect";
183 if (input.filter) return "filter";
184 if (input.boost !== undefined) return "boost";
185 if (input.ignoreTerms && input.ignoreTerms.length > 0) return "ignore";
186 return "synonyms";
187};
188
189const toAttrs = (
190 control: retail.GoogleCloudRetailV2Control,
191 project: string,
192) => {
193 const name = control.name ?? "";
194 const parsed = parseResourceName(name, "controls");
195 return {
196 name,
197 controlId: parsed.id,
198 catalog: parsed.catalog,
199 project: parsed.project || project,
200 location: parsed.location,
201 displayName: control.displayName,
202 solutionTypes: [...(control.solutionTypes ?? [])],
203 searchSolutionUseCase: [...(control.searchSolutionUseCase ?? [])],
204 synonyms: [...(control.rule?.twowaySynonymsAction?.synonyms ?? [])],
205 redirectUri: control.rule?.redirectAction?.redirectUri,
206 filter: control.rule?.filterAction?.filter,
207 boost: control.rule?.boostAction?.boost,
208 associatedServingConfigIds: [...(control.associatedServingConfigIds ?? [])],
209 };
210};
211
212const resourceName = (catalog: string, controlId: string) =>
213 `${catalog}/controls/${controlId}`;
214
215const toRule = (news: CatalogsControlProps): retail.GoogleCloudRetailV2Rule => {
216 const condition = news.condition;
217 const kind = actionKind(news);
218 return {
219 condition,
220 twowaySynonymsAction:
221 kind === "synonyms"
222 ? { synonyms: news.synonyms ?? ["hello", "hi"] }
223 : undefined,
224 redirectAction:
225 kind === "redirect" ? { redirectUri: news.redirectUri } : undefined,
226 filterAction: kind === "filter" ? { filter: news.filter } : undefined,
227 boostAction:
228 kind === "boost"
229 ? { boost: news.boost, productsFilter: news.productsFilter }
230 : undefined,
231 ignoreAction:
232 kind === "ignore" ? { ignoreTerms: news.ignoreTerms } : undefined,
233 };
234};
235
236const toBody = (
237 news: CatalogsControlProps,
238 displayName: string,
239): retail.GoogleCloudRetailV2Control => ({
240 displayName,
241 solutionTypes: news.solutionTypes ?? [...defaultSolutionTypes],
242 searchSolutionUseCase: news.searchSolutionUseCase ?? [...defaultUseCase],
243 rule: toRule(news),
244});
245
246const getByName = (name: string) =>
247 name.length === 0
248 ? Effect.succeed(undefined)
249 : retail
250 .getProjectsLocationsCatalogsControls({ name })
251 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
252
253export const CatalogsControlProvider = () =>
254 Provider.succeed(CatalogsControl, {
255 stables: ["name", "controlId", "catalog", "project", "location"],
256
257 diff: Effect.fn(function* ({ news, olds, output }) {
258 if (!isResolved(news)) return undefined;
259 const env = yield* GcpEnvironment.current;
260 const previousParent = olds?.catalog ?? output?.catalog;
261 const nextParent = expandCatalog(
262 news.catalog,
263 env.project,
264 normalizeLocation(news.location ?? output?.location),
265 );
266 const previousTypes = olds?.solutionTypes ?? output?.solutionTypes;
267 const nextTypes = news.solutionTypes ?? previousTypes;
268 const previousKind = actionKind({
269 synonyms: olds?.synonyms ?? output?.synonyms,
270 redirectUri: olds?.redirectUri ?? output?.redirectUri,
271 filter: olds?.filter ?? output?.filter,
272 boost: olds?.boost ?? output?.boost,
273 });
274 const nextKind = actionKind(news);
275 const identity = replaceOnIdentity({
276 previousId: olds?.controlId ?? output?.controlId,
277 nextId: news.controlId,
278 previousParent,
279 nextParent,
280 });
281 if (
282 identity !== undefined ||
283 (previousTypes !== undefined &&
284 nextTypes !== undefined &&
285 !sameStringList(previousTypes, nextTypes)) ||
286 previousKind !== nextKind
287 ) {
288 return (
289 identity ?? {
290 action: "replace" as const,
291 deleteFirst: true,
292 }
293 );
294 }
295 return undefined;
296 }),
297
298 read: Effect.fn(function* ({ id, olds, output }) {
299 const env = yield* GcpEnvironment.current;
300 const catalog = olds?.catalog ?? output?.catalog;
301 const controlId = yield* toPhysical(
302 id,
303 olds?.controlId,
304 output?.controlId,
305 (name) => slugNoDigits(name, MAX_ID_LENGTH),
306 MAX_ID_LENGTH,
307 );
308 const name =
309 output?.name ??
310 (catalog !== undefined
311 ? resourceName(
312 expandCatalog(
313 catalog,
314 env.project,
315 normalizeLocation(output?.location),
316 ),
317 controlId,
318 )
319 : undefined);
320 if (name === undefined) return undefined;
321 const existing = yield* getByName(name);
322 if (existing === undefined) return undefined;
323 const attrs = toAttrs(existing, env.project);
324 // No labels field: without prior state it may not be ours.
325 return output !== undefined ? attrs : Unowned(attrs);
326 }),
327
328 reconcile: Effect.fn(function* ({ id, news, output }) {
329 const env = yield* GcpEnvironment.current;
330 const location = normalizeLocation(news.location ?? output?.location);
331 const catalog = expandCatalog(news.catalog, env.project, location);
332 const controlId = yield* toPhysical(
333 id,
334 news.controlId,
335 output?.controlId,
336 (name) => slugNoDigits(name, MAX_ID_LENGTH),
337 MAX_ID_LENGTH,
338 );
339 const name = resourceName(catalog, controlId);
340 const displayName = news.displayName ?? controlId;
341 const body = toBody(news, displayName);
342
343 let current = yield* getByName(output?.name ?? name);
344
345 if (current === undefined) {
346 const created = yield* retail
347 .createProjectsLocationsCatalogsControls({
348 parent: catalog,
349 controlId,
350 body,
351 })
352 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
353 current = created ?? undefined;
354 }
355
356 if (current === undefined) {
357 return yield* new CatalogsControlNotResolved({ name });
358 }
359
360 const resource = current.name ?? name;
361 const rule = toRule(news);
362 const mask = updateMaskOf(
363 (current.displayName ?? "") !== displayName
364 ? "display_name"
365 : undefined,
366 sameJson(current.rule, rule) ? undefined : "rule",
367 sameStringList(
368 current.searchSolutionUseCase,
369 news.searchSolutionUseCase ?? [...defaultUseCase],
370 )
371 ? undefined
372 : "search_solution_use_case",
373 );
374
375 if (mask.length > 0) {
376 current = yield* retail.patchProjectsLocationsCatalogsControls({
377 name: resource,
378 updateMask: mask,
379 body: { ...body, name: resource },
380 });
381 }
382
383 return toAttrs(current, env.project);
384 }),
385
386 delete: Effect.fn(function* ({ output }) {
387 yield* retail
388 .deleteProjectsLocationsCatalogsControls({
389 name: output.name,
390 })
391 .pipe(Effect.catchTag("NotFound", () => Effect.void));
392 }),
393 });