| 1 | import * as Cloudflare from "@/Cloudflare"; |
| 2 | import * as Test from "@/Test/Alchemy"; |
| 3 | import { expect } from "alchemy-test"; |
| 4 | import * as Data from "effect/Data"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import { MinimumLogLevel } from "effect/References"; |
| 7 | import * as Schedule from "effect/Schedule"; |
| 8 | import * as HttpClient from "effect/http/HttpClient"; |
| 9 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 10 | import type * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 11 | import ReadBindingWorker from "./fixtures/read-binding.ts"; |
| 12 | import ReadHttpWorker from "./fixtures/read-http.ts"; |
| 13 | import ReadWriteBindingWorker from "./fixtures/readwrite-binding.ts"; |
| 14 | import ReadWriteHttpWorker from "./fixtures/readwrite-http.ts"; |
| 15 | import WriteBindingWorker from "./fixtures/write-binding.ts"; |
| 16 | import WriteHttpWorker from "./fixtures/write-http.ts"; |
| 17 | |
| 18 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 19 | |
| 20 | const logLevel = Effect.provideService( |
| 21 | MinimumLogLevel, |
| 22 | process.env.DEBUG ? "Debug" : "Info", |
| 23 | ); |
| 24 | |
| 25 | class 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. |
| 33 | const ready = Schedule.max([Schedule.spaced("2 seconds"), Schedule.recurs(30)]); |
| 34 | |
| 35 | const 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 | |
| 54 | class 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. |
| 62 | const propagate = Schedule.max([ |
| 63 | Schedule.spaced("2 seconds"), |
| 64 | Schedule.recurs(45), |
| 65 | ]); |
| 66 | |
| 67 | const enc = encodeURIComponent; |
| 68 | |
| 69 | /** Retry a read-back until KV propagation makes it consistent. */ |
| 70 | const 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. */ |
| 81 | const 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. */ |
| 94 | const 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. */ |
| 112 | const 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. */ |
| 134 | const 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`. */ |
| 155 | const 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`). */ |
| 170 | const 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 | |
| 182 | const 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 | |
| 191 | const 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 | |
| 200 | const 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 | */ |
| 214 | const 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 | */ |
| 256 | test.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 | ); |