- New: src/lib/storage.ts — S3-compatible client, uploadFile/deleteFile/listFiles/publicUrl helpers, bucket constants - New: docker-compose adds minio + minio_init services - Replaced Supabase fetch PUTs in: - src/actions/brand-settings.ts (3 uploaders) - src/actions/products/upload-image.ts - src/actions/communications/import-contacts.ts - src/app/api/water-photo-upload/route.ts - Replaced hardcoded Supabase URLs in: - src/components/storefront/TuxedoVideoHero.tsx - src/components/time-tracking/TimeTrackingFieldClient.tsx - src/app/tuxedo/about/page.tsx - src/lib/email-service.ts (4 occurrences) - Added @aws-sdk/client-s3 + @aws-sdk/s3-request-presigner - .env.example adds MinIO + storage vars - Migration cleanup: deleted BUNDLE_018_042, 4 XXX drafts, 087/145/099-contact storage migrations - Migration patches: 006 STATIC→STABLE, 135 param reordering - Preflight: added pgcrypto extension, removed storage stub - Verified: MinIO upload/list/delete round-trip works against local instance
15 KiB
Self-Hosted Storage + Schema Plan
Context
The user is migrating Route Commerce from Supabase to a self-hosted stack:
- Already done in this branch (
feat/better-auth): Better Auth (auth), Docker Postgres, PostgREST, env var rewiring, ~10 source files updated to drop Supabase JS client. - What's broken right now: The website is missing images (Supabase Storage URLs 404 because Supabase is going away), and the local Postgres can't apply the 137 migrations because the base schema is missing (the initial tables —
brands,orders,products,stops,admin_users— were created in Supabase directly and never exported as a migration). - User constraint: "no band-aids" — proper self-hosted replacement, not runtime patches.
- User constraint: "no data migration" — start with fresh empty DB; users re-upload assets.
Approach
Three independent workstreams, executed in order:
- Capture the base schema from the still-live Supabase project using
pg_dump --schema-only(recommended oversupabase db pull, which has a broken migration history). Apply the captured schema + existing 137 migrations to the local self-hosted Postgres. - Stand up MinIO in Docker as the S3-compatible object store. Wire it to the app via the AWS SDK. MinIO's URL structure is clean:
http://minio:9000/<bucket>/<key>, publicly readable per-bucket via bucket policy. - Replace every Supabase Storage URL in the codebase with MinIO URLs (one env var:
NEXT_PUBLIC_STORAGE_BASE_URL).
Storage buckets in use (from explore agent):
| Bucket | Purpose | Current state |
|---|---|---|
brand-logos |
Brand logo images | Hardcoded in 8+ files via env-var URL |
product-images |
Product photos | 80aa01da-ab4b-44f8-b6e7-700552457e18 (Supabase bucket UUID) |
contacts-imports |
CSV contact imports | a1b2c3d4-… (Supabase bucket UUID) |
videos |
Tuxedo video hero | Hardcoded Supabase URL in TuxedoVideoHero.tsx |
water-photos (dynamic) |
Water log photos | API route, bucket name in form field |
Implementation
Phase A — Capture base schema and apply to local Postgres
Why pg_dump, not supabase db pull: The remote Supabase project's supabase_migrations.schema_migrations history is out of sync with the local files (that's the long error list the user got). supabase db pull requires that history to be in sync before it'll write a new initial migration. pg_dump reads the live catalog directly and ignores the migration history entirely.
Steps:
-
Capture the schema from the remote Supabase DB (run on the user's Mac where direct PG works — MEMORY.md confirms direct PG is blocked from this dev box, but their Mac can do it):
pg_dump "postgresql://postgres:<service-role-pw>@db.wnzkhezyhnfzhkhiflrp.supabase.co:5432/postgres" \ --schema-only --no-owner --no-privileges \ --schema=public \ -f supabase/captured_schema.sqlExclude
authandstorageschemas — we stubauthourselves and use MinIO instead of Supabase Storage. Note: 185 SECURITY DEFINER functions referenceauth.uid(); these will compile against the preflight stub but return NULL at runtime. Phase D addresses this. -
Delete files that don't belong in the local apply order:
BUNDLE_018_042.sql— concatenated duplicateXXX_blog_tables.sql,XXX_launch_checklist.sql,XXX_roadmap_tables.sql,XXX_waitlist_waitlist.sql— drafts (XXX convention)099_contact_imports_bucket.sql(the bucket-creation one — keep the RPCs though)- Rename to disambiguate any number collisions (no current collision, but defensive)
-
Patch the 4 broken migrations identified by the explore agent (read the file first, then
search_replace):006_water_log_rpcs_fixed.sql: replaceSTATICwithSTABLE(6 occurrences)087_brand_logos_bucket.sql: convertCREATE POLICY IF NOT EXISTS …toDROP POLICY IF EXISTS …; CREATE POLICY …;(4 policies)099_harvest_reach_segmentation.sql: quote thetimecolumn references (matches the 148 patch)135_email_automation_rpcs.sql: reorderenroll_abandoned_cartparams so defaults come last, or add a default top_next_email_at
-
Update
000_preflight_supabase_compat.sql(already in the branch):- Add
CREATE EXTENSION IF NOT EXISTS pgcrypto;at the top - Remove the
storage.buckets/storage.objectsstub (MinIO replaces it; 087/145 storage policies will be deleted in step 2)
- Add
-
Apply to local Postgres (running on this dev box, port 5432):
PGPASSWORD='lay7yqM3fHNvwpfjb4tvz7M3kimopyrSHQF24vsuGUM' \ psql -h 127.0.0.1 -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -f supabase/migrations/000_preflight_supabase_compat.sql PGPASSWORD='lay7yqM3fHNvwpfjb4tvz7M3kimopyrSHQF24vsuGUM' \ psql -h 127.0.0.1 -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -f supabase/captured_schema.sql PGPASSWORD='lay7yqM3fHNvwpfjb4tvz7M3kimopyrSHQF24vsuGUM' \ for f in supabase/migrations/[0-9]*.sql; do psql -h 127.0.0.1 -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -q -f "$f" || break doneExpected: most migrations apply, some fail (drop those, log in plan). This is the test that the local Postgres is actually usable.
Phase B — MinIO for object storage
Add to docker-compose.yml (new minio service + minio_init one-shot to create buckets via mc):
minio:
image: minio/minio:latest
container_name: route_commerce_minio
restart: unless-stopped
env_file: [.env]
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
ports: ["127.0.0.1:9000:9000", "127.0.0.1:9001:9001"]
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 5
minio_init:
image: minio/mc:latest
depends_on:
minio: { condition: service_healthy }
env_file: [.env]
entrypoint: ["/bin/sh", "-c"]
command:
- |
mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}
for b in brand-logos product-images contacts-imports videos water-photos; do
mc mb --ignore-existing local/$${b}
mc anonymous set download local/$${b}
done
profiles: ["init"]
volumes:
minio_data: { driver: local }
Add to .env.example:
MINIO_ROOT_USER=routecommerce
MINIO_ROOT_PASSWORD=change-me-minio-root
NEXT_PUBLIC_STORAGE_BASE_URL=http://localhost:9000
STORAGE_ENDPOINT=http://localhost:9000
STORAGE_REGION=us-east-1
STORAGE_ACCESS_KEY=routecommerce
STORAGE_SECRET_KEY=change-me-minio-root
STORAGE_BUCKET_PREFIX=
Install AWS SDK (MinIO is S3-compatible):
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
Phase C — Replace Supabase Storage calls with MinIO
New server-side helper src/lib/storage.ts:
s3Clientconfigured from env (uses@aws-sdk/client-s3)uploadFile({ bucket, key, body, contentType })— replacesfetch(PUT .../storage/v1/object/{bucket}/{key})patternsdeleteFile({ bucket, key })publicUrl(bucket, key)— returns${STORAGE_BASE_URL}/${bucket}/${key}- Bucket name constants exported as a single source of truth
Files to modify (from explore agent):
src/actions/brand-settings.ts— 8fetchPUTs to/storage/v1/object/brand-logos/...src/actions/products/upload-image.ts— 3 calls toproduct-imagesbucketsrc/actions/communications/import-contacts.ts—contacts-importsbucket (PUT + LIST + RPC)src/app/api/water-photo-upload/route.ts— dynamic bucketsrc/components/storefront/TuxedoVideoHero.tsx— hardcodedhttps://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/...for videos and brand-logossrc/components/time-tracking/TimeTrackingFieldClient.tsx— same hardcoded URLsrc/lib/email-service.ts— 4 occurrences ofwnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/...src/app/tuxedo/about/page.tsx— same hardcoded URL
For the hardcoded Supabase URLs: replace with publicUrl(bucket, key) or ${process.env.NEXT_PUBLIC_STORAGE_BASE_URL}/${bucket}/${key}.
Remove:
- The
MockStorageBuilderinsrc/lib/supabase.ts:193-223(no longer needed) SUPABASE_PATenv var from.env.example(only used by water-photo-upload)
Phase D — Auth wiring (closes the loop)
The 185 auth.uid() references in the captured schema will return NULL without intervention. Two options:
- Option 1 (recommended, matches existing pattern): Disable RLS on all public tables, rely on the SECURITY DEFINER RPCs (which the codebase already does, per CLAUDE.md). One-time SQL after
captured_schema.sqlapplies:DO $$ DECLARE r record; BEGIN FOR r IN SELECT tablename FROM pg_tables WHERE schemaname='public' LOOP EXECUTE 'ALTER TABLE public.' || quote_ident(r.tablename) || ' DISABLE ROW LEVEL SECURITY'; END LOOP; END $$; - Option 2: Wire PostgREST to set
request.jwt.claim.subfrom the Better Auth session cookie. More complex; deferred unless RLS proves necessary.
Recommend Option 1 for now — it's consistent with the existing "brand scoping in server actions" pattern, and the SECURITY DEFINER functions still work because they execute with the function owner's privileges (no RLS blocking).
Phase E — Verification
End-to-end test sequence (no more "trust me, it works"):
-
DB schema applies cleanly:
docker compose up -d db minio PGPASSWORD=$POSTGRES_PASSWORD psql -h 127.0.0.1 -U routecommerce -d route_commerce -f 000_preflight.sql PGPASSWORD=$POSTGRES_PASSWORD psql -h 127.0.0.1 -U routecommerce -d route_commerce -f captured_schema.sql for f in supabase/migrations/[0-9]*.sql; do psql ... -f "$f" || echo "FAIL: $f"; doneGoal: all migrations apply (with the 4 patches from Phase A) OR the remaining failures are documented and skipped.
-
PostgREST serves the schema:
curl http://127.0.0.1:3001/brands?limit=1 -H "apikey: local-anon" curl http://127.0.0.1:3001/rpc/get_public_stops_for_brand -X POST -H "Content-Type: application/json" -d '{"p_slug":"indian-river-direct"}' -
MinIO buckets are public:
mc alias set local http://127.0.0.1:9000 $USER $PASS mc ls local/ curl -I http://127.0.0.1:9000/brand-logos/test.png # should return 200 or 404, never 403 -
App build passes:
DATABASE_URL=... NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:3001 \ NEXT_PUBLIC_STORAGE_BASE_URL=http://127.0.0.1:9000 \ BETTER_AUTH_SECRET=... npm run buildGoal:
✓ Compiled successfullywith no Supabase URL parse errors, noauth.uid()undefined, no missing bucket errors. -
Image display in the browser:
- Start the dev server:
npm run dev - Sign in via Better Auth (mock or real)
- Upload a product image via admin UI → verify it lands in MinIO (
mc ls local/product-images/) - Visit
/indian-river-direct/products/[slug]→ verify the image renders with the MinIO URL
- Start the dev server:
-
Auth round-trip:
POST /api/auth/sign-up/emailwith test email/password → expect 200, user created inbetter_auth_user(or whatever Better Auth's table is — check200_better_auth_tables.sql)POST /api/auth/sign-in/email→ expect session cookie- Hit
/adminwith the cookie → expect 200, not redirect to/login
Files to modify (summary)
| File | Change |
|---|---|
docker-compose.yml |
Add minio + minio_init services, minio_data volume |
.env.example |
Add MinIO + storage env vars, remove SUPABASE_PAT |
package.json |
Add @aws-sdk/client-s3, @aws-sdk/s3-request-presigner |
supabase/migrations/000_preflight_supabase_compat.sql |
Add pgcrypto extension, remove storage stub |
supabase/migrations/006_water_log_rpcs_fixed.sql |
STATIC → STABLE |
supabase/migrations/087_brand_logos_bucket.sql |
CREATE POLICY IF NOT EXISTS → DROP + CREATE |
supabase/migrations/099_harvest_reach_segmentation.sql |
Quote time column refs |
supabase/migrations/135_email_automation_rpcs.sql |
Reorder enroll_abandoned_cart params |
supabase/captured_schema.sql |
NEW — output of pg_dump from remote |
src/lib/storage.ts |
NEW — S3 client, upload/delete/publicUrl helpers, bucket constants |
src/actions/brand-settings.ts |
Replace 8 Supabase fetch PUTs with uploadFile |
src/actions/products/upload-image.ts |
Replace 3 calls with uploadFile |
src/actions/communications/import-contacts.ts |
Replace PUT + LIST with uploadFile / S3 ListObjectsV2 |
src/app/api/water-photo-upload/route.ts |
Replace Supabase call with uploadFile |
src/components/storefront/TuxedoVideoHero.tsx |
Replace hardcoded Supabase URL with publicUrl() |
src/components/time-tracking/TimeTrackingFieldClient.tsx |
Same |
src/lib/email-service.ts |
Same (4 occurrences) |
src/app/tuxedo/about/page.tsx |
Same |
src/lib/supabase.ts |
Remove MockStorageBuilder (lines 193-223) |
Reuse from existing code
src/lib/supabase.ts— thesupabaseJS client still exports query builders used bysrc/lib/svc-headers.tsand many server actions for non-auth RPCs. Keep it for now; it's PostgREST-compatible because both speak the PostgREST protocol.src/lib/auth.ts— Better Auth config, already wired in this branch. No changes needed.src/lib/admin-permissions.ts— already swapped to use Better Auth + directpg. No changes needed.src/lib/svc-headers.ts— used to build Supabase REST headers. Stays as-is (the anon/service-role strings still work against PostgREST).
Risks
- Migration apply order: even after patches, some migrations may reference tables that haven't been created yet due to deletion order. The captured
pg_dumpputs everything in dependency order, so applying it first should resolve most cross-references. - The 4 broken migrations may be load-bearing: 087 (brand-logos RLS) is already removed in the cleanup step. 006 (water log RPCs) is critical for the
/admin/water-logpages. 099 is critical for communications. 135 is critical for the email automation. If the patches don't work, the affected features will be broken — but the rest of the app should still build and run. - Existing user-uploaded images in Supabase will be unreachable: the user said "no data migration", so this is expected. Admins will re-upload logos/product images. The Tuxedo video and Olathe logos (referenced in
email-service.tsandtuxedo/about/page.tsx) are brand assets the user will need to copy over manually. - PostgREST connection pooling: PostgREST opens ~10 connections to Postgres. The 137 migrations +
pg_dumpschema may referenceauth.uid()inside SECURITY DEFINER functions, which will fail to plan if theauthschema is missing. The preflight stubs the function but thepg_dumpwill also try to create the same function (with the real Supabase body). Ifpg_dumpincludes a non-stubauth.uid()that conflicts with the preflight, applypg_dumpfirst, then the preflight.