1import { Action } from "@/Action";
2import * as Cloudflare from "@/Cloudflare";
3import * as Test from "@/Test/Alchemy";
4import { expect } from "alchemy-test";
5import type * as runtime from "@cloudflare/workers-types";
6import * as Effect from "effect/Effect";
7import { MinimumLogLevel } from "effect/References";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: Cloudflare.providers() });
11
12const logLevel = Effect.provideService(
13 MinimumLogLevel,
14 process.env.DEBUG ? "Debug" : "Info",
15);
16
17// Vectorize requires dimensions in [32, 1536]; use the minimum. Two
18// deterministic, well-separated 32-d vectors so the nearest neighbor of the
19// query vector (v1) is unambiguously "1".
20const DIMENSIONS = 32;
21const v1 = Array.from({ length: DIMENSIONS }, (_, i) => (i + 1) / 100);
22const v2 = Array.from({ length: DIMENSIONS }, (_, i) => 1 - (i + 1) / 100);
23
24// Binding a Vectorize index inside an Action via `SearchIndexLocal` — the local
25// (current-credentials) implementation of the `SearchIndex` binding. Exercises
26// the binding client (upsert/query/getByIds) over the Vectorize HTTP API and
27// the accessor mechanism (`yield* index.indexName` resolved at apply time).
28//
29// Vectorize mutations are async / eventually consistent, so after the upsert we
30// poll `query` on a bounded schedule until the seeded vector is returnable.
31test.provider(
32 "SearchIndexLocal: seed and query an index from an Action",
33 (stack) =>
34 Effect.gen(function* () {
35 yield* stack.destroy();
36
37 const out = yield* stack.deploy(
38 Effect.gen(function* () {
39 const index = yield* Cloudflare.Vectorize.Index("SeedIndex", {
40 dimensions: DIMENSIONS,
41 metric: "cosine",
42 });
43
44 const Seed = Action(
45 "Seed",
46 Effect.gen(function* () {
47 const vec = yield* Cloudflare.Vectorize.SearchIndex(index);
48 // Accessor — resolved at apply time against the tracker.
49 const indexName = yield* index.indexName;
50
51 return Effect.fn(function* () {
52 const vectors: runtime.VectorizeVector[] = [
53 { id: "1", values: v1 },
54 { id: "2", values: v2 },
55 ];
56
57 const mutation = yield* vec.upsert(vectors);
58
59 // Vectorize processes mutations asynchronously. Poll the query
60 // until the seeded vector is returnable (bounded — fail fast if
61 // provisioning is slower than the schedule allows).
62 const matches = yield* vec
63 .query(v1, {
64 topK: 2,
65 returnValues: true,
66 })
67 .pipe(
68 Effect.repeat({
69 schedule: Schedule.spaced("3 seconds"),
70 until: (m) => m.matches.some((x) => x.id === "1"),
71 times: 20,
72 }),
73 );
74
75 const fetched = yield* vec.getByIds(["1"]);
76
77 return {
78 indexName: yield* indexName,
79 mutationId: mutation.mutationId,
80 topId: matches.matches[0]?.id,
81 matchIds: matches.matches.map((m) => m.id),
82 fetchedIds: fetched.map((v) => v.id),
83 };
84 });
85 }).pipe(Effect.provide(Cloudflare.Vectorize.SearchIndexLocal)),
86 );
87
88 return yield* Seed({});
89 }),
90 );
91
92 expect(out.indexName).toBeTruthy();
93 expect(out.mutationId).toBeTruthy();
94 // Nearest neighbor of the first vector is itself.
95 expect(out.topId).toBe("1");
96 expect(out.matchIds).toContain("1");
97 expect(out.fetchedIds).toContain("1");
98
99 yield* stack.destroy();
100 }).pipe(logLevel),
101 {
102 tags: ["provider:cloudflare", "provider:cloudflare:vectorize", "live"],
103 timeout: 120_000,
104 },
105);