| 1 | import { Action } from "@/Action"; |
| 2 | import * as Cloudflare from "@/Cloudflare/index.ts"; |
| 3 | import * as Alchemy from "@/index.ts"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as kv from "@distilled.cloud/cloudflare/kv"; |
| 6 | import { expect } from "alchemy-test"; |
| 7 | import * as Data from "effect/Data"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Stream from "effect/Stream"; |
| 10 | import { MinimumLogLevel } from "effect/References"; |
| 11 | import * as Schedule from "effect/Schedule"; |
| 12 | import * as HttpClient from "effect/http/HttpClient"; |
| 13 | import * as pathe from "pathe"; |
| 14 | import { 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). |
| 19 | const { test } = Test.make({ |
| 20 | providers: Cloudflare.providers(), |
| 21 | dev: true, |
| 22 | }); |
| 23 | |
| 24 | const logLevel = Effect.provideService( |
| 25 | MinimumLogLevel, |
| 26 | process.env.DEBUG ? "Debug" : "Info", |
| 27 | ); |
| 28 | |
| 29 | class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{ |
| 30 | status: number; |
| 31 | }> {} |
| 32 | |
| 33 | const 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 | */ |
| 65 | test.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 | */ |
| 126 | test.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 | */ |
| 205 | test.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 | ); |