1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as aiGateway from "@distilled.cloud/cloudflare/ai-gateway";
6import { describe, expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11const { test } = Test.make({ providers: Cloudflare.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18const GATEWAY_ID = "alchemy-test-aigw-dataset";
19const GATEWAY_ID_B = "alchemy-test-aigw-dataset-b";
20
21class DatasetStillExists extends Data.TaggedError("DatasetStillExists") {}
22
23// A deleted dataset surfaces as `DatasetNotFound` (Cloudflare error code
24// 7002, which also covers a deleted parent gateway) — that's the success
25// condition here.
26const expectGone = (accountId: string, gatewayId: string, datasetId: string) =>
27 aiGateway.getDataset({ accountId, gatewayId, id: datasetId }).pipe(
28 Effect.flatMap(() => Effect.fail(new DatasetStillExists())),
29 Effect.retry({
30 while: (e): e is DatasetStillExists => e instanceof DatasetStillExists,
31 schedule: Schedule.max([
32 Schedule.exponential("250 millis"),
33 Schedule.recurs(10),
34 ]),
35 }),
36 Effect.catchTag("DatasetNotFound", () => Effect.void),
37 );
38
39// Every case provisions a gateway under the same fixed `GATEWAY_ID`, so under
40// the suite's default concurrent execution one case's `stack.destroy()` would
41// delete the gateway out from under another (yielding "Gateway ... not
42// found"). Run them one at a time so each owns its gateway lifecycle.
43describe.sequential(
44 "AiGatewayDataset",
45 { tags: ["provider:cloudflare", "provider:cloudflare:ai", "live"] },
46 () => {
47 test.provider("create, update, delete a dataset on a gateway", (stack) =>
48 Effect.gen(function* () {
49 const { accountId } = yield* yield* CloudflareEnvironment;
50
51 yield* stack.destroy();
52
53 const initial = yield* stack.deploy(
54 Effect.gen(function* () {
55 const gateway = yield* Cloudflare.AI.Gateway("DatasetGateway", {
56 id: GATEWAY_ID,
57 });
58 const dataset = yield* Cloudflare.AI.Dataset("Dataset", {
59 gatewayId: gateway.gatewayId,
60 name: "alchemy-test-dataset",
61 filters: [{ key: "success", operator: "eq", value: [true] }],
62 });
63 return { gateway, dataset };
64 }),
65 );
66
67 expect(initial.dataset.datasetId).toBeDefined();
68 expect(initial.dataset.accountId).toEqual(accountId);
69 expect(initial.dataset.gatewayId).toEqual(GATEWAY_ID);
70 expect(initial.dataset.name).toEqual("alchemy-test-dataset");
71 expect(initial.dataset.enable).toBe(true);
72 expect(initial.dataset.filters).toEqual([
73 { key: "success", operator: "eq", value: [true] },
74 ]);
75
76 // Verify out-of-band via the API.
77 const live = yield* aiGateway.getDataset({
78 accountId,
79 gatewayId: GATEWAY_ID,
80 id: initial.dataset.datasetId,
81 });
82 expect(live.name).toEqual("alchemy-test-dataset");
83 expect(live.enable).toBe(true);
84 expect(live.filters).toEqual([
85 { key: "success", operator: "eq", value: [true] },
86 ]);
87
88 // Update mutable props in place — same dataset id.
89 const updated = yield* stack.deploy(
90 Effect.gen(function* () {
91 const gateway = yield* Cloudflare.AI.Gateway("DatasetGateway", {
92 id: GATEWAY_ID,
93 });
94 const dataset = yield* Cloudflare.AI.Dataset("Dataset", {
95 gatewayId: gateway.gatewayId,
96 name: "alchemy-test-dataset-v2",
97 enable: false,
98 filters: [
99 { key: "provider", operator: "eq", value: ["workers-ai"] },
100 { key: "cached", operator: "eq", value: [false] },
101 ],
102 });
103 return { gateway, dataset };
104 }),
105 );
106
107 expect(updated.dataset.datasetId).toEqual(initial.dataset.datasetId);
108 expect(updated.dataset.name).toEqual("alchemy-test-dataset-v2");
109 expect(updated.dataset.enable).toBe(false);
110 expect(updated.dataset.filters).toHaveLength(2);
111
112 const liveUpdated = yield* aiGateway.getDataset({
113 accountId,
114 gatewayId: GATEWAY_ID,
115 id: initial.dataset.datasetId,
116 });
117 expect(liveUpdated.name).toEqual("alchemy-test-dataset-v2");
118 expect(liveUpdated.enable).toBe(false);
119 expect(liveUpdated.filters).toHaveLength(2);
120
121 // Redeploying identical props is a no-op (still the same dataset).
122 const noop = yield* stack.deploy(
123 Effect.gen(function* () {
124 const gateway = yield* Cloudflare.AI.Gateway("DatasetGateway", {
125 id: GATEWAY_ID,
126 });
127 const dataset = yield* Cloudflare.AI.Dataset("Dataset", {
128 gatewayId: gateway.gatewayId,
129 name: "alchemy-test-dataset-v2",
130 enable: false,
131 filters: [
132 { key: "provider", operator: "eq", value: ["workers-ai"] },
133 { key: "cached", operator: "eq", value: [false] },
134 ],
135 });
136 return { gateway, dataset };
137 }),
138 );
139 expect(noop.dataset.datasetId).toEqual(initial.dataset.datasetId);
140
141 yield* stack.destroy();
142
143 yield* expectGone(accountId, GATEWAY_ID, initial.dataset.datasetId);
144 }).pipe(logLevel),
145 );
146
147 test.provider("replaces dataset when the gateway changes", (stack) =>
148 Effect.gen(function* () {
149 const { accountId } = yield* yield* CloudflareEnvironment;
150
151 yield* stack.destroy();
152
153 const initial = yield* stack.deploy(
154 Effect.gen(function* () {
155 const gatewayA = yield* Cloudflare.AI.Gateway("ReplaceGatewayA", {
156 id: GATEWAY_ID,
157 });
158 yield* Cloudflare.AI.Gateway("ReplaceGatewayB", {
159 id: GATEWAY_ID_B,
160 });
161 const dataset = yield* Cloudflare.AI.Dataset("ReplaceDataset", {
162 gatewayId: gatewayA.gatewayId,
163 name: "alchemy-test-dataset-replace",
164 filters: [],
165 });
166 return { dataset };
167 }),
168 );
169 expect(initial.dataset.gatewayId).toEqual(GATEWAY_ID);
170
171 // Moving the dataset to another gateway is a replacement: new id,
172 // new parent, and the old dataset is removed from gateway A.
173 const moved = yield* stack.deploy(
174 Effect.gen(function* () {
175 yield* Cloudflare.AI.Gateway("ReplaceGatewayA", {
176 id: GATEWAY_ID,
177 });
178 const gatewayB = yield* Cloudflare.AI.Gateway("ReplaceGatewayB", {
179 id: GATEWAY_ID_B,
180 });
181 const dataset = yield* Cloudflare.AI.Dataset("ReplaceDataset", {
182 gatewayId: gatewayB.gatewayId,
183 name: "alchemy-test-dataset-replace",
184 filters: [],
185 });
186 return { dataset };
187 }),
188 );
189
190 expect(moved.dataset.gatewayId).toEqual(GATEWAY_ID_B);
191 expect(moved.dataset.datasetId).not.toEqual(initial.dataset.datasetId);
192
193 // The replaced dataset is gone from gateway A.
194 yield* expectGone(accountId, GATEWAY_ID, initial.dataset.datasetId);
195
196 yield* stack.destroy();
197
198 yield* expectGone(accountId, GATEWAY_ID_B, moved.dataset.datasetId);
199 }).pipe(logLevel),
200 );
201
202 test.provider("recreates a dataset after out-of-band delete", (stack) =>
203 Effect.gen(function* () {
204 const { accountId } = yield* yield* CloudflareEnvironment;
205
206 yield* stack.destroy();
207
208 const initial = yield* stack.deploy(
209 Effect.gen(function* () {
210 const gateway = yield* Cloudflare.AI.Gateway("HealGateway", {
211 id: GATEWAY_ID,
212 });
213 const dataset = yield* Cloudflare.AI.Dataset("HealDataset", {
214 gatewayId: gateway.gatewayId,
215 name: "alchemy-test-dataset-heal",
216 filters: [{ key: "success", operator: "eq", value: [true] }],
217 });
218 return { dataset };
219 }),
220 );
221
222 // Delete the dataset out-of-band. A redeploy with identical props is a
223 // planner no-op, so change a prop to force reconcile — it must observe
224 // the dataset as missing and recreate it instead of failing on a 404.
225 yield* aiGateway.deleteDataset({
226 accountId,
227 gatewayId: GATEWAY_ID,
228 id: initial.dataset.datasetId,
229 });
230
231 const healed = yield* stack.deploy(
232 Effect.gen(function* () {
233 const gateway = yield* Cloudflare.AI.Gateway("HealGateway", {
234 id: GATEWAY_ID,
235 });
236 const dataset = yield* Cloudflare.AI.Dataset("HealDataset", {
237 gatewayId: gateway.gatewayId,
238 name: "alchemy-test-dataset-heal",
239 enable: false,
240 filters: [{ key: "success", operator: "eq", value: [true] }],
241 });
242 return { dataset };
243 }),
244 );
245
246 expect(healed.dataset.datasetId).not.toEqual(initial.dataset.datasetId);
247 expect(healed.dataset.enable).toBe(false);
248
249 const live = yield* aiGateway.getDataset({
250 accountId,
251 gatewayId: GATEWAY_ID,
252 id: healed.dataset.datasetId,
253 });
254 expect(live.name).toEqual("alchemy-test-dataset-heal");
255
256 yield* stack.destroy();
257
258 yield* expectGone(accountId, GATEWAY_ID, healed.dataset.datasetId);
259 }).pipe(logLevel),
260 );
261
262 test.provider("list enumerates datasets across gateways", (stack) =>
263 Effect.gen(function* () {
264 const { accountId } = yield* yield* CloudflareEnvironment;
265
266 yield* stack.destroy();
267
268 const deployed = yield* stack.deploy(
269 Effect.gen(function* () {
270 const gateway = yield* Cloudflare.AI.Gateway("ListGateway", {
271 id: GATEWAY_ID,
272 });
273 const dataset = yield* Cloudflare.AI.Dataset("ListDataset", {
274 gatewayId: gateway.gatewayId,
275 name: "alchemy-test-dataset-list",
276 filters: [{ key: "success", operator: "eq", value: [true] }],
277 });
278 return { dataset };
279 }),
280 );
281
282 const provider = yield* Provider.findProvider(Cloudflare.AI.Dataset);
283 const all = yield* provider.list();
284
285 expect(
286 all.some((d) => d.datasetId === deployed.dataset.datasetId),
287 ).toBe(true);
288
289 yield* stack.destroy();
290
291 yield* expectGone(accountId, GATEWAY_ID, deployed.dataset.datasetId);
292 }).pipe(logLevel),
293 );
294 },
295);