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 expandDataStore,
12 parseResourceName,
13 sameStringList,
14 servingConfigIdOf,
15 toPhysical,
16} from "./internal.ts";
17
18export type DataStoresServingConfigProps = {
19 /**
20 * Parent Data Store resource name
21 * `projects/{project}/locations/{location}/dataStores/{dataStore}`.
22 * Immutable — changing it replaces the serving config.
23 */
24 dataStore: string;
25 /**
26 * Serving config id (4-63 characters, `a-zA-Z0-9`). If omitted, a
27 * unique id is generated. Immutable — changing it replaces the config.
28 */
29 servingConfigId?: string;
30 /**
31 * Human-readable name (max 128 characters).
32 */
33 displayName?: string;
34 /**
35 * Solution type. Immutable — changing it replaces the config.
36 * @default "SOLUTION_TYPE_SEARCH"
37 */
38 solutionType?: string;
39 /**
40 * Filter control ids applied on the serving path. Maximum 20.
41 */
42 filterControlIds?: string[];
43 /**
44 * Boost control ids applied on the serving path. Maximum 20.
45 */
46 boostControlIds?: string[];
47 /**
48 * Redirect control ids. Only the first triggered redirect is applied.
49 */
50 redirectControlIds?: string[];
51 /**
52 * Synonyms control ids.
53 */
54 synonymsControlIds?: string[];
55 /**
56 * One-way synonyms control ids.
57 */
58 onewaySynonymsControlIds?: string[];
59 /**
60 * Dissociate control ids.
61 */
62 dissociateControlIds?: string[];
63 /**
64 * Replacement control ids.
65 */
66 replacementControlIds?: string[];
67 /**
68 * Ignore control ids.
69 */
70 ignoreControlIds?: string[];
71 /**
72 * Promote control ids.
73 */
74 promoteControlIds?: string[];
75 /**
76 * Ranking expression, for example
77 * `0.5 * relevance_score + 0.3 * dotProduct(doc_embedding)`.
78 */
79 rankingExpression?: string;
80 /**
81 * Recommendation model id. Required when `solutionType` is
82 * `SOLUTION_TYPE_RECOMMENDATION`.
83 */
84 modelId?: string;
85 /**
86 * Diversity level for recommendation results
87 * (`no-diversity`, `low-diversity`, `medium-diversity`,
88 * `high-diversity`, `auto-diversity`).
89 */
90 diversityLevel?: string;
91};
92
93export type DataStoresServingConfig = Resource<
94 "GCP.DiscoveryEngine.DataStoresServingConfig",
95 DataStoresServingConfigProps,
96 {
97 /** Full resource name `.../dataStores/{dataStore}/servingConfigs/{id}`. */
98 name: string;
99 /** Serving config id (last path segment). */
100 servingConfigId: string;
101 /** Parent data store resource name. */
102 dataStore: string;
103 /** Project id. */
104 project: string;
105 /** Location id. */
106 location: string;
107 /** Display name. */
108 displayName: string | undefined;
109 /** Solution type. */
110 solutionType: string | undefined;
111 /** Filter control ids. */
112 filterControlIds: string[];
113 /** Boost control ids. */
114 boostControlIds: string[];
115 /** Ranking expression, if set. */
116 rankingExpression: string | undefined;
117 /** Recommendation model id, if set. */
118 modelId: string | undefined;
119 /** Diversity level, if set. */
120 diversityLevel: string | undefined;
121 /** RFC3339 creation timestamp. */
122 createTime: string | undefined;
123 /** RFC3339 last-update timestamp. */
124 updateTime: string | undefined;
125 },
126 never,
127 Providers
128>;
129
130/**
131 * A Vertex AI Search ServingConfig attached to a Data Store.
132 *
133 * Without labels, ownership rests on the deterministic id: `read` reports a
134 * resource it finds without prior state as unowned (adopt it with `--adopt`).
135 * Parent, id, and solution type are immutable; display name, attached
136 * controls, and ranking update in place. The console only shows the default
137 * serving config — additional configs are API-only.
138 *
139 * ### Creating a Serving Config
140 * **Example:** Search serving config
141 * ```typescript
142 * const config = yield* GCP.DiscoveryEngine.DataStoresServingConfig("Search", {
143 * dataStore: dataStore.name,
144 * displayName: "web-search",
145 * solutionType: "SOLUTION_TYPE_SEARCH",
146 * });
147 * ```
148 *
149 * ### Updating a Serving Config
150 * **Example:** Attach filter controls
151 * ```typescript
152 * // Same logical id as before; only the changed props differ.
153 * const config = yield* GCP.DiscoveryEngine.DataStoresServingConfig("Search", {
154 * dataStore: dataStore.name,
155 * displayName: "web-search",
156 * filterControlIds: [control.controlId],
157 * });
158 * ```
159 *
160 * @resource
161 * @category DiscoveryEngine
162 */
163export const DataStoresServingConfig = Resource<DataStoresServingConfig>(
164 "GCP.DiscoveryEngine.DataStoresServingConfig",
165);
166
167export class DataStoresServingConfigNotResolved extends Data.TaggedError(
168 "GCP.DiscoveryEngine.DataStoresServingConfigNotResolved",
169)<{
170 name: string;
171}> {}
172
173const DEFAULT_SOLUTION = "SOLUTION_TYPE_SEARCH";
174
175const resourceName = (dataStore: string, servingConfigId: string) =>
176 `${dataStore}/servingConfigs/${servingConfigId}`;
177
178const toAttrs = (
179 config: discoveryengine.GoogleCloudDiscoveryengineV1ServingConfig,
180 project: string,
181 dataStoreHint?: string,
182) => {
183 const name = config.name ?? "";
184 const parsed = parseResourceName(name, "servingConfigs");
185 const dataStore = parsed.dataStore.includes("/dataStores/")
186 ? parsed.dataStore
187 : (dataStoreHint ?? parsed.dataStore);
188 return {
189 name,
190 servingConfigId: parsed.id,
191 dataStore,
192 project: parsed.project || project,
193 location: parsed.location,
194 displayName: config.displayName,
195 solutionType: config.solutionType,
196 filterControlIds: [...(config.filterControlIds ?? [])],
197 boostControlIds: [...(config.boostControlIds ?? [])],
198 rankingExpression: config.rankingExpression,
199 modelId: config.modelId,
200 diversityLevel: config.diversityLevel,
201 createTime: config.createTime,
202 updateTime: config.updateTime,
203 };
204};
205
206const getByName = (name: string) =>
207 name.length === 0
208 ? Effect.succeed(undefined)
209 : discoveryengine
210 .getProjectsLocationsDataStoresServingConfigs({ name })
211 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
212
213export const DataStoresServingConfigProvider = () =>
214 Provider.succeed(DataStoresServingConfig, {
215 stables: [
216 "name",
217 "servingConfigId",
218 "dataStore",
219 "project",
220 "location",
221 "createTime",
222 ],
223
224 diff: Effect.fn(function* ({ news, olds, output }) {
225 if (!isResolved(news)) return undefined;
226 const previousParent = olds?.dataStore ?? output?.dataStore;
227 if (previousParent !== undefined && news.dataStore !== previousParent) {
228 return { action: "replace" as const, deleteFirst: false };
229 }
230 const previousId = olds?.servingConfigId ?? output?.servingConfigId;
231 if (
232 previousId !== undefined &&
233 news.servingConfigId !== undefined &&
234 news.servingConfigId !== previousId
235 ) {
236 return { action: "replace" as const, deleteFirst: true };
237 }
238 const previousSolution =
239 olds?.solutionType ?? output?.solutionType ?? DEFAULT_SOLUTION;
240 const nextSolution = news.solutionType ?? previousSolution;
241 if (previousSolution !== nextSolution) {
242 return { action: "replace" as const, deleteFirst: true };
243 }
244 return undefined;
245 }),
246
247 read: Effect.fn(function* ({ id, olds, output }) {
248 const env = yield* GcpEnvironment.current;
249 const servingConfigId = yield* toPhysical(
250 id,
251 olds?.servingConfigId,
252 output?.servingConfigId,
253 servingConfigIdOf,
254 );
255 const parent = olds?.dataStore
256 ? expandDataStore(
257 olds.dataStore,
258 env.project,
259 output?.location ?? "global",
260 )
261 : undefined;
262 const name =
263 output?.name ?? (parent ? resourceName(parent, servingConfigId) : "");
264 const existing = yield* getByName(name);
265 if (existing === undefined) return undefined;
266 const attrs = toAttrs(existing, env.project);
267 // No labels field: without prior state it may not be ours.
268 return output !== undefined ? attrs : Unowned(attrs);
269 }),
270
271 reconcile: Effect.fn(function* ({ id, news, output }) {
272 const env = yield* GcpEnvironment.current;
273 const parent = expandDataStore(
274 news.dataStore,
275 env.project,
276 output?.location ?? "global",
277 );
278 const servingConfigId = yield* toPhysical(
279 id,
280 news.servingConfigId,
281 output?.servingConfigId,
282 servingConfigIdOf,
283 );
284 const name = resourceName(parent, servingConfigId);
285 const displayName = news.displayName ?? servingConfigId;
286 const solutionType = news.solutionType ?? DEFAULT_SOLUTION;
287
288 let current = yield* getByName(output?.name ?? name);
289
290 if (current === undefined) {
291 const created = yield* discoveryengine
292 .createProjectsLocationsDataStoresServingConfigs({
293 parent,
294 servingConfigId,
295 body: {
296 displayName,
297 solutionType,
298 filterControlIds: news.filterControlIds,
299 boostControlIds: news.boostControlIds,
300 redirectControlIds: news.redirectControlIds,
301 synonymsControlIds: news.synonymsControlIds,
302 onewaySynonymsControlIds: news.onewaySynonymsControlIds,
303 dissociateControlIds: news.dissociateControlIds,
304 replacementControlIds: news.replacementControlIds,
305 ignoreControlIds: news.ignoreControlIds,
306 promoteControlIds: news.promoteControlIds,
307 rankingExpression: news.rankingExpression,
308 modelId: news.modelId,
309 diversityLevel: news.diversityLevel,
310 },
311 })
312 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
313 current = created ?? undefined;
314 }
315
316 if (current === undefined) {
317 return yield* new DataStoresServingConfigNotResolved({ name });
318 }
319
320 const displayChanged = (current.displayName ?? "") !== displayName;
321 const filterChanged = !sameStringList(
322 current.filterControlIds,
323 news.filterControlIds,
324 );
325 const boostChanged = !sameStringList(
326 current.boostControlIds,
327 news.boostControlIds,
328 );
329 const rankingChanged =
330 (current.rankingExpression ?? "") !== (news.rankingExpression ?? "");
331 const modelChanged = (current.modelId ?? "") !== (news.modelId ?? "");
332 const diversityChanged =
333 (current.diversityLevel ?? "") !== (news.diversityLevel ?? "");
334 const redirectChanged = !sameStringList(
335 current.redirectControlIds,
336 news.redirectControlIds,
337 );
338 const synonymsChanged = !sameStringList(
339 current.synonymsControlIds,
340 news.synonymsControlIds,
341 );
342
343 if (
344 displayChanged ||
345 filterChanged ||
346 boostChanged ||
347 rankingChanged ||
348 modelChanged ||
349 diversityChanged ||
350 redirectChanged ||
351 synonymsChanged
352 ) {
353 current =
354 yield* discoveryengine.patchProjectsLocationsDataStoresServingConfigs(
355 {
356 name: current.name ?? name,
357 updateMask: [
358 displayChanged ? "display_name" : undefined,
359 filterChanged ? "filter_control_ids" : undefined,
360 boostChanged ? "boost_control_ids" : undefined,
361 rankingChanged ? "ranking_expression" : undefined,
362 modelChanged ? "model_id" : undefined,
363 diversityChanged ? "diversity_level" : undefined,
364 redirectChanged ? "redirect_control_ids" : undefined,
365 synonymsChanged ? "synonyms_control_ids" : undefined,
366 ]
367 .filter((field): field is string => field !== undefined)
368 .join(","),
369 body: {
370 name: current.name ?? name,
371 displayName,
372 filterControlIds: news.filterControlIds,
373 boostControlIds: news.boostControlIds,
374 redirectControlIds: news.redirectControlIds,
375 synonymsControlIds: news.synonymsControlIds,
376 rankingExpression: news.rankingExpression,
377 modelId: news.modelId,
378 diversityLevel: news.diversityLevel,
379 },
380 },
381 );
382 }
383
384 return toAttrs(current, env.project, parent);
385 }),
386
387 delete: Effect.fn(function* ({ output }) {
388 const existing = yield* getByName(output.name);
389 if (existing === undefined) return;
390 yield* discoveryengine
391 .deleteProjectsLocationsDataStoresServingConfigs({ name: output.name })
392 .pipe(Effect.catchTag("NotFound", () => Effect.void));
393 }),
394 });