1import * as Cloudflare from "@/Cloudflare";
2import * as Test from "@/Test/Alchemy";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import { CloudflareApiLive } from "@/Cloudflare/Providers.ts";
5import { waitForMetadata, waitForVectorize } from "./Readiness.ts";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import { HttpClientResponse } from "effect/http";
11import * as HttpClient from "effect/http/HttpClient";
12import Stack from "./fixtures/stack.ts";
13
14/**
15 * End-to-end test of the `Cloudflare.Vectorize` native worker binding against a
16 * real Cloudflare Worker + Vectorize index, covering BOTH invocation styles:
17 *
18 * - effect-worker: `yield* Cloudflare.Vectorize.SearchIndex(index)` inside a
19 * `Cloudflare.Worker` init, binding provided via `SearchIndexBinding`.
20 * - async-worker: the index declared on the Worker `env`, used as the native
21 * runtime `Vectorize` binding from a plain `async fetch`.
22 *
23 * Both workers share ONE index (vectors are id-prefixed by style so they stay
24 * independent) and are driven by a single `exercise(label, baseUrl)` flow that
25 * upserts → describes → queries → filtered-queries → getByIds. Vectorize
26 * mutations are eventually consistent, so reads are polled until visible.
27 */
28const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
29 providers: Cloudflare.providers(),
30});
31
32const logLevel = Effect.provideService(
33 MinimumLogLevel,
34 process.env.DEBUG ? "Debug" : "Info",
35);
36
37// Fresh workers.dev URLs take a few seconds to start serving 200s, and edge
38// propagation can still transiently 404/500 individual route hits after the
39// script is resolvable. Bound both the retry count and the entire request.
40const readinessRetry = {
41 schedule: Schedule.max([Schedule.spaced("1 second"), Schedule.recurs(8)]),
42} as const;
43
44const getJson = (url: string) =>
45 HttpClient.get(url).pipe(
46 Effect.flatMap(HttpClientResponse.filterStatusOk),
47 Effect.flatMap((res) => res.json),
48 Effect.retry(readinessRetry),
49 Effect.timeout("15 seconds"),
50 );
51
52const postJson = (url: string) =>
53 HttpClient.post(url).pipe(
54 Effect.flatMap(HttpClientResponse.filterStatusOk),
55 Effect.flatMap((res) => res.json),
56 Effect.retry(readinessRetry),
57 Effect.timeout("15 seconds"),
58 );
59
60/** Drives the client surface using this worker's vector ID prefix. */
61const exercise = (label: string, baseUrl: string) =>
62 Effect.gen(function* () {
63 // Gate on /health first to prove the script is resolvable.
64 yield* HttpClient.get(`${baseUrl}/health`).pipe(
65 Effect.flatMap(HttpClientResponse.filterStatusOk),
66 Effect.timeout("5 seconds"),
67 Effect.retry({
68 schedule: Schedule.max([
69 Schedule.spaced("1 second"),
70 Schedule.recurs(8),
71 ]),
72 }),
73 Effect.timeout("15 seconds"),
74 );
75
76 const upsertRes = yield* postJson(`${baseUrl}/upsert`);
77 expect(upsertRes).toMatchObject({ mutationId: expect.any(String) });
78
79 const describeRes = yield* getJson(`${baseUrl}/describe`);
80 expect(describeRes).toMatchObject({ dimensions: 32 });
81
82 // Observe all read surfaces under one readiness budget. Both workers
83 // write to the shared index, so readiness is judged by this worker's
84 // own vectors rather than the index-wide processed mutation.
85 const { queryBody, getRes, filteredBody } = yield* waitForVectorize({
86 description: `[${label}] upserted vectors visible to query, ID lookup and metadata filtering`,
87 effect: Effect.all(
88 {
89 queryBody: getJson(`${baseUrl}/query`).pipe(
90 Effect.map((body) => body as { count: number; ids: string[] }),
91 ),
92 getRes: getJson(`${baseUrl}/get`).pipe(
93 Effect.map((body) => body as { ids: string[] }),
94 ),
95 filteredBody: getJson(`${baseUrl}/query-filtered`).pipe(
96 Effect.map(
97 (body) =>
98 body as { count: number; ids: string[]; kinds: string[] },
99 ),
100 ),
101 },
102 { concurrency: "unbounded" },
103 ),
104 predicate: ({ queryBody, getRes, filteredBody }) =>
105 queryBody.count >= 3 &&
106 getRes.ids.length === 2 &&
107 filteredBody.ids.length === 1 &&
108 filteredBody.kinds.length === 1,
109 });
110 expect(queryBody.count).toBeGreaterThanOrEqual(3);
111 expect(queryBody.ids[0]).toBe(`${label}-a`);
112 expect(getRes).toEqual({ ids: [`${label}-a`, `${label}-b`] });
113 expect(filteredBody.ids).toEqual([`${label}-b`]);
114 expect(filteredBody.kinds).toEqual(["second"]);
115 }).pipe(logLevel);
116
117const stack = beforeAll(
118 Effect.gen(function* () {
119 yield* destroy(Stack);
120 const deployed = yield* deploy(Stack);
121 const { accountId } = yield* yield* CloudflareEnvironment;
122 yield* waitForMetadata(accountId, deployed.indexName, [
123 { propertyName: "kind", indexType: "string" },
124 ]);
125 return deployed;
126 }).pipe(Effect.provide(CloudflareApiLive())),
127 { timeout: 210_000 },
128);
129afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
130
131test(
132 "effect-worker: SearchIndex(index) exercises the client surface",
133 Effect.gen(function* () {
134 const { effectWorkerUrl } = yield* stack;
135 yield* exercise("effect", effectWorkerUrl);
136 }),
137 {
138 tags: [
139 "provider:cloudflare",
140 "provider:cloudflare:vectorize",
141 "provider:cloudflare:worker",
142 "live",
143 ],
144 timeout: 210_000,
145 },
146);
147
148test(
149 "async-worker: env Vectorize binding exercises the client surface",
150 Effect.gen(function* () {
151 const { asyncWorkerUrl } = yield* stack;
152 yield* exercise("async", asyncWorkerUrl);
153 }),
154 {
155 tags: [
156 "provider:cloudflare",
157 "provider:cloudflare:vectorize",
158 "provider:cloudflare:worker",
159 "live",
160 ],
161 timeout: 210_000,
162 },
163);