Pular para o conteúdo
🔄 GUIAS

Migrar do Firebase para o SuperDB.

O Firestore é NoSQL, e o SuperDB é Postgres: a migração troca o paradigma, e em troca vêm SQL, joins, RLS e preço fixo em real. Este guia mapeia coleções para tabelas e regras para RLS, e migra os usuários no primeiro login, com a mesma senha, sem desligar o Firebase de uma vez.

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.

AspectoFirebaseSuperDB
Modelo de dadosNoSQL (documentos)Postgres (relacional)
ConsultasSem JOIN, agregação limitadaSQL completo, views, funções
Regras de acessoSecurity RulesRLS no Postgres
CobrançaPor leitura, escrita e GB, em dólarPlano fixo em real (Pro a R$ 119/mês)
Self-hostNão existeAinda não: o código é AGPL-3.0, mas o repositório é privado
SuporteEm inglêsEm 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.

FirebaseSuperDBObservação
CollectionTabelaCriada no schema do projeto, pelo SQL Editor
DocumentLinhaO id do documento vai para uma coluna firebase_id
SubcollectionTabela com chave estrangeirausers/{uid}/orders vira pedidos.user_id
Security RulesPolicies de RLSSintaxe diferente, mesma intenção
Firebase AuthAuth do SuperDBSenha, código por e-mail e WhatsApp, Google, GitHub e Apple
Cloud StorageStorage do SuperDBBuckets pelo SDK; leia o aviso do passo 5
onSnapshotRealtime (postgres_changes)WebSocket; leia o aviso do passo 8
Cloud FunctionsGatilhos SQL, hooks de auth ou o seu servidorNão há Edge Functions
FCM (push)Push do SuperDBAparelhos, 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 projetoTempo típicoO que mais toma tempo
Pequeno (menos de 5 coleções)1 a 2 diasModelar tabelas e reescrever as consultas
Médio (10 a 30 coleções)1 semanaSubcoleções viram chaves estrangeiras; regras viram RLS
Grande (Cloud Functions extensas)2 a 4 semanasLevar 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:

scripts/exportar-firestore.ts
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.

superdb/pedidos.sql (SQL Editor)
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:

scripts/importar.ts
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:

firestore.rules (antes)
match /orders/{orderId} {
  allow read, update, delete: if request.auth.uid == resource.data.userId;
  allow create: if request.auth.uid == request.resource.data.userId;
}
superdb/pedidos.sql (depois)
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 para anon e authenticated
  • get(/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:

  1. A tela de login tenta o SuperDB. Deu certo: a pessoa já migrou.
  2. Não deu: o app manda e-mail e senha para uma rota do seu servidor, que confere a senha no Firebase.
  3. A senha confere: o servidor cria a conta no SuperDB com a mesma senha (signUp), passa os dados daquele firebase_uid para a conta nova e devolve a sessão.
app/api/migrar-login/route.ts (servidor)
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 })
}
src/entrar.ts (navegador)
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 pelo uid que 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:

scripts/copiar-storage.ts
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?

  1. Gatilhos SQL, para lógica de banco: auditoria, cascata, validação, contadores. Rodam dentro da transação, sem custo extra.
  2. Hooks de auth, para eventos de cadastro, login e MFA: o SuperDB chama uma URL sua, e os before-* podem recusar.
  3. 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á.

gatilho de auditoria (substitui uma Cloud Function)
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

antes: Firestore
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() }))
depois: SuperDB
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.

depois: postgres_changes com o usuário da sessão
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_uid nas 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.

Essa página ajudou?