| 1 | import * as aisearch from "@distilled.cloud/cloudflare/aisearch"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schedule from "effect/Schedule"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | |
| 6 | import { deepEqual, isResolved } from "../../Diff.ts"; |
| 7 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 8 | import * as Provider from "../../Provider.ts"; |
| 9 | import { isResourceOfType, Resource } from "../../Resource.ts"; |
| 10 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 11 | import type { Providers } from "../Providers.ts"; |
| 12 | |
| 13 | const TypeId = "Cloudflare.AI.Search" as const; |
| 14 | type TypeId = typeof TypeId; |
| 15 | |
| 16 | /** |
| 17 | * The kind of data source an AI Search instance indexes. |
| 18 | */ |
| 19 | export type SearchInstanceSourceType = "r2" | "web-crawler"; |
| 20 | |
| 21 | /** |
| 22 | * Generation model used to answer AI Search queries. |
| 23 | */ |
| 24 | export type SearchModel = Exclude< |
| 25 | NonNullable<aisearch.CreateInstanceRequest["aiSearchModel"]>, |
| 26 | "" |
| 27 | >; |
| 28 | |
| 29 | /** |
| 30 | * Embedding model used to vectorize indexed content. Cannot be changed |
| 31 | * after creation (it defines the vector space). |
| 32 | */ |
| 33 | export type EmbeddingModel = Exclude< |
| 34 | NonNullable<aisearch.CreateInstanceRequest["embeddingModel"]>, |
| 35 | "" |
| 36 | >; |
| 37 | |
| 38 | /** |
| 39 | * Reranking model applied to retrieved results. |
| 40 | */ |
| 41 | export type RerankingModel = "@cf/baai/bge-reranker-base"; |
| 42 | |
| 43 | /** |
| 44 | * Data-source specific indexing parameters (R2 prefix / include / exclude |
| 45 | * filters, or web-crawler options). |
| 46 | */ |
| 47 | export type SourceParams = NonNullable< |
| 48 | aisearch.CreateInstanceRequest["sourceParams"] |
| 49 | >; |
| 50 | |
| 51 | /** |
| 52 | * Controls which storage backends are used during indexing. |
| 53 | */ |
| 54 | export type IndexMethod = NonNullable< |
| 55 | aisearch.CreateInstanceRequest["indexMethod"] |
| 56 | >; |
| 57 | |
| 58 | /** |
| 59 | * Keyword indexing options. |
| 60 | */ |
| 61 | export type IndexingOptions = NonNullable< |
| 62 | aisearch.CreateInstanceRequest["indexingOptions"] |
| 63 | >; |
| 64 | |
| 65 | /** |
| 66 | * Custom metadata fields extracted at indexing time. |
| 67 | */ |
| 68 | export type CustomMetadata = NonNullable< |
| 69 | aisearch.CreateInstanceRequest["customMetadata"] |
| 70 | >; |
| 71 | |
| 72 | /** |
| 73 | * Retrieval-time options (boosting and keyword match mode). |
| 74 | */ |
| 75 | export type RetrievalOptions = NonNullable< |
| 76 | aisearch.CreateInstanceRequest["retrievalOptions"] |
| 77 | >; |
| 78 | |
| 79 | /** |
| 80 | * Public REST endpoint configuration for the instance. |
| 81 | */ |
| 82 | export type PublicEndpointParams = NonNullable< |
| 83 | aisearch.CreateInstanceRequest["publicEndpointParams"] |
| 84 | >; |
| 85 | |
| 86 | /** |
| 87 | * Similarity-cache threshold preset. |
| 88 | */ |
| 89 | export type CacheThreshold = |
| 90 | | "super_strict_match" |
| 91 | | "close_enough" |
| 92 | | "flexible_friend" |
| 93 | | "anything_goes"; |
| 94 | |
| 95 | export type SearchInstanceProps = { |
| 96 | /** |
| 97 | * SearchInstance identifier (the AI Search "name" shown in the dashboard). |
| 98 | * Lowercase alphanumeric, hyphens, and underscores. If omitted, a unique |
| 99 | * id is generated from the app, stage, and logical ID. Changing it |
| 100 | * triggers a replacement. |
| 101 | * @default ${app}-${id}-${stage}-${suffix} |
| 102 | */ |
| 103 | instanceId?: string; |
| 104 | /** |
| 105 | * Namespace this instance belongs to. AI Search instances are |
| 106 | * namespace-scoped; omitting this places the instance in the |
| 107 | * account-provided `default` namespace. Pass a `SearchNamespace`'s |
| 108 | * `name` output to group instances under a custom namespace (which also |
| 109 | * orders this instance after that namespace on deploy and before it on |
| 110 | * destroy). The namespace is immutable — changing it triggers a |
| 111 | * replacement. |
| 112 | * @default "default" |
| 113 | */ |
| 114 | namespace?: string; |
| 115 | /** |
| 116 | * Data source kind: `r2` indexes objects in an R2 bucket, `web-crawler` |
| 117 | * crawls a seed URL. Changing it triggers a replacement. |
| 118 | * @default "r2" |
| 119 | */ |
| 120 | type?: SearchInstanceSourceType; |
| 121 | /** |
| 122 | * Data source: the R2 bucket name (for `type: "r2"`) or the crawl seed |
| 123 | * URL (for `type: "web-crawler"`). Changing it triggers a replacement — |
| 124 | * the index must be rebuilt from scratch. |
| 125 | */ |
| 126 | source: string; |
| 127 | /** |
| 128 | * Source-specific indexing parameters (R2 prefix / include / exclude |
| 129 | * filters, web-crawler crawl and parse options). |
| 130 | */ |
| 131 | sourceParams?: SourceParams; |
| 132 | /** |
| 133 | * Id of the AI Search service token used to access the data source on |
| 134 | * sync. When omitted, Cloudflare provisions one automatically. |
| 135 | */ |
| 136 | tokenId?: string; |
| 137 | /** |
| 138 | * AI Gateway to route model inference calls through. |
| 139 | */ |
| 140 | aiGatewayId?: string; |
| 141 | /** |
| 142 | * Embedding model used to vectorize content. Cannot be changed after |
| 143 | * creation — updating this property triggers a replacement. |
| 144 | * @default service default |
| 145 | */ |
| 146 | embeddingModel?: EmbeddingModel; |
| 147 | /** |
| 148 | * Generation model used to answer AI Search queries. |
| 149 | * @default service default |
| 150 | */ |
| 151 | aiSearchModel?: SearchModel; |
| 152 | /** |
| 153 | * Whether to rewrite the user query before retrieval. |
| 154 | * @default false |
| 155 | */ |
| 156 | rewriteQuery?: boolean; |
| 157 | /** |
| 158 | * Model used to rewrite queries when `rewriteQuery` is enabled. |
| 159 | */ |
| 160 | rewriteModel?: SearchModel; |
| 161 | /** |
| 162 | * Whether custom chunking settings are applied during indexing. |
| 163 | */ |
| 164 | chunk?: boolean; |
| 165 | /** |
| 166 | * Chunk size (in tokens) used when splitting documents for indexing. |
| 167 | * Only affects future indexing runs. |
| 168 | */ |
| 169 | chunkSize?: number; |
| 170 | /** |
| 171 | * Overlap between consecutive chunks, as a percentage (0–30). Only |
| 172 | * affects future indexing runs. |
| 173 | */ |
| 174 | chunkOverlap?: number; |
| 175 | /** |
| 176 | * Controls which storage backends are used during indexing. Defaults to |
| 177 | * vector-only. |
| 178 | */ |
| 179 | indexMethod?: IndexMethod; |
| 180 | /** |
| 181 | * Keyword indexing options (tokenizer selection). |
| 182 | */ |
| 183 | indexingOptions?: IndexingOptions; |
| 184 | /** |
| 185 | * Custom metadata fields extracted at indexing time. |
| 186 | */ |
| 187 | customMetadata?: CustomMetadata; |
| 188 | /** |
| 189 | * Whether the similarity cache is enabled. |
| 190 | * @default false |
| 191 | */ |
| 192 | cache?: boolean; |
| 193 | /** |
| 194 | * Similarity-cache match strictness preset. |
| 195 | */ |
| 196 | cacheThreshold?: CacheThreshold; |
| 197 | /** |
| 198 | * Cache entry TTL in seconds. Allowed values: 600, 1800, 3600, 7200, |
| 199 | * 21600, 43200, 86400, 172800, 259200, 518400. |
| 200 | */ |
| 201 | cacheTtl?: number; |
| 202 | /** |
| 203 | * Whether retrieved results are reranked before generation. |
| 204 | * @default false |
| 205 | */ |
| 206 | reranking?: boolean; |
| 207 | /** |
| 208 | * Model used for reranking when `reranking` is enabled. |
| 209 | */ |
| 210 | rerankingModel?: RerankingModel; |
| 211 | /** |
| 212 | * Retrieval-time options (boosting and keyword match mode). |
| 213 | */ |
| 214 | retrievalOptions?: RetrievalOptions; |
| 215 | /** |
| 216 | * How vector and keyword results are fused: `max` or `rrf` |
| 217 | * (reciprocal rank fusion). |
| 218 | */ |
| 219 | fusionMethod?: "max" | "rrf"; |
| 220 | /** |
| 221 | * Maximum number of results returned by retrieval. |
| 222 | */ |
| 223 | maxNumResults?: number; |
| 224 | /** |
| 225 | * Minimum similarity score for a result to be returned. |
| 226 | */ |
| 227 | scoreThreshold?: number; |
| 228 | /** |
| 229 | * Public REST endpoint configuration (search / chat-completions / MCP). |
| 230 | */ |
| 231 | publicEndpointParams?: PublicEndpointParams; |
| 232 | /** |
| 233 | * Interval between automatic syncs, in seconds. Allowed values: 900, |
| 234 | * 1800, 3600, 7200, 14400, 21600, 43200, 86400. |
| 235 | */ |
| 236 | syncInterval?: number; |
| 237 | /** |
| 238 | * Kick off an initial indexing job right after the instance is first |
| 239 | * created, instead of waiting for the first scheduled sync. The job is |
| 240 | * triggered best-effort and not awaited — the deploy does not block on |
| 241 | * indexing, which can take much longer than a provisioning step should. |
| 242 | * Has no effect on updates or when no `source` is configured. |
| 243 | * @default false |
| 244 | */ |
| 245 | indexOnCreate?: boolean; |
| 246 | }; |
| 247 | |
| 248 | export type SearchInstanceAttributes = { |
| 249 | /** |
| 250 | * AI Search instance id. Lowercase alphanumeric, hyphens, underscores. |
| 251 | */ |
| 252 | instanceId: string; |
| 253 | /** |
| 254 | * The Cloudflare account the instance belongs to. |
| 255 | */ |
| 256 | accountId: string; |
| 257 | /** |
| 258 | * Namespace the instance belongs to (`default` when unspecified). |
| 259 | */ |
| 260 | namespace: string; |
| 261 | /** |
| 262 | * Data source kind (`r2` or `web-crawler`). |
| 263 | */ |
| 264 | type: SearchInstanceSourceType; |
| 265 | /** |
| 266 | * Data source (R2 bucket name or crawl seed URL). |
| 267 | */ |
| 268 | source: string | undefined; |
| 269 | /** |
| 270 | * Id of the AI Search service token used to access the data source. |
| 271 | */ |
| 272 | tokenId: string | undefined; |
| 273 | /** |
| 274 | * AI Gateway inference calls are routed through. |
| 275 | */ |
| 276 | aiGatewayId: string | undefined; |
| 277 | /** |
| 278 | * Embedding model used to vectorize content. |
| 279 | */ |
| 280 | embeddingModel: string | undefined; |
| 281 | /** |
| 282 | * Generation model used to answer queries. |
| 283 | */ |
| 284 | aiSearchModel: string | undefined; |
| 285 | /** |
| 286 | * Current instance status (indexing is asynchronous). |
| 287 | */ |
| 288 | status: string | undefined; |
| 289 | /** |
| 290 | * Whether the instance is paused. |
| 291 | */ |
| 292 | paused: boolean | undefined; |
| 293 | /** |
| 294 | * Id of the public REST endpoint, when enabled. |
| 295 | */ |
| 296 | publicEndpointId: string | undefined; |
| 297 | /** |
| 298 | * When the instance was created. |
| 299 | */ |
| 300 | createdAt: string | undefined; |
| 301 | /** |
| 302 | * When the instance was last modified. |
| 303 | */ |
| 304 | modifiedAt: string | undefined; |
| 305 | }; |
| 306 | |
| 307 | export type SearchInstance = Resource< |
| 308 | TypeId, |
| 309 | SearchInstanceProps, |
| 310 | SearchInstanceAttributes, |
| 311 | never, |
| 312 | Providers |
| 313 | >; |
| 314 | |
| 315 | /** |
| 316 | * A Cloudflare.AI. Search (formerly AutoRAG) instance — a fully managed |
| 317 | * retrieval-augmented generation pipeline over your own data. |
| 318 | * |
| 319 | * An instance continuously indexes a data source (an R2 bucket or a web |
| 320 | * crawl), embeds it into a managed Vectorize index, and answers search and |
| 321 | * chat queries against it. Creation returns immediately; the initial |
| 322 | * indexing run happens asynchronously. |
| 323 | * |
| 324 | * The instance `instanceId`, `namespace`, `type`, `source`, and |
| 325 | * `embeddingModel` are fixed at creation — changing any of them triggers a |
| 326 | * replacement. Everything else (models, chunking, caching, reranking, |
| 327 | * public endpoint, sync interval) is mutable in place. |
| 328 | * |
| 329 | * For the common R2 case, prefer the {@link Search} construct, which also |
| 330 | * mints the service token the indexer needs to read your bucket. Use this |
| 331 | * low-level resource directly when you manage the token yourself, share one |
| 332 | * token across instances, or group instances under a {@link SearchNamespace}. |
| 333 | * |
| 334 | * ### Creating a SearchInstance |
| 335 | * **Example:** R2-backed instance |
| 336 | * An R2 source needs a service token to read the bucket. Either pass a |
| 337 | * `tokenId` (see {@link SearchToken}) or let the {@link Search} |
| 338 | * construct provision one for you. |
| 339 | * ```typescript |
| 340 | * const bucket = yield* Cloudflare.R2.Bucket("docs", {}); |
| 341 | * const instance = yield* Cloudflare.AI.SearchInstance("docs-search", { |
| 342 | * source: bucket.bucketName, |
| 343 | * tokenId: serviceToken.id, |
| 344 | * }); |
| 345 | * ``` |
| 346 | * |
| 347 | * **Example:** Tuned retrieval settings |
| 348 | * ```typescript |
| 349 | * const instance = yield* Cloudflare.AI.SearchInstance("docs-search", { |
| 350 | * source: bucket.bucketName, |
| 351 | * aiSearchModel: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", |
| 352 | * chunkSize: 512, |
| 353 | * chunkOverlap: 64, |
| 354 | * maxNumResults: 20, |
| 355 | * cache: true, |
| 356 | * cacheThreshold: "close_enough", |
| 357 | * }); |
| 358 | * ``` |
| 359 | * |
| 360 | * ### R2 source options |
| 361 | * For an `r2` source, `sourceParams` filters which objects are indexed (all |
| 362 | * fields optional): |
| 363 | * - `prefix` — only index keys under this prefix. |
| 364 | * - `includeItems` / `excludeItems` — micromatch glob patterns (`*` within a |
| 365 | * path segment, `**` across segments; max 10 each). Only objects matching an |
| 366 | * `includeItems` pattern are indexed; `excludeItems` takes precedence. |
| 367 | * - `r2Jurisdiction` — R2 data-residency jurisdiction of the source bucket. |
| 368 | * **Example:** Index only part of a bucket |
| 369 | * ```typescript |
| 370 | * const instance = yield* Cloudflare.AI.SearchInstance("docs-search", { |
| 371 | * source: bucket.bucketName, |
| 372 | * tokenId: serviceToken.id, |
| 373 | * sourceParams: { |
| 374 | * prefix: "docs/", |
| 375 | * includeItems: ["/docs/**"], |
| 376 | * excludeItems: ["/docs/drafts/**"], |
| 377 | * }, |
| 378 | * }); |
| 379 | * ``` |
| 380 | * |
| 381 | * ### Web-crawler source options |
| 382 | * `sourceParams.webCrawler` tunes how a `web-crawler` source is fetched, |
| 383 | * parsed, and stored. All fields are optional. |
| 384 | * |
| 385 | * `parseType` selects how pages are discovered: |
| 386 | * - `"sitemap"` (Cloudflare default) — read `<seed>/sitemap.xml` (discovered |
| 387 | * via `robots.txt`) and index the URLs it lists. |
| 388 | * - `"discover"` — start at `source` and follow links. |
| 389 | * |
| 390 | * `crawlOptions` is no longer accepted by the API — Cloudflare removed it; |
| 391 | * discovery behavior is controlled solely by `parseType`. |
| 392 | * |
| 393 | * `parseOptions` controls how each page is parsed: |
| 394 | * - `useBrowserRendering` — render JS in a headless browser before parsing. |
| 395 | * - `includeImages` — index image content. |
| 396 | * - `specificSitemaps` — explicit sitemap URLs to read (for `"sitemap"`). |
| 397 | * - `contentSelector` — `{ path, selector }[]` CSS selectors scoping which |
| 398 | * part of a page is indexed per URL path. |
| 399 | * - `includeHeaders` — extra request headers sent while crawling. |
| 400 | * |
| 401 | * `storeOptions` overrides where crawled content is stored — Cloudflare |
| 402 | * provisions managed storage by default: |
| 403 | * - `storageId` — R2 bucket name to store crawl output in. |
| 404 | * - `storageType` — `"r2"`. |
| 405 | * - `r2Jurisdiction` — R2 data-residency jurisdiction for the store bucket. |
| 406 | * **Example:** Basic web-crawler instance |
| 407 | * ```typescript |
| 408 | * const instance = yield* Cloudflare.AI.SearchInstance("site-search", { |
| 409 | * type: "web-crawler", |
| 410 | * source: "https://example.com", |
| 411 | * sourceParams: { webCrawler: { parseType: "discover" } }, |
| 412 | * }); |
| 413 | * ``` |
| 414 | * **Example:** Fully-configured crawl |
| 415 | * ```typescript |
| 416 | * const instance = yield* Cloudflare.AI.SearchInstance("site-search", { |
| 417 | * type: "web-crawler", |
| 418 | * source: "https://example.com", |
| 419 | * sourceParams: { |
| 420 | * webCrawler: { |
| 421 | * parseType: "discover", |
| 422 | * parseOptions: { |
| 423 | * useBrowserRendering: true, |
| 424 | * includeImages: false, |
| 425 | * contentSelector: [{ path: "/docs", selector: "main" }], |
| 426 | * }, |
| 427 | * }, |
| 428 | * }, |
| 429 | * }); |
| 430 | * ``` |
| 431 | * **Example:** Sitemap source |
| 432 | * ```typescript |
| 433 | * // Index the URLs listed in one or more sitemaps (the default parse mode). |
| 434 | * const fromSitemap = yield* Cloudflare.AI.SearchInstance("sitemap-search", { |
| 435 | * type: "web-crawler", |
| 436 | * source: "https://example.com", |
| 437 | * sourceParams: { |
| 438 | * webCrawler: { |
| 439 | * parseType: "sitemap", |
| 440 | * parseOptions: { specificSitemaps: ["https://example.com/sitemap.xml"] }, |
| 441 | * }, |
| 442 | * }, |
| 443 | * }); |
| 444 | * ``` |
| 445 | * **Example:** Store crawl output in a specific R2 bucket |
| 446 | * ```typescript |
| 447 | * const instance = yield* Cloudflare.AI.SearchInstance("site-search", { |
| 448 | * type: "web-crawler", |
| 449 | * source: "https://example.com", |
| 450 | * sourceParams: { |
| 451 | * webCrawler: { |
| 452 | * parseType: "discover", |
| 453 | * storeOptions: { storageId: "my-crawl-bucket", storageType: "r2" }, |
| 454 | * }, |
| 455 | * }, |
| 456 | * }); |
| 457 | * ``` |
| 458 | * |
| 459 | * ### Grouping under a namespace |
| 460 | * SearchInstances live in a namespace (the account-provided `default` when |
| 461 | * unspecified). Pass a {@link SearchNamespace}'s `name` to group related |
| 462 | * instances — the engine then orders this instance after the namespace on |
| 463 | * deploy. The namespace is immutable; changing it replaces the instance. |
| 464 | * **Example:** Place the instance in a custom namespace |
| 465 | * ```typescript |
| 466 | * const ns = yield* Cloudflare.AI.SearchNamespace("docs-ns", {}); |
| 467 | * const instance = yield* Cloudflare.AI.SearchInstance("docs-search", { |
| 468 | * source: bucket.bucketName, |
| 469 | * namespace: ns.name, |
| 470 | * }); |
| 471 | * ``` |
| 472 | * |
| 473 | * ### Binding to an Effect Worker |
| 474 | * Bind the instance during the Worker's init phase with |
| 475 | * `Cloudflare.AI.QuerySearch(instance)`, which attaches the |
| 476 | * single-instance `ai_search` binding and returns an Effect-native client |
| 477 | * whose `search` / `chatCompletions` methods return `Effect`s. Provide |
| 478 | * {@link QuerySearchBinding} in the Worker's runtime layer. |
| 479 | * **Example:** Effect Worker that answers from AI Search |
| 480 | * ```typescript |
| 481 | * import * as Cloudflare from "alchemy/Cloudflare"; |
| 482 | * import * as Effect from "effect/Effect"; |
| 483 | * import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 484 | * import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 485 | * |
| 486 | * export default class Api extends Cloudflare.Worker<Api>()( |
| 487 | * "api", |
| 488 | * { main: import.meta.url }, |
| 489 | * Effect.gen(function* () { |
| 490 | * const bucket = yield* Cloudflare.R2.Bucket("docs", {}); |
| 491 | * const instance = yield* Cloudflare.AI.SearchInstance("docs-search", { |
| 492 | * source: bucket.bucketName, |
| 493 | * }); |
| 494 | * const search = yield* Cloudflare.AI.QuerySearch(instance); |
| 495 | * |
| 496 | * return { |
| 497 | * fetch: Effect.gen(function* () { |
| 498 | * const request = yield* HttpServerRequest; |
| 499 | * const query = new URL(request.url).searchParams.get("q") ?? ""; |
| 500 | * const answer = yield* search.chatCompletions({ |
| 501 | * messages: [{ role: "user", content: query }], |
| 502 | * }); |
| 503 | * return yield* HttpServerResponse.json(answer); |
| 504 | * }), |
| 505 | * }; |
| 506 | * }).pipe(Effect.provide(Cloudflare.AI.QuerySearchBinding)), |
| 507 | * ) {} |
| 508 | * ``` |
| 509 | * |
| 510 | * ### Binding to an Async Worker |
| 511 | * For a vanilla `async fetch` Worker, pass the instance under `Worker.env`. |
| 512 | * The engine attaches the same `ai_search` binding and `InferEnv` types |
| 513 | * `env.SEARCH` as the runtime `SearchInstance` handle. |
| 514 | * **Example:** Async Worker via `env` |
| 515 | * ```typescript |
| 516 | * export const Api = Cloudflare.Worker("api", { |
| 517 | * main: "./worker.ts", |
| 518 | * env: { SEARCH: search }, |
| 519 | * }); |
| 520 | * export type ApiEnv = Cloudflare.InferEnv<typeof Api>; |
| 521 | * |
| 522 | * // worker.ts |
| 523 | * export default { |
| 524 | * async fetch(request: Request, env: ApiEnv): Promise<Response> { |
| 525 | * const query = new URL(request.url).searchParams.get("q") ?? ""; |
| 526 | * return Response.json( |
| 527 | * await env.SEARCH.chatCompletions({ |
| 528 | * messages: [{ role: "user", content: query }], |
| 529 | * }), |
| 530 | * ); |
| 531 | * }, |
| 532 | * }; |
| 533 | * ``` |
| 534 | * |
| 535 | * @see https://developers.cloudflare.com/ai-search/ |
| 536 | * |
| 537 | * @resource |
| 538 | * @product AI Search |
| 539 | * @category AI |
| 540 | */ |
| 541 | export const SearchInstance = Resource<SearchInstance>(TypeId, { |
| 542 | aliases: ["Cloudflare.AiSearch.Instance"], |
| 543 | }); |
| 544 | |
| 545 | /** |
| 546 | * Returns true if the given value is a SearchInstance resource. |
| 547 | */ |
| 548 | export const isSearchInstance = (value: unknown): value is SearchInstance => |
| 549 | isResourceOfType(value, TypeId); |
| 550 | |
| 551 | export const SearchInstanceProvider = () => |
| 552 | Provider.succeed(SearchInstance, { |
| 553 | stables: [ |
| 554 | "instanceId", |
| 555 | "accountId", |
| 556 | "namespace", |
| 557 | "type", |
| 558 | "embeddingModel", |
| 559 | "createdAt", |
| 560 | ], |
| 561 | diff: Effect.fn(function* ({ id, olds, news, output }) { |
| 562 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 563 | if (!isResolved(news)) return undefined; |
| 564 | if ((output?.accountId ?? accountId) !== accountId) { |
| 565 | return { action: "replace" } as const; |
| 566 | } |
| 567 | // The namespace is immutable on the Cloudflare side (it is the API |
| 568 | // path) — moving an instance between namespaces is a replacement. The |
| 569 | // new instance lives at a different path, so create-before-delete is |
| 570 | // always safe here regardless of whether the id is pinned. |
| 571 | const newNamespace = resolveNamespace(news.namespace); |
| 572 | const oldNamespace = resolveNamespace( |
| 573 | output?.namespace ?? olds.namespace, |
| 574 | ); |
| 575 | if (newNamespace !== oldNamespace) { |
| 576 | return { action: "replace" } as const; |
| 577 | } |
| 578 | // The instance id is its identity — renaming is a replacement. |
| 579 | const oldId = |
| 580 | output?.instanceId ?? (yield* createInstanceId(id, olds.instanceId)); |
| 581 | // Auto-generated ids are engine-owned: the deployed id stays |
| 582 | // authoritative even if the generator would name this id differently |
| 583 | // today. Only an explicit user-provided instanceId can force a replace. |
| 584 | const newId = news.instanceId ?? oldId; |
| 585 | if (newId !== oldId) { |
| 586 | return { action: "replace" } as const; |
| 587 | } |
| 588 | // When the user pinned an explicit `instanceId`, the replacement's |
| 589 | // create would collide with the still-existing old instance — the |
| 590 | // old one must be deleted first. Generated ids get a fresh suffix |
| 591 | // from the new SearchInstance ID, so create-before-delete is safe there. |
| 592 | const replace = { |
| 593 | action: "replace", |
| 594 | deleteFirst: news.instanceId !== undefined || undefined, |
| 595 | } as const; |
| 596 | // The data-source kind and location are fixed at creation; changing |
| 597 | // either requires re-indexing from scratch (a replacement). |
| 598 | if ((news.type ?? "r2") !== (output?.type ?? olds.type ?? "r2")) { |
| 599 | return replace; |
| 600 | } |
| 601 | const oldSource = output?.source ?? olds.source; |
| 602 | if (oldSource !== undefined && news.source !== oldSource) { |
| 603 | return replace; |
| 604 | } |
| 605 | // The embedding model defines the vector space and is immutable. |
| 606 | const oldEmbedding = |
| 607 | normalize(output?.embeddingModel) ?? olds.embeddingModel; |
| 608 | if ( |
| 609 | news.embeddingModel !== undefined && |
| 610 | oldEmbedding !== undefined && |
| 611 | news.embeddingModel !== oldEmbedding |
| 612 | ) { |
| 613 | return replace; |
| 614 | } |
| 615 | return undefined; |
| 616 | }), |
| 617 | read: Effect.fn(function* ({ id, output, olds }) { |
| 618 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 619 | const acct = output?.accountId ?? accountId; |
| 620 | const namespace = resolveNamespace(output?.namespace ?? olds?.namespace); |
| 621 | // The id is deterministic (explicit prop or generated from the |
| 622 | // logical id + instance id), so a cold read (lost state) resolves |
| 623 | // the same identifier as the original create did. |
| 624 | const instanceId = |
| 625 | output?.instanceId ?? (yield* createInstanceId(id, olds?.instanceId)); |
| 626 | const observed = yield* getInstance(acct, namespace, instanceId); |
| 627 | return observed ? toAttributes(observed, acct, namespace) : undefined; |
| 628 | }), |
| 629 | list: Effect.fn(function* () { |
| 630 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 631 | // SearchInstances are namespace-scoped, so enumerate every namespace |
| 632 | // (always including the account-provided `default`) and fan out a |
| 633 | // paginated instance list per namespace. |
| 634 | const namespaces = yield* aisearch.listNamespaces |
| 635 | .pages({ accountId }) |
| 636 | .pipe( |
| 637 | Stream.runCollect, |
| 638 | Effect.map((chunk) => |
| 639 | Array.from(chunk).flatMap((page) => |
| 640 | (page.result ?? []).map((ns) => ns.name), |
| 641 | ), |
| 642 | ), |
| 643 | ); |
| 644 | const allNamespaces = Array.from(new Set(["default", ...namespaces])); |
| 645 | const rows = yield* Effect.forEach( |
| 646 | allNamespaces, |
| 647 | (namespace) => |
| 648 | aisearch.listNamespaceInstances |
| 649 | .pages({ accountId, name: namespace }) |
| 650 | .pipe( |
| 651 | Stream.runCollect, |
| 652 | Effect.map((chunk) => |
| 653 | Array.from(chunk).flatMap((page) => |
| 654 | (page.result ?? []).map((instance) => |
| 655 | toAttributes(instance, accountId, namespace), |
| 656 | ), |
| 657 | ), |
| 658 | ), |
| 659 | // A namespace deleted between list and fan-out is simply empty. |
| 660 | Effect.catchTag("NamespaceNotFound", () => Effect.succeed([])), |
| 661 | ), |
| 662 | { concurrency: 5 }, |
| 663 | ); |
| 664 | return rows.flat(); |
| 665 | }), |
| 666 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 667 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 668 | const acct = output?.accountId ?? accountId; |
| 669 | const namespace = resolveNamespace(news.namespace); |
| 670 | const instanceId = |
| 671 | output?.instanceId ?? (yield* createInstanceId(id, news.instanceId)); |
| 672 | |
| 673 | // Observe — `output.instanceId` is a cache, not a guarantee: a NotFound |
| 674 | // falls through to "missing" and we recreate. |
| 675 | let observed = yield* getInstance(acct, namespace, instanceId); |
| 676 | |
| 677 | if (!observed) { |
| 678 | // Ensure — greenfield (or out-of-band delete): create with the |
| 679 | // full desired body. If Cloudflare rejects the request because |
| 680 | // the instance already exists (a race), fall through to the |
| 681 | // sync path against the observed instance. |
| 682 | const ensured = yield* aisearch |
| 683 | .createNamespaceInstance({ |
| 684 | accountId: acct, |
| 685 | name: namespace, |
| 686 | id: instanceId, |
| 687 | type: news.type ?? "r2", |
| 688 | ...toMutableBody(news), |
| 689 | }) |
| 690 | .pipe( |
| 691 | retryTokenPropagation, |
| 692 | Effect.map((created) => ({ |
| 693 | created: true as const, |
| 694 | instance: created as ObservedInstance, |
| 695 | })), |
| 696 | Effect.catchTag("InstanceAlreadyExists", (originalError) => |
| 697 | Effect.gen(function* () { |
| 698 | const existing = yield* getInstance( |
| 699 | acct, |
| 700 | namespace, |
| 701 | instanceId, |
| 702 | ); |
| 703 | if (!existing) return yield* Effect.fail(originalError); |
| 704 | return { created: false as const, instance: existing }; |
| 705 | }), |
| 706 | ), |
| 707 | ); |
| 708 | if (ensured.created) { |
| 709 | // Optionally kick off the initial index instead of waiting for |
| 710 | // the first scheduled sync. Best-effort and NOT awaited for |
| 711 | // completion — the deploy must not block on indexing (the |
| 712 | // scheduled sync is the backstop if this transiently fails). |
| 713 | if ((news.indexOnCreate ?? false) && news.source !== undefined) { |
| 714 | yield* aisearch |
| 715 | .createNamespaceInstanceJob({ |
| 716 | accountId: acct, |
| 717 | name: namespace, |
| 718 | id: instanceId, |
| 719 | }) |
| 720 | .pipe( |
| 721 | Effect.catch((error) => |
| 722 | Effect.logWarning( |
| 723 | "AI Search initial index job failed to start; the scheduled sync will index instead.", |
| 724 | error, |
| 725 | ), |
| 726 | ), |
| 727 | ); |
| 728 | } |
| 729 | // Indexing itself starts asynchronously; we deliberately do NOT |
| 730 | // wait for the first sync to finish. |
| 731 | return toAttributes(ensured.instance, acct, namespace); |
| 732 | } |
| 733 | observed = ensured.instance; |
| 734 | } |
| 735 | |
| 736 | // Sync — diff observed cloud state against the desired mutable |
| 737 | // config; skip the PUT entirely on a no-op. Only fields the user |
| 738 | // actually set participate in the diff; for the rest the observed |
| 739 | // value is preserved in the full PUT body. |
| 740 | const desired = toMutableBody(news); |
| 741 | const observedRecord = observed as unknown as Record<string, unknown>; |
| 742 | const dirty = Object.entries(desired).some( |
| 743 | ([key, value]) => |
| 744 | // Cloudflare's read projection always returns `tokenId: null` |
| 745 | // (the association is write-only), so it can never be diffed |
| 746 | // against desired — excluding it avoids perpetual false drift. |
| 747 | // It still rides along in the create body and in any update PUT |
| 748 | // triggered by other fields. |
| 749 | key !== "tokenId" && |
| 750 | value !== undefined && |
| 751 | !deepEqual(normalize(observedRecord[key]), normalize(value), { |
| 752 | stripNullish: true, |
| 753 | }), |
| 754 | ); |
| 755 | if (!dirty) { |
| 756 | return toAttributes(observed, acct, namespace); |
| 757 | } |
| 758 | |
| 759 | const updated = yield* aisearch |
| 760 | .updateNamespaceInstance({ |
| 761 | accountId: acct, |
| 762 | name: namespace, |
| 763 | id: instanceId, |
| 764 | ...preserveObserved(observed), |
| 765 | ...defined(desired), |
| 766 | }) |
| 767 | .pipe(retryTokenPropagation); |
| 768 | return toAttributes(updated, acct, namespace); |
| 769 | }), |
| 770 | delete: Effect.fn(function* ({ output }) { |
| 771 | // The managed Vectorize index is torn down asynchronously; a missing |
| 772 | // instance or namespace (already deleted) is success. |
| 773 | yield* aisearch |
| 774 | .deleteNamespaceInstance({ |
| 775 | accountId: output.accountId, |
| 776 | name: output.namespace, |
| 777 | id: output.instanceId, |
| 778 | }) |
| 779 | .pipe( |
| 780 | Effect.catchTag( |
| 781 | ["AiSearchInstanceNotFound", "NamespaceNotFound"], |
| 782 | () => Effect.void, |
| 783 | ), |
| 784 | ); |
| 785 | }), |
| 786 | }); |
| 787 | |
| 788 | /** |
| 789 | * Ride out the two eventual-consistency windows Cloudflare opens when an |
| 790 | * instance's source is (re)validated at create/update time: |
| 791 | * |
| 792 | * - `InvalidTokenCredentials` (code 7012, message |
| 793 | * `ai_search_instance_invalid_token`): on a greenfield create Cloudflare |
| 794 | * auto-provisions an R2 service token for the instance to read its source |
| 795 | * bucket, and validates it eventually-consistently. |
| 796 | * - `MissingSitemap` (400, message `missing_sitemap`): a `web-crawler` |
| 797 | * source is fetched synchronously at validation time. A freshly-deployed |
| 798 | * seed (e.g. a brand-new `workers.dev` URL) is reachable from the test's |
| 799 | * own vantage well before Cloudflare's crawler infrastructure can fetch |
| 800 | * it, so the first validation can spuriously report no content. |
| 801 | * |
| 802 | * Both settle within a bounded window; a genuinely invalid token or |
| 803 | * unreachable/empty seed still fails once the retries are exhausted. |
| 804 | */ |
| 805 | const retryTokenPropagation = <A, E extends { _tag: string }, R>( |
| 806 | effect: Effect.Effect<A, E, R>, |
| 807 | ) => |
| 808 | effect.pipe( |
| 809 | Effect.retry({ |
| 810 | while: (e) => |
| 811 | e._tag === "InvalidTokenCredentials" || e._tag === "MissingSitemap", |
| 812 | // A full-body update PUT re-sends `source`, which makes Cloudflare |
| 813 | // re-validate the (write-only, auto-provisioned) R2 service token and |
| 814 | // re-fetch a web-crawler seed — opening a fresh propagation window each |
| 815 | // time. The window stretches under full-suite parallel load (token |
| 816 | // propagation across the edge contends with every other test's calls), |
| 817 | // so the budget is generous (~2 min). Crucially the per-attempt delay is |
| 818 | // *capped* at 6s (`either` takes the min of the two schedules): an |
| 819 | // uncapped exponential balloons to ~38s gaps by attempt 10, so it polls |
| 820 | // sparsely and detects a settled token tens of seconds late. Capped |
| 821 | // polling detects within 6s of propagation completing while still |
| 822 | // covering a long total window. |
| 823 | schedule: Schedule.min([ |
| 824 | Schedule.exponential("1 second", 1.5), |
| 825 | Schedule.spaced("6 seconds"), |
| 826 | ]), |
| 827 | times: 22, |
| 828 | }), |
| 829 | ); |
| 830 | |
| 831 | type ObservedInstance = aisearch.ReadNamespaceInstanceResponse; |
| 832 | |
| 833 | /** |
| 834 | * Resolve the namespace name an instance lives in, defaulting to the |
| 835 | * account-provided `default` namespace. |
| 836 | */ |
| 837 | const resolveNamespace = (namespace: string | undefined): string => |
| 838 | namespace ?? "default"; |
| 839 | |
| 840 | /** |
| 841 | * Read an instance by id within its namespace, mapping "gone" to |
| 842 | * `undefined` — either the instance is missing (`AiSearchInstanceNotFound`, |
| 843 | * code 7002) or its whole namespace is (`NamespaceNotFound`, code 7063). |
| 844 | */ |
| 845 | const getInstance = (accountId: string, namespace: string, id: string) => |
| 846 | aisearch |
| 847 | .readNamespaceInstance({ accountId, name: namespace, id }) |
| 848 | .pipe( |
| 849 | Effect.catchTag(["AiSearchInstanceNotFound", "NamespaceNotFound"], () => |
| 850 | Effect.succeed(undefined), |
| 851 | ), |
| 852 | ); |
| 853 | |
| 854 | const createInstanceId = (id: string, instanceId: string | undefined) => |
| 855 | Effect.gen(function* () { |
| 856 | return instanceId ?? (yield* createPhysicalName({ id, lowercase: true })); |
| 857 | }); |
| 858 | |
| 859 | /** |
| 860 | * Cloudflare returns `null` (and sometimes `""` for model enums) for |
| 861 | * unconfigured optional fields; desired-state shapes leave them |
| 862 | * `undefined`. Collapse both to `undefined` for diffing. |
| 863 | */ |
| 864 | const normalize = <T>(value: T | "" | null | undefined): T | undefined => |
| 865 | value === "" || value == null ? undefined : value; |
| 866 | |
| 867 | type MutableBody = ReturnType<typeof toMutableBody>; |
| 868 | |
| 869 | /** |
| 870 | * The mutable slice of the desired state, shaped for the create/update |
| 871 | * request bodies. Immutable props (`id`, `type`) are handled separately. |
| 872 | */ |
| 873 | const toMutableBody = (news: SearchInstanceProps) => ({ |
| 874 | source: news.source as string, |
| 875 | sourceParams: news.sourceParams, |
| 876 | tokenId: news.tokenId as string | undefined, |
| 877 | aiGatewayId: news.aiGatewayId as string | undefined, |
| 878 | embeddingModel: news.embeddingModel, |
| 879 | aiSearchModel: news.aiSearchModel, |
| 880 | rewriteQuery: news.rewriteQuery, |
| 881 | rewriteModel: news.rewriteModel, |
| 882 | chunk: news.chunk, |
| 883 | chunkSize: news.chunkSize, |
| 884 | chunkOverlap: news.chunkOverlap, |
| 885 | indexMethod: news.indexMethod, |
| 886 | indexingOptions: news.indexingOptions, |
| 887 | customMetadata: news.customMetadata, |
| 888 | cache: news.cache, |
| 889 | cacheThreshold: news.cacheThreshold, |
| 890 | cacheTtl: news.cacheTtl, |
| 891 | reranking: news.reranking, |
| 892 | rerankingModel: news.rerankingModel, |
| 893 | retrievalOptions: news.retrievalOptions, |
| 894 | fusionMethod: news.fusionMethod, |
| 895 | maxNumResults: news.maxNumResults, |
| 896 | scoreThreshold: news.scoreThreshold, |
| 897 | publicEndpointParams: news.publicEndpointParams, |
| 898 | syncInterval: news.syncInterval, |
| 899 | }); |
| 900 | |
| 901 | /** |
| 902 | * The update API is a PUT — fields the user did not set are preserved by |
| 903 | * sending the observed values back. Observed `null`s are omitted (the |
| 904 | * field was never configured). |
| 905 | */ |
| 906 | const preserveObserved = (observed: ObservedInstance) => |
| 907 | defined({ |
| 908 | source: normalize(observed.source), |
| 909 | sourceParams: normalize(observed.sourceParams), |
| 910 | tokenId: normalize(observed.tokenId), |
| 911 | aiGatewayId: normalize(observed.aiGatewayId), |
| 912 | embeddingModel: normalize(observed.embeddingModel), |
| 913 | aiSearchModel: normalize(observed.aiSearchModel), |
| 914 | rewriteQuery: normalize(observed.rewriteQuery), |
| 915 | rewriteModel: normalize(observed.rewriteModel), |
| 916 | chunkSize: normalize(observed.chunkSize), |
| 917 | chunkOverlap: normalize(observed.chunkOverlap), |
| 918 | indexMethod: normalize(observed.indexMethod), |
| 919 | indexingOptions: normalize(observed.indexingOptions), |
| 920 | customMetadata: normalize(observed.customMetadata), |
| 921 | cache: normalize(observed.cache), |
| 922 | cacheThreshold: normalize(observed.cacheThreshold), |
| 923 | cacheTtl: normalize(observed.cacheTtl), |
| 924 | reranking: normalize(observed.reranking), |
| 925 | rerankingModel: normalize(observed.rerankingModel), |
| 926 | retrievalOptions: normalize(observed.retrievalOptions), |
| 927 | fusionMethod: normalize(observed.fusionMethod), |
| 928 | maxNumResults: normalize(observed.maxNumResults), |
| 929 | scoreThreshold: normalize(observed.scoreThreshold), |
| 930 | publicEndpointParams: normalize(observed.publicEndpointParams), |
| 931 | syncInterval: normalize(observed.syncInterval), |
| 932 | }) as Partial<MutableBody>; |
| 933 | |
| 934 | /** Strip `undefined` entries so they don't override spread order. */ |
| 935 | const defined = <T extends Record<string, unknown>>(value: T): Partial<T> => |
| 936 | Object.fromEntries( |
| 937 | Object.entries(value).filter(([, v]) => v !== undefined), |
| 938 | ) as Partial<T>; |
| 939 | |
| 940 | /** |
| 941 | * The instance fields `read` projects, common to the create / read / |
| 942 | * update / list-item response shapes (all optional but `id`). Decoupling |
| 943 | * from the exact distilled response unions lets the namespace-scoped list |
| 944 | * items — which carry a reduced field set — flow through unchanged. |
| 945 | */ |
| 946 | type SearchInstanceLike = { |
| 947 | id: string; |
| 948 | type?: string | null; |
| 949 | source?: string | null; |
| 950 | tokenId?: string | null; |
| 951 | aiGatewayId?: string | null; |
| 952 | embeddingModel?: string | null; |
| 953 | aiSearchModel?: string | null; |
| 954 | status?: string | null; |
| 955 | paused?: boolean | null; |
| 956 | publicEndpointId?: string | null; |
| 957 | createdAt?: string | null; |
| 958 | modifiedAt?: string | null; |
| 959 | }; |
| 960 | |
| 961 | const toAttributes = ( |
| 962 | instance: SearchInstanceLike, |
| 963 | accountId: string, |
| 964 | namespace: string, |
| 965 | ): SearchInstanceAttributes => ({ |
| 966 | instanceId: instance.id, |
| 967 | accountId, |
| 968 | namespace, |
| 969 | // Distilled widens generated string enums to open unions. |
| 970 | type: (normalize(instance.type) ?? "r2") as SearchInstanceSourceType, |
| 971 | source: normalize(instance.source), |
| 972 | tokenId: normalize(instance.tokenId), |
| 973 | aiGatewayId: normalize(instance.aiGatewayId), |
| 974 | embeddingModel: normalize(instance.embeddingModel), |
| 975 | aiSearchModel: normalize(instance.aiSearchModel), |
| 976 | status: normalize(instance.status), |
| 977 | paused: instance.paused ?? undefined, |
| 978 | publicEndpointId: normalize(instance.publicEndpointId), |
| 979 | createdAt: normalize(instance.createdAt), |
| 980 | modifiedAt: normalize(instance.modifiedAt), |
| 981 | }); |