1import * as retail from "@distilled.cloud/gcp/retail_v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 MAX_MODEL_ID_LENGTH,
13 expandCatalog,
14 normalizeLocation,
15 parseResourceName,
16 replaceOnIdentity,
17 sameText,
18 slugNoDigits,
19 toPhysical,
20 updateMaskOf,
21} from "./internal.ts";
22import { resourceNameFromOperation, waitForOperation } from "./operations.ts";
23
24export type CatalogsModelProps = {
25 /**
26 * Parent catalog resource name
27 * `projects/{project}/locations/{location}/catalogs/{catalog}` or a
28 * catalog id (combined with `location`). Immutable — changing it
29 * replaces the model.
30 * @default "default_catalog"
31 */
32 catalog?: string;
33 /**
34 * Location used when `catalog` is a bare id. Immutable.
35 * @default "global"
36 */
37 location?: string;
38 /**
39 * Model id (4-40 characters). If omitted, a unique id is generated.
40 * Immutable — changing it replaces the model.
41 */
42 modelId?: string;
43 /**
44 * Human-readable name (max 1,024 characters). Display name is set at create
45 * time and is not updated in place.
46 */
47 displayName?: string;
48 /**
49 * Model type. Immutable.
50 * @default "recommended-for-you"
51 */
52 type?: string;
53 /**
54 * Optimization objective (`ctr`, `cvr`, `revenue-per-order`). Immutable
55 * after create.
56 */
57 optimizationObjective?: string;
58 /**
59 * Training state. Synced via pause/resume after create.
60 * @default "PAUSED"
61 */
62 trainingState?:
63 | "TRAINING_STATE_UNSPECIFIED"
64 | "PAUSED"
65 | "TRAINING"
66 | (string & {});
67 /**
68 * Periodic tuning state. Patchable.
69 */
70 periodicTuningState?:
71 | "PERIODIC_TUNING_STATE_UNSPECIFIED"
72 | "PERIODIC_TUNING_DISABLED"
73 | "ALL_TUNING_DISABLED"
74 | "PERIODIC_TUNING_ENABLED"
75 | (string & {});
76 /**
77 * Recommendation attribute filtering.
78 */
79 filteringOption?:
80 | "RECOMMENDATIONS_FILTERING_OPTION_UNSPECIFIED"
81 | "RECOMMENDATIONS_FILTERING_DISABLED"
82 | "RECOMMENDATIONS_FILTERING_ENABLED"
83 | (string & {});
84};
85
86export type CatalogsModel = Resource<
87 "GCP.Retail.CatalogsModel",
88 CatalogsModelProps,
89 {
90 /** Full resource name. */
91 name: string;
92 /** Model id. */
93 modelId: string;
94 /** Parent catalog resource name. */
95 catalog: string;
96 /** Project id. */
97 project: string;
98 /** Location id. */
99 location: string;
100 /** Display name. */
101 displayName: string | undefined;
102 /** Model type. */
103 type: string | undefined;
104 /** Optimization objective. */
105 optimizationObjective: string | undefined;
106 /** Training state. */
107 trainingState: string | undefined;
108 /** Serving state. */
109 servingState: string | undefined;
110 /** Data state. */
111 dataState: string | undefined;
112 /** Periodic tuning state. */
113 periodicTuningState: string | undefined;
114 /** Filtering option. */
115 filteringOption: string | undefined;
116 /** RFC3339 create time. */
117 createTime: string | undefined;
118 /** RFC3339 update time. */
119 updateTime: string | undefined;
120 },
121 never,
122 Providers
123>;
124
125/**
126 * A Retail recommendation model on a catalog.
127 *
128 * Without labels, ownership rests on the deterministic id: `read` reports a
129 * resource it finds without prior state as unowned (adopt it with `--adopt`).
130 * Parent, model id, type, and optimization objective are immutable.
131 * `filteringOption` and `periodicTuningState` patch in place; `trainingState`
132 * is synced with pause/resume.
133 *
134 * Creating a model requires Retail Recommendations and enough catalog
135 * and user-event data. Create is a long-running operation.
136 *
137 * ### Creating a Model
138 * **Example:** Recommended-for-you model, paused
139 * ```typescript
140 * const model = yield* GCP.Retail.CatalogsModel("Homepage", {
141 * displayName: "homepage recs",
142 * type: "recommended-for-you",
143 * trainingState: "PAUSED",
144 * });
145 * ```
146 *
147 * @resource
148 * @category Retail
149 */
150export const CatalogsModel = Resource<CatalogsModel>(
151 "GCP.Retail.CatalogsModel",
152);
153
154export class CatalogsModelNotResolved extends Data.TaggedError(
155 "GCP.Retail.CatalogsModelNotResolved",
156)<{
157 name: string;
158}> {}
159
160const toAttrs = (model: retail.GoogleCloudRetailV2Model, project: string) => {
161 const name = model.name ?? "";
162 const parsed = parseResourceName(name, "models");
163 return {
164 name,
165 modelId: parsed.id,
166 catalog: parsed.catalog,
167 project: parsed.project || project,
168 location: parsed.location,
169 displayName: model.displayName,
170 type: model.type,
171 optimizationObjective: model.optimizationObjective,
172 trainingState: model.trainingState,
173 servingState: model.servingState,
174 dataState: model.dataState,
175 periodicTuningState: model.periodicTuningState,
176 filteringOption: model.filteringOption,
177 createTime: model.createTime,
178 updateTime: model.updateTime,
179 };
180};
181
182const resourceName = (catalog: string, modelId: string) =>
183 `${catalog}/models/${modelId}`;
184
185const getByName = (name: string) =>
186 name.length === 0
187 ? Effect.succeed(undefined)
188 : retail
189 .getProjectsLocationsCatalogsModels({ name })
190 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
191
192const refresh = (name: string) =>
193 getByName(name).pipe(
194 Effect.filterOrFail(
195 (model): model is retail.GoogleCloudRetailV2Model => model !== undefined,
196 () => new CatalogsModelNotResolved({ name }),
197 ),
198 Effect.retry({
199 while: (error) => error._tag === "GCP.Retail.CatalogsModelNotResolved",
200 times: 5,
201 schedule: Schedule.exponential("250 millis"),
202 }),
203 );
204
205export const CatalogsModelProvider = () =>
206 Provider.succeed(CatalogsModel, {
207 stables: [
208 "name",
209 "modelId",
210 "catalog",
211 "project",
212 "location",
213 "type",
214 "createTime",
215 ],
216
217 diff: Effect.fn(function* ({ news, olds, output }) {
218 if (!isResolved(news)) return undefined;
219 const env = yield* GcpEnvironment.current;
220 const previousParent = olds?.catalog ?? output?.catalog;
221 const nextParent = expandCatalog(
222 news.catalog,
223 env.project,
224 normalizeLocation(news.location ?? output?.location),
225 );
226 const previousType = olds?.type ?? output?.type;
227 const nextType = news.type ?? previousType;
228 const previousObjective =
229 olds?.optimizationObjective ?? output?.optimizationObjective;
230 const nextObjective = news.optimizationObjective ?? previousObjective;
231 const identity = replaceOnIdentity({
232 previousId: olds?.modelId ?? output?.modelId,
233 nextId: news.modelId,
234 previousParent,
235 nextParent,
236 });
237 if (
238 identity !== undefined ||
239 (previousType !== undefined &&
240 nextType !== undefined &&
241 previousType !== nextType) ||
242 (previousObjective !== undefined &&
243 nextObjective !== undefined &&
244 previousObjective !== nextObjective)
245 ) {
246 return (
247 identity ?? {
248 action: "replace" as const,
249 deleteFirst: true,
250 }
251 );
252 }
253 return undefined;
254 }),
255
256 read: Effect.fn(function* ({ id, olds, output }) {
257 const env = yield* GcpEnvironment.current;
258 const catalog = olds?.catalog ?? output?.catalog;
259 const modelId = yield* toPhysical(
260 id,
261 olds?.modelId,
262 output?.modelId,
263 (name) => slugNoDigits(name, MAX_MODEL_ID_LENGTH),
264 MAX_MODEL_ID_LENGTH,
265 );
266 const name =
267 output?.name ??
268 (catalog !== undefined
269 ? resourceName(
270 expandCatalog(
271 catalog,
272 env.project,
273 normalizeLocation(output?.location),
274 ),
275 modelId,
276 )
277 : undefined);
278 if (name === undefined) return undefined;
279 const existing = yield* getByName(name);
280 if (existing === undefined) return undefined;
281 const attrs = toAttrs(existing, env.project);
282 // No labels field: without prior state it may not be ours.
283 return output !== undefined ? attrs : Unowned(attrs);
284 }),
285
286 reconcile: Effect.fn(function* ({ id, news, output }) {
287 const env = yield* GcpEnvironment.current;
288 const location = normalizeLocation(news.location ?? output?.location);
289 const catalog = expandCatalog(news.catalog, env.project, location);
290 const modelId = yield* toPhysical(
291 id,
292 news.modelId,
293 output?.modelId,
294 (name) => slugNoDigits(name, MAX_MODEL_ID_LENGTH),
295 MAX_MODEL_ID_LENGTH,
296 );
297 const name = resourceName(catalog, modelId);
298 const displayName = news.displayName ?? modelId;
299 const type = news.type ?? "recommended-for-you";
300 const desiredTraining = news.trainingState ?? "PAUSED";
301
302 let current = yield* getByName(output?.name ?? name);
303
304 if (current === undefined) {
305 const operation = yield* retail
306 .createProjectsLocationsCatalogsModels({
307 parent: catalog,
308 body: {
309 name,
310 displayName,
311 type,
312 optimizationObjective: news.optimizationObjective,
313 trainingState: "TRAINING",
314 periodicTuningState: news.periodicTuningState,
315 filteringOption: news.filteringOption,
316 },
317 })
318 .pipe(
319 Effect.catchTag("Conflict", () =>
320 Effect.succeed<retail.GoogleLongrunningOperation>({
321 name: "",
322 done: true,
323 }),
324 ),
325 );
326 yield* waitForOperation(operation);
327 const createdName = resourceNameFromOperation(operation) ?? name;
328 current = yield* getByName(createdName).pipe(
329 Effect.flatMap((model) =>
330 model !== undefined ? Effect.succeed(model) : getByName(name),
331 ),
332 );
333 if (current === undefined) {
334 current = yield* refresh(name).pipe(
335 Effect.catchTag("GCP.Retail.CatalogsModelNotResolved", () =>
336 Effect.succeed(undefined),
337 ),
338 );
339 }
340 }
341
342 if (current === undefined) {
343 return yield* new CatalogsModelNotResolved({ name });
344 }
345
346 const resource = current.name ?? name;
347 const mask = updateMaskOf(
348 sameText(current.filteringOption, news.filteringOption)
349 ? undefined
350 : "filtering_option",
351 sameText(current.periodicTuningState, news.periodicTuningState)
352 ? undefined
353 : "periodic_tuning_state",
354 );
355 if (mask.length > 0) {
356 current = yield* retail.patchProjectsLocationsCatalogsModels({
357 name: resource,
358 updateMask: mask,
359 body: {
360 name: resource,
361 filteringOption: news.filteringOption,
362 periodicTuningState: news.periodicTuningState,
363 },
364 });
365 }
366
367 const observedTraining = current.trainingState ?? "TRAINING";
368 if (desiredTraining === "PAUSED" && observedTraining !== "PAUSED") {
369 current = yield* retail.pauseProjectsLocationsCatalogsModels({
370 name: resource,
371 body: {},
372 });
373 } else if (
374 desiredTraining === "TRAINING" &&
375 observedTraining !== "TRAINING"
376 ) {
377 current = yield* retail.resumeProjectsLocationsCatalogsModels({
378 name: resource,
379 body: {},
380 });
381 }
382
383 return toAttrs(current, env.project);
384 }),
385
386 delete: Effect.fn(function* ({ output }) {
387 yield* retail
388 .deleteProjectsLocationsCatalogsModels({
389 name: output.name,
390 })
391 .pipe(Effect.catchTag("NotFound", () => Effect.void));
392 }),
393 });