1import * as aisearch from "@distilled.cloud/cloudflare/aisearch";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5
6import { deepEqual, isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { isResourceOfType, Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.AI.Search" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * The kind of data source an AI Search instance indexes.
18 */
19export type SearchInstanceSourceType = "r2" | "web-crawler";
20
21/**
22 * Generation model used to answer AI Search queries.
23 */
24export 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 */
33export type EmbeddingModel = Exclude<
34 NonNullable<aisearch.CreateInstanceRequest["embeddingModel"]>,
35 ""
36>;
37
38/**
39 * Reranking model applied to retrieved results.
40 */
41export 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 */
47export type SourceParams = NonNullable<
48 aisearch.CreateInstanceRequest["sourceParams"]
49>;
50
51/**
52 * Controls which storage backends are used during indexing.
53 */
54export type IndexMethod = NonNullable<
55 aisearch.CreateInstanceRequest["indexMethod"]
56>;
57
58/**
59 * Keyword indexing options.
60 */
61export type IndexingOptions = NonNullable<
62 aisearch.CreateInstanceRequest["indexingOptions"]
63>;
64
65/**
66 * Custom metadata fields extracted at indexing time.
67 */
68export type CustomMetadata = NonNullable<
69 aisearch.CreateInstanceRequest["customMetadata"]
70>;
71
72/**
73 * Retrieval-time options (boosting and keyword match mode).
74 */
75export type RetrievalOptions = NonNullable<
76 aisearch.CreateInstanceRequest["retrievalOptions"]
77>;
78
79/**
80 * Public REST endpoint configuration for the instance.
81 */
82export type PublicEndpointParams = NonNullable<
83 aisearch.CreateInstanceRequest["publicEndpointParams"]
84>;
85
86/**
87 * Similarity-cache threshold preset.
88 */
89export type CacheThreshold =
90 | "super_strict_match"
91 | "close_enough"
92 | "flexible_friend"
93 | "anything_goes";
94
95export 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
248export 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
307export 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 */
541export 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 */
548export const isSearchInstance = (value: unknown): value is SearchInstance =>
549 isResourceOfType(value, TypeId);
550
551export 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 */
805const 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
831type ObservedInstance = aisearch.ReadNamespaceInstanceResponse;
832
833/**
834 * Resolve the namespace name an instance lives in, defaulting to the
835 * account-provided `default` namespace.
836 */
837const 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 */
845const 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
854const 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 */
864const normalize = <T>(value: T | "" | null | undefined): T | undefined =>
865 value === "" || value == null ? undefined : value;
866
867type 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 */
873const 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 */
906const 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. */
935const 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 */
946type 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
961const 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});