1import { Action } from "@/Action";
2import * as Cloudflare from "@/Cloudflare";
3import * as Test from "@/Test/Alchemy";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7import * as Schedule from "effect/Schedule";
8
9const { test } = Test.make({ providers: Cloudflare.providers() });
10
11const logLevel = Effect.provideService(
12 MinimumLogLevel,
13 process.env.DEBUG ? "Debug" : "Info",
14);
15
16// R2 is eventually consistent — a freshly written object can read back `null`
17// for a moment. Bounded spaced retry so a real failure surfaces fast.
18const readBack = Schedule.max([
19 Schedule.spaced("1 second"),
20 Schedule.recurs(15),
21]);
22
23// Binding an R2 bucket inside an Action via `ReadWriteBucketLocal` — the local
24// (current-credentials) implementation of the `ReadWriteBucket` binding.
25// Exercises the client (put/get/list/delete) and the accessor mechanism
26// (`yield* bucket.bucketName` resolved at apply time).
27test.provider(
28 "ReadWriteBucketLocal: seed and read a bucket from an Action",
29 (stack) =>
30 Effect.gen(function* () {
31 yield* stack.destroy();
32
33 const out = yield* stack.deploy(
34 Effect.gen(function* () {
35 const bucket = yield* Cloudflare.R2.Bucket("SeedBucket", {
36 forceDestroy: true,
37 });
38
39 const Seed = Action(
40 "Seed",
41 Effect.gen(function* () {
42 const r2 = yield* Cloudflare.R2.ReadWriteBucket(bucket);
43 // Accessor — resolved at apply time against the tracker.
44 const bucketName = yield* bucket.bucketName;
45
46 return Effect.fn(function* () {
47 yield* r2.put("hello.txt", "Hello, World!").pipe(Effect.orDie);
48 yield* r2.put("greeting/hi.txt", "hi").pipe(Effect.orDie);
49
50 // Read back with bounded retry (eventual consistency).
51 const value = yield* r2.get("hello.txt").pipe(
52 Effect.flatMap((o) =>
53 o ? o.text() : Effect.succeed<string | null>(null),
54 ),
55 Effect.orDie,
56 Effect.repeat({
57 schedule: readBack,
58 until: (v) => v !== null,
59 }),
60 );
61
62 const listed = yield* r2.list().pipe(Effect.orDie);
63 const keys = listed.objects.map((o) => o.key).sort();
64
65 const head = yield* r2.head("hello.txt").pipe(Effect.orDie);
66
67 yield* r2.delete("hello.txt").pipe(Effect.orDie);
68 yield* r2.delete("greeting/hi.txt").pipe(Effect.orDie);
69
70 const afterDelete = yield* r2.head("hello.txt").pipe(
71 Effect.orDie,
72 Effect.repeat({
73 schedule: readBack,
74 until: (o) => o === null,
75 }),
76 );
77
78 return {
79 bucketName: yield* bucketName,
80 value,
81 keys,
82 headExists: head !== null,
83 deleted: afterDelete === null,
84 };
85 });
86 }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketLocal)),
87 );
88
89 return yield* Seed({});
90 }),
91 );
92
93 expect(out.bucketName).toBeTruthy();
94 expect(out.value).toBe("Hello, World!");
95 expect(out.keys).toEqual(["greeting/hi.txt", "hello.txt"]);
96 expect(out.headExists).toBe(true);
97 expect(out.deleted).toBe(true);
98
99 yield* stack.destroy();
100 }).pipe(logLevel),
101 {
102 tags: ["provider:cloudflare", "provider:cloudflare:r2", "live"],
103 timeout: 120_000,
104 },
105);