diff --git a/docs/superpowers/plans/2026-06-05-selfhost-migration-plan.md b/docs/superpowers/plans/2026-06-05-selfhost-migration-plan.md new file mode 100644 index 0000000..d24f060 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-selfhost-migration-plan.md @@ -0,0 +1,1662 @@ +# Supabase → Self-Hosted Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move Route Commerce off Supabase's hosted platform onto a self-hosted stack (Postgres + PostgREST + MinIO + better-auth) while preserving all data and behavior. Consolidate the two in-flight branches into one coherent `selfhost/migrate` branch. + +**Architecture:** Merge `crispygoat/self-hosted-postgres` (infra: docker-compose, Gitea deploy, env) and `crispygoat/feat/better-auth` (app: better-auth + S3 SDK + admin-permissions rewrite). Capture full schema + data from live Supabase via `pg_dump`. Apply preflight + captured schema + 137 migrations + RLS disable to a local Postgres 16 container. Stand up MinIO + PostgREST alongside. Keep `supabase-js` client (it speaks PostgREST natively) — only swap the base URL. + +**Tech Stack:** Postgres 16 (Docker), PostgREST 12 (Docker), MinIO (Docker), better-auth (Node), Kysely + pg, AWS SDK for JavaScript v3, Next.js 16, Node 22. + +**Spec:** `docs/superpowers/specs/2026-06-05-selfhost-migration-design.md` + +**Working assumptions:** +- Working directory: `/home/coder/.grok/worktrees/kyle-route-commerce-main/2026-06-05-164dcb44` +- Supabase project ref: `wnzkhezyhnfzhkhiflrp` (per CLAUDE.md / MEMORY.md) +- Supabase DB password: in `.env.local` on the user's Mac (not committed); passed via env var to `pg_dump` +- Docker is installed locally; user can run `docker compose` +- Direct PG connection to Supabase works from the user's Mac (not from this dev box, per MEMORY.md) +- Local Postgres: `127.0.0.1:5432`; PostgREST: `127.0.0.1:3001`; MinIO API: `127.0.0.1:9000`; MinIO console: `127.0.0.1:9001` +- Local Postgres credentials: user `routecommerce`, password from local `.env` + +--- + +## Phase 0 — Branch setup and merge + +### Task 0.1: Create the migration branch + +**Files:** none (git only) + +- [ ] **Step 1: Fetch both source branches** + +```bash +cd /home/coder/.grok/worktrees/kyle-route-commerce-main/2026-06-05-164dcb44 +git fetch crispygoat +git fetch origin +``` + +Expected: `crispygoat/self-hosted-postgres` and `crispygoat/feat/better-auth` both updated. + +- [ ] **Step 2: Create and check out the new branch from main** + +```bash +git checkout -b selfhost/migrate main +``` + +Expected: `Switched to a new branch 'selfhost/migrate'`. + +- [ ] **Step 3: Verify branch state** + +```bash +git log --oneline -1 +``` + +Expected: `9374e63 docs(memory): record Codex review round 2 pass`. + +--- + +### Task 0.2: Cherry-pick the self-hosted-postgres branch + +**Files:** cherry-picked from `crispygoat/self-hosted-postgres` + +- [ ] **Step 1: Cherry-pick the 7 commits in chronological order** + +```bash +git cherry-pick 2de32b0 988310f 8ad8dbb fddb917 beac3e4 e8f2d76 367a562 +``` + +Expected: Either all commits apply cleanly, OR you see "CONFLICT" messages. + +- [ ] **Step 2: If conflicts appear, resolve them now** + +Expected conflicts: `.env.example` (will be merged with better-auth env in Task 0.3); `.gitea/workflows/deploy.yml` (updated in Task 0.5); `docker-compose.yml` (extended with PostgREST + MinIO in Task 0.4). + +For each conflict: +1. Open the file. +2. Resolve by taking the union of both sides, with section comments. +3. `git add `. +4. `git cherry-pick --continue`. + +- [ ] **Step 3: Verify all 7 commits applied** + +```bash +git log --oneline -8 +``` + +Expected: top 7 commits match the cherry-pick list, in order, with `9374e63` at position 8. + +- [ ] **Step 4: Verify docker-compose.yml exists and has the db service** + +```bash +grep -A2 "services:" docker-compose.yml +``` + +Expected: shows the `db:` service block (Postgres 16). + +--- + +### Task 0.3: Cherry-pick the feat/better-auth branch + +**Files:** cherry-picked from `crispygoat/feat/better-auth` + +- [ ] **Step 1: Cherry-pick the single commit** + +```bash +git cherry-pick 456b5b1 +``` + +Expected: Either applies cleanly, OR conflicts in `.env.example`, `docker-compose.yml`, or `.gitea/workflows/deploy.yml`. + +- [ ] **Step 2: Resolve conflicts if needed** + +Take the union of both sides. For `.env.example`, organize into sections: + +```bash +# ── Postgres (self-hosted) ── (from self-hosted-postgres) +# ── PostgREST ── (NEW — added in Task 0.4) +# ── better-auth ── (from feat/better-auth) +# ── MinIO ── (NEW — added in Task 0.4) +# ── Supabase env vars (legacy) ── (modified to point to local PostgREST) +``` + +For each conflict: open the file, resolve by union, `git add `, `git cherry-pick --continue`. + +- [ ] **Step 3: Verify the new files exist** + +```bash +ls -la src/lib/auth.ts src/lib/auth-client.ts src/lib/storage.ts \ + src/app/api/auth/\[...all\]/route.ts \ + supabase/migrations/000_preflight_supabase_compat.sql \ + supabase/migrations/200_better_auth_tables.sql +``` + +Expected: all 6 files exist. + +- [ ] **Step 4: Verify the deleted migrations are gone** + +```bash +ls supabase/migrations/BUNDLE_018_042.sql \ + supabase/migrations/087_brand_logos_bucket.sql \ + supabase/migrations/099_contact_imports_bucket.sql \ + supabase/migrations/145_create_product_images_bucket.sql 2>&1 +``` + +Expected: all 4 files report "No such file or directory". + +- [ ] **Step 5: Verify `src/lib/supabase/server.ts` is gone** + +```bash +ls src/lib/supabase/server.ts 2>&1 +``` + +Expected: "No such file or directory". + +--- + +### Task 0.4: Add PostgREST + MinIO services to docker-compose.yml + +**Files:** +- Modify: `docker-compose.yml` + +- [ ] **Step 1: Read current docker-compose.yml** + +```bash +cat docker-compose.yml +``` + +- [ ] **Step 2: Append the PostgREST + MinIO + minio_init services** + +Append to `docker-compose.yml` (before the existing `volumes:` section if present, else at end): + +```yaml + postgrest: + image: postgrest/postgrest:v12.2.3 + container_name: route_commerce_postgrest + restart: unless-stopped + env_file: [.env] + environment: + PGRST_SERVER_PORT: 3001 + PGRST_SERVER_HOST: 0.0.0.0 + PGRST_DB_URI: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + PGRST_DB_ANON_ROLE: anon + PGRST_DB_SCHEMAS: public + PGRST_OPENAPI_MODE: disabled + PGRST_JWT_SECRET: ${POSTGREST_JWT_SECRET:-dev-secret-not-for-prod} + ports: + - "127.0.0.1:3001:3001" + depends_on: + db: + condition: service_healthy + + 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 + start_period: 10s + + minio_init: + image: minio/mc:latest + container_name: route_commerce_minio_init + depends_on: + minio: + condition: service_healthy + env_file: [.env] + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + 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 + echo "MinIO buckets initialized" +``` + +- [ ] **Step 3: Add `minio_data` to the volumes section** + +Find the existing `volumes:` block and add `minio_data`: + +```yaml +volumes: + db_data: + driver: local + minio_data: + driver: local +``` + +- [ ] **Step 4: Verify the YAML parses** + +```bash +docker compose config --quiet +``` + +Expected: exit code 0, no output. + +- [ ] **Step 5: Commit** + +```bash +git add docker-compose.yml +git commit -m "feat(docker): add PostgREST, MinIO, and minio_init services" +``` + +--- + +### Task 0.5: Update Gitea deploy workflow to bring up Docker stack + +**Files:** +- Modify: `.gitea/workflows/deploy.yml` + +- [ ] **Step 1: Read current deploy.yml** + +```bash +cat .gitea/workflows/deploy.yml +``` + +- [ ] **Step 2: Add a "Start Docker stack" step before the existing "Deploy" step** + +Insert AFTER `npm run build` and BEFORE `Deploy`: + +```yaml + - name: Start Docker stack + env: + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }} + MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} + POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: | + cd /home/tyler/route-commerce + [ -f .env ] || cp .env.example .env + { + echo "POSTGRES_USER=${POSTGRES_USER}" + echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" + echo "POSTGRES_DB=${POSTGRES_DB}" + echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}" + echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}" + echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}" + echo "DATABASE_URL=${DATABASE_URL}" + } >> .env + docker compose up -d db postgrest minio minio_init + for i in $(seq 1 30); do + if docker compose exec -T db pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then + echo "Postgres is ready" + break + fi + sleep 2 + done +``` + +- [ ] **Step 3: Add a migration apply step** + +Insert AFTER `Start Docker stack` and BEFORE `Deploy`: + +```yaml + - name: Apply migrations + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + run: | + cd /home/tyler/route-commerce + PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/migrations/000_preflight_supabase_compat.sql || true + [ -f supabase/captured_schema.sql ] && PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/captured_schema.sql || echo "captured_schema.sql not present, skipping" + for f in supabase/migrations/[0-9]*.sql; do + PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -q -f "$f" || echo "FAIL: $f" + done +``` + +- [ ] **Step 4: Update the `Deploy` step's `env:` block** + +Add to the `Deploy` step's `env:` block (preserving the existing env entries): + +```yaml + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }} + MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} + POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} + BETTER_AUTH_URL: ${{ secrets.BETTER_AUTH_URL }} + NEXT_PUBLIC_BETTER_AUTH_URL: ${{ secrets.NEXT_PUBLIC_BETTER_AUTH_URL }} + STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }} + STORAGE_REGION: ${{ secrets.STORAGE_REGION }} + STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }} + STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }} + STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }} + NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }} + PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }} + PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }} + PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }} + PGRST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }} +``` + +- [ ] **Step 5: Update the `printf` block in the `Deploy` step** + +Replace the existing printf block with: + +```bash + { + printf "POSTGRES_USER=%s\n" "$POSTGRES_USER" + printf "POSTGRES_PASSWORD=%s\n" "$POSTGRES_PASSWORD" + printf "POSTGRES_DB=%s\n" "$POSTGRES_DB" + printf "MINIO_ROOT_USER=%s\n" "$MINIO_ROOT_USER" + printf "MINIO_ROOT_PASSWORD=%s\n" "$MINIO_ROOT_PASSWORD" + printf "POSTGREST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET" + printf "DATABASE_URL=%s\n" "$DATABASE_URL" + printf "BETTER_AUTH_SECRET=%s\n" "$BETTER_AUTH_SECRET" + printf "BETTER_AUTH_URL=%s\n" "$BETTER_AUTH_URL" + printf "NEXT_PUBLIC_BETTER_AUTH_URL=%s\n" "$NEXT_PUBLIC_BETTER_AUTH_URL" + printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT" + printf "STORAGE_REGION=%s\n" "$STORAGE_REGION" + printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY" + printf "STORAGE_SECRET_KEY=%s\n" "$STORAGE_SECRET_KEY" + printf "STORAGE_BUCKET_PREFIX=%s\n" "$STORAGE_BUCKET_PREFIX" + printf "NEXT_PUBLIC_STORAGE_BASE_URL=%s\n" "$NEXT_PUBLIC_STORAGE_BASE_URL" + printf "PGRST_SERVER_PORT=%s\n" "$PGRST_SERVER_PORT" + printf "PGRST_DB_URI=%s\n" "$PGRST_DB_URI" + printf "PGRST_DB_ANON_ROLE=%s\n" "$PGRST_DB_ANON_ROLE" + printf "PGRST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET" + printf "NEXT_PUBLIC_SUPABASE_URL=%s\n" "$NEXT_PUBLIC_SUPABASE_URL" + printf "NEXT_PUBLIC_SUPABASE_ANON_KEY=%s\n" "$NEXT_PUBLIC_SUPABASE_ANON_KEY" + printf "STRIPE_SECRET_KEY=%s\n" "$STRIPE_SECRET_KEY" + printf "MINIMAX_API_KEY=%s\n" "$MINIMAX_API_KEY" + printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL" + } > $APP_DIR/.env.production +``` + +- [ ] **Step 6: Validate YAML syntax** + +```bash +python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/deploy.yml'))" +``` + +Expected: exit code 0, no output. + +- [ ] **Step 7: Commit** + +```bash +git add .gitea/workflows/deploy.yml +git commit -m "feat(deploy): bring up Docker stack + apply migrations in Gitea workflow" +``` + +--- + +### Task 0.6: Fix useMockData to not trigger on non-Supabase URLs + +**Files:** +- Modify: `src/lib/supabase.ts:8` + +- [ ] **Step 1: Read the current line 8** + +```bash +sed -n '8p' src/lib/supabase.ts +``` + +Expected: +```ts +const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !supabaseUrl || !supabaseUrl.includes("supabase.co"); +``` + +- [ ] **Step 2: Replace the line** + +Replace: +```ts +const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !supabaseUrl || !supabaseUrl.includes("supabase.co"); +``` + +With: +```ts +const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !supabaseUrl; +``` + +Rationale: with local PostgREST, the URL is `http://localhost:3001`. The previous check treated non-Supabase URLs as a "no backend" signal and forced mock mode. + +- [ ] **Step 3: Verify the change** + +```bash +sed -n '8p' src/lib/supabase.ts +``` + +Expected: +```ts +const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !supabaseUrl; +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/supabase.ts +git commit -m "fix(supabase): don't force mock mode for non-Supabase URLs" +``` + +--- + +### Task 0.7: Update package.json with new dependencies + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Read current package.json** + +```bash +cat package.json +``` + +- [ ] **Step 2: Add dependencies (if not already added by the cherry-pick)** + +In the `dependencies` block, ensure these are present: + +```json + "@aws-sdk/client-s3": "^3.687.0", + "@aws-sdk/s3-request-presigner": "^3.687.0", + "better-auth": "^1.0.0", + "kysely": "^0.27.5", + "pg": "^8.13.1" +``` + +- [ ] **Step 3: Install** + +```bash +npm install +``` + +Expected: no errors. + +- [ ] **Step 4: Verify packages are installed** + +```bash +ls node_modules/better-auth node_modules/@aws-sdk/client-s3 node_modules/kysely node_modules/pg 2>&1 | head -10 +``` + +Expected: all 4 directories exist. + +- [ ] **Step 5: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore(deps): add better-auth, Kysely, pg, AWS SDK" +``` + +--- + +### Task 0.8: Verify the merged build + +**Files:** none (verification only) + +- [ ] **Step 1: Run type check** + +```bash +npx tsc --noEmit +``` + +Expected: exit code 0. If errors appear, fix inline (likely from feat/better-auth's `src/lib/admin-permissions.ts` rewrite). + +- [ ] **Step 2: Run lint** + +```bash +npm run lint +``` + +Expected: exit code 0. + +- [ ] **Step 3: Run build** + +```bash +npm run build +``` + +Expected: `✓ Compiled successfully`. + +- [ ] **Step 4: Commit any fixes** + +```bash +git add -A +git diff --cached --quiet || git commit -m "fix(build): address merge issues in selfhost/migrate branch" +``` + +--- + +## Phase A — Database schema capture and apply + +### Task A.1: Set up local Postgres via Docker + +**Files:** none (docker only) + +- [ ] **Step 1: Create local `.env` from `.env.example`** + +```bash +cp .env.example .env +``` + +- [ ] **Step 2: Generate strong passwords and write to `.env`** + +```bash +POSTGRES_PW=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32) +MINIO_PW=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32) +JWT_SECRET=$(openssl rand -base64 32 | tr -d '/+=' | head -c 48) +BETTER_AUTH_SECRET=$(openssl rand -base64 32 | tr -d '/+=' | head -c 48) +sed -i "s|change-me-strong-password|$POSTGRES_PW|g" .env +sed -i "s|change-me-minio-root|$MINIO_PW|g" .env +echo "POSTGREST_JWT_SECRET=$JWT_SECRET" >> .env +echo "BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET" >> .env +``` + +- [ ] **Step 3: Verify `.env` is updated** + +```bash +grep -E "^POSTGRES_PASSWORD|^MINIO_ROOT_PASSWORD|^POSTGREST_JWT_SECRET|^BETTER_AUTH_SECRET" .env +``` + +Expected: 4 non-empty lines, no `change-me-...` placeholders. + +- [ ] **Step 4: Start the db container** + +```bash +docker compose up -d db +``` + +Expected: `Container route_commerce_db ... Started`. + +- [ ] **Step 5: Wait for Postgres** + +```bash +for i in $(seq 1 30); do + if docker compose exec -T db pg_isready -U routecommerce -d route_commerce > /dev/null 2>&1; then + echo "Postgres is ready" + break + fi + sleep 2 +done +``` + +Expected: `Postgres is ready` within 60 seconds. + +- [ ] **Step 6: Verify connectivity** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT version();" +``` + +Expected: `PostgreSQL 16.x ...`. + +- [ ] **Step 7: Do NOT commit `.env`** (gitignored). + +--- + +### Task A.2: Capture schema + data from live Supabase + +**Files:** +- Create: `supabase/captured_schema.sql` (committed to repo) +- Create: `supabase/captured_data.sql` (committed to repo) + +**NOTE:** This step runs on the user's Mac (direct PG blocked from this dev box per MEMORY.md). + +- [ ] **Step 1: Get the Supabase DB password** + +Ask the user for the Supabase DB password (it should be in `.env.local` as `SUPABASE_DB_PASSWORD` or `SUPABASE_SERVICE_ROLE_KEY`). + +- [ ] **Step 2: Run pg_dump with retry (per spec's error handling)** + +```bash +SUPABASE_PW="" +run_pg_dump_schema() { + pg_dump "postgresql://postgres:${SUPABASE_PW}@db.wnzkhezyhnfzhkhiflrp.supabase.co:5432/postgres" \ + --schema-only --no-owner --no-privileges \ + --schema=public \ + -f /tmp/captured_schema.sql +} +run_pg_dump_data() { + pg_dump "postgresql://postgres:${SUPABASE_PW}@db.wnzkhezyhnfzhkhiflrp.supabase.co:5432/postgres" \ + --data-only --no-owner --no-privileges \ + --schema=public \ + -f /tmp/captured_data.sql +} + +if ! run_pg_dump_schema; then + echo "First schema dump failed; retrying once..." + sleep 5 + if ! run_pg_dump_schema; then + echo "Schema dump failed twice; aborting. Do NOT proceed with a partial dump." + exit 1 + fi +fi + +if ! run_pg_dump_data; then + echo "First data dump failed; retrying once..." + sleep 5 + if ! run_pg_dump_data; then + echo "Data dump failed twice; aborting." + exit 1 + fi +fi +``` + +Expected: two files created, ~5-20MB schema + variable data. + +- [ ] **Step 3: Verify the dumps are non-trivial** + +```bash +wc -l /tmp/captured_schema.sql /tmp/captured_data.sql +``` + +Expected: thousands of lines each. + +- [ ] **Step 4: Copy the dumps into the repo** + +```bash +cp /tmp/captured_schema.sql supabase/captured_schema.sql +cp /tmp/captured_data.sql supabase/captured_data.sql +ls -la supabase/captured_*.sql +``` + +Expected: both files exist, > 1MB. + +- [ ] **Step 5: Sanity-check the schema dump** + +```bash +grep -c "CREATE TABLE" supabase/captured_schema.sql +grep -c "CREATE FUNCTION" supabase/captured_schema.sql +``` + +Expected: many `CREATE TABLE` (~60) and `CREATE FUNCTION` (~185) statements. + +- [ ] **Step 6: Commit the dumps** + +```bash +git add supabase/captured_schema.sql supabase/captured_data.sql +git commit -m "chore(supabase): capture schema + data dump from live Supabase" +``` + +--- + +### Task A.3: Apply the preflight migration + +**Files:** none (uses `000_preflight_supabase_compat.sql` already in repo) + +- [ ] **Step 1: Apply 000_preflight_supabase_compat.sql** + +```bash +docker compose cp supabase/migrations/000_preflight_supabase_compat.sql db:/tmp/000_preflight.sql +docker compose exec -T db psql -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -f /tmp/000_preflight.sql +``` + +Expected: no errors; `CREATE EXTENSION`, `CREATE ROLE`, `CREATE SCHEMA`, `CREATE OR REPLACE FUNCTION auth.uid()` succeed. + +- [ ] **Step 2: Verify the preflight created the expected objects** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "\dn" +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT rolname FROM pg_roles WHERE rolname IN ('anon', 'authenticated', 'service_role');" +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT proname FROM pg_proc WHERE pronamespace = 'auth'::regnamespace;" +``` + +Expected: `auth` schema exists; 3 roles exist; `uid()`, `role()`, `email()` functions exist. + +- [ ] **Step 3: Verify routecommerce can act as the stub roles** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SET ROLE anon; SELECT current_user; RESET ROLE;" +``` + +Expected: `current_user` reports `anon`; `RESET ROLE` works. + +--- + +### Task A.4: Apply the captured schema to local Postgres + +**Files:** none + +- [ ] **Step 1: Apply the captured schema** + +```bash +docker compose cp supabase/captured_schema.sql db:/tmp/captured_schema.sql +docker compose exec -T db psql -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -f /tmp/captured_schema.sql 2>&1 | tee /tmp/captured_schema_apply.log +``` + +Expected: many `CREATE TABLE`, `CREATE FUNCTION` messages. Some may fail (e.g., `auth.uid()` redefinition — preflight stub gets replaced by the real Supabase body). That's OK if no critical errors block table creation. + +- [ ] **Step 2: Re-apply the preflight to restore the stub auth.uid()** + +```bash +docker compose cp supabase/migrations/000_preflight_supabase_compat.sql db:/tmp/000_preflight.sql +docker compose exec -T db psql -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -f /tmp/000_preflight.sql +``` + +Expected: `CREATE OR REPLACE FUNCTION` succeeds. + +- [ ] **Step 3: Verify tables exist** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "\dt" | wc -l +``` + +Expected: > 60 (60+ tables in public). + +- [ ] **Step 4: Verify SECURITY DEFINER functions exist** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) FROM pg_proc WHERE prosecdef = true;" +``` + +Expected: ~185. + +--- + +### Task A.5: Patch the 3 broken migrations + +**Files:** +- Modify: `supabase/migrations/006_water_log_rpcs_fixed.sql` +- Modify: `supabase/migrations/099_harvest_reach_segmentation.sql` +- Modify: `supabase/migrations/135_email_automation_rpcs.sql` + +- [ ] **Step 1: Patch 006 — replace `STATIC` with `STABLE`** + +```bash +sed -i 's/\bSTATIC\b/STABLE/g' supabase/migrations/006_water_log_rpcs_fixed.sql +grep -c "STABLE" supabase/migrations/006_water_log_rpcs_fixed.sql +``` + +Expected: 6+ occurrences of `STABLE`. + +- [ ] **Step 2: Patch 099 — quote the `time` column references** + +```bash +grep -n "time" supabase/migrations/099_harvest_reach_segmentation.sql +``` + +In the file, find references to the `time` column (a reserved word). Use `search_replace` to wrap in double quotes, matching the pattern from the existing 148 patch: + +```sql +RETURNS TABLE ( ..., "time" TEXT, ... ) +... +s."time", +``` + +- [ ] **Step 3: Patch 135 — add a default to `p_next_email_at` in `enroll_abandoned_cart`** + +```bash +grep -n "enroll_abandoned_cart" supabase/migrations/135_email_automation_rpcs.sql +``` + +Find the function signature. Add a default value to the `p_next_email_at` parameter: + +```sql +CREATE OR REPLACE FUNCTION public.enroll_abandoned_cart( + p_brand_id UUID, + p_cart_id UUID, + p_next_email_at TIMESTAMPTZ DEFAULT now() + interval '1 hour' +) +RETURNS ... +``` + +(If the function doesn't have `p_next_email_at`, find the actual signature and apply a similar fix.) + +- [ ] **Step 4: Commit the patches** + +```bash +git add supabase/migrations/006_water_log_rpcs_fixed.sql \ + supabase/migrations/099_harvest_reach_segmentation.sql \ + supabase/migrations/135_email_automation_rpcs.sql +git commit -m "fix(migrations): patch 3 broken migrations (006, 099, 135)" +``` + +--- + +### Task A.6: Apply all 137 migrations + +**Files:** +- Create: `scripts/apply-migrations.sh` (committed) + +- [ ] **Step 1: Create the migration apply script** + +Create `scripts/apply-migrations.sh`: + +```bash +#!/usr/bin/env bash +set -u +CONTAINER=route_commerce_db +DB=route_commerce +USER=routecommerce +LOG=/tmp/migration_apply.log +> $LOG + +for f in supabase/migrations/[0-9]*.sql; do + echo "Applying $f ..." | tee -a $LOG + docker compose cp "$f" "$CONTAINER:/tmp/$(basename $f)" + if docker compose exec -T "$CONTAINER" psql -U "$USER" -d "$DB" -v ON_ERROR_STOP=0 -q -f "/tmp/$(basename $f)" >> $LOG 2>&1; then + echo " OK" | tee -a $LOG + else + echo " FAIL (continuing)" | tee -a $LOG + fi +done + +echo "--- summary ---" +echo "Failures:" +grep -B1 "FAIL" $LOG | head -40 +``` + +Make it executable: + +```bash +chmod +x scripts/apply-migrations.sh +``` + +- [ ] **Step 2: Run the migration apply** + +```bash +./scripts/apply-migrations.sh 2>&1 | tail -40 +``` + +Expected: most migrations apply; some may fail (captured in log). The 3 patched ones (006, 099, 135) should succeed. + +- [ ] **Step 3: Verify which migrations failed** + +```bash +grep -B1 "FAIL" /tmp/migration_apply.log +``` + +Expected: a small list. If anything critical failed (e.g., `200_better_auth_tables.sql`), investigate. + +- [ ] **Step 4: Verify the better-auth tables exist** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "\dt" | grep -E '"user"|"session"|"account"|"verification"' +``` + +Expected: 4 tables. + +- [ ] **Step 5: Commit the apply script** + +```bash +git add scripts/apply-migrations.sh +git commit -m "chore(scripts): add apply-migrations.sh for local Postgres" +``` + +--- + +### Task A.7: Apply the data dump + +**Files:** none (uses `supabase/captured_data.sql` from Task A.2) + +- [ ] **Step 1: Apply the data dump** + +```bash +docker compose cp supabase/captured_data.sql db:/tmp/captured_data.sql +docker compose exec -T db psql -U routecommerce -d route_commerce -v ON_ERROR_STOP=0 -f /tmp/captured_data.sql 2>&1 | tail -20 +``` + +Expected: many `INSERT` statements. Some may fail (e.g., foreign key violations if migration order differs) — document in log. + +- [ ] **Step 2: Verify data fidelity (spot check)** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) FROM brands;" +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) FROM products;" +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) FROM orders;" +``` + +Expected: counts > 0 (or 0 if the corresponding table is empty). + +--- + +### Task A.8: Disable RLS on all public tables + +**Files:** none (SQL only) + +- [ ] **Step 1: Apply the RLS disable block** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -v ON_ERROR_STOP=0 <<'SQL' +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 $$; +SQL +``` + +Expected: no errors; runs in a few seconds. + +- [ ] **Step 2: Verify RLS is disabled** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) FROM pg_tables WHERE schemaname = 'public' AND rowsecurity = true;" +``` + +Expected: 0 rows. + +--- + +## Phase B — MinIO setup and asset migration + +### Task B.1: Start MinIO and verify buckets + +**Files:** none + +- [ ] **Step 1: Start MinIO + minio_init** + +```bash +docker compose up -d minio minio_init +``` + +Expected: both containers start. + +- [ ] **Step 2: Wait for the init container to finish** + +```bash +docker compose wait minio_init +docker compose logs minio_init | tail -10 +``` + +Expected: `MinIO buckets initialized` in logs. + +- [ ] **Step 3: Verify all 5 buckets exist** + +```bash +docker compose exec -T minio mc alias set local http://localhost:9000 routecommerce "$MINIO_ROOT_PASSWORD" +docker compose exec -T minio mc ls local/ +``` + +Expected: 5 buckets: `brand-logos/`, `contacts-imports/`, `product-images/`, `videos/`, `water-photos/`. + +- [ ] **Step 4: Verify a bucket is publicly readable** + +```bash +docker compose exec -T minio mc anonymous get local/brand-logos +``` + +Expected: `download`. + +- [ ] **Step 5: Verify MinIO is accessible from the host** + +```bash +curl -I http://127.0.0.1:9000/minio/health/live +``` + +Expected: `HTTP/1.1 200 OK`. + +--- + +### Task B.2: Copy existing brand assets from Supabase Storage to MinIO + +**Files:** none + +- [ ] **Step 1: Find the Tuxedo hero video URL** + +```bash +grep -A2 "videos" src/components/storefront/TuxedoVideoHero.tsx | head -20 +``` + +The URL looks like: +``` +https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/videos/ +``` + +- [ ] **Step 2: Download the Tuxedo hero video** + +```bash +mkdir -p /tmp/minio-migration/videos +curl -o /tmp/minio-migration/videos/ "" +ls -la /tmp/minio-migration/videos/ +``` + +Expected: file downloaded, > 0 bytes. + +- [ ] **Step 3: Find the brand logo URLs in `email-service.ts`** + +```bash +grep -A1 "brand-logos" src/lib/email-service.ts | head -20 +``` + +Four hardcoded URLs are expected. + +- [ ] **Step 4: Download the logos** + +```bash +mkdir -p /tmp/minio-migration/brand-logos/ +for url in "" "" "" ""; do + fname=$(basename "$url") + curl -o "/tmp/minio-migration/brand-logos//$fname" "$url" +done +ls -la /tmp/minio-migration/brand-logos// +``` + +Expected: 4 files downloaded. + +- [ ] **Step 5: Find any other hardcoded Supabase storage URLs** + +```bash +grep -rE "wnzkhezyhnfzhkhiflrp\.supabase\.co/storage/v1/object/public" src/ --include="*.ts" --include="*.tsx" | head -20 +``` + +Download any URLs found to `/tmp/minio-migration/misc/`. + +- [ ] **Step 6: Upload the videos to MinIO** + +```bash +docker compose cp /tmp/minio-migration/videos minio:/tmp/videos +docker compose exec -T minio mc cp --recursive /tmp/videos/ local/videos/ +docker compose exec -T minio mc ls local/videos/ +``` + +Expected: video file(s) listed. + +- [ ] **Step 7: Upload the brand logos to MinIO** + +```bash +docker compose cp /tmp/minio-migration/brand-logos minio:/tmp/brand-logos +docker compose exec -T minio mc cp --recursive /tmp/brand-logos/ local/brand-logos/ +docker compose exec -T minio mc ls local/brand-logos/ --recursive +``` + +Expected: logo files listed. + +- [ ] **Step 8: Verify the assets are publicly accessible** + +```bash +curl -I http://127.0.0.1:9000/videos/ +curl -I http://127.0.0.1:9000/brand-logos// +``` + +Expected: `HTTP/1.1 200 OK`. + +- [ ] **Step 9: Clean up** + +```bash +rm -rf /tmp/minio-migration +``` + +--- + +## Phase C — PostgREST + end-to-end local verification + +### Task C.1: Start PostgREST and verify + +**Files:** none + +- [ ] **Step 1: Start the PostgREST service** + +```bash +docker compose up -d postgrest +``` + +Expected: `Container route_commerce_postgrest ... Started`. + +- [ ] **Step 2: Wait for PostgREST healthcheck** + +```bash +for i in $(seq 1 15); do + if curl -sf http://127.0.0.1:3001/ > /dev/null 2>&1; then + echo "PostgREST is up" + break + fi + sleep 2 +done +``` + +Expected: `PostgREST is up` within 30 seconds. + +- [ ] **Step 3: Smoke test: list brands** + +```bash +curl -s "http://127.0.0.1:3001/brands?limit=1" -H "apikey: local-anon" | head -c 500 +``` + +Expected: a JSON array. + +- [ ] **Step 4: Smoke test: call a SECURITY DEFINER RPC** + +```bash +curl -s -X POST "http://127.0.0.1:3001/rpc/get_public_stops_for_brand" \ + -H "Content-Type: application/json" \ + -H "apikey: local-anon" \ + -d '{"p_slug":"indian-river-direct"}' | head -c 500 +``` + +Expected: a JSON array (or `[]` if no data yet). + +- [ ] **Step 5: Check the PostgREST logs for errors** + +```bash +docker compose logs postgrest --tail=30 +``` + +Expected: no critical errors. + +--- + +### Task C.2: Set up local env and verify app build + +**Files:** +- Create: `.env.local` (gitignored) + +- [ ] **Step 1: Create `.env.local`** + +```bash +PG_PW=$(grep "^POSTGRES_PASSWORD=" .env | cut -d= -f2-) +BA_S=$(grep "^BETTER_AUTH_SECRET=" .env | cut -d= -f2-) +MINIO_PW=$(grep "^MINIO_ROOT_PASSWORD=" .env | cut -d= -f2-) +cat > .env.local <` placeholders. + +- [ ] **Step 3: Run type check** + +```bash +npx tsc --noEmit +``` + +Expected: exit code 0. + +- [ ] **Step 4: Run build** + +```bash +npm run build +``` + +Expected: `✓ Compiled successfully`. + +- [ ] **Step 5: Fix any errors** + +Common issues: +- Missing `pg` types → `npm install --save-dev @types/pg` +- better-auth import errors → `ls node_modules/better-auth` +- PostgREST call returning errors → check Task A.6 verification + +--- + +### Task C.3: Verify mock mode still works + +**Files:** none + +- [ ] **Step 1: Start the dev server with mock mode** + +```bash +NEXT_PUBLIC_USE_MOCK_DATA=true npm run dev +``` + +Expected: dev server starts on `http://localhost:3000`. + +- [ ] **Step 2: Visit the homepage** + +Open `http://localhost:3000` in a browser. Expected: page loads. + +- [ ] **Step 3: Set the dev session cookie** + +In the browser dev tools, set a cookie: +``` +Name: dev_session +Value: platform_admin +Domain: localhost +Path: / +``` + +- [ ] **Step 4: Reload `/admin`** + +Expected: admin dashboard loads with mock data. + +- [ ] **Step 5: Stop the dev server** + +```bash +# Ctrl+C in the terminal +``` + +--- + +### Task C.4: Verify dev mode bypass works against local Postgres + +**Files:** none + +- [ ] **Step 1: Start the dev server with the real DB** + +```bash +unset NEXT_PUBLIC_USE_MOCK_DATA +npm run dev +``` + +- [ ] **Step 2: Set the dev session cookie** + +Same as Task C.3 Step 3: `dev_session=platform_admin`. + +- [ ] **Step 3: Visit /admin** + +Expected: admin dashboard loads with real data from local Postgres. + +- [ ] **Step 4: Verify a known data point** + +Visit `/admin/brands` and confirm a known brand is listed. + +- [ ] **Step 5: Stop the dev server** + +```bash +# Ctrl+C +``` + +--- + +### Task C.5: Verify auth round-trip with better-auth + +**Files:** none + +- [ ] **Step 1: Start the dev server** + +```bash +npm run dev +``` + +- [ ] **Step 2: Sign up via better-auth API** + +```bash +curl -X POST http://localhost:3000/api/auth/sign-up/email \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"testpassword123","name":"Test User"}' +``` + +Expected: 200 with a user object (or 400 if signup is disabled — adjust per the better-auth config). + +- [ ] **Step 3: Sign in** + +```bash +curl -X POST http://localhost:3000/api/auth/sign-in/email \ + -H "Content-Type: application/json" \ + -c /tmp/cookies.txt \ + -d '{"email":"test@example.com","password":"testpassword123"}' +``` + +Expected: 200; `/tmp/cookies.txt` contains `rc_session_token`. + +- [ ] **Step 4: Use the session to call a protected endpoint** + +```bash +curl -X GET http://localhost:3000/admin \ + -b /tmp/cookies.txt \ + -o /dev/null -w "%{http_code}\n" +``` + +Expected: 200 (or 307 redirect). + +- [ ] **Step 5: Verify the user was created** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT id, email, name FROM \"user\";" +``` + +Expected: row with `email = 'test@example.com'`. + +- [ ] **Step 6: Clean up** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "DELETE FROM \"user\" WHERE email = 'test@example.com';" +``` + +--- + +### Task C.6: Verify storage round-trip with MinIO + +**Files:** none + +- [ ] **Step 1: Start the dev server** + +```bash +npm run dev +``` + +- [ ] **Step 2: Sign in as a platform admin** + +Set `dev_session=platform_admin` cookie in the browser. + +- [ ] **Step 3: Upload a product image** + +Go to `/admin/products`. Edit a product. Upload a new product image (JPG/PNG). + +- [ ] **Step 4: Verify the image is in MinIO** + +```bash +docker compose exec -T minio mc ls local/product-images/ --recursive | head -10 +``` + +Expected: the uploaded image appears. + +- [ ] **Step 5: Verify the image is publicly accessible** + +```bash +IMG_KEY=$(docker compose exec -T minio mc ls local/product-images/ --recursive | head -1 | awk '{print $NF}') +curl -I "http://127.0.0.1:9000/product-images/$IMG_KEY" +``` + +Expected: `HTTP/1.1 200 OK`. + +- [ ] **Step 6: Verify the image renders on the storefront** + +Visit `/tuxedo/products/` (or `/indian-river-direct/products/`). The image should display. + +--- + +## Phase D — Production cutover + +### Task D.1: Set up prod server with Docker stack + +**Files:** none (server admin) + +- [ ] **Step 1: SSH to the prod server** + +```bash +ssh tyler@route.crispygoat.com +``` + +- [ ] **Step 2: Pull the latest code on `main`** + +```bash +cd /home/tyler/route-commerce +git pull origin main +``` + +Expected: latest `selfhost/migrate` branch commits (merge `selfhost/migrate` to `main` first if your flow requires it). + +- [ ] **Step 3: Verify Docker is installed** + +```bash +docker --version +docker compose version +``` + +Expected: `Docker version 24.x` and `Docker Compose version v2.x` (or `docker-compose version 1.x`). + +- [ ] **Step 4: Verify Gitea secrets are set** + +In `https://git.crispygoat.com/tyler/route-commerce/settings/secrets`, verify: + +- `POSTGRES_USER` = `routecommerce` +- `POSTGRES_PASSWORD` = (32+ char random) +- `POSTGRES_DB` = `route_commerce` +- `MINIO_ROOT_USER` = `routecommerce` +- `MINIO_ROOT_PASSWORD` = (32+ char random) +- `POSTGREST_JWT_SECRET` = (48+ char random) +- `DATABASE_URL` = `postgresql://routecommerce:@db:5432/route_commerce?schema=public` +- `BETTER_AUTH_SECRET` = (48+ char random) +- `BETTER_AUTH_URL` = `https://route.crispygoat.com` +- `NEXT_PUBLIC_BETTER_AUTH_URL` = `https://route.crispygoat.com` +- `STORAGE_ENDPOINT` = `http://minio:9000` +- `STORAGE_REGION` = `us-east-1` +- `STORAGE_ACCESS_KEY` = `routecommerce` +- `STORAGE_SECRET_KEY` = (same as `MINIO_ROOT_PASSWORD`) +- `STORAGE_BUCKET_PREFIX` = (empty) +- `NEXT_PUBLIC_STORAGE_BASE_URL` = `https://route.crispygoat.com/storage` (or your reverse proxy URL) +- `PGRST_SERVER_PORT` = `3001` +- `PGRST_DB_URI` = `postgresql://routecommerce:@db:5432/route_commerce` +- `PGRST_DB_ANON_ROLE` = `anon` +- `NEXT_PUBLIC_SUPABASE_URL` = `http://postgrest:3001` +- `NEXT_PUBLIC_SUPABASE_ANON_KEY` = (any string) +- `STRIPE_SECRET_KEY` = (existing) +- `RESEND_API_KEY` = (existing) +- `MINIMAX_API_KEY` = (existing) +- `MINIMAX_BASE_URL` = (existing) + +- [ ] **Step 5: Push to trigger the Gitea workflow** + +From the dev box: + +```bash +git push crispygoat selfhost/migrate:main +``` + +(Or merge `selfhost/migrate` to `main` first, then push `main`.) + +- [ ] **Step 6: Watch the Gitea Actions run** + +Open `https://git.crispygoat.com/tyler/route-commerce/actions`. Expected: `Start Docker stack` succeeds, `Apply migrations` runs through 137 migrations, `Deploy` restarts the app. + +--- + +### Task D.2: Verify prod is working + +**Files:** none + +- [ ] **Step 1: Visit the homepage** + +Open `https://route.crispygoat.com`. Expected: page loads. + +- [ ] **Step 2: Visit a brand storefront** + +Open `https://route.crispygoat.com/tuxedo` and `https://route.crispygoat.com/indian-river-direct`. Expected: pages load, brand logos display from MinIO. + +- [ ] **Step 3: Sign in to the admin** + +Open `https://route.crispygoat.com/login` and sign in with a known admin account. + +- [ ] **Step 4: Verify data is present** + +Navigate `/admin/orders`, `/admin/products`, `/admin/stops`, `/admin/communications`. Verify data is from the dump (or fresh if no dump was applied). + +- [ ] **Step 5: Upload a new product image** + +In `/admin/products`, upload a new image. Verify it lands in MinIO and renders on the storefront. + +- [ ] **Step 6: Check prod logs for errors** + +```bash +ssh tyler@route.crispygoat.com +pm2 logs route-commerce --lines 100 +docker compose logs db postgrest minio --tail=30 +``` + +Expected: no critical errors. + +--- + +### Task D.3: 24h monitoring + +**Files:** none + +- [ ] **Step 1: Monitor for 24 hours** + +Watch for: +- Error rates in the Next.js app +- Postgres connection issues +- MinIO disk usage +- PostgREST 5xx responses + +- [ ] **Step 2: Roll back if critical issues appear** + +If anything goes wrong: +1. `git revert` the merge commit on `main`. +2. `pm2 restart route-commerce`. +3. Investigate before re-attempting. + +- [ ] **Step 3: After 24h of stable operation, deactivate Supabase** + +In the Supabase dashboard: +1. Pause the project (or delete, if certain). +2. Cancel any active subscriptions. +3. Note the deactivation date in `MEMORY.md`. + +- [ ] **Step 4: Document the cutover in MEMORY.md** + +Append to `MEMORY.md`: + +```markdown +## Self-Hosted Cutover (YYYY-MM-DD) + +- Migrated from Supabase to self-hosted Postgres + PostgREST + MinIO + better-auth. +- Branch: `selfhost/migrate` (merged to `main`). +- Schema dump: `supabase/captured_schema.sql` (commit ). +- Data dump: `supabase/captured_data.sql` (commit ) — or "no data dump, fresh start" if not applied. +- 24h monitoring: passed. +- Supabase project: paused/deleted on YYYY-MM-DD. +``` + +- [ ] **Step 5: Final commit** + +```bash +git add MEMORY.md +git commit -m "docs(memory): record self-hosted cutover" +``` + +--- + +## Phase E — Final verification (after cutover) + +### Task E.1: Run Playwright E2E tests (if available) + +**Files:** +- Modify: `playwright.config.ts` (if needed for env) + +- [ ] **Step 1: Check if Playwright tests exist** + +```bash +ls tests/ 2>&1 | head -10 +cat playwright.config.ts | head -40 +``` + +- [ ] **Step 2: Update `playwright.config.ts` to use the local env (if needed)** + +```ts +webServer: { + command: 'npm run dev', + env: { + NEXT_PUBLIC_SUPABASE_URL: 'http://localhost:3001', + NEXT_PUBLIC_SUPABASE_ANON_KEY: 'local-anon', + DATABASE_URL: process.env.DATABASE_URL, + BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, + NEXT_PUBLIC_STORAGE_BASE_URL: 'http://localhost:9000', + STORAGE_ENDPOINT: 'http://127.0.0.1:9000', + STORAGE_ACCESS_KEY: 'routecommerce', + STORAGE_SECRET_KEY: process.env.STORAGE_SECRET_KEY, + }, + url: 'http://localhost:3000', + timeout: 120_000, +}, +``` + +- [ ] **Step 3: Install Playwright browsers** + +```bash +npx playwright install --with-deps chromium +``` + +- [ ] **Step 4: Run the tests** + +```bash +npx playwright test +``` + +Expected: tests pass (or skip if no tests exist). If tests fail, document the failures but don't block the migration. + +- [ ] **Step 5: Note the result** + +```bash +echo "Playwright result: $(date)" >> docs/superpowers/migration-e2e-result.log +echo "Pass/Fail: " >> docs/superpowers/migration-e2e-result.log +``` + +--- + +### Task E.2: Final cross-check against spec's acceptance criteria + +**Files:** none + +- [ ] **Step 1: Verify the spec's acceptance criteria** + +```bash +git rev-parse --abbrev-ref HEAD +npx tsc --noEmit && echo "TSC OK" && npm run build && echo "BUILD OK" +``` + +Expected: `selfhost/migrate`, `TSC OK`, `BUILD OK`. + +- [ ] **Step 2: Check all 3 patched migrations applied** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "\df" | grep -E "verify_water_pin|enroll_abandoned_cart" | head -5 +``` + +Expected: function names from 006 and 135 visible. + +- [ ] **Step 3: Check supabase-js works against PostgREST** + +```bash +curl -s "http://127.0.0.1:3001/brands?limit=1" -H "apikey: local-anon" +``` + +Expected: 200 with JSON array. + +- [ ] **Step 4: Check better-auth tables exist** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) AS user_count FROM \"user\";" +``` + +Expected: a count. + +- [ ] **Step 5: Check MinIO buckets exist and are public** + +```bash +docker compose exec -T minio mc ls local/ | wc -l +``` + +Expected: 5. + +- [ ] **Step 6: Check env vars don't reference Supabase (except the legacy vars)** + +```bash +grep -rE "wnzkhezyhnfzhkhiflrp\.supabase\.co" src/ --include="*.ts" --include="*.tsx" | head -10 +``` + +Expected: only references in the old Supabase storage URLs (already migrated to MinIO). If any non-storage references remain, fix them. + +- [ ] **Step 7: Note the result in the spec** + +Add a status note to the spec's "Acceptance Criteria" section in `docs/superpowers/specs/2026-06-05-selfhost-migration-design.md` and commit. + +--- + +## Out of scope (deferred to follow-up plans) + +- Migrating user passwords from `auth.users` to `user.password` (better-auth). Users will need to reset passwords. +- Performance tuning (indexes, connection pooling, query optimization). +- Replacing `supabase-js` (still used for PostgREST RPC calls; works fine). +- Setting up a Caddy reverse proxy in front of MinIO (deferred; current setup exposes MinIO on port 9000 directly). +- Self-hosted email/SMS sending (Resend + Twilio still used as third-party services). +- Backup/restore strategy for the new Postgres (currently relies on Docker volume; consider adding `pg_dump` cron).