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 controlIdOf,
12 expandDataStore,
13 fingerprint,
14 parseResourceName,
15 toPhysical,
16} from "./internal.ts";
17
18export type DataStoresControlQueryTerm = {
19 /** Query value to match. Must be lowercase UTF-8. */
20 value: string;
21 /**
22 * Whether the search query must exactly match the term.
23 * @default false
24 */
25 fullMatch?: boolean;
26};
27
28export type DataStoresControlTimeRange = {
29 /** Inclusive start of the active window (RFC3339). */
30 startTime?: string;
31 /** Inclusive end of the active window (RFC3339). */
32 endTime?: string;
33};
34
35export type DataStoresControlCondition = {
36 /** Query regex matching the whole search query. */
37 queryRegex?: string;
38 /** Query terms to match. Maximum 10. */
39 queryTerms?: DataStoresControlQueryTerm[];
40 /** Active time windows. Maximum 10. */
41 activeTimeRange?: DataStoresControlTimeRange[];
42};
43
44export type DataStoresControlRedirectAction = {
45 /** URI to redirect the shopper to (max 2000 characters). */
46 redirectUri: string;
47};
48
49export type DataStoresControlFilterAction = {
50 /** Filter expression applied to matching results. */
51 filter: string;
52 /** Data store whose documents can be filtered. Defaults to the parent. */
53 dataStore?: string;
54};
55
56export type DataStoresControlSynonymsAction = {
57 /** Synonym set. At least 2, at most 100. */
58 synonyms: string[];
59};
60
61export type DataStoresControlBoostAction = {
62 /** Filter selecting documents to boost. */
63 filter?: string;
64 /** Data store whose documents can be boosted. Defaults to the parent. */
65 dataStore?: string;
66 /** Boost strength in `[-1, 1]`. Negative values demote. */
67 boost?: number;
68 /** Fixed boost strength in `[-1, 1]`. */
69 fixedBoost?: number;
70};
71
72export type DataStoresControlPromoteAction = {
73 /** Data store this promotion is attached to. Defaults to the parent. */
74 dataStore?: string;
75 /** Promotion title (max 160 characters). */
76 title: string;
77 /** Promotion URI. */
78 uri?: string;
79 /** Promotion description (max 200 characters). */
80 description?: string;
81 /** Thumbnail image URI. */
82 imageUri?: string;
83 /** Document resource name to promote. */
84 document?: string;
85 /**
86 * Whether the promotion is enabled.
87 * @default true
88 */
89 enabled?: boolean;
90};
91
92export type DataStoresControlProps = {
93 /**
94 * Parent Data Store resource name
95 * `projects/{project}/locations/{location}/dataStores/{dataStore}`.
96 * Immutable — changing it replaces the control.
97 */
98 dataStore: string;
99 /**
100 * Control id (1-63 characters, `a-z`, `_`, `-`). If omitted, a unique
101 * id is generated. Immutable — changing it replaces the control.
102 */
103 controlId?: string;
104 /**
105 * Human-readable name (max 128 characters).
106 */
107 displayName?: string;
108 /**
109 * Solution this control belongs to. Immutable — changing it replaces
110 * the control.
111 * @default "SOLUTION_TYPE_SEARCH"
112 */
113 solutionType?: string;
114 /**
115 * Search use cases. Required when `solutionType` is
116 * `SOLUTION_TYPE_SEARCH`.
117 * @default ["SEARCH_USE_CASE_SEARCH"]
118 */
119 useCases?: string[];
120 /**
121 * Conditions that trigger the action. Omit to always apply.
122 */
123 conditions?: DataStoresControlCondition[];
124 /** Redirect-type action. Mutually exclusive with the other actions. */
125 redirectAction?: DataStoresControlRedirectAction;
126 /** Filter-type action. Mutually exclusive with the other actions. */
127 filterAction?: DataStoresControlFilterAction;
128 /** Synonyms-type action. Mutually exclusive with the other actions. */
129 synonymsAction?: DataStoresControlSynonymsAction;
130 /** Boost-type action. Mutually exclusive with the other actions. */
131 boostAction?: DataStoresControlBoostAction;
132 /** Promote-type action. Mutually exclusive with the other actions. */
133 promoteAction?: DataStoresControlPromoteAction;
134};
135
136export type DataStoresControl = Resource<
137 "GCP.DiscoveryEngine.DataStoresControl",
138 DataStoresControlProps,
139 {
140 /** Full resource name `.../dataStores/{dataStore}/controls/{control}`. */
141 name: string;
142 /** Control id (last path segment). */
143 controlId: string;
144 /** Parent data store resource name. */
145 dataStore: string;
146 /** Project id. */
147 project: string;
148 /** Location id. */
149 location: string;
150 /** Display name. */
151 displayName: string | undefined;
152 /** Solution type. */
153 solutionType: string | undefined;
154 /** Search use cases. */
155 useCases: string[];
156 /** Serving configs this control is attached to. */
157 associatedServingConfigIds: string[];
158 },
159 never,
160 Providers
161>;
162
163/**
164 * A Vertex AI Search Control attached to a Data Store.
165 *
166 * Without labels, ownership rests on the deterministic id: `read` reports a
167 * resource it finds without prior state as unowned (adopt it with `--adopt`).
168 * Parent, control id, solution type, and action kind are immutable; display
169 * name, conditions, and action payload update in place.
170 *
171 * ### Creating a Control
172 * **Example:** Synonym control
173 * ```typescript
174 * const control = yield* GCP.DiscoveryEngine.DataStoresControl("Synonyms", {
175 * dataStore: dataStore.name,
176 * displayName: "happy-glad",
177 * synonymsAction: { synonyms: ["happy", "glad"] },
178 * });
179 * ```
180 *
181 * ### Updating a Control
182 * **Example:** Rename and expand the synonym set
183 * ```typescript
184 * // Same logical id as before; only the changed props differ.
185 * const control = yield* GCP.DiscoveryEngine.DataStoresControl("Synonyms", {
186 * dataStore: dataStore.name,
187 * displayName: "mood",
188 * synonymsAction: { synonyms: ["happy", "glad", "cheerful"] },
189 * });
190 * ```
191 *
192 * @resource
193 * @category DiscoveryEngine
194 */
195export const DataStoresControl = Resource<DataStoresControl>(
196 "GCP.DiscoveryEngine.DataStoresControl",
197);
198
199export class DataStoresControlNotResolved extends Data.TaggedError(
200 "GCP.DiscoveryEngine.DataStoresControlNotResolved",
201)<{
202 name: string;
203}> {}
204
205const DEFAULT_SOLUTION = "SOLUTION_TYPE_SEARCH";
206const DEFAULT_USE_CASES = ["SEARCH_USE_CASE_SEARCH"];
207
208const resourceName = (dataStore: string, controlId: string) =>
209 `${dataStore}/controls/${controlId}`;
210
211const actionKind = (props: {
212 redirectAction?: unknown;
213 filterAction?: unknown;
214 synonymsAction?: unknown;
215 boostAction?: unknown;
216 promoteAction?: unknown;
217}) => {
218 if (props.redirectAction) return "redirect";
219 if (props.filterAction) return "filter";
220 if (props.synonymsAction) return "synonyms";
221 if (props.boostAction) return "boost";
222 if (props.promoteAction) return "promote";
223 return "none";
224};
225
226const toBody = (
227 news: DataStoresControlProps,
228 displayName: string,
229): discoveryengine.GoogleCloudDiscoveryengineV1Control => ({
230 displayName,
231 solutionType: news.solutionType ?? DEFAULT_SOLUTION,
232 useCases: news.useCases ?? DEFAULT_USE_CASES,
233 conditions: news.conditions,
234 redirectAction: news.redirectAction,
235 filterAction: news.filterAction
236 ? {
237 filter: news.filterAction.filter,
238 dataStore: news.filterAction.dataStore ?? news.dataStore,
239 }
240 : undefined,
241 synonymsAction: news.synonymsAction,
242 boostAction: news.boostAction
243 ? {
244 filter: news.boostAction.filter,
245 dataStore: news.boostAction.dataStore ?? news.dataStore,
246 boost: news.boostAction.boost,
247 fixedBoost: news.boostAction.fixedBoost,
248 }
249 : undefined,
250 promoteAction: news.promoteAction
251 ? {
252 dataStore: news.promoteAction.dataStore ?? news.dataStore,
253 searchLinkPromotion: {
254 title: news.promoteAction.title,
255 uri: news.promoteAction.uri,
256 description: news.promoteAction.description,
257 imageUri: news.promoteAction.imageUri,
258 document: news.promoteAction.document,
259 enabled: news.promoteAction.enabled,
260 },
261 }
262 : undefined,
263});
264
265const toAttrs = (
266 control: discoveryengine.GoogleCloudDiscoveryengineV1Control,
267 project: string,
268 dataStoreHint?: string,
269) => {
270 const name = control.name ?? "";
271 const parsed = parseResourceName(name, "controls");
272 const dataStore = parsed.dataStore.includes("/dataStores/")
273 ? parsed.dataStore
274 : (dataStoreHint ?? parsed.dataStore);
275 return {
276 name,
277 controlId: parsed.id,
278 dataStore,
279 project: parsed.project || project,
280 location: parsed.location,
281 displayName: control.displayName,
282 solutionType: control.solutionType,
283 useCases: [...(control.useCases ?? [])],
284 associatedServingConfigIds: [...(control.associatedServingConfigIds ?? [])],
285 };
286};
287
288const getByName = (name: string) =>
289 name.length === 0
290 ? Effect.succeed(undefined)
291 : discoveryengine
292 .getProjectsLocationsDataStoresControls({ name })
293 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
294
295export const DataStoresControlProvider = () =>
296 Provider.succeed(DataStoresControl, {
297 stables: ["name", "controlId", "dataStore", "project", "location"],
298
299 diff: Effect.fn(function* ({ news, olds, output }) {
300 if (!isResolved(news)) return undefined;
301 const previousParent = olds?.dataStore ?? output?.dataStore;
302 if (previousParent !== undefined && news.dataStore !== previousParent) {
303 return { action: "replace" as const, deleteFirst: false };
304 }
305 const previousId = olds?.controlId ?? output?.controlId;
306 if (
307 previousId !== undefined &&
308 news.controlId !== undefined &&
309 news.controlId !== previousId
310 ) {
311 return { action: "replace" as const, deleteFirst: true };
312 }
313 const previousSolution =
314 olds?.solutionType ?? output?.solutionType ?? DEFAULT_SOLUTION;
315 const nextSolution = news.solutionType ?? previousSolution;
316 if (previousSolution !== nextSolution) {
317 return { action: "replace" as const, deleteFirst: true };
318 }
319 const previousKind = actionKind(olds ?? {});
320 const nextKind = actionKind(news);
321 if (previousKind !== "none" && nextKind !== previousKind) {
322 return { action: "replace" as const, deleteFirst: true };
323 }
324 const previousAction = fingerprint({
325 redirectAction: olds?.redirectAction,
326 filterAction: olds?.filterAction,
327 synonymsAction: olds?.synonymsAction,
328 boostAction: olds?.boostAction,
329 promoteAction: olds?.promoteAction,
330 });
331 const nextAction = fingerprint({
332 redirectAction: news.redirectAction,
333 filterAction: news.filterAction,
334 synonymsAction: news.synonymsAction,
335 boostAction: news.boostAction,
336 promoteAction: news.promoteAction,
337 });
338 if (previousKind !== "none" && previousAction !== nextAction) {
339 return { action: "replace" as const, deleteFirst: true };
340 }
341 return undefined;
342 }),
343
344 read: Effect.fn(function* ({ id, olds, output }) {
345 const env = yield* GcpEnvironment.current;
346 const controlId = yield* toPhysical(
347 id,
348 olds?.controlId,
349 output?.controlId,
350 controlIdOf,
351 );
352 const parent = olds?.dataStore
353 ? expandDataStore(
354 olds.dataStore,
355 env.project,
356 output?.location ?? "global",
357 )
358 : undefined;
359 const name =
360 output?.name ?? (parent ? resourceName(parent, controlId) : "");
361 const existing = yield* getByName(name);
362 if (existing === undefined) return undefined;
363 const attrs = toAttrs(existing, env.project);
364 // No labels field: without prior state it may not be ours.
365 return output !== undefined ? attrs : Unowned(attrs);
366 }),
367
368 reconcile: Effect.fn(function* ({ id, news, output }) {
369 const env = yield* GcpEnvironment.current;
370 const parent = expandDataStore(
371 news.dataStore,
372 env.project,
373 output?.location ?? "global",
374 );
375 const controlId = yield* toPhysical(
376 id,
377 news.controlId,
378 output?.controlId,
379 controlIdOf,
380 );
381 const name = resourceName(parent, controlId);
382 const displayName = news.displayName ?? controlId;
383
384 let current = yield* getByName(output?.name ?? name);
385
386 if (current === undefined) {
387 const created = yield* discoveryengine
388 .createProjectsLocationsDataStoresControls({
389 parent,
390 controlId,
391 body: toBody(news, displayName),
392 })
393 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
394 current = created ?? undefined;
395 }
396
397 if (current === undefined) {
398 return yield* new DataStoresControlNotResolved({ name });
399 }
400
401 const desired = toBody(news, displayName);
402 const displayChanged = (current.displayName ?? "") !== displayName;
403 const useCasesChanged =
404 fingerprint([...(current.useCases ?? [])].sort()) !==
405 fingerprint([...(desired.useCases ?? [])].sort());
406 const conditionsChanged =
407 fingerprint(current.conditions) !== fingerprint(desired.conditions);
408 if (displayChanged || useCasesChanged || conditionsChanged) {
409 current =
410 yield* discoveryengine.patchProjectsLocationsDataStoresControls({
411 name: current.name ?? name,
412 updateMask: [
413 displayChanged ? "display_name" : undefined,
414 useCasesChanged ? "use_cases" : undefined,
415 conditionsChanged ? "conditions" : undefined,
416 ]
417 .filter((field): field is string => field !== undefined)
418 .join(","),
419 body: { ...desired, name: current.name ?? name },
420 });
421 }
422
423 return toAttrs(current, env.project, parent);
424 }),
425
426 delete: Effect.fn(function* ({ output }) {
427 const existing = yield* getByName(output.name);
428 if (existing === undefined) return;
429 yield* discoveryengine
430 .deleteProjectsLocationsDataStoresControls({ name: output.name })
431 .pipe(Effect.catchTag("NotFound", () => Effect.void));
432 }),
433 });