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 findSearchApplicationByDisplayName,
12 getSearchApplication,
13 ignoreMissing,
14 jsonEqual,
15 MAX_DISPLAY_NAME_LENGTH,
16 operationResourceName,
17 sameBoolean,
18 sameText,
19 searchApplicationIdOf,
20 toGeneratedName,
21 toSearchApplicationName,
22 updateMaskOf,
23 waitForOperation,
24 waitUntilExists,
25} from "./internal.ts";
26
27export type DataSourceRestriction = cloudsearch.DataSourceRestriction;
28export type SortOptions = cloudsearch.SortOptions;
29export type QueryInterpretationConfig = cloudsearch.QueryInterpretationConfig;
30export type ScoringConfig = cloudsearch.ScoringConfig;
31export type SourceConfig = cloudsearch.SourceConfig;
32export type FacetOptions = cloudsearch.FacetOptions;
33
34export type SettingsSearchapplicationProps = {
35 /**
36 * Search application id (`{application_id}` or
37 * `searchapplications/{application_id}`). Server assigned on create.
38 * Immutable — changing it replaces the application.
39 */
40 searchApplicationId?: string;
41 /**
42 * Display name (max 300 characters). Without state Alchemy finds the
43 * application by this name.
44 * @default a unique name generated from the stack, stage, and logical id
45 */
46 displayName?: string;
47 /**
48 * Enable audit logging for query APIs.
49 * @default false
50 */
51 enableAuditLog?: boolean;
52 /**
53 * Return thumbnail URIs with search results when available.
54 * @default false
55 */
56 returnResultThumbnailUrls?: boolean;
57 /**
58 * Sources this application may search (max 10).
59 */
60 dataSourceRestrictions?: DataSourceRestriction[];
61 /**
62 * Default sort for search results.
63 */
64 defaultSortOptions?: SortOptions;
65 /**
66 * Default query-interpretation options.
67 */
68 queryInterpretationConfig?: QueryInterpretationConfig;
69 /**
70 * Ranking configuration.
71 */
72 scoringConfig?: ScoringConfig;
73 /**
74 * Per-source scoring and crowding configuration.
75 */
76 sourceConfig?: SourceConfig[];
77 /**
78 * Default facet fields. Sources listed here must also appear in
79 * `dataSourceRestrictions`.
80 */
81 defaultFacetOptions?: FacetOptions[];
82};
83
84export type SettingsSearchapplication = Resource<
85 "GCP.CloudSearch.SettingsSearchapplication",
86 SettingsSearchapplicationProps,
87 {
88 /** Full resource name `searchapplications/{application_id}`. */
89 name: string;
90 /** Search application id (last path segment). */
91 searchApplicationId: string;
92 /** Project id used when the application was reconciled. */
93 project: string;
94 /** User-facing display name with the Alchemy prefix stripped. */
95 displayName: string | undefined;
96 /** Whether query audit logging is enabled. */
97 enableAuditLog: boolean;
98 /** Whether result thumbnail URIs are returned. */
99 returnResultThumbnailUrls: boolean;
100 /** Restricted data sources. */
101 dataSourceRestrictions: DataSourceRestriction[];
102 /** Default sort options. */
103 defaultSortOptions: SortOptions | undefined;
104 /** Query interpretation config. */
105 queryInterpretationConfig: QueryInterpretationConfig | undefined;
106 /** Scoring config. */
107 scoringConfig: ScoringConfig | undefined;
108 /** Per-source config. */
109 sourceConfig: SourceConfig[];
110 /** Default facet options. */
111 defaultFacetOptions: FacetOptions[];
112 /** Server-reported in-flight operation ids. */
113 operationIds: string[];
114 },
115 never,
116 Providers
117>;
118
119/**
120 * A Cloud Search search application.
121 *
122 * Search applications have no labels field, so without state Alchemy finds
123 * one by `displayName` and reports it unowned unless the name is
124 * generated. The server-assigned application id is identity — changing
125 * `searchApplicationId` replaces the application. Display name, audit
126 * logging, sources, scoring, and facet options update in place. Admin
127 * credentials are required.
128 *
129 * ### Creating a Search Application
130 * **Example:** Generated name
131 * ```typescript
132 * const app = yield* GCP.CloudSearch.SettingsSearchapplication(
133 * "Intranet",
134 * {},
135 * );
136 * ```
137 *
138 * **Example:** Named application with audit logging
139 * ```typescript
140 * const app = yield* GCP.CloudSearch.SettingsSearchapplication(
141 * "Intranet",
142 * {
143 * displayName: "Intranet",
144 * enableAuditLog: true,
145 * },
146 * );
147 * ```
148 *
149 * ### Updating a Search Application
150 * Change props on the same logical id; the engine keeps the physical id.
151 *
152 * **Example:** Rename and enable result thumbnails
153 * ```typescript
154 * const app = yield* GCP.CloudSearch.SettingsSearchapplication(
155 * "Intranet",
156 * {
157 * displayName: "Company search",
158 * returnResultThumbnailUrls: true,
159 * },
160 * );
161 * ```
162 *
163 * @resource
164 * @category CloudSearch
165 */
166export const SettingsSearchapplication = Resource<SettingsSearchapplication>(
167 "GCP.CloudSearch.SettingsSearchapplication",
168);
169
170export class SettingsSearchapplicationNotResolved extends Data.TaggedError(
171 "GCP.CloudSearch.SettingsSearchapplicationNotResolved",
172)<{
173 name: string;
174}> {}
175
176const toAttrs = (app: cloudsearch.SearchApplication, project: string) => {
177 const name = app.name ?? "";
178 return {
179 name,
180 searchApplicationId: searchApplicationIdOf(name),
181 project,
182 displayName: app.displayName,
183 enableAuditLog: app.enableAuditLog === true,
184 returnResultThumbnailUrls: app.returnResultThumbnailUrls === true,
185 dataSourceRestrictions: app.dataSourceRestrictions ?? [],
186 defaultSortOptions: app.defaultSortOptions,
187 queryInterpretationConfig: app.queryInterpretationConfig,
188 scoringConfig: app.scoringConfig,
189 sourceConfig: app.sourceConfig ?? [],
190 defaultFacetOptions: app.defaultFacetOptions ?? [],
191 operationIds: app.operationIds ?? [],
192 };
193};
194
195const lookupName = (
196 searchApplicationId: string | undefined,
197 existingName: string | undefined,
198) => {
199 if (searchApplicationId !== undefined && searchApplicationId.length > 0) {
200 return toSearchApplicationName(searchApplicationId);
201 }
202 if (existingName !== undefined && existingName.length > 0) {
203 return toSearchApplicationName(existingName);
204 }
205 return "";
206};
207
208export const SettingsSearchapplicationProvider = () =>
209 Provider.succeed(SettingsSearchapplication, {
210 stables: ["name", "searchApplicationId", "project"],
211
212 diff: Effect.fn(function* ({ news, olds, output }) {
213 if (!isResolved(news)) return undefined;
214 const previous =
215 olds?.searchApplicationId ??
216 output?.searchApplicationId ??
217 output?.name;
218 if (
219 previous !== undefined &&
220 news.searchApplicationId !== undefined &&
221 toSearchApplicationName(news.searchApplicationId) !==
222 toSearchApplicationName(previous) &&
223 news.searchApplicationId !== output?.searchApplicationId &&
224 toSearchApplicationName(news.searchApplicationId) !== output?.name
225 ) {
226 return { action: "replace" as const, deleteFirst: false };
227 }
228 return undefined;
229 }),
230
231 read: Effect.fn(function* ({ id, olds, output }) {
232 const env = yield* GcpEnvironment.current;
233 const name = lookupName(
234 olds?.searchApplicationId ?? output?.searchApplicationId,
235 output?.name,
236 );
237 let existing = yield* getSearchApplication(name);
238 if (existing !== undefined) return toAttrs(existing, env.project);
239 if (olds === undefined) return undefined;
240 const generated = yield* toGeneratedName(id, undefined, undefined);
241 const displayName = olds.displayName ?? generated;
242 const found = yield* findSearchApplicationByDisplayName(displayName);
243 if (found === undefined) return undefined;
244 const attrs = toAttrs(found, env.project);
245 // A generated display name is unique to this stack, stage and id.
246 return displayName === generated ? attrs : Unowned(attrs);
247 }),
248
249 reconcile: Effect.fn(function* ({ id, news, output }) {
250 const env = yield* GcpEnvironment.current;
251 const displayName = (yield* toGeneratedName(
252 id,
253 news.displayName,
254 output?.displayName,
255 )).slice(0, MAX_DISPLAY_NAME_LENGTH);
256 const name = lookupName(news.searchApplicationId, output?.name);
257 const desiredEnableAuditLog = news.enableAuditLog === true;
258 const desiredThumbnails = news.returnResultThumbnailUrls === true;
259 const desiredRestrictions = news.dataSourceRestrictions ?? [];
260 const desiredSourceConfig = news.sourceConfig ?? [];
261 const desiredFacets = news.defaultFacetOptions ?? [];
262 const desired: cloudsearch.SearchApplication = {
263 displayName,
264 enableAuditLog: desiredEnableAuditLog,
265 returnResultThumbnailUrls: desiredThumbnails,
266 dataSourceRestrictions: desiredRestrictions,
267 defaultSortOptions: news.defaultSortOptions,
268 queryInterpretationConfig: news.queryInterpretationConfig,
269 scoringConfig: news.scoringConfig,
270 sourceConfig: desiredSourceConfig,
271 defaultFacetOptions: desiredFacets,
272 };
273
274 let current = yield* getSearchApplication(name);
275 if (current === undefined) {
276 current = yield* findSearchApplicationByDisplayName(displayName);
277 }
278
279 if (current === undefined) {
280 const created = yield* cloudsearch
281 .createSettingsSearchapplications({ body: desired })
282 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
283 if (created !== undefined) {
284 const done = yield* waitForOperation(created);
285 const createdName = operationResourceName(done);
286 if (createdName !== undefined) {
287 current = yield* waitUntilExists(
288 getSearchApplication(createdName),
289 createdName,
290 ).pipe(
291 Effect.catchTag("GCP.CloudSearch.ResourceNotResolved", () =>
292 Effect.succeed(undefined),
293 ),
294 );
295 }
296 }
297 if (current === undefined) {
298 current = yield* findSearchApplicationByDisplayName(displayName);
299 }
300 }
301
302 if (current === undefined) {
303 return yield* new SettingsSearchapplicationNotResolved({
304 name: name || displayName,
305 });
306 }
307
308 const resourceName = current.name ?? name;
309 const displayChanged = !sameText(current.displayName, displayName);
310 const auditChanged = !sameBoolean(
311 current.enableAuditLog,
312 desiredEnableAuditLog,
313 );
314 const thumbnailsChanged = !sameBoolean(
315 current.returnResultThumbnailUrls,
316 desiredThumbnails,
317 );
318 const restrictionsChanged = !jsonEqual(
319 current.dataSourceRestrictions ?? [],
320 desiredRestrictions,
321 );
322 const sortChanged = !jsonEqual(
323 current.defaultSortOptions,
324 news.defaultSortOptions,
325 );
326 const interpretationChanged = !jsonEqual(
327 current.queryInterpretationConfig,
328 news.queryInterpretationConfig,
329 );
330 const scoringChanged = !jsonEqual(
331 current.scoringConfig,
332 news.scoringConfig,
333 );
334 const sourceConfigChanged = !jsonEqual(
335 current.sourceConfig ?? [],
336 desiredSourceConfig,
337 );
338 const facetsChanged = !jsonEqual(
339 current.defaultFacetOptions ?? [],
340 desiredFacets,
341 );
342
343 const updateMask = updateMaskOf(
344 displayChanged ? "displayName" : undefined,
345 auditChanged ? "enableAuditLog" : undefined,
346 thumbnailsChanged ? "returnResultThumbnailUrls" : undefined,
347 restrictionsChanged ? "dataSourceRestrictions" : undefined,
348 sortChanged ? "defaultSortOptions" : undefined,
349 interpretationChanged ? "queryInterpretationConfig" : undefined,
350 scoringChanged ? "scoringConfig" : undefined,
351 sourceConfigChanged ? "sourceConfig" : undefined,
352 facetsChanged ? "defaultFacetOptions" : undefined,
353 );
354
355 if (updateMask.length > 0) {
356 const patched = yield* cloudsearch.patchSettingsSearchapplications({
357 name: toSearchApplicationName(resourceName),
358 updateMask,
359 body: desired,
360 });
361 yield* waitForOperation(patched);
362 const refreshed = yield* waitUntilExists(
363 getSearchApplication(resourceName),
364 resourceName,
365 );
366 current = refreshed;
367 }
368
369 return toAttrs(current, env.project);
370 }),
371
372 delete: Effect.fn(function* ({ output }) {
373 if (output.name.length === 0 && output.searchApplicationId.length === 0) {
374 return;
375 }
376 const name = toSearchApplicationName(
377 output.name || output.searchApplicationId,
378 );
379 const operation = yield* cloudsearch
380 .deleteSettingsSearchapplications({ name })
381 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
382 if (operation !== undefined) {
383 yield* waitForOperation(operation, { notFoundOk: true });
384 }
385 yield* ignoreMissing(
386 cloudsearch.deleteSettingsSearchapplications({ name }),
387 );
388 }),
389 });