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 * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 parentOf,
14 parseResourceName,
15 siteSearchEngineParent,
16 matchesUriPattern,
17} from "./internal.ts";
18import { waitForOperation } from "./operations.ts";
19
20export type CollectionsDataStoresSiteSearchEngineTargetSiteProps = {
21 /**
22 * Parent data store resource name (must use `PUBLIC_WEBSITE` content
23 * config). Immutable — changing it replaces the target site.
24 */
25 dataStore: string;
26 /**
27 * User-provided URI pattern used to generate the crawl pattern. Required.
28 */
29 providedUriPattern: string;
30 /**
31 * Whether the site is included or excluded.
32 * @default "INCLUDE"
33 */
34 type?: "TYPE_UNSPECIFIED" | "INCLUDE" | "EXCLUDE" | (string & {});
35 /**
36 * Exact-match URI pattern generation. Immutable after create.
37 * @default false
38 */
39 exactMatch?: boolean;
40};
41
42export type CollectionsDataStoresSiteSearchEngineTargetSite = Resource<
43 "GCP.DiscoveryEngine.CollectionsDataStoresSiteSearchEngineTargetSite",
44 CollectionsDataStoresSiteSearchEngineTargetSiteProps,
45 {
46 /** Full resource name. */
47 name: string;
48 /** System-generated target site id. */
49 targetSiteId: string;
50 /** Parent data store resource name. */
51 dataStore: string;
52 /** Project id. */
53 project: string;
54 /** Location id. */
55 location: string;
56 /** User-provided URI pattern. */
57 providedUriPattern: string;
58 /** Generated URI pattern. */
59 generatedUriPattern: string | undefined;
60 /** Include or exclude. */
61 type: string | undefined;
62 /** Exact-match flag. */
63 exactMatch: boolean;
64 /** Indexing status. */
65 indexingStatus: string | undefined;
66 /** Root domain of the provided URI pattern. */
67 rootDomainUri: string | undefined;
68 /** RFC3339 last-update timestamp. */
69 updateTime: string | undefined;
70 },
71 never,
72 Providers
73>;
74
75/**
76 * A Discovery Engine target site on a collection data store site-search
77 * engine.
78 *
79 * Its id is assigned by the API, so only a recorded resource can be read
80 * back. Parent data store and `exactMatch` are immutable. Type updates in
81 * place. The target site id is assigned by the API.
82 *
83 * ### Creating a Target Site
84 * **Example:** Include a site
85 * ```typescript
86 * const store = yield* GCP.DiscoveryEngine.CollectionsDataStore("Web", {
87 * contentConfig: "PUBLIC_WEBSITE",
88 * });
89 * const site = yield* GCP.DiscoveryEngine.CollectionsDataStoresSiteSearchEngineTargetSite(
90 * "Docs",
91 * {
92 * dataStore: store.name,
93 * providedUriPattern: "https://example.com/docs/",
94 * },
95 * );
96 * ```
97 *
98 * @resource
99 * @category DiscoveryEngine
100 */
101export const CollectionsDataStoresSiteSearchEngineTargetSite =
102 Resource<CollectionsDataStoresSiteSearchEngineTargetSite>(
103 "GCP.DiscoveryEngine.CollectionsDataStoresSiteSearchEngineTargetSite",
104 );
105
106export class CollectionsDataStoresSiteSearchEngineTargetSiteNotResolved extends Data.TaggedError(
107 "GCP.DiscoveryEngine.CollectionsDataStoresSiteSearchEngineTargetSiteNotResolved",
108)<{
109 name: string;
110}> {}
111
112export class CollectionsDataStoresSiteSearchEngineTargetSiteStillExists extends Data.TaggedError(
113 "GCP.DiscoveryEngine.CollectionsDataStoresSiteSearchEngineTargetSiteStillExists",
114)<{
115 name: string;
116}> {}
117
118const toAttrs = (
119 site: discoveryengine.GoogleCloudDiscoveryengineV1TargetSite,
120 project: string,
121 pattern?: string,
122) => {
123 const name = site.name ?? "";
124 const parsed = parseResourceName(name, "targetSites");
125 return {
126 name,
127 targetSiteId: parsed.id,
128 dataStore: parentOf(name, "siteSearchEngine"),
129 project: parsed.project || project,
130 location: parsed.location,
131 providedUriPattern:
132 site.providedUriPattern ?? pattern ?? site.generatedUriPattern ?? "",
133 generatedUriPattern: site.generatedUriPattern,
134 type: site.type,
135 exactMatch: site.exactMatch === true,
136 indexingStatus: site.indexingStatus,
137 rootDomainUri: site.rootDomainUri,
138 updateTime: site.updateTime,
139 };
140};
141
142const getByName = (name: string) =>
143 name.length === 0
144 ? Effect.succeed(undefined)
145 : discoveryengine
146 .getProjectsLocationsCollectionsDataStoresSiteSearchEngineTargetSites({
147 name,
148 })
149 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
150
151const listAtParent = (parent: string) =>
152 discoveryengine.listProjectsLocationsCollectionsDataStoresSiteSearchEngineTargetSites
153 .pages({ parent, pageSize: 1000 })
154 .pipe(
155 Stream.flatMap((page) => Stream.fromIterable(page.targetSites ?? [])),
156 Stream.runCollect,
157 Effect.map((chunk) => Array.from(chunk)),
158 Effect.catchTag("NotFound", () => Effect.succeed([])),
159 );
160
161/** Target sites are identified by their URI pattern within a data store. */
162const findByPattern = (dataStore: string, pattern: string, hinted?: string) =>
163 Effect.gen(function* () {
164 if (hinted !== undefined && hinted.length > 0) {
165 const existing = yield* getByName(hinted);
166 if (existing !== undefined) return existing;
167 }
168 const sites = yield* listAtParent(siteSearchEngineParent(dataStore));
169 return sites.find((site) => matchesUriPattern(site, pattern));
170 });
171
172const waitUntilExists = (name: string) =>
173 getByName(name).pipe(
174 Effect.flatMap((site) =>
175 site
176 ? Effect.succeed(site)
177 : Effect.fail(
178 new CollectionsDataStoresSiteSearchEngineTargetSiteNotResolved({
179 name,
180 }),
181 ),
182 ),
183 Effect.retry({
184 while: (error) =>
185 error._tag ===
186 "GCP.DiscoveryEngine.CollectionsDataStoresSiteSearchEngineTargetSiteNotResolved",
187 times: 8,
188 schedule: Schedule.spaced("2 seconds"),
189 }),
190 );
191
192const waitUntilGone = (name: string) =>
193 getByName(name).pipe(
194 Effect.flatMap((site) =>
195 site === undefined
196 ? Effect.void
197 : Effect.fail(
198 new CollectionsDataStoresSiteSearchEngineTargetSiteStillExists({
199 name,
200 }),
201 ),
202 ),
203 Effect.retry({
204 while: (error) =>
205 error._tag ===
206 "GCP.DiscoveryEngine.CollectionsDataStoresSiteSearchEngineTargetSiteStillExists",
207 times: 10,
208 schedule: Schedule.spaced("3 seconds"),
209 }),
210 );
211
212export const CollectionsDataStoresSiteSearchEngineTargetSiteProvider = () =>
213 Provider.succeed(CollectionsDataStoresSiteSearchEngineTargetSite, {
214 stables: [
215 "name",
216 "targetSiteId",
217 "dataStore",
218 "project",
219 "location",
220 "exactMatch",
221 ],
222
223 diff: Effect.fn(function* ({ news, olds, output }) {
224 if (!isResolved(news)) return undefined;
225 const previousParent = olds?.dataStore ?? output?.dataStore;
226 const previousExact = olds?.exactMatch ?? output?.exactMatch ?? false;
227 const nextExact = news.exactMatch ?? previousExact;
228 if (
229 (previousParent !== undefined && news.dataStore !== previousParent) ||
230 previousExact !== nextExact
231 ) {
232 return { action: "replace" as const, deleteFirst: false };
233 }
234 return undefined;
235 }),
236
237 read: Effect.fn(function* ({ olds, output }) {
238 const env = yield* GcpEnvironment.current;
239 const dataStore = olds?.dataStore ?? output?.dataStore;
240 const pattern = olds?.providedUriPattern ?? output?.providedUriPattern;
241 const existing =
242 output?.name !== undefined
243 ? yield* getByName(output.name)
244 : dataStore !== undefined && pattern !== undefined
245 ? yield* findByPattern(dataStore, pattern)
246 : undefined;
247 if (existing === undefined) return undefined;
248 const attrs = toAttrs(existing, env.project, pattern);
249 // No labels field: without prior state it may not be ours.
250 return output !== undefined ? attrs : Unowned(attrs);
251 }),
252
253 reconcile: Effect.fn(function* ({ news, output }) {
254 const env = yield* GcpEnvironment.current;
255 const providedUriPattern = news.providedUriPattern;
256 const type = news.type ?? "INCLUDE";
257 const exactMatch = news.exactMatch === true;
258 const parent = siteSearchEngineParent(news.dataStore);
259
260 let current = yield* findByPattern(
261 news.dataStore,
262 providedUriPattern,
263 output?.name,
264 );
265
266 if (current === undefined) {
267 const created = yield* discoveryengine
268 .createProjectsLocationsCollectionsDataStoresSiteSearchEngineTargetSites(
269 {
270 parent,
271 body: {
272 providedUriPattern,
273 type,
274 exactMatch: exactMatch ? true : undefined,
275 },
276 },
277 )
278 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
279 if (created !== undefined) {
280 yield* waitForOperation(created);
281 }
282 current = yield* findByPattern(news.dataStore, providedUriPattern);
283 }
284
285 if (current === undefined) {
286 return yield* new CollectionsDataStoresSiteSearchEngineTargetSiteNotResolved(
287 { name: output?.name ?? `${parent}/targetSites/-` },
288 );
289 }
290
291 const resource = current.name ?? "";
292 const typeChanged = (current.type ?? "INCLUDE") !== type;
293 const patternChanged = !matchesUriPattern(current, providedUriPattern);
294
295 if (typeChanged || patternChanged) {
296 const patched =
297 yield* discoveryengine.patchProjectsLocationsCollectionsDataStoresSiteSearchEngineTargetSites(
298 {
299 name: resource,
300 body: {
301 name: resource,
302 providedUriPattern,
303 type,
304 exactMatch: current.exactMatch,
305 },
306 },
307 );
308 yield* waitForOperation(patched);
309 current = yield* waitUntilExists(resource);
310 }
311
312 return toAttrs(current, env.project, providedUriPattern);
313 }),
314
315 delete: Effect.fn(function* ({ output }) {
316 const existing = yield* getByName(output.name);
317 if (existing === undefined) return;
318 const operation = yield* discoveryengine
319 .deleteProjectsLocationsCollectionsDataStoresSiteSearchEngineTargetSites(
320 { name: output.name },
321 )
322 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
323 if (operation !== undefined) {
324 yield* waitForOperation(operation, { notFoundOk: true });
325 }
326 yield* waitUntilGone(output.name);
327 }),
328 });