1import * as discoveryengine from "@distilled.cloud/gcp/discoveryengine_v1";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import { createInternalLabels } from "../Labels.ts";
6
7export const DEFAULT_LOCATION = "global";
8export const DEFAULT_COLLECTION = "default_collection";
9export const DEFAULT_BRANCH = "default_branch";
10export const LIST_LOCATIONS = ["global"] as const;
11export const MAX_ID_LENGTH = 63;
12export const MAX_NAME_LENGTH = MAX_ID_LENGTH;
13export const MAX_DOCUMENT_ID_LENGTH = 128;
14export const MAX_DISPLAY_NAME_LENGTH = 128;
15
16export const lastSegment = (value: string) => {
17 const trimmed = value.replace(/\/+$/, "");
18 const parts = trimmed.split("/");
19 return parts[parts.length - 1] || trimmed;
20};
21
22export const normalizeLocation = (location: string | undefined) =>
23 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
24
25export const locationParent = (project: string, location: string) =>
26 `projects/${project}/locations/${location}`;
27
28export const parentOf = (name: string, collection?: string) => {
29 if (collection !== undefined) {
30 const marker = `/${collection}/`;
31 const index = name.lastIndexOf(marker);
32 return index >= 0 ? name.slice(0, index) : name;
33 }
34 const parts = name.split("/").filter((part) => part.length > 0);
35 return parts.slice(0, -2).join("/");
36};
37
38export const parentBefore = parentOf;
39
40export const parseResourceName = (name: string, collection: string) => {
41 const parts = name.split("/").filter((part) => part.length > 0);
42 const collectionAt = parts.lastIndexOf(collection);
43 const locationsAt = parts.lastIndexOf("locations");
44 const projectsAt = parts.lastIndexOf("projects");
45 const dataStoresAt = parts.lastIndexOf("dataStores");
46 const collectionsAt = parts.lastIndexOf("collections");
47 const collectionId =
48 collectionsAt >= 0 && parts[collectionsAt + 1]
49 ? parts[collectionsAt + 1]!
50 : DEFAULT_COLLECTION;
51 return {
52 project:
53 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
54 location:
55 locationsAt >= 0 && parts[locationsAt + 1]
56 ? parts[locationsAt + 1]!
57 : DEFAULT_LOCATION,
58 collection: collectionId,
59 collectionId,
60 id:
61 collectionAt >= 0 && parts[collectionAt + 1]
62 ? parts[collectionAt + 1]!
63 : lastSegment(name),
64 dataStoreId:
65 dataStoresAt >= 0 && parts[dataStoresAt + 1]
66 ? parts[dataStoresAt + 1]!
67 : "",
68 dataStore:
69 dataStoresAt >= 0
70 ? parts.slice(0, dataStoresAt + 2).join("/")
71 : parentOf(name),
72 parent:
73 collectionAt > 0
74 ? parts.slice(0, collectionAt).join("/")
75 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
76 };
77};
78
79export const expandDataStore = (
80 value: string,
81 project: string,
82 location: string,
83) =>
84 value.includes("/")
85 ? value
86 : `projects/${project}/locations/${location}/collections/${DEFAULT_COLLECTION}/dataStores/${value}`;
87
88export const rfc1035 = (name: string, maxLength = MAX_ID_LENGTH): string => {
89 let next = name
90 .toLowerCase()
91 .replace(/[^a-z0-9-]/g, "-")
92 .replace(/-+/g, "-")
93 .replace(/^-+|-+$/g, "");
94 if (!/^[a-z]/.test(next)) next = `a${next}`;
95 next = next.slice(0, maxLength).replace(/-+$/g, "");
96 if (next.length === 0) return "resource";
97 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, maxLength - 1)}0`;
98 return next.slice(0, maxLength);
99};
100
101export const controlIdOf = (
102 name: string,
103 maxLength = MAX_ID_LENGTH,
104): string => {
105 let next = name
106 .toLowerCase()
107 .replace(/[0-9]/g, (digit) => "abcdefghij"[Number(digit)]!)
108 .replace(/[^a-z_-]/g, "-")
109 .replace(/-+/g, "-")
110 .replace(/^-+|-+$/g, "");
111 if (!/^[a-z]/.test(next)) next = `c${next}`;
112 next = next.slice(0, maxLength).replace(/[-_]+$/g, "");
113 return next.length > 0 ? next : "control";
114};
115
116export const servingConfigIdOf = (
117 name: string,
118 maxLength = MAX_ID_LENGTH,
119): string => {
120 let next = name.toLowerCase().replace(/[^a-z0-9]/g, "");
121 if (!/^[a-z]/.test(next)) next = `s${next}`;
122 next = next.slice(0, maxLength);
123 if (next.length < 4) next = `${next}xxxx`.slice(0, 4);
124 return next.length > 0 ? next : "scfg";
125};
126
127export const sessionIdOf = (
128 name: string,
129 maxLength = MAX_ID_LENGTH,
130): string => {
131 let next = name.toLowerCase().replace(/[^a-z0-9]/g, "");
132 if (!/^[a-z]/.test(next)) next = `s${next}`;
133 next = next.slice(0, maxLength);
134 return next.length > 0 ? next : "session";
135};
136
137export const identityMappingStoreIdOf = (name: string): string => {
138 const body = rfc1035(name, 59);
139 const prefixed = body.startsWith("alch") ? body : `alch${body}`;
140 return prefixed.slice(0, MAX_ID_LENGTH);
141};
142
143export const toPhysical = (
144 id: string,
145 explicit: string | undefined,
146 existing: string | undefined,
147 format: (name: string) => string,
148 maxLength = MAX_ID_LENGTH,
149) =>
150 Effect.gen(function* () {
151 if (explicit !== undefined) return explicit;
152 if (existing !== undefined) return existing;
153 return format(
154 yield* createPhysicalName({
155 id,
156 maxLength,
157 lowercase: true,
158 }),
159 );
160 });
161
162export const internalLabels = (id: string) => createInternalLabels(id);
163
164export const canonical = (value: unknown): unknown => {
165 if (value === undefined || value === null) return undefined;
166 if (typeof value === "boolean" || typeof value === "number") return value;
167 if (typeof value === "string") {
168 return value.length === 0 ? undefined : value;
169 }
170 if (Array.isArray(value)) {
171 const items = value.map(canonical).filter((item) => item !== undefined);
172 return items.length === 0 ? undefined : items;
173 }
174 if (typeof value === "object") {
175 const entries = Object.entries(value as Record<string, unknown>)
176 .map(([key, item]) => [key, canonical(item)] as const)
177 .filter(([, item]) => item !== undefined)
178 .sort(([left], [right]) => left.localeCompare(right));
179 if (entries.length === 0) return undefined;
180 return Object.fromEntries(entries);
181 }
182 return undefined;
183};
184
185export const fingerprint = (value: unknown): string =>
186 JSON.stringify(canonical(value) ?? null);
187
188export const sameStringList = (
189 left: readonly string[] | undefined,
190 right: readonly string[] | undefined,
191) =>
192 fingerprint([...(left ?? [])].sort()) ===
193 fingerprint([...(right ?? [])].sort());
194
195export const parseJsonObject = (
196 json: string | undefined,
197): Record<string, unknown> | undefined => {
198 if (json === undefined || json.length === 0) return undefined;
199 try {
200 const parsed: unknown = JSON.parse(json);
201 if (
202 parsed !== null &&
203 typeof parsed === "object" &&
204 !Array.isArray(parsed)
205 ) {
206 return parsed as Record<string, unknown>;
207 }
208 } catch {
209 return undefined;
210 }
211 return undefined;
212};
213
214/** Parse a JSON Schema document and default its `$schema` version. */
215export const withSchemaVersion = (jsonSchema: string | undefined): string => {
216 const obj = parseJsonObject(jsonSchema) ?? {
217 type: "object",
218 properties: {
219 title: { type: "string" },
220 description: { type: "string" },
221 },
222 };
223 if (obj.$schema === undefined) {
224 obj.$schema = "https://json-schema.org/draft/2020-12/schema";
225 }
226 return JSON.stringify(obj);
227};
228
229export const listDataStores = (project: string) =>
230 Effect.forEach(
231 LIST_LOCATIONS,
232 (location) =>
233 discoveryengine.listProjectsLocationsDataStores
234 .pages({
235 parent: locationParent(project, location),
236 pageSize: 50,
237 })
238 .pipe(
239 Stream.flatMap((page) => Stream.fromIterable(page.dataStores ?? [])),
240 Stream.runCollect,
241 Effect.map((chunk) => Array.from(chunk)),
242 Effect.catchTag("NotFound", () => Effect.succeed([])),
243 ),
244 { concurrency: 2 },
245 ).pipe(Effect.map((groups) => groups.flat()));
246
247export const normalizeCollection = (collection: string | undefined) =>
248 lastSegment(collection ?? DEFAULT_COLLECTION);
249
250export const collectionParent = (
251 project: string,
252 location: string,
253 collectionId: string,
254) => `${locationParent(project, location)}/collections/${collectionId}`;
255
256export const dataStoreName = (
257 project: string,
258 location: string,
259 collectionId: string,
260 dataStoreId: string,
261) =>
262 `${collectionParent(project, location, collectionId)}/dataStores/${dataStoreId}`;
263
264export const dataStoreIdOf = (value: string) => lastSegment(value);
265
266export const servingConfigId = servingConfigIdOf;
267
268export const toResourceId = (
269 id: string,
270 explicit: string | undefined,
271 existing: string | undefined,
272) =>
273 Effect.gen(function* () {
274 if (explicit !== undefined) return rfc1035(explicit);
275 if (existing !== undefined) return existing;
276 return rfc1035(
277 yield* createPhysicalName({
278 id,
279 maxLength: MAX_ID_LENGTH,
280 lowercase: true,
281 }),
282 );
283 });
284
285export const ownershipLabels = (id: string) => createInternalLabels(id);
286
287export const sameJson = (left: unknown, right: unknown) =>
288 fingerprint(left) === fingerprint(right);
289
290export const listProjectDataStores = (project: string) =>
291 Effect.gen(function* () {
292 const seen = new Set<string>();
293 const stores: discoveryengine.GoogleCloudDiscoveryengineV1DataStore[] = [];
294 const locationStores = yield* listDataStores(project);
295 const collectionStores = yield* listCollectionDataStores(project);
296 for (const store of [...locationStores, ...collectionStores]) {
297 const name = store.name ?? "";
298 if (name.length === 0 || seen.has(name)) continue;
299 seen.add(name);
300 stores.push(store);
301 }
302 return stores;
303 });
304
305export const branchParent = (dataStore: string, branchId = DEFAULT_BRANCH) =>
306 `${dataStore}/branches/${branchId}`;
307
308export const siteSearchEngineParent = (dataStore: string) =>
309 `${dataStore}/siteSearchEngine`;
310
311export const listCollectionDataStores = (project: string) =>
312 Effect.forEach(
313 LIST_LOCATIONS,
314 (location) =>
315 discoveryengine.listProjectsLocationsCollectionsDataStores
316 .pages({
317 parent: collectionParent(project, location, DEFAULT_COLLECTION),
318 pageSize: 50,
319 })
320 .pipe(
321 Stream.flatMap((page) => Stream.fromIterable(page.dataStores ?? [])),
322 Stream.runCollect,
323 Effect.map((chunk) => Array.from(chunk)),
324 Effect.catchTag("NotFound", () => Effect.succeed([])),
325 ),
326 { concurrency: 2 },
327 ).pipe(Effect.map((groups) => groups.flat()));
328
329/**
330 * Target sites echo only `generatedUriPattern` (scheme stripped, directory
331 * patterns suffixed with `*`), so compare against that normalized form.
332 */
333const normalizeUriPattern = (pattern: string) => {
334 const bare = pattern.replace(/^https?:\/\//, "");
335 return bare.endsWith("/") ? `${bare}*` : bare;
336};
337
338export const matchesUriPattern = (
339 site: { providedUriPattern?: string; generatedUriPattern?: string },
340 pattern: string,
341) => {
342 const observed = site.providedUriPattern ?? site.generatedUriPattern ?? "";
343 return (
344 observed === pattern ||
345 normalizeUriPattern(observed) === normalizeUriPattern(pattern)
346 );
347};