1import { AlchemyContext } from "@/AlchemyContext.ts";
2import { ArtifactStore, createArtifactStore } from "@/Artifacts.ts";
3import * as Cloudflare from "@/Cloudflare";
4import type { CloudflareResolvedCredentials } from "@/Cloudflare/Auth/AuthConfig.ts";
5import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
6import * as Drift from "@/Drift.ts";
7import { LocalRuntimeState } from "@/Cloudflare/LocalRuntime.ts";
8import { InstanceId } from "@/InstanceId.ts";
9import * as RemovalPolicy from "@/RemovalPolicy.ts";
10import { Provider } from "@/Provider.ts";
11import { Stack, type StackSpec } from "@/Stack.ts";
12import { Stage } from "@/Stage.ts";
13import { type ResourceState, State } from "@/State";
14import * as Test from "@/Test/Alchemy";
15import {
16 apiTokenCredentials,
17 Credentials,
18} from "@distilled.cloud/cloudflare/Credentials";
19import * as r2 from "@distilled.cloud/cloudflare/r2";
20import { NodeServices } from "@effect/platform-node";
21import { describe, expect, it } from "alchemy-test";
22import * as Data from "effect/Data";
23import * as Effect from "effect/Effect";
24import * as Layer from "effect/Layer";
25import * as MutableHashMap from "effect/MutableHashMap";
26import * as Redacted from "effect/Redacted";
27import { MinimumLogLevel } from "effect/References";
28import * as Result from "effect/Result";
29import * as Schedule from "effect/Schedule";
30import * as Stream from "effect/Stream";
31import * as FetchHttpClient from "effect/http/FetchHttpClient";
32import type * as HttpClient from "effect/http/HttpClient";
33
34const { test } = Test.make({ providers: Cloudflare.providers() });
35
36const logLevel = Effect.provideService(
37 MinimumLogLevel,
38 process.env.DEBUG ? "Debug" : "Info",
39);
40
41test.provider(
42 "create and delete bucket with default props",
43 (stack) =>
44 Effect.gen(function* () {
45 const { accountId } = yield* yield* CloudflareEnvironment;
46
47 yield* stack.destroy();
48
49 const bucket = yield* stack.deploy(
50 Effect.gen(function* () {
51 return yield* Cloudflare.R2.Bucket("DefaultBucket", {
52 forceDestroy: true,
53 });
54 }),
55 );
56
57 expect(bucket.bucketName).toBeDefined();
58 expect(bucket.storageClass).toEqual("Standard");
59 expect(bucket.jurisdiction).toEqual("default");
60 expect(bucket.publicDomain).toBeUndefined();
61
62 const actualBucket = yield* getBucketWhenReady(
63 bucket.bucketName,
64 accountId,
65 );
66 expect(actualBucket.name).toEqual(bucket.bucketName);
67
68 yield* stack.destroy();
69
70 yield* waitForBucketToBeDeleted(bucket.bucketName, accountId);
71 }).pipe(logLevel),
72 { tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"] },
73);
74
75test.provider(
76 "create, update, delete bucket",
77 (stack) =>
78 Effect.gen(function* () {
79 const { accountId } = yield* yield* CloudflareEnvironment;
80
81 yield* stack.destroy();
82
83 const bucket = yield* stack.deploy(
84 Effect.gen(function* () {
85 return yield* Cloudflare.R2.Bucket("TestBucket", {
86 forceDestroy: true,
87 storageClass: "Standard",
88 });
89 }),
90 );
91
92 const actualBucket = yield* getBucketWhenReady(
93 bucket.bucketName,
94 accountId,
95 );
96 expect(actualBucket.name).toEqual(bucket.bucketName);
97 expect(actualBucket.storageClass).toEqual("Standard");
98
99 const updatedBucket = yield* stack.deploy(
100 Effect.gen(function* () {
101 return yield* Cloudflare.R2.Bucket("TestBucket", {
102 forceDestroy: true,
103 storageClass: "InfrequentAccess",
104 });
105 }),
106 );
107
108 // The storage-class change is an IN-PLACE update (PATCH with the
109 // `cf-r2-storage-class` header), never a replacement: same physical
110 // bucket name and unchanged creation date.
111 expect(updatedBucket.bucketName).toEqual(bucket.bucketName);
112
113 const actualUpdatedBucket = yield* getBucketWhenReady(
114 updatedBucket.bucketName,
115 accountId,
116 );
117 expect(actualUpdatedBucket.name).toEqual(updatedBucket.bucketName);
118 expect(actualUpdatedBucket.storageClass).toEqual("InfrequentAccess");
119 expect(actualUpdatedBucket.creationDate).toEqual(
120 actualBucket.creationDate,
121 );
122
123 yield* stack.destroy();
124
125 yield* waitForBucketToBeDeleted(updatedBucket.bucketName, accountId);
126 }).pipe(logLevel),
127 { tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"] },
128);
129
130// Engine-level adoption: R2 buckets have no ownership signal (Cloudflare
131// doesn't expose tags on R2 buckets), so a name match in `read` is treated
132// as silent adoption.
133test.provider(
134 "existing bucket (matching name) is silently adopted without --adopt",
135 (stack) =>
136 Effect.gen(function* () {
137 const { accountId } = yield* yield* CloudflareEnvironment;
138
139 yield* stack.destroy();
140
141 // Phase 1: deploy normally so a real R2 bucket exists. No explicit
142 // `name` — the engine generates a random-suffixed physical name
143 // (collision-free across concurrent runs); the deploy output hands
144 // back the real name, which pins the bucket's identity for the
145 // adoption phase below.
146 const initial = yield* stack.deploy(
147 Effect.gen(function* () {
148 return yield* Cloudflare.R2.Bucket("AdoptableBucket", {
149 forceDestroy: true,
150 });
151 }),
152 );
153 const bucketName = initial.bucketName;
154
155 // Phase 2: wipe local state — the bucket stays on Cloudflare.
156 yield* Effect.gen(function* () {
157 const state = yield* yield* State;
158 yield* state.delete({
159 stack: stack.name,
160 stage: stack.stage,
161 fqn: "AdoptableBucket",
162 });
163 }).pipe(Effect.provide(stack.state));
164
165 // Phase 3: redeploy without `adopt(true)`. The engine calls
166 // `provider.read`, which fetches the bucket by name and returns
167 // plain attrs — silent adoption.
168 const adopted = yield* stack.deploy(
169 Effect.gen(function* () {
170 return yield* Cloudflare.R2.Bucket("AdoptableBucket", {
171 forceDestroy: true,
172 name: bucketName,
173 });
174 }),
175 );
176
177 expect(adopted.bucketName).toEqual(bucketName);
178
179 const persisted = yield* Effect.gen(function* () {
180 const state = yield* yield* State;
181 return yield* state.get({
182 stack: stack.name,
183 stage: stack.stage,
184 fqn: "AdoptableBucket",
185 });
186 }).pipe(Effect.provide(stack.state));
187
188 expect((persisted as any)?.attr).toMatchObject({ bucketName });
189
190 yield* stack.destroy();
191 yield* waitForBucketToBeDeleted(bucketName, accountId);
192 }).pipe(logLevel),
193 { tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"] },
194);
195
196test.provider(
197 "destroying a bucket with forceDestroy empties its objects first",
198 (stack) =>
199 Effect.gen(function* () {
200 const { accountId } = yield* yield* CloudflareEnvironment;
201
202 yield* stack.destroy();
203
204 const bucket = yield* stack.deploy(
205 Effect.gen(function* () {
206 return yield* Cloudflare.R2.Bucket("BucketWithObjects", {
207 forceDestroy: true,
208 });
209 }),
210 );
211
212 yield* putObject(accountId, bucket.bucketName, "hello.txt", "hello");
213 yield* putObject(
214 accountId,
215 bucket.bucketName,
216 "nested/world.txt",
217 "world",
218 );
219
220 const before = yield* listKeysWhenReady(accountId, bucket.bucketName, 2);
221 expect(before.sort()).toEqual(["hello.txt", "nested/world.txt"]);
222
223 yield* stack.destroy();
224
225 yield* waitForBucketToBeDeleted(bucket.bucketName, accountId);
226 }).pipe(logLevel),
227 { tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"] },
228);
229
230// Without `forceDestroy`, R2's own refusal to delete a non-empty bucket is
231// the last line of defense for the data in it — alchemy must not empty the
232// bucket to get past it. See https://github.com/alchemy-run/alchemy/issues/1248.
233test.provider(
234 "destroying a non-empty bucket without forceDestroy keeps the objects",
235 (stack) =>
236 Effect.gen(function* () {
237 const { accountId } = yield* yield* CloudflareEnvironment;
238
239 yield* stack.destroy();
240
241 const declaration = Effect.gen(function* () {
242 return yield* Cloudflare.R2.Bucket("ProtectedBucket");
243 });
244
245 const bucket = yield* stack.deploy(declaration);
246
247 yield* putObject(accountId, bucket.bucketName, "keep.txt", "precious");
248 yield* listKeysWhenReady(accountId, bucket.bucketName, 1);
249
250 // R2's own refusal (`BucketNotEmpty`) is what fails the teardown.
251 const destroyed = yield* Effect.result(stack.destroy());
252 expect(Result.isFailure(destroyed)).toBe(true);
253 expect(String(destroyed)).toContain("BucketNotEmpty");
254
255 // Both the bucket and its object survived the teardown.
256 const survived = yield* r2.getBucket({
257 accountId,
258 bucketName: bucket.bucketName,
259 });
260 expect(survived.name).toEqual(bucket.bucketName);
261 const keys = yield* listKeysWhenReady(accountId, bucket.bucketName, 1);
262 expect(keys).toEqual(["keep.txt"]);
263
264 // Opting in lets the same stack tear down for real.
265 yield* stack.deploy(
266 Effect.gen(function* () {
267 return yield* Cloudflare.R2.Bucket("ProtectedBucket", {
268 forceDestroy: true,
269 });
270 }),
271 );
272 yield* stack.destroy();
273 yield* waitForBucketToBeDeleted(bucket.bucketName, accountId);
274 }).pipe(logLevel),
275 { tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"] },
276);
277
278// The incident behind https://github.com/alchemy-run/alchemy/issues/1248,
279// end to end against a real bucket: `retain` is added to an
280// already-deployed bucket (a props-identical, noop deploy), the bucket's
281// declaration then moves away, and the old stack's orphan sweep must leave
282// both the bucket and its objects untouched.
283//
284// A bucket cannot be renamed in place — R2 has no rename API, and the
285// provider's `diff` orders a REPLACE on a name change — so "moving" a
286// bucket is always: drop the state row here, adopt the same physical bucket
287// there. Retain is what makes that safe.
288test.provider(
289 "a retained bucket outlives its declaration with its objects intact",
290 (stack) =>
291 Effect.gen(function* () {
292 const { accountId } = yield* yield* CloudflareEnvironment;
293 const bucketName = "alchemy-test-retained-move";
294
295 // A retained bucket is alchemy's to forget, not to delete, so an
296 // interrupted earlier run can leave a NON-EMPTY bucket behind — which
297 // the leading `stack.destroy()` could never reclaim on its own (that
298 // is the whole point of the protection under test). Reclaim it out of
299 // band first, then let destroy drain any state row pointing at it.
300 yield* forceDeleteBucket(accountId, bucketName);
301 yield* stack.destroy();
302
303 // ── 1. deploy with the DEFAULT policy, then put an object in it ──
304 const origin = yield* stack.deploy(
305 Effect.gen(function* () {
306 return yield* Cloudflare.R2.Bucket("Origin", { name: bucketName });
307 }),
308 );
309 expect(origin.bucketName).toEqual(bucketName);
310 yield* putObject(accountId, bucketName, "precious.txt", "irreplaceable");
311 yield* listKeysWhenReady(accountId, bucketName, 1);
312 expect(yield* removalPolicyOf(stack, "Origin")).toEqual("destroy");
313
314 // ── 2. add `retain`, props otherwise identical ──
315 // The policy is a declaration decoration, not a prop, so the plan is a
316 // noop — and the noop path is the only pass that can persist it.
317 const retained = Effect.gen(function* () {
318 return yield* Cloudflare.R2.Bucket("Origin", { name: bucketName }).pipe(
319 RemovalPolicy.retain(),
320 );
321 });
322 const plan = yield* stack.plan(retained);
323 expect(
324 (Object.values(plan.resources) as { action: string }[])[0]?.action,
325 ).toEqual("noop");
326 yield* stack.deploy(retained);
327 expect(yield* removalPolicyOf(stack, "Origin")).toEqual("retain");
328
329 // ── 3. the declaration moves away — the old stack sweeps ──
330 yield* stack.destroy();
331
332 // The state row is dropped either way; only the physical bucket
333 // survives. That is why the bug was silent.
334 expect(yield* stateOf(stack, "Origin")).toBeUndefined();
335 const survivor = yield* r2.getBucket({ accountId, bucketName });
336 expect(survivor.name).toEqual(bucketName);
337 expect(yield* listKeysWhenReady(accountId, bucketName, 1)).toEqual([
338 "precious.txt",
339 ]);
340
341 // ── 4. it lands in its new home by adopting the same bucket ──
342 // (Same stack, new logical id — the engine cannot tell that apart from
343 // the same declaration in a different stack: both `read` the bucket by
344 // name and adopt it.) `forceDestroy` is set here purely so the final
345 // teardown reclaims the test bucket.
346 const moved = yield* stack.deploy(
347 Effect.gen(function* () {
348 return yield* Cloudflare.R2.Bucket("Destination", {
349 name: bucketName,
350 forceDestroy: true,
351 });
352 }),
353 );
354 expect(moved.bucketName).toEqual(bucketName);
355 expect(yield* listKeysWhenReady(accountId, bucketName, 1)).toEqual([
356 "precious.txt",
357 ]);
358
359 yield* stack.destroy();
360 yield* waitForBucketToBeDeleted(bucketName, accountId);
361 }).pipe(logLevel),
362 {
363 tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"],
364 timeout: 180_000,
365 },
366);
367
368// The other half of a move: a retained bucket whose REPLACEMENT is ordered
369// (an explicit name change, the only way to "rename" an R2 bucket). The old
370// generation must be left standing, objects and all.
371test.provider(
372 "replacing a retained bucket leaves the old generation and its objects",
373 (stack) =>
374 Effect.gen(function* () {
375 const { accountId } = yield* yield* CloudflareEnvironment;
376
377 const oldName = "alchemy-test-retained-replace-old";
378 const newName = "alchemy-test-retained-replace-new";
379
380 // Same reason as above: retained leftovers from an interrupted run are
381 // never reclaimed by the engine.
382 yield* forceDeleteBucket(accountId, oldName);
383 yield* forceDeleteBucket(accountId, newName);
384 yield* stack.destroy();
385
386 const declare = (name: string) =>
387 Effect.gen(function* () {
388 return yield* Cloudflare.R2.Bucket("Renamed", { name }).pipe(
389 RemovalPolicy.retain(),
390 );
391 });
392
393 yield* stack.deploy(declare(oldName));
394 yield* putObject(accountId, oldName, "precious.txt", "irreplaceable");
395 yield* listKeysWhenReady(accountId, oldName, 1);
396
397 // A name change is a replacement: new bucket created, state re-pointed.
398 const replaced = yield* stack.deploy(declare(newName));
399 expect(replaced.bucketName).toEqual(newName);
400 yield* getBucketWhenReady(newName, accountId);
401
402 // Retain covers the replacement's old generation too — the bucket is
403 // dropped from state, never deleted, and its objects are still there.
404 const old = yield* r2.getBucket({ accountId, bucketName: oldName });
405 expect(old.name).toEqual(oldName);
406 expect(yield* listKeysWhenReady(accountId, oldName, 1)).toEqual([
407 "precious.txt",
408 ]);
409
410 yield* stack.destroy();
411
412 // Retained buckets are alchemy's to forget, not to delete, so this
413 // suite reclaims both out of band.
414 yield* forceDeleteBucket(accountId, oldName);
415 yield* forceDeleteBucket(accountId, newName);
416 }).pipe(logLevel),
417 {
418 tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"],
419 timeout: 180_000,
420 },
421);
422
423test.provider(
424 "lifecycle rules are added, updated, and removed",
425 (stack) =>
426 Effect.gen(function* () {
427 const { accountId } = yield* yield* CloudflareEnvironment;
428
429 yield* stack.destroy();
430
431 // Create with one rule.
432 const initial = yield* stack.deploy(
433 Effect.gen(function* () {
434 return yield* Cloudflare.R2.Bucket("LifecycleBucket", {
435 forceDestroy: true,
436 lifecycleRules: [
437 {
438 id: "expire-after-30d",
439 deleteObjectsTransition: {
440 condition: { type: "Age", maxAge: 60 * 60 * 24 * 30 },
441 },
442 },
443 ],
444 });
445 }),
446 );
447
448 const initialRules = yield* r2.getBucketLifecycle({
449 accountId,
450 bucketName: initial.bucketName,
451 });
452 expect(initialRules.rules).toHaveLength(1);
453 expect(initialRules.rules?.[0]?.id).toEqual("expire-after-30d");
454 expect(initialRules.rules?.[0]?.enabled).toEqual(true);
455 expect(
456 initialRules.rules?.[0]?.deleteObjectsTransition?.condition,
457 ).toEqual({ type: "Age", maxAge: 60 * 60 * 24 * 30 });
458
459 // Update: change the prefix and add a storage class transition.
460 yield* stack.deploy(
461 Effect.gen(function* () {
462 return yield* Cloudflare.R2.Bucket("LifecycleBucket", {
463 forceDestroy: true,
464 lifecycleRules: [
465 {
466 id: "expire-after-30d",
467 prefix: "logs/",
468 storageClassTransitions: [
469 {
470 condition: { type: "Age", maxAge: 60 * 60 * 24 * 7 },
471 storageClass: "InfrequentAccess",
472 },
473 ],
474 deleteObjectsTransition: {
475 condition: { type: "Age", maxAge: 60 * 60 * 24 * 30 },
476 },
477 },
478 ],
479 });
480 }),
481 );
482
483 const updatedRules = yield* r2.getBucketLifecycle({
484 accountId,
485 bucketName: initial.bucketName,
486 });
487 expect(updatedRules.rules).toHaveLength(1);
488 expect(updatedRules.rules?.[0]?.conditions.prefix).toEqual("logs/");
489 expect(updatedRules.rules?.[0]?.storageClassTransitions).toEqual([
490 {
491 condition: { type: "Age", maxAge: 60 * 60 * 24 * 7 },
492 storageClass: "InfrequentAccess",
493 },
494 ]);
495
496 // Clear all rules.
497 yield* stack.deploy(
498 Effect.gen(function* () {
499 return yield* Cloudflare.R2.Bucket("LifecycleBucket", {
500 forceDestroy: true,
501 lifecycleRules: [],
502 });
503 }),
504 );
505
506 const clearedRules = yield* r2.getBucketLifecycle({
507 accountId,
508 bucketName: initial.bucketName,
509 });
510 expect(clearedRules.rules ?? []).toEqual([]);
511
512 yield* stack.destroy();
513 yield* waitForBucketToBeDeleted(initial.bucketName, accountId);
514 }).pipe(logLevel),
515 { tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"] },
516);
517
518test.provider(
519 "cors rules are added, updated, and removed",
520 (stack) =>
521 Effect.gen(function* () {
522 const { accountId } = yield* yield* CloudflareEnvironment;
523
524 yield* stack.destroy();
525
526 // Create with one rule.
527 const initial = yield* stack.deploy(
528 Effect.gen(function* () {
529 return yield* Cloudflare.R2.Bucket("CorsBucket", {
530 forceDestroy: true,
531 cors: [
532 {
533 id: "range-reads",
534 allowedMethods: ["GET", "HEAD"],
535 allowedOrigins: ["https://map.example.com"],
536 allowedHeaders: ["range"],
537 exposeHeaders: ["etag", "content-range"],
538 maxAgeSeconds: 3600,
539 },
540 ],
541 });
542 }),
543 );
544
545 expect(initial.cors).toHaveLength(1);
546
547 const initialCors = yield* r2.getBucketCors({
548 accountId,
549 bucketName: initial.bucketName,
550 });
551 expect(initialCors.rules).toHaveLength(1);
552 expect(initialCors.rules?.[0]?.id).toEqual("range-reads");
553 expect(initialCors.rules?.[0]?.allowed.methods).toEqual(["GET", "HEAD"]);
554 expect(initialCors.rules?.[0]?.allowed.origins).toEqual([
555 "https://map.example.com",
556 ]);
557 expect(initialCors.rules?.[0]?.exposeHeaders).toEqual([
558 "etag",
559 "content-range",
560 ]);
561 expect(initialCors.rules?.[0]?.maxAgeSeconds).toEqual(3600);
562
563 // Update: widen origins and add a second rule.
564 yield* stack.deploy(
565 Effect.gen(function* () {
566 return yield* Cloudflare.R2.Bucket("CorsBucket", {
567 forceDestroy: true,
568 cors: [
569 {
570 id: "range-reads",
571 allowedMethods: ["GET", "HEAD"],
572 allowedOrigins: ["*"],
573 allowedHeaders: ["range"],
574 exposeHeaders: ["etag", "content-range"],
575 maxAgeSeconds: 3600,
576 },
577 {
578 id: "uploads",
579 allowedMethods: ["PUT", "POST"],
580 allowedOrigins: ["https://app.example.com"],
581 allowedHeaders: ["content-type"],
582 },
583 ],
584 });
585 }),
586 );
587
588 const updatedCors = yield* r2.getBucketCors({
589 accountId,
590 bucketName: initial.bucketName,
591 });
592 expect(updatedCors.rules).toHaveLength(2);
593 expect(updatedCors.rules?.[0]?.allowed.origins).toEqual(["*"]);
594 expect(updatedCors.rules?.[1]?.id).toEqual("uploads");
595 expect(updatedCors.rules?.[1]?.allowed.methods).toEqual(["PUT", "POST"]);
596
597 // Clear all rules — the CORS configuration is deleted entirely, so the
598 // GET endpoint reports the typed NoCorsConfiguration error.
599 yield* stack.deploy(
600 Effect.gen(function* () {
601 return yield* Cloudflare.R2.Bucket("CorsBucket", {
602 forceDestroy: true,
603 cors: [],
604 });
605 }),
606 );
607
608 const cleared = yield* r2
609 .getBucketCors({
610 accountId,
611 bucketName: initial.bucketName,
612 })
613 .pipe(
614 Effect.map((response) => response.rules ?? []),
615 Effect.catchTag("NoCorsConfiguration", () => Effect.succeed([])),
616 );
617 expect(cleared).toEqual([]);
618
619 yield* stack.destroy();
620 yield* waitForBucketToBeDeleted(initial.bucketName, accountId);
621 }).pipe(logLevel),
622 { tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"] },
623);
624
625test.provider(
626 "cors reconciliation converges drift and adoption",
627 (stack) =>
628 Effect.gen(function* () {
629 const { accountId } = yield* yield* CloudflareEnvironment;
630
631 yield* stack.destroy();
632
633 const bucketName = "alchemy-test-r2-cors-drift";
634 const rangeReads = {
635 id: "range-reads",
636 allowedMethods: ["GET", "HEAD"] as ("GET" | "HEAD")[],
637 allowedOrigins: ["https://map.example.com"],
638 allowedHeaders: ["range"],
639 exposeHeaders: ["etag"],
640 maxAgeSeconds: 3600,
641 };
642 const foreignRule = {
643 id: "foreign",
644 allowed: {
645 methods: ["DELETE" as const],
646 origins: ["https://other.example.com"],
647 },
648 };
649
650 const initial = yield* stack.deploy(
651 Effect.gen(function* () {
652 return yield* Cloudflare.R2.Bucket("DriftCorsBucket", {
653 forceDestroy: true,
654 name: bucketName,
655 cors: [rangeReads],
656 });
657 }),
658 );
659
660 // Drift: overwrite the CORS configuration out-of-band.
661 yield* r2.putBucketCors({
662 accountId,
663 bucketName,
664 rules: [foreignRule],
665 });
666
667 // Re-deploy with a changed rule. Reconcile diffs desired against
668 // *observed* cloud state (not olds), so the foreign rule is replaced
669 // even though olds still describes the original rule.
670 yield* stack.deploy(
671 Effect.gen(function* () {
672 return yield* Cloudflare.R2.Bucket("DriftCorsBucket", {
673 forceDestroy: true,
674 name: bucketName,
675 cors: [{ ...rangeReads, maxAgeSeconds: 7200 }],
676 });
677 }),
678 );
679
680 const repaired = yield* r2.getBucketCors({ accountId, bucketName });
681 expect(repaired.rules).toHaveLength(1);
682 expect(repaired.rules?.[0]?.id).toEqual("range-reads");
683 expect(repaired.rules?.[0]?.maxAgeSeconds).toEqual(7200);
684
685 // Adoption: re-drift the CORS config, then wipe local state so the next
686 // deploy adopts via `read` (output defined, olds undefined).
687 yield* r2.putBucketCors({
688 accountId,
689 bucketName,
690 rules: [foreignRule],
691 });
692 yield* Effect.gen(function* () {
693 const state = yield* yield* State;
694 yield* state.delete({
695 stack: stack.name,
696 stage: stack.stage,
697 fqn: "DriftCorsBucket",
698 });
699 }).pipe(Effect.provide(stack.state));
700
701 const adopted = yield* stack.deploy(
702 Effect.gen(function* () {
703 return yield* Cloudflare.R2.Bucket("DriftCorsBucket", {
704 forceDestroy: true,
705 name: bucketName,
706 cors: [rangeReads],
707 });
708 }),
709 );
710 expect(adopted.bucketName).toEqual(bucketName);
711 expect(adopted.cors).toHaveLength(1);
712 expect(adopted.cors[0]?.id).toEqual("range-reads");
713
714 const converged = yield* r2.getBucketCors({ accountId, bucketName });
715 expect(converged.rules).toHaveLength(1);
716 expect(converged.rules?.[0]?.id).toEqual("range-reads");
717 expect(converged.rules?.[0]?.allowed.origins).toEqual([
718 "https://map.example.com",
719 ]);
720
721 yield* stack.destroy();
722 yield* waitForBucketToBeDeleted(bucketName, accountId);
723 }).pipe(logLevel),
724 { tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"] },
725);
726
727test.provider(
728 "drift detects and repairs a live storage-class change",
729 (stack) =>
730 Effect.gen(function* () {
731 const { accountId } = yield* yield* CloudflareEnvironment;
732 const bucketName = "alchemy-test-r2-drift-repair";
733
734 yield* forceDeleteBucket(accountId, bucketName);
735 yield* stack.destroy();
736
737 yield* stack.deploy(
738 Effect.gen(function* () {
739 return yield* Cloudflare.R2.Bucket("DriftRepairBucket", {
740 forceDestroy: true,
741 name: bucketName,
742 storageClass: "Standard",
743 });
744 }),
745 );
746
747 yield* r2.patchBucket({
748 accountId,
749 bucketName,
750 storageClass: "InfrequentAccess",
751 });
752
753 const detected = yield* Drift.detect({
754 name: stack.name,
755 stage: stack.stage,
756 }).pipe(Effect.provide(stack.state));
757 expect(detected.resources.DriftRepairBucket).toMatchObject({
758 action: "drifted",
759 resourceType: "Cloudflare.R2.Bucket",
760 });
761
762 const repaired = yield* Drift.repair({
763 name: stack.name,
764 stage: stack.stage,
765 }).pipe(Effect.provide(stack.state));
766 expect(repaired.resources.DriftRepairBucket).toMatchObject({
767 action: "repaired",
768 });
769
770 const bucket = yield* getBucketWhenReady(bucketName, accountId);
771 expect(bucket.storageClass).toEqual("Standard");
772
773 yield* stack.destroy();
774 yield* waitForBucketToBeDeleted(bucketName, accountId);
775 }).pipe(logLevel),
776 { tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"] },
777);
778
779test.provider(
780 "drift detects and recreates a bucket deleted out of band",
781 (stack) =>
782 Effect.gen(function* () {
783 const { accountId } = yield* yield* CloudflareEnvironment;
784 const bucketName = "alchemy-test-r2-drift-recreate";
785
786 yield* forceDeleteBucket(accountId, bucketName);
787 yield* stack.destroy();
788
789 yield* stack.deploy(
790 Effect.gen(function* () {
791 return yield* Cloudflare.R2.Bucket("DriftRecreateBucket", {
792 forceDestroy: true,
793 name: bucketName,
794 });
795 }),
796 );
797 const before = yield* stateOf(stack, "DriftRecreateBucket");
798
799 yield* forceDeleteBucket(accountId, bucketName);
800 yield* waitForBucketToBeDeleted(bucketName, accountId);
801
802 const detected = yield* Drift.detect({
803 name: stack.name,
804 stage: stack.stage,
805 }).pipe(Effect.provide(stack.state));
806 expect(detected.resources.DriftRecreateBucket).toMatchObject({
807 action: "missing",
808 resourceType: "Cloudflare.R2.Bucket",
809 });
810
811 const repaired = yield* Drift.repair({
812 name: stack.name,
813 stage: stack.stage,
814 }).pipe(Effect.provide(stack.state));
815 expect(repaired.resources.DriftRecreateBucket).toMatchObject({
816 action: "recreated",
817 });
818
819 const after = yield* stateOf(stack, "DriftRecreateBucket");
820 expect(after?.instanceId).toEqual(before?.instanceId);
821 expect((yield* getBucketWhenReady(bucketName, accountId)).name).toEqual(
822 bucketName,
823 );
824
825 yield* stack.destroy();
826 yield* waitForBucketToBeDeleted(bucketName, accountId);
827 }).pipe(logLevel),
828 {
829 tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"],
830 timeout: 180_000,
831 },
832);
833
834test.provider(
835 "cors is applied to the new bucket on replacement",
836 (stack) =>
837 Effect.gen(function* () {
838 const { accountId } = yield* yield* CloudflareEnvironment;
839
840 yield* stack.destroy();
841
842 const oldName = "alchemy-test-r2-cors-replace-a";
843 const newName = "alchemy-test-r2-cors-replace-b";
844 const cors = [
845 {
846 id: "range-reads",
847 allowedMethods: ["GET", "HEAD"] as ("GET" | "HEAD")[],
848 allowedOrigins: ["https://map.example.com"],
849 allowedHeaders: ["range"],
850 exposeHeaders: ["etag"],
851 maxAgeSeconds: 3600,
852 },
853 ];
854
855 const initial = yield* stack.deploy(
856 Effect.gen(function* () {
857 return yield* Cloudflare.R2.Bucket("ReplaceCorsBucket", {
858 forceDestroy: true,
859 name: oldName,
860 cors,
861 });
862 }),
863 );
864 expect(initial.bucketName).toEqual(oldName);
865
866 const initialCors = yield* r2.getBucketCors({
867 accountId,
868 bucketName: oldName,
869 });
870 expect(initialCors.rules).toHaveLength(1);
871
872 // Changing the name replaces the bucket: the new bucket is created
873 // (greenfield reconcile must apply the CORS config from scratch) and
874 // the old bucket is deleted afterwards.
875 const replaced = yield* stack.deploy(
876 Effect.gen(function* () {
877 return yield* Cloudflare.R2.Bucket("ReplaceCorsBucket", {
878 forceDestroy: true,
879 name: newName,
880 cors,
881 });
882 }),
883 );
884 expect(replaced.bucketName).toEqual(newName);
885 expect(replaced.cors).toHaveLength(1);
886 expect(replaced.cors[0]?.id).toEqual("range-reads");
887
888 const replacedCors = yield* r2.getBucketCors({
889 accountId,
890 bucketName: newName,
891 });
892 expect(replacedCors.rules).toHaveLength(1);
893 expect(replacedCors.rules?.[0]?.id).toEqual("range-reads");
894 expect(replacedCors.rules?.[0]?.allowed.origins).toEqual([
895 "https://map.example.com",
896 ]);
897
898 // The replaced bucket is cleaned up.
899 yield* waitForBucketToBeDeleted(oldName, accountId);
900
901 yield* stack.destroy();
902 yield* waitForBucketToBeDeleted(newName, accountId);
903 }).pipe(logLevel),
904 { tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"] },
905);
906
907test.provider(
908 "managed r2.dev domain is enabled and disabled",
909 (stack) =>
910 Effect.gen(function* () {
911 const { accountId } = yield* yield* CloudflareEnvironment;
912
913 yield* stack.destroy();
914
915 const initial = yield* stack.deploy(
916 Effect.gen(function* () {
917 return yield* Cloudflare.R2.Bucket("PublicBucket", {
918 forceDestroy: true,
919 publicAccess: true,
920 });
921 }),
922 );
923
924 expect(initial.publicDomain).toBeDefined();
925 expect(initial.publicDomain).toMatch(/\.r2\.dev$/);
926
927 const enabled = yield* r2.listBucketDomainManageds({
928 accountId,
929 bucketName: initial.bucketName,
930 });
931 expect(enabled.enabled).toEqual(true);
932 expect(enabled.domain).toEqual(initial.publicDomain);
933
934 const disabled = yield* stack.deploy(
935 Effect.gen(function* () {
936 return yield* Cloudflare.R2.Bucket("PublicBucket", {
937 forceDestroy: true,
938 publicAccess: false,
939 });
940 }),
941 );
942
943 expect(disabled.bucketName).toEqual(initial.bucketName);
944 expect(disabled.publicDomain).toBeUndefined();
945
946 const observed = yield* r2.listBucketDomainManageds({
947 accountId,
948 bucketName: initial.bucketName,
949 });
950 expect(observed.enabled).toEqual(false);
951 expect(observed.domain).toEqual(enabled.domain);
952
953 yield* stack.destroy();
954 yield* waitForBucketToBeDeleted(initial.bucketName, accountId);
955 }).pipe(logLevel),
956 { tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"] },
957);
958
959test.provider(
960 "managed r2.dev domain converges drift and adoption",
961 (stack) =>
962 Effect.gen(function* () {
963 const { accountId } = yield* yield* CloudflareEnvironment;
964
965 yield* stack.destroy();
966
967 // Pin the physical name on every deploy so adding `name` cannot be
968 // what schedules the update — same shape as the CORS drift case
969 // above. A PR/user suffix keeps concurrent runs from colliding.
970 const suffix = (process.env.PULL_REQUEST ?? process.env.USER ?? "local")
971 .toLowerCase()
972 .replace(/[^a-z0-9]/g, "")
973 .slice(0, 24);
974 const bucketName = `alchemy-test-r2-pub-drift-${suffix}`;
975
976 const initial = yield* stack.deploy(
977 Effect.gen(function* () {
978 return yield* Cloudflare.R2.Bucket("DriftPublicBucket", {
979 forceDestroy: true,
980 name: bucketName,
981 publicAccess: false,
982 });
983 }),
984 );
985 expect(initial.publicDomain).toBeUndefined();
986
987 // Drift: enable the managed domain out of band.
988 yield* r2.putBucketDomainManaged({
989 accountId,
990 bucketName,
991 enabled: true,
992 });
993
994 // Re-deploy with publicAccess still false. The storage-class change
995 // is what makes Plan run reconcile (unchanged props skip it); the
996 // managed-domain sync must still disable the leaked enable because
997 // it diffs desired against *observed* cloud state, not olds.
998 const repaired = yield* stack.deploy(
999 Effect.gen(function* () {
1000 return yield* Cloudflare.R2.Bucket("DriftPublicBucket", {
1001 forceDestroy: true,
1002 name: bucketName,
1003 publicAccess: false,
1004 storageClass: "InfrequentAccess",
1005 });
1006 }),
1007 );
1008 expect(repaired.bucketName).toEqual(bucketName);
1009 expect(repaired.storageClass).toEqual("InfrequentAccess");
1010 expect(repaired.publicDomain).toBeUndefined();
1011
1012 const afterRepair = yield* r2.listBucketDomainManageds({
1013 accountId,
1014 bucketName,
1015 });
1016 expect(afterRepair.enabled).toEqual(false);
1017
1018 // Adoption: re-enable out of band, wipe local state, then adopt
1019 // with publicAccess: true (output defined, olds undefined).
1020 yield* r2.putBucketDomainManaged({
1021 accountId,
1022 bucketName,
1023 enabled: true,
1024 });
1025 yield* Effect.gen(function* () {
1026 const state = yield* yield* State;
1027 yield* state.delete({
1028 stack: stack.name,
1029 stage: stack.stage,
1030 fqn: "DriftPublicBucket",
1031 });
1032 }).pipe(Effect.provide(stack.state));
1033
1034 const adopted = yield* stack.deploy(
1035 Effect.gen(function* () {
1036 return yield* Cloudflare.R2.Bucket("DriftPublicBucket", {
1037 forceDestroy: true,
1038 name: bucketName,
1039 publicAccess: true,
1040 });
1041 }),
1042 );
1043 expect(adopted.bucketName).toEqual(bucketName);
1044 expect(adopted.publicDomain).toMatch(/\.r2\.dev$/);
1045
1046 const converged = yield* r2.listBucketDomainManageds({
1047 accountId,
1048 bucketName,
1049 });
1050 expect(converged.enabled).toEqual(true);
1051 expect(converged.domain).toEqual(adopted.publicDomain);
1052
1053 yield* stack.destroy();
1054 yield* waitForBucketToBeDeleted(bucketName, accountId);
1055 }).pipe(logLevel),
1056 { tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"] },
1057);
1058
1059test.provider(
1060 "managed r2.dev domain is applied on replacement",
1061 (stack) =>
1062 Effect.gen(function* () {
1063 const { accountId } = yield* yield* CloudflareEnvironment;
1064
1065 yield* stack.destroy();
1066
1067 const initial = yield* stack.deploy(
1068 Effect.gen(function* () {
1069 return yield* Cloudflare.R2.Bucket("ReplacePublicBucket", {
1070 forceDestroy: true,
1071 publicAccess: true,
1072 });
1073 }),
1074 );
1075 const oldName = initial.bucketName;
1076 // Flip the first character so the replacement name is unique and
1077 // stays within R2's 63-char limit regardless of the original length.
1078 const newName = `x${oldName.slice(1)}`;
1079
1080 const replaced = yield* stack.deploy(
1081 Effect.gen(function* () {
1082 return yield* Cloudflare.R2.Bucket("ReplacePublicBucket", {
1083 forceDestroy: true,
1084 name: newName,
1085 publicAccess: true,
1086 });
1087 }),
1088 );
1089 expect(replaced.bucketName).toEqual(newName);
1090 expect(replaced.publicDomain).toMatch(/\.r2\.dev$/);
1091
1092 const replacedDomain = yield* r2.listBucketDomainManageds({
1093 accountId,
1094 bucketName: newName,
1095 });
1096 expect(replacedDomain.enabled).toEqual(true);
1097 expect(replacedDomain.domain).toEqual(replaced.publicDomain);
1098
1099 yield* waitForBucketToBeDeleted(oldName, accountId);
1100
1101 yield* stack.destroy();
1102 yield* waitForBucketToBeDeleted(newName, accountId);
1103 }).pipe(logLevel),
1104 { tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"] },
1105);
1106
1107// R2 bucket creates are eventually consistent — a read immediately after
1108// deploy can briefly return NoSuchBucket until the bucket propagates.
1109const getBucketWhenReady = Effect.fn(function* (
1110 bucketName: string,
1111 accountId: string,
1112) {
1113 return yield* r2.getBucket({ accountId, bucketName }).pipe(
1114 Effect.retry({
1115 while: (e) => e._tag === "NoSuchBucket",
1116 // Cap the backoff at 2s so we keep sampling instead of sleeping
1117 // through the budget on the geometric tail.
1118 schedule: Schedule.max([
1119 Schedule.min([
1120 Schedule.exponential("200 millis"),
1121 Schedule.spaced("2 seconds"),
1122 ]),
1123 Schedule.recurs(20),
1124 ]),
1125 }),
1126 );
1127});
1128
1129const waitForBucketToBeDeleted = Effect.fn(function* (
1130 bucketName: string,
1131 accountId: string,
1132) {
1133 yield* r2
1134 .getBucket({
1135 accountId,
1136 bucketName,
1137 })
1138 .pipe(
1139 Effect.flatMap(() => Effect.fail(new BucketStillExists())),
1140 Effect.retry({
1141 while: (e): e is BucketStillExists => e instanceof BucketStillExists,
1142 schedule: Schedule.exponential(100),
1143 }),
1144 Effect.catchTag("NoSuchBucket", () => Effect.void),
1145 );
1146});
1147
1148class BucketStillExists extends Data.TaggedError("BucketStillExists") {}
1149
1150const putObject = (
1151 accountId: string,
1152 bucketName: string,
1153 key: string,
1154 body: string,
1155) =>
1156 r2.putObject({
1157 accountId,
1158 bucketName,
1159 objectName: key,
1160 contentType: "text/plain",
1161 body: new Blob([body], { type: "text/plain" }),
1162 });
1163
1164// R2's object listing lags a write by a beat — poll until the expected
1165// number of keys shows up, then return them.
1166const listKeysWhenReady = Effect.fn(function* (
1167 accountId: string,
1168 bucketName: string,
1169 count: number,
1170) {
1171 return yield* r2.listObjects({ accountId, bucketName, perPage: 1000 }).pipe(
1172 Effect.flatMap((page) => {
1173 const keys = (page.result ?? [])
1174 .map((o) => o.key)
1175 .filter((k): k is string => typeof k === "string");
1176 return keys.length === count
1177 ? Effect.succeed(keys)
1178 : Effect.fail(new ListLagError());
1179 }),
1180 Effect.retry({
1181 while: (e): e is ListLagError => e instanceof ListLagError,
1182 schedule: Schedule.max([Schedule.exponential(200), Schedule.recurs(8)]),
1183 }),
1184 );
1185});
1186
1187class ListLagError extends Data.TaggedError("ListLagError") {}
1188
1189const stateOf = Effect.fn(function* (
1190 stack: { name: string; stage: string; state: Layer.Layer<State> },
1191 fqn: string,
1192) {
1193 return yield* Effect.gen(function* () {
1194 const state = yield* yield* State;
1195 return (yield* state.get({
1196 stack: stack.name,
1197 stage: stack.stage,
1198 fqn,
1199 })) as ResourceState | undefined;
1200 }).pipe(Effect.provide(stack.state));
1201});
1202
1203const removalPolicyOf = Effect.fn(function* (
1204 stack: { name: string; stage: string; state: Layer.Layer<State> },
1205 fqn: string,
1206) {
1207 return (yield* stateOf(stack, fqn))?.removalPolicy;
1208});
1209
1210/**
1211 * Empty and delete a bucket out of band. Needed only for buckets a test
1212 * deliberately RETAINED: alchemy has forgotten them by design, so nothing
1213 * else will ever reclaim them.
1214 */
1215const forceDeleteBucket = Effect.fn(function* (
1216 accountId: string,
1217 bucketName: string,
1218) {
1219 yield* r2.listObjects.items({ accountId, bucketName, perPage: 1000 }).pipe(
1220 Stream.filter(
1221 (o): o is typeof o & { key: string } => typeof o.key === "string",
1222 ),
1223 Stream.map((o) => o.key),
1224 Stream.runForEachArray((chunk) =>
1225 r2.deleteObjects({ accountId, bucketName, body: [...chunk] }),
1226 ),
1227 Effect.catchTag("NoSuchBucket", () => Effect.void),
1228 );
1229 yield* r2
1230 .deleteBucket({ accountId, bucketName })
1231 .pipe(Effect.catchTag("NoSuchBucket", () => Effect.void));
1232});
1233
1234// ── destructive deletes require explicit opt-in ────────────────────────
1235//
1236// DATA-PROTECTION INVARIANT: `delete` may remove the bucket, but it must
1237// NEVER destroy the bucket's CONTENTS unless the user opted in on the
1238// resource (`forceDestroy`) or an operator ran `alchemy unsafe nuke`
1239// (which passes `force: true`).
1240//
1241// R2 refuses to delete a non-empty bucket (409 "is not empty", typed
1242// `BucketNotEmpty`). That refusal is the last line of defense for
1243// production data, and emptying the bucket first silently converts a
1244// routine teardown into irreversible data loss — which is what happened
1245// in https://github.com/alchemy-run/alchemy/issues/1248, where a stale
1246// removal policy orphaned a bucket and the delete wiped 60k objects the
1247// API would otherwise have protected.
1248//
1249// The live test above can only observe that the bucket survived, not that
1250// no destructive request was ever issued. These run the REAL provider
1251// `delete` against a recording transport and assert on the wire traffic.
1252
1253type Recorded = { method: string; url: string };
1254
1255/** Fetch transport that records every request and answers from `respond`. */
1256const recordingTransport = (respond: (call: Recorded) => Response) => {
1257 const calls: Recorded[] = [];
1258 const fetch = async (
1259 input: string | URL | Request,
1260 init?: RequestInit,
1261 ): Promise<Response> => {
1262 calls.push({
1263 method: (input instanceof Request ? input.method : init?.method) ?? "GET",
1264 url: input instanceof Request ? input.url : String(input),
1265 });
1266 return respond(calls[calls.length - 1]!);
1267 };
1268 return {
1269 calls,
1270 layer: FetchHttpClient.layer.pipe(
1271 Layer.provide(
1272 Layer.succeed(FetchHttpClient.Fetch, fetch as typeof globalThis.fetch),
1273 ),
1274 ),
1275 };
1276};
1277
1278const TEST_ACCOUNT = "test-account-id";
1279const INSTANCE_ID = "0123456789abcdef0123456789abcdef";
1280
1281const testStack: Omit<StackSpec, "output"> = {
1282 name: "my-stack",
1283 stage: "dev",
1284 resources: {},
1285 bindings: {},
1286 actions: {},
1287};
1288
1289const stubbedEnv = (transport: Layer.Layer<HttpClient.HttpClient>) =>
1290 Layer.mergeAll(
1291 Layer.succeed(
1292 CloudflareEnvironment,
1293 Effect.succeed({
1294 type: "apiToken",
1295 apiToken: Redacted.make("test-token"),
1296 accountId: TEST_ACCOUNT,
1297 source: { type: "env" },
1298 } satisfies CloudflareResolvedCredentials),
1299 ),
1300 Layer.succeed(
1301 Credentials,
1302 Effect.succeed(apiTokenCredentials({ apiToken: "test-token" })),
1303 ),
1304 Layer.succeed(
1305 LocalRuntimeState,
1306 LocalRuntimeState.of({
1307 queues: MutableHashMap.empty(),
1308 queueConsumers: MutableHashMap.empty(),
1309 workerRestarts: MutableHashMap.empty(),
1310 }),
1311 ),
1312 Layer.succeed(Stack, testStack),
1313 Layer.succeed(Stage, testStack.stage),
1314 Layer.succeed(InstanceId, INSTANCE_ID),
1315 Layer.succeed(AlchemyContext, {
1316 dotAlchemy: "/tmp/.alchemy-test",
1317 dev: false,
1318 adopt: false,
1319 }),
1320 Layer.sync(ArtifactStore, createArtifactStore),
1321 NodeServices.layer,
1322 ).pipe(Layer.provideMerge(transport));
1323
1324const stubbedOutput = {
1325 bucketName: "my-bucket",
1326 storageClass: "Standard" as const,
1327 jurisdiction: "default" as const,
1328 location: undefined,
1329 accountId: TEST_ACCOUNT,
1330 domains: [],
1331 lifecycleRules: [],
1332 cors: [],
1333 publicDomain: undefined,
1334};
1335
1336/** `DELETE .../r2/buckets/{name}/objects` — the request that wipes data. */
1337const objectDeletes = (calls: Recorded[]) =>
1338 calls.filter((c) => c.method === "DELETE" && c.url.includes("/objects"));
1339
1340/** `DELETE .../r2/buckets/{name}` — deleting the bucket itself. */
1341const bucketDeletes = (calls: Recorded[]) =>
1342 calls.filter(
1343 (c) =>
1344 c.method === "DELETE" &&
1345 c.url.endsWith(`/r2/buckets/${stubbedOutput.bucketName}`),
1346 );
1347
1348/** One object in the bucket, so the empty path has something to delete. */
1349const stubResponse = (call: Recorded) =>
1350 new Response(
1351 JSON.stringify(
1352 call.method === "GET" && call.url.includes("/objects")
1353 ? { success: true, result: [{ key: "precious.txt" }] }
1354 : { success: true, result: {} },
1355 ),
1356 { status: 200, headers: { "content-type": "application/json" } },
1357 );
1358
1359/** Run the real provider delete; return every request it made. */
1360const recordDelete = (
1361 props: { forceDestroy?: boolean },
1362 options?: { force?: boolean },
1363) =>
1364 Effect.gen(function* () {
1365 const transport = recordingTransport(stubResponse);
1366 yield* Effect.gen(function* () {
1367 const provider = yield* Provider<Cloudflare.R2.Bucket>(
1368 "Cloudflare.R2.Bucket",
1369 );
1370 yield* provider.delete({
1371 id: "Bucket",
1372 fqn: "Bucket",
1373 instanceId: INSTANCE_ID,
1374 olds: props,
1375 output: stubbedOutput,
1376 bindings: [] as never,
1377 session: {
1378 emit: () => Effect.void,
1379 done: () => Effect.void,
1380 note: () => Effect.void,
1381 },
1382 force: options?.force,
1383 });
1384 }).pipe(
1385 Effect.provide(Cloudflare.R2.BucketProvider()),
1386 Effect.provide(stubbedEnv(transport.layer)),
1387 );
1388 return transport.calls;
1389 });
1390
1391describe(
1392 "destructive delete requires explicit opt-in",
1393 {
1394 tags: [
1395 "unit",
1396 "provider:cloudflare",
1397 "provider:cloudflare:auth",
1398 "provider:cloudflare:r2",
1399 "local",
1400 ],
1401 },
1402 () => {
1403 it.effect("no forceDestroy never empties the bucket", () =>
1404 Effect.gen(function* () {
1405 const calls = yield* recordDelete({});
1406
1407 expect(objectDeletes(calls)).toEqual([]);
1408 // The bucket delete itself is still attempted — R2 answers 409
1409 // "is not empty" (`BucketNotEmpty`), which is the protection.
1410 expect(bucketDeletes(calls)).toHaveLength(1);
1411 }),
1412 );
1413
1414 it.effect("forceDestroy empties the bucket first", () =>
1415 Effect.gen(function* () {
1416 const calls = yield* recordDelete({ forceDestroy: true });
1417
1418 expect(objectDeletes(calls).length).toBeGreaterThan(0);
1419 expect(bucketDeletes(calls)).toHaveLength(1);
1420 }),
1421 );
1422
1423 // Nuke enumerates buckets from the cloud, so `olds` carries Attributes and
1424 // never has `forceDestroy` — the operator's confirmation IS the flag.
1425 it.effect("nuke's force empties without the prop", () =>
1426 Effect.gen(function* () {
1427 const calls = yield* recordDelete({}, { force: true });
1428
1429 expect(objectDeletes(calls).length).toBeGreaterThan(0);
1430 }),
1431 );
1432 },
1433);