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 dataStoreName,
14 normalizeCollection,
15 normalizeLocation,
16 parseResourceName,
17 toResourceId,
18} from "./internal.ts";
19import { waitForOperation } from "./operations.ts";
20
21export type CollectionsDataStoreProps = {
22 /**
23 * Data store id (the `{data_store}` segment). If omitted, a unique
24 * RFC-1034 id is generated. Immutable — changing it replaces the
25 * data store.
26 */
27 dataStoreId?: string;
28 /**
29 * Location (`global`, `us`, `eu`). Immutable — changing it replaces
30 * the data store.
31 * @default "global"
32 */
33 location?: string;
34 /**
35 * Collection id. Immutable — changing it replaces the data store.
36 * @default "default_collection"
37 */
38 collection?: string;
39 /**
40 * User-facing display name (max 128 characters).
41 * @default the data store id
42 */
43 displayName?: string;
44 /**
45 * Industry vertical. Immutable.
46 * @default "GENERIC"
47 */
48 industryVertical?:
49 | "INDUSTRY_VERTICAL_UNSPECIFIED"
50 | "GENERIC"
51 | "MEDIA"
52 | "HEALTHCARE_FHIR"
53 | (string & {});
54 /**
55 * Content config. Immutable.
56 * @default "NO_CONTENT"
57 */
58 contentConfig?:
59 | "CONTENT_CONFIG_UNSPECIFIED"
60 | "NO_CONTENT"
61 | "CONTENT_REQUIRED"
62 | "PUBLIC_WEBSITE"
63 | "GOOGLE_WORKSPACE"
64 | (string & {});
65 /**
66 * Solutions this data store enrolls.
67 * @default ["SOLUTION_TYPE_SEARCH"]
68 */
69 solutionTypes?: Array<
70 | "SOLUTION_TYPE_UNSPECIFIED"
71 | "SOLUTION_TYPE_RECOMMENDATION"
72 | "SOLUTION_TYPE_SEARCH"
73 | "SOLUTION_TYPE_CHAT"
74 | "SOLUTION_TYPE_GENERATIVE_CHAT"
75 | "SOLUTION_TYPE_AI_MODE"
76 | (string & {})
77 >;
78 /**
79 * Whether ingested documents carry ACL information. Immutable.
80 * @default false
81 */
82 aclEnabled?: boolean;
83 /**
84 * Skip creating the default schema. Cannot be combined with
85 * `startingSchema`.
86 * @default false
87 */
88 skipDefaultSchemaCreation?: boolean;
89 /**
90 * Create an advanced site-search data store. Ignored unless
91 * `contentConfig` is `PUBLIC_WEBSITE`.
92 * @default false
93 */
94 createAdvancedSiteSearch?: boolean;
95 /**
96 * Disable CMEK even if the project has a default CmekConfig.
97 * @default true
98 */
99 disableCmek?: boolean;
100 /**
101 * Input-only CMEK key resource name used at create time.
102 */
103 kmsKeyName?: string;
104 /**
105 * Starting schema applied only on create.
106 */
107 startingSchema?: {
108 jsonSchema?: string;
109 structSchema?: Record<string, unknown>;
110 };
111 /**
112 * When true, the data store is not available for serving search
113 * requests.
114 */
115 disabledForServing?: boolean;
116};
117
118export type CollectionsDataStore = Resource<
119 "GCP.DiscoveryEngine.CollectionsDataStore",
120 CollectionsDataStoreProps,
121 {
122 /** Full resource name. */
123 name: string;
124 /** Data store id (last path segment). */
125 dataStoreId: string;
126 /** Project id. */
127 project: string;
128 /** Location id. */
129 location: string;
130 /** Collection id. */
131 collection: string;
132 /** Display name. */
133 displayName: string | undefined;
134 /** Industry vertical. */
135 industryVertical: string | undefined;
136 /** Content config. */
137 contentConfig: string | undefined;
138 /** Enrolled solution types. */
139 solutionTypes: string[];
140 /** Whether ACL is enabled. */
141 aclEnabled: boolean;
142 /** Default schema id, if any. */
143 defaultSchemaId: string | undefined;
144 /** Whether serving is disabled. */
145 disabledForServing: boolean;
146 /** RFC3339 creation timestamp. */
147 createTime: string | undefined;
148 },
149 never,
150 Providers
151>;
152
153/**
154 * A Discovery Engine data store under a collection.
155 *
156 * Without labels, ownership rests on the deterministic id: `read` reports a
157 * resource it finds without prior state as unowned (adopt it with `--adopt`).
158 * Location, collection, data store id, industry vertical, content config, and
159 * ACL are immutable. Display name and serving-disabled flag update in place.
160 *
161 * ### Creating a Data Store
162 * **Example:** Generated id, generic search store
163 * ```typescript
164 * const store = yield* GCP.DiscoveryEngine.CollectionsDataStore("Docs", {
165 * displayName: "product docs",
166 * });
167 * ```
168 *
169 * **Example:** Site-search store
170 * ```typescript
171 * const store = yield* GCP.DiscoveryEngine.CollectionsDataStore("Web", {
172 * contentConfig: "PUBLIC_WEBSITE",
173 * displayName: "public site",
174 * });
175 * ```
176 *
177 * ### Updating a Data Store
178 * **Example:** Rename
179 * ```typescript
180 * // Same logical id as before; only the changed props differ.
181 * const store = yield* GCP.DiscoveryEngine.CollectionsDataStore("Docs", {
182 * displayName: "product docs v2",
183 * });
184 * ```
185 *
186 * @resource
187 * @category DiscoveryEngine
188 */
189export const CollectionsDataStore = Resource<CollectionsDataStore>(
190 "GCP.DiscoveryEngine.CollectionsDataStore",
191);
192
193export class CollectionsDataStoreNotResolved extends Data.TaggedError(
194 "GCP.DiscoveryEngine.CollectionsDataStoreNotResolved",
195)<{
196 name: string;
197}> {}
198
199export class CollectionsDataStoreStillExists extends Data.TaggedError(
200 "GCP.DiscoveryEngine.CollectionsDataStoreStillExists",
201)<{
202 name: string;
203}> {}
204
205const verticalOf = (value: string | undefined) => value ?? "GENERIC";
206const contentOf = (value: string | undefined) => value ?? "NO_CONTENT";
207const solutionsOf = (value: readonly string[] | undefined) =>
208 value && value.length > 0 ? [...value] : ["SOLUTION_TYPE_SEARCH"];
209
210const toAttrs = (
211 store: discoveryengine.GoogleCloudDiscoveryengineV1DataStore,
212 project: string,
213) => {
214 const name = store.name ?? "";
215 const parsed = parseResourceName(name, "dataStores");
216 return {
217 name,
218 dataStoreId: parsed.id,
219 project: parsed.project || project,
220 location: parsed.location,
221 collection: parsed.collection,
222 displayName: store.displayName,
223 industryVertical: store.industryVertical,
224 contentConfig: store.contentConfig,
225 solutionTypes: [...(store.solutionTypes ?? [])],
226 aclEnabled: store.aclEnabled === true,
227 defaultSchemaId: store.defaultSchemaId,
228 disabledForServing:
229 store.servingConfigDataStore?.disabledForServing === true,
230 createTime: store.createTime,
231 };
232};
233
234const getByName = (name: string) =>
235 name.length === 0
236 ? Effect.succeed(undefined)
237 : discoveryengine
238 .getProjectsLocationsCollectionsDataStores({ name })
239 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
240
241const waitUntilExists = (name: string) =>
242 getByName(name).pipe(
243 Effect.flatMap((store) =>
244 store
245 ? Effect.succeed(store)
246 : Effect.fail(new CollectionsDataStoreNotResolved({ name })),
247 ),
248 Effect.retry({
249 while: (error) =>
250 error._tag === "GCP.DiscoveryEngine.CollectionsDataStoreNotResolved",
251 times: 8,
252 schedule: Schedule.spaced("2 seconds"),
253 }),
254 );
255
256const waitUntilGone = (name: string) =>
257 getByName(name).pipe(
258 Effect.flatMap((store) =>
259 store === undefined
260 ? Effect.void
261 : Effect.fail(new CollectionsDataStoreStillExists({ name })),
262 ),
263 Effect.retry({
264 while: (error) =>
265 error._tag === "GCP.DiscoveryEngine.CollectionsDataStoreStillExists",
266 times: 10,
267 schedule: Schedule.spaced("3 seconds"),
268 }),
269 );
270
271export const CollectionsDataStoreProvider = () =>
272 Provider.succeed(CollectionsDataStore, {
273 stables: [
274 "name",
275 "dataStoreId",
276 "project",
277 "location",
278 "collection",
279 "industryVertical",
280 "contentConfig",
281 "createTime",
282 ],
283
284 diff: Effect.fn(function* ({ news, olds, output }) {
285 if (!isResolved(news)) return undefined;
286 const previousId = olds?.dataStoreId ?? output?.dataStoreId;
287 const nextId = news.dataStoreId ?? previousId;
288 const previousLocation = normalizeLocation(
289 olds?.location ?? output?.location,
290 );
291 const nextLocation = normalizeLocation(
292 news.location ?? olds?.location ?? output?.location,
293 );
294 const previousCollection = normalizeCollection(
295 olds?.collection ?? output?.collection,
296 );
297 const nextCollection = normalizeCollection(
298 news.collection ?? olds?.collection ?? output?.collection,
299 );
300 const previousVertical = verticalOf(
301 olds?.industryVertical ?? output?.industryVertical,
302 );
303 const nextVertical = verticalOf(
304 news.industryVertical ??
305 olds?.industryVertical ??
306 output?.industryVertical,
307 );
308 const previousContent = contentOf(
309 olds?.contentConfig ?? output?.contentConfig,
310 );
311 const nextContent = contentOf(
312 news.contentConfig ?? olds?.contentConfig ?? output?.contentConfig,
313 );
314 const previousAcl = olds?.aclEnabled ?? output?.aclEnabled ?? false;
315 const nextAcl = news.aclEnabled ?? previousAcl;
316 const replace =
317 (previousId !== undefined &&
318 nextId !== undefined &&
319 nextId !== previousId) ||
320 previousLocation !== nextLocation ||
321 previousCollection !== nextCollection ||
322 previousVertical !== nextVertical ||
323 previousContent !== nextContent ||
324 previousAcl !== nextAcl;
325 if (!replace) return undefined;
326 return {
327 action: "replace" as const,
328 deleteFirst:
329 previousLocation === nextLocation &&
330 previousCollection === nextCollection &&
331 previousId !== undefined &&
332 nextId === previousId,
333 };
334 }),
335
336 read: Effect.fn(function* ({ id, olds, output }) {
337 const env = yield* GcpEnvironment.current;
338 const dataStoreId = yield* toResourceId(
339 id,
340 olds?.dataStoreId,
341 output?.dataStoreId,
342 );
343 const location = normalizeLocation(olds?.location ?? output?.location);
344 const collection = normalizeCollection(
345 olds?.collection ?? output?.collection,
346 );
347 const existing = yield* getByName(
348 output?.name ??
349 dataStoreName(env.project, location, collection, dataStoreId),
350 );
351 if (existing === undefined) return undefined;
352 const attrs = toAttrs(existing, env.project);
353 // No labels field: without prior state it may not be ours.
354 return output !== undefined ? attrs : Unowned(attrs);
355 }),
356
357 reconcile: Effect.fn(function* ({ id, news, output }) {
358 const env = yield* GcpEnvironment.current;
359 const location = normalizeLocation(news.location ?? output?.location);
360 const collection = normalizeCollection(
361 news.collection ?? output?.collection,
362 );
363 const dataStoreId = yield* toResourceId(
364 id,
365 news.dataStoreId,
366 output?.dataStoreId,
367 );
368 const name = dataStoreName(
369 env.project,
370 location,
371 collection,
372 dataStoreId,
373 );
374 const displayName = news.displayName ?? dataStoreId;
375 const industryVertical = verticalOf(news.industryVertical);
376 const contentConfig = contentOf(news.contentConfig);
377 const solutionTypes = solutionsOf(news.solutionTypes);
378 const desiredDisabled = news.disabledForServing === true;
379
380 let current = yield* getByName(output?.name ?? name);
381
382 if (current === undefined) {
383 const created = yield* discoveryengine
384 .createProjectsLocationsCollectionsDataStores({
385 parent: collectionParent(env.project, location, collection),
386 dataStoreId,
387 skipDefaultSchemaCreation: news.skipDefaultSchemaCreation,
388 createAdvancedSiteSearch: news.createAdvancedSiteSearch,
389 disableCmek: news.disableCmek !== false,
390 body: {
391 displayName,
392 industryVertical,
393 contentConfig,
394 solutionTypes,
395 aclEnabled: news.aclEnabled === true ? true : undefined,
396 kmsKeyName: news.kmsKeyName,
397 startingSchema: news.startingSchema,
398 servingConfigDataStore:
399 news.disabledForServing !== undefined
400 ? { disabledForServing: desiredDisabled }
401 : undefined,
402 },
403 })
404 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
405 if (created !== undefined) {
406 yield* waitForOperation(created);
407 }
408 current = yield* waitUntilExists(name);
409 }
410
411 if (current === undefined) {
412 return yield* new CollectionsDataStoreNotResolved({ name });
413 }
414
415 const resource = current.name ?? name;
416 const displayNameChanged = (current.displayName ?? "") !== displayName;
417 const observedDisabled =
418 current.servingConfigDataStore?.disabledForServing === true;
419 const servingChanged =
420 news.disabledForServing !== undefined &&
421 observedDisabled !== desiredDisabled;
422
423 if (displayNameChanged || servingChanged) {
424 current =
425 yield* discoveryengine.patchProjectsLocationsCollectionsDataStores({
426 name: resource,
427 updateMask: [
428 displayNameChanged ? "display_name" : undefined,
429 servingChanged
430 ? "serving_config_data_store.disabled_for_serving"
431 : undefined,
432 ]
433 .filter((field): field is string => field !== undefined)
434 .join(","),
435 body: {
436 name: resource,
437 displayName,
438 servingConfigDataStore: servingChanged
439 ? { disabledForServing: desiredDisabled }
440 : current.servingConfigDataStore,
441 },
442 });
443 }
444
445 return toAttrs(current, env.project);
446 }),
447
448 delete: Effect.fn(function* ({ output }) {
449 const existing = yield* getByName(output.name);
450 if (existing === undefined) return;
451 const operation = yield* discoveryengine
452 .deleteProjectsLocationsCollectionsDataStores({ name: output.name })
453 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
454 if (operation !== undefined) {
455 yield* waitForOperation(operation, { notFoundOk: true });
456 }
457 yield* waitUntilGone(output.name);
458 }),
459 });