This commit is contained in:
lovebird 2026-04-08 11:45:33 +02:00
parent 07d1341c76
commit 27bf61cea8
2 changed files with 215 additions and 14 deletions

View File

@ -0,0 +1,202 @@
# Supabase -> Internal VFS (Images) Trial Plan
## Goal
Move **new image uploads** from Supabase Storage to internal VFS mount configured by `IMAGE_VFS_STORE` (trial default: `images`), while keeping:
- existing `pictures` table workflow (`createPictureRecord` with meta)
- ability to read old Supabase URLs
- fast rollback to Supabase write path
This is a **write-path migration first**, not a full historical backfill cutover.
---
## Current State (verified)
- Frontend upload goes to `POST /api/images?forward=supabase&original=true` from `src/lib/uploadUtils.ts`.
- Server image endpoint handles `forward === 'supabase'` in `server/src/products/images/index.ts`.
- VFS mount already exists:
- mount: `images`
- path: `./storage/images`
- config: `server/config/vfs.json`
- VFS API exists for file reads/uploads under `server/src/products/storage/api/vfs.ts`.
---
## Constraints / Requirements
- Keep `pictures` insert behavior intact (title/meta/etc).
- New `image_url` should be VFS-readable URL (mount=`IMAGE_VFS_STORE`, path inside user folder).
- Rollback must be immediate and low risk.
- Trial should be controllable without broad code churn.
---
## URL Strategy (important)
Use a stable public URL shape for VFS-hosted images:
- `image_url = <IMAGE_VFS_URL>/api/vfs/get/<IMAGE_VFS_STORE>/<user_id>/<filename>`
Notes:
- This keeps `image_url` directly consumable by clients.
- It avoids exposing filesystem paths.
- It maps cleanly to VFS mount + subpath permissions.
- Existing Supabase URLs remain untouched for historical records.
- `token` query param is optional at request time for protected reads, but it is **not stored** in `pictures.image_url`.
`IMAGE_VFS_URL` is the canonical server base for these URLs.
---
## Phased Execution
## Phase 0 - Safety Rails (no behavior change)
1. Add env-driven upload target on server:
- `IMAGE_UPLOAD_TARGET=supabase|vfs` (default `supabase`)
- `IMAGE_VFS_STORE=images` (trial default mount name)
2. Keep query param override support:
- `forward` query still accepted (`supabase`/`vfs`)
- precedence: explicit query param -> env default
3. Add structured logs:
- chosen target, user id, filename/hash, bytes, duration, status
Rollback: N/A (no functional switch yet).
## Phase 1 - Add `forward=vfs` in image endpoint
In `server/src/products/images/index.ts` (`handlePostImage`):
1. Reuse already-produced `processedBuffer` and `filename`.
2. Implement `if (forward === 'vfs')` branch:
- Determine user id from auth context (same identity used today for inserts).
- Build relative VFS path: `<user_id>/<filename>`.
- Resolve mount from `IMAGE_VFS_STORE` (default `images`).
- Write bytes to resolved mount destination (for `images`: `./storage/images/<user_id>/<filename>`), either:
- through internal VFS write/upload helper (preferred), or
- direct fs write to resolved mount path if helper coupling is expensive.
3. Return JSON same shape as Supabase branch:
- `url`: `<IMAGE_VFS_URL>/api/vfs/get/<IMAGE_VFS_STORE>/<user_id>/<filename>`
- include width/height/format/size/meta as now.
Safety:
- Keep current Supabase branch unchanged.
- If VFS write fails and trial fallback is enabled, optionally retry Supabase write.
## Phase 2 - Frontend trial toggle (small change)
In `src/lib/uploadUtils.ts`:
1. Replace hardcoded `forward=supabase` with configurable target:
- `VITE_IMAGE_UPLOAD_FORWARD` (`supabase` default, `vfs` for trial users/environments)
2. Keep response handling unchanged (`{ url, meta }`).
3. Keep `createPictureRecord` unchanged except optional `type` marker (see below).
Recommended optional marker:
- set `type` to `vfs-image` or `supabase-image` for observability and easy SQL filtering.
## Phase 3 - Trial rollout
1. Enable `vfs` only in selected environment(s) or for selected users.
2. Monitor:
- upload success/failure rate
- read success for generated `image_url`
- latency vs Supabase path
- disk growth under `server/storage/images`
3. Validate ACL behavior:
- only expected users can access private paths (or keep public semantics intentionally).
## Phase 4 - Decide on default
If trial is healthy:
- change default upload target to `vfs`
- keep Supabase path available for emergency rollback for at least one release cycle
---
## Data Model / Record Semantics
No schema migration required for trial.
- Keep writing `pictures` row as-is.
- `image_url` stores either:
- old Supabase public URL (historical rows), or
- new VFS URL (`<IMAGE_VFS_URL>/api/vfs/get/<mount>/...`) for new rows.
Strongly recommended:
- include `storage_backend` in `meta` (`"supabase"` or `"vfs"`), or use `type` column consistently.
This enables mixed-backend rendering and easier operational debugging.
---
## Rollback Plan (must be instant)
Primary rollback switch:
1. Set `IMAGE_UPLOAD_TARGET=supabase` on server.
2. Keep `IMAGE_VFS_STORE=images` as-is (or any mount name; ignored while on supabase target).
3. Set `VITE_IMAGE_UPLOAD_FORWARD=supabase` on frontend (if used).
4. Redeploy/restart.
Behavior after rollback:
- New uploads go to Supabase again.
- Existing VFS-backed records continue to load via VFS read endpoint.
- No data loss, no record rewrite needed.
Emergency fallback option:
- In server `forward=vfs` branch, on write failure, fallback to Supabase upload and log fallback event.
---
## Test Plan
## Unit / integration
- `forward=supabase` still returns 200 and public Supabase URL.
- `forward=vfs` returns 200 and `<IMAGE_VFS_URL>/api/vfs/get/<IMAGE_VFS_STORE>/<uid>/<filename>`.
- VFS write failure path returns clear error (or controlled fallback).
- metadata extraction remains present in response for both paths.
## E2E manual
1. Upload image with `forward=vfs`.
2. Confirm file exists in `server/storage/images/<uid>/`.
3. Open returned `image_url`; verify content-type and caching headers.
- verify base URL works directly; for protected ACL mode, verify request-time `?token=...` also works.
4. Confirm `pictures.image_url` and `meta/type` are correct.
5. Switch env back to Supabase and repeat to verify rollback.
---
## Open Decisions
1. **Auth model for VFS read URL**
- public read (Supabase-like behavior) vs authenticated read
2. **Canonical URL**
- keep absolute via `IMAGE_VFS_URL` (recommended and now selected)
3. **Collision policy**
- current hash-based filename is deterministic; acceptable to overwrite same hash
4. **Backfill**
- not needed for trial, but define later if we want full Supabase deprecation
---
## Minimal Implementation Checklist
- [ ] Add `forward === 'vfs'` branch in image upload endpoint.
- [ ] Add server env defaults (`IMAGE_UPLOAD_TARGET`, `IMAGE_VFS_STORE='images'`).
- [ ] Add frontend env toggle (`VITE_IMAGE_UPLOAD_FORWARD`) instead of hardcoded `supabase`.
- [ ] Persist backend marker (`meta.storage_backend` and/or `type`).
- [ ] Add metrics/logging for backend choice and failures.
- [ ] Run trial in one environment/user cohort.
- [ ] Keep rollback toggles documented in runbook.

View File

@ -1,28 +1,27 @@
import { supabase } from '@/integrations/supabase/client';
import { getAuthToken, serverUrl } from './db';
const IMAGE_UPLOAD_FORWARD_PRESET = (import.meta.env.VITE_IMAGE_UPLOAD_FORWARD || 'vfs').toLowerCase() === 'supabase'
? 'supabase'
: 'vfs';
/**
* Uploads an image file via the server API (sharp processing + Supabase forwarding).
* Always goes through VITE_SERVER_IMAGE_API_URL/api/images.
* Uploads an image file via the server API.
* Call sites are storage-agnostic; this module enforces the internal upload target preset.
*/
export const uploadImage = async (file: File, userId: string): Promise<{ publicUrl: string, meta?: any }> => {
if (!userId) throw new Error('User ID is required for upload');
const SERVER_URL = import.meta.env.VITE_SERVER_IMAGE_API_URL;
if (!SERVER_URL) throw new Error('VITE_SERVER_IMAGE_API_URL is not configured');
// Get auth token for authenticated upload
const { data: { session } } = await supabase.auth.getSession();
console.log(`Uploading ${file.name} (${file.size} bytes) via server API...`);
if (!serverUrl) throw new Error('VITE_SERVER_IMAGE_API_URL is not configured');
const token = await getAuthToken();
const formData = new FormData();
formData.append('file', file);
const headers: Record<string, string> = {};
if (session?.access_token) {
headers['Authorization'] = `Bearer ${session.access_token}`;
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${SERVER_URL}/api/images?forward=supabase&original=true`, {
const response = await fetch(`${serverUrl}/api/images?forward=${IMAGE_UPLOAD_FORWARD_PRESET}&original=true`, {
method: 'POST',
headers,
body: formData,
@ -58,7 +57,7 @@ export const createPictureRecord = async (userId: string, file: File, publicUrl:
title: file.name.split('.')[0] || 'Uploaded Image',
description: null,
image_url: publicUrl,
type: 'supabase-image',
type: IMAGE_UPLOAD_FORWARD_PRESET === 'vfs' ? 'vfs-image' : 'supabase-image',
meta: meta || {},
})
.select()