1import * as discoveryengine from "@distilled.cloud/gcp/discoveryengine_v1";
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 parentBefore,
12 parseResourceName,
13 sameJson,
14 sameStringList,
15 toResourceId,
16} from "./internal.ts";
17
18export type CollectionsEnginesControlProps = {
19 /**
20 * Parent Engine resource name
21 * `projects/{project}/locations/{location}/collections/{collection}/engines/{engine}`.
22 * Immutable — changing it replaces the control.
23 */
24 engine: string;
25 /**
26 * Control id (1-63 characters, `a-z`, `_`, `-`). If omitted, a unique
27 * id is generated. Immutable — changing it replaces the control.
28 */
29 controlId?: string;
30 /**
31 * Human-readable name.
32 */
33 displayName?: string;
34 /**
35 * Solution the control belongs to. Immutable.
36 * @default "SOLUTION_TYPE_SEARCH"
37 */
38 solutionType?: discoveryengine.GoogleCloudDiscoveryengineV1ControlSolutionTypeEnum;
39 /**
40 * Use cases. Required when `solutionType` is `SOLUTION_TYPE_SEARCH`.
41 * @default ["SEARCH_USE_CASE_SEARCH"]
42 */
43 useCases?: discoveryengine.GoogleCloudDiscoveryengineV1ControlUseCasesItemEnumList;
44 /**
45 * Conditions that must match before the action runs.
46 */
47 conditions?: discoveryengine.GoogleCloudDiscoveryengineV1ConditionList;
48 /**
49 * Redirect-type action.
50 */
51 redirectAction?: discoveryengine.GoogleCloudDiscoveryengineV1ControlRedirectAction;
52 /**
53 * Filter-type action.
54 */
55 filterAction?: discoveryengine.GoogleCloudDiscoveryengineV1ControlFilterAction;
56 /**
57 * Synonyms-type action.
58 */
59 synonymsAction?: discoveryengine.GoogleCloudDiscoveryengineV1ControlSynonymsAction;
60 /**
61 * Boost-type action.
62 */
63 boostAction?: discoveryengine.GoogleCloudDiscoveryengineV1ControlBoostAction;
64 /**
65 * Promote-type action.
66 */
67 promoteAction?: discoveryengine.GoogleCloudDiscoveryengineV1ControlPromoteAction;
68};
69
70export type CollectionsEnginesControl = Resource<
71 "GCP.DiscoveryEngine.CollectionsEnginesControl",
72 CollectionsEnginesControlProps,
73 {
74 /** Full resource name. */
75 name: string;
76 /** Control id (last path segment). */
77 controlId: string;
78 /** Parent engine resource name. */
79 engine: string;
80 /** Project id. */
81 project: string;
82 /** Location id. */
83 location: string;
84 /** Collection id. */
85 collectionId: string;
86 /** Display name. */
87 displayName: string | undefined;
88 /** Solution type. */
89 solutionType: string | undefined;
90 /** Use cases. */
91 useCases: string[];
92 /** Serving configs this control is attached to. */
93 associatedServingConfigIds: string[];
94 },
95 never,
96 Providers
97>;
98
99/**
100 * A Discovery Engine Control on a collection Engine — redirects,
101 * filters, synonyms, boosts, or promotions applied at serving time.
102 *
103 * Without labels, ownership rests on the deterministic id: `read` reports a
104 * resource it finds without prior state as unowned (adopt it with `--adopt`).
105 * Parent engine, control id, and solution type are immutable, and so is the
106 * action (changing it replaces the control); display name, use cases, and
107 * conditions update in place.
108 *
109 * ### Creating a Control
110 * **Example:** Synonym control
111 * ```typescript
112 * const control = yield* GCP.DiscoveryEngine.CollectionsEnginesControl(
113 * "Synonyms",
114 * {
115 * engine: engine.name,
116 * displayName: "hello-hi",
117 * synonymsAction: { synonyms: ["hello", "hi"] },
118 * },
119 * );
120 * ```
121 *
122 * ### Updating a Control
123 * **Example:** Change synonyms
124 * ```typescript
125 * // Same logical id as before; only the changed props differ.
126 * const control = yield* GCP.DiscoveryEngine.CollectionsEnginesControl(
127 * "Synonyms",
128 * {
129 * engine: engine.name,
130 * synonymsAction: { synonyms: ["hello", "hi", "hey"] },
131 * },
132 * );
133 * ```
134 *
135 * @resource
136 * @category DiscoveryEngine
137 */
138export const CollectionsEnginesControl = Resource<CollectionsEnginesControl>(
139 "GCP.DiscoveryEngine.CollectionsEnginesControl",
140);
141
142export class CollectionsEnginesControlNotResolved extends Data.TaggedError(
143 "GCP.DiscoveryEngine.CollectionsEnginesControlNotResolved",
144)<{
145 name: string;
146}> {}
147
148const resourceName = (engine: string, controlId: string) =>
149 `${engine}/controls/${controlId}`;
150
151const solutionOf = (
152 value:
153 | discoveryengine.GoogleCloudDiscoveryengineV1ControlSolutionTypeEnum
154 | undefined,
155) => value ?? "SOLUTION_TYPE_SEARCH";
156
157const useCasesOf = (
158 value:
159 | discoveryengine.GoogleCloudDiscoveryengineV1ControlUseCasesItemEnumList
160 | undefined,
161 solutionType: string,
162):
163 | discoveryengine.GoogleCloudDiscoveryengineV1ControlUseCasesItemEnumList
164 | undefined => {
165 if (value && value.length > 0) return value;
166 if (solutionType === "SOLUTION_TYPE_SEARCH") {
167 return ["SEARCH_USE_CASE_SEARCH"];
168 }
169 return undefined;
170};
171
172const getByName = (name: string) =>
173 discoveryengine
174 .getProjectsLocationsCollectionsEnginesControls({ name })
175 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
176
177const toAttrs = (
178 control: discoveryengine.GoogleCloudDiscoveryengineV1Control,
179 project: string,
180) => {
181 const name = control.name ?? "";
182 const parsed = parseResourceName(name, "controls");
183 return {
184 name,
185 controlId: parsed.id,
186 engine: parentBefore(name, "controls"),
187 project: parsed.project || project,
188 location: parsed.location,
189 collectionId: parsed.collectionId,
190 displayName: control.displayName,
191 solutionType: control.solutionType,
192 useCases: [...(control.useCases ?? [])],
193 associatedServingConfigIds: [...(control.associatedServingConfigIds ?? [])],
194 };
195};
196
197const bodyOf = (
198 news: CollectionsEnginesControlProps,
199 displayName: string,
200 solutionType: discoveryengine.GoogleCloudDiscoveryengineV1ControlSolutionTypeEnum,
201 useCases:
202 | discoveryengine.GoogleCloudDiscoveryengineV1ControlUseCasesItemEnumList
203 | undefined,
204): discoveryengine.GoogleCloudDiscoveryengineV1Control => ({
205 displayName,
206 solutionType,
207 useCases,
208 conditions: news.conditions,
209 redirectAction: news.redirectAction,
210 filterAction: news.filterAction,
211 synonymsAction: news.synonymsAction,
212 boostAction: news.boostAction,
213 promoteAction: news.promoteAction,
214});
215
216export const CollectionsEnginesControlProvider = () =>
217 Provider.succeed(CollectionsEnginesControl, {
218 stables: [
219 "name",
220 "controlId",
221 "engine",
222 "project",
223 "location",
224 "collectionId",
225 "solutionType",
226 ],
227
228 diff: Effect.fn(function* ({ news, olds, output }) {
229 if (!isResolved(news)) return undefined;
230 const previousEngine = olds?.engine ?? output?.engine;
231 const previousId = olds?.controlId ?? output?.controlId;
232 const nextId = news.controlId ?? previousId;
233 const previousSolution = solutionOf(
234 olds?.solutionType ??
235 (output?.solutionType as CollectionsEnginesControlProps["solutionType"]),
236 );
237 const nextSolution = solutionOf(
238 news.solutionType ??
239 (output?.solutionType as CollectionsEnginesControlProps["solutionType"]),
240 );
241 if (
242 (previousEngine !== undefined && news.engine !== previousEngine) ||
243 (previousId !== undefined &&
244 nextId !== undefined &&
245 nextId !== previousId) ||
246 previousSolution !== nextSolution
247 ) {
248 return { action: "replace" as const, deleteFirst: false };
249 }
250 // A control's action is immutable ("updateMask contains an immutable
251 // path synonyms_action"), so any action change replaces it.
252 if (
253 olds !== undefined &&
254 (!sameJson(olds.redirectAction, news.redirectAction) ||
255 !sameJson(olds.filterAction, news.filterAction) ||
256 !sameJson(olds.synonymsAction, news.synonymsAction) ||
257 !sameJson(olds.boostAction, news.boostAction) ||
258 !sameJson(olds.promoteAction, news.promoteAction))
259 ) {
260 return { action: "replace" as const, deleteFirst: true };
261 }
262 return undefined;
263 }),
264
265 read: Effect.fn(function* ({ id, olds, output }) {
266 const env = yield* GcpEnvironment.current;
267 const parent = olds?.engine ?? output?.engine;
268 const childId = yield* toResourceId(
269 id,
270 olds?.controlId,
271 output?.controlId,
272 );
273 const name =
274 output?.name ??
275 (parent !== undefined ? resourceName(parent, childId) : undefined);
276 if (name === undefined) return undefined;
277 const existing = yield* getByName(name);
278 if (existing === undefined) return undefined;
279 const attrs = toAttrs(existing, env.project);
280 // No labels field: without prior state it may not be ours.
281 return output !== undefined ? attrs : Unowned(attrs);
282 }),
283
284 reconcile: Effect.fn(function* ({ id, news, output }) {
285 const env = yield* GcpEnvironment.current;
286 const controlId = yield* toResourceId(
287 id,
288 news.controlId,
289 output?.controlId,
290 );
291 const displayName = news.displayName ?? controlId;
292 const solutionType = solutionOf(news.solutionType);
293 const useCases = useCasesOf(news.useCases, solutionType);
294 const fallbackName = output?.name ?? resourceName(news.engine, controlId);
295 const desired = bodyOf(news, displayName, solutionType, useCases);
296
297 let current = yield* getByName(fallbackName);
298
299 if (current === undefined) {
300 const created = yield* discoveryengine
301 .createProjectsLocationsCollectionsEnginesControls({
302 parent: news.engine,
303 controlId,
304 body: desired,
305 })
306 .pipe(Effect.catchTag("Conflict", () => getByName(fallbackName)));
307 current = created ?? undefined;
308 }
309
310 if (current === undefined) {
311 return yield* new CollectionsEnginesControlNotResolved({
312 name: fallbackName,
313 });
314 }
315
316 const name = current.name ?? fallbackName;
317 const displayNameChanged = (current.displayName ?? "") !== displayName;
318 const useCasesChanged = !sameStringList(current.useCases, useCases);
319 const conditionsChanged = !sameJson(current.conditions, news.conditions);
320
321 // Actions are immutable; diff replaces the control when they change.
322 if (displayNameChanged || useCasesChanged || conditionsChanged) {
323 current =
324 yield* discoveryengine.patchProjectsLocationsCollectionsEnginesControls(
325 {
326 name,
327 updateMask: [
328 displayNameChanged ? "display_name" : undefined,
329 useCasesChanged ? "use_cases" : undefined,
330 conditionsChanged ? "conditions" : undefined,
331 ]
332 .filter((field): field is string => field !== undefined)
333 .join(","),
334 body: { name, ...desired },
335 },
336 );
337 }
338
339 return toAttrs(current, env.project);
340 }),
341
342 delete: Effect.fn(function* ({ output }) {
343 const existing = yield* getByName(output.name);
344 if (existing === undefined) return;
345 yield* discoveryengine
346 .deleteProjectsLocationsCollectionsEnginesControls({
347 name: output.name,
348 })
349 .pipe(Effect.catchTag("NotFound", () => Effect.void));
350 }),
351 });