1import { adopt } from "@/AdoptPolicy";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Cloudflare from "@/Cloudflare/index.ts";
4import * as R2 from "@/Cloudflare/R2";
5import * as Command from "@/Command/index.ts";
6import * as Provider from "@/Provider";
7import * as Output from "@/Output";
8import { Stack } from "@/Stack";
9import { State } from "@/State";
10import * as Test from "@/Test/Alchemy";
11import { initialCwd } from "@/Util/Node.ts";
12import * as workers from "@distilled.cloud/cloudflare/workers";
13import { describe, expect } from "alchemy-test";
14import * as ConfigProvider from "effect/ConfigProvider";
15import * as Effect from "effect/Effect";
16import * as FileSystem from "effect/FileSystem";
17import * as Layer from "effect/Layer";
18import * as Path from "effect/Path";
19import * as Redacted from "effect/Redacted";
20import { MinimumLogLevel } from "effect/References";
21import * as pathe from "pathe";
22import { cloneFixture } from "../Utils/Fixture.ts";
23import { expectUrlContains } from "../Utils/Http.ts";
24import {
25 expectWorkerExists,
26 expectWorkersDevPreviews,
27 expectWorkersDevSubdomain,
28 findWorker,
29 getWorkerTags,
30 waitForWorkerToBeDeleted,
31} from "../Utils/Worker.ts";
32import type { Counter, Meter } from "./fixtures/do-counter-worker.ts";
33import InitConfigWorker from "./fixtures/init-config-worker.ts";
34import InternalWorker from "./fixtures/internal-worker.ts";
35
36const { test } = Test.make({ providers: Cloudflare.providers() });
37const { test: devTest } = Test.make({
38 providers: Cloudflare.providers(),
39 dev: true,
40});
41
42const logLevel = Effect.provideService(
43 MinimumLogLevel,
44 process.env.DEBUG ? "Debug" : "Info",
45);
46
47const main = pathe.resolve(import.meta.dirname, "fixtures/worker.ts");
48const doMain = pathe.resolve(
49 import.meta.dirname,
50 "fixtures/do-counter-worker.ts",
51);
52
53describe.concurrent(
54 "Cloudflare.Worker",
55 { tags: ["provider:cloudflare", "provider:cloudflare:worker"] },
56 () => {
57 test.provider(
58 "create, update, delete worker",
59 (stack) =>
60 Effect.gen(function* () {
61 const { accountId } = yield* yield* CloudflareEnvironment;
62 const s = yield* Stack;
63
64 yield* stack.destroy();
65
66 const worker = yield* stack.deploy(
67 Effect.gen(function* () {
68 yield* R2.Bucket("Bucket", {
69 forceDestroy: true,
70 storageClass: "Standard",
71 });
72
73 const worker = yield* Cloudflare.Worker("TestWorker", {
74 main,
75 workersDev: true,
76 compatibility: {
77 date: "2024-01-01",
78 },
79 });
80
81 return worker;
82 }),
83 );
84
85 const actualWorker = yield* findWorker(worker.workerName, accountId);
86 expect(actualWorker?.scriptName).toEqual(worker.workerName);
87 expect(yield* getWorkerTags(worker.workerName, accountId)).toContain(
88 `alchemy:stack:${s.name}`,
89 );
90 expect(yield* getWorkerTags(worker.workerName, accountId)).toContain(
91 `alchemy:stage:${s.stage}`,
92 );
93 expect(yield* getWorkerTags(worker.workerName, accountId)).toContain(
94 "alchemy:id:TestWorker",
95 );
96
97 // Verify the workers.dev subdomain is enabled on Cloudflare
98 // (rather than just trusting the resource's output attributes).
99 expect(worker.url).toBeDefined();
100 const initialSubdomain = yield* workers.getScriptSubdomain({
101 accountId,
102 scriptName: worker.workerName,
103 });
104 expect(initialSubdomain).toEqual({
105 enabled: true,
106 previewsEnabled: true,
107 });
108
109 // Update the worker
110 const updatedWorker = yield* stack.deploy(
111 Effect.gen(function* () {
112 return yield* Cloudflare.Worker("TestWorker", {
113 main,
114 workersDev: true,
115 compatibility: {
116 date: "2024-01-01",
117 },
118 });
119 }),
120 );
121
122 const actualUpdatedWorker = yield* findWorker(
123 updatedWorker.workerName,
124 accountId,
125 );
126 expect(actualUpdatedWorker?.scriptName).toEqual(
127 updatedWorker.workerName,
128 );
129 const actualUpdatedSubdomain = yield* workers.getScriptSubdomain({
130 accountId,
131 scriptName: updatedWorker.workerName,
132 });
133 expect(actualUpdatedSubdomain).toEqual({
134 enabled: true,
135 previewsEnabled: true,
136 });
137
138 yield* stack.destroy();
139
140 yield* waitForWorkerToBeDeleted(worker.workerName, accountId);
141 }).pipe(logLevel),
142 { tags: ["provider:cloudflare:r2", "live"] },
143 );
144
145 test.provider(
146 "create, update, delete worker with assets",
147 (stack) =>
148 Effect.gen(function* () {
149 const { accountId } = yield* yield* CloudflareEnvironment;
150 const s = yield* Stack;
151
152 yield* stack.destroy();
153
154 const worker = yield* stack.deploy(
155 Effect.gen(function* () {
156 return yield* Cloudflare.Worker("TestWorkerWithAssets", {
157 main,
158 assets: pathe.resolve(import.meta.dirname, "assets"),
159 workersDev: true,
160 compatibility: {
161 date: "2024-01-01",
162 },
163 });
164 }),
165 );
166
167 const actualWorker = yield* findWorker(worker.workerName, accountId);
168 expect(actualWorker?.scriptName).toEqual(worker.workerName);
169 expect(yield* getWorkerTags(worker.workerName, accountId)).toContain(
170 `alchemy:stack:${s.name}`,
171 );
172 expect(yield* getWorkerTags(worker.workerName, accountId)).toContain(
173 `alchemy:stage:${s.stage}`,
174 );
175 expect(yield* getWorkerTags(worker.workerName, accountId)).toContain(
176 "alchemy:id:TestWorkerWithAssets",
177 );
178
179 // Verify the worker has assets
180 expect(worker.hash?.assets).toBeDefined();
181
182 // Verify the workers.dev subdomain is enabled on Cloudflare
183 // (rather than just trusting the resource's output attributes).
184 expect(worker.url).toBeDefined();
185 const assetsWorkerSubdomain = yield* workers.getScriptSubdomain({
186 accountId,
187 scriptName: worker.workerName,
188 });
189 expect(assetsWorkerSubdomain).toEqual({
190 enabled: true,
191 previewsEnabled: true,
192 });
193
194 // Update the worker
195 const updatedWorker = yield* stack.deploy(
196 Effect.gen(function* () {
197 return yield* Cloudflare.Worker("TestWorkerWithAssets", {
198 main,
199 assets: pathe.resolve(import.meta.dirname, "assets"),
200 workersDev: true,
201 compatibility: {
202 date: "2024-01-01",
203 },
204 });
205 }),
206 );
207
208 const actualUpdatedWorker = yield* findWorker(
209 updatedWorker.workerName,
210 accountId,
211 );
212 expect(actualUpdatedWorker?.scriptName).toEqual(
213 updatedWorker.workerName,
214 );
215 expect(updatedWorker.hash?.assets).toBeDefined();
216
217 // Final update
218 const finalWorker = yield* stack.deploy(
219 Effect.gen(function* () {
220 return yield* Cloudflare.Worker("TestWorkerWithAssets", {
221 main,
222 workersDev: true,
223 assets: pathe.resolve(import.meta.dirname, "assets"),
224 compatibility: {
225 date: "2024-01-01",
226 },
227 });
228 }),
229 );
230
231 yield* stack.destroy();
232
233 yield* waitForWorkerToBeDeleted(finalWorker.workerName, accountId);
234 }).pipe(logLevel),
235 { tags: ["live"] },
236 );
237
238 // ─────────────────────────────────────────────────────────────────────
239 // Asset hashing & keepAssets behavior
240 //
241 // `hash.assets` is content-addressed: it must depend only on the bytes
242 // in the directory, not on where the directory lives. The provider
243 // uses that hash to decide whether to upload a fresh manifest or tell
244 // Cloudflare to keep the existing one (`keepAssets: true`). These
245 // tests pin down the user-visible contract:
246 //
247 // 1. Same bytes at a different path → same hash, no re-upload.
248 // 2. Different bytes (any change) → new hash, re-upload.
249 // 3. A worker-only change leaves the asset hash alone, so the
250 // script update goes out without re-walking the asset tree.
251 //
252 // The "moved path" cases also guard against the regression where state
253 // written by one machine (e.g. a CI runner) recorded an absolute path
254 // that the next machine couldn't open — the deploy used to crash with
255 // `NotFound: FileSystem.readDirectory`.
256 // ─────────────────────────────────────────────────────────────────────
257
258 const assetsFixtureDir = pathe.resolve(import.meta.dirname, "assets");
259
260 test.provider(
261 "Worker assets: relocating to a fresh path with identical bytes preserves hash and keeps assets",
262 (stack) =>
263 Effect.gen(function* () {
264 const { accountId } = yield* yield* CloudflareEnvironment;
265 const fs = yield* FileSystem.FileSystem;
266
267 yield* stack.destroy();
268
269 const dirA = yield* cloneFixture(assetsFixtureDir, {
270 prefix: "alchemy-worker-assets-a-",
271 });
272 const dirB = yield* cloneFixture(assetsFixtureDir, {
273 prefix: "alchemy-worker-assets-b-",
274 });
275
276 const deploy = (assetsDir: string) =>
277 stack.deploy(
278 Effect.gen(function* () {
279 return yield* Cloudflare.Worker("RelocatedAssets", {
280 main,
281 assets: assetsDir,
282 workersDev: true,
283 compatibility: { date: "2024-01-01" },
284 });
285 }),
286 );
287
288 const v1 = yield* deploy(dirA);
289 expect(v1.hash?.assets).toBeDefined();
290 yield* expectWorkerExists(v1.workerName, accountId);
291 yield* expectUrlContains(
292 `${v1.url!}/index.html`,
293 "Hello from Worker",
294 {
295 timeout: "120 seconds",
296 label: "v1 served",
297 },
298 );
299
300 // Wipe dirA before the second deploy. If anything in the apply
301 // path still tries to read the previously-recorded directory,
302 // this is where we'd fail with NotFound.
303 yield* fs.remove(dirA, { recursive: true });
304
305 const v2 = yield* deploy(dirB);
306
307 // Identical bytes ⇒ identical asset hash ⇒ keepAssets path.
308 expect(v2.hash?.assets).toEqual(v1.hash?.assets);
309 // The script binding stayed live; the URL keeps serving.
310 yield* expectUrlContains(
311 `${v2.url!}/index.html`,
312 "Hello from Worker",
313 {
314 timeout: "60 seconds",
315 label: "v2 served",
316 },
317 );
318
319 yield* stack.destroy();
320 yield* waitForWorkerToBeDeleted(v1.workerName, accountId);
321 }).pipe(logLevel),
322 { tags: ["live"], timeout: 360_000 },
323 );
324
325 test.provider(
326 "Worker assets: editing a file changes the hash and republishes the manifest",
327 (stack) =>
328 Effect.gen(function* () {
329 const { accountId } = yield* yield* CloudflareEnvironment;
330 const fs = yield* FileSystem.FileSystem;
331 const path = yield* Path.Path;
332
333 yield* stack.destroy();
334
335 const dir = yield* cloneFixture(assetsFixtureDir, {
336 prefix: "alchemy-worker-assets-edit-",
337 });
338 const indexPath = path.join(dir, "index.html");
339
340 const v1Marker = `worker-assets-v1-${Date.now()}`;
341 yield* fs.writeFileString(
342 indexPath,
343 `<!doctype html><title>${v1Marker}</title><body>${v1Marker}</body>`,
344 );
345
346 const deploy = () =>
347 stack.deploy(
348 Effect.gen(function* () {
349 return yield* Cloudflare.Worker("EditedAssets", {
350 main,
351 assets: dir,
352 workersDev: true,
353 compatibility: { date: "2024-01-01" },
354 });
355 }),
356 );
357
358 const v1 = yield* deploy();
359 expect(v1.hash?.assets).toBeDefined();
360 yield* expectUrlContains(`${v1.url!}/index.html`, v1Marker, {
361 timeout: "120 seconds",
362 label: "v1 marker",
363 });
364
365 const v2Marker = `worker-assets-v2-${Date.now()}`;
366 yield* fs.writeFileString(
367 indexPath,
368 `<!doctype html><title>${v2Marker}</title><body>${v2Marker}</body>`,
369 );
370
371 const v2 = yield* deploy();
372 expect(v2.hash?.assets).toBeDefined();
373 expect(v2.hash?.assets).not.toEqual(v1.hash?.assets);
374 yield* expectUrlContains(`${v2.url!}/index.html`, v2Marker, {
375 timeout: "60 seconds",
376 label: "v2 marker",
377 });
378
379 yield* stack.destroy();
380 yield* waitForWorkerToBeDeleted(v1.workerName, accountId);
381 }).pipe(logLevel),
382 { tags: ["live"], timeout: 360_000 },
383 );
384
385 test.provider(
386 "Worker assets: a bundle-only change keeps the asset manifest (hash.assets stable)",
387 (stack) =>
388 Effect.gen(function* () {
389 const { accountId } = yield* yield* CloudflareEnvironment;
390 const fs = yield* FileSystem.FileSystem;
391 const path = yield* Path.Path;
392
393 yield* stack.destroy();
394
395 const dir = yield* cloneFixture(assetsFixtureDir, {
396 prefix: "alchemy-worker-assets-bundle-only-",
397 });
398 // Write the worker entry into a fresh temp dir so we can edit
399 // it between deploys to force a bundle hash change without
400 // touching the assets directory.
401 const workerDir = yield* fs.makeTempDirectory({
402 prefix: "alchemy-worker-assets-bundle-only-entry-",
403 });
404 const workerPath = path.join(workerDir, "worker.ts");
405 const writeWorker = (marker: string) =>
406 fs.writeFileString(
407 workerPath,
408 `export default {
409 fetch: async () => new Response(${JSON.stringify(`Hello from BundleOnly: ${marker}`)}),
410 };
411 `,
412 );
413
414 const deploy = () =>
415 stack.deploy(
416 Effect.gen(function* () {
417 return yield* Cloudflare.Worker("BundleOnlyChange", {
418 main: workerPath,
419 assets: dir,
420 workersDev: true,
421 compatibility: { date: "2024-01-01" },
422 });
423 }),
424 );
425
426 yield* writeWorker("v1");
427 const v1 = yield* deploy();
428 expect(v1.hash?.assets).toBeDefined();
429 expect(v1.hash?.bundle).toBeDefined();
430
431 yield* writeWorker("v2");
432 const v2 = yield* deploy();
433 // Bundle changed (worker source edited) → hash.bundle moves.
434 // Assets are byte-identical → hash.assets must not move, and
435 // the keepAssets branch must keep the manifest live.
436 expect(v2.hash?.bundle).not.toEqual(v1.hash?.bundle);
437 expect(v2.hash?.assets).toEqual(v1.hash?.assets);
438 yield* expectUrlContains(
439 `${v2.url!}/index.html`,
440 "Hello from Worker",
441 {
442 timeout: "60 seconds",
443 label: "assets still served after bundle-only change",
444 },
445 );
446
447 yield* stack.destroy();
448 yield* waitForWorkerToBeDeleted(v1.workerName, accountId);
449 }).pipe(logLevel),
450 { tags: ["live"], timeout: 360_000 },
451 );
452
453 test.provider(
454 "create, update, delete internal worker",
455 (stack) =>
456 Effect.gen(function* () {
457 const { accountId } = yield* yield* CloudflareEnvironment;
458 const s = yield* Stack;
459
460 yield* stack.destroy();
461
462 const worker = yield* stack.deploy(
463 Effect.gen(function* () {
464 return yield* InternalWorker;
465 }),
466 );
467
468 const actualWorker = yield* findWorker(worker.workerName, accountId);
469 expect(actualWorker?.scriptName).toEqual(worker.workerName);
470 expect(yield* getWorkerTags(worker.workerName, accountId)).toContain(
471 `alchemy:stack:${s.name}`,
472 );
473 expect(yield* getWorkerTags(worker.workerName, accountId)).toContain(
474 `alchemy:stage:${s.stage}`,
475 );
476 expect(yield* getWorkerTags(worker.workerName, accountId)).toContain(
477 "alchemy:id:InternalWorker",
478 );
479
480 const updatedWorker = yield* stack.deploy(
481 Effect.gen(function* () {
482 return yield* InternalWorker;
483 }),
484 );
485
486 expect(updatedWorker.workerName).toEqual(worker.workerName);
487
488 yield* stack.destroy();
489
490 yield* waitForWorkerToBeDeleted(worker.workerName, accountId);
491 }).pipe(logLevel),
492 { tags: ["live"] },
493 );
494
495 // ── Engine-level adoption ─────────────────────────────────────────────────
496 //
497 // The engine always calls `provider.read` when there is no prior state, and
498 // routes on the returned shape:
499 //
500 // - undefined → resource doesn't exist, drive a normal create
501 // - plain attrs → resource exists and is owned by us (Worker
502 // determines this from `alchemy:*` tags); silent
503 // adoption regardless of `--adopt`
504 // - `Unowned(attrs)` → resource exists but the tags don't identify us;
505 // the engine fails with `OwnedBySomeoneElse` unless
506 // the user opted in via `adopt(true)` / `--adopt`,
507 // in which case it's a silent takeover.
508 //
509 // The tests below use `test.provider`'s scratch state so we can wipe state
510 // mid-test while leaving the actual Cloudflare Worker in place — simulating
511 // "the user created/deployed this worker before, but this state store has
512 // never seen it" (e.g. CLI-driven first deploy on a fresh machine, or a
513 // state-store reset).
514
515 test.provider(
516 "owned worker (matching alchemy tags) is silently adopted without --adopt",
517 (stack) =>
518 Effect.gen(function* () {
519 const { accountId } = yield* yield* CloudflareEnvironment;
520
521 yield* stack.destroy();
522
523 // Phase 1: deploy normally so a real Worker exists on Cloudflare,
524 // tagged with this stack/stage/id. No explicit `name` — the engine
525 // generates a random-suffixed physical name (collision-free across
526 // concurrent runs, and alchemy-tagged so a crashed run's leftover is
527 // sweepable). The deploy output hands back the real name, which
528 // pins the worker's identity for the adoption phase below.
529 const initial = yield* stack.deploy(
530 Effect.gen(function* () {
531 return yield* Cloudflare.Worker("AdoptableWorker", {
532 main,
533 workersDev: true,
534 compatibility: { date: "2024-01-01" },
535 });
536 }),
537 );
538 const physicalName = initial.workerName;
539 expect(yield* findWorker(physicalName, accountId)).toBeDefined();
540
541 // Phase 2: wipe local state for this resource — the worker stays on
542 // Cloudflare. From the next deploy's perspective this looks like a
543 // fresh state store that has never seen this resource.
544 yield* Effect.gen(function* () {
545 const state = yield* yield* State;
546 yield* state.delete({
547 stack: stack.name,
548 stage: stack.stage,
549 fqn: "AdoptableWorker",
550 });
551 }).pipe(Effect.provide(stack.state));
552
553 // Phase 3: redeploy *without* `adopt(true)`. The engine calls
554 // `provider.read`, the Worker's read sees its own alchemy tags and
555 // returns plain (owned) attrs, and the engine silently adopts.
556 // No `--adopt` flag is required.
557 const adopted = yield* stack.deploy(
558 Effect.gen(function* () {
559 return yield* Cloudflare.Worker("AdoptableWorker", {
560 main,
561 name: physicalName,
562 workersDev: true,
563 compatibility: { date: "2024-01-01" },
564 });
565 }),
566 );
567
568 expect(adopted.workerName).toEqual(physicalName);
569
570 const persisted = yield* Effect.gen(function* () {
571 const state = yield* yield* State;
572 return yield* state.get({
573 stack: stack.name,
574 stage: stack.stage,
575 fqn: "AdoptableWorker",
576 });
577 }).pipe(Effect.provide(stack.state));
578
579 expect(persisted?.status).toBeDefined();
580 expect((persisted as any)?.attr).toMatchObject({
581 workerName: physicalName,
582 });
583
584 yield* stack.destroy();
585 yield* waitForWorkerToBeDeleted(physicalName, accountId);
586 }).pipe(logLevel),
587 { tags: ["live"] },
588 );
589
590 test.provider(
591 "adopt(true) takes over a foreign-tagged worker",
592 (stack) =>
593 Effect.gen(function* () {
594 const { accountId } = yield* yield* CloudflareEnvironment;
595
596 yield* stack.destroy();
597
598 // Phase 1: deploy under logical id "Original". The Cloudflare Worker
599 // is now tagged `alchemy:id:Original` — i.e. owned by *that* logical
600 // resource. No explicit `name` — the engine generates a random-suffixed
601 // physical name (collision-free across runs); the deploy output hands
602 // back the real name, which phase 2 reuses to target the same worker.
603 const original = yield* stack.deploy(
604 Effect.gen(function* () {
605 return yield* Cloudflare.Worker("Original", {
606 main,
607 workersDev: true,
608 compatibility: { date: "2024-01-01" },
609 });
610 }),
611 );
612 const physicalName = original.workerName;
613 expect(
614 yield* findWorker(original.workerName, accountId),
615 ).toBeDefined();
616 expect(yield* getWorkerTags(physicalName, accountId)).toContain(
617 "alchemy:id:Original",
618 );
619
620 // Wipe state for the "Original" entry; the worker stays on Cloudflare.
621 yield* Effect.gen(function* () {
622 const state = yield* yield* State;
623 yield* state.delete({
624 stack: stack.name,
625 stage: stack.stage,
626 fqn: "Original",
627 });
628 }).pipe(Effect.provide(stack.state));
629
630 // Phase 2: redeploy under a *different* logical id with the same
631 // physical name and `adopt(true)`. `Worker.read` returns
632 // `Unowned(attrs)` because the existing tags identify a different
633 // logical id; with adopt enabled the engine takes over and the
634 // follow-up create/update rewrites the tags. (The rejection path
635 // — same scenario without `adopt(true)` — is covered by the unit
636 // tests in `plan.test.ts`.)
637 const takenOver = yield* stack
638 .deploy(
639 Effect.gen(function* () {
640 return yield* Cloudflare.Worker("Different", {
641 main,
642 name: physicalName,
643 workersDev: true,
644 compatibility: { date: "2024-01-01" },
645 });
646 }),
647 )
648 .pipe(adopt(true));
649
650 expect(takenOver.workerName).toEqual(physicalName);
651
652 const newTags = yield* getWorkerTags(physicalName, accountId);
653 expect(newTags).toContain("alchemy:id:Different");
654 expect(newTags).not.toContain("alchemy:id:Original");
655
656 yield* stack.destroy();
657 yield* waitForWorkerToBeDeleted(physicalName, accountId);
658 }).pipe(logLevel),
659 { tags: ["live"] },
660 );
661
662 // First-deploy behaviour: the default (omitting `workersDev`) must enable
663 // the workers.dev subdomain, and `workersDev: false` must disable it. Both
664 // are asserted against live Cloudflare state via `getScriptSubdomain`,
665 // not just the resource's output attributes.
666 test.provider(
667 "workersDev defaults to enabling the workers.dev subdomain on first deploy",
668 (stack) =>
669 Effect.gen(function* () {
670 const { accountId } = yield* yield* CloudflareEnvironment;
671
672 yield* stack.destroy();
673
674 const worker = yield* stack.deploy(
675 Effect.gen(function* () {
676 return yield* Cloudflare.Worker("SubdomainDefaultWorker", {
677 main,
678 compatibility: { date: "2024-01-01" },
679 });
680 }),
681 );
682
683 // Default: url = the stable workers.dev URL = allUrls[0], and
684 // domains mirrors allUrls as bare hostnames.
685 expect(worker.url).toMatch(
686 new RegExp(`^https://${worker.workerName}\\..*\\.workers\\.dev$`),
687 );
688 expect(worker.urls).toEqual([worker.url]);
689 expect(worker.domain).toBeUndefined();
690 yield* expectWorkersDevSubdomain(worker.workerName, accountId, true);
691
692 yield* stack.destroy();
693 yield* waitForWorkerToBeDeleted(worker.workerName, accountId);
694 }).pipe(logLevel),
695 { tags: ["live"] },
696 );
697
698 test.provider(
699 "workersDev: false disables the workers.dev subdomain on first deploy",
700 (stack) =>
701 Effect.gen(function* () {
702 const { accountId } = yield* yield* CloudflareEnvironment;
703
704 yield* stack.destroy();
705
706 const worker = yield* stack.deploy(
707 Effect.gen(function* () {
708 return yield* Cloudflare.Worker("SubdomainDisabledWorker", {
709 main,
710 workersDev: false,
711 compatibility: { date: "2024-01-01" },
712 });
713 }),
714 );
715
716 expect(worker.url).toBeUndefined();
717 expect(worker.urls).toEqual([]);
718 expect(worker.domain).toBeUndefined();
719 yield* expectWorkersDevSubdomain(worker.workerName, accountId, false);
720
721 yield* stack.destroy();
722 yield* waitForWorkerToBeDeleted(worker.workerName, accountId);
723 }).pipe(logLevel),
724 { tags: ["live"] },
725 );
726
727 // Update regression: toggling `workersDev` between deploys must propagate
728 // to the live Cloudflare subdomain state. Before this regression
729 // was fixed, the reconciler diffed the props symmetrically — but the
730 // observed-vs-desired check inside reconcile must still flip the toggle
731 // when props really do change.
732 test.provider(
733 "toggling workersDev between deploys flips the workers.dev subdomain",
734 (stack) =>
735 Effect.gen(function* () {
736 const { accountId } = yield* yield* CloudflareEnvironment;
737
738 yield* stack.destroy();
739
740 const deploy = (workersDev: boolean) =>
741 stack.deploy(
742 Effect.gen(function* () {
743 return yield* Cloudflare.Worker("SubdomainToggleWorker", {
744 main,
745 workersDev,
746 compatibility: { date: "2024-01-01" },
747 });
748 }),
749 );
750
751 const v1 = yield* deploy(true);
752 expect(v1.url).toBeDefined();
753 yield* expectWorkersDevSubdomain(v1.workerName, accountId, true);
754
755 const v2 = yield* deploy(false);
756 expect(v2.workerName).toEqual(v1.workerName);
757 expect(v2.url).toBeUndefined();
758 yield* expectWorkersDevSubdomain(v2.workerName, accountId, false);
759
760 const v3 = yield* deploy(true);
761 expect(v3.workerName).toEqual(v1.workerName);
762 expect(v3.url).toBeDefined();
763 yield* expectWorkersDevSubdomain(v3.workerName, accountId, true);
764
765 yield* stack.destroy();
766 yield* waitForWorkerToBeDeleted(v1.workerName, accountId);
767 }).pipe(logLevel),
768 { tags: ["live"] },
769 );
770
771 // `workersDev: { enabled: false, previewsEnabled: true }` — a "preview-only" worker.
772 // The stable workers.dev URL is off, so the current version's preview URL
773 // (`https://<version-prefix>-<name>.<account>.workers.dev`) becomes the
774 // primary `url` output.
775 test.provider(
776 "workersDev previews-only surfaces the version preview URL as url",
777 (stack) =>
778 Effect.gen(function* () {
779 const { accountId } = yield* yield* CloudflareEnvironment;
780
781 yield* stack.destroy();
782
783 const worker = yield* stack.deploy(
784 Effect.gen(function* () {
785 return yield* Cloudflare.Worker("PreviewOnlyWorker", {
786 main,
787 workersDev: { enabled: false, previewsEnabled: true },
788 compatibility: { date: "2024-01-01" },
789 });
790 }),
791 );
792
793 yield* expectWorkersDevPreviews(worker.workerName, accountId, {
794 enabled: false,
795 previewsEnabled: true,
796 });
797 // The preview URL is version-scoped: an 8-char version prefix in
798 // front of the worker name.
799 expect(worker.url).toMatch(
800 new RegExp(
801 `^https://[0-9a-f]{8}-${worker.workerName}\\..*\\.workers\\.dev$`,
802 ),
803 );
804 expect(worker.urls).toEqual([worker.url]);
805 expect(worker.domain).toBeUndefined();
806
807 yield* stack.destroy();
808 yield* waitForWorkerToBeDeleted(worker.workerName, accountId);
809 }).pipe(logLevel),
810 { tags: ["live"] },
811 );
812
813 // Dev mode: no workers.dev or custom-domain URLs exist, so the local dev
814 // server's localhost URL leads `allUrls` and is the `url`.
815 devTest.provider(
816 "dev mode: url is the local dev server URL",
817 (stack) =>
818 Effect.gen(function* () {
819 yield* stack.destroy();
820
821 const worker = yield* stack.deploy(
822 Effect.gen(function* () {
823 return yield* Cloudflare.Worker("LocalUrlWorker", {
824 main,
825 dev: { port: 0 },
826 compatibility: { date: "2024-01-01" },
827 });
828 }),
829 );
830
831 expect(worker.url).toMatch(/^http:\/\/localhost:\d+$/);
832 expect(worker.urls[0]).toEqual(worker.url);
833 expect(new URL(worker.urls[0]).hostname).toEqual("localhost");
834 expect(worker.domain).toBeUndefined();
835
836 // The URL actually serves the local worker.
837 yield* expectUrlContains(`${worker.url}/`, "Hello from TestWorker", {
838 timeout: "30 seconds",
839 label: "local dev worker",
840 });
841
842 yield* stack.destroy();
843 }).pipe(logLevel),
844 { tags: ["local"] },
845 );
846
847 // Drift regression: if something external (a previous failed deploy,
848 // a Cloudflare dashboard toggle, the bootstrap path in `loginWithCloudflare`)
849 // leaves the workers.dev subdomain in `enabled: true, previewsEnabled: false`,
850 // a redeploy must observe `previewsEnabled` and flip it back on. The
851 // pre-fix reconciler diffed only `enabled` against desired, so it
852 // skipped the API call and let the drift persist.
853 test.provider(
854 "redeploy re-enables previewsEnabled when externally disabled",
855 (stack) =>
856 Effect.gen(function* () {
857 const { accountId } = yield* yield* CloudflareEnvironment;
858
859 yield* stack.destroy();
860
861 // Deploy with different compatibility dates to force the update.
862 const deploy = (date: string) =>
863 stack.deploy(
864 Cloudflare.Worker("SubdomainPreviewsDriftWorker", {
865 main,
866 compatibility: { date },
867 }),
868 );
869
870 const v1 = yield* deploy("2026-01-01");
871 yield* expectWorkersDevPreviews(v1.workerName, accountId, {
872 enabled: true,
873 previewsEnabled: true,
874 });
875
876 // Simulate external drift: leave `enabled: true` but turn
877 // `previewsEnabled` off out-of-band.
878 yield* workers.createScriptSubdomain({
879 accountId,
880 scriptName: v1.workerName,
881 enabled: true,
882 previewsEnabled: false,
883 });
884 const drifted = yield* workers.getScriptSubdomain({
885 accountId,
886 scriptName: v1.workerName,
887 });
888 expect(drifted).toEqual({ enabled: true, previewsEnabled: false });
889
890 const v2 = yield* deploy("2026-01-02");
891 expect(v2.workerName).toEqual(v1.workerName);
892 yield* expectWorkersDevPreviews(v2.workerName, accountId, {
893 enabled: true,
894 previewsEnabled: true,
895 });
896
897 yield* stack.destroy();
898 yield* waitForWorkerToBeDeleted(v1.workerName, accountId);
899 }).pipe(logLevel),
900 { tags: ["live"] },
901 );
902
903 // #745 regression: metadata-only edits (compatibility flags, observability,
904 // placement, limits, logpush, env literals, ...) never touch the
905 // bundle/vite/asset-content hashes, so the update decision used to plan
906 // them as a noop and silently skip the deploy. `hash.metadata` makes them
907 // visible to the diff. Deploy a worker, re-deploy with a
908 // compatibility-flag-only and then an observability-only change, and
909 // assert each change actually lands in the live script settings. Identical
910 // props must keep planning as a noop — that guards the hash's stability
911 // across runs (Redacted env values hash by value, not by reference, so the
912 // freshly-constructed secret in each plan must not force a phantom update).
913 test.provider(
914 "metadata-only changes (compatibility flags, observability) deploy",
915 (stack) =>
916 Effect.gen(function* () {
917 const { accountId } = yield* yield* CloudflareEnvironment;
918
919 yield* stack.destroy();
920
921 const program = (opts: { flags: string[]; observability: boolean }) =>
922 Effect.gen(function* () {
923 return yield* Cloudflare.Worker("MetadataOnlyWorker", {
924 main,
925 compatibility: { date: "2024-01-01", flags: opts.flags },
926 observability: { enabled: opts.observability },
927 env: {
928 WORKER_SECRET: Redacted.make("metadata-hash-stability"),
929 },
930 });
931 });
932
933 const actionOf = (plan: any, logicalId: string) =>
934 (Object.values(plan.resources) as any[]).find(
935 (node: any) => node.resource.LogicalId === logicalId,
936 )?.action;
937
938 const v1 = yield* stack.deploy(
939 program({ flags: [], observability: false }),
940 );
941
942 // Identical props → noop.
943 const stablePlan = yield* stack.plan(
944 program({ flags: [], observability: false }),
945 );
946 expect(actionOf(stablePlan, "MetadataOnlyWorker")).toBe("noop");
947
948 // A compatibility-flag-only change must plan as an update ...
949 const flagPlan = yield* stack.plan(
950 program({ flags: ["nodejs_als"], observability: false }),
951 );
952 expect(actionOf(flagPlan, "MetadataOnlyWorker")).toBe("update");
953
954 // ... and the deploy must apply it to the live script settings.
955 const v2 = yield* stack.deploy(
956 program({ flags: ["nodejs_als"], observability: false }),
957 );
958 expect(v2.workerName).toEqual(v1.workerName);
959 const flagSettings = yield* workers.getScriptScriptAndVersionSetting({
960 accountId,
961 scriptName: v2.workerName,
962 });
963 expect(flagSettings.compatibilityFlags).toContain("nodejs_als");
964
965 // Same for an observability-only change. The bundle hash must not
966 // move — proof the update decision came from the metadata hash alone,
967 // not from an incidental rebuild.
968 const v3 = yield* stack.deploy(
969 program({ flags: ["nodejs_als"], observability: true }),
970 );
971 expect(v3.hash?.bundle).toEqual(v2.hash?.bundle);
972 const observabilitySettings =
973 yield* workers.getScriptScriptAndVersionSetting({
974 accountId,
975 scriptName: v3.workerName,
976 });
977 expect(observabilitySettings.observability?.enabled).toBe(true);
978
979 // The applied props are now the stored state → back to noop.
980 const settledPlan = yield* stack.plan(
981 program({ flags: ["nodejs_als"], observability: true }),
982 );
983 expect(actionOf(settledPlan, "MetadataOnlyWorker")).toBe("noop");
984
985 yield* stack.destroy();
986 yield* waitForWorkerToBeDeleted(v1.workerName, accountId);
987 }).pipe(logLevel),
988 { tags: ["live"], timeout: 360_000 },
989 );
990
991 // #874 regression: binding a tagged Worker identity (an Effect class) in
992 // another Worker's `env` — the circular-bindings pattern — must converge.
993 // The tag stays in the desired props (`news.env.TARGET` is an Effect) while
994 // `stripUnresolved` removes it from the stored props at commit, so a diff
995 // that compares the two raw shapes plans an update on every deploy, forever.
996 // After a successful deploy, an identical program must plan as a noop —
997 // and a code-only change must STILL plan as an update (the tag must not
998 // knock the diff off its bundle-hash path onto the raw-props fallback,
999 // which can't see file contents).
1000 test.provider(
1001 "Effect-valued worker tag in env converges to a noop plan",
1002 (stack) =>
1003 Effect.gen(function* () {
1004 const { accountId } = yield* yield* CloudflareEnvironment;
1005 const fs = yield* FileSystem.FileSystem;
1006 const path = yield* Path.Path;
1007
1008 yield* stack.destroy();
1009
1010 class EnvTagTarget extends Cloudflare.Worker<EnvTagTarget, {}>()(
1011 "EnvTagTargetWorker",
1012 ) {}
1013 class EnvTagCaller extends Cloudflare.Worker<EnvTagCaller, {}>()(
1014 "EnvTagCallerWorker",
1015 ) {}
1016
1017 // The caller's entry lives in a temp dir so the test can edit its
1018 // contents mid-flight without touching the shared checked-in fixture.
1019 const callerScript = (marker: string) =>
1020 `export default { fetch: async () => new Response(${JSON.stringify(marker)}) };\n`;
1021 const tempDir = yield* fs.makeTempDirectory({
1022 prefix: "alchemy-env-tag-worker",
1023 });
1024 const callerMain = path.join(tempDir, "worker.ts");
1025 yield* fs.writeFileString(callerMain, callerScript("v1"));
1026
1027 const layers = Layer.mergeAll(
1028 EnvTagTarget.make({ main, isExternal: true }, Effect.succeed({})),
1029 EnvTagCaller.make(
1030 {
1031 main: callerMain,
1032 isExternal: true,
1033 env: { TARGET: EnvTagTarget },
1034 },
1035 Effect.succeed({}),
1036 ),
1037 );
1038
1039 const program = () =>
1040 Effect.gen(function* () {
1041 const target = yield* EnvTagTarget;
1042 const caller = yield* EnvTagCaller;
1043 return { target, caller };
1044 }).pipe(Effect.provide(layers));
1045
1046 const actionOf = (plan: any, logicalId: string) =>
1047 (Object.values(plan.resources) as any[]).find(
1048 (node: any) => node.resource.LogicalId === logicalId,
1049 )?.action;
1050
1051 const deployed = yield* stack.deploy(program());
1052
1053 // The env tag must have landed as a live service binding.
1054 const settings = yield* workers.getScriptScriptAndVersionSetting({
1055 accountId,
1056 scriptName: deployed.caller.workerName,
1057 });
1058 expect(settings.bindings).toContainEqual(
1059 expect.objectContaining({
1060 type: "service",
1061 name: "TARGET",
1062 service: deployed.target.workerName,
1063 }),
1064 );
1065
1066 // Identical program → both workers plan as noop.
1067 const settledPlan = yield* stack.plan(program());
1068 expect(actionOf(settledPlan, "EnvTagTargetWorker")).toBe("noop");
1069 expect(actionOf(settledPlan, "EnvTagCallerWorker")).toBe("noop");
1070
1071 // A code-only change to the caller's entry (identical props — the
1072 // bundle hash is the only difference) must still plan as an update.
1073 yield* fs.writeFileString(callerMain, callerScript("v2"));
1074 const codeChangePlan = yield* stack.plan(program());
1075 expect(actionOf(codeChangePlan, "EnvTagTargetWorker")).toBe("noop");
1076 expect(actionOf(codeChangePlan, "EnvTagCallerWorker")).toBe("update");
1077
1078 yield* stack.destroy();
1079 yield* waitForWorkerToBeDeleted(
1080 deployed.caller.workerName,
1081 accountId,
1082 );
1083 yield* waitForWorkerToBeDeleted(
1084 deployed.target.workerName,
1085 accountId,
1086 );
1087 }).pipe(logLevel),
1088 { tags: ["live"], timeout: 360_000 },
1089 );
1090
1091 // #1272 regression, remaining #874 case: an already-yielded Worker is
1092 // plain resource attributes rather than an Effect-valued tag. In a binding
1093 // chain, plan and apply may materialize different stable/full projections
1094 // of those attributes, but both describe the same service binding and must
1095 // converge.
1096 // Switching the actual target must still update the caller and land in
1097 // Cloudflare.
1098 test.provider(
1099 "nested yielded worker resources in env converge to noop plans",
1100 (stack) =>
1101 Effect.gen(function* () {
1102 const { accountId } = yield* yield* CloudflareEnvironment;
1103
1104 yield* stack.destroy();
1105
1106 const program = (selected: "A" | "B") =>
1107 Effect.gen(function* () {
1108 const targetB = yield* Cloudflare.Worker("YieldedEnvTargetB", {
1109 main,
1110 });
1111 const targetA = yield* Cloudflare.Worker("YieldedEnvTargetA", {
1112 main,
1113 env: { UPSTREAM: targetB },
1114 });
1115 const caller = yield* Cloudflare.Worker("YieldedEnvCaller", {
1116 main,
1117 env: { TARGET: selected === "A" ? targetA : targetB },
1118 });
1119 return { targetA, targetB, caller };
1120 });
1121
1122 const actionOf = (plan: any, logicalId: string) =>
1123 (Object.values(plan.resources) as any[]).find(
1124 (node: any) => node.resource.LogicalId === logicalId,
1125 )?.action;
1126
1127 const deployedA = yield* stack.deploy(program("A"));
1128 const bindingTarget = Effect.fn(function* (callerName: string) {
1129 const settings = yield* workers.getScriptScriptAndVersionSetting({
1130 accountId,
1131 scriptName: callerName,
1132 });
1133 return settings.bindings?.find(
1134 (binding) =>
1135 binding.type === "service" && binding.name === "TARGET",
1136 );
1137 });
1138 expect(yield* bindingTarget(deployedA.caller.workerName)).toEqual(
1139 expect.objectContaining({
1140 type: "service",
1141 name: "TARGET",
1142 service: deployedA.targetA.workerName,
1143 }),
1144 );
1145
1146 const stableA = yield* stack.plan(program("A"));
1147 expect(actionOf(stableA, "YieldedEnvTargetA")).toBe("noop");
1148 expect(actionOf(stableA, "YieldedEnvTargetB")).toBe("noop");
1149 expect(actionOf(stableA, "YieldedEnvCaller")).toBe("noop");
1150
1151 const switchPlan = yield* stack.plan(program("B"));
1152 expect(actionOf(switchPlan, "YieldedEnvTargetA")).toBe("noop");
1153 expect(actionOf(switchPlan, "YieldedEnvTargetB")).toBe("noop");
1154 expect(actionOf(switchPlan, "YieldedEnvCaller")).toBe("update");
1155
1156 const deployedB = yield* stack.deploy(program("B"));
1157 expect(yield* bindingTarget(deployedB.caller.workerName)).toEqual(
1158 expect.objectContaining({
1159 type: "service",
1160 name: "TARGET",
1161 service: deployedB.targetB.workerName,
1162 }),
1163 );
1164
1165 const stableB = yield* stack.plan(program("B"));
1166 expect(actionOf(stableB, "YieldedEnvTargetA")).toBe("noop");
1167 expect(actionOf(stableB, "YieldedEnvTargetB")).toBe("noop");
1168 expect(actionOf(stableB, "YieldedEnvCaller")).toBe("noop");
1169
1170 yield* stack.destroy();
1171 yield* Effect.all(
1172 [
1173 deployedB.caller.workerName,
1174 deployedB.targetA.workerName,
1175 deployedB.targetB.workerName,
1176 ].map((name) => waitForWorkerToBeDeleted(name, accountId)),
1177 { concurrency: "unbounded" },
1178 );
1179 }).pipe(logLevel),
1180 { tags: ["live"], timeout: 360_000 },
1181 );
1182
1183 // The full circular case from the #874 report: A and B each bind the
1184 // OTHER's tag in env. The tags keep the dependency on the binding channel
1185 // (precreate stubs + converge pass) — a cycle the props channel cannot
1186 // express — and both workers must still converge to noop plans.
1187 test.provider(
1188 "circular worker tags in env converge to noop plans",
1189 (stack) =>
1190 Effect.gen(function* () {
1191 const { accountId } = yield* yield* CloudflareEnvironment;
1192
1193 yield* stack.destroy();
1194
1195 class CircTagA extends Cloudflare.Worker<CircTagA, {}>()(
1196 "CircTagAWorker",
1197 ) {}
1198 class CircTagB extends Cloudflare.Worker<CircTagB, {}>()(
1199 "CircTagBWorker",
1200 ) {}
1201
1202 const layers = Layer.mergeAll(
1203 CircTagA.make(
1204 { main, isExternal: true, env: { PEER: CircTagB } },
1205 Effect.succeed({}),
1206 ),
1207 CircTagB.make(
1208 { main, isExternal: true, env: { PEER: CircTagA } },
1209 Effect.succeed({}),
1210 ),
1211 );
1212
1213 const program = () =>
1214 Effect.gen(function* () {
1215 const a = yield* CircTagA;
1216 const b = yield* CircTagB;
1217 return { a, b };
1218 }).pipe(Effect.provide(layers));
1219
1220 const actionOf = (plan: any, logicalId: string) =>
1221 (Object.values(plan.resources) as any[]).find(
1222 (node: any) => node.resource.LogicalId === logicalId,
1223 )?.action;
1224
1225 const deployed = yield* stack.deploy(program());
1226
1227 // Each side must carry a live service binding to the other.
1228 for (const [self, peer] of [
1229 [deployed.a, deployed.b],
1230 [deployed.b, deployed.a],
1231 ] as const) {
1232 const settings = yield* workers.getScriptScriptAndVersionSetting({
1233 accountId,
1234 scriptName: self.workerName,
1235 });
1236 expect(settings.bindings).toContainEqual(
1237 expect.objectContaining({
1238 type: "service",
1239 name: "PEER",
1240 service: peer.workerName,
1241 }),
1242 );
1243 }
1244
1245 const settled = yield* stack.plan(program());
1246 expect(actionOf(settled, "CircTagAWorker")).toBe("noop");
1247 expect(actionOf(settled, "CircTagBWorker")).toBe("noop");
1248
1249 yield* stack.destroy();
1250 yield* waitForWorkerToBeDeleted(deployed.a.workerName, accountId);
1251 yield* waitForWorkerToBeDeleted(deployed.b.workerName, accountId);
1252 }).pipe(logLevel),
1253 { tags: ["live"], timeout: 360_000 },
1254 );
1255
1256 // #874 (comments): an Output-valued `worker.bind` binding must also
1257 // converge. Terminal apply commits must persist the RESOLVED binding
1258 // payload — raw `node.bindings` hold the Output expression, which JSON
1259 // state stores silently drop, so every later plan's `diffBindings` would
1260 // compare a lossy stored shape against resolved data and re-update
1261 // forever (the PR #266 path).
1262 test.provider(
1263 "Output-valued worker.bind binding converges to a noop plan",
1264 (stack) =>
1265 Effect.gen(function* () {
1266 const { accountId } = yield* yield* CloudflareEnvironment;
1267
1268 yield* stack.destroy();
1269
1270 const program = () =>
1271 Effect.gen(function* () {
1272 const source = yield* Cloudflare.Worker("BindTextSource", {
1273 main,
1274 });
1275 const host = yield* Cloudflare.Worker("BindTextHost", {
1276 main,
1277 });
1278 // `source.workerName` is an Output<string> at plan time — the
1279 // same shape as an Action-produced value bound as plain_text.
1280 yield* host.bind`REV`({
1281 bindings: [
1282 { type: "plain_text", name: "REV", text: source.workerName },
1283 ],
1284 });
1285 return { source, host };
1286 });
1287
1288 const actionOf = (plan: any, logicalId: string) =>
1289 (Object.values(plan.resources) as any[]).find(
1290 (node: any) => node.resource.LogicalId === logicalId,
1291 )?.action;
1292
1293 const deployed = yield* stack.deploy(program());
1294
1295 // The resolved text must have landed in the live binding.
1296 const settings = yield* workers.getScriptScriptAndVersionSetting({
1297 accountId,
1298 scriptName: deployed.host.workerName,
1299 });
1300 expect(settings.bindings).toContainEqual(
1301 expect.objectContaining({
1302 type: "plain_text",
1303 name: "REV",
1304 text: deployed.source.workerName,
1305 }),
1306 );
1307
1308 const settled = yield* stack.plan(program());
1309 expect(actionOf(settled, "BindTextSource")).toBe("noop");
1310 expect(actionOf(settled, "BindTextHost")).toBe("noop");
1311
1312 yield* stack.destroy();
1313 yield* waitForWorkerToBeDeleted(deployed.host.workerName, accountId);
1314 yield* waitForWorkerToBeDeleted(
1315 deployed.source.workerName,
1316 accountId,
1317 );
1318 }).pipe(logLevel),
1319 { tags: ["live"], timeout: 360_000 },
1320 );
1321
1322 // Effect-valued env entries are stripped from the props comparison (#874),
1323 // so change detection for them rides entirely on the evaluated binding
1324 // data. This test guards that channel: when only the VALUE an env Effect
1325 // resolves to changes (a `gen` Effect — serializes value-blind, so the
1326 // props comparison could never see it), the plan must still flip from
1327 // noop to update.
1328 test.provider(
1329 "changing the value of an Effect-valued env entry plans an update",
1330 (stack) =>
1331 Effect.gen(function* () {
1332 const { accountId } = yield* yield* CloudflareEnvironment;
1333
1334 yield* stack.destroy();
1335
1336 const program = (value: string) =>
1337 Effect.gen(function* () {
1338 return yield* Cloudflare.Worker("EffectEnvValueWorker", {
1339 main,
1340 env: {
1341 VALUE: Effect.gen(function* () {
1342 return value;
1343 }),
1344 },
1345 });
1346 });
1347
1348 const actionOf = (plan: any, logicalId: string) =>
1349 (Object.values(plan.resources) as any[]).find(
1350 (node: any) => node.resource.LogicalId === logicalId,
1351 )?.action;
1352
1353 const deployed = yield* stack.deploy(program("v1"));
1354
1355 // The evaluated value lands as a plain_text binding.
1356 const settings = yield* workers.getScriptScriptAndVersionSetting({
1357 accountId,
1358 scriptName: deployed.workerName,
1359 });
1360 expect(settings.bindings).toContainEqual(
1361 expect.objectContaining({
1362 type: "plain_text",
1363 name: "VALUE",
1364 text: "v1",
1365 }),
1366 );
1367
1368 // Same value → noop; changed value → update.
1369 const samePlan = yield* stack.plan(program("v1"));
1370 expect(actionOf(samePlan, "EffectEnvValueWorker")).toBe("noop");
1371 const changedPlan = yield* stack.plan(program("v2"));
1372 expect(actionOf(changedPlan, "EffectEnvValueWorker")).toBe("update");
1373
1374 // The changed value must actually deploy — and then converge.
1375 const redeployed = yield* stack.deploy(program("v2"));
1376 const updatedSettings =
1377 yield* workers.getScriptScriptAndVersionSetting({
1378 accountId,
1379 scriptName: redeployed.workerName,
1380 });
1381 expect(updatedSettings.bindings).toContainEqual(
1382 expect.objectContaining({
1383 type: "plain_text",
1384 name: "VALUE",
1385 text: "v2",
1386 }),
1387 );
1388 const resettled = yield* stack.plan(program("v2"));
1389 expect(actionOf(resettled, "EffectEnvValueWorker")).toBe("noop");
1390
1391 yield* stack.destroy();
1392 yield* waitForWorkerToBeDeleted(deployed.workerName, accountId);
1393 }).pipe(logLevel),
1394 { tags: ["live"], timeout: 360_000 },
1395 );
1396
1397 // #1831 regression: a `Config` value read in an Effect-native Worker's
1398 // Init lands in `props.env` only, which the Worker diff does not compare;
1399 // the planner compares Init-captured values itself. Changing the value
1400 // must plan an update and reach the deployed Worker; an unchanged value
1401 // must stay a noop.
1402 test.provider(
1403 "changing a Config value read in Init plans an update",
1404 (stack) =>
1405 Effect.gen(function* () {
1406 const { accountId } = yield* yield* CloudflareEnvironment;
1407
1408 yield* stack.destroy();
1409
1410 // Layer the value over the ambient provider: the harness snapshots
1411 // `process.env` when the test starts.
1412 const program = (mode: string) =>
1413 Effect.gen(function* () {
1414 const ambient = yield* ConfigProvider.ConfigProvider;
1415 return yield* InitConfigWorker.pipe(
1416 Effect.provideService(
1417 ConfigProvider.ConfigProvider,
1418 ConfigProvider.orElse(
1419 ConfigProvider.fromUnknown({
1420 INIT_CONFIG_WORKER_MODE: mode,
1421 }),
1422 ambient,
1423 ),
1424 ),
1425 );
1426 });
1427
1428 const actionOf = (plan: any, logicalId: string) =>
1429 (Object.values(plan.resources) as any[]).find(
1430 (node: any) => node.resource.LogicalId === logicalId,
1431 )?.action;
1432
1433 const deployed = yield* stack.deploy(program("init-config-v1"));
1434 yield* expectUrlContains(deployed.url!, "init-config-v1", {
1435 timeout: "60 seconds",
1436 label: "initial Init config value",
1437 });
1438
1439 const samePlan = yield* stack.plan(program("init-config-v1"));
1440 expect(actionOf(samePlan, "InitConfigWorker")).toBe("noop");
1441 const changedPlan = yield* stack.plan(program("init-config-v2"));
1442 expect(actionOf(changedPlan, "InitConfigWorker")).toBe("update");
1443
1444 const redeployed = yield* stack.deploy(program("init-config-v2"));
1445 yield* expectUrlContains(redeployed.url!, "init-config-v2", {
1446 timeout: "60 seconds",
1447 label: "updated Init config value",
1448 });
1449 const resettled = yield* stack.plan(program("init-config-v2"));
1450 expect(actionOf(resettled, "InitConfigWorker")).toBe("noop");
1451
1452 yield* stack.destroy();
1453 yield* waitForWorkerToBeDeleted(deployed.workerName, accountId);
1454 }).pipe(logLevel),
1455 { tags: ["live"], timeout: 360_000 },
1456 );
1457
1458 // `allUrls`/`domains` should reflect the workers.dev URL when the
1459 // subdomain is enabled and be empty when it isn't. `worker.url` is always
1460 // `allUrls[0]`, so the three must stay in lockstep across deploys.
1461 test.provider(
1462 "allUrls and domains reflect the workers.dev subdomain and track url",
1463 (stack) =>
1464 Effect.gen(function* () {
1465 const { accountId } = yield* yield* CloudflareEnvironment;
1466
1467 yield* stack.destroy();
1468
1469 const deploy = (workersDev: boolean) =>
1470 stack.deploy(
1471 Effect.gen(function* () {
1472 return yield* Cloudflare.Worker("DomainsWorker", {
1473 main,
1474 workersDev,
1475 compatibility: { date: "2024-01-01" },
1476 });
1477 }),
1478 );
1479
1480 const enabled = yield* deploy(true);
1481 expect(enabled.urls).toHaveLength(1);
1482 expect(enabled.urls[0]).toMatch(/\.workers\.dev$/);
1483 expect(enabled.url).toEqual(enabled.urls[0]);
1484 expect(enabled.domain).toBeUndefined();
1485
1486 const disabled = yield* deploy(false);
1487 expect(disabled.urls).toEqual([]);
1488 expect(disabled.domain).toBeUndefined();
1489 expect(disabled.url).toBeUndefined();
1490
1491 yield* stack.destroy();
1492 yield* waitForWorkerToBeDeleted(enabled.workerName, accountId);
1493 }).pipe(logLevel),
1494 { tags: ["live"] },
1495 );
1496
1497 // Ordering contract: the canonical custom domain leads `urls` (it is the
1498 // most significant URL), aliases follow in declared order, and the
1499 // workers.dev URL comes last. Swapping name and alias moves `url`.
1500 const customDomainZone =
1501 process.env.CLOUDFLARE_TEST_WORKER_DOMAIN_ZONE_NAME;
1502 test.provider.skipIf(!customDomainZone)(
1503 "custom domain outranks workers.dev in urls and selects url",
1504 (stack) =>
1505 Effect.gen(function* () {
1506 const { accountId } = yield* yield* CloudflareEnvironment;
1507 const suffix =
1508 process.env.PULL_REQUEST ?? process.env.USER ?? "local";
1509 const domainA = `alchemy-worker-a-${suffix}.${customDomainZone}`;
1510 const domainB = `alchemy-worker-b-${suffix}.${customDomainZone}`;
1511
1512 yield* stack.destroy();
1513
1514 const deploy = (
1515 workersDev: boolean,
1516 domain: { name: string; aliases?: string[] },
1517 ) =>
1518 stack.deploy(
1519 Effect.gen(function* () {
1520 return yield* Cloudflare.Worker("CustomDomainWorker", {
1521 main,
1522 workersDev,
1523 domain,
1524 compatibility: { date: "2024-01-01" },
1525 });
1526 }),
1527 );
1528
1529 // workers.dev enabled: the canonical domain still leads `urls`,
1530 // the alias follows, and the workers.dev URL comes last.
1531 const worker = yield* deploy(true, {
1532 name: domainA,
1533 aliases: [domainB],
1534 });
1535 expect(worker.urls).toHaveLength(3);
1536 expect(worker.urls.slice(0, 2)).toEqual([
1537 `https://${domainA}`,
1538 `https://${domainB}`,
1539 ]);
1540 expect(worker.urls[2]).toMatch(
1541 new RegExp(`^https://${worker.workerName}\\..*\\.workers\\.dev$`),
1542 );
1543 expect(worker.url).toEqual(`https://${domainA}`);
1544 expect(worker.domain).toEqual({
1545 name: domainA,
1546 aliases: [domainB],
1547 redirects: [],
1548 });
1549
1550 // Domain only (no workers.dev): urls is just the domain + alias.
1551 const domainOnly = yield* deploy(false, {
1552 name: domainA,
1553 aliases: [domainB],
1554 });
1555 expect(domainOnly.urls).toEqual([
1556 `https://${domainA}`,
1557 `https://${domainB}`,
1558 ]);
1559 expect(domainOnly.url).toEqual(`https://${domainA}`);
1560
1561 // Swap name and alias — `url` follows the canonical name.
1562 const swapped = yield* deploy(false, {
1563 name: domainB,
1564 aliases: [domainA],
1565 });
1566 expect(swapped.urls).toEqual([
1567 `https://${domainB}`,
1568 `https://${domainA}`,
1569 ]);
1570 expect(swapped.url).toEqual(`https://${domainB}`);
1571 expect(swapped.domain).toEqual({
1572 name: domainB,
1573 aliases: [domainA],
1574 redirects: [],
1575 });
1576
1577 yield* stack.destroy();
1578 yield* waitForWorkerToBeDeleted(worker.workerName, accountId);
1579 }).pipe(logLevel),
1580 { tags: ["live"] },
1581 );
1582
1583 // State-migration: Worker state written by older Alchemy versions stored
1584 // `domains` as `https://<hostname>` URL strings with the workers.dev URL
1585 // mixed in (beta.45–57) or as `{ id, hostname, zoneId }` objects
1586 // (<= beta.44), and had no `allUrls`. A deploy on top of either shape must
1587 // not crash the diff/reconcile and must rewrite the attributes to the
1588 // current shape (bare hostnames aligned with `allUrls`).
1589 test.provider(
1590 "redeploy coerces legacy domains state",
1591 (stack) =>
1592 Effect.gen(function* () {
1593 const { accountId } = yield* yield* CloudflareEnvironment;
1594
1595 yield* stack.destroy();
1596
1597 const deploy = (date: string) =>
1598 stack.deploy(
1599 Effect.gen(function* () {
1600 return yield* Cloudflare.Worker("LegacyStateWorker", {
1601 main,
1602 compatibility: { date },
1603 });
1604 }),
1605 );
1606
1607 const v1 = yield* deploy("2024-01-01");
1608 const workersDevHost = new URL(v1.url!).hostname;
1609
1610 // Rewrite the persisted record to a legacy shape. The stale
1611 // custom-domain entry ("app.example.com") was never attached on
1612 // Cloudflare, so the redeploy must also converge state back to the
1613 // observed cloud truth rather than trusting the record. `props`
1614 // optionally gains the pre-redesign keys so `olds` reaching the diff
1615 // looks exactly like a record an old deploy persisted.
1616 const writeLegacyRecord = (legacy: {
1617 url: string | undefined;
1618 domains: unknown[];
1619 legacyProps?: Record<string, unknown>;
1620 }) =>
1621 Effect.gen(function* () {
1622 const state = yield* yield* State;
1623 const key = {
1624 stack: stack.name,
1625 stage: stack.stage,
1626 fqn: "LegacyStateWorker",
1627 };
1628 const current = yield* state.get(key);
1629 expect(current).toBeDefined();
1630 const attr = {
1631 ...(current as any).attr,
1632 url: legacy.url,
1633 domains: legacy.domains,
1634 };
1635 delete attr.urls;
1636 delete attr.domain;
1637 yield* state.set({
1638 ...key,
1639 value: {
1640 ...(current as any),
1641 attr,
1642 props: { ...(current as any).props, ...legacy.legacyProps },
1643 },
1644 });
1645 }).pipe(Effect.provide(stack.state));
1646
1647 // beta.45–57: URL strings, workers.dev mixed in, no allUrls.
1648 yield* writeLegacyRecord({
1649 url: "https://app.example.com",
1650 domains: ["https://app.example.com", `https://${workersDevHost}`],
1651 });
1652
1653 const v2 = yield* deploy("2024-01-02");
1654 expect(v2.url).toEqual(`https://${workersDevHost}`);
1655 expect(v2.urls).toEqual([`https://${workersDevHost}`]);
1656 expect(v2.domain).toBeUndefined();
1657
1658 // <= beta.44: `{ id, hostname, zoneId }` objects — and the props the
1659 // old code persisted alongside them (`url`/`subdomain` keys), so the
1660 // diff's `olds` carries the pre-redesign prop shape too.
1661 yield* writeLegacyRecord({
1662 url: undefined,
1663 domains: [
1664 { id: "legacy", hostname: "app.example.com", zoneId: "z" },
1665 ],
1666 legacyProps: {
1667 url: true,
1668 subdomain: { enabled: true, previewsEnabled: true },
1669 },
1670 });
1671
1672 const v3 = yield* deploy("2024-01-03");
1673 expect(v3.url).toEqual(`https://${workersDevHost}`);
1674 expect(v3.urls).toEqual([`https://${workersDevHost}`]);
1675 expect(v3.domain).toBeUndefined();
1676
1677 yield* stack.destroy();
1678 yield* waitForWorkerToBeDeleted(v1.workerName, accountId);
1679 }).pipe(logLevel),
1680 { tags: ["live"] },
1681 );
1682
1683 // The real upgrade path for a bare Worker: the user's props are byte-for-
1684 // byte identical before and after upgrading Alchemy, so the *only* thing
1685 // that can trigger the state migration is the metadata hash (its surface
1686 // changed: `url`/`subdomain` keys out, `workersDev` in). The migration
1687 // must be a one-time update — and a downstream resource consuming
1688 // `worker.urls` (absent from legacy state) must re-resolve through it
1689 // and settle back to noop.
1690 test.provider(
1691 "props-identical redeploy migrates legacy state via the metadata hash",
1692 (stack) =>
1693 Effect.gen(function* () {
1694 const { accountId } = yield* yield* CloudflareEnvironment;
1695
1696 yield* stack.destroy();
1697
1698 // Never changes across the test — like a user who upgrades Alchemy
1699 // without touching their stack code.
1700 const program = Effect.gen(function* () {
1701 const worker = yield* Cloudflare.Worker("BareUpstream", {
1702 main,
1703 compatibility: { date: "2024-01-01" },
1704 });
1705 yield* Cloudflare.Alerting.NotificationWebhook("Hook", {
1706 url: worker.urls.pipe(Output.map((urls) => urls[0]!)),
1707 });
1708 return worker;
1709 });
1710
1711 const actionOf = (plan: any, logicalId: string) =>
1712 (Object.values(plan.resources) as any[]).find(
1713 (node: any) => node.resource.LogicalId === logicalId,
1714 )?.action;
1715
1716 const v1 = yield* stack.deploy(program);
1717 const workersDevHost = new URL(v1.url!).hostname;
1718
1719 // Rewrite the record to what an old deploy persisted: URL-string
1720 // domains, no allUrls, and a metadata hash computed over the old
1721 // surface (any value the new code can't reproduce).
1722 yield* Effect.gen(function* () {
1723 const state = yield* yield* State;
1724 const key = {
1725 stack: stack.name,
1726 stage: stack.stage,
1727 fqn: "BareUpstream",
1728 };
1729 const current = yield* state.get(key);
1730 expect(current).toBeDefined();
1731 const attr = {
1732 ...(current as any).attr,
1733 url: `https://${workersDevHost}`,
1734 domains: [`https://${workersDevHost}`],
1735 hash: { ...(current as any).attr.hash, metadata: "legacy" },
1736 };
1737 delete attr.urls;
1738 yield* state.set({ ...key, value: { ...(current as any), attr } });
1739 }).pipe(Effect.provide(stack.state));
1740
1741 // Identical props still plan as an update — driven by the metadata
1742 // hash alone. The downstream's `allUrls` reference is unresolvable
1743 // from legacy state (never marked stable), so it takes a one-time
1744 // update alongside the migration.
1745 const migrationPlan = yield* stack.plan(program);
1746 expect(actionOf(migrationPlan, "BareUpstream")).toBe("update");
1747 expect(actionOf(migrationPlan, "Hook")).toBe("update");
1748
1749 const migrated = yield* stack.deploy(program);
1750 expect(migrated.url).toEqual(`https://${workersDevHost}`);
1751 expect(migrated.urls).toEqual([`https://${workersDevHost}`]);
1752 expect(migrated.domain).toBeUndefined();
1753
1754 // Migration is one-time: the same props now settle as a full noop,
1755 // including the downstream (allUrls is present and stable again).
1756 const settledPlan = yield* stack.plan(program);
1757 expect(actionOf(settledPlan, "BareUpstream")).toBe("noop");
1758 expect(actionOf(settledPlan, "Hook")).toBe("noop");
1759
1760 yield* stack.destroy();
1761 yield* waitForWorkerToBeDeleted(v1.workerName, accountId);
1762 }).pipe(logLevel),
1763 { tags: ["provider:cloudflare:alerting", "live"], timeout: 240_000 },
1764 );
1765
1766 // Canonical `list()` test (account collection): deploy a real Worker and
1767 // assert it shows up in the exhaustively-paginated account-wide listing.
1768 test.provider(
1769 "list enumerates the deployed worker",
1770 (stack) =>
1771 Effect.gen(function* () {
1772 const { accountId } = yield* yield* CloudflareEnvironment;
1773
1774 yield* stack.destroy();
1775
1776 const worker = yield* stack.deploy(
1777 Effect.gen(function* () {
1778 return yield* Cloudflare.Worker("ListWorker", {
1779 main,
1780 compatibility: { date: "2024-01-01" },
1781 });
1782 }),
1783 );
1784
1785 const provider = yield* Provider.findProvider(Cloudflare.Worker);
1786 const all = yield* provider.list();
1787
1788 expect(all.some((w) => w.workerName === worker.workerName)).toBe(
1789 true,
1790 );
1791 const found = all.find((w) => w.workerName === worker.workerName);
1792 expect(found?.workerId).toEqual(worker.workerId);
1793 expect(found?.accountId).toEqual(accountId);
1794
1795 yield* stack.destroy();
1796 yield* waitForWorkerToBeDeleted(worker.workerName, accountId);
1797 }).pipe(logLevel),
1798 { tags: ["live"] },
1799 );
1800
1801 test.provider(
1802 "downstream referencing worker.url is not re-updated when the worker changes",
1803 (stack) =>
1804 // Regression: a downstream resource that references `worker.url` as a
1805 // plain prop (e.g. a GitHub Webhook delivery URL built via
1806 // `Output.interpolate`) must not spuriously re-update every time the
1807 // upstream worker changes. The worker's url is stable across a
1808 // code/config change, so the planner must resolve `worker.url` to a
1809 // concrete value (rather than an unresolved Output, which would make
1810 // `havePropsChanged` short-circuit on `Output.hasOutputs` and force a
1811 // phantom update) and plan the downstream as a no-op.
1812 Effect.gen(function* () {
1813 yield* stack.destroy();
1814
1815 // A worker plus a notification webhook whose `url` prop points at the
1816 // worker (a plain prop dependency, exactly like a GitHub webhook's
1817 // delivery URL). `crons` is the only thing that varies between the
1818 // deploy and the re-plan — it forces the worker to plan as an
1819 // `update` while leaving its url untouched.
1820 const program = (crons: string[]) =>
1821 Effect.gen(function* () {
1822 const worker = yield* Cloudflare.Worker("Upstream", {
1823 main,
1824 crons,
1825 compatibility: { date: "2024-01-01" },
1826 });
1827 yield* Cloudflare.Alerting.NotificationWebhook("Hook", {
1828 url: Output.interpolate`${worker.url}`,
1829 });
1830 });
1831
1832 yield* stack.deploy(program([]));
1833
1834 // Re-plan with the worker changed (a new cron forces an update) but
1835 // the webhook identical. The plan is never applied, so the cron is
1836 // never actually deployed.
1837 const plan = yield* stack.plan(program(["*/10 * * * *"]));
1838
1839 const actionOf = (logicalId: string) =>
1840 Object.values(plan.resources).find(
1841 (node) => node.resource.LogicalId === logicalId,
1842 )?.action;
1843
1844 expect(actionOf("Upstream")).toBe("update");
1845 expect(actionOf("Hook")).toBe("noop");
1846
1847 yield* stack.destroy();
1848 }).pipe(logLevel),
1849 { tags: ["provider:cloudflare:alerting", "live"], timeout: 180_000 },
1850 );
1851
1852 test.provider(
1853 "worker.durableObjectNamespaces stability across DO and worker changes",
1854 (stack) =>
1855 // Exercises plan actions for a downstream resource whose props reference
1856 // `worker.durableObjectNamespaces.<ClassName>`. Scenarios:
1857 //
1858 // | Step | Worker | Hook |
1859 // |------------------------------|------------|------------|
1860 // | First deploy (no DO) | create | — |
1861 // | Add first DO + hook | update | create |
1862 // | Worker-only change | update | noop |
1863 // | Add another DO class | update | noop |
1864 // | Remove a DO class | update | update |
1865 // | Worker-only change (restored)| update | noop |
1866 // | Swap DO class (add+remove) | update | noop |
1867 // | Deploy swap + hook follows | (apply) | (apply) |
1868 // | No further changes | noop | noop |
1869 // | Remove last DO class | update | update |
1870 Effect.gen(function* () {
1871 yield* stack.destroy();
1872
1873 type DoClass = "Counter" | "Meter";
1874
1875 const program = (opts: {
1876 crons: string[];
1877 dos: ReadonlyArray<DoClass>;
1878 hookRef: DoClass | null;
1879 }) =>
1880 Effect.gen(function* () {
1881 const bindings: any = {};
1882 if (opts.dos.includes("Counter")) {
1883 bindings.Counter = Cloudflare.DurableObject<Counter>("Counter");
1884 }
1885 if (opts.dos.includes("Meter")) {
1886 bindings.Meter = Cloudflare.DurableObject<Meter>("Meter");
1887 }
1888
1889 const worker = yield* Cloudflare.Worker("Upstream", {
1890 main: doMain,
1891 crons: opts.crons,
1892 compatibility: { date: "2024-09-23" },
1893 bindings,
1894 } as any);
1895
1896 if (opts.hookRef !== null) {
1897 // Embed the DO namespace id in the (real, reachable) worker URL so
1898 // the webhook's live URL validation passes while still depending on
1899 // `durableObjectNamespaces`. The worker responds 200 to any path.
1900 yield* Cloudflare.Alerting.NotificationWebhook("Hook", {
1901 url: Output.interpolate`${worker.url}/${worker.durableObjectNamespaces.pipe(
1902 Output.map((namespaces) => namespaces[opts.hookRef!]),
1903 )}`,
1904 });
1905 }
1906 });
1907
1908 const actionOf = (plan: any, logicalId: string) =>
1909 (Object.values(plan.resources) as any[]).find(
1910 (node: any) => node.resource.LogicalId === logicalId,
1911 )?.action;
1912
1913 // ── First deploy: worker with no DO classes yet ──
1914 const workerOnlyFirstPlan = yield* stack.plan(
1915 program({ crons: [], dos: [], hookRef: null }),
1916 );
1917 expect(actionOf(workerOnlyFirstPlan, "Upstream")).toBe("create");
1918 expect(actionOf(workerOnlyFirstPlan, "Hook")).toBeUndefined();
1919
1920 yield* stack.deploy(program({ crons: [], dos: [], hookRef: null }));
1921
1922 // ── Add the first DO class + hook referencing it ──
1923 const addFirstDoPlan = yield* stack.plan(
1924 program({ crons: [], dos: ["Counter"], hookRef: "Counter" }),
1925 );
1926 expect(actionOf(addFirstDoPlan, "Upstream")).toBe("update");
1927 expect(actionOf(addFirstDoPlan, "Hook")).toBe("create");
1928
1929 yield* stack.deploy(
1930 program({ crons: [], dos: ["Counter"], hookRef: "Counter" }),
1931 );
1932
1933 // ── Worker-only change, same DO set → hook noop ──
1934 const workerOnlyPlan = yield* stack.plan(
1935 program({
1936 crons: ["*/10 * * * *"],
1937 dos: ["Counter"],
1938 hookRef: "Counter",
1939 }),
1940 );
1941 expect(actionOf(workerOnlyPlan, "Upstream")).toBe("update");
1942 expect(actionOf(workerOnlyPlan, "Hook")).toBe("noop");
1943
1944 // ── Add a DO class (Meter) while hook still refs Counter → Counter's
1945 // namespace id is unchanged, so the hook is a noop even though the
1946 // worker must update to register the new class ──
1947 const addDoPlan = yield* stack.plan(
1948 program({
1949 crons: ["*/10 * * * *"],
1950 dos: ["Counter", "Meter"],
1951 hookRef: "Counter",
1952 }),
1953 );
1954 expect(actionOf(addDoPlan, "Upstream")).toBe("update");
1955 expect(actionOf(addDoPlan, "Hook")).toBe("noop");
1956
1957 yield* stack.deploy(
1958 program({
1959 crons: ["*/10 * * * *"],
1960 dos: ["Counter", "Meter"],
1961 hookRef: "Counter",
1962 }),
1963 );
1964
1965 // ── Remove a DO class (Meter) while hook still refs Counter → DO set
1966 // changed, so the hook must re-plan even though Counter's id is
1967 // unchanged in the cloud ──
1968 const removeDoPlan = yield* stack.plan(
1969 program({
1970 crons: ["*/10 * * * *"],
1971 dos: ["Counter"],
1972 hookRef: "Counter",
1973 }),
1974 );
1975 expect(actionOf(removeDoPlan, "Upstream")).toBe("update");
1976 expect(actionOf(removeDoPlan, "Hook")).toBe("update");
1977
1978 yield* stack.deploy(
1979 program({
1980 crons: ["*/10 * * * *"],
1981 dos: ["Counter"],
1982 hookRef: "Counter",
1983 }),
1984 );
1985
1986 // ── Same DO set restored → hook noop on another worker-only change ──
1987 const stableAgainPlan = yield* stack.plan(
1988 program({ crons: [], dos: ["Counter"], hookRef: "Counter" }),
1989 );
1990 expect(actionOf(stableAgainPlan, "Upstream")).toBe("update");
1991 expect(actionOf(stableAgainPlan, "Hook")).toBe("noop");
1992
1993 // ── Swap Counter → Meter (add & remove in one step), hook still refs
1994 // Counter. The worker must update; the hook plans as noop because the
1995 // persisted Counter namespace id is still carried in state until apply ──
1996 const swapDoPlan = yield* stack.plan(
1997 program({
1998 crons: [],
1999 dos: ["Meter"],
2000 hookRef: "Counter",
2001 }),
2002 );
2003 expect(actionOf(swapDoPlan, "Upstream")).toBe("update");
2004 expect(actionOf(swapDoPlan, "Hook")).toBe("noop");
2005
2006 yield* stack.deploy(
2007 program({
2008 crons: [],
2009 dos: ["Meter"],
2010 hookRef: "Meter",
2011 }),
2012 );
2013
2014 // ── No further changes → noop ──
2015 const hookFollowsDoPlan = yield* stack.plan(
2016 program({ crons: [], dos: ["Meter"], hookRef: "Meter" }),
2017 );
2018 expect(actionOf(hookFollowsDoPlan, "Upstream")).toBe("noop");
2019 expect(actionOf(hookFollowsDoPlan, "Hook")).toBe("noop");
2020
2021 // ── Remove the last DO class entirely while hook still refs Meter →
2022 // hook must update (plan-only; URL would be invalid to deploy) ──
2023 const removeLastDoPlan = yield* stack.plan(
2024 program({ crons: [], dos: [], hookRef: "Meter" }),
2025 );
2026 expect(actionOf(removeLastDoPlan, "Upstream")).toBe("update");
2027 expect(actionOf(removeLastDoPlan, "Hook")).toBe("update");
2028
2029 yield* stack.destroy();
2030 }).pipe(logLevel),
2031 { tags: ["provider:cloudflare:alerting", "live"], timeout: 360_000 },
2032 );
2033
2034 test.provider(
2035 "plain worker importing node builtins deploys without explicit compatibility flags",
2036 (stack) =>
2037 Effect.gen(function* () {
2038 const fs = yield* FileSystem.FileSystem;
2039 const path = yield* Path.Path;
2040 const { accountId } = yield* yield* CloudflareEnvironment;
2041
2042 yield* stack.destroy();
2043
2044 // Regression for #796: a plain (external) `export default { fetch }`
2045 // Worker importing `node:crypto`, with NO `compatibility` prop. The
2046 // default `nodejs_compat` must reach both the bundler (so `node:*`
2047 // stays external instead of warning) and the upload metadata (so
2048 // Cloudflare doesn't reject with `No such module "node:crypto"`).
2049 const workerDir = yield* fs.makeTempDirectory({
2050 prefix: "alchemy-worker-node-compat-",
2051 });
2052 const workerPath = path.join(workerDir, "worker.ts");
2053 yield* fs.writeFileString(
2054 workerPath,
2055 `import { randomBytes } from "node:crypto";
2056export default {
2057 fetch: async () =>
2058 new Response("node-compat:" + randomBytes(8).toString("hex")),
2059};
2060`,
2061 );
2062
2063 const worker = yield* stack.deploy(
2064 Effect.gen(function* () {
2065 return yield* Cloudflare.Worker("NodeCompatDefault", {
2066 main: workerPath,
2067 workersDev: true,
2068 });
2069 }),
2070 );
2071
2072 yield* expectUrlContains(worker.url!, "node-compat:", {
2073 timeout: "60 seconds",
2074 label: "node:crypto served under default nodejs_compat",
2075 });
2076
2077 yield* stack.destroy();
2078 yield* waitForWorkerToBeDeleted(worker.workerName, accountId);
2079 }).pipe(logLevel),
2080 { tags: ["live"], timeout: 360_000 },
2081 );
2082
2083 test.provider(
2084 "deploys a worker whose main is derived from a Command.Build output",
2085 (stack) =>
2086 Effect.gen(function* () {
2087 const { accountId } = yield* yield* CloudflareEnvironment;
2088 const fs = yield* FileSystem.FileSystem;
2089 const path = yield* Path.Path;
2090
2091 yield* stack.destroy();
2092
2093 // Regression for #1049: `main` computed from another resource's
2094 // output is still an unresolved Output when `precreate` runs on raw
2095 // props — `getCompatibility` (via `isPythonMain`) must tolerate it.
2096 // The entry module only exists after the build command runs, so the
2097 // Worker cannot know its `main` path until `build.outdir` resolves.
2098 const marker = "alchemy-output-main-e2e-ok-7c31";
2099 const tempDir = yield* fs.makeTempDirectory({
2100 prefix: "alchemy-output-main-",
2101 });
2102 yield* fs.writeFileString(
2103 path.join(tempDir, "worker.src.mjs"),
2104 `export default { fetch: () => new Response(${JSON.stringify(marker)}) };\n`,
2105 );
2106 yield* fs.writeFileString(
2107 path.join(tempDir, "build.sh"),
2108 "mkdir -p dist\ncp worker.src.mjs dist/worker.mjs\n",
2109 );
2110
2111 const worker = yield* stack.deploy(
2112 Effect.gen(function* () {
2113 const build = yield* Command.Build("OutputMainBuild", {
2114 command: "bash build.sh",
2115 cwd: tempDir,
2116 outdir: "dist",
2117 });
2118 return yield* Cloudflare.Worker("OutputMainWorker", {
2119 isExternal: true,
2120 workersDev: true,
2121 // `build.outdir` is persisted relative to the initial cwd —
2122 // resolve it against `initialCwd` (per Build's contract), not
2123 // live `process.cwd()`, which another suite's in-process build
2124 // can transiently chdir away.
2125 main: Output.map(build.outdir, (dir) =>
2126 pathe.resolve(initialCwd, dir, "worker.mjs"),
2127 ),
2128 });
2129 }),
2130 );
2131
2132 expect(worker.url).toBeDefined();
2133 yield* expectUrlContains(worker.url!, marker);
2134
2135 yield* stack.destroy();
2136 yield* waitForWorkerToBeDeleted(worker.workerName, accountId);
2137 }).pipe(logLevel),
2138 { tags: ["live"], timeout: 360_000 },
2139 );
2140 },
2141);