1import * as cloudsearch from "@distilled.cloud/gcp/cloudsearch_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 datasourceIdOf,
12 findDatasourceByDisplayName,
13 getDatasource,
14 ignoreMissing,
15 jsonEqual,
16 MAX_DISPLAY_NAME_LENGTH,
17 operationResourceName,
18 sameBoolean,
19 sameText,
20 toDatasourceName,
21 toGeneratedName,
22 toShortName,
23 updateMaskOf,
24 waitForOperation,
25 waitUntilExists,
26} from "./internal.ts";
27
28export type GSuitePrincipal = cloudsearch.GSuitePrincipal;
29
30export type SettingsDatasourceProps = {
31 /**
32 * Datasource id (`{source_id}` or `datasources/{source_id}`). Server
33 * assigned on create. Immutable — changing it replaces the datasource.
34 */
35 datasourceId?: string;
36 /**
37 * Display name (max 300 characters). Without state Alchemy finds the
38 * datasource by this name.
39 * @default a unique name generated from the stack, stage, and logical id
40 */
41 displayName?: string;
42 /**
43 * Short operator name used as `source:<value>`. Alphanumeric, max 32
44 * characters, unique across datasources. Cannot start with `google` or
45 * equal a reserved Workspace source (mail, gmail, docs, drive, …).
46 */
47 shortName?: string;
48 /**
49 * Reject Indexing API writes while still serving previously indexed
50 * items.
51 * @default false
52 */
53 disableModifications?: boolean;
54 /**
55 * Disable serving search and assist results from this source.
56 * @default false
57 */
58 disableServing?: boolean;
59 /**
60 * Whether users can request thumbnail URIs for indexed items.
61 * @default false
62 */
63 returnThumbnailUrls?: boolean;
64 /**
65 * Service accounts allowed to index this source.
66 */
67 indexingServiceAccounts?: string[];
68 /**
69 * Datasource-level item visibility (union of users and groups).
70 */
71 itemsVisibility?: GSuitePrincipal[];
72};
73
74export type SettingsDatasource = Resource<
75 "GCP.CloudSearch.SettingsDatasource",
76 SettingsDatasourceProps,
77 {
78 /** Full resource name `datasources/{source_id}`. */
79 name: string;
80 /** Datasource id (last path segment). */
81 datasourceId: string;
82 /** Project id used when the datasource was reconciled. */
83 project: string;
84 /** User-facing display name with the Alchemy prefix stripped. */
85 displayName: string | undefined;
86 /** Short operator name. */
87 shortName: string | undefined;
88 /** Whether indexing writes are rejected. */
89 disableModifications: boolean;
90 /** Whether serving is disabled. */
91 disableServing: boolean;
92 /** Whether thumbnail URIs may be requested. */
93 returnThumbnailUrls: boolean;
94 /** Indexing service accounts. */
95 indexingServiceAccounts: string[];
96 /** Datasource-level visibility. */
97 itemsVisibility: GSuitePrincipal[];
98 /** Server-reported in-flight operation ids. */
99 operationIds: string[];
100 },
101 never,
102 Providers
103>;
104
105/**
106 * A Cloud Search datasource.
107 *
108 * Cloud Search datasources have no labels field, so without state Alchemy
109 * finds one by `displayName` and reports it unowned unless the name is
110 * generated. The server-assigned datasource id is identity — changing
111 * `datasourceId` replaces the source. Display name, short name, serving
112 * flags, indexing accounts, and visibility update in place. Admin
113 * credentials are required.
114 *
115 * ### Creating a Datasource
116 * **Example:** Generated name
117 * ```typescript
118 * const source = yield* GCP.CloudSearch.SettingsDatasource("Wiki", {});
119 * ```
120 *
121 * **Example:** Explicit display name and short name
122 * ```typescript
123 * const source = yield* GCP.CloudSearch.SettingsDatasource("Wiki", {
124 * displayName: "Wiki",
125 * shortName: "wiki",
126 * returnThumbnailUrls: true,
127 * });
128 * ```
129 *
130 * ### Updating a Datasource
131 * Change props on the same logical id; the engine keeps the physical id.
132 *
133 * **Example:** Rename and disable serving
134 * ```typescript
135 * const source = yield* GCP.CloudSearch.SettingsDatasource("Wiki", {
136 * displayName: "Knowledge base",
137 * shortName: "wiki",
138 * disableServing: true,
139 * });
140 * ```
141 *
142 * @resource
143 * @category CloudSearch
144 */
145export const SettingsDatasource = Resource<SettingsDatasource>(
146 "GCP.CloudSearch.SettingsDatasource",
147);
148
149export class SettingsDatasourceNotResolved extends Data.TaggedError(
150 "GCP.CloudSearch.SettingsDatasourceNotResolved",
151)<{
152 name: string;
153}> {}
154
155const toAttrs = (source: cloudsearch.DataSource, project: string) => {
156 const name = source.name ?? "";
157 return {
158 name,
159 datasourceId: datasourceIdOf(name),
160 project,
161 displayName: source.displayName,
162 shortName: source.shortName,
163 disableModifications: source.disableModifications === true,
164 disableServing: source.disableServing === true,
165 returnThumbnailUrls: source.returnThumbnailUrls === true,
166 indexingServiceAccounts: source.indexingServiceAccounts ?? [],
167 itemsVisibility: source.itemsVisibility ?? [],
168 operationIds: source.operationIds ?? [],
169 };
170};
171
172const lookupName = (
173 datasourceId: string | undefined,
174 existingName: string | undefined,
175) => {
176 if (datasourceId !== undefined && datasourceId.length > 0) {
177 return toDatasourceName(datasourceId);
178 }
179 if (existingName !== undefined && existingName.length > 0) {
180 return toDatasourceName(existingName);
181 }
182 return "";
183};
184
185export const SettingsDatasourceProvider = () =>
186 Provider.succeed(SettingsDatasource, {
187 stables: ["name", "datasourceId", "project"],
188
189 diff: Effect.fn(function* ({ news, olds, output }) {
190 if (!isResolved(news)) return undefined;
191 const previous =
192 olds?.datasourceId ?? output?.datasourceId ?? output?.name;
193 if (
194 previous !== undefined &&
195 news.datasourceId !== undefined &&
196 toDatasourceName(news.datasourceId) !== toDatasourceName(previous) &&
197 news.datasourceId !== output?.datasourceId &&
198 toDatasourceName(news.datasourceId) !== output?.name
199 ) {
200 return { action: "replace" as const, deleteFirst: false };
201 }
202 return undefined;
203 }),
204
205 read: Effect.fn(function* ({ id, olds, output }) {
206 const env = yield* GcpEnvironment.current;
207 const name = lookupName(
208 olds?.datasourceId ?? output?.datasourceId,
209 output?.name,
210 );
211 let existing = yield* getDatasource(name);
212 if (existing !== undefined) return toAttrs(existing, env.project);
213 if (olds === undefined) return undefined;
214 const generated = yield* toGeneratedName(id, undefined, undefined);
215 const displayName = olds.displayName ?? generated;
216 const found = yield* findDatasourceByDisplayName(displayName);
217 if (found === undefined) return undefined;
218 const attrs = toAttrs(found, env.project);
219 // A generated display name is unique to this stack, stage and id.
220 return displayName === generated ? attrs : Unowned(attrs);
221 }),
222
223 reconcile: Effect.fn(function* ({ id, news, output }) {
224 const env = yield* GcpEnvironment.current;
225 const displayName = (yield* toGeneratedName(
226 id,
227 news.displayName,
228 output?.displayName,
229 )).slice(0, MAX_DISPLAY_NAME_LENGTH);
230 const name = lookupName(news.datasourceId, output?.name);
231
232 let current = yield* getDatasource(name);
233 if (current === undefined) {
234 current = yield* findDatasourceByDisplayName(displayName);
235 }
236
237 const shortName = yield* toShortName(
238 id,
239 news.shortName,
240 current?.shortName ?? output?.shortName,
241 );
242 const desiredDisableModifications = news.disableModifications === true;
243 const desiredDisableServing = news.disableServing === true;
244 const desiredReturnThumbnailUrls = news.returnThumbnailUrls === true;
245 const desiredAccounts = news.indexingServiceAccounts ?? [];
246 const desiredVisibility = news.itemsVisibility ?? [];
247 const desired: cloudsearch.DataSource = {
248 displayName,
249 shortName,
250 disableModifications: desiredDisableModifications,
251 disableServing: desiredDisableServing,
252 returnThumbnailUrls: desiredReturnThumbnailUrls,
253 indexingServiceAccounts: desiredAccounts,
254 itemsVisibility: desiredVisibility,
255 };
256
257 if (current === undefined) {
258 const created = yield* cloudsearch
259 .createSettingsDatasources({ body: desired })
260 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
261 if (created !== undefined) {
262 const done = yield* waitForOperation(created);
263 const createdName = operationResourceName(done);
264 if (createdName !== undefined) {
265 current = yield* waitUntilExists(
266 getDatasource(createdName),
267 createdName,
268 ).pipe(
269 Effect.catchTag("GCP.CloudSearch.ResourceNotResolved", () =>
270 Effect.succeed(undefined),
271 ),
272 );
273 }
274 }
275 if (current === undefined) {
276 current = yield* findDatasourceByDisplayName(displayName);
277 }
278 }
279
280 if (current === undefined) {
281 return yield* new SettingsDatasourceNotResolved({
282 name: name || displayName,
283 });
284 }
285
286 const resourceName = current.name ?? name;
287 const displayChanged = !sameText(current.displayName, displayName);
288 const shortNameChanged = !sameText(current.shortName, shortName);
289 const modificationsChanged = !sameBoolean(
290 current.disableModifications,
291 desiredDisableModifications,
292 );
293 const servingChanged = !sameBoolean(
294 current.disableServing,
295 desiredDisableServing,
296 );
297 const thumbnailsChanged = !sameBoolean(
298 current.returnThumbnailUrls,
299 desiredReturnThumbnailUrls,
300 );
301 const accountsChanged = !jsonEqual(
302 current.indexingServiceAccounts ?? [],
303 desiredAccounts,
304 );
305 const visibilityChanged = !jsonEqual(
306 current.itemsVisibility ?? [],
307 desiredVisibility,
308 );
309
310 const updateMask = updateMaskOf(
311 displayChanged ? "displayName" : undefined,
312 shortNameChanged ? "shortName" : undefined,
313 modificationsChanged ? "disableModifications" : undefined,
314 servingChanged ? "disableServing" : undefined,
315 thumbnailsChanged ? "returnThumbnailUrls" : undefined,
316 accountsChanged ? "indexingServiceAccounts" : undefined,
317 visibilityChanged ? "itemsVisibility" : undefined,
318 );
319
320 if (updateMask.length > 0) {
321 const patched = yield* cloudsearch.patchSettingsDatasources({
322 name: toDatasourceName(resourceName),
323 updateMask,
324 body: desired,
325 });
326 yield* waitForOperation(patched);
327 const refreshed = yield* waitUntilExists(
328 getDatasource(resourceName),
329 resourceName,
330 );
331 current = refreshed;
332 }
333
334 return toAttrs(current, env.project);
335 }),
336
337 delete: Effect.fn(function* ({ output }) {
338 if (output.name.length === 0 && output.datasourceId.length === 0) {
339 return;
340 }
341 const name = toDatasourceName(output.name || output.datasourceId);
342 const operation = yield* cloudsearch
343 .deleteSettingsDatasources({ name })
344 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
345 if (operation !== undefined) {
346 yield* waitForOperation(operation, { notFoundOk: true });
347 }
348 yield* ignoreMissing(cloudsearch.deleteSettingsDatasources({ name }));
349 }),
350 });