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";
10
11const { test } = Test.make({ providers: Cloudflare.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18// The scoped API token the test harness mints propagates eventually-
19// consistently across Cloudflare's edge — ride out 403 blips
20// (`Forbidden`, declared in the distilled error union) on the test's
21// own out-of-band verification calls.
22const getNamespace = (accountId: string, name: string) =>
23 aisearch.readNamespace({ accountId, name }).pipe(
24 Effect.retry({
25 while: (e) => e._tag === "Forbidden",
26 schedule: Schedule.exponential("500 millis"),
27 times: 8,
28 }),
29 );
30
31// A freshly created namespace can lag on the read path — ride out
32// `NamespaceNotFound` (Cloudflare error code 7063) with a bounded retry
33// when the namespace is expected to exist.
34const waitForNamespace = (accountId: string, name: string) =>
35 getNamespace(accountId, name).pipe(
36 Effect.retry({
37 while: (e) => e._tag === "NamespaceNotFound",
38 schedule: Schedule.exponential("500 millis"),
39 times: 8,
40 }),
41 );
42
43const expectGone = (accountId: string, name: string) =>
44 getNamespace(accountId, name).pipe(
45 Effect.flatMap(() => Effect.fail({ _tag: "NamespaceNotDeleted" } as const)),
46 // A missing namespace surfaces as `NamespaceNotFound` (Cloudflare
47 // error code 7063) — that's the success condition here.
48 Effect.catchTag("NamespaceNotFound", () => Effect.void),
49 Effect.retry({
50 while: (e) => e._tag === "NamespaceNotDeleted",
51 schedule: Schedule.max([
52 Schedule.exponential("500 millis"),
53 Schedule.recurs(10),
54 ]),
55 }),
56 );
57
58// Namespace reads are served eventually-consistently — poll (bounded)
59// until the live description converges to the expected value.
60const expectDescription = (
61 accountId: string,
62 name: string,
63 expected: string | undefined,
64) =>
65 getNamespace(accountId, name).pipe(
66 Effect.repeat({
67 schedule: Schedule.spaced("2 seconds"),
68 until: (ns) => (ns.description ?? undefined) === expected,
69 times: 30,
70 }),
71 Effect.map((ns) => expect(ns.description ?? undefined).toEqual(expected)),
72 );
73
74const program = (props?: Cloudflare.AI.SearchNamespaceProps) =>
75 Effect.gen(function* () {
76 const namespace = yield* Cloudflare.AI.SearchNamespace("Namespace", {
77 ...props,
78 });
79 return { namespace };
80 });
81
82test.provider(
83 "create, update description in place, and delete a namespace",
84 (stack) =>
85 Effect.gen(function* () {
86 const { accountId } = yield* yield* CloudflareEnvironment;
87
88 yield* stack.destroy();
89
90 // Create — engine-generated name, no description.
91 const initial = yield* stack.deploy(program());
92
93 expect(initial.namespace.name).toBeTruthy();
94 expect(initial.namespace.accountId).toEqual(accountId);
95 expect(initial.namespace.description).toBeUndefined();
96
97 const live = yield* waitForNamespace(accountId, initial.namespace.name);
98 expect(live.name).toEqual(initial.namespace.name);
99
100 // Update the description in place — same namespace (same name and
101 // creation timestamp).
102 const updated = yield* stack.deploy(
103 program({ description: "alchemy aisearch namespace test" }),
104 );
105
106 expect(updated.namespace.name).toEqual(initial.namespace.name);
107 expect(updated.namespace.description).toEqual(
108 "alchemy aisearch namespace test",
109 );
110
111 yield* expectDescription(
112 accountId,
113 updated.namespace.name,
114 "alchemy aisearch namespace test",
115 );
116
117 // Redeploying identical props is a no-op (still the same namespace).
118 const noop = yield* stack.deploy(
119 program({ description: "alchemy aisearch namespace test" }),
120 );
121 expect(noop.namespace.name).toEqual(initial.namespace.name);
122
123 // Clearing the description converges back to unset.
124 const cleared = yield* stack.deploy(program());
125 expect(cleared.namespace.name).toEqual(initial.namespace.name);
126 expect(cleared.namespace.description).toBeUndefined();
127
128 yield* expectDescription(accountId, cleared.namespace.name, undefined);
129
130 yield* stack.destroy();
131
132 yield* expectGone(accountId, initial.namespace.name);
133
134 // Destroy again — delete must be idempotent (already gone).
135 yield* stack.destroy();
136 }).pipe(logLevel),
137 {
138 tags: ["provider:cloudflare", "provider:cloudflare:ai", "live"],
139 timeout: 240_000,
140 },
141);
142
143test.provider(
144 "changing the name triggers a replacement",
145 (stack) =>
146 Effect.gen(function* () {
147 const { accountId } = yield* yield* CloudflareEnvironment;
148 const user = (process.env.USER ?? "test").toLowerCase();
149 const nameA = `${user}-alch-ais-ns-a`;
150 const nameB = `${user}-alch-ais-ns-b`;
151
152 yield* stack.destroy();
153
154 const initial = yield* stack.deploy(program({ name: nameA }));
155 expect(initial.namespace.name).toEqual(nameA);
156
157 const replaced = yield* stack.deploy(program({ name: nameB }));
158
159 // The name is the namespace's identity — a new physical namespace
160 // exists and the old one was deleted as part of the replacement.
161 expect(replaced.namespace.name).toEqual(nameB);
162
163 const live = yield* waitForNamespace(accountId, nameB);
164 expect(live.name).toEqual(nameB);
165
166 yield* expectGone(accountId, nameA);
167
168 yield* stack.destroy();
169
170 yield* expectGone(accountId, nameB);
171 }).pipe(logLevel),
172 {
173 tags: ["provider:cloudflare", "provider:cloudflare:ai", "live"],
174 timeout: 240_000,
175 },
176);
177
178// Canonical `list()` test (account-scoped collection): deploy a real
179// namespace, resolve the provider from context via the typed
180// `findProvider`, call `list()`, and assert the deployed namespace
181// appears in the exhaustively-paginated result.
182test.provider(
183 "list enumerates the deployed namespace",
184 (stack) =>
185 Effect.gen(function* () {
186 yield* stack.destroy();
187
188 const deployed = yield* stack.deploy(program());
189
190 const provider = yield* Provider.findProvider(
191 Cloudflare.AI.SearchNamespace,
192 );
193 const all = yield* provider.list();
194
195 expect(all.some((ns) => ns.name === deployed.namespace.name)).toBe(true);
196
197 yield* stack.destroy();
198 }).pipe(logLevel),
199 {
200 tags: ["provider:cloudflare", "provider:cloudflare:ai", "live"],
201 timeout: 240_000,
202 },
203);
204
205test.provider(
206 "adopts the reserved default namespace without deleting it on teardown",
207 (stack) =>
208 Effect.gen(function* () {
209 const { accountId } = yield* yield* CloudflareEnvironment;
210
211 yield* stack.destroy();
212
213 // The account-provided `default` namespace always exists; adopting it
214 // is bindable but must never be created or torn down.
215 const adopted = yield* stack.deploy(program({ name: "default" }));
216 expect(adopted.namespace.name).toEqual("default");
217
218 const live = yield* getNamespace(accountId, "default");
219 expect(live.name).toEqual("default");
220
221 yield* stack.destroy();
222
223 // Destroy is a no-op for the default namespace — it must still exist.
224 const stillThere = yield* getNamespace(accountId, "default");
225 expect(stillThere.name).toEqual("default");
226 }).pipe(logLevel),
227 {
228 tags: ["provider:cloudflare", "provider:cloudflare:ai", "live"],
229 timeout: 120_000,
230 },
231);
232
233test.provider(
234 "recreates after out-of-band delete",
235 (stack) =>
236 Effect.gen(function* () {
237 const { accountId } = yield* yield* CloudflareEnvironment;
238
239 yield* stack.destroy();
240
241 const initial = yield* stack.deploy(program());
242
243 // Delete the namespace out-of-band. A redeploy with identical props
244 // is a planner no-op, so change a mutable prop to force reconcile —
245 // it must observe the namespace as missing and recreate it instead
246 // of failing on a 404.
247 yield* aisearch
248 .deleteNamespace({ accountId, name: initial.namespace.name })
249 .pipe(
250 Effect.retry({
251 while: (e) => e._tag === "Forbidden",
252 schedule: Schedule.exponential("500 millis"),
253 times: 8,
254 }),
255 );
256 yield* expectGone(accountId, initial.namespace.name);
257
258 const healed = yield* stack.deploy(
259 program({ description: "healed after out-of-band delete" }),
260 );
261
262 expect(healed.namespace.name).toEqual(initial.namespace.name);
263 yield* expectDescription(
264 accountId,
265 healed.namespace.name,
266 "healed after out-of-band delete",
267 );
268
269 yield* stack.destroy();
270
271 yield* expectGone(accountId, healed.namespace.name);
272 }).pipe(logLevel),
273 {
274 tags: ["provider:cloudflare", "provider:cloudflare:ai", "live"],
275 timeout: 240_000,
276 },
277);