Image archive storage — plan implementacji
Przegląd
Zarchiwizuj obrazki przepisów w Supabase Storage podczas ekstrakcji, żeby przeżyły usunięcie/CDN rotation oryginalnego źródła. Spełnia archive-first z PRD (Wizja: „trwała kopia… niezależna od oryginału”). Dziś recipes.image_url trzyma external URL (mecooks.pl/…); jutro może 404 albo zmienić path.
Analiza stanu obecnego
- Schema
recipes.image_url text(supabase/migrations/20260607000000_recipe_schema.sql:39) — string URL, brak rozdzielenia external vs archived. - Inngest function (
src/inngest/functions.ts) extractujeogImagez Firecrawl metadata i zapisuje doimage_urlbezpośrednio. - URL-collision branch (z recipe-detail-improvements Phase 3) ma gap-fill: jeśli existing recipe ma
image_url = null, wypełnia. Ale jeśliimage_urlzawiera external URL, gap-fill nie ruszy — bo nie jest null. - Detail page używa
next/imagezsrc={typedRecipe.image_url}+remotePatterns: 'https://**'(PR #59 jako temporary unlock). - API delete (
src/app/api/recipes/delete/route.ts) usuwa tylko z DB, brak storage cleanup. - Supabase Storage włączony w
supabase/config.tomlale brak bucket’u. Trzeba stworzyć. - Decyzja: public bucket — oryginalne og:image są publiczne content, archiwizacja nie zmienia poziomu prywatności.
Pożądany stan końcowy
Po wdrożeniu:
- Każdy nowy ekstraktowany przepis ma
image_urlw formiehttps://<project>.supabase.co/storage/v1/object/public/recipe-images/<user_id>/<recipe_id>.<ext>(jeśli download/upload się udał) lub external URL (fallback gdy nie). - Detail page renderuje obrazki przez
next/imagebez zmian w komponencie. - Usunięcie przepisu czyści również plik w Storage.
- Skrypt
scripts/archive-recipe-images.tswypełnia istniejący przepis (Tortilla z patelni) zarchiwizowanym URL przez Inngest gap-fill.
Kluczowe odkrycia
image_urljest pełnym URL, nie ścieżką. Strategia: zachowujemy całehttps://...supabase.co/...w polu — zero zmian w detail page.- Inngest function
src/inngest/functions.ts:117na insert używaogImage ?? null— punkt do wpięcia archiwizacji. - URL-collision branch
src/inngest/functions.ts:147-180(po Phase 3 recipe-detail-improvements) wypełnia tylko nulle. Backfill wymaga rozszerzenia: również nadpisuj external URL gdy archiwizacja się udała. - Lesson centralize env reads —
recipe-image-archive.tsjako shared helper, używany z dwóch miejsc Inngest function (insert + gap-fill).
Czego NIE robimy
- Image resizing / WebP conversion (Next/Image robi to runtime — wystarczy).
- Lifecycle policies / TTL na Storage (nic nie wygasa).
- CDN headers tuning (Supabase Storage ma domyślny cache-control).
- Multi-image / gallery (osobny scope).
- Cron cleanup orphans (delete cleanup w Phase 1 wystarczy; orphans z race conditions akceptujemy).
- Migration column
image_url→image_storage_path(full URL stored — zero schema change wrecipestable). - Wsparcie SVG / video (niepotrzebne dla og:image typowo).
Podejście do implementacji
Dwie fazy:
- Foundation + cleanup: stworzenie bucketu w migracji, helper
recipe-image-archive.ts, wpięcie w Inngest insert + gap-fill flow, storage cleanup w API delete. Nowe shares zaczynają archiwizować. - Backfill: skrypt + rozszerzenie gap-fill semantics żeby nadpisał external URL gdy archiwizacja się powiedzie. Istniejące przepisy podciągnięte.
Archive helper jest pure: download bytes (z timeout) → sniff Content-Type → walidacja MIME/size → upload do Storage → zwróć public URL lub null (fallback). Inngest function woła helper i interpretuje wynik.
Faza 1: Foundation + cleanup
Przegląd
Storage bucket setup, archive helper, integracja w Inngest function (insert flow + URL-collision gap-fill flow), cleanup w API delete.
Wymagane zmiany
1. Migration: bucket + RLS policy
Plik: supabase/migrations/<TIMESTAMP>_image_archive_storage.sql (nowy — użyj date -u +%Y%m%d%H%M%S)
Cel: Stworzyć bucket recipe-images (public, MIME whitelist jpeg/png/webp, 5MB limit) i RLS policy dla service role + public read.
Kontrakt:
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) values ('recipe-images', 'recipe-images', true, 5242880, array['image/jpeg', 'image/png', 'image/webp'])- Policy
recipe_images_public_readnastorage.objectsdla SELECT nabucket_id = 'recipe-images'(all users, since bucket is public). - Policy
recipe_images_service_writenastorage.objectsdla INSERT/UPDATE/DELETE — service role only (Inngest function używa service role). - Idempotentność:
on conflict (id) do nothingna bucket insert.
2. Archive helper
Plik: src/lib/recipe-image-archive.ts (nowy)
Cel: Pure function archiveImage(supabase, userId, recipeId, externalUrl) → Promise<string | null>. Download bytes z externalUrl (timeout 15s), check Content-Type i size, upload do recipe-images/<userId>/<recipeId>.<ext>, zwróć public URL. Zwróć null jeśli cokolwiek się nie powiodło (caller fallback).
Kontrakt:
- Signature:
archiveImage(supabase: SupabaseServiceClient, userId: string, recipeId: number, externalUrl: string): Promise<string | null> - Allowed MIME:
image/jpeg,image/png,image/webp(set jako const) - Max bytes:
5 * 1024 * 1024 - Extension mapping: jpeg →
.jpg, png →.png, webp →.webp(dla URL aesthetics) - Upload path:
${userId}/${recipeId}.${ext}z opcjąupsert: true(idempotentne re-runs) - Public URL z
supabase.storage.from('recipe-images').getPublicUrl(path). - Wszystkie błędy łapie i loguje
console.warn, zwracanull— fallback do external URL po stronie callera. - Pułapka:
fetchw Workers wymagaAbortSignal.timeout()żeby nie wisiał; download size check na podstawieContent-Lengthheader BEFORE reading body (oszczędność).
3. Wpięcie archive w Inngest insert flow
Plik: src/inngest/functions.ts
Cel: W insert flow, po udanym INSERT do recipes, jeśli ogImage nie jest null, wywołaj archiveImage(supabase, userId, recipe.id, ogImage). Jeśli zwróci storage URL, UPDATE recipes.image_url z tym URL. Jeśli null, zostaw external URL z insertu (już zapisany).
Kontrakt: Importuj archiveImage z @/lib/recipe-image-archive. Po if (data) { recipe = data; break }, dodaj archive step. Wymaga recipe.id z insertu — select('id') już istnieje. UPDATE: await supabase.from('recipes').update({ image_url: archivedUrl }).eq('id', recipe.id).
4. Wpięcie archive w URL-collision gap-fill flow
Plik: src/inngest/functions.ts
Cel: W gap-fill flow (error?.message.includes(URL_KEY)), gdy existing.image_url jest null AND ogImage jest dostępny, archiwizuj zamiast użyć surowego ogImage. Wypełnij image_url zarchiwizowanym URL.
Kontrakt: Modyfikuj sekcję if (existing.image_url == null && ogImage != null) { gapFill.image_url = ogImage } na:
if (existing.image_url == null && ogImage != null) { const archived = await archiveImage(supabase, userId, existing.id, ogImage) gapFill.image_url = archived ?? ogImage}5. Cleanup w API delete
Plik: src/app/api/recipes/delete/route.ts
Cel: Przed DELETE z recipes, jeśli image_url zaczyna się od <project>.supabase.co/storage/ (znaczy: zarchiwizowany), wyciągnij path i wywołaj storage.from('recipe-images').remove([path]). Logging na fail, ale nie blokuj delete.
Kontrakt: Po select id, image_url przed delete, helper extractStoragePath(image_url): string | null — regex/URL parsing do wyciągnięcia path. Jeśli zwróci path, await supabase.storage.from('recipe-images').remove([path]). Errors → console.warn.
Kryteria sukcesu
Weryfikacja automatyczna
- TypeScript przechodzi:
pnpm exec tsc --noEmit - Migration aplikuje się:
pnpm exec supabase db push - ESLint przechodzi na zmienionych plikach
- W Supabase dashboard widać bucket
recipe-images(public, 5MB limit)
Weryfikacja ręczna
- Udostępnij nowy URL przepisu z bloga zawierającego og:image (np. inny mecooks tortilla post)
- Po Inngest run, w
recipesrowimage_urlzaczyna się odhttps://wvrlgddgdddwmgzbvtij.supabase.co/storage/v1/object/public/recipe-images/<user_id>/ - Plik widoczny w Supabase Storage dashboard pod właściwą ścieżką
- Detail page nowego przepisu renderuje obrazek (próba kliknięcia w „Otwórz oryginał” → external URL nadal działa)
- Usuń przepis przez UI → plik znika ze Storage
- Test fallback: udostępnij URL bloga którego og:image jest 404 albo > 5MB →
image_urlpozostaje external (lub null jeśli og:image nie istnieje), detail page nadal renderuje
Uwaga implementacyjna: Po zakończeniu fazy i pomyślnym przejściu weryfikacji, zatrzymaj się na ręczne potwierdzenie.
Faza 2: Backfill istniejących przepisów
Przegląd
Skrypt CLI mirror wzorca refresh-recipe-times.ts, plus rozszerzenie gap-fill semantics żeby zastąpił external URL zarchiwizowanym (overwrite, nie gap-fill, gdy nowy URL jest storage URL).
Wymagane zmiany
1. Rozszerzenie gap-fill semantics dla image_url
Plik: src/inngest/functions.ts
Cel: Bieżący gap-fill wypełnia image_url tylko gdy null. Dla backfill istniejących external URLs, gdy archiwizacja się powiedzie, ZAWSZE nadpisz — niezależnie od tego co jest. Logika: jeśli archiveImage() zwróci storage URL (NOT null), nadpisz image_url.
Kontrakt: W gap-fill branch zmień:
if (ogImage != null) { const archived = await archiveImage(...) if (archived != null) { gapFill.image_url = archived // overwrite, even if existing has external URL } else if (existing.image_url == null) { gapFill.image_url = ogImage // fallback to external, only if had nothing }}Zachowuje semantykę „archive wins over external; external wins over null”.
2. Refresh script
Plik: scripts/archive-recipe-images.ts (nowy)
Cel: Mirror scripts/refresh-recipe-times.ts. Znajdź przepisy gdzie image_url nie jest zarchiwizowany (nie zaczyna się od <project>.supabase.co/storage), fire Inngest event dla każdego. Inngest gap-fill (po #1) zarchiwizuje.
Kontrakt:
- Import wzorca z
scripts/refresh-recipe-times.ts(env loading, supabase service client, inngest client) - Query:
select r.id, r.source_url, r.user_id, r.source_type, r.image_url, rs.id as share_id ... where r.image_url not ilike 'https://wvrlgddgdddwmgzbvtij.supabase.co/storage/%' or r.image_url is null - Skip jeśli
image_url is null— nie ma co archiwizować (chyba że re-extract znajdzie ogImage) - Dla każdego:
inngest.send({ name: 'recipe/extract', data: { shareId, sharedUrl, userId, sourceType } }) - Log każdy event
- Run via
pnpm exec node --env-file=.env.local --import tsx scripts/archive-recipe-images.ts
3. Package.json script
Plik: package.json
Cel: Dodaj "archive:recipe-images" script analogicznie do refresh:recipe-times.
Kontrakt: "archive:recipe-images": "node --env-file=.env.local --import tsx scripts/archive-recipe-images.ts".
Kryteria sukcesu
Weryfikacja automatyczna
- TypeScript przechodzi:
pnpm exec tsc --noEmit - ESLint przechodzi na zmienionych plikach
Weryfikacja ręczna
- Uruchom
pnpm archive:recipe-imagespo deployu Phase 1 — logi „Triggered archive for recipe N (URL …)” dla Tortilli - Inngest dashboard pokazuje run dla
recipe/extract - Po ~30-60s:
select id, image_url from recipes where id = <tortilla id>pokazuje URL zaczynający się odhttps://wvrlgddgdddwmgzbvtij.supabase.co/storage/ - Plik widoczny w Supabase Storage dashboard
- Detail page Tortilli nadal renderuje (z nowego URL)
Uwaga implementacyjna: Po zakończeniu fazy i pomyślnym przejściu weryfikacji, zatrzymaj się na ręczne potwierdzenie.
Strategia testowania
Testy jednostkowe
- Brak —
archiveImageto integration helper; testy E2E bardziej sensowne.
Testy integracyjne
- Brak nowych — istniejące Playwright add-recipe powinny nadal działać.
Kroki testowania ręcznego
- Po Phase 1: share nowy URL →
recipes.image_urlto storage URL; obrazek widoczny w bucket; detail page renderuje - Po Phase 1: delete recipe → plik znika ze Storage
- Po Phase 1: share URL bez og:image lub z og:image > 5MB → fallback do external URL działa
- Po Phase 2: skrypt podciąga Tortillę; image_url ma storage URL
Uwagi dotyczące wydajności
- Download (Firecrawl + obrazek) wydłuża Inngest run o ~1-3s na obrazek typowego rozmiaru (~500KB).
- Storage upload to ~500ms-1s typowo.
- Workers fetch ma limit ~100MB ciała — nasze 5MB limit jest bezpieczne.
- Inngest function timeout 45s już ustawiony — pozostaje budżet ~30s na archiwizację + DB ops.
Uwagi dotyczące migracji
- Bucket creation idempotentne via
on conflict do nothing - Rollback: drop bucket (manual via dashboard) + drop migration. Stracone zarchiwizowane obrazki — w MVP akceptowalne.
Referencje
- Schema:
supabase/migrations/20260607000000_recipe_schema.sql:39 - Inngest function:
src/inngest/functions.ts - Delete API:
src/app/api/recipes/delete/route.ts - Wzorzec backfill:
scripts/refresh-recipe-times.ts - Lesson o env validation:
context/foundation/lessons.md:64-72
Postęp
Konwencja:
- [ ]oczekujące,- [x]wykonane. Dodaj— <commit sha>przy realizacji.
Faza 1: Foundation + cleanup
Automatyczne
- 1.1 TypeScript przechodzi:
pnpm exec tsc --noEmit— 2fdd663 - 1.2 Migration aplikuje się:
pnpm exec supabase db push— 2fdd663 - 1.3 ESLint przechodzi na zmienionych plikach — 2fdd663
- 1.4 Bucket
recipe-imageswidoczny w Supabase dashboard (public, 5MB limit, MIME jpeg/png/webp) — 2fdd663
Ręczne
- 1.5 Nowy share przepisu →
image_urlzaczyna się od storage URL; plik w bucket — 2fdd663 - 1.6 Detail page nowego przepisu renderuje obrazek — 2fdd663
- 1.7 Delete recipe → plik znika ze Storage
- 1.8 Fallback: og:image > 5MB lub 404 →
image_urlpozostaje external (lub null), detail nadal działa
Faza 2: Backfill istniejących przepisów
Automatyczne
- 2.1 TypeScript przechodzi:
pnpm exec tsc --noEmit— fd5d876 - 2.2 ESLint przechodzi na zmienionych plikach — fd5d876
Ręczne
- 2.3
pnpm archive:recipe-imagesloguje wyzwolone eventy — fd5d876 - 2.4 Po Inngest run Tortilla z patelni ma storage URL w
image_url— fd5d876 - 2.5 Detail page Tortilli renderuje zarchiwizowany obrazek — fd5d876