1import * as discoveryengine from "@distilled.cloud/gcp/discoveryengine_v1";
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 collectionParent,
13 dataStoreIdOf,
14 DEFAULT_COLLECTION,
15 normalizeCollection,
16 normalizeLocation,
17 parseResourceName,
18 sameJson,
19 sameStringList,
20 toResourceId,
21} from "./internal.ts";
22import { waitForOperation } from "./operations.ts";
23
24export type CollectionsEngineSearchEngineConfig = {
25 /**
26 * Search feature tier. Defaults to `SEARCH_TIER_STANDARD`.
27 */
28 searchTier?: discoveryengine.GoogleCloudDiscoveryengineV1EngineSearchEngineConfigSearchTierEnum;
29 /**
30 * Search add-ons enabled on this engine.
31 */
32 searchAddOns?: discoveryengine.GoogleCloudDiscoveryengineV1EngineSearchEngineConfigSearchAddOnsItemEnumList;
33 /**
34 * Required subscription tier. Immutable after create.
35 */
36 requiredSubscriptionTier?: discoveryengine.GoogleCloudDiscoveryengineV1EngineSearchEngineConfigRequiredSubscriptionTierEnum;
37};
38
39export type CollectionsEngineCommonConfig = {
40 /**
41 * Company or entity associated with the engine. Setting this may help
42 * LLM features.
43 */
44 companyName?: string;
45};
46
47export type CollectionsEngineProps = {
48 /**
49 * Engine id (the `{engine}` segment of the resource name). If omitted,
50 * a unique RFC-1034 id is generated. Immutable — changing it replaces
51 * the engine.
52 */
53 engineId?: string;
54 /**
55 * Location (`global`, `us`, `eu`, …). Immutable — changing it replaces
56 * the engine.
57 * @default "global"
58 */
59 location?: string;
60 /**
61 * Collection id. Immutable — changing it replaces the engine.
62 * @default "default_collection"
63 */
64 collectionId?: string;
65 /**
66 * User-facing display name.
67 * @default the engine id
68 */
69 displayName?: string;
70 /**
71 * Solution type. Immutable — changing it replaces the engine.
72 * @default "SOLUTION_TYPE_SEARCH"
73 */
74 solutionType?: discoveryengine.GoogleCloudDiscoveryengineV1EngineSolutionTypeEnum;
75 /**
76 * Industry vertical. Must match the linked data store. Immutable.
77 * @default "GENERIC"
78 */
79 industryVertical?: discoveryengine.GoogleCloudDiscoveryengineV1EngineIndustryVerticalEnum;
80 /**
81 * Data store ids to attach. For search and recommendation engines at
82 * most one id is allowed. Values may be ids or full resource names.
83 */
84 dataStoreIds?: string[];
85 /**
86 * Search-engine configuration. Applied when `solutionType` is
87 * `SOLUTION_TYPE_SEARCH`.
88 */
89 searchEngineConfig?: CollectionsEngineSearchEngineConfig;
90 /**
91 * Common metadata (company name).
92 */
93 commonConfig?: CollectionsEngineCommonConfig;
94 /**
95 * Chat-engine configuration. One-time consumed at create.
96 */
97 chatEngineConfig?: discoveryengine.GoogleCloudDiscoveryengineV1EngineChatEngineConfig;
98 /**
99 * Media recommendation configuration.
100 */
101 mediaRecommendationEngineConfig?: discoveryengine.GoogleCloudDiscoveryengineV1EngineMediaRecommendationEngineConfig;
102 /**
103 * Disable analytics for searches on this engine.
104 * @default false
105 */
106 disableAnalytics?: boolean;
107 /**
108 * Application type. Immutable.
109 */
110 appType?: discoveryengine.GoogleCloudDiscoveryengineV1EngineAppTypeEnum;
111};
112
113export type CollectionsEngine = Resource<
114 "GCP.DiscoveryEngine.CollectionsEngine",
115 CollectionsEngineProps,
116 {
117 /** Full resource name. */
118 name: string;
119 /** Engine id (last path segment). */
120 engineId: string;
121 /** Project id. */
122 project: string;
123 /** Location id. */
124 location: string;
125 /** Collection id. */
126 collectionId: string;
127 /** Display name. */
128 displayName: string | undefined;
129 /** Solution type. */
130 solutionType: string | undefined;
131 /** Industry vertical. */
132 industryVertical: string | undefined;
133 /** Attached data store ids. */
134 dataStoreIds: string[];
135 /** Whether analytics are disabled. */
136 disableAnalytics: boolean;
137 /** Company name from common config. */
138 companyName: string | undefined;
139 /** RFC3339 creation timestamp. */
140 createTime: string | undefined;
141 /** RFC3339 last-update timestamp. */
142 updateTime: string | undefined;
143 },
144 never,
145 Providers
146>;
147
148/**
149 * A Discovery Engine Engine under a collection — serves Search,
150 * Recommendation, or Chat from one or more DataStores.
151 *
152 * Without labels, ownership rests on the deterministic id: `read` reports a
153 * resource it finds without prior state as unowned (adopt it with `--adopt`).
154 * Parent is
155 * `projects/{project}/locations/{location}/collections/{collection}`. Id,
156 * location, collection, solution type, and vertical are immutable; display
157 * name, analytics flag, and common config update in place.
158 *
159 * ### Creating an Engine
160 * **Example:** Search engine over a data store
161 * ```typescript
162 * const store = yield* GCP.DiscoveryEngine.DataStore("Docs", {});
163 * const engine = yield* GCP.DiscoveryEngine.CollectionsEngine("Search", {
164 * dataStoreIds: [store.dataStoreId],
165 * displayName: "docs search",
166 * });
167 * ```
168 *
169 * ### Updating an Engine
170 * **Example:** Rename
171 * ```typescript
172 * // Same logical id as before; only the changed props differ.
173 * const engine = yield* GCP.DiscoveryEngine.CollectionsEngine("Search", {
174 * dataStoreIds: [store.dataStoreId],
175 * displayName: "docs search prod",
176 * });
177 * ```
178 *
179 * @resource
180 * @category DiscoveryEngine
181 */
182export const CollectionsEngine = Resource<CollectionsEngine>(
183 "GCP.DiscoveryEngine.CollectionsEngine",
184);
185
186export class CollectionsEngineNotResolved extends Data.TaggedError(
187 "GCP.DiscoveryEngine.CollectionsEngineNotResolved",
188)<{
189 name: string;
190}> {}
191
192export class CollectionsEngineStillExists extends Data.TaggedError(
193 "GCP.DiscoveryEngine.CollectionsEngineStillExists",
194)<{
195 name: string;
196}> {}
197
198const resourceName = (
199 project: string,
200 location: string,
201 collectionId: string,
202 engineId: string,
203) => `${collectionParent(project, location, collectionId)}/engines/${engineId}`;
204
205const solutionOf = (
206 value:
207 | discoveryengine.GoogleCloudDiscoveryengineV1EngineSolutionTypeEnum
208 | undefined,
209) => value ?? "SOLUTION_TYPE_SEARCH";
210
211const verticalOf = (
212 value:
213 | discoveryengine.GoogleCloudDiscoveryengineV1EngineIndustryVerticalEnum
214 | undefined,
215) => value ?? "GENERIC";
216
217const idsOf = (values: readonly string[] | undefined) =>
218 (values ?? []).map(dataStoreIdOf);
219
220const getByName = (name: string) =>
221 discoveryengine
222 .getProjectsLocationsCollectionsEngines({ name })
223 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
224
225const toAttrs = (
226 engine: discoveryengine.GoogleCloudDiscoveryengineV1Engine,
227 project: string,
228) => {
229 const name = engine.name ?? "";
230 const parsed = parseResourceName(name, "engines");
231 return {
232 name,
233 engineId: parsed.id,
234 project: parsed.project || project,
235 location: parsed.location,
236 collectionId: parsed.collectionId || DEFAULT_COLLECTION,
237 displayName: engine.displayName,
238 solutionType: engine.solutionType,
239 industryVertical: engine.industryVertical,
240 dataStoreIds: [...(engine.dataStoreIds ?? [])],
241 disableAnalytics: engine.disableAnalytics === true,
242 companyName: engine.commonConfig?.companyName,
243 createTime: engine.createTime,
244 updateTime: engine.updateTime,
245 };
246};
247
248const waitUntilExists = (name: string) =>
249 getByName(name).pipe(
250 Effect.flatMap((engine) =>
251 engine
252 ? Effect.succeed(engine)
253 : Effect.fail(new CollectionsEngineNotResolved({ name })),
254 ),
255 Effect.retry({
256 while: (error) =>
257 error._tag === "GCP.DiscoveryEngine.CollectionsEngineNotResolved",
258 times: 8,
259 schedule: Schedule.spaced("1 second"),
260 }),
261 );
262
263const waitUntilGone = (name: string) =>
264 getByName(name).pipe(
265 Effect.flatMap((engine) =>
266 engine === undefined
267 ? Effect.void
268 : Effect.fail(new CollectionsEngineStillExists({ name })),
269 ),
270 Effect.retry({
271 while: (error) =>
272 error._tag === "GCP.DiscoveryEngine.CollectionsEngineStillExists",
273 times: 10,
274 schedule: Schedule.spaced("2 seconds"),
275 }),
276 );
277
278export const CollectionsEngineProvider = () =>
279 Provider.succeed(CollectionsEngine, {
280 stables: [
281 "name",
282 "engineId",
283 "project",
284 "location",
285 "collectionId",
286 "solutionType",
287 "industryVertical",
288 "createTime",
289 ],
290
291 diff: Effect.fn(function* ({ news, olds, output }) {
292 if (!isResolved(news)) return undefined;
293 const previousId = olds?.engineId ?? output?.engineId;
294 const nextId = news.engineId ?? previousId;
295 const previousLocation = normalizeLocation(
296 olds?.location ?? output?.location,
297 );
298 const nextLocation = normalizeLocation(
299 news.location ?? olds?.location ?? output?.location,
300 );
301 const previousCollection = normalizeCollection(
302 olds?.collectionId ?? output?.collectionId,
303 );
304 const nextCollection = normalizeCollection(
305 news.collectionId ?? olds?.collectionId ?? output?.collectionId,
306 );
307 const previousSolution = solutionOf(
308 olds?.solutionType ??
309 (output?.solutionType as CollectionsEngineProps["solutionType"]),
310 );
311 const nextSolution = solutionOf(
312 news.solutionType ??
313 (output?.solutionType as CollectionsEngineProps["solutionType"]),
314 );
315 const previousVertical = verticalOf(
316 olds?.industryVertical ??
317 (output?.industryVertical as CollectionsEngineProps["industryVertical"]),
318 );
319 const nextVertical = verticalOf(
320 news.industryVertical ??
321 (output?.industryVertical as CollectionsEngineProps["industryVertical"]),
322 );
323 const replace =
324 (previousId !== undefined &&
325 nextId !== undefined &&
326 nextId !== previousId) ||
327 previousLocation !== nextLocation ||
328 previousCollection !== nextCollection ||
329 previousSolution !== nextSolution ||
330 previousVertical !== nextVertical;
331 if (!replace) return undefined;
332 return {
333 action: "replace" as const,
334 deleteFirst:
335 previousLocation === nextLocation &&
336 previousCollection === nextCollection &&
337 previousId !== undefined &&
338 nextId === previousId,
339 };
340 }),
341
342 read: Effect.fn(function* ({ id, olds, output }) {
343 const env = yield* GcpEnvironment.current;
344 const engineId = yield* toResourceId(
345 id,
346 olds?.engineId,
347 output?.engineId,
348 );
349 const location = normalizeLocation(olds?.location ?? output?.location);
350 const collectionId = normalizeCollection(
351 olds?.collectionId ?? output?.collectionId,
352 );
353 const existing = yield* getByName(
354 output?.name ??
355 resourceName(env.project, location, collectionId, engineId),
356 );
357 if (existing === undefined) return undefined;
358 const attrs = toAttrs(existing, env.project);
359 // No labels field: without prior state it may not be ours.
360 return output !== undefined ? attrs : Unowned(attrs);
361 }),
362
363 reconcile: Effect.fn(function* ({ id, news, output }) {
364 const env = yield* GcpEnvironment.current;
365 const location = normalizeLocation(news.location ?? output?.location);
366 const collectionId = normalizeCollection(
367 news.collectionId ?? output?.collectionId,
368 );
369 const engineId = yield* toResourceId(id, news.engineId, output?.engineId);
370 const displayName = news.displayName ?? engineId;
371 const solutionType = solutionOf(news.solutionType);
372 const industryVertical = verticalOf(news.industryVertical);
373 const dataStoreIds = idsOf(news.dataStoreIds ?? output?.dataStoreIds);
374 const parent = collectionParent(env.project, location, collectionId);
375 const fallbackName =
376 output?.name ??
377 resourceName(env.project, location, collectionId, engineId);
378 const searchEngineConfig =
379 solutionType === "SOLUTION_TYPE_SEARCH"
380 ? (news.searchEngineConfig ?? {})
381 : news.searchEngineConfig;
382 const disableAnalytics = news.disableAnalytics === true;
383
384 let current = yield* getByName(fallbackName);
385
386 if (current === undefined) {
387 const created = yield* discoveryengine
388 .createProjectsLocationsCollectionsEngines({
389 parent,
390 engineId,
391 body: {
392 displayName,
393 solutionType,
394 industryVertical,
395 dataStoreIds,
396 searchEngineConfig,
397 commonConfig: news.commonConfig,
398 chatEngineConfig: news.chatEngineConfig,
399 mediaRecommendationEngineConfig:
400 news.mediaRecommendationEngineConfig,
401 disableAnalytics: disableAnalytics ? true : undefined,
402 appType: news.appType,
403 },
404 })
405 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
406 if (created !== undefined) {
407 yield* waitForOperation(created);
408 }
409 current = yield* waitUntilExists(fallbackName);
410 }
411
412 if (current === undefined) {
413 return yield* new CollectionsEngineNotResolved({ name: fallbackName });
414 }
415
416 const name = current.name ?? fallbackName;
417 const displayNameChanged = (current.displayName ?? "") !== displayName;
418 const analyticsChanged =
419 (current.disableAnalytics === true) !== disableAnalytics;
420 const companyChanged =
421 (current.commonConfig?.companyName ?? "") !==
422 (news.commonConfig?.companyName ?? "");
423 const searchChanged =
424 searchEngineConfig !== undefined &&
425 !sameJson(current.searchEngineConfig, searchEngineConfig);
426 const dataStoresChanged = !sameStringList(
427 current.dataStoreIds,
428 dataStoreIds,
429 );
430
431 if (
432 displayNameChanged ||
433 analyticsChanged ||
434 companyChanged ||
435 searchChanged ||
436 dataStoresChanged
437 ) {
438 current =
439 yield* discoveryengine.patchProjectsLocationsCollectionsEngines({
440 name,
441 updateMask: [
442 displayNameChanged ? "display_name" : undefined,
443 analyticsChanged ? "disable_analytics" : undefined,
444 companyChanged ? "common_config" : undefined,
445 searchChanged ? "search_engine_config" : undefined,
446 dataStoresChanged ? "data_store_ids" : undefined,
447 ]
448 .filter((field): field is string => field !== undefined)
449 .join(","),
450 body: {
451 name,
452 displayName,
453 disableAnalytics,
454 commonConfig: news.commonConfig,
455 searchEngineConfig,
456 dataStoreIds,
457 },
458 });
459 }
460
461 return toAttrs(current, env.project);
462 }),
463
464 delete: Effect.fn(function* ({ output }) {
465 const existing = yield* getByName(output.name);
466 if (existing === undefined) return;
467 const operation = yield* discoveryengine
468 .deleteProjectsLocationsCollectionsEngines({ name: output.name })
469 .pipe(
470 Effect.retry({
471 while: (error) => error._tag === "Conflict",
472 times: 8,
473 schedule: Schedule.spaced("2 seconds"),
474 }),
475 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
476 );
477 if (operation !== undefined) {
478 yield* waitForOperation(operation, { notFoundOk: true });
479 }
480 yield* waitUntilGone(output.name);
481 }),
482 });