Skip to content

Commit 31303e5

Browse files
committed
Improve upload resilience and add Supabase keepalive workflow
1 parent 7301691 commit 31303e5

7 files changed

Lines changed: 182 additions & 60 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: Supabase Keep Alive
2+
3+
on:
4+
schedule:
5+
- cron: "*/30 * * * *"
6+
workflow_dispatch:
7+
8+
jobs:
9+
ping-supabase:
10+
runs-on: ubuntu-latest
11+
timeout-minutes: 5
12+
13+
steps:
14+
- name: Validate Supabase secrets
15+
env:
16+
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
17+
SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
18+
run: |
19+
if [ -z "$SUPABASE_URL" ] || [ -z "$SUPABASE_ANON_KEY" ]; then
20+
echo "Missing SUPABASE_URL or SUPABASE_ANON_KEY GitHub secrets."
21+
exit 1
22+
fi
23+
24+
- name: Ping Supabase endpoint
25+
env:
26+
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
27+
SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
28+
run: |
29+
HEALTH_URL="${SUPABASE_URL%/}/rest/v1/"
30+
for attempt in 1 2 3 4 5; do
31+
code=$(curl --silent --show-error --output /tmp/supabase_ping.txt --write-out "%{http_code}" \
32+
-H "apikey: $SUPABASE_ANON_KEY" \
33+
-H "Authorization: Bearer $SUPABASE_ANON_KEY" \
34+
-H "Accept: application/json" \
35+
"$HEALTH_URL")
36+
37+
if [ "$code" -ge 200 ] && [ "$code" -lt 500 ] && [ "$code" != "000" ]; then
38+
echo "Supabase is reachable (HTTP $code) on attempt $attempt."
39+
exit 0
40+
fi
41+
42+
echo "Attempt $attempt failed with HTTP $code. Retrying in 20 seconds..."
43+
sleep 20
44+
done
45+
46+
echo "Supabase did not respond after retries."
47+
cat /tmp/supabase_ping.txt || true
48+
exit 1

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ A modern, centralized e-learning repository built for the Notes . This platform
5151
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your_project_id.appspot.com
5252
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_sender_id
5353
NEXT_PUBLIC_FIREBASE_APP_ID=your_app_id
54+
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
55+
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
5456
```
5557

5658
4. **Run Development Server**
@@ -59,6 +61,13 @@ A modern, centralized e-learning repository built for the Notes . This platform
5961
```
6062
Visit [http://localhost:3000](http://localhost:3000) to see the app.
6163

64+
### Supabase Keep-Alive (GitHub Actions)
65+
66+
If your Supabase free project sleeps during low traffic, configure these repository secrets to keep it warm via `.github/workflows/supabase-keepalive.yml`:
67+
68+
- `SUPABASE_URL` (example: `https://your-project-ref.supabase.co`)
69+
- `SUPABASE_ANON_KEY` (anon key from Supabase API settings)
70+
6271
## 📂 Project Structure
6372

6473
```

src/app/globals.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ button {
130130
border: 1px solid var(--border);
131131
}
132132

133-
/* Animations */
133+
134134
@keyframes fadeInUp {
135135
from {
136136
opacity: 0;
@@ -198,7 +198,7 @@ button {
198198
.animate-fade-in {
199199
animation: fadeInUp 0.8s ease-out forwards;
200200
opacity: 0;
201-
/* Star hidden */
201+
202202
}
203203

204204
.delay-100 {

src/app/layout.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ export const viewport: Viewport = {
2828
themeColor: "#2563eb",
2929
};
3030

31-
import DisableDevTools from "@/components/common/DisableDevTools";
3231
import { UndoProvider } from "@/context/UndoContext";
3332
import { ThemeProvider } from "@/context/ThemeContext";
3433
import { ToastProvider } from "@/context/ToastContext";
@@ -64,7 +63,6 @@ export default function RootLayout({
6463
<AmpAutoAds type="adsense"
6564
data-ad-client="ca-pub-6253589071371136">
6665
</AmpAutoAds>
67-
<DisableDevTools />
6866
<ThemeProvider>
6967
<UndoProvider>
7068
<ToastProvider>

src/components/dashboard/MyNotes.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ import FilePreviewModal from "@/components/common/FilePreviewModal";
3838
import MoveItemsModal from "./MoveItemsModal";
3939
import ConfirmationModal from "@/components/common/ConfirmationModal";
4040
import { createFolder } from "@/lib/firebase/firestore";
41-
import { uploadFile } from "@/lib/supabase/storage";
41+
import { uploadFile as uploadToSupabase } from "@/lib/supabase/storage";
42+
import { uploadFile as uploadToFirebase } from "@/lib/firebase/storage";
4243
import { FolderPlus, RefreshCw, Upload } from "lucide-react";
4344
import CreateFolderModal from "./CreateFolderModal";
4445

@@ -164,7 +165,16 @@ export default function MyNotes() {
164165
try {
165166
const uniqueId = Math.random().toString(36).substring(2, 10);
166167
const path = `uploads/${user?.uid}/${Date.now()}_${uniqueId}_${file.name}`;
167-
const url = await uploadFile(file, path);
168+
169+
let url: string;
170+
try {
171+
url = await uploadToSupabase(file, path);
172+
} catch (supabaseError: unknown) {
173+
const fallbackReason =
174+
supabaseError instanceof Error ? supabaseError.message : "Unknown error";
175+
console.warn("Supabase replace upload failed, falling back to Firebase:", fallbackReason);
176+
url = await uploadToFirebase(file, path);
177+
}
168178

169179
await updateNote(replacingNoteId, {
170180
fileUrl: url,

src/lib/config.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
// Configuration constants for the application
21

32
export const CONFIG = {
43
// Authentication

src/lib/supabase/storage.ts

Lines changed: 111 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,127 @@
11
import { supabase } from './client';
22

3-
export const uploadFile = async (file: File, path: string, retries = 3) => {
4-
let lastError: any;
5-
6-
for (let attempt = 0; attempt < retries; attempt++) {
7-
try {
8-
// 1. Upload file to 'files' bucket (ensure this bucket exists in Supabase or change name)
9-
const { data, error } = await supabase.storage
3+
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
4+
5+
const isDuplicateError = (message: string) => {
6+
const lower = message.toLowerCase();
7+
return lower.includes('already exists') || lower.includes('duplicate');
8+
};
9+
10+
const isTransientUploadError = (error: unknown) => {
11+
const status =
12+
typeof error === 'object' && error !== null
13+
? (error as { status?: number; statusCode?: number }).status ??
14+
(error as { statusCode?: number }).statusCode
15+
: undefined;
16+
17+
if (typeof status === 'number' && [408, 425, 429, 500, 502, 503, 504].includes(status)) {
18+
return true;
19+
}
20+
21+
const message =
22+
typeof error === 'object' && error !== null && 'message' in error
23+
? String((error as { message?: string }).message ?? '').toLowerCase()
24+
: String(error ?? '').toLowerCase();
25+
26+
return [
27+
'paused',
28+
'waking',
29+
'wake up',
30+
'temporarily unavailable',
31+
'gateway timeout',
32+
'network',
33+
'fetch failed',
34+
'timeout',
35+
'connection',
36+
].some((token) => message.includes(token));
37+
};
38+
39+
const wakeSupabaseProject = async () => {
40+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
41+
if (!supabaseUrl) return;
42+
43+
const healthUrl = `${supabaseUrl.replace(/\/$/, '')}/auth/v1/health`;
44+
45+
try {
46+
await fetch(healthUrl, { method: 'GET', cache: 'no-store' });
47+
} catch (error) {
48+
console.warn('Supabase wake ping failed:', error);
49+
}
50+
};
51+
52+
const attemptUpload = async (file: File, path: string) => {
53+
const { error } = await supabase.storage
54+
.from('files')
55+
.upload(path, file, {
56+
cacheControl: '3600',
57+
upsert: false,
58+
contentType: file.type || 'application/octet-stream',
59+
});
60+
61+
if (error) {
62+
if (isDuplicateError(error.message || '')) {
63+
const { error: upsertError } = await supabase.storage
1064
.from('files')
1165
.upload(path, file, {
1266
cacheControl: '3600',
13-
upsert: false,
67+
upsert: true,
1468
contentType: file.type || 'application/octet-stream',
1569
});
1670

17-
if (error) {
18-
console.error(`Supabase upload error (attempt ${attempt + 1}/${retries}):`, error);
19-
lastError = error;
20-
21-
// If it's a duplicate file error, try with upsert
22-
if (error.message?.includes('already exists') || error.message?.includes('duplicate')) {
23-
const { data: upsertData, error: upsertError } = await supabase.storage
24-
.from('files')
25-
.upload(path, file, {
26-
cacheControl: '3600',
27-
upsert: true,
28-
contentType: file.type || 'application/octet-stream',
29-
});
30-
31-
if (upsertError) {
32-
throw new Error(`Upload failed: ${upsertError.message}`);
33-
}
34-
35-
// Success with upsert
36-
const { data: publicUrlData } = supabase.storage
37-
.from('files')
38-
.getPublicUrl(path);
39-
return publicUrlData.publicUrl;
40-
}
41-
42-
// Retry on network errors
43-
if (attempt < retries - 1) {
44-
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));
45-
continue;
46-
}
47-
48-
throw new Error(`Upload failed: ${error.message || 'Unknown error'}`);
71+
if (upsertError) {
72+
throw new Error(`Upload failed: ${upsertError.message}`);
4973
}
74+
} else {
75+
throw error;
76+
}
77+
}
5078

51-
// 2. Get Public URL
52-
const { data: publicUrlData } = supabase.storage
53-
.from('files')
54-
.getPublicUrl(path);
55-
56-
return publicUrlData.publicUrl;
57-
} catch (err: any) {
58-
lastError = err;
59-
if (attempt < retries - 1) {
60-
console.log(`Retrying upload for ${path} (attempt ${attempt + 2}/${retries})`);
61-
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));
79+
const { data: publicUrlData } = supabase.storage
80+
.from('files')
81+
.getPublicUrl(path);
82+
83+
return publicUrlData.publicUrl;
84+
};
85+
86+
export const uploadFile = async (file: File, path: string, retries = 4) => {
87+
let lastError: unknown;
88+
89+
await wakeSupabaseProject();
90+
91+
for (let attempt = 0; attempt < retries; attempt++) {
92+
try {
93+
return await attemptUpload(file, path);
94+
} catch (error) {
95+
lastError = error;
96+
const shouldRetry = attempt < retries - 1;
97+
98+
if (!shouldRetry) {
99+
break;
62100
}
101+
102+
const transient = isTransientUploadError(error);
103+
const retryDelayMs = transient
104+
? Math.min(30000, 5000 * (attempt + 1))
105+
: 1000 * (attempt + 1);
106+
107+
if (transient) {
108+
await wakeSupabaseProject();
109+
}
110+
111+
console.warn(
112+
`Supabase upload retry ${attempt + 1}/${retries - 1} for ${path} in ${retryDelayMs}ms:`,
113+
error
114+
);
115+
await delay(retryDelayMs);
63116
}
64117
}
65-
66-
throw lastError || new Error('Upload failed after retries');
118+
119+
const finalMessage =
120+
typeof lastError === 'object' && lastError !== null && 'message' in lastError
121+
? String((lastError as { message?: string }).message ?? 'Unknown error')
122+
: 'Unknown error';
123+
124+
throw new Error(`Upload failed after retries: ${finalMessage}`);
67125
};
68126

69127
export const deleteFile = async (path: string) => {

0 commit comments

Comments
 (0)