1import * as Cloudflare from "@/Cloudflare/index.ts";
2import * as Effect from "effect/Effect";
3import { HttpServerRequest } from "effect/http/HttpServerRequest";
4import * as HttpServerResponse from "effect/http/HttpServerResponse";
5import { ensureMetaIndex, TestIndex } from "./index-resource.ts";
6import { seedVectors, vector } from "./vectors.ts";
7
8const LABEL = "effect";
9
10/**
11 * Effect-native Worker style: `Cloudflare.Vectorize.SearchIndex(index)` during
12 * init attaches the native `vectorize` binding to this Worker and resolves to
13 * the Effect-flavored client (`upsert`, `describe`, `query`, `getByIds`).
14 *
15 * POST /upsert — `index.upsert([...])`
16 * GET /describe — `index.describe()`
17 * GET /query — `index.query(vector, { topK })`
18 * GET /query-filtered — `index.query(vector, { filter: { kind: ... } })`
19 * GET /get — `index.getByIds([...])`
20 */
21export default class VectorizeEffectWorker extends Cloudflare.Worker<VectorizeEffectWorker>()(
22 "VectorizeEffectWorker",
23 {
24 main: import.meta.url,
25 },
26 Effect.gen(function* () {
27 const index = yield* TestIndex;
28 const vec = yield* Cloudflare.Vectorize.SearchIndex(index);
29
30 return {
31 fetch: Effect.gen(function* () {
32 const request = yield* HttpServerRequest;
33 const url = new URL(request.url, "http://x");
34
35 if (request.method === "GET" && url.pathname === "/health") {
36 return yield* HttpServerResponse.json({ ok: true });
37 }
38
39 if (request.method === "POST" && url.pathname === "/upsert") {
40 const mutation = yield* vec.upsert(seedVectors(LABEL));
41 return yield* HttpServerResponse.json({
42 mutationId: mutation.mutationId,
43 });
44 }
45
46 if (request.method === "GET" && url.pathname === "/describe") {
47 const info = yield* vec.describe();
48 return yield* HttpServerResponse.json({
49 dimensions: info.dimensions,
50 vectorCount: info.vectorCount,
51 });
52 }
53
54 if (request.method === "GET" && url.pathname === "/query") {
55 const matches = yield* vec.query(vector(0.1, 0.2, 0.3), {
56 topK: 20,
57 returnMetadata: "all",
58 });
59 // Restrict to this worker's own vectors so the shared index doesn't
60 // leak the async worker's data into the assertions.
61 const mine = matches.matches.filter((m) =>
62 m.id.startsWith(`${LABEL}-`),
63 );
64 return yield* HttpServerResponse.json({
65 count: mine.length,
66 ids: mine.map((m) => m.id),
67 });
68 }
69
70 if (request.method === "GET" && url.pathname === "/query-filtered") {
71 const matches = yield* vec.query(vector(0.1, 0.2, 0.3), {
72 topK: 3,
73 returnMetadata: "all",
74 filter: { kind: { $eq: "second" } },
75 });
76 const mine = matches.matches.filter((m) =>
77 m.id.startsWith(`${LABEL}-`),
78 );
79 return yield* HttpServerResponse.json({
80 count: mine.length,
81 ids: mine.map((m) => m.id),
82 kinds: mine.map(
83 (m) => (m.metadata as { kind?: string } | undefined)?.kind,
84 ),
85 });
86 }
87
88 if (request.method === "GET" && url.pathname === "/get") {
89 const vectors = yield* vec.getByIds([`${LABEL}-a`, `${LABEL}-b`]);
90 return yield* HttpServerResponse.json({
91 ids: vectors.map((v) => v.id).sort(),
92 });
93 }
94
95 return HttpServerResponse.text("Not Found", { status: 404 });
96 }),
97 };
98 }).pipe(Effect.provide(Cloudflare.Vectorize.SearchIndexBinding)),
99) {}