| 1 | import { Action } from "@/Action"; |
| 2 | import * as Cloudflare from "@/Cloudflare"; |
| 3 | import * as Test from "@/Test/Alchemy"; |
| 4 | import { expect } from "alchemy-test"; |
| 5 | import type * as runtime from "@cloudflare/workers-types"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import { MinimumLogLevel } from "effect/References"; |
| 8 | import * as Schedule from "effect/Schedule"; |
| 9 | |
| 10 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 11 | |
| 12 | const 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". |
| 20 | const DIMENSIONS = 32; |
| 21 | const v1 = Array.from({ length: DIMENSIONS }, (_, i) => (i + 1) / 100); |
| 22 | const 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. |
| 31 | test.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 | ); |