1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as aisearch from "@distilled.cloud/cloudflare/aisearch";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import AiSearchCrawlTargetWorker from "./fixtures/crawl-target-worker.ts";
12
13const { test } = Test.make({ providers: Cloudflare.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20// The scoped API token the test harness mints propagates eventually-
21// consistently across Cloudflare's edge — ride out 403 blips
22// (`Forbidden`, declared in the distilled error union) on the test's
23// own out-of-band verification calls. Instances are namespace-scoped, so
24// verify through the namespace-scoped read (defaulting to `default`).
25const getInstance = (accountId: string, id: string, namespace = "default") =>
26 aisearch.readNamespaceInstance({ accountId, name: namespace, id }).pipe(
27 Effect.retry({
28 while: (e) => e._tag === "Forbidden",
29 schedule: Schedule.exponential("500 millis"),
30 times: 8,
31 }),
32 );
33
34const expectGone = (accountId: string, id: string, namespace = "default") =>
35 getInstance(accountId, id, namespace).pipe(
36 Effect.flatMap(() => Effect.fail({ _tag: "InstanceNotDeleted" } as const)),
37 // A missing instance (`AiSearchInstanceNotFound`, code 7002) or a
38 // missing enclosing namespace (`NamespaceNotFound`, code 7063) is the
39 // success condition here.
40 Effect.catchTag(
41 ["AiSearchInstanceNotFound", "NamespaceNotFound"],
42 () => Effect.void,
43 ),
44 Effect.retry({
45 while: (e) => e._tag === "InstanceNotDeleted",
46 schedule: Schedule.max([
47 Schedule.exponential("500 millis"),
48 Schedule.recurs(10),
49 ]),
50 }),
51 );
52
53// Keep each stack's credential explicit so Cloudflare cannot reuse a token
54// owned by another test stack.
55const scopedToken = Effect.gen(function* () {
56 const { accountId } = yield* yield* CloudflareEnvironment;
57 const apiToken = yield* Cloudflare.ApiToken.AccountApiToken("TokenSource", {
58 policies: [
59 {
60 effect: "allow",
61 permissionGroups: ["AI Search Index Engine"],
62 resources: { [`com.cloudflare.api.account.${accountId}`]: "*" },
63 },
64 ],
65 });
66 return yield* Cloudflare.AI.SearchToken("Token", {
67 cfApiId: apiToken.tokenId,
68 cfApiKey: apiToken.value,
69 });
70});
71
72// One program deploying both the R2 source bucket and the AI Search
73// instance indexing it. The instance's `source` references the bucket
74// name so the engine orders instance-after-bucket on deploy (and the
75// reverse on destroy).
76const program = (props?: Partial<Cloudflare.AI.SearchInstanceProps>) =>
77 Effect.gen(function* () {
78 const bucket = yield* Cloudflare.R2.Bucket("AiSearchSource", {
79 forceDestroy: true,
80 });
81 const token = yield* scopedToken;
82 const instance = yield* Cloudflare.AI.SearchInstance("Search", {
83 tokenId: token.id,
84 source: bucket.bucketName,
85 ...props,
86 });
87 return { bucket, instance };
88 });
89
90test.provider(
91 "create, update mutable props, and delete an r2-backed instance",
92 (stack) =>
93 Effect.gen(function* () {
94 const { accountId } = yield* yield* CloudflareEnvironment;
95
96 yield* stack.destroy();
97
98 // Create — engine-generated instance id, default settings.
99 const initial = yield* stack.deploy(program());
100
101 expect(initial.instance.instanceId).toBeTruthy();
102 expect(initial.instance.accountId).toEqual(accountId);
103 expect(initial.instance.type).toEqual("r2");
104 expect(initial.instance.source).toEqual(initial.bucket.bucketName);
105
106 const live = yield* getInstance(accountId, initial.instance.instanceId);
107 expect(live.id).toEqual(initial.instance.instanceId);
108 expect(live.source).toEqual(initial.bucket.bucketName);
109 expect(live.type).toEqual("r2");
110
111 // Update mutable props in place — same instance id.
112 const updated = yield* stack.deploy(
113 program({
114 aiSearchModel: "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
115 maxNumResults: 20,
116 chunkSize: 512,
117 chunkOverlap: 15,
118 }),
119 );
120
121 expect(updated.instance.instanceId).toEqual(initial.instance.instanceId);
122 expect(updated.instance.aiSearchModel).toEqual(
123 "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
124 );
125
126 // The update PUT returns the new settings immediately, but the
127 // out-of-band read endpoint reflects them eventually-consistently (it
128 // briefly serves `aiSearchModel: ""` right after the write). Poll the
129 // readback until the mutated props land before asserting, bounded.
130 const liveUpdated = yield* getInstance(
131 accountId,
132 updated.instance.instanceId,
133 ).pipe(
134 Effect.flatMap((live) =>
135 live.aiSearchModel === "@cf/meta/llama-3.3-70b-instruct-fp8-fast"
136 ? Effect.succeed(live)
137 : Effect.fail({ _tag: "InstanceUpdateNotApplied" } as const),
138 ),
139 Effect.retry({
140 while: (e) => e._tag === "InstanceUpdateNotApplied",
141 schedule: Schedule.spaced("3 seconds"),
142 times: 20,
143 }),
144 );
145 expect(liveUpdated.aiSearchModel).toEqual(
146 "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
147 );
148 expect(liveUpdated.maxNumResults).toEqual(20);
149 expect(liveUpdated.chunkSize).toEqual(512);
150 expect(liveUpdated.chunkOverlap).toEqual(15);
151
152 // Redeploying identical props is a no-op (still the same instance).
153 const noop = yield* stack.deploy(
154 program({
155 aiSearchModel: "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
156 maxNumResults: 20,
157 chunkSize: 512,
158 chunkOverlap: 15,
159 }),
160 );
161 expect(noop.instance.instanceId).toEqual(initial.instance.instanceId);
162
163 yield* stack.destroy();
164
165 yield* expectGone(accountId, initial.instance.instanceId);
166
167 // Destroy again — delete must be idempotent (already gone).
168 yield* stack.destroy();
169 }).pipe(logLevel),
170 {
171 tags: [
172 "provider:cloudflare",
173 "provider:cloudflare:ai",
174 "provider:cloudflare:apitoken",
175 "provider:cloudflare:r2",
176 "live",
177 ],
178 timeout: 240_000,
179 },
180);
181
182test.provider(
183 "changing the embedding model triggers a replacement",
184 (stack) =>
185 Effect.gen(function* () {
186 const { accountId } = yield* yield* CloudflareEnvironment;
187
188 yield* stack.destroy();
189
190 const initial = yield* stack.deploy(
191 program({ embeddingModel: "@cf/baai/bge-m3" }),
192 );
193 expect(initial.instance.embeddingModel).toEqual("@cf/baai/bge-m3");
194
195 const replaced = yield* stack.deploy(
196 program({ embeddingModel: "@cf/baai/bge-large-en-v1.5" }),
197 );
198
199 // The embedding model defines the vector space and is fixed at
200 // creation — a new physical instance exists.
201 expect(replaced.instance.instanceId).not.toEqual(
202 initial.instance.instanceId,
203 );
204 expect(replaced.instance.embeddingModel).toEqual(
205 "@cf/baai/bge-large-en-v1.5",
206 );
207
208 const live = yield* getInstance(accountId, replaced.instance.instanceId);
209 expect(live.embeddingModel).toEqual("@cf/baai/bge-large-en-v1.5");
210
211 // The old instance was deleted as part of the replacement.
212 yield* expectGone(accountId, initial.instance.instanceId);
213
214 yield* stack.destroy();
215
216 yield* expectGone(accountId, replaced.instance.instanceId);
217 }).pipe(logLevel),
218 {
219 tags: [
220 "provider:cloudflare",
221 "provider:cloudflare:ai",
222 "provider:cloudflare:apitoken",
223 "provider:cloudflare:r2",
224 "live",
225 ],
226 timeout: 240_000,
227 },
228);
229
230test.provider(
231 "recreates after out-of-band delete",
232 (stack) =>
233 Effect.gen(function* () {
234 const { accountId } = yield* yield* CloudflareEnvironment;
235
236 yield* stack.destroy();
237
238 const initial = yield* stack.deploy(program());
239
240 // Delete the instance out-of-band. A redeploy with identical props is
241 // a planner no-op, so change a mutable prop to force reconcile — it
242 // must observe the instance as missing and recreate it instead of
243 // failing on a 404.
244 yield* aisearch
245 .deleteNamespaceInstance({
246 accountId,
247 name: "default",
248 id: initial.instance.instanceId,
249 })
250 .pipe(
251 Effect.retry({
252 while: (e) => e._tag === "Forbidden",
253 schedule: Schedule.exponential("500 millis"),
254 times: 8,
255 }),
256 );
257 yield* expectGone(accountId, initial.instance.instanceId);
258
259 const healed = yield* stack.deploy(program({ maxNumResults: 10 }));
260
261 expect(healed.instance.instanceId).toEqual(initial.instance.instanceId);
262 const live = yield* getInstance(accountId, healed.instance.instanceId);
263 expect(live.id).toEqual(initial.instance.instanceId);
264
265 yield* stack.destroy();
266
267 yield* expectGone(accountId, healed.instance.instanceId);
268 }).pipe(logLevel),
269 {
270 tags: [
271 "provider:cloudflare",
272 "provider:cloudflare:ai",
273 "provider:cloudflare:apitoken",
274 "provider:cloudflare:r2",
275 "live",
276 ],
277 timeout: 240_000,
278 },
279);
280
281// Canonical `list()` test: instances are namespace-scoped, so `list()`
282// enumerates every namespace (including the account-provided `default`) and
283// fans out a paginated instance list per namespace, hydrating each into the
284// `read` Attributes shape. Deploy an instance and assert its id appears.
285test.provider(
286 "list enumerates the deployed instance",
287 (stack) =>
288 Effect.gen(function* () {
289 yield* stack.destroy();
290
291 const deployed = yield* stack.deploy(program());
292
293 const provider = yield* Provider.findProvider(
294 Cloudflare.AI.SearchInstance,
295 );
296 const all = yield* provider.list();
297
298 expect(
299 all.some((x) => x.instanceId === deployed.instance.instanceId),
300 ).toBe(true);
301
302 yield* stack.destroy();
303
304 yield* expectGone(
305 deployed.instance.accountId,
306 deployed.instance.instanceId,
307 );
308 }).pipe(logLevel),
309 {
310 tags: [
311 "provider:cloudflare",
312 "provider:cloudflare:ai",
313 "provider:cloudflare:apitoken",
314 "provider:cloudflare:r2",
315 "live",
316 ],
317 timeout: 240_000,
318 },
319);
320
321// A web-crawler source crawls a seed URL and needs no service token (unlike
322// an R2 source). Cloudflare only crawls a domain the account owns, so the
323// crawl is seeded at a Worker we deploy (its `workers.dev` URL is owned by the
324// account); the fixture serves a sitemap so discovery always finds content.
325test.provider(
326 "creates a web-crawler instance (no service token)",
327 (stack) =>
328 Effect.gen(function* () {
329 const { accountId } = yield* yield* CloudflareEnvironment;
330
331 yield* stack.destroy();
332
333 // Deploy the crawl target first and wait until its `workers.dev` URL is
334 // actually serving. AI Search fetches the seed synchronously when it
335 // validates a web-crawler at create time; against a URL that isn't live
336 // yet it finds no content and rejects with `missing_sitemap`.
337 const warmed = yield* stack.deploy(
338 Effect.gen(function* () {
339 return yield* AiSearchCrawlTargetWorker;
340 }),
341 );
342 const client = yield* HttpClient.HttpClient;
343 const targetUrl = warmed.url;
344 expect(targetUrl).toBeTypeOf("string");
345 yield* client.get(targetUrl as string).pipe(
346 Effect.flatMap((res) =>
347 res.status === 200
348 ? Effect.succeed(res)
349 : Effect.fail(new Error(`crawl target not ready: ${res.status}`)),
350 ),
351 Effect.retry({ schedule: Schedule.spaced("3 seconds"), times: 20 }),
352 );
353
354 const initial = yield* stack.deploy(
355 Effect.gen(function* () {
356 const target = yield* AiSearchCrawlTargetWorker;
357 const instance = yield* Cloudflare.AI.SearchInstance("Search", {
358 type: "web-crawler",
359 source: target.url.as<string>(),
360 sourceParams: {
361 webCrawler: {
362 // Cloudflare renamed `crawl` → `discover` and removed
363 // `crawlOptions` from the API. The fixture serves a
364 // sitemap, so discovery always finds content.
365 parseType: "discover",
366 },
367 },
368 });
369 return { target, instance };
370 }),
371 );
372
373 // Creating without any tokenId proves a web-crawler needs no token.
374 expect(initial.instance.type).toEqual("web-crawler");
375 expect(initial.instance.source).toEqual(initial.target.url);
376
377 const live = yield* getInstance(accountId, initial.instance.instanceId);
378 expect(live.type).toEqual("web-crawler");
379
380 yield* stack.destroy();
381
382 yield* expectGone(accountId, initial.instance.instanceId);
383 }).pipe(logLevel),
384 {
385 tags: [
386 "provider:cloudflare",
387 "provider:cloudflare:ai",
388 "provider:cloudflare:worker",
389 "live",
390 ],
391 timeout: 300_000,
392 },
393);
394
395// A program that places the instance in a custom namespace. The instance's
396// `namespace` references the namespace's `name` output, so the engine orders
397// instance-after-namespace on deploy (and namespace-after-instance on
398// destroy, so the namespace's instances are torn down before it).
399const nsProgram = (props?: Partial<Cloudflare.AI.SearchInstanceProps>) =>
400 Effect.gen(function* () {
401 const namespace = yield* Cloudflare.AI.SearchNamespace("AiSearchNs", {});
402 const bucket = yield* Cloudflare.R2.Bucket("AiSearchSource", {
403 forceDestroy: true,
404 });
405 const token = yield* scopedToken;
406 const instance = yield* Cloudflare.AI.SearchInstance("Search", {
407 tokenId: token.id,
408 source: bucket.bucketName,
409 namespace: namespace.name,
410 ...props,
411 });
412 return { namespace, bucket, instance };
413 });
414
415test.provider(
416 "creates an instance in a custom namespace and moving namespaces replaces",
417 (stack) =>
418 Effect.gen(function* () {
419 const { accountId } = yield* yield* CloudflareEnvironment;
420
421 yield* stack.destroy();
422
423 const initial = yield* stack.deploy(nsProgram());
424 expect(initial.instance.namespace).toEqual(initial.namespace.name);
425 expect(initial.namespace.name).not.toEqual("default");
426
427 // The instance is readable in its namespace, but NOT in `default`.
428 const live = yield* getInstance(
429 accountId,
430 initial.instance.instanceId,
431 initial.namespace.name,
432 );
433 expect(live.id).toEqual(initial.instance.instanceId);
434 yield* expectGone(accountId, initial.instance.instanceId, "default");
435
436 yield* stack.destroy();
437
438 yield* expectGone(
439 accountId,
440 initial.instance.instanceId,
441 initial.namespace.name,
442 );
443 }).pipe(logLevel),
444 {
445 tags: [
446 "provider:cloudflare",
447 "provider:cloudflare:ai",
448 "provider:cloudflare:apitoken",
449 "provider:cloudflare:r2",
450 "live",
451 ],
452 timeout: 240_000,
453 },
454);