sathvik77 commited on
Commit
beb3bd0
·
1 Parent(s): 0abe23e

feat(backend): add SupabaseStorage adapter (replaces R2 dependency)

Browse files

Cloudflare R2 requires a credit card to enable on the account, blocking
provisioning. Supabase Storage already exists in the same project and
satisfies the same interface (upload_file, delete, public_url). Trades
10GB R2 free tier for 1GB Supabase, which still covers v1 traffic and
just accelerates the planned LRU eviction work to v1.5.

R2Storage class kept in place for a future migration.

Files changed (2) hide show
  1. storage.py +29 -0
  2. tests/test_storage.py +46 -0
storage.py CHANGED
@@ -1,5 +1,34 @@
1
  from pathlib import Path
2
  import boto3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
 
5
  class R2Storage:
 
1
  from pathlib import Path
2
  import boto3
3
+ from supabase import Client
4
+
5
+
6
+ class SupabaseStorage:
7
+ """Storage adapter backed by Supabase Storage. Same interface as R2Storage."""
8
+
9
+ def __init__(self, *, client: Client, bucket: str, public_base_url: str):
10
+ self.bucket = bucket
11
+ self.public_base_url = public_base_url.rstrip("/")
12
+ self._client = client
13
+
14
+ def upload(self, data: bytes, key: str, *, content_type: str = "application/octet-stream") -> None:
15
+ self._client.storage.from_(self.bucket).upload(
16
+ path=key, file=data,
17
+ file_options={"content-type": content_type, "upsert": "true"},
18
+ )
19
+
20
+ def upload_file(self, path: Path, key: str, *, content_type: str = "audio/mpeg") -> None:
21
+ with open(path, "rb") as f:
22
+ self._client.storage.from_(self.bucket).upload(
23
+ path=key, file=f.read(),
24
+ file_options={"content-type": content_type, "upsert": "true"},
25
+ )
26
+
27
+ def delete(self, key: str) -> None:
28
+ self._client.storage.from_(self.bucket).remove([key])
29
+
30
+ def public_url(self, key: str) -> str:
31
+ return f"{self.public_base_url}/{key}"
32
 
33
 
34
  class R2Storage:
tests/test_storage.py CHANGED
@@ -48,3 +48,49 @@ def test_upload_file_streams_from_path(mock_boto, tmp_path):
48
  def test_public_url_composes_base_and_key():
49
  storage = _make_storage()
50
  assert storage.public_url("karaoke/abc.mp3") == "https://pub.example.com/karaoke/abc.mp3"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  def test_public_url_composes_base_and_key():
49
  storage = _make_storage()
50
  assert storage.public_url("karaoke/abc.mp3") == "https://pub.example.com/karaoke/abc.mp3"
51
+
52
+
53
+ # --- SupabaseStorage ---------------------------------------------------------
54
+
55
+ from storage import SupabaseStorage
56
+
57
+
58
+ def _make_supabase_storage():
59
+ client = MagicMock()
60
+ storage = SupabaseStorage(
61
+ client=client, bucket="karaoke",
62
+ public_base_url="https://x.supabase.co/storage/v1/object/public/karaoke",
63
+ )
64
+ return storage, client
65
+
66
+
67
+ def test_supabase_upload_calls_storage_from_upload():
68
+ storage, client = _make_supabase_storage()
69
+ storage.upload(b"data", "abc.mp3", content_type="audio/mpeg")
70
+ client.storage.from_.assert_called_with("karaoke")
71
+ client.storage.from_.return_value.upload.assert_called_once()
72
+ kwargs = client.storage.from_.return_value.upload.call_args.kwargs
73
+ assert kwargs["path"] == "abc.mp3"
74
+ assert kwargs["file"] == b"data"
75
+ assert kwargs["file_options"]["content-type"] == "audio/mpeg"
76
+
77
+
78
+ def test_supabase_upload_file_reads_path(tmp_path):
79
+ storage, client = _make_supabase_storage()
80
+ p = tmp_path / "x.mp3"
81
+ p.write_bytes(b"abc")
82
+ storage.upload_file(p, "abc.mp3")
83
+ client.storage.from_.return_value.upload.assert_called_once()
84
+ kwargs = client.storage.from_.return_value.upload.call_args.kwargs
85
+ assert kwargs["file"] == b"abc"
86
+
87
+
88
+ def test_supabase_public_url():
89
+ storage, _ = _make_supabase_storage()
90
+ assert storage.public_url("abc.mp3") == "https://x.supabase.co/storage/v1/object/public/karaoke/abc.mp3"
91
+
92
+
93
+ def test_supabase_delete_calls_remove():
94
+ storage, client = _make_supabase_storage()
95
+ storage.delete("abc.mp3")
96
+ client.storage.from_.return_value.remove.assert_called_once_with(["abc.mp3"])