Move image storage from Postgres bytea to Supabase Storage#69
Move image storage from Postgres bytea to Supabase Storage#69ThatXliner wants to merge 11 commits intomainfrom
Conversation
Adds a storage-agnostic uploadImage/deleteImage API in packages/db/storage backed by Supabase Storage, so consumers never import Supabase directly. Adds imageUrl text column to Video schema (imageData kept temporarily for migration). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Scraper now uploads DALL-E images via @acme/db/storage and stores the public URL in Video.imageUrl. API serves the URL directly instead of base64-encoding blobs — eliminates the data URI overhead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Renames imageUri → imageUrl across frontend and social-media-agent. Adds Supabase Storage env vars to .env.example. Adds migration script to move existing bytea blobs to Supabase Storage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR introduces a DB-package storage abstraction for image uploads (backed by Supabase Storage) and transitions the Video model from storing binary image blobs to storing an imageUrl, updating upstream generators and downstream consumers accordingly.
Changes:
- Add
@acme/db/storagewithuploadImage/deleteImagebacked by Supabase Storage. - Extend the
Videoschema withimageUrl(keepingimageDatatemporarily) and add a one-off migration script to move blobs to object storage. - Update scraper generation, API payloads, and Expo/social-media-agent types/UI to use
imageUrlinstead ofimageUri.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| social-media-agent/src/agent.ts | Renames ContentItem.imageUri → imageUrl to match new API model. |
| pnpm-lock.yaml | Adds Supabase JS and related transitive deps to the lockfile. |
| packages/db/src/storage.ts | New storage abstraction for uploading/deleting images via Supabase Storage. |
| packages/db/src/schema.ts | Adds Video.imageUrl, repositions thumbnailUrl, and marks blob columns as deprecated. |
| packages/db/package.json | Exposes ./storage export and adds @supabase/supabase-js dependency. |
| packages/db/migrate-images-to-storage.ts | New script to migrate video.image_data blobs into storage and backfill image_url. |
| packages/api/src/router/video.ts | Switches video feed payload from imageUri (data URI) to imageUrl. |
| packages/api/src/router/content.ts | Updates content card schema from imageUri to imageUrl. |
| apps/scraper/src/utils/db/video-operations.ts | Generates JPEG, uploads to storage, and persists imageUrl on Video upsert. |
| apps/expo/src/app/(tabs)/index.tsx | Uses imageUrl when rendering thumbnails in browse list. |
| apps/expo/src/app/(tabs)/feed.tsx | Uses imageUrl for feed image rendering (fallback to thumbnailUrl). |
| .env.example | Documents Supabase Storage env vars needed for uploads. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| articlePreview: video.description, | ||
| imageUrl: video.imageUrl ?? undefined, | ||
| thumbnailUrl: video.thumbnailUrl ?? undefined, | ||
| originalContentId: video.contentId, |
There was a problem hiding this comment.
The feed response now only returns imageUrl; if there are existing video rows that still have imageData populated but haven’t been migrated to imageUrl yet (as implied by keeping imageData in the schema and adding a migration script), those posts will lose images in clients. Consider temporarily keeping the previous imageData → data-URI fallback (or gating this change behind completing the migration) to avoid a rollout regression.
|
|
||
| # Supabase Storage (for image uploads) | ||
| # Project URL: https://supabase.com/dashboard/project/_/settings/api | ||
| SUPABASE_URL=https://your-project-ref.supabase.co |
There was a problem hiding this comment.
SUPABASE_SERVICE_ROLE_KEY is a highly-privileged secret; since this file is a template that often gets copied into multiple environments, it would help to explicitly note that this key must only ever be used server-side (never in Expo/browser code) and should be kept out of any client-exposed env var mechanisms.
| SUPABASE_URL=https://your-project-ref.supabase.co | |
| SUPABASE_URL=https://your-project-ref.supabase.co | |
| # WARNING: `SUPABASE_SERVICE_ROLE_KEY` is highly privileged and must only be used server-side. | |
| # Never use it in Expo/browser code and never expose it via `EXPO_PUBLIC_*` or any other client-exposed env var mechanism. |
Code reviewFound 1 issue:
billion/apps/scraper/src/utils/db/video-operations.ts Lines 119 to 134 in 1c982f6 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Separate upload and DB update into distinct try/catch blocks so that a transient DB failure doesn't delete the image at the deterministic storage path, which may already be referenced by an existing imageUrl. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 17 changed files in this pull request and generated 6 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| import { db } from '@acme/db/client'; | ||
| import { Video } from '@acme/db/schema'; | ||
| import { uploadImage, deleteImage } from '@acme/db/storage'; |
There was a problem hiding this comment.
deleteImage is imported but never used in this module. This will either fail typecheck with noUnusedLocals or add dead code; remove the import or use it where appropriate.
| import { uploadImage, deleteImage } from '@acme/db/storage'; | |
| import { uploadImage } from '@acme/db/storage'; |
| import { createClient } from "@supabase/supabase-js"; | ||
|
|
||
| const BUCKET = process.env.SUPABASE_STORAGE_BUCKET ?? "images"; | ||
|
|
||
| function getClient() { | ||
| const url = process.env.SUPABASE_URL; | ||
| const key = process.env.SUPABASE_SERVICE_ROLE_KEY; | ||
| if (!url || !key) { | ||
| throw new Error( | ||
| "Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY for storage", | ||
| ); | ||
| } | ||
| return createClient(url, key); |
There was a problem hiding this comment.
SUPABASE_STORAGE_BUCKET is read once at module import time (const BUCKET = ...). In migrate-images-to-storage.ts, dotenv is loaded after importing this module, so a bucket value set in .env won’t be picked up and the script may upload to the default bucket unintentionally. Consider reading the bucket env var inside getClient()/uploadImage() (or exporting a getter) so it reflects the runtime environment.
| // Hybrid image storage: Binary AI-generated images OR URL-based scraped thumbnails | ||
| imageData: bytea("image_data"), // Raw JPEG bytes (AI-generated) | ||
| imageMimeType: t.varchar("image_mime_type", { length: 50 }), // "image/jpeg" | ||
| // Image storage: source thumbnail URL (scraped) |
There was a problem hiding this comment.
The comment says "Image storage: source thumbnail URL (scraped)", but this block now contains both imageUrl (uploaded AI image) and thumbnailUrl (scraped). Updating the comment to reflect both fields will prevent future confusion during migrations/cleanup.
| // Image storage: source thumbnail URL (scraped) | |
| // Image storage: uploaded image URL and source thumbnail URL (scraped) |
| try { | ||
| await db | ||
| .update(Video) | ||
| .set({ imageUrl }) |
There was a problem hiding this comment.
When updating the video row after a successful upload, it only sets imageUrl. If this row previously had legacy imageData/imageMimeType/imageWidth/imageHeight, those blobs will remain stored indefinitely (and the migration script won’t clear them because it only processes rows where image_url is NULL). Consider also nulling the legacy columns in this update (or expanding the migration script to clear blobs whenever image_url is non-null).
| .set({ imageUrl }) | |
| .set({ | |
| imageUrl, | |
| imageData: null, | |
| imageMimeType: null, | |
| imageWidth: null, | |
| imageHeight: null, | |
| }) |
SUPABASE_STORAGE_BUCKET was captured at import time, before the migration script dotenv.config() could populate it — uploads silently used the default bucket. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a new image URL was saved, the old imageData/imageMimeType/ imageWidth/imageHeight columns were left populated — wasting storage and preventing the migration script from skipping them. Also removes unused deleteImage import. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
uploadImage/deleteImageAPI inpackages/db/storagebacked by Supabase Storage, keeping Supabase as a db-package implementation detailVideo.imageUrlinstead of writing raw JPEG bytes to abyteacolumnimageUri→imageUrlacross frontend, API, and social-media-agentDeployment steps
images→ set to Publicpnpm db:pushto add theimage_urlcolumn to thevideotablebytea/imageDatafields frompackages/db/src/schema.tsand the custombyteatype if no longer usedTest plan
pnpm db:pushsucceeds with the newimage_urlcolumn--dry-runand confirm it finds existing blobsimageUrlpopulated (notimageData)🤖 Generated with Claude Code