Por que migrar?
O Firebase é ótimo para protótipo. Em escala, três coisas costumam apertar: o custo (o Firestore cobra por leitura e escrita, e listagens grandes ficam caras), as consultas (sem JOIN e com agregação limitada) e a cobrança em dólar.
| Aspecto | Firebase | SuperDB |
|---|---|---|
| Modelo de dados | NoSQL (documentos) | Postgres (relacional) |
| Consultas | Sem JOIN, agregação limitada | SQL completo, views, funções |
| Regras de acesso | Security Rules | RLS no Postgres |
| Cobrança | Por leitura, escrita e GB, em dólar | Plano fixo em real (Pro a R$ 119/mês) |
| Self-host | Não existe | Ainda não: o código é AGPL-3.0, mas o repositório é privado |
| Suporte | Em inglês | Em português |
Mapeamento Firebase → SuperDB
A mudança de cabeça mais importante: parar de pensar em documentos aninhados e voltar para tabelas com chave estrangeira.
| Firebase | SuperDB | Observação |
|---|---|---|
| Collection | Tabela | Criada no schema do projeto, pelo SQL Editor |
| Document | Linha | O id do documento vai para uma coluna firebase_id |
| Subcollection | Tabela com chave estrangeira | users/{uid}/orders vira pedidos.user_id |
| Security Rules | Policies de RLS | Sintaxe diferente, mesma intenção |
| Firebase Auth | Auth do SuperDB | Senha, código por e-mail e WhatsApp, Google, GitHub e Apple |
| Cloud Storage | Storage do SuperDB | Buckets pelo SDK; leia o aviso do passo 5 |
onSnapshot | Realtime (postgres_changes) | WebSocket; leia o aviso do passo 8 |
| Cloud Functions | Gatilhos SQL, hooks de auth ou o seu servidor | Não há Edge Functions |
| FCM (push) | Push do SuperDB | Aparelhos, envio e recibo no SuperDB; no Android o FCM segue como o cano (a mesma conta de serviço), e o iPhone vai direto pela Apple |
Não é troca de pacote. Diferente da migração do Supabase, aqui as consultas são reescritas: .where('user', '==', uid) vira .from('pedidos').select(), e o filtro por dono some, porque quem filtra é o RLS.
Tempo estimado
| Tamanho do projeto | Tempo típico | O que mais toma tempo |
|---|---|---|
| Pequeno (menos de 5 coleções) | 1 a 2 dias | Modelar tabelas e reescrever as consultas |
| Médio (10 a 30 coleções) | 1 semana | Subcoleções viram chaves estrangeiras; regras viram RLS |
| Grande (Cloud Functions extensas) | 2 a 4 semanas | Levar a lógica das funções para o seu servidor |
Passo 1: exporte o Firestore
O gcloud firestore export gera um backup no formato interno do Google, bom para guardar e ruim para ler. Para migrar, um script com o firebase-admin gera JSON:
import admin from 'firebase-admin'
import { writeFileSync } from 'node:fs'
admin.initializeApp({ credential: admin.credential.cert('./service-account.json') })
const firestore = admin.firestore()
async function exportar(colecao: string) {
const snap = await firestore.collection(colecao).get()
const docs = snap.docs.map((d) => ({ id: d.id, ...d.data() }))
writeFileSync(`${colecao}.json`, JSON.stringify(docs, null, 2))
console.log(`[ok] ${colecao}: ${docs.length} documentos`)
}
await Promise.all([exportar('orders'), exportar('products')])
Timestamps do Firestore saem como { _seconds, _nanoseconds }: converta para ISO (new Date(t._seconds * 1000).toISOString()) na importação.
Passo 2: modele as tabelas
Cada coleção vira uma tabela. O detalhe que importa: o dono de cada linha no SuperDB ainda não existe na hora da importação, porque as contas são criadas no primeiro login (passo 4). Por isso cada tabela com dono guarda o firebase_uid antigo, e o user_id fica vazio até a pessoa migrar.
create table if not exists pedidos (
id uuid primary key default gen_random_uuid(),
user_id uuid default auth.uid(), -- dono no SuperDB; vazio até a pessoa migrar
firebase_uid text, -- dono no Firebase, para o vínculo do passo 4
firebase_id text unique, -- id do documento no Firestore
status text not null default 'pendente',
total_centavos int not null,
criado_em timestamptz not null default now()
);
create index if not exists pedidos_por_dono on pedidos (user_id, criado_em desc);
create index if not exists pedidos_a_vincular on pedidos (firebase_uid) where user_id is null;
create table if not exists itens_do_pedido (
id uuid primary key default gen_random_uuid(),
pedido_id uuid not null references pedidos (id) on delete cascade,
produto_id uuid not null,
quantidade int not null,
preco_centavos int not null
);
Para importar, use a service key num script que roda na sua máquina ou no seu servidor, nunca no navegador:
import { createClient } from '@superdb/client'
import pedidos from './orders.json' with { type: 'json' }
const admin = createClient(process.env.SUPERDB_URL!, process.env.SUPERDB_SERVICE_ROLE_KEY!, {
project: process.env.SUPERDB_PROJECT!,
})
for (let i = 0; i < pedidos.length; i += 500) {
const lote = pedidos.slice(i, i + 500).map((p) => ({
firebase_id: p.id,
firebase_uid: p.userId, // quem era o dono no Firebase
status: p.status,
total_centavos: Math.round(p.total * 100),
}))
const { error } = await admin.from('pedidos').insert(lote)
if (error) throw new Error(`lote ${i}: ${error.message}`)
}
Passo 3: regras → RLS
Toda tabela nova no SuperDB nasce fechada (RLS ligado e nenhuma policy). As regras do Firestore viram policies:
match /orders/{orderId} {
allow read, update, delete: if request.auth.uid == resource.data.userId;
allow create: if request.auth.uid == request.resource.data.userId;
}
alter table pedidos enable row level security;
drop policy if exists pedidos_do_dono on pedidos;
create policy pedidos_do_dono on pedidos
for all to authenticated
using (user_id = auth.uid()) with check (user_id = auth.uid());
revoke all on pedidos from anon;
grant select, insert, update, delete on pedidos to authenticated;
Linha com user_id vazio não aparece para ninguém: é o estado certo até a pessoa migrar. Equivalências comuns:
request.auth.uid == resource.data.userId→user_id = auth.uid()request.auth.token.admin == true→ uma tabela de papéis consultada na policy (veja claims próprios)resource.data.public == true→using (publico)numa policy paraanoneauthenticatedget(/databases/.../doc).data.x→ uma subconsulta dentro da policy
Passo 4: usuários, migrados no primeiro login
O SuperDB confere senha em Argon2id e, para contas importadas do Supabase, em bcrypt. O Firebase usa uma variante própria do scrypt, que o SuperDB não confere. A saída é migrar cada pessoa no primeiro login, enquanto o Firebase ainda está no ar:
- A tela de login tenta o SuperDB. Deu certo: a pessoa já migrou.
- Não deu: o app manda e-mail e senha para uma rota do seu servidor, que confere a senha no Firebase.
- A senha confere: o servidor cria a conta no SuperDB com a mesma senha (
signUp), passa os dados daquelefirebase_uidpara a conta nova e devolve a sessão.
import { createClient } from '@superdb/client'
const SUPERDB_URL = process.env.SUPERDB_URL! // https://auth.superdb.com.br
const PROJETO = process.env.SUPERDB_PROJECT!
const admin = createClient(SUPERDB_URL, process.env.SUPERDB_SERVICE_ROLE_KEY!, { project: PROJETO })
const TABELAS_COM_DONO = ['pedidos']
// Um cliente por pedido, com a sessão só em memória: nada vaza entre usuários.
function clienteDoPedido() {
const m = new Map<string, string>()
return createClient(SUPERDB_URL, process.env.SUPERDB_ANON_KEY!, {
project: PROJETO,
storage: {
getItem: (k) => m.get(k) ?? null,
setItem: (k, v) => { m.set(k, v) },
removeItem: (k) => { m.delete(k) },
},
})
}
// A senha confere no Firebase? É a prova de que a conta antiga é desta pessoa.
async function uidNoFirebase(email: string, senha: string) {
const r = await fetch(
`https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${process.env.FIREBASE_API_KEY}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: senha, returnSecureToken: true }),
},
)
return r.ok ? ((await r.json()) as { localId: string }).localId : null
}
export async function POST(req: Request) {
const { email, senha, novaSenha } = (await req.json()) as { email: string; senha: string; novaSenha?: string }
const firebaseUid = await uidNoFirebase(email, senha)
if (!firebaseUid) return Response.json({ erro: 'E-mail ou senha incorretos.' }, { status: 401 })
// O SuperDB pede 8 caracteres; o Firebase aceitava 6. Nesse caso, a tela pede uma nova.
const senhaFinal = novaSenha ?? senha
if (senhaFinal.length < 8) return Response.json({ precisaSenhaNova: true }, { status: 409 })
const { data, error } = await clienteDoPedido().auth.signUp({ email, password: senhaFinal })
if (error?.code === 'password_breached') return Response.json({ precisaSenhaNova: true }, { status: 409 })
if (error) return Response.json({ erro: 'Não deu para migrar a conta agora. Fale com o suporte.' }, { status: 409 })
// Os dados que eram desta conta no Firebase passam a ser dela no SuperDB.
for (const tabela of TABELAS_COM_DONO) {
await admin.from(tabela).update({ user_id: data.user.id }).eq('firebase_uid', firebaseUid).is('user_id', null)
}
const { access_token, refresh_token, data_plane_token, expires_in } = data.session
return Response.json({ access_token, refresh_token, data_plane_token, expires_in })
}
import { db } from './lib/superdb'
export async function entrar(email: string, senha: string, novaSenha?: string) {
// 1. Quem já migrou entra direto.
const direto = await db.auth.signInWithPassword({ email, password: senha })
if (!direto.error) return { ok: true as const }
if (direto.error.code !== 'invalid_credentials') return { erro: 'Não deu para entrar agora.' }
// 2. Talvez ainda esteja no Firebase: o servidor confere e migra.
const r = await fetch('/api/migrar-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, senha, novaSenha }),
})
const corpo = await r.json()
if (corpo.precisaSenhaNova) return { precisaSenhaNova: true as const }
if (!r.ok) return { erro: corpo.erro as string }
// 3. A sessão criada no servidor passa a valer aqui.
await db.auth.setSession(corpo)
return { ok: true as const }
}
Vincule só depois da prova. O servidor passa os dados para a conta nova porque acabou de conferir a senha no Firebase. Não vincule pelo e-mail sozinho: o cadastro com senha não confirma o e-mail, então um e-mail na conta não prova que ela é de quem diz.
- Limite de cadastro: 30 por minuto por IP no projeto, e aqui todos saem do IP do seu servidor. Numa virada com muita gente, alguns recebem 429 e tentam de novo em seguida.
- Contas só com Google no Firebase: a pessoa entra com Google no SuperDB (Signup com Google). Para vincular os dados, o app pede uma vez o login Google do Firebase, ainda ativo, e manda o ID token para o servidor, que confere com
admin.auth().verifyIdToken(token)e vincula pelouidque ele devolve. - Enquanto a migração dura: um hook
before-signup(em Hooks, no painel) pode recusar cadastro com senha de um e-mail que ainda está no Firebase quando não vem do seu servidor. É uma regra de negócio, não uma trava: se o seu endpoint cair, o SuperDB deixa passar. - Depois de algumas semanas, quem não voltou ainda está só no Firebase. Decida: mantenha a rota de migração no ar por mais tempo, ou avise essas pessoas para entrarem com o código por e-mail, que cria a conta, e vincule os dados delas pelo suporte.
Passo 5: Cloud Storage → Storage do SuperDB
O Storage do SuperDB isola projetos, não usuários. Qualquer usuário logado no seu projeto lê, envia e apaga arquivos em todos os buckets dele. Se as suas Storage Rules restringem arquivos por usuário (documentos pessoais, fotos privadas), não traga esses arquivos para cá ainda.
Para o que pode ser compartilhado dentro do app (catálogo, imagens públicas), crie o bucket na tela Storage do projeto e copie:
import admin from 'firebase-admin'
import { createClient } from '@superdb/client'
admin.initializeApp({
credential: admin.credential.cert('./service-account.json'),
storageBucket: '[PROJETO].appspot.com',
})
const superdb = createClient(process.env.SUPERDB_URL!, process.env.SUPERDB_SERVICE_ROLE_KEY!, {
project: process.env.SUPERDB_PROJECT!,
})
const [arquivos] = await admin.storage().bucket().getFiles({ prefix: 'catalogo/' })
for (const arquivo of arquivos) {
const [conteudo] = await arquivo.download()
// Nome completo do bucket: proj_<slug>_catalogo, como aparece no painel
const { error } = await superdb.storage
.from(`proj_${process.env.SUPERDB_PROJECT}_catalogo`)
.upload(arquivo.name.replace(/^catalogo\//, ''), conteudo, {
contentType: arquivo.metadata.contentType,
upsert: true,
})
console.log(error ? `[falhou] ${arquivo.name}: ${error.message}` : `[ok] ${arquivo.name}`)
}
Se o banco guarda URLs absolutas do Firebase (https://firebasestorage.googleapis.com/...), reescreva com um update no SQL Editor; com caminhos relativos, não há o que mudar.
Passo 6: e as Cloud Functions?
- Gatilhos SQL, para lógica de banco: auditoria, cascata, validação, contadores. Rodam dentro da transação, sem custo extra.
- Hooks de auth, para eventos de cadastro, login e MFA: o SuperDB chama uma URL sua, e os
before-*podem recusar. - O seu servidor (Vercel, Railway, Cloud Run, uma VPS), para o resto: pagamento, integrações, imagens. Ele fala com o SuperDB pelo SDK, com a service key.
Não há Edge Functions nem webhook de banco (chamar uma URL a cada INSERT). Para reagir a mudanças no seu servidor, use um gatilho que grava numa tabela de eventos e processe a fila de lá.
create table if not exists auditoria (
id bigint generated always as identity primary key,
tabela text not null,
acao text not null,
linha_id uuid,
autor uuid default auth.uid(),
em timestamptz not null default now()
);
alter table auditoria enable row level security;
revoke all on auditoria from anon, authenticated;
create or replace function auditar_pedido() returns trigger
language plpgsql security definer set search_path from current as $$
begin
insert into auditoria (tabela, acao, linha_id) values ('pedidos', tg_op, coalesce(new.id, old.id));
return coalesce(new, old);
end $$;
drop trigger if exists pedidos_auditoria on pedidos;
create trigger pedidos_auditoria after insert or update or delete on pedidos
for each row execute function auditar_pedido();
Passo 7: troque o SDK no frontend
import { getFirestore, collection, query, where, getDocs } from 'firebase/firestore'
import { getAuth, signInWithEmailAndPassword } from 'firebase/auth'
const auth = getAuth(app)
const firestore = getFirestore(app)
await signInWithEmailAndPassword(auth, email, senha)
const snap = await getDocs(query(collection(firestore, 'orders'), where('userId', '==', auth.currentUser!.uid)))
const pedidos = snap.docs.map((d) => ({ id: d.id, ...d.data() }))
import { createClient } from '@superdb/client'
const db = createClient(
import.meta.env.VITE_SUPERDB_URL, // https://auth.superdb.com.br
import.meta.env.VITE_SUPERDB_ANON_KEY,
{ project: import.meta.env.VITE_SUPERDB_PROJECT },
)
await db.auth.signInWithPassword({ email, password: senha }) // ou o entrar() do passo 4
const { data: pedidos } = await db
.from('pedidos')
.select('id,status,total_centavos,criado_em')
.order('criado_em', { ascending: false })
// sem filtro por dono: o RLS já devolve só os pedidos de quem está logado
O equivalente do onAuthStateChanged é o db.auth.onAuthStateChange((evento, sessao) => ...). A sessão se renova sozinha antes de o token vencer, como no Firebase.
Passo 8: listeners em tempo real
Como as Security Rules no onSnapshot, o realtime segue o RLS: cada inscrito recebe só as linhas que ele leria pela API. Para isso, peça o token de realtime com a sessão do usuário (db.auth.getDataPlaneToken()). Com a anon key, o realtime entrega só o que um visitante sem login leria.
import { RealtimeClient } from '@supabase/realtime-js'
import { db } from './lib/superdb'
async function tokenDeRealtime() {
const credencial = (await db.auth.getDataPlaneToken()) ?? import.meta.env.VITE_SUPERDB_ANON_KEY
const r = await fetch('https://auth.superdb.com.br/rt/v1/token', {
method: 'POST',
headers: { Authorization: `Bearer ${credencial}` },
})
return r.json() // { token, url, expires_in }: vale 1 hora
}
const { token, url } = await tokenDeRealtime()
const rt = new RealtimeClient(url, { params: { apikey: token } })
const canal = rt
.channel(`proj_${import.meta.env.VITE_SUPERDB_PROJECT}:pedidos`, { config: { private: true } })
.on('postgres_changes',
{ event: '*', schema: `proj_${import.meta.env.VITE_SUPERDB_PROJECT}`, table: 'pedidos' },
(mudanca) => aplicarMudanca(mudanca))
.subscribe()
// antes de vencer, e ao entrar ou sair da conta: rt.setAuth((await tokenDeRealtime()).token)
// ao sair da tela: rt.removeChannel(canal)
Regra que consulta outra coleção (por exemplo, "só membros do grupo") precisa usar uma função security definer para valer no realtime. A receita de chat mostra como.
O onSnapshot manda o estado completo a cada mudança; o postgres_changes manda só a linha que mudou. Carregue a lista uma vez pelo REST e aplique as mudanças em cima dela.
Checklist final
- ☐ Tabelas criadas no SQL Editor, com chaves estrangeiras, índices e
firebase_uidnas tabelas com dono - ☐ Dados importados com a service key; conferência de algumas linhas por amostragem
- ☐ RLS ligado e com policy em todas as tabelas (a tela Saúde aponta as que estão sem RLS)
- ☐ Rota de migração no login testada com uma conta real do Firebase, inclusive senha de 6 caracteres
- ☐ Login com Google configurado no SuperDB e vínculo das contas só-Google testado
- ☐ Só arquivos compartilháveis copiados para o Storage
- ☐ Frontend trocado: consultas, login, renovação da sessão
- ☐ Realtime ligado só em tabelas sem conteúdo privado
- ☐ Cloud Functions levadas para gatilhos, hooks ou o seu servidor
Quanto custa
O SuperDB cobra um plano fixo em real, sem cobrança por leitura. O Pro custa R$ 119/mês e inclui 8 GB de banco, 100 GB de arquivos, 2 TB de transferência e 250 mil usuários ativos por mês. Compare com a sua fatura do Firebase dos últimos meses, e confira os outros planos em Preços.
O tamanho dos dados muda com o formato: meça depois da importação, na tela Uso do projeto, antes de escolher o plano.