1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import { poll } from "@/Util/poll.ts";
6import { waitForMetadata } from "./Readiness.ts";
7import * as vectorize from "@distilled.cloud/cloudflare/vectorize";
8import { describe, expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12
13const { test } = Test.make({ providers: Cloudflare.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20// Bounded typed wait for a parent VectorizeIndex to actually disappear from
21// Cloudflare after a delete/replace. Index deletes are quick; allow eight
22// retries before surfacing the last observed state as a `PredicateFailed`.
23const waitForIndexGone = (accountId: string, indexName: string) =>
24 poll({
25 description: `parent index ${indexName} is gone`,
26 effect: vectorize.getIndex({ accountId, indexName }).pipe(
27 Effect.timeout("5 seconds"),
28 Effect.as(false),
29 Effect.catchTag(["NotFound", "Gone"], () => Effect.succeed(true)),
30 ),
31 predicate: (gone) => gone,
32 schedule: Schedule.max([Schedule.spaced("2 seconds"), Schedule.recurs(8)]),
33 });
34
35describe.skipIf(!!process.env.FAST)(
36 "Cloudflare.Vectorize.MetadataIndex",
37 { tags: ["provider:cloudflare", "provider:cloudflare:vectorize", "live"] },
38 () => {
39 test.provider(
40 "create and delete a metadata index",
41 (stack) =>
42 Effect.gen(function* () {
43 const { accountId } = yield* yield* CloudflareEnvironment;
44
45 yield* stack.destroy();
46
47 const { index, meta } = yield* stack.deploy(
48 Effect.gen(function* () {
49 const index = yield* Cloudflare.Vectorize.Index("ParentIdx", {
50 dimensions: 32,
51 metric: "cosine",
52 });
53 const meta = yield* Cloudflare.Vectorize.MetadataIndex(
54 "MetaIdx",
55 {
56 indexName: index.indexName,
57 propertyName: "category",
58 indexType: "string",
59 },
60 );
61 return { index, meta };
62 }),
63 );
64
65 expect(meta.propertyName).toBe("category");
66 expect(meta.indexType).toBe("string");
67 expect(meta.indexName).toBe(index.indexName);
68
69 // The metadata index appears in the parent's list once Cloudflare
70 // processes the async mutation.
71 const entries = yield* waitForMetadata(accountId, index.indexName, [
72 meta,
73 ]);
74 expect(
75 entries
76 .find((e) => e.propertyName === "category")
77 ?.indexType?.toLowerCase(),
78 ).toBe("string");
79
80 yield* stack.destroy();
81
82 // Both parent and metadata index are gone.
83 const after = yield* listMetadataIndexes(accountId, index.indexName);
84 expect(after.length).toBe(0);
85 }).pipe(
86 Effect.ensuring(
87 stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore),
88 ),
89 logLevel,
90 ),
91 { timeout: 210_000 },
92 );
93
94 test.provider(
95 "multiple metadata indexes on the same parent coexist",
96 (stack) =>
97 Effect.gen(function* () {
98 const { accountId } = yield* yield* CloudflareEnvironment;
99
100 yield* stack.destroy();
101
102 const { index, category, price } = yield* stack.deploy(
103 Effect.gen(function* () {
104 const index = yield* Cloudflare.Vectorize.Index("MultiParent", {
105 dimensions: 32,
106 metric: "cosine",
107 });
108 const category = yield* Cloudflare.Vectorize.MetadataIndex(
109 "CategoryMeta",
110 {
111 indexName: index.indexName,
112 propertyName: "category",
113 indexType: "string",
114 },
115 );
116 const price = yield* Cloudflare.Vectorize.MetadataIndex(
117 "PriceMeta",
118 {
119 indexName: index.indexName,
120 propertyName: "price",
121 indexType: "number",
122 },
123 );
124 return { index, category, price };
125 }),
126 );
127 const entries = yield* waitForMetadata(accountId, index.indexName, [
128 category,
129 price,
130 ]);
131 expect(
132 entries
133 .find((e) => e.propertyName === "category")
134 ?.indexType?.toLowerCase(),
135 ).toBe("string");
136 expect(
137 entries
138 .find((e) => e.propertyName === "price")
139 ?.indexType?.toLowerCase(),
140 ).toBe("number");
141
142 yield* stack.destroy();
143 }).pipe(
144 // Guarantee teardown even if a poll/assertion fails or the test is
145 // interrupted by a timeout — the scratch stack's state is in-memory
146 // only, so a body that throws before the trailing `destroy()` would
147 // otherwise leak the parent + metadata indexes with no next-run
148 // cleanup.
149 Effect.ensuring(
150 stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore),
151 ),
152 logLevel,
153 ),
154 { timeout: 210_000 },
155 );
156
157 test.provider(
158 "replacing the parent index also replaces the metadata index",
159 (stack) =>
160 Effect.gen(function* () {
161 const { accountId } = yield* yield* CloudflareEnvironment;
162
163 yield* stack.destroy();
164
165 // Initial deploy with dimensions=32.
166 const { index: oldIndex } = yield* stack.deploy(
167 Effect.gen(function* () {
168 const index = yield* Cloudflare.Vectorize.Index("ReplaceParent", {
169 dimensions: 32,
170 metric: "cosine",
171 });
172 yield* Cloudflare.Vectorize.MetadataIndex("ReplaceMeta", {
173 indexName: index.indexName,
174 propertyName: "tag",
175 indexType: "string",
176 });
177 return { index };
178 }),
179 );
180 // Replacement must also work while the old metadata mutation is pending.
181
182 // Re-deploy with different dimensions — the parent replaces, which
183 // also replaces the metadata index on the new parent.
184 const { index: newIndex, meta: newMeta } = yield* stack.deploy(
185 Effect.gen(function* () {
186 const index = yield* Cloudflare.Vectorize.Index("ReplaceParent", {
187 dimensions: 64,
188 metric: "cosine",
189 });
190 const meta = yield* Cloudflare.Vectorize.MetadataIndex(
191 "ReplaceMeta",
192 {
193 indexName: index.indexName,
194 propertyName: "tag",
195 indexType: "string",
196 },
197 );
198 return { index, meta };
199 }),
200 );
201
202 expect(newIndex.indexName).not.toBe(oldIndex.indexName);
203 expect(newMeta.indexName).toBe(newIndex.indexName);
204
205 // Old parent is gone — bounded typed wait for the replacement's
206 // delete of the old index to settle.
207 const oldGone = yield* waitForIndexGone(
208 accountId,
209 oldIndex.indexName,
210 );
211 expect(oldGone).toBe(true);
212
213 // The new parent has the metadata index.
214 yield* waitForMetadata(accountId, newIndex.indexName, [newMeta]);
215
216 yield* stack.destroy();
217 }).pipe(
218 Effect.ensuring(
219 stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore),
220 ),
221 logLevel,
222 ),
223 { timeout: 210_000 },
224 );
225
226 test.provider(
227 "list enumerates the deployed metadata index",
228 (stack) =>
229 Effect.gen(function* () {
230 yield* stack.destroy();
231
232 const { index, meta } = yield* stack.deploy(
233 Effect.gen(function* () {
234 const index = yield* Cloudflare.Vectorize.Index("ListParent", {
235 dimensions: 32,
236 metric: "cosine",
237 });
238 const meta = yield* Cloudflare.Vectorize.MetadataIndex(
239 "ListMeta",
240 {
241 indexName: index.indexName,
242 propertyName: "category",
243 indexType: "string",
244 },
245 );
246 return { index, meta };
247 }),
248 );
249
250 const provider = yield* Provider.findProvider(
251 Cloudflare.Vectorize.MetadataIndex,
252 );
253
254 const { accountId } = yield* yield* CloudflareEnvironment;
255 yield* waitForMetadata(accountId, index.indexName, [meta]);
256 const all = yield* provider.list().pipe(Effect.timeout("20 seconds"));
257
258 const entry = all.find(
259 (x) =>
260 x.indexName === index.indexName && x.propertyName === "category",
261 );
262 expect(entry?.indexType).toBe("string");
263 expect(entry?.accountId).toBeDefined();
264
265 yield* stack.destroy();
266 }).pipe(
267 Effect.ensuring(
268 stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore),
269 ),
270 logLevel,
271 ),
272 { timeout: 210_000 },
273 );
274
275 test.provider(
276 "destroy is idempotent when the parent index was deleted out-of-band",
277 (stack) =>
278 Effect.gen(function* () {
279 const { accountId } = yield* yield* CloudflareEnvironment;
280
281 yield* stack.destroy();
282
283 const { index } = yield* stack.deploy(
284 Effect.gen(function* () {
285 const index = yield* Cloudflare.Vectorize.Index("OobParent", {
286 dimensions: 32,
287 metric: "cosine",
288 });
289 yield* Cloudflare.Vectorize.MetadataIndex("OobMeta", {
290 indexName: index.indexName,
291 propertyName: "ns",
292 indexType: "string",
293 });
294 return { index };
295 }),
296 );
297 // Simulate Cloudflare's cascading delete: drop the parent directly.
298 // On Cloudflare's side this also removes the metadata index.
299 // Deletion must work even while the metadata mutation is pending;
300 // waiting for query visibility is not a prerequisite for teardown.
301 yield* vectorize.deleteIndex({
302 accountId,
303 indexName: index.indexName,
304 });
305
306 // Bounded typed wait for the out-of-band delete to actually settle
307 // before exercising the idempotent `destroy` path.
308 const gone = yield* waitForIndexGone(accountId, index.indexName);
309 expect(gone).toBe(true);
310
311 // The metadata index provider's delete tolerates 404/410 from the
312 // missing parent, so `destroy` succeeds without erroring.
313 yield* stack.destroy();
314 }).pipe(
315 Effect.ensuring(
316 stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore),
317 ),
318 logLevel,
319 ),
320 { timeout: 210_000 },
321 );
322 },
323);
324
325const listMetadataIndexes = Effect.fn(function* (
326 accountId: string,
327 indexName: string,
328) {
329 return yield* vectorize
330 .listIndexMetadataIndexes({ accountId, indexName })
331 .pipe(
332 Effect.timeout("5 seconds"),
333 Effect.map((res) => res.metadataIndexes ?? []),
334 Effect.catchTag(["NotFound", "Gone"], () =>
335 // Parent index gone — treat as "no metadata indexes".
336 Effect.succeed([]),
337 ),
338 );
339});