1import { adopt } from "@/AdoptPolicy";
2import * as Cloudflare from "@/Cloudflare";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import * as Output from "@/Output";
5import * as Test from "@/Test/Alchemy";
6import * as durableObjects from "@distilled.cloud/cloudflare/durable-objects";
7import * as workers from "@distilled.cloud/cloudflare/workers";
8import { describe, expect } from "alchemy-test";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import { MinimumLogLevel } from "effect/References";
12import * as Schedule from "effect/Schedule";
13import * as Stream from "effect/Stream";
14import * as HttpClient from "effect/http/HttpClient";
15import * as HttpClientRequest from "effect/http/HttpClientRequest";
16import { getWorkerTags } from "../Utils/Worker.ts";
17import Stack from "./fixtures/do-rpc/stack.ts";
18
19const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
20 providers: Cloudflare.providers(),
21});
22
23const logLevel = Effect.provideService(
24 MinimumLogLevel,
25 process.env.DEBUG ? "Debug" : "Info",
26);
27
28const stack = beforeAll(deploy(Stack));
29afterAll.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.
34const readinessSchedule = Schedule.min([
35 Schedule.exponential("500 millis"),
36 Schedule.spaced("3 seconds"),
37]);
38
39const 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.
50const freshConn = HttpClient.mapRequest(
51 HttpClientRequest.setHeader("connection", "close"),
52);
53
54test(
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
84class DurableObjectLocationNotReady extends Data.TaggedError(
85 "DurableObjectLocationNotReady",
86)<{ readonly message: string }> {}
87
88const 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.
121test(
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.
151test(
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"].
181test(
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.
227const 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.
233const 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
252const 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
281const hostWorkerScript = `import { DurableObject } from "cloudflare:workers";
282export 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}
296export 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
315const 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.
340describe.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";
425export class DO_A extends DurableObject {}
426export 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";
442export class DO_A_v2 extends DurableObject {}
443export 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";
461export class DO_A_v2 extends DurableObject {}
462export class DO_B extends DurableObject {}
463export 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";
482export class DO_B extends DurableObject {}
483export 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")}
1185export 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";
1316export class CounterClass extends DurableObject {}
1317export class MeterClass extends DurableObject {}
1318export 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";
1357export class CounterClassV2 extends DurableObject {}
1358export class MeterClass extends DurableObject {}
1359export 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);