1import * as Cloudflare from "@/Cloudflare";
2import * as Test from "@/Test/Alchemy";
3import { expect } from "alchemy-test";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import type * as HttpClientResponse from "effect/http/HttpClientResponse";
11import ReadBindingWorker from "./fixtures/read-binding.ts";
12import ReadHttpWorker from "./fixtures/read-http.ts";
13import ReadWriteBindingWorker from "./fixtures/readwrite-binding.ts";
14import ReadWriteHttpWorker from "./fixtures/readwrite-http.ts";
15import WriteBindingWorker from "./fixtures/write-binding.ts";
16import WriteHttpWorker from "./fixtures/write-http.ts";
17
18const { test } = Test.make({ providers: Cloudflare.providers() });
19
20const logLevel = Effect.provideService(
21 MinimumLogLevel,
22 process.env.DEBUG ? "Debug" : "Info",
23);
24
25class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
26 status: number;
27 body: string;
28}> {}
29
30// Bounded spaced schedule — caps total wait so a genuine failure
31// surfaces fast instead of an uncapped exponential blowing past the
32// test timeout while riding out cold-start propagation.
33const ready = Schedule.max([Schedule.spaced("2 seconds"), Schedule.recurs(30)]);
34
35const untilOk = <E, R>(
36 eff: Effect.Effect<HttpClientResponse.HttpClientResponse, E, R>,
37) =>
38 eff.pipe(
39 Effect.flatMap((res) =>
40 res.status === 200
41 ? Effect.succeed(res)
42 : res.text.pipe(
43 Effect.flatMap((body) =>
44 Effect.fail(new WorkerNotReady({ status: res.status, body })),
45 ),
46 ),
47 ),
48 Effect.retry({
49 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
50 schedule: ready,
51 }),
52 );
53
54class ValueMismatch extends Data.TaggedError("ValueMismatch")<{
55 expected: string;
56 actual: string | null;
57}> {}
58
59// KV is eventually consistent — a fresh write can take a while to be
60// visible to a read on a different binding/edge, so allow generous
61// retries on the read-back.
62const propagate = Schedule.max([
63 Schedule.spaced("2 seconds"),
64 Schedule.recurs(45),
65]);
66
67const enc = encodeURIComponent;
68
69/** Retry a read-back until KV propagation makes it consistent. */
70const retryMismatch = <A, E, R>(
71 eff: Effect.Effect<A, E, R>,
72): Effect.Effect<A, E, R> =>
73 eff.pipe(
74 Effect.retry({
75 while: (e: E) => e instanceof ValueMismatch,
76 schedule: propagate,
77 }),
78 );
79
80/** `GET /get` (text) and retry until the value matches. */
81const expectValue = (base: string, key: string, expected: string) =>
82 untilOk(HttpClient.get(`${base}/get?key=${enc(key)}`)).pipe(
83 Effect.flatMap((res) => res.json),
84 Effect.flatMap((body) => {
85 const actual = (body as { value: string | null }).value;
86 return actual === expected
87 ? Effect.succeed(actual)
88 : Effect.fail(new ValueMismatch({ expected, actual }));
89 }),
90 retryMismatch,
91 );
92
93/** `GET /get-json` and retry until the parsed value deep-matches. */
94const expectJson = (base: string, key: string, expected: unknown) =>
95 untilOk(HttpClient.get(`${base}/get-json?key=${enc(key)}`)).pipe(
96 Effect.flatMap((res) => res.json),
97 Effect.flatMap((body) => {
98 const actual = (body as { value: unknown }).value;
99 return JSON.stringify(actual) === JSON.stringify(expected)
100 ? Effect.succeed(actual)
101 : Effect.fail(
102 new ValueMismatch({
103 expected: JSON.stringify(expected),
104 actual: JSON.stringify(actual),
105 }),
106 );
107 }),
108 retryMismatch,
109 );
110
111/** `GET /get-bulk` and retry until every key resolves to its expected value. */
112const expectBulk = (base: string, expected: Record<string, string>) =>
113 untilOk(
114 HttpClient.get(
115 `${base}/get-bulk?keys=${enc(Object.keys(expected).join(","))}`,
116 ),
117 ).pipe(
118 Effect.flatMap((res) => res.json),
119 Effect.flatMap((body) => {
120 const values = (body as { values: Record<string, string | null> }).values;
121 return Object.entries(expected).every(([k, v]) => values[k] === v)
122 ? Effect.succeed(values)
123 : Effect.fail(
124 new ValueMismatch({
125 expected: JSON.stringify(expected),
126 actual: JSON.stringify(values),
127 }),
128 );
129 }),
130 retryMismatch,
131 );
132
133/** `GET /getWithMetadata` and retry until value + metadata are present. */
134const expectMeta = (base: string, key: string, expectedValue: string) =>
135 untilOk(HttpClient.get(`${base}/getWithMetadata?key=${enc(key)}`)).pipe(
136 Effect.flatMap((res) => res.json),
137 Effect.flatMap((body) => {
138 const b = body as {
139 value: string | null;
140 metadata: { tag?: string } | null;
141 };
142 return b.value === expectedValue && b.metadata?.tag === "meta"
143 ? Effect.succeed(b)
144 : Effect.fail(
145 new ValueMismatch({
146 expected: `${expectedValue}+{tag:meta}`,
147 actual: JSON.stringify(b),
148 }),
149 );
150 }),
151 retryMismatch,
152 );
153
154/** `GET /list?prefix=` and retry until `key` appears under `prefix`. */
155const expectListed = (base: string, prefix: string, key: string) =>
156 untilOk(HttpClient.get(`${base}/list?prefix=${enc(prefix)}`)).pipe(
157 Effect.flatMap((res) => res.json),
158 Effect.flatMap((body) => {
159 const keys = (body as { keys: string[] }).keys;
160 return keys.includes(key)
161 ? Effect.succeed(keys)
162 : Effect.fail(
163 new ValueMismatch({ expected: key, actual: keys.join(",") }),
164 );
165 }),
166 retryMismatch,
167 );
168
169/** `GET /get` and retry until the object is gone (`value === null`). */
170const expectMissing = (base: string, key: string) =>
171 untilOk(HttpClient.get(`${base}/get?key=${enc(key)}`)).pipe(
172 Effect.flatMap((res) => res.json),
173 Effect.flatMap((body) => {
174 const actual = (body as { value: string | null }).value;
175 return actual === null
176 ? Effect.succeed(null)
177 : Effect.fail(new ValueMismatch({ expected: "<missing>", actual }));
178 }),
179 retryMismatch,
180 );
181
182const put = (base: string, key: string, value: string) =>
183 untilOk(
184 HttpClient.execute(
185 HttpClientRequest.put(`${base}/put?key=${enc(key)}`).pipe(
186 HttpClientRequest.bodyText(value),
187 ),
188 ),
189 );
190
191const putMeta = (base: string, key: string, value: string) =>
192 untilOk(
193 HttpClient.execute(
194 HttpClientRequest.put(`${base}/put-meta?key=${enc(key)}`).pipe(
195 HttpClientRequest.bodyText(value),
196 ),
197 ),
198 );
199
200const del = (base: string, key: string) =>
201 untilOk(
202 HttpClient.execute(
203 HttpClientRequest.make("DELETE")(`${base}/del?key=${enc(key)}`),
204 ),
205 );
206
207/**
208 * Drive the FULL {@link ReadWriteNamespaceClient} surface through one
209 * read-write worker: every write method (`put`, `put` with metadata/ttl,
210 * `delete`) and every read method (`get` text/json, bulk `get`,
211 * `getWithMetadata`, `list` with prefix). Keys are namespaced by `label`
212 * so the binding and http runs stay independent on the shared namespace.
213 */
214const exercise = (label: string, base: string) =>
215 Effect.gen(function* () {
216 const prefix = `${label}/`;
217 const k = (s: string) => `${prefix}${s}`;
218
219 // put + get (text)
220 expect((yield* put(base, k("v"), `${label}-v`)).status).toBe(200);
221 expect(yield* expectValue(base, k("v"), `${label}-v`)).toBe(`${label}-v`);
222
223 // put with metadata + ttl → getWithMetadata round-trips value + metadata
224 yield* putMeta(base, k("m"), `${label}-m`);
225 yield* expectMeta(base, k("m"), `${label}-m`);
226
227 // put JSON string → get(key, "json") parses it
228 yield* put(base, k("j"), JSON.stringify({ n: 1, label }));
229 yield* expectJson(base, k("j"), { n: 1, label });
230
231 // bulk get → get([...], "text") returns every key
232 yield* put(base, k("b1"), "B1");
233 yield* put(base, k("b2"), "B2");
234 yield* expectBulk(base, { [k("b1")]: "B1", [k("b2")]: "B2" });
235
236 // list({ prefix }) surfaces a written key under its prefix
237 yield* expectListed(base, prefix, k("v"));
238
239 // delete → get reports it gone
240 yield* del(base, k("v"));
241 yield* expectMissing(base, k("v"));
242 });
243
244/**
245 * Deploys six Workers that all bind one shared KV namespace — read /
246 * write / read-write, each over the native Worker binding
247 * (`*NamespaceBinding`) and over a scoped HTTP API token
248 * (`*NamespaceHttp`) — then drives them over `fetch`:
249 *
250 * - the read-write worker exercises the entire client surface by itself
251 * (every read + write method), once per transport;
252 * - the split Read/Write workers prove the separate bindings agree on
253 * the namespace: write through Write, read back through Read, delete
254 * through Write, observe gone through Read.
255 */
256test.provider.skipIf(!!process.env.FAST)(
257 "KV read/write/read-write bindings over binding + http",
258 (stack) =>
259 Effect.gen(function* () {
260 yield* stack.destroy();
261
262 const out = yield* stack.deploy(
263 Effect.gen(function* () {
264 const readBinding = yield* ReadBindingWorker;
265 const writeBinding = yield* WriteBindingWorker;
266 const readWriteBinding = yield* ReadWriteBindingWorker;
267 const readHttp = yield* ReadHttpWorker;
268 const writeHttp = yield* WriteHttpWorker;
269 const readWriteHttp = yield* ReadWriteHttpWorker;
270 return {
271 readBinding: readBinding.url,
272 writeBinding: writeBinding.url,
273 readWriteBinding: readWriteBinding.url,
274 readHttp: readHttp.url,
275 writeHttp: writeHttp.url,
276 readWriteHttp: readWriteHttp.url,
277 };
278 }),
279 );
280
281 const url = (u: unknown) => {
282 expect(u).toBeTypeOf("string");
283 return u as string;
284 };
285
286 // ── Full client surface through a single read-write worker ──
287 yield* exercise("rw-bind", url(out.readWriteBinding));
288 yield* exercise("rw-http", url(out.readWriteHttp));
289
290 // ── Split Read/Write bindings agree on the shared namespace ──
291 const crossWorker = (
292 label: string,
293 writeBase: string,
294 readBase: string,
295 ) =>
296 Effect.gen(function* () {
297 const key = `${label}-key`;
298 expect((yield* put(writeBase, key, `${label}-value`)).status).toBe(
299 200,
300 );
301 expect(yield* expectValue(readBase, key, `${label}-value`)).toBe(
302 `${label}-value`,
303 );
304 yield* del(writeBase, key);
305 yield* expectMissing(readBase, key);
306 });
307
308 yield* crossWorker("bind", url(out.writeBinding), url(out.readBinding));
309 yield* crossWorker("http", url(out.writeHttp), url(out.readHttp));
310
311 yield* stack.destroy();
312 }).pipe(logLevel),
313 {
314 tags: [
315 "provider:cloudflare",
316 "provider:cloudflare:kv",
317 "provider:cloudflare:worker",
318 "live",
319 ],
320 timeout: 300_000,
321 },
322);