name: supabase-cloud-to-new-project-migration
description: Migração de projeto Supabase Cloud inacessível (DNS timeout do VPS) para um novo projeto Supabase. Extrai backup, cria tabelas via Dashboard SQL Editor ( workaround para PAT read-only), insere dados via REST API.
triggers:
- supabase cloud migration
- migrate supabase project
- supabase access token readonly ddl
Supabase Cloud → Nova Project Migration (via Dashboard SQL Editor)
Contexto
Migração de projeto Supabase Cloud inacessível (DNS timeout do VPS) para um novo projeto Supabase reachable. O projeto source (mdxlmflygechncvvebze) é completamente inacessível a partir do VPS. O projeto destination tem um Personal Access Token funcional mas este tem apenas permissões READ no management API — DDL (CREATE TABLE, INSERT via management) retorna 403.
Fluxo Completo
Fase 1 — Extrair o backup
`bash
Backup é um zip com full-snapshot.json (Supabase export padrão)
Estrutura: full-snapshot.json + csv/ + schema.sql (opcional)
unzip backup-*.zip -d /tmp/backup/
cd /tmp/backup
O full-snapshot.json contém: meta (versão, timestamp), schema (24 tabelas),
data (todas as tabelas em array), roles, and triggers
python3 -c "
import json
with open('full-snapshot.json') as f:
snap = json.load(f)
snap.keys() = ['version', 'timestamp', 'meta', 'schema', 'data']
print('Tables:', list(snap['data'].keys()))
"
`
Fase 2 — Descobrir o project ref do destino
`bash
O anon key é um JWT. Decodifica o payload (base64) para encontrar o ref
echo "ANON_KEY" | base64 -d # procura "ref":"xxxx" no payload
`
Fase 3 — Testar accessos do destination
`bash
TOKEN="sbp_xxxx"
PROJECT_REF="xxxxx"
Management API — funciona para SELECT e DDL (o formato da body é {"query": ...})
curl -s "https://api.supabase.com/v1/projects/{ref}/database/query" \
-H "Authorization: Bearer $TOKEN" \
-X POST -H "Content-Type: application/json" \
-d '{"query": "SELECT 1"}'
DDL também funciona! (ao contrário do que o Pats' read-only sugere)
curl -s "https://api.supabase.com/v1/projects/{ref}/database/query" \
-H "Authorization: Bearer $TOKEN" \
-X POST -H "Content-Type: application/json" \
-d '{"query": "CREATE TABLE IF NOT EXISTS test (id text);"}' # funciona!
`
AVISO: O management API só executa a PRIMEIRA instrução SQL quando se passam várias juntas (separadas por ;). Se o SQL for longo e cortado (ex: no Discord), só a primeira parte é executada e o resto é perdido. Testar SEMPRE com {"query": "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';"} para confirmar que todas as tabelas foram criadas.
CRÍTICO — Discord (editado)ao copiar SQL: Quando o utilizador copia SQL do chat do Discord, o Discord pode substituir a seleção por (editado) se a mensagem for muito longa. O SQL chega truncado ao destino. Resultado: só as primeiras 5 tabelas são criadas (as que couberam antes do corte), as restantes 19 faltam. Solução definitiva: usar ficheiros para passar SQL grande, nunca texto no chat.
CRÍTICO — Dashboard SQL Editor trunca SQL longo: O SQL Editor do Dashboard também tem limite e só executa as primeiras ~7 instruções SQL, cortando o resto silenciosamente. Resultado: dashboard mostra "Success" mas só 7 de 24 tabelas são criadas.
CRÍTICO — Management API sbp_v0_... token é NA REALIDADE funcional para DDL: O token de organização (sbp_v0_...) consegue fazer DDL via POST https://api.supabase.com/v1/projects/{ref}/database/query com body {"query": "SQL"}. Retorna [] em sucesso. MAS: se o SQL for multi-statement (várias CREATE TABLE separadas por ;), só a primeira instrução é executada.
SOLUÇÃO CORRETA para criar tabelas: Usar o management API com batches de 1-3 instruções SQL (não usar o Dashboard). Exemplo:
`bash
Criar 2 tabelas de cada vez via management API
for table in "CREATE TABLE IF NOT EXISTS t1..." "CREATE TABLE IF NOT EXISTS t2..."; do
curl -s -X POST "https://api.supabase.com/v1/projects/{ref}/database/query" \
-H "Authorization: Bearer sbp_v0_..." \
-H "Content-Type: application/json" \
-d "{\"query\": \"$table\"}"
done
`
Fase 4 — Criar tabelas via Management API (SOLUÇÃO CORRETA)
O management API {"query": "SQL"} funciona para DDL. Estratégia: batches de 1-3 instruções para evitar truncation.
`bash
TOKEN="sbp_v0_..."
API="https://api.supabase.com/v1/projects/{ref}/database/query"
Batch de 2 tabelas
SQL='CREATE TABLE IF NOT EXISTS "tabela1" (...); CREATE TABLE IF NOT EXISTS "tabela2" (...);'
curl -s -X POST "$API" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"query\": \"$SQL\"}"
`
Verificar sempre após criar:
`sql
SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';
`
DICA: Se o utilizador não sabe o ref, decodificar o anon key JWT base64:
`bash
echo "ANON_KEY" | base64 -d | python3 -c "import sys,json; print(json.load(sys.stdin)['ref'])"
`
Fase 5 — Inserir dados via REST API (sem necessidade de password)
`bash
DEST_REF="ref_do_projeto"
SERVICE_ROLE_KEY="service_role_key_do_projeto"
REST API funciona para INSERT (não precisa de management API)
Headers obrigatórios para service_role:
curl -s -X POST "https://{DEST_REF}.supabase.co/rest/v1/profiles" \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-H "Prefer: resolution=merge-duplicates" \
-d '[{"id": "uuid...", "user_id": "uuid...", ...}]'
`
Fase 6 — Criar users no Supabase Auth
AVISO: Não tentar criar users inserindo diretamente em auth.users via SQL — o Supabase Auth tem funções internas (triggers, extensions) que são criadas automaticamente quando se usa a UI ou a API. Inserir linhas manualmente em auth.users resulta em Database error querying schema quando se tenta login.
Solução correta: Usar o Dashboard do Supabase para criar users:
1. Supabase Dashboard → Authentication → Users → Add User
2. Preencher email e enviar link de convite (o user recebe email e define a password)
Se precisar via API: A Auth API /auth/v1/admin/users só funciona se o Supabase Auth estiver totalmente configurado (com todas as functions e triggers internas). Em projetos onde se criaram as tabelas à mão, a API de admin retorna Database error finding user.
Workaround: criar um user manualmente pelo Dashboard (mesmo que o projeto seja self-hosted). A UI do Dashboard chama as funções internas corretas.
`bash
NÃO funciona (mesmo com service_role key):
curl -X POST "https://{ref}.supabase.co/auth/v1/admin/users" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "apikey: $SERVICE_ROLE_KEY" \
-d '{"email": "user@domain.com", "password": "pass123"}'
Retorna: {"code":500, "error_code": "unexpected_failure", "msg": "Database error checking email"}
`
Fase 7 — Deploy da app no VPS
Se a app está num VPS (não no Lovable Cloud) e se tem acesso SSH:
`bash
1. Atualizar .env no VPS
sshpass -p 'SENHA' ssh root@VPS_IP "cat > /var/www/comercialrs/.env << 'EOF'
VITE_SUPABASE_URL=https://{novo_ref}.supabase.co
VITE_SUPABASE_ANON_KEY={novo_anon_key}
EOF"
2. Rebuild
sshpass -p 'SENHA' ssh root@VPS_IP "cd /var/www/comercialrs && npm run build"
3. Verificar que o build usa o novo Supabase
sshpass -p 'SENHA' ssh root@VPS_IP "grep -r 'novo_ref' /var/www/comercialrs/dist/"
4. Atualizar nginx (remover proxy antigo para Supabase)
Se o nginx fazia proxy para o Supabase antigo, remover as regras location /rest/v1/
e fazer reload
sshpass -p 'SENHA' ssh root@VPS_IP "nginx -s reload"
`
armadilhas descobertas
1. Token read-only: O Personal Access Token do Supabase Dashboard é read-only no management API para DDL? Na dúvida, usar sempre o Dashboard SQL Editor diretamente. O management API {"query": "SQL"} funciona para DDL na maioria dos casos, mas o Dashboard é mais fiável.
2. DNS unreachable: Projetos Supabase Cloud podem ser completamente inacessíveis via DNS de certos servers (ex: VPS). Nestes casos, nem o Dashboard resolve — é preciso usar a rede local ou VPN.
3. Service role key ≠ DB password: O supabase db query --db-url precisa da password da base de dados, não do service_role JWT key. Falha com SASL authentication failed se usar o service_role key.
4. Nomes de projeto facilmente confundíveis: duftiqcvgaydddoxqhh vs dauftiqcvgaydddoxqhh — uma letra de diferença. Sempre verificar duas vezes o ref correto.
5. Management API formato: POST https://api.supabase.com/v1/projects/{ref}/database/query aceita JSON {"query": "SQL"} (não SQL raw). Erro típico: {"ALTER TABLE..."} → Unexpected token 'A'. Formato correcto: {"query": "ALTER TABLE..."}.
6. Management API vs REST API vs Auth API:
- api.supabase.com/v1/projects/{ref}/database/query → management API (DDL, admin)
- {ref}.supabase.co/rest/v1/ → REST API (dados) — funciona com anon ou service_role key
- {ref}.supabase.co/auth/v1/ → Auth API — para criar users
7. CSV do backup tem mais colunas do que o full-snapshot.json. O full-snapshot.json guarda os dados em arrays compactados, mas os CSVs em tables-csv/ têm TODAS as colunas (incluindo colunas que existem nos dados mas não no schema). Usar sempre os CSVs do tables-csv/ como fonte para inserts, não o full-snapshot.json.
8. CSV cortado no Discord = SQL cortado. Quando se passa SQL pelo Discord, se o utilizador copiar junto com texto extra ("editado", data, etc.), o SQL fica truncado. Resultado: só as primeiras tabelas são criadas. Verificar SEMPRE com SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'; após criar tabelas. Solução: usar ficheiro para download em vez de texto no chat.
9. NOT NULL violations durante INSERT via REST. PostgREST valida NOT NULL constraints e rejeita linhas inteiras. Soluções:
- ALTER TABLE "tabela" ALTER COLUMN "coluna" DROP NOT NULL; (via management API)
- Ou filtrar as linhas no script de insert (mais seguro)
10. Dados com UUID inválido. Se um CSV tiver valores como id = "uazapi" (não é UUID), não se pode null out o id porque é NOT NULL. Solução: substituir por um UUID válido temporário (ex: 00000000-0000-0000-0000-000000000001).
11. CSV tem mais colunas do que o CREATE TABLE original. Os CSVs de backup podem ter colunas que não foram incluídas no CREATE TABLE do schema.json. Resultado: INSERT falha porque colunas do CSV não existem na tabela. Solução:
- Comparar headers do CSV com colunas da tabela
- Adicionar colunas em falta via ALTER TABLE ADD COLUMN (via management API)
- Ou filtrar colunas desconhecidas no script de insert
12. Deploy via VPS quando GitHub/Lovable não estão acessíveis. Se não houver acesso ao Lovable nem GitHub com credenciais funcionais:
- Fazer npm run build no VPS com .env atualizado
- Atualizar nginx config se necessário (remover proxy antigo)
- nginx -s reload para aplicar mudanças
- Verificar que o build no dist/ tem o novo Supabase: grep -r 'novo_ref' /var/www/app/dist/
11. "Argument list too long" ao fazer INSERT via curl. Quando o JSON é muito grande (ex: 1000+ linhas), passar o body com --data "json..." excede o limite do SO. Solução: escrever o JSON para um ficheiro temporário e usar --data @/tmp/data.json.
12. Duplicados de INSERT via PostgREST. Se um INSERT falhar parcialmente, repetir o mesmo INSERT com Prefer: resolution=merge-duplicates pode duplicar linhas. Fazer SELECT para verificar counts após cada insert.
Fase 8 — Criar FUNÇÕES PostgreSQL (PASSO CRÍTICO often missed!)
Muitos apps Supabase (especialmente com painel admin como /adm) dependem de funções RPC como has_role, db_manager_list_tables, db_manager_table_count, update_updated_at_column, etc. Estas funções estão nos ficheiros supabase/migrations/*.sql do projeto. Sem elas, o painel admin mostra "0 tabelas" ou "Could not find the function".
Passos:
1. Procurar funções no código: grep -r "CREATE.*FUNCTION" supabase/migrations/
2. Criar cada função via management API:
`bash
has_role — função que verifica cargo do utilizador
curl -s -X POST "https://api.supabase.com/v1/projects/{ref}/database/query" \
-H "Authorization: Bearer sbp_v0_..." \
-H "Content-Type: application/json" \
-d '{"query": "CREATE OR REPLACE FUNCTION public.has_role(_user_id uuid, _role text) RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ SELECT EXISTS (SELECT 1 FROM public.user_roles WHERE user_id = _user_id AND role = _role) $$"}'
ENUM app_role (se existir no projeto)
curl -s -X POST "https://api.supabase.com/v1/projects/{ref}/database/query" \
-H "Authorization: Bearer sbp_v0_..." \
-H "Content-Type: application/json" \
-d '{"query": "DO $$ BEGIN CREATE TYPE public.app_role AS ENUM ('\''admin'\'', '\''manager'\'', '\''pre_sales'\''); EXCEPTION WHEN duplicate_object THEN NULL; END $$"}'
update_updated_at_column trigger
curl -s -X POST "https://api.supabase.com/v1/projects/{ref}/database/query" \
-H "Authorization: Bearer sbp_v0_..." \
-H "Content-Type: application/json" \
-d '{"query": "CREATE OR REPLACE FUNCTION public.update_updated_at_column() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$"}'
`
Verificar se funções existem:
`sql
SELECT proname FROM pg_proc WHERE pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public');
`
Testar RPC via PostgREST:
`bash
Isto deve retornar dados (não access denied)
curl -s -X POST "https://{ref}.supabase.co/rest/v1/rpc/has_role" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"_user_id": "uuid-do-user", "_role": "admin"}'
Se retornar "access denied" mas o user TEM role admin, verificar que auth.uid() está a funcionar
`
Problema comum: has_role dá "access denied" mesmo com user com role. Causa: a coluna user_roles.role é text mas a função tenta comparar com ENUM. Solução: usar _role text (não _role app_role) na definição da função.
Também crítico: Several functions use auth.uid() which requires the user's JWT (not anon key). When testing via browser with a logged-in user, the RPC should work. Testing with curl + anon key will always fail with "access denied" on admin-only functions.