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