| 1 | import * as discoveryengine from "@distilled.cloud/gcp/discoveryengine_v1"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Stream from "effect/Stream"; |
| 4 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 5 | import { createInternalLabels } from "../Labels.ts"; |
| 6 | |
| 7 | export const DEFAULT_LOCATION = "global"; |
| 8 | export const DEFAULT_COLLECTION = "default_collection"; |
| 9 | export const DEFAULT_BRANCH = "default_branch"; |
| 10 | export const LIST_LOCATIONS = ["global"] as const; |
| 11 | export const MAX_ID_LENGTH = 63; |
| 12 | export const MAX_NAME_LENGTH = MAX_ID_LENGTH; |
| 13 | export const MAX_DOCUMENT_ID_LENGTH = 128; |
| 14 | export const MAX_DISPLAY_NAME_LENGTH = 128; |
| 15 | |
| 16 | export const lastSegment = (value: string) => { |
| 17 | const trimmed = value.replace(/\/+$/, ""); |
| 18 | const parts = trimmed.split("/"); |
| 19 | return parts[parts.length - 1] || trimmed; |
| 20 | }; |
| 21 | |
| 22 | export const normalizeLocation = (location: string | undefined) => |
| 23 | lastSegment(location ?? DEFAULT_LOCATION).toLowerCase(); |
| 24 | |
| 25 | export const locationParent = (project: string, location: string) => |
| 26 | `projects/${project}/locations/${location}`; |
| 27 | |
| 28 | export 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 | |
| 38 | export const parentBefore = parentOf; |
| 39 | |
| 40 | export 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 | |
| 79 | export 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 | |
| 88 | export 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 | |
| 101 | export 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 | |
| 116 | export 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 | |
| 127 | export 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 | |
| 137 | export 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 | |
| 143 | export 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 | |
| 162 | export const internalLabels = (id: string) => createInternalLabels(id); |
| 163 | |
| 164 | export 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 | |
| 185 | export const fingerprint = (value: unknown): string => |
| 186 | JSON.stringify(canonical(value) ?? null); |
| 187 | |
| 188 | export const sameStringList = ( |
| 189 | left: readonly string[] | undefined, |
| 190 | right: readonly string[] | undefined, |
| 191 | ) => |
| 192 | fingerprint([...(left ?? [])].sort()) === |
| 193 | fingerprint([...(right ?? [])].sort()); |
| 194 | |
| 195 | export 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. */ |
| 215 | export 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 | |
| 229 | export 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 | |
| 247 | export const normalizeCollection = (collection: string | undefined) => |
| 248 | lastSegment(collection ?? DEFAULT_COLLECTION); |
| 249 | |
| 250 | export const collectionParent = ( |
| 251 | project: string, |
| 252 | location: string, |
| 253 | collectionId: string, |
| 254 | ) => `${locationParent(project, location)}/collections/${collectionId}`; |
| 255 | |
| 256 | export const dataStoreName = ( |
| 257 | project: string, |
| 258 | location: string, |
| 259 | collectionId: string, |
| 260 | dataStoreId: string, |
| 261 | ) => |
| 262 | `${collectionParent(project, location, collectionId)}/dataStores/${dataStoreId}`; |
| 263 | |
| 264 | export const dataStoreIdOf = (value: string) => lastSegment(value); |
| 265 | |
| 266 | export const servingConfigId = servingConfigIdOf; |
| 267 | |
| 268 | export 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 | |
| 285 | export const ownershipLabels = (id: string) => createInternalLabels(id); |
| 286 | |
| 287 | export const sameJson = (left: unknown, right: unknown) => |
| 288 | fingerprint(left) === fingerprint(right); |
| 289 | |
| 290 | export 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 | |
| 305 | export const branchParent = (dataStore: string, branchId = DEFAULT_BRANCH) => |
| 306 | `${dataStore}/branches/${branchId}`; |
| 307 | |
| 308 | export const siteSearchEngineParent = (dataStore: string) => |
| 309 | `${dataStore}/siteSearchEngine`; |
| 310 | |
| 311 | export 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 | */ |
| 333 | const normalizeUriPattern = (pattern: string) => { |
| 334 | const bare = pattern.replace(/^https?:\/\//, ""); |
| 335 | return bare.endsWith("/") ? `${bare}*` : bare; |
| 336 | }; |
| 337 | |
| 338 | export 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 | }; |