| 1 | import { adopt } from "@/AdoptPolicy"; |
| 2 | import * as Cloudflare from "@/Cloudflare"; |
| 3 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 4 | import * as Output from "@/Output"; |
| 5 | import * as Test from "@/Test/Alchemy"; |
| 6 | import * as durableObjects from "@distilled.cloud/cloudflare/durable-objects"; |
| 7 | import * as workers from "@distilled.cloud/cloudflare/workers"; |
| 8 | import { describe, expect } from "alchemy-test"; |
| 9 | import * as Data from "effect/Data"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import { MinimumLogLevel } from "effect/References"; |
| 12 | import * as Schedule from "effect/Schedule"; |
| 13 | import * as Stream from "effect/Stream"; |
| 14 | import * as HttpClient from "effect/http/HttpClient"; |
| 15 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 16 | import { getWorkerTags } from "../Utils/Worker.ts"; |
| 17 | import Stack from "./fixtures/do-rpc/stack.ts"; |
| 18 | |
| 19 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 20 | providers: Cloudflare.providers(), |
| 21 | }); |
| 22 | |
| 23 | const logLevel = Effect.provideService( |
| 24 | MinimumLogLevel, |
| 25 | process.env.DEBUG ? "Debug" : "Info", |
| 26 | ); |
| 27 | |
| 28 | const stack = beforeAll(deploy(Stack)); |
| 29 | afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack)); |
| 30 | |
| 31 | // Cap exponential backoff at 3s — keeps the fast-path snappy but stops |
| 32 | // the geometric blow-up (0.5 + 1 + 2 + 4 + 8 + 16 + 32 + 64s ...) that |
| 33 | // makes retries dominate test wall time when CF edge is slow. |
| 34 | const readinessSchedule = Schedule.min([ |
| 35 | Schedule.exponential("500 millis"), |
| 36 | Schedule.spaced("3 seconds"), |
| 37 | ]); |
| 38 | |
| 39 | const readinessRetries = 15; |
| 40 | |
| 41 | // The test runtime's HttpClient (FetchHttpClient/undici) keeps HTTP/1.1 |
| 42 | // connections alive and pooled. A pooled connection stays pinned to a single |
| 43 | // Cloudflare edge metal, so when that metal lags the freshly-deployed version |
| 44 | // every retry rides the same stale socket and keeps reading the old body for |
| 45 | // the life of the keep-alive — even though the new version is already live. |
| 46 | // Forcing `Connection: close` makes each readiness attempt open a fresh |
| 47 | // connection, letting it land on an edge that has the new version (this is |
| 48 | // why a brand-new `curl` sees the update immediately while a kept-alive |
| 49 | // client does not). See do-rpc DurableObject test investigation. |
| 50 | const freshConn = HttpClient.mapRequest( |
| 51 | HttpClientRequest.setHeader("connection", "close"), |
| 52 | ); |
| 53 | |
| 54 | test( |
| 55 | "durable object methods can use binding clients", |
| 56 | Effect.gen(function* () { |
| 57 | const { url } = yield* stack; |
| 58 | const client = yield* HttpClient.HttpClient; |
| 59 | |
| 60 | const res = yield* client.post(`${url}/roundtrip`).pipe( |
| 61 | Effect.flatMap((res) => |
| 62 | res.status === 200 |
| 63 | ? Effect.succeed(res) |
| 64 | : Effect.fail(new Error(`Worker not ready: ${res.status}`)), |
| 65 | ), |
| 66 | Effect.retry({ schedule: readinessSchedule, times: 15 }), |
| 67 | ); |
| 68 | |
| 69 | expect(res.status).toBe(200); |
| 70 | const body = (yield* res.json) as { value: string }; |
| 71 | expect(body.value).toBe("ok"); |
| 72 | }).pipe(logLevel), |
| 73 | { |
| 74 | tags: [ |
| 75 | "provider:cloudflare", |
| 76 | "provider:cloudflare:kv", |
| 77 | "provider:cloudflare:worker", |
| 78 | "live", |
| 79 | ], |
| 80 | timeout: 60_000, |
| 81 | }, |
| 82 | ); |
| 83 | |
| 84 | class DurableObjectLocationNotReady extends Data.TaggedError( |
| 85 | "DurableObjectLocationNotReady", |
| 86 | )<{ readonly message: string }> {} |
| 87 | |
| 88 | const locationFor = Effect.fn(function* ( |
| 89 | url: string, |
| 90 | options: { name?: string; hint?: Cloudflare.DurableObjectLocationHint } = {}, |
| 91 | ) { |
| 92 | const client = freshConn(yield* HttpClient.HttpClient); |
| 93 | const name = options.name ?? (yield* Effect.sync(() => crypto.randomUUID())); |
| 94 | const query = `name=${encodeURIComponent(name)}${options.hint ? `&hint=${options.hint}` : ""}`; |
| 95 | return yield* client.get(`${url}/colo?${query}`).pipe( |
| 96 | Effect.flatMap((res) => |
| 97 | Effect.gen(function* () { |
| 98 | if (res.status !== 200) { |
| 99 | return yield* new DurableObjectLocationNotReady({ |
| 100 | message: `Worker not ready: ${res.status}`, |
| 101 | }); |
| 102 | } |
| 103 | const body = (yield* res.json) as { |
| 104 | id: string; |
| 105 | colo: string; |
| 106 | locationHintRead: boolean; |
| 107 | }; |
| 108 | if (!body.id || !body.colo || body.colo === "unknown") { |
| 109 | return yield* new DurableObjectLocationNotReady({ |
| 110 | message: `Instance not ready: ${JSON.stringify(body)}`, |
| 111 | }); |
| 112 | } |
| 113 | return body; |
| 114 | }), |
| 115 | ), |
| 116 | Effect.retry({ schedule: readinessSchedule, times: readinessRetries }), |
| 117 | ); |
| 118 | }); |
| 119 | |
| 120 | // Nearby placement is best-effort; named identity is the stable contract. |
| 121 | test( |
| 122 | "unhinted lookups preserve named instance identity", |
| 123 | Effect.gen(function* () { |
| 124 | const { url } = yield* stack; |
| 125 | const name = yield* Effect.sync(() => crypto.randomUUID()); |
| 126 | const [first, second] = yield* Effect.all( |
| 127 | [locationFor(url, { name }), locationFor(url, { name })], |
| 128 | { concurrency: 2 }, |
| 129 | ); |
| 130 | const other = yield* locationFor(url); |
| 131 | |
| 132 | expect(first.id).toMatch(/^[0-9a-f]{64}$/); |
| 133 | expect(first.id).toBe(second.id); |
| 134 | expect(first.id).not.toBe(other.id); |
| 135 | for (const result of [first, second, other]) { |
| 136 | expect(result.locationHintRead).toBe(false); |
| 137 | } |
| 138 | }).pipe(logLevel), |
| 139 | { |
| 140 | tags: [ |
| 141 | "provider:cloudflare", |
| 142 | "provider:cloudflare:kv", |
| 143 | "provider:cloudflare:worker", |
| 144 | "live", |
| 145 | ], |
| 146 | timeout: 60_000, |
| 147 | }, |
| 148 | ); |
| 149 | |
| 150 | // The getter is read by the real native namespace only when options reach it. |
| 151 | test( |
| 152 | "locationHint reaches the native namespace for new instances", |
| 153 | Effect.gen(function* () { |
| 154 | const { url } = yield* stack; |
| 155 | const [wnam, apac] = yield* Effect.all( |
| 156 | [locationFor(url, { hint: "wnam" }), locationFor(url, { hint: "apac" })], |
| 157 | { concurrency: 2 }, |
| 158 | ); |
| 159 | |
| 160 | expect(wnam.locationHintRead).toBe(true); |
| 161 | expect(apac.locationHintRead).toBe(true); |
| 162 | expect(wnam.id).not.toBe(apac.id); |
| 163 | }).pipe(logLevel), |
| 164 | { |
| 165 | tags: [ |
| 166 | "provider:cloudflare", |
| 167 | "provider:cloudflare:kv", |
| 168 | "provider:cloudflare:worker", |
| 169 | "live", |
| 170 | ], |
| 171 | timeout: 60_000, |
| 172 | }, |
| 173 | ); |
| 174 | |
| 175 | // Reproduces the `tick` streaming example from the Durable Objects tutorial: |
| 176 | // https://alchemy.run/cloudflare/compute/durable-objects |
| 177 | // |
| 178 | // The DO exposes `tick(n): Stream<number>` and the Worker forwards it to the |
| 179 | // HTTP response with `HttpServerResponse.stream`. The client reads the body as |
| 180 | // newline-delimited integers. With `/tick/5` we expect ["0","1","2","3","4"]. |
| 181 | test( |
| 182 | "tick streams sequential values from a durable object (tutorial repro)", |
| 183 | Effect.gen(function* () { |
| 184 | const { url } = yield* stack; |
| 185 | const client = freshConn(yield* HttpClient.HttpClient); |
| 186 | |
| 187 | const lines = yield* client.get(`${url}/tick/5`).pipe( |
| 188 | Effect.flatMap((res) => |
| 189 | res.status === 200 |
| 190 | ? res.stream.pipe( |
| 191 | Stream.decodeText, |
| 192 | Stream.splitLines, |
| 193 | Stream.filter((line) => line.length > 0), |
| 194 | Stream.runCollect, |
| 195 | Effect.map((chunk) => [...chunk]), |
| 196 | // A cold edge can answer 200 with an empty/placeholder body |
| 197 | // (the script isn't serving yet), which collects to `[]` |
| 198 | // instead of failing — fail so the readiness retry rides it out |
| 199 | // rather than asserting against an empty stream. |
| 200 | Effect.flatMap((rows) => |
| 201 | rows.length > 0 |
| 202 | ? Effect.succeed(rows) |
| 203 | : Effect.fail(new Error("Worker not ready: empty stream")), |
| 204 | ), |
| 205 | ) |
| 206 | : Effect.fail(new Error(`Worker not ready: ${res.status}`)), |
| 207 | ), |
| 208 | Effect.retry({ schedule: readinessSchedule, times: readinessRetries }), |
| 209 | ); |
| 210 | |
| 211 | expect(lines).toEqual(["0", "1", "2", "3", "4"]); |
| 212 | }).pipe(logLevel), |
| 213 | { |
| 214 | tags: [ |
| 215 | "provider:cloudflare", |
| 216 | "provider:cloudflare:kv", |
| 217 | "provider:cloudflare:worker", |
| 218 | "live", |
| 219 | ], |
| 220 | timeout: 60_000, |
| 221 | }, |
| 222 | ); |
| 223 | |
| 224 | // While a freshly pre-created worker is propagating, Cloudflare's edge |
| 225 | // serves Alchemy's pre-create stub, which responds 200 with this plain-text |
| 226 | // body. It is not the real script, so any poll that sees it must retry. |
| 227 | const DEPLOY_PLACEHOLDER = "Alchemy worker is being deployed..."; |
| 228 | |
| 229 | // Cloudflare's edge keeps serving the previous worker version (or the |
| 230 | // pre-create stub) for a while after a (re)deploy, so retrying on 200-only |
| 231 | // is not enough — the stale version still returns 200 with the old body. |
| 232 | // Retry until the body matches the expected version string. |
| 233 | const fetchReady = (url: string, expected: string) => |
| 234 | Effect.gen(function* () { |
| 235 | const client = freshConn(yield* HttpClient.HttpClient); |
| 236 | return yield* client.get(url).pipe( |
| 237 | Effect.flatMap((r) => |
| 238 | r.status === 200 |
| 239 | ? Effect.flatMap(r.text, (body) => |
| 240 | body === expected |
| 241 | ? Effect.succeed(body) |
| 242 | : Effect.fail( |
| 243 | new Error(`stale: got ${body}, want ${expected}`), |
| 244 | ), |
| 245 | ) |
| 246 | : Effect.fail(new Error(`Worker not ready: ${r.status}`)), |
| 247 | ), |
| 248 | Effect.retry({ schedule: readinessSchedule, times: readinessRetries }), |
| 249 | ); |
| 250 | }); |
| 251 | |
| 252 | const fetchJsonReady = <T>(url: string) => |
| 253 | Effect.gen(function* () { |
| 254 | const client = freshConn(yield* HttpClient.HttpClient); |
| 255 | return yield* client.get(url).pipe( |
| 256 | // Parse the body INSIDE the retry: the pre-create stub answers 200 with |
| 257 | // a non-JSON placeholder, so JSON decoding must be part of the readiness |
| 258 | // check (a 200 status alone does not mean the real script is live yet). |
| 259 | Effect.flatMap((r) => |
| 260 | r.status !== 200 |
| 261 | ? Effect.flatMap(r.text, (body) => |
| 262 | Effect.fail( |
| 263 | new Error( |
| 264 | `Worker not ready at ${url}: ${r.status} ${body.slice(0, 500)}`, |
| 265 | ), |
| 266 | ), |
| 267 | ) |
| 268 | : Effect.flatMap(r.text, (body) => |
| 269 | body.includes(DEPLOY_PLACEHOLDER) |
| 270 | ? Effect.fail(new Error("stale: still deploying")) |
| 271 | : Effect.try({ |
| 272 | try: () => JSON.parse(body) as T, |
| 273 | catch: () => new Error(`non-json body: ${body}`), |
| 274 | }), |
| 275 | ), |
| 276 | ), |
| 277 | Effect.retry({ schedule: readinessSchedule, times: readinessRetries }), |
| 278 | ); |
| 279 | }); |
| 280 | |
| 281 | const hostWorkerScript = `import { DurableObject } from "cloudflare:workers"; |
| 282 | export class Counter extends DurableObject { |
| 283 | async increment() { |
| 284 | const value = (await this.ctx.storage.get("count")) ?? 0; |
| 285 | const next = value + 1; |
| 286 | await this.ctx.storage.put("count", next); |
| 287 | return next; |
| 288 | } |
| 289 | async reset() { |
| 290 | await this.ctx.storage.delete("count"); |
| 291 | } |
| 292 | async get() { |
| 293 | return (await this.ctx.storage.get("count")) ?? 0; |
| 294 | } |
| 295 | } |
| 296 | export default { |
| 297 | async fetch(request, env) { |
| 298 | const stub = env.Counter.getByName("shared"); |
| 299 | const url = new URL(request.url); |
| 300 | if (url.pathname === "/increment") { |
| 301 | return Response.json({ value: await stub.increment() }); |
| 302 | } |
| 303 | if (url.pathname === "/get") { |
| 304 | return Response.json({ value: await stub.get() }); |
| 305 | } |
| 306 | if (url.pathname === "/reset") { |
| 307 | await stub.reset(); |
| 308 | return Response.json({ ok: true }); |
| 309 | } |
| 310 | return new Response("Not Found", { status: 404 }); |
| 311 | }, |
| 312 | }; |
| 313 | `; |
| 314 | |
| 315 | const consumerWorkerScript = `export default { |
| 316 | async fetch(request, env) { |
| 317 | const stub = env.Counter.getByName("shared"); |
| 318 | const url = new URL(request.url); |
| 319 | if (url.pathname === "/increment") { |
| 320 | return Response.json({ value: await stub.increment() }); |
| 321 | } |
| 322 | if (url.pathname === "/get") { |
| 323 | return Response.json({ value: await stub.get() }); |
| 324 | } |
| 325 | if (url.pathname === "/reset") { |
| 326 | await stub.reset(); |
| 327 | return Response.json({ ok: true }); |
| 328 | } |
| 329 | return new Response("Not Found", { status: 404 }); |
| 330 | }, |
| 331 | }; |
| 332 | `; |
| 333 | |
| 334 | // Every `test.provider` test below owns an isolated scratch stack (private |
| 335 | // in-memory state, physical names derived from the unique test name), so |
| 336 | // none of them can observe another's cloud resources. Run them concurrently: |
| 337 | // each test's wall time is dominated by workers.dev edge-propagation waits, |
| 338 | // so overlapping them collapses the file from the sum of all tests (~7 min) |
| 339 | // to roughly the slowest single test. |
| 340 | describe.concurrent( |
| 341 | "scratch", |
| 342 | { |
| 343 | tags: [ |
| 344 | "provider:cloudflare", |
| 345 | "provider:cloudflare:kv", |
| 346 | "provider:cloudflare:worker", |
| 347 | "live", |
| 348 | ], |
| 349 | }, |
| 350 | () => { |
| 351 | test.provider( |
| 352 | "async worker durable object binding accepts scriptName", |
| 353 | (scratch) => |
| 354 | Effect.gen(function* () { |
| 355 | yield* scratch.deploy( |
| 356 | Effect.gen(function* () { |
| 357 | return { |
| 358 | host: yield* Cloudflare.Worker("host-worker", { |
| 359 | script: hostWorkerScript, |
| 360 | env: { |
| 361 | Counter: Cloudflare.DurableObject("Counter"), |
| 362 | }, |
| 363 | }), |
| 364 | }; |
| 365 | }), |
| 366 | ); |
| 367 | |
| 368 | const deployed = yield* scratch.deploy( |
| 369 | Effect.gen(function* () { |
| 370 | const host = yield* Cloudflare.Worker("host-worker", { |
| 371 | script: hostWorkerScript, |
| 372 | env: { |
| 373 | Counter: Cloudflare.DurableObject("Counter"), |
| 374 | }, |
| 375 | }); |
| 376 | const consumer = yield* Cloudflare.Worker("consumer-worker", { |
| 377 | script: consumerWorkerScript, |
| 378 | env: { |
| 379 | Counter: Cloudflare.DurableObject("Counter", { |
| 380 | scriptName: host.workerName, |
| 381 | }), |
| 382 | }, |
| 383 | }); |
| 384 | |
| 385 | return { consumer, host }; |
| 386 | }), |
| 387 | ); |
| 388 | |
| 389 | const reset = yield* fetchJsonReady<{ ok: boolean }>( |
| 390 | `${deployed.host.url}/reset`, |
| 391 | ); |
| 392 | expect(reset.ok).toBe(true); |
| 393 | |
| 394 | const first = yield* fetchJsonReady<{ value: number }>( |
| 395 | `${deployed.consumer.url}/increment`, |
| 396 | ); |
| 397 | expect(first.value).toBe(1); |
| 398 | |
| 399 | const second = yield* fetchJsonReady<{ value: number }>( |
| 400 | `${deployed.host.url}/get`, |
| 401 | ); |
| 402 | expect(second.value).toBe(1); |
| 403 | |
| 404 | yield* scratch.destroy(); |
| 405 | }).pipe(logLevel), |
| 406 | { timeout: 60_000 }, |
| 407 | ); |
| 408 | |
| 409 | // Walk an async worker through four redeploys against the same scratch state, |
| 410 | // each one swapping in a new script + bindings shape so we exercise the |
| 411 | // migration paths `putWorker` relies on: |
| 412 | // v1 — create with a single DO class `DO_A` |
| 413 | // v2 — rename `DO_A` → `DO_A_v2` (className change, same binding id) |
| 414 | // v3 — add a brand-new DO class `DO_B` alongside `DO_A_v2` |
| 415 | // v4 — delete `DO_A`, keep only `DO_B` |
| 416 | test.provider( |
| 417 | "durable object class migrations across redeploys", |
| 418 | (scratch) => |
| 419 | Effect.gen(function* () { |
| 420 | const v1 = yield* scratch.deploy( |
| 421 | Effect.gen(function* () { |
| 422 | return { |
| 423 | worker: yield* Cloudflare.Worker("worker", { |
| 424 | script: `import { DurableObject } from "cloudflare:workers"; |
| 425 | export class DO_A extends DurableObject {} |
| 426 | export default { async fetch() { return new Response("v1"); } }; |
| 427 | `, |
| 428 | env: { |
| 429 | DO_A: Cloudflare.DurableObject("DO_A"), |
| 430 | }, |
| 431 | }), |
| 432 | }; |
| 433 | }), |
| 434 | ); |
| 435 | expect(yield* fetchReady(v1.worker.url!, "v1")).toBe("v1"); |
| 436 | |
| 437 | const v2 = yield* scratch.deploy( |
| 438 | Effect.gen(function* () { |
| 439 | return { |
| 440 | worker: yield* Cloudflare.Worker("worker", { |
| 441 | script: `import { DurableObject } from "cloudflare:workers"; |
| 442 | export class DO_A_v2 extends DurableObject {} |
| 443 | export default { async fetch() { return new Response("v2"); } }; |
| 444 | `, |
| 445 | env: { |
| 446 | DO_A: Cloudflare.DurableObject("DO_A", { |
| 447 | className: "DO_A_v2", |
| 448 | }), |
| 449 | }, |
| 450 | }), |
| 451 | }; |
| 452 | }), |
| 453 | ); |
| 454 | expect(yield* fetchReady(v2.worker.url!, "v2")).toBe("v2"); |
| 455 | |
| 456 | const v3 = yield* scratch.deploy( |
| 457 | Effect.gen(function* () { |
| 458 | return { |
| 459 | worker: yield* Cloudflare.Worker("worker", { |
| 460 | script: `import { DurableObject } from "cloudflare:workers"; |
| 461 | export class DO_A_v2 extends DurableObject {} |
| 462 | export class DO_B extends DurableObject {} |
| 463 | export default { async fetch() { return new Response("v3"); } }; |
| 464 | `, |
| 465 | env: { |
| 466 | DO_A: Cloudflare.DurableObject("DO_A", { |
| 467 | className: "DO_A_v2", |
| 468 | }), |
| 469 | DO_B: Cloudflare.DurableObject("DO_B"), |
| 470 | }, |
| 471 | }), |
| 472 | }; |
| 473 | }), |
| 474 | ); |
| 475 | expect(yield* fetchReady(v3.worker.url!, "v3")).toBe("v3"); |
| 476 | |
| 477 | const v4 = yield* scratch.deploy( |
| 478 | Effect.gen(function* () { |
| 479 | return { |
| 480 | worker: yield* Cloudflare.Worker("worker", { |
| 481 | script: `import { DurableObject } from "cloudflare:workers"; |
| 482 | export class DO_B extends DurableObject {} |
| 483 | export default { async fetch() { return new Response("v4"); } }; |
| 484 | `, |
| 485 | env: { |
| 486 | DO_B: Cloudflare.DurableObject("DO_B"), |
| 487 | }, |
| 488 | }), |
| 489 | }; |
| 490 | }), |
| 491 | ); |
| 492 | expect(yield* fetchReady(v4.worker.url!, "v4")).toBe("v4"); |
| 493 | |
| 494 | yield* scratch.destroy(); |
| 495 | }).pipe(logLevel), |
| 496 | { timeout: 180_000 }, |
| 497 | ); |
| 498 | |
| 499 | // Reproduces #763: a Worker's precreate stub must declare Durable Object |
| 500 | // classes that exist only as env *bindings* — an async worker has no |
| 501 | // Effect-native exports, so before the fix the stub declared no DO classes at |
| 502 | // all. A resource caught in a dependency cycle with the worker resolves |
| 503 | // `worker.durableObjectNamespaces` against that stub (not the final reconcile |
| 504 | // output), so a binding-only class had no namespace id there and the deploy |
| 505 | // failed with "Worker did not expose Durable Object namespace <class>" on |
| 506 | // every fresh stage's FIRST deploy (reruns found the script already existing |
| 507 | // and passed — which is why this must run on a scratch stack, not a shared |
| 508 | // beforeAll deploy). |
| 509 | // |
| 510 | // The cycle here mirrors the worker<->container shape from the issue (a bare |
| 511 | // `Cloudflare.DurableObject` fronting a Container, where the container binds |
| 512 | // the DO's namespace id and the worker binds the container) without needing a |
| 513 | // Docker build: the consumer worker binds the host's `Counter` namespace id, |
| 514 | // and the host binds the consumer's name back. |
| 515 | test.provider( |
| 516 | "precreate stub exposes binding-only durable object namespaces to cycle peers", |
| 517 | (scratch) => |
| 518 | Effect.gen(function* () { |
| 519 | const deployed = yield* scratch.deploy( |
| 520 | Effect.gen(function* () { |
| 521 | // `Counter` lives only in the inline script + env binding — it is |
| 522 | // never an Effect-native export. |
| 523 | const host = yield* Cloudflare.Worker("host-worker", { |
| 524 | script: hostWorkerScript, |
| 525 | env: { |
| 526 | Counter: Cloudflare.DurableObject("Counter"), |
| 527 | }, |
| 528 | }); |
| 529 | |
| 530 | const consumer = yield* Cloudflare.Worker("consumer-worker", { |
| 531 | script: `export default { |
| 532 | async fetch(request, env) { |
| 533 | return Response.json({ namespaceId: env.COUNTER_NS }); |
| 534 | }, |
| 535 | }; |
| 536 | `, |
| 537 | }); |
| 538 | |
| 539 | // consumer -> host: resolve the DO namespace id, exactly how a |
| 540 | // Container application binds `durableObjects.namespaceId`. The |
| 541 | // throw fires when the precreate stub omits the class — failing the |
| 542 | // deploy the same way the container cycle in #763 did. |
| 543 | yield* consumer.bind("counter-namespace", { |
| 544 | bindings: [ |
| 545 | { |
| 546 | type: "plain_text", |
| 547 | name: "COUNTER_NS", |
| 548 | text: host.durableObjectNamespaces.pipe( |
| 549 | Output.map((namespaces) => { |
| 550 | const id = namespaces.Counter; |
| 551 | if (!id) { |
| 552 | throw new Error( |
| 553 | "Worker did not expose Durable Object namespace Counter.", |
| 554 | ); |
| 555 | } |
| 556 | return id; |
| 557 | }), |
| 558 | ), |
| 559 | }, |
| 560 | ], |
| 561 | }); |
| 562 | |
| 563 | // host -> consumer: closes the cycle so both workers are SCC |
| 564 | // members and rendezvous on each other's precreate stubs. |
| 565 | yield* host.bind("consumer-name", { |
| 566 | bindings: [ |
| 567 | { |
| 568 | type: "plain_text", |
| 569 | name: "CONSUMER_NAME", |
| 570 | text: consumer.workerName, |
| 571 | }, |
| 572 | ], |
| 573 | }); |
| 574 | |
| 575 | return { host, consumer }; |
| 576 | }), |
| 577 | ); |
| 578 | |
| 579 | // The namespace id created for the precreate stub must survive into the |
| 580 | // final reconcile output... |
| 581 | const finalNamespaceId = |
| 582 | deployed.host.durableObjectNamespaces.Counter; |
| 583 | expect(finalNamespaceId).toBeDefined(); |
| 584 | |
| 585 | // ...and be the same id the consumer resolved from the stub and |
| 586 | // deployed into its live environment. |
| 587 | const body = yield* fetchJsonReady<{ namespaceId: string }>( |
| 588 | deployed.consumer.url!, |
| 589 | ); |
| 590 | |
| 591 | expect(body.namespaceId).toBe(finalNamespaceId); |
| 592 | |
| 593 | yield* scratch.destroy(); |
| 594 | }).pipe(logLevel), |
| 595 | { timeout: 120_000 }, |
| 596 | ); |
| 597 | |
| 598 | // Adopt a Durable Object class that already exists on a worker created |
| 599 | // *outside* Alchemy (raw Cloudflare API here, standing in for Wrangler or the |
| 600 | // dashboard). The worker carries no `alchemy:*` ownership tags and no |
| 601 | // `alchemy:do:` logical-id→class mapping, so `read` returns `Unowned` and the |
| 602 | // takeover requires `adopt(true)`. The matching `Counter` class must be reused |
| 603 | // — if `putWorker` instead asked Cloudflare to create it as a *new* class the |
| 604 | // migration would fail with "class already exists". This is the documented |
| 605 | // limitation: on the first (adopting) deploy the binding's class name must |
| 606 | // match the existing class; renames only work once Alchemy owns the worker. |
| 607 | test.provider( |
| 608 | "adopts a durable object class created outside alchemy", |
| 609 | (scratch) => |
| 610 | Effect.gen(function* () { |
| 611 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 612 | |
| 613 | // Phase 1: provision a worker + `Counter` DO class straight through the |
| 614 | // Cloudflare API — no Alchemy involvement, so none of our tags. The |
| 615 | // name is deterministic (never Date.now()/random); a crashed run can |
| 616 | // leave the worker behind, and `putScript` would then reject the |
| 617 | // `newSqliteClasses` migration ("class already exists"), so purge any |
| 618 | // leftover first. |
| 619 | const physicalName = "alchemy-test-do-adopt"; |
| 620 | |
| 621 | yield* workers |
| 622 | .deleteScript({ accountId, scriptName: physicalName, force: true }) |
| 623 | .pipe(Effect.catchTag("WorkerNotFound", () => Effect.void)); |
| 624 | |
| 625 | yield* workers.putScript({ |
| 626 | accountId, |
| 627 | scriptName: physicalName, |
| 628 | metadata: { |
| 629 | mainModule: "main.js", |
| 630 | bindings: [ |
| 631 | { |
| 632 | type: "durable_object_namespace", |
| 633 | name: "Counter", |
| 634 | className: "Counter", |
| 635 | }, |
| 636 | ], |
| 637 | migrations: { |
| 638 | newSqliteClasses: ["Counter"], |
| 639 | }, |
| 640 | // Match Alchemy's default compatibility date so adoption is a |
| 641 | // pure class-reuse with no compat-date churn. Old dates predate |
| 642 | // `DurableObjectNamespace.getByName`, which `hostWorkerScript` |
| 643 | // relies on. |
| 644 | compatibilityDate: "2026-03-17", |
| 645 | }, |
| 646 | files: [ |
| 647 | new File([hostWorkerScript], "main.js", { |
| 648 | type: "application/javascript+module", |
| 649 | }), |
| 650 | ], |
| 651 | }); |
| 652 | |
| 653 | // Phase 2: deploy an async Alchemy Worker (inline script) over the same |
| 654 | // physical name with a matching `Counter` binding, opting in to the |
| 655 | // takeover via `adopt(true)`. |
| 656 | const adopted = yield* scratch |
| 657 | .deploy( |
| 658 | Effect.gen(function* () { |
| 659 | return yield* Cloudflare.Worker("AdoptDO", { |
| 660 | name: physicalName, |
| 661 | script: hostWorkerScript, |
| 662 | env: { |
| 663 | Counter: Cloudflare.DurableObject("Counter"), |
| 664 | }, |
| 665 | }); |
| 666 | }), |
| 667 | ) |
| 668 | .pipe(adopt(true)); |
| 669 | |
| 670 | expect(adopted.workerName).toBe(physicalName); |
| 671 | // The existing class was adopted (and resolved to a namespace id), |
| 672 | // not recreated. |
| 673 | expect(adopted.durableObjectNamespaces.Counter).toBeDefined(); |
| 674 | |
| 675 | // The adopted DO is functional end-to-end: increment round-trips |
| 676 | // through the reused `Counter` class. |
| 677 | yield* fetchJsonReady<{ ok: boolean }>(`${adopted.url}/reset`); |
| 678 | const first = yield* fetchJsonReady<{ value: number }>( |
| 679 | `${adopted.url}/increment`, |
| 680 | ); |
| 681 | expect(first.value).toBe(1); |
| 682 | |
| 683 | yield* scratch.destroy(); |
| 684 | }).pipe(logLevel), |
| 685 | { timeout: 120_000 }, |
| 686 | ); |
| 687 | |
| 688 | // #799: moving a Durable Object class from one Worker to another is |
| 689 | // *declared*: the new host names the former host with `transferredFrom` — |
| 690 | // here by Worker *logical id*, resolved to the actual script via alchemy's |
| 691 | // ownership tags (`alchemy:id:` + stack + stage) among scripts observed to |
| 692 | // host the class. Worker-a's deploy ships Cloudflare's data-preserving |
| 693 | // `transferred_classes` migration; worker-b's deploy converges on its own |
| 694 | // (no `deleted_classes` for a class that moved away). |
| 695 | // |
| 696 | // v1 — worker-b hosts `Counter` locally; write data into the namespace |
| 697 | // v2 — worker-a hosts `Counter` with `transferredFrom: "worker-b"`; |
| 698 | // worker-b re-binds it cross-script. The stored data must survive |
| 699 | // the move, reachable from both. |
| 700 | // v3 — redeploy of the same shape is inert (no re-transfer; the |
| 701 | // declaration stays in the code). |
| 702 | test.provider( |
| 703 | "durable object host move declared by worker logical id preserves data", |
| 704 | (scratch) => |
| 705 | Effect.gen(function* () { |
| 706 | yield* scratch.destroy(); |
| 707 | |
| 708 | const v1 = yield* scratch.deploy( |
| 709 | Effect.gen(function* () { |
| 710 | return { |
| 711 | b: yield* Cloudflare.Worker("worker-b", { |
| 712 | script: hostWorkerScript, |
| 713 | env: { |
| 714 | Counter: Cloudflare.DurableObject("Counter"), |
| 715 | }, |
| 716 | }), |
| 717 | }; |
| 718 | }), |
| 719 | ); |
| 720 | |
| 721 | const originalNamespaceId = v1.b.durableObjectNamespaces.Counter; |
| 722 | expect(originalNamespaceId).toBeDefined(); |
| 723 | |
| 724 | // Write data while worker-b hosts the namespace. |
| 725 | yield* fetchJsonReady<{ ok: boolean }>(`${v1.b.url}/reset`); |
| 726 | const written = yield* fetchJsonReady<{ value: number }>( |
| 727 | `${v1.b.url}/increment`, |
| 728 | ); |
| 729 | expect(written.value).toBe(1); |
| 730 | |
| 731 | const moved = Effect.gen(function* () { |
| 732 | const a = yield* Cloudflare.Worker("worker-a", { |
| 733 | script: hostWorkerScript, |
| 734 | env: { |
| 735 | Counter: Cloudflare.DurableObject("Counter", { |
| 736 | // The former host's Worker *logical id* — resolved to its |
| 737 | // physical script via alchemy's ownership tags. |
| 738 | transferredFrom: "worker-b", |
| 739 | }), |
| 740 | }, |
| 741 | }); |
| 742 | const b = yield* Cloudflare.Worker("worker-b", { |
| 743 | script: consumerWorkerScript, |
| 744 | env: { |
| 745 | Counter: Cloudflare.DurableObject("Counter", { |
| 746 | scriptName: a.workerName, |
| 747 | }), |
| 748 | }, |
| 749 | }); |
| 750 | return { a, b }; |
| 751 | }); |
| 752 | |
| 753 | const v2 = yield* scratch.deploy(moved); |
| 754 | expect(v2.a.durableObjectNamespaces.Counter).toBe( |
| 755 | originalNamespaceId, |
| 756 | ); |
| 757 | expect(v2.b.durableObjectNamespaces.Counter).toBe( |
| 758 | originalNamespaceId, |
| 759 | ); |
| 760 | |
| 761 | // The namespace moved to worker-a with its data intact... |
| 762 | const viaA = yield* fetchJsonReady<{ value: number }>( |
| 763 | `${v2.a.url}/get`, |
| 764 | ); |
| 765 | expect(viaA.value).toBe(1); |
| 766 | |
| 767 | // ...and worker-b reaches the same objects through the cross-script |
| 768 | // binding. |
| 769 | const viaB = yield* fetchJsonReady<{ value: number }>( |
| 770 | `${v2.b.url}/increment`, |
| 771 | ); |
| 772 | expect(viaB.value).toBe(2); |
| 773 | |
| 774 | // Redeploying the same shape is inert: the class now lives on |
| 775 | // worker-a (tracked by its alchemy:do tag), so the standing |
| 776 | // declaration must not re-emit a transfer migration. Data intact. |
| 777 | const v3 = yield* scratch.deploy(moved); |
| 778 | const afterRedeploy = yield* fetchJsonReady<{ value: number }>( |
| 779 | `${v3.a.url}/get`, |
| 780 | ); |
| 781 | expect(afterRedeploy.value).toBe(2); |
| 782 | |
| 783 | // ...and once the move has landed everywhere, the declaration can be |
| 784 | // removed entirely: the class is anchored by its alchemy:do tag, so |
| 785 | // dropping `transferredFrom` emits no migration and touches no data. |
| 786 | const v4 = yield* scratch.deploy( |
| 787 | Effect.gen(function* () { |
| 788 | const a = yield* Cloudflare.Worker("worker-a", { |
| 789 | script: hostWorkerScript, |
| 790 | env: { |
| 791 | Counter: Cloudflare.DurableObject("Counter"), |
| 792 | }, |
| 793 | }); |
| 794 | const b = yield* Cloudflare.Worker("worker-b", { |
| 795 | script: consumerWorkerScript, |
| 796 | env: { |
| 797 | Counter: Cloudflare.DurableObject("Counter", { |
| 798 | scriptName: a.workerName, |
| 799 | }), |
| 800 | }, |
| 801 | }); |
| 802 | return { a, b }; |
| 803 | }), |
| 804 | ); |
| 805 | const afterRemoval = yield* fetchJsonReady<{ value: number }>( |
| 806 | `${v4.a.url}/get`, |
| 807 | ); |
| 808 | expect(afterRemoval.value).toBe(2); |
| 809 | |
| 810 | yield* scratch.destroy(); |
| 811 | }).pipe(logLevel), |
| 812 | { timeout: 240_000 }, |
| 813 | ); |
| 814 | |
| 815 | // #799: `transferredFrom` also accepts the former host's *physical script |
| 816 | // name* — the form required for cross-stack moves, where the new host |
| 817 | // deploys from a different plan entirely (modeled here with raw-string |
| 818 | // script names on a pre-existing worker). |
| 819 | test.provider( |
| 820 | "transferredFrom by physical script name transfers the namespace", |
| 821 | (scratch) => |
| 822 | Effect.gen(function* () { |
| 823 | yield* scratch.destroy(); |
| 824 | |
| 825 | // v1: worker-b hosts the class; worker-a exists but has no DO yet — |
| 826 | // deploying it now is what makes its physical name knowable as a raw |
| 827 | // string in v2 (the cross-stack shape). |
| 828 | const v1 = yield* scratch.deploy( |
| 829 | Effect.gen(function* () { |
| 830 | return { |
| 831 | a: yield* Cloudflare.Worker("worker-a", { |
| 832 | script: `export default { fetch() { return new Response("plain"); } };`, |
| 833 | }), |
| 834 | b: yield* Cloudflare.Worker("worker-b", { |
| 835 | script: hostWorkerScript, |
| 836 | env: { |
| 837 | Counter: Cloudflare.DurableObject("Counter"), |
| 838 | }, |
| 839 | }), |
| 840 | }; |
| 841 | }), |
| 842 | ); |
| 843 | |
| 844 | yield* fetchJsonReady<{ ok: boolean }>(`${v1.b.url}/reset`); |
| 845 | const written = yield* fetchJsonReady<{ value: number }>( |
| 846 | `${v1.b.url}/increment`, |
| 847 | ); |
| 848 | expect(written.value).toBe(1); |
| 849 | |
| 850 | const aScriptName = v1.a.workerName; |
| 851 | const bScriptName = v1.b.workerName; |
| 852 | |
| 853 | const v2 = yield* scratch.deploy( |
| 854 | Effect.gen(function* () { |
| 855 | const a = yield* Cloudflare.Worker("worker-a", { |
| 856 | script: hostWorkerScript, |
| 857 | env: { |
| 858 | Counter: Cloudflare.DurableObject("Counter", { |
| 859 | // Thunk form — evaluated lazily at plan time and |
| 860 | // normalized to the plain script name. |
| 861 | transferredFrom: () => bScriptName, |
| 862 | }), |
| 863 | }, |
| 864 | }); |
| 865 | const b = yield* Cloudflare.Worker("worker-b", { |
| 866 | script: consumerWorkerScript, |
| 867 | env: { |
| 868 | // Service binding orders worker-b after worker-a (the raw |
| 869 | // string scriptName below carries no dependency edge). |
| 870 | A: a, |
| 871 | Counter: Cloudflare.DurableObject("Counter", { |
| 872 | scriptName: aScriptName, |
| 873 | }), |
| 874 | }, |
| 875 | }); |
| 876 | return { a, b }; |
| 877 | }), |
| 878 | ); |
| 879 | |
| 880 | const viaA = yield* fetchJsonReady<{ value: number }>( |
| 881 | `${v2.a.url}/get`, |
| 882 | ); |
| 883 | expect(viaA.value).toBe(1); |
| 884 | |
| 885 | yield* scratch.destroy(); |
| 886 | }).pipe(logLevel), |
| 887 | { timeout: 240_000 }, |
| 888 | ); |
| 889 | |
| 890 | // #799 (safety interlock): a host move WITHOUT `transferredFrom` must fail |
| 891 | // loudly before the former host uploads anything — moves are never |
| 892 | // inferred, the namespace (and its data) still lives on worker-b, and |
| 893 | // deleting the class would destroy it. The error names the exact |
| 894 | // declaration to add. |
| 895 | test.provider( |
| 896 | "undeclared host move fails with DurableObjectTransferRequired", |
| 897 | (scratch) => |
| 898 | Effect.gen(function* () { |
| 899 | yield* scratch.destroy(); |
| 900 | |
| 901 | const v1 = yield* scratch.deploy( |
| 902 | Effect.gen(function* () { |
| 903 | return { |
| 904 | a: yield* Cloudflare.Worker("worker-a", { |
| 905 | script: `export default { fetch() { return new Response("plain"); } };`, |
| 906 | }), |
| 907 | b: yield* Cloudflare.Worker("worker-b", { |
| 908 | script: hostWorkerScript, |
| 909 | env: { |
| 910 | Counter: Cloudflare.DurableObject("Counter"), |
| 911 | }, |
| 912 | }), |
| 913 | }; |
| 914 | }), |
| 915 | ); |
| 916 | |
| 917 | // Data exists on worker-b's namespace — this is what the interlock |
| 918 | // protects. |
| 919 | yield* fetchJsonReady<{ ok: boolean }>(`${v1.b.url}/reset`); |
| 920 | yield* fetchJsonReady<{ value: number }>(`${v1.b.url}/increment`); |
| 921 | |
| 922 | const aScriptName = v1.a.workerName; |
| 923 | |
| 924 | const error = yield* scratch |
| 925 | .deploy( |
| 926 | Effect.gen(function* () { |
| 927 | const a = yield* Cloudflare.Worker("worker-a", { |
| 928 | script: hostWorkerScript, |
| 929 | env: { |
| 930 | // Fresh, empty namespace for the same class name — the |
| 931 | // move is not declared, so nothing transfers. |
| 932 | Counter: Cloudflare.DurableObject("Counter"), |
| 933 | }, |
| 934 | }); |
| 935 | const b = yield* Cloudflare.Worker("worker-b", { |
| 936 | script: consumerWorkerScript, |
| 937 | env: { |
| 938 | Counter: Cloudflare.DurableObject("Counter", { |
| 939 | scriptName: aScriptName, |
| 940 | }), |
| 941 | }, |
| 942 | }); |
| 943 | return { a, b }; |
| 944 | }), |
| 945 | ) |
| 946 | .pipe(Effect.flip); |
| 947 | |
| 948 | expect(error._tag).toEqual("DurableObjectTransferRequired"); |
| 949 | expect( |
| 950 | (yield* fetchJsonReady<{ value: number }>(`${v1.b.url}/get`)).value, |
| 951 | ).toBe(1); |
| 952 | |
| 953 | yield* scratch.destroy(); |
| 954 | }).pipe(logLevel), |
| 955 | { timeout: 240_000 }, |
| 956 | ); |
| 957 | |
| 958 | test.provider( |
| 959 | "a transferred namespace cannot be rebound to a different namespace", |
| 960 | (scratch) => |
| 961 | Effect.gen(function* () { |
| 962 | yield* scratch.destroy(); |
| 963 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 964 | |
| 965 | const v1 = yield* scratch.deploy( |
| 966 | Effect.gen(function* () { |
| 967 | return { |
| 968 | a: yield* Cloudflare.Worker("worker-a", { |
| 969 | script: hostWorkerScript, |
| 970 | env: { Counter: Cloudflare.DurableObject("Counter") }, |
| 971 | }), |
| 972 | }; |
| 973 | }), |
| 974 | ); |
| 975 | const originalNamespaceId = v1.a.durableObjectNamespaces.Counter; |
| 976 | expect(originalNamespaceId).toBeDefined(); |
| 977 | yield* fetchJsonReady<{ ok: boolean }>(`${v1.a.url}/reset`); |
| 978 | expect( |
| 979 | (yield* fetchJsonReady<{ value: number }>(`${v1.a.url}/increment`)) |
| 980 | .value, |
| 981 | ).toBe(1); |
| 982 | |
| 983 | const transferredStack = (scriptName?: string) => |
| 984 | Effect.gen(function* () { |
| 985 | const a = yield* Cloudflare.Worker("worker-a", { |
| 986 | script: |
| 987 | scriptName === undefined |
| 988 | ? hostWorkerScript |
| 989 | : consumerWorkerScript, |
| 990 | env: { |
| 991 | Counter: |
| 992 | scriptName === undefined |
| 993 | ? Cloudflare.DurableObject("Counter") |
| 994 | : Cloudflare.DurableObject("Counter", { scriptName }), |
| 995 | }, |
| 996 | }); |
| 997 | const b = yield* Cloudflare.Worker("worker-b", { |
| 998 | script: hostWorkerScript, |
| 999 | env: { |
| 1000 | Counter: Cloudflare.DurableObject("Counter", { |
| 1001 | transferredFrom: a, |
| 1002 | }), |
| 1003 | }, |
| 1004 | }); |
| 1005 | const c = yield* Cloudflare.Worker("worker-c", { |
| 1006 | script: hostWorkerScript, |
| 1007 | env: { Counter: Cloudflare.DurableObject("Counter") }, |
| 1008 | }); |
| 1009 | return { a, b, c }; |
| 1010 | }); |
| 1011 | |
| 1012 | // A stays unchanged while B receives its namespace; C owns a fresh one. |
| 1013 | const v2 = yield* scratch.deploy(transferredStack()); |
| 1014 | expect(v2.b.durableObjectNamespaces.Counter).toBe( |
| 1015 | originalNamespaceId, |
| 1016 | ); |
| 1017 | const unrelatedNamespaceId = v2.c.durableObjectNamespaces.Counter; |
| 1018 | expect(unrelatedNamespaceId).toBeDefined(); |
| 1019 | expect(unrelatedNamespaceId).not.toBe(originalNamespaceId); |
| 1020 | |
| 1021 | const namespaces = yield* durableObjects.listNamespaces |
| 1022 | .items({ accountId }) |
| 1023 | .pipe( |
| 1024 | Stream.runCollect, |
| 1025 | Effect.repeat({ |
| 1026 | schedule: Schedule.spaced("1 second"), |
| 1027 | times: 8, |
| 1028 | until: (namespaces) => |
| 1029 | namespaces.some( |
| 1030 | (ns) => |
| 1031 | ns.id === originalNamespaceId && |
| 1032 | ns.script === v2.b.workerName, |
| 1033 | ) && |
| 1034 | namespaces.some( |
| 1035 | (ns) => |
| 1036 | ns.id === unrelatedNamespaceId && |
| 1037 | ns.script === v2.c.workerName, |
| 1038 | ) && |
| 1039 | !namespaces.some( |
| 1040 | (ns) => |
| 1041 | ns.script === v2.a.workerName && ns.class === "Counter", |
| 1042 | ), |
| 1043 | }), |
| 1044 | ); |
| 1045 | expect( |
| 1046 | namespaces.find((ns) => ns.id === originalNamespaceId), |
| 1047 | ).toMatchObject({ script: v2.b.workerName, class: "Counter" }); |
| 1048 | expect( |
| 1049 | namespaces.find((ns) => ns.id === unrelatedNamespaceId), |
| 1050 | ).toMatchObject({ script: v2.c.workerName, class: "Counter" }); |
| 1051 | expect( |
| 1052 | namespaces.some( |
| 1053 | (ns) => ns.script === v2.a.workerName && ns.class === "Counter", |
| 1054 | ), |
| 1055 | ).toBe(false); |
| 1056 | expect( |
| 1057 | (yield* fetchJsonReady<{ value: number }>(`${v2.b.url}/get`)).value, |
| 1058 | ).toBe(1); |
| 1059 | expect( |
| 1060 | (yield* fetchJsonReady<{ value: number }>(`${v2.c.url}/get`)).value, |
| 1061 | ).toBe(0); |
| 1062 | |
| 1063 | const error = yield* scratch |
| 1064 | .deploy(transferredStack(v2.c.workerName)) |
| 1065 | .pipe(Effect.flip); |
| 1066 | expect(error._tag).toBe("DurableObjectTransferRequired"); |
| 1067 | expect( |
| 1068 | (yield* fetchJsonReady<{ value: number }>(`${v2.b.url}/get`)).value, |
| 1069 | ).toBe(1); |
| 1070 | expect( |
| 1071 | (yield* fetchJsonReady<{ value: number }>(`${v2.c.url}/get`)).value, |
| 1072 | ).toBe(0); |
| 1073 | |
| 1074 | yield* scratch.destroy(); |
| 1075 | }).pipe(logLevel), |
| 1076 | { timeout: 120_000 }, |
| 1077 | ); |
| 1078 | |
| 1079 | // #799: the documented *pure move* — the former host drops the DO entirely, |
| 1080 | // keeping no cross-script reference — done as two deploys. Phase 1 adds the |
| 1081 | // class to worker-a, declaring the former host by **Worker resource |
| 1082 | // reference** (`transferredFrom: b`, normalized at plan time to worker-b's |
| 1083 | // logical id — no dependency edge on b); worker-b is untouched. Phase 2 |
| 1084 | // removes the DO from worker-b, whose deploy observes the class already |
| 1085 | // transferred away and skips the delete. |
| 1086 | test.provider( |
| 1087 | "two-deploy pure move with a Worker resource reference", |
| 1088 | (scratch) => |
| 1089 | Effect.gen(function* () { |
| 1090 | yield* scratch.destroy(); |
| 1091 | |
| 1092 | const v1 = yield* scratch.deploy( |
| 1093 | Effect.gen(function* () { |
| 1094 | return { |
| 1095 | b: yield* Cloudflare.Worker("worker-b", { |
| 1096 | script: hostWorkerScript, |
| 1097 | env: { |
| 1098 | Counter: Cloudflare.DurableObject("Counter"), |
| 1099 | }, |
| 1100 | }), |
| 1101 | }; |
| 1102 | }), |
| 1103 | ); |
| 1104 | |
| 1105 | yield* fetchJsonReady<{ ok: boolean }>(`${v1.b.url}/reset`); |
| 1106 | const written = yield* fetchJsonReady<{ value: number }>( |
| 1107 | `${v1.b.url}/increment`, |
| 1108 | ); |
| 1109 | expect(written.value).toBe(1); |
| 1110 | |
| 1111 | // Phase 1: worker-a takes the class, naming the former host by |
| 1112 | // resource reference. worker-b's declaration is unchanged (noop). |
| 1113 | const v2 = yield* scratch.deploy( |
| 1114 | Effect.gen(function* () { |
| 1115 | const b = yield* Cloudflare.Worker("worker-b", { |
| 1116 | script: hostWorkerScript, |
| 1117 | env: { |
| 1118 | Counter: Cloudflare.DurableObject("Counter"), |
| 1119 | }, |
| 1120 | }); |
| 1121 | const a = yield* Cloudflare.Worker("worker-a", { |
| 1122 | script: hostWorkerScript, |
| 1123 | env: { |
| 1124 | Counter: Cloudflare.DurableObject("Counter", { |
| 1125 | transferredFrom: b, |
| 1126 | }), |
| 1127 | }, |
| 1128 | }); |
| 1129 | return { a, b }; |
| 1130 | }), |
| 1131 | ); |
| 1132 | |
| 1133 | // The namespace moved to worker-a with its data intact. |
| 1134 | const viaA = yield* fetchJsonReady<{ value: number }>( |
| 1135 | `${v2.a.url}/get`, |
| 1136 | ); |
| 1137 | expect(viaA.value).toBe(1); |
| 1138 | |
| 1139 | // Phase 2: worker-b drops the DO entirely. Its deploy observes the |
| 1140 | // class already lives on worker-a and emits no delete migration. |
| 1141 | const v3 = yield* scratch.deploy( |
| 1142 | Effect.gen(function* () { |
| 1143 | const b = yield* Cloudflare.Worker("worker-b", { |
| 1144 | script: `export default { fetch() { return new Response("plain"); } };`, |
| 1145 | }); |
| 1146 | const a = yield* Cloudflare.Worker("worker-a", { |
| 1147 | script: hostWorkerScript, |
| 1148 | env: { |
| 1149 | Counter: Cloudflare.DurableObject("Counter", { |
| 1150 | transferredFrom: b, |
| 1151 | }), |
| 1152 | }, |
| 1153 | }); |
| 1154 | return { a, b }; |
| 1155 | }), |
| 1156 | ); |
| 1157 | |
| 1158 | const afterRemoval = yield* fetchJsonReady<{ value: number }>( |
| 1159 | `${v3.a.url}/increment`, |
| 1160 | ); |
| 1161 | expect(afterRemoval.value).toBe(2); |
| 1162 | |
| 1163 | yield* scratch.destroy(); |
| 1164 | }).pipe(logLevel), |
| 1165 | { timeout: 240_000 }, |
| 1166 | ); |
| 1167 | |
| 1168 | // Reproduces #811: one script tag per DO binding blew Cloudflare's 10-tag |
| 1169 | // limit at 7+ Durable Objects (3 ownership tags + 1 migration tag + N DO |
| 1170 | // tags). The logical-id→class mapping is now packed into `alchemy:dos:` |
| 1171 | // tags, so a worker with 20 DOs — double the count that used to consume the |
| 1172 | // entire tag budget — deploys fine and the whole tag set stays within the |
| 1173 | // limit. The second deploy renames a class and drops another to prove |
| 1174 | // migrations are still driven by the packed mapping. |
| 1175 | test.provider( |
| 1176 | "worker with 20 durable objects deploys within the 10-tag limit", |
| 1177 | (scratch) => |
| 1178 | Effect.gen(function* () { |
| 1179 | yield* scratch.destroy(); |
| 1180 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 1181 | const ids = Array.from({ length: 20 }, (_, i) => `DO_${i}`); |
| 1182 | const makeScript = (classes: string[], version: string) => |
| 1183 | `import { DurableObject } from "cloudflare:workers"; |
| 1184 | ${classes.map((c) => `export class ${c} extends DurableObject {}`).join("\n")} |
| 1185 | export default { async fetch() { return new Response("${version}"); } }; |
| 1186 | `; |
| 1187 | |
| 1188 | const v1 = yield* scratch.deploy( |
| 1189 | Effect.gen(function* () { |
| 1190 | return { |
| 1191 | worker: yield* Cloudflare.Worker("worker", { |
| 1192 | script: makeScript( |
| 1193 | ids.map((_, i) => `Class${i}`), |
| 1194 | "v1", |
| 1195 | ), |
| 1196 | env: Object.fromEntries( |
| 1197 | ids.map((id, i) => [ |
| 1198 | id, |
| 1199 | Cloudflare.DurableObject(id, { className: `Class${i}` }), |
| 1200 | ]), |
| 1201 | ), |
| 1202 | }), |
| 1203 | }; |
| 1204 | }), |
| 1205 | ); |
| 1206 | expect(yield* fetchReady(v1.worker.url!, "v1")).toBe("v1"); |
| 1207 | |
| 1208 | const tags = yield* getWorkerTags(v1.worker.workerName, accountId); |
| 1209 | expect(tags.length).toBeLessThanOrEqual(10); |
| 1210 | expect(tags.filter((t) => t.startsWith("alchemy:dos:"))).toHaveLength( |
| 1211 | 1, |
| 1212 | ); |
| 1213 | expect(tags.filter((t) => t.startsWith("alchemy:do:"))).toHaveLength( |
| 1214 | 0, |
| 1215 | ); |
| 1216 | |
| 1217 | expect(Object.keys(v1.worker.durableObjectNamespaces).sort()).toEqual( |
| 1218 | ids.map((_, i) => `Class${i}`).sort(), |
| 1219 | ); |
| 1220 | |
| 1221 | // Rename Class0 → Class0V2 (same binding id) and delete DO_19 — both |
| 1222 | // migrations resolve their previous class through the packed tag. |
| 1223 | const v2 = yield* scratch.deploy( |
| 1224 | Effect.gen(function* () { |
| 1225 | return { |
| 1226 | worker: yield* Cloudflare.Worker("worker", { |
| 1227 | script: makeScript( |
| 1228 | [ |
| 1229 | "Class0V2", |
| 1230 | ...ids.slice(1, 19).map((_, i) => `Class${i + 1}`), |
| 1231 | ], |
| 1232 | "v2", |
| 1233 | ), |
| 1234 | env: Object.fromEntries( |
| 1235 | ids.slice(0, 19).map((id, i) => [ |
| 1236 | id, |
| 1237 | Cloudflare.DurableObject(id, { |
| 1238 | className: i === 0 ? "Class0V2" : `Class${i}`, |
| 1239 | }), |
| 1240 | ]), |
| 1241 | ), |
| 1242 | }), |
| 1243 | }; |
| 1244 | }), |
| 1245 | ); |
| 1246 | expect(yield* fetchReady(v2.worker.url!, "v2")).toBe("v2"); |
| 1247 | expect(Object.keys(v2.worker.durableObjectNamespaces).sort()).toEqual( |
| 1248 | [ |
| 1249 | "Class0V2", |
| 1250 | ...ids.slice(1, 19).map((_, i) => `Class${i + 1}`), |
| 1251 | ].sort(), |
| 1252 | ); |
| 1253 | expect(v2.worker.durableObjectNamespaces.Class0V2).toBe( |
| 1254 | v1.worker.durableObjectNamespaces.Class0, |
| 1255 | ); |
| 1256 | expect(v2.worker.durableObjectNamespaces.Class19).toBeUndefined(); |
| 1257 | for (let i = 1; i < 19; i++) { |
| 1258 | expect(v2.worker.durableObjectNamespaces[`Class${i}`]).toBe( |
| 1259 | v1.worker.durableObjectNamespaces[`Class${i}`], |
| 1260 | ); |
| 1261 | } |
| 1262 | |
| 1263 | const namespaces = yield* durableObjects.listNamespaces |
| 1264 | .items({ accountId }) |
| 1265 | .pipe( |
| 1266 | Stream.runCollect, |
| 1267 | Effect.map((namespaces) => |
| 1268 | namespaces.filter((ns) => ns.script === v2.worker.workerName), |
| 1269 | ), |
| 1270 | Effect.repeat({ |
| 1271 | schedule: Schedule.spaced("2 seconds"), |
| 1272 | until: (namespaces) => |
| 1273 | namespaces.length === 19 && |
| 1274 | new Set(namespaces.map((ns) => ns.id)).size === 19 && |
| 1275 | namespaces.every( |
| 1276 | (ns) => |
| 1277 | ns.class !== undefined && |
| 1278 | ns.class !== null && |
| 1279 | v2.worker.durableObjectNamespaces[ns.class] === ns.id, |
| 1280 | ), |
| 1281 | times: 8, |
| 1282 | }), |
| 1283 | ); |
| 1284 | expect(namespaces).toHaveLength(19); |
| 1285 | expect(namespaces.map((ns) => ns.id).sort()).toEqual( |
| 1286 | Object.values(v2.worker.durableObjectNamespaces).sort(), |
| 1287 | ); |
| 1288 | expect( |
| 1289 | namespaces.some( |
| 1290 | (ns) => ns.id === v1.worker.durableObjectNamespaces.Class19, |
| 1291 | ), |
| 1292 | ).toBe(false); |
| 1293 | |
| 1294 | yield* scratch.destroy(); |
| 1295 | }).pipe(logLevel), |
| 1296 | { timeout: 120_000 }, |
| 1297 | ); |
| 1298 | |
| 1299 | // Roll-forward from the legacy tag format: a worker last deployed by an |
| 1300 | // older alchemy carries one `alchemy:do:{logicalId}:{className}` tag per DO. |
| 1301 | // Simulate that by rewriting the live script tags to the legacy format |
| 1302 | // out-of-band, then redeploy with a class rename — reconcile must parse the |
| 1303 | // legacy tags to resolve the previous class, and the deploy rewrites the |
| 1304 | // mapping in the packed format. |
| 1305 | test.provider( |
| 1306 | "rolls forward from legacy per-DO alchemy:do: tags", |
| 1307 | (scratch) => |
| 1308 | Effect.gen(function* () { |
| 1309 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 1310 | |
| 1311 | const v1 = yield* scratch.deploy( |
| 1312 | Effect.gen(function* () { |
| 1313 | return { |
| 1314 | worker: yield* Cloudflare.Worker("worker", { |
| 1315 | script: `import { DurableObject } from "cloudflare:workers"; |
| 1316 | export class CounterClass extends DurableObject {} |
| 1317 | export class MeterClass extends DurableObject {} |
| 1318 | export default { async fetch() { return new Response("v1"); } }; |
| 1319 | `, |
| 1320 | env: { |
| 1321 | Counter: Cloudflare.DurableObject("Counter", { |
| 1322 | className: "CounterClass", |
| 1323 | }), |
| 1324 | Meter: Cloudflare.DurableObject("Meter", { |
| 1325 | className: "MeterClass", |
| 1326 | }), |
| 1327 | }, |
| 1328 | }), |
| 1329 | }; |
| 1330 | }), |
| 1331 | ); |
| 1332 | expect(yield* fetchReady(v1.worker.url!, "v1")).toBe("v1"); |
| 1333 | const scriptName = v1.worker.workerName; |
| 1334 | |
| 1335 | // Rewrite the tags the way alchemy <= beta.62 wrote them: one |
| 1336 | // `alchemy:do:` tag per binding, no packed tag. |
| 1337 | const deployedTags = yield* getWorkerTags(scriptName, accountId); |
| 1338 | yield* workers.patchScriptScriptAndVersionSetting({ |
| 1339 | accountId, |
| 1340 | scriptName, |
| 1341 | settings: { |
| 1342 | tags: [ |
| 1343 | ...deployedTags.filter((t) => !t.startsWith("alchemy:dos:")), |
| 1344 | "alchemy:do:Counter:CounterClass", |
| 1345 | "alchemy:do:Meter:MeterClass", |
| 1346 | ], |
| 1347 | }, |
| 1348 | }); |
| 1349 | |
| 1350 | // Redeploy with a rename; the previous class must be resolved from the |
| 1351 | // legacy tags for the migration to succeed. |
| 1352 | const v2 = yield* scratch.deploy( |
| 1353 | Effect.gen(function* () { |
| 1354 | return { |
| 1355 | worker: yield* Cloudflare.Worker("worker", { |
| 1356 | script: `import { DurableObject } from "cloudflare:workers"; |
| 1357 | export class CounterClassV2 extends DurableObject {} |
| 1358 | export class MeterClass extends DurableObject {} |
| 1359 | export default { async fetch() { return new Response("v2"); } }; |
| 1360 | `, |
| 1361 | env: { |
| 1362 | Counter: Cloudflare.DurableObject("Counter", { |
| 1363 | className: "CounterClassV2", |
| 1364 | }), |
| 1365 | Meter: Cloudflare.DurableObject("Meter", { |
| 1366 | className: "MeterClass", |
| 1367 | }), |
| 1368 | }, |
| 1369 | }), |
| 1370 | }; |
| 1371 | }), |
| 1372 | ); |
| 1373 | expect(yield* fetchReady(v2.worker.url!, "v2")).toBe("v2"); |
| 1374 | |
| 1375 | // The deploy rewrote the mapping in the packed format. |
| 1376 | const rolledForward = yield* getWorkerTags(scriptName, accountId); |
| 1377 | expect( |
| 1378 | rolledForward.filter((t) => t.startsWith("alchemy:dos:")), |
| 1379 | ).toHaveLength(1); |
| 1380 | expect( |
| 1381 | rolledForward.filter((t) => t.startsWith("alchemy:do:")), |
| 1382 | ).toHaveLength(0); |
| 1383 | |
| 1384 | yield* scratch.destroy(); |
| 1385 | }).pipe(logLevel), |
| 1386 | { timeout: 180_000 }, |
| 1387 | ); |
| 1388 | |
| 1389 | // #799 (idempotence): a standing `transferredFrom` declaration must never do |
| 1390 | // anything destructive after its job is done. |
| 1391 | // |
| 1392 | // v1 — a FRESH stage deployed directly in the final topology, declaration |
| 1393 | // present: no listed source hosts the class, so worker-a creates a |
| 1394 | // fresh namespace. Same-class namespaces owned by other stacks and |
| 1395 | // stages on the account must not match the declaration. |
| 1396 | // v2 — worker-b later hosts its OWN Durable Object under the same name and |
| 1397 | // class (the isolated-twins pattern): a fresh, separate namespace. |
| 1398 | // v3 — worker-a redeploys (forced update) with the now-stale declaration |
| 1399 | // while that same-name namespace exists on worker-b: the class is |
| 1400 | // already anchored to worker-a by its DO tag, so the stale |
| 1401 | // declaration must not steal worker-b's namespace or emit any |
| 1402 | // migration. Distinct counter values prove both namespaces survive |
| 1403 | // untouched. |
| 1404 | test.provider( |
| 1405 | "standing transferredFrom is inert on fresh stages and never steals a same-name namespace", |
| 1406 | (scratch) => |
| 1407 | Effect.gen(function* () { |
| 1408 | yield* scratch.destroy(); |
| 1409 | |
| 1410 | // Fresh stage, final topology, declaration present from day one. |
| 1411 | const v1 = yield* scratch.deploy( |
| 1412 | Effect.gen(function* () { |
| 1413 | const a = yield* Cloudflare.Worker("worker-a", { |
| 1414 | script: hostWorkerScript, |
| 1415 | env: { |
| 1416 | Counter: Cloudflare.DurableObject("Counter", { |
| 1417 | transferredFrom: "worker-b", |
| 1418 | }), |
| 1419 | }, |
| 1420 | }); |
| 1421 | const b = yield* Cloudflare.Worker("worker-b", { |
| 1422 | script: consumerWorkerScript, |
| 1423 | env: { |
| 1424 | Counter: Cloudflare.DurableObject("Counter", { |
| 1425 | scriptName: a.workerName, |
| 1426 | }), |
| 1427 | }, |
| 1428 | }); |
| 1429 | return { a, b }; |
| 1430 | }), |
| 1431 | ); |
| 1432 | |
| 1433 | // Fresh namespace on worker-a; give it a distinctly non-zero count. |
| 1434 | // (`/increment` is not idempotent and readiness retries can re-run |
| 1435 | // it, so assertions use the idempotent `/get` instead of increment |
| 1436 | // return values.) |
| 1437 | yield* fetchJsonReady<{ ok: boolean }>(`${v1.a.url}/reset`); |
| 1438 | yield* fetchJsonReady<{ value: number }>(`${v1.a.url}/increment`); |
| 1439 | yield* fetchJsonReady<{ value: number }>(`${v1.a.url}/increment`); |
| 1440 | const aBefore = (yield* fetchJsonReady<{ value: number }>( |
| 1441 | `${v1.a.url}/get`, |
| 1442 | )).value; |
| 1443 | expect(aBefore).toBeGreaterThanOrEqual(2); |
| 1444 | |
| 1445 | // worker-b now hosts its OWN same-name Counter — an isolated twin. |
| 1446 | const v2 = yield* scratch.deploy( |
| 1447 | Effect.gen(function* () { |
| 1448 | const a = yield* Cloudflare.Worker("worker-a", { |
| 1449 | script: hostWorkerScript, |
| 1450 | env: { |
| 1451 | Counter: Cloudflare.DurableObject("Counter", { |
| 1452 | transferredFrom: "worker-b", |
| 1453 | }), |
| 1454 | }, |
| 1455 | }); |
| 1456 | const b = yield* Cloudflare.Worker("worker-b", { |
| 1457 | script: hostWorkerScript, |
| 1458 | env: { |
| 1459 | Counter: Cloudflare.DurableObject("Counter"), |
| 1460 | }, |
| 1461 | }); |
| 1462 | return { a, b }; |
| 1463 | }), |
| 1464 | ); |
| 1465 | |
| 1466 | // The twin starts empty — its own namespace, not worker-a's. b's |
| 1467 | // edge may briefly serve the previous (cross-script) version, whose |
| 1468 | // /get reads worker-a's non-zero count, so poll until the fresh |
| 1469 | // version answers with the twin's empty count. |
| 1470 | yield* fetchJsonReady<{ value: number }>(`${v2.b.url}/get`).pipe( |
| 1471 | Effect.flatMap((r) => |
| 1472 | r.value === 0 |
| 1473 | ? Effect.void |
| 1474 | : Effect.fail(new Error(`stale: twin sees ${r.value}`)), |
| 1475 | ), |
| 1476 | Effect.retry({ |
| 1477 | schedule: readinessSchedule, |
| 1478 | times: readinessRetries, |
| 1479 | }), |
| 1480 | ); |
| 1481 | |
| 1482 | // Force a real reconcile of worker-a with the stale declaration while |
| 1483 | // the twin exists. The class is tag-anchored to worker-a, so no |
| 1484 | // migration may be emitted and neither namespace may change. |
| 1485 | const v3 = yield* scratch.deploy( |
| 1486 | Effect.gen(function* () { |
| 1487 | const a = yield* Cloudflare.Worker("worker-a", { |
| 1488 | script: hostWorkerScript, |
| 1489 | env: { |
| 1490 | FORCE_UPDATE: "1", |
| 1491 | Counter: Cloudflare.DurableObject("Counter", { |
| 1492 | transferredFrom: "worker-b", |
| 1493 | }), |
| 1494 | }, |
| 1495 | }); |
| 1496 | const b = yield* Cloudflare.Worker("worker-b", { |
| 1497 | script: hostWorkerScript, |
| 1498 | env: { |
| 1499 | Counter: Cloudflare.DurableObject("Counter"), |
| 1500 | }, |
| 1501 | }); |
| 1502 | return { a, b }; |
| 1503 | }), |
| 1504 | ); |
| 1505 | |
| 1506 | const aAfter = yield* fetchJsonReady<{ value: number }>( |
| 1507 | `${v3.a.url}/get`, |
| 1508 | ); |
| 1509 | expect(aAfter.value).toBe(aBefore); |
| 1510 | // worker-b was a noop in v3, but its v2 upload was only moments ago — |
| 1511 | // an edge metal can still serve the v1 (cross-script) version, whose |
| 1512 | // /get reads worker-a's non-zero counter. Poll until the twin's own |
| 1513 | // empty namespace answers: a genuinely stolen/deleted namespace never |
| 1514 | // reads 0, so the bounded retry still fails in the regression case. |
| 1515 | const twinAfter = yield* fetchJsonReady<{ value: number }>( |
| 1516 | `${v3.b.url}/get`, |
| 1517 | ).pipe( |
| 1518 | Effect.flatMap((r) => |
| 1519 | r.value === 0 |
| 1520 | ? Effect.succeed(r) |
| 1521 | : Effect.fail(new Error(`stale: twin sees ${r.value}`)), |
| 1522 | ), |
| 1523 | Effect.retry({ |
| 1524 | schedule: readinessSchedule, |
| 1525 | times: readinessRetries, |
| 1526 | }), |
| 1527 | ); |
| 1528 | expect(twinAfter.value).toBe(0); |
| 1529 | |
| 1530 | yield* scratch.destroy(); |
| 1531 | }).pipe(logLevel), |
| 1532 | { timeout: 240_000 }, |
| 1533 | ); |
| 1534 | }, |
| 1535 | ); |