1import { Action } from "@/Action";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import * as Alchemy from "@/index.ts";
4import * as Test from "@/Test/Alchemy";
5import * as kv from "@distilled.cloud/cloudflare/kv";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Stream from "effect/Stream";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12import * as HttpClient from "effect/http/HttpClient";
13import * as pathe from "pathe";
14import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment.ts";
15
16// `dev: true` runs local providers behind the RPC sidecar proxy by default,
17// matching the process topology of the real `alchemy dev` command (see
18// MakeOptions.sidecar in Test/Core.ts).
19const { test } = Test.make({
20 providers: Cloudflare.providers(),
21 dev: true,
22});
23
24const logLevel = Effect.provideService(
25 MinimumLogLevel,
26 process.env.DEBUG ? "Debug" : "Info",
27);
28
29class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
30 status: number;
31}> {}
32
33const getJsonReady = (url: string) =>
34 Effect.gen(function* () {
35 const client = yield* HttpClient.HttpClient;
36 const res = yield* client.get(url).pipe(
37 Effect.flatMap((res) =>
38 res.status === 200
39 ? Effect.succeed(res)
40 : Effect.fail(new WorkerNotReady({ status: res.status })),
41 ),
42 Effect.retry({
43 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
44 // Cap the backoff: an uncapped exponential over 10 recurs sums to
45 // ~8.5 minutes and turns a persistent non-200 into an apparent hang.
46 schedule: Schedule.max([
47 Schedule.min([
48 Schedule.exponential("500 millis"),
49 Schedule.spaced("2 seconds"),
50 ]),
51 Schedule.recurs(10),
52 ]),
53 }),
54 );
55 return yield* res.json;
56 }).pipe(Effect.orDie);
57
58/**
59 * Under `alchemy dev` the KV Namespace resource is emulated by the local
60 * provider (a `dev:` id, no cloud API calls) and the worker's `kv_namespace`
61 * binding is lowered onto the local workerd KV simulator. This exercises the
62 * full local roundtrip: put / get / list / delete through the native `env`
63 * binding.
64 */
65test.provider(
66 "KV namespace binding round-trips against the local simulator",
67 (stack) =>
68 Effect.gen(function* () {
69 yield* stack.destroy();
70
71 const deployed = yield* stack.deploy(
72 Effect.gen(function* () {
73 const kv = yield* Cloudflare.KV.Namespace("LocalKV");
74 const worker = yield* Cloudflare.Worker("kv-local-worker", {
75 main: pathe.resolve(
76 import.meta.dirname,
77 "fixtures/kv-local-worker.ts",
78 ),
79 env: { KV: kv },
80 });
81 return { kv, worker };
82 }),
83 );
84
85 // The local provider fabricates a `dev:` id — proof no cloud call ran
86 // — and the worker serves from the local dev proxy.
87 expect(deployed.kv.namespaceId).toMatch(/^dev:/);
88 expect(deployed.worker.url).toMatch(/^http:\/\/localhost:\d+$/);
89
90 const body = (yield* getJsonReady(
91 `${deployed.worker.url}/roundtrip`,
92 )) as {
93 value: string;
94 value2: string;
95 metadata: { hello: string };
96 keys: string[];
97 afterDelete: string | null;
98 };
99 expect(body.value).toBe("value1");
100 expect(body.value2).toBe("value2");
101 expect(body.metadata).toEqual({ hello: "world" });
102 expect(body.keys.sort()).toEqual(["key1", "key2"]);
103 expect(body.afterDelete).toBeNull();
104
105 yield* stack.destroy();
106 }).pipe(logLevel),
107 {
108 tags: [
109 "provider:cloudflare",
110 "provider:cloudflare:kv",
111 "provider:cloudflare:worker",
112 "local",
113 ],
114 timeout: 120_000,
115 },
116);
117
118/**
119 * The `*Local` KV capability layers reach the local simulator through an
120 * ephemeral workerd gateway that emulates the KV REST API over the native
121 * binding. An Action seeds (put/get/getWithMetadata/list/delete) a `dev:`
122 * namespace, and the worker's native binding then reads the same simulator
123 * storage — proving Node-side capability clients and worker bindings share
124 * one local data plane.
125 */
126test.provider(
127 "ReadWriteNamespaceLocal Action seeds the local simulator in dev",
128 (stack) =>
129 Effect.gen(function* () {
130 yield* stack.destroy();
131
132 const deployed = yield* stack.deploy(
133 Effect.gen(function* () {
134 const ns = yield* Cloudflare.KV.Namespace("ActionSeededKV");
135
136 const Seed = Action(
137 "Seed",
138 Effect.gen(function* () {
139 const client = yield* Cloudflare.KV.ReadWriteNamespace(ns);
140 return Effect.fn(function* () {
141 yield* client.put("seeded", "from-action", {
142 metadata: { source: "action" },
143 });
144 yield* client.put("other", "value");
145 const value = yield* client.get("seeded");
146 const withMetadata = yield* client.getWithMetadata("seeded");
147 const list = yield* client.list();
148 yield* client.delete("other");
149 const afterDelete = yield* client.get("other");
150 return {
151 value,
152 metadata: withMetadata.metadata,
153 keys: list.keys.map((k) => k.name),
154 afterDelete,
155 };
156 });
157 }).pipe(Effect.provide(Cloudflare.KV.ReadWriteNamespaceLocal)),
158 );
159 const seeded = yield* Seed({});
160
161 const worker = yield* Cloudflare.Worker("kv-action-worker", {
162 main: pathe.resolve(
163 import.meta.dirname,
164 "fixtures/kv-local-worker.ts",
165 ),
166 env: { KV: ns },
167 });
168 return { ns, worker, seeded };
169 }),
170 );
171
172 expect(deployed.ns.namespaceId).toMatch(/^dev:/);
173 expect(deployed.seeded.value).toBe("from-action");
174 expect(deployed.seeded.metadata).toEqual({ source: "action" });
175 expect(deployed.seeded.keys.sort()).toEqual(["other", "seeded"]);
176 expect(deployed.seeded.afterDelete).toBeNull();
177
178 // The worker's native binding reads the same simulator storage the
179 // Action's gateway wrote to.
180 const body = (yield* getJsonReady(
181 `${deployed.worker.url}/get?key=seeded`,
182 )) as { value: string | null };
183 expect(body.value).toBe("from-action");
184
185 yield* stack.destroy();
186 }).pipe(logLevel),
187 {
188 tags: [
189 "provider:cloudflare",
190 "provider:cloudflare:kv",
191 "provider:cloudflare:worker",
192 "local",
193 ],
194 timeout: 120_000,
195 },
196);
197
198/**
199 * `Alchemy.remote()` opts a resource OUT of local emulation: even under
200 * `alchemy dev` the namespace is created on real Cloudflare (live provider)
201 * and the worker's binding proxies to it remotely, while a sibling default
202 * namespace in the same stack stays fully local. Out-of-band reads through
203 * the cloud API prove the worker's writes landed in the real namespace.
204 */
205test.provider(
206 "Alchemy.remote() namespace runs live in dev alongside a local one",
207 (stack) =>
208 Effect.gen(function* () {
209 yield* stack.destroy();
210
211 const deployed = yield* stack.deploy(
212 Effect.gen(function* () {
213 const localKv = yield* Cloudflare.KV.Namespace("MixedLocalKV");
214 const liveKv = yield* Cloudflare.KV.Namespace("MixedLiveKV").pipe(
215 Alchemy.remote(),
216 );
217 const worker = yield* Cloudflare.Worker("kv-mixed-worker", {
218 main: pathe.resolve(
219 import.meta.dirname,
220 "fixtures/kv-local-worker.ts",
221 ),
222 env: { KV: localKv, KV_LIVE: liveKv },
223 });
224 return { localKv, liveKv, worker };
225 }),
226 );
227
228 // The default namespace is emulated; the remote() one is real.
229 expect(deployed.localKv.namespaceId).toMatch(/^dev:/);
230 expect(deployed.liveKv.namespaceId).not.toMatch(/^dev:/);
231
232 // Both bindings round-trip through the same locally-served worker.
233 const local = (yield* getJsonReady(
234 `${deployed.worker.url}/roundtrip`,
235 )) as { value: string };
236 expect(local.value).toBe("value1");
237
238 const live = (yield* getJsonReady(
239 `${deployed.worker.url}/roundtrip?binding=KV_LIVE`,
240 )) as { value: string; value2: string };
241 expect(live.value).toBe("value1");
242 expect(live.value2).toBe("value2");
243
244 // Out-of-band: the worker's write is visible through the cloud API —
245 // the remote-proxied binding really hit the live namespace.
246 const { accountId } = yield* yield* CloudflareEnvironment;
247 const value = yield* kv
248 .getNamespaceValue({
249 accountId,
250 namespaceId: deployed.liveKv.namespaceId,
251 keyName: "key2",
252 })
253 .pipe(
254 Effect.flatMap((res) =>
255 Effect.tryPromise(() =>
256 new Response(
257 Stream.toReadableStream(res.body) as BodyInit,
258 ).text(),
259 ),
260 ),
261 );
262 expect(value).toBe("value2");
263
264 yield* stack.destroy();
265
266 // The live namespace was deleted from the cloud on destroy (its state
267 // row is stamped live, so the live provider handles the delete even
268 // in a dev run).
269 const gone = yield* kv
270 .getNamespace({
271 accountId,
272 namespaceId: deployed.liveKv.namespaceId,
273 })
274 .pipe(
275 Effect.as(false),
276 Effect.catchTag("NamespaceNotFound", () => Effect.succeed(true)),
277 );
278 expect(gone).toBe(true);
279 }).pipe(logLevel),
280 {
281 tags: [
282 "provider:cloudflare",
283 "provider:cloudflare:kv",
284 "provider:cloudflare:worker",
285 "live",
286 ],
287 timeout: 120_000,
288 },
289);