Files
agenciapsilmno/novo-rumo.txt
T
Leonardo 05c6746e33 schema-per-tenant: F0 categorizacao + F1 template/helpers + F2 provisionamento
- docs/F0_categorizacao.md: varredura completa (137 tabelas -> 84 tenant + 53
  global, 66 funcoes, FKs, policies, edge functions) + decisoes Q1-Q4
- F1 (migrations 01-05): tenants.slug, helpers de schema, _tenant_template
  (84 tabelas sem tenant_id, singletons, views __SCHEMA__/__TENANT_ID__),
  clone_tenant_template/drop_tenant_schema, channel_routing, tenant_schemas
- F2 (migration 06): provision_account_tenant/create_clinic_tenant/
  ensure_personal_tenant_for_user clonam schema na mesma transacao
- db.cjs: psqlFile agora usa ON_ERROR_STOP=1 (falha de migration nao passa
  mais como sucesso silencioso)
- blueprint original em novo-rumo.txt; wiki Obsidian atualizada

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:58:46 -03:00

339 lines
17 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Prompt: Refactor Multi-Tenant para Schema-per-Tenant em Supabase
Contexto e objetivo
Estou migrando meu sistema multi-tenant de RLS-only com tenant_id em cada tabela para schema-per-tenant (tenant_<slug>
com clones físicos da estrutura). Quero isolamento físico das tabelas que pertencem a um tenant, mantendo em public
apenas tabelas globais (auth.users, profiles, tenants, planos SaaS, notificações de sistema, etc.).
Já fiz esse refactor num projeto irmão (Vue 3 + Supabase + Postgres 17). Quero que você execute o mesmo aqui,
considerando as lições que aprendi.
Antes de começar — varredura obrigatória
Não confie na lista que o usuário (ou um amigo programador) te entregar. Verifique tudo:
1. Liste TODAS as tabelas em public e classifique cada uma como "tenant-scoped" ou "global". Use a heurística: tem
coluna tenant_id? É candidata a tenant-scoped. Mas reveja caso a caso — algumas globais (tenant_features,
tenant_audit_log, support_messages) também têm tenant_id como FK e devem ficar em public.
SELECT table_name,
EXISTS(SELECT 1 FROM information_schema.columns c
WHERE c.table_schema='public' AND c.table_name=t.table_name
AND c.column_name='tenant_id') AS has_tenant_id
FROM information_schema.tables t
WHERE table_schema='public' AND table_type='BASE TABLE'
ORDER BY table_name;
2. Liste TODAS as funções em public que referenciam essas tabelas-tenant. Não confie em listas pré-feitas — eu recebi
"29 funções" e eram na verdade 52. Use:
WITH tenant_tabs AS (SELECT unnest(ARRAY[/* sua lista */]) AS tab)
SELECT DISTINCT p.proname, p.prokind, l.lanname
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
JOIN pg_language l ON l.oid = p.prolang
CROSS JOIN tenant_tabs t
WHERE n.nspname='public'
AND pg_get_functiondef(p.oid) ~ ('\m' || t.tab || '\M')
ORDER BY 1;
3. Liste FKs cross-schema (de tabelas que vão ficar em public, apontando pras que vão sair). Se houver, planeje
cuidado especial.
4. Liste todas as edge functions e grep cada uma por .from('<tabela_tenant>').
5. Liste as policies RLS que usam funções a refatorar — vão precisar ser dropadas/recriadas.
Plano de execução em fases
F0 — Categorização (não codar nada ainda)
Faça as listagens acima. Salve em documento markdown na raiz: docs/F0_categorizacao.md. Conte tabelas, funções, edge
functions, FKs cross-schema, policies dependentes. Pause e mostre pro usuário antes de seguir.
F1 — Template + helpers
- Crie schema _tenant_template com TODAS as tabelas tenant-scoped clonadas SEM a coluna tenant_id (compostos unique
também perdem tenant_id). Inclua índices, FKs locais, sequences, constraints.
- Crie helpers em public:
- tenant_schema_name(slug text) → text (IMMUTABLE) — converte slug→nome de schema sanitizado.
- tenant_schema_for(tenant_id uuid) → text (STABLE) — busca slug e devolve schema.
- tenant_id_for_schema(schema text) → uuid (STABLE) — inverso. CRÍTICO pra triggers que precisam descobrir o
tenant_id (porque a coluna não existe mais nas tabelas tenant).
- current_tenant_schema() → text (STABLE SECURITY DEFINER) — lê profiles.tenant_id do auth.uid() e devolve o schema
dele.
- clone_tenant_template(slug) → void (SECURITY DEFINER) — clona o template pra um schema novo.
- drop_tenant_schema(tenant_id) → void — proteção: assert que target LIKE 'tenant_%' antes de DROP CASCADE.
F2 — Provisionamento
- Adapte sua função/edge provision_from_intent (ou equivalente) pra chamar clone_tenant_template(slug) quando criar
tenant novo.
- Confirme que policies padrão são criadas no schema clonado (uma policy tenant_member_full TO authenticated filtrando
por profiles.tenant_id = '<id-do-tenant>').
F3 — Frontend: composable de acesso tenant
- Crie useTenantDb.js:
export function useTenantDb() {
const { perfil } = useAuth();
const schemaName = computed(() => tenantSchemaName(perfil.value?.tenant_slug));
const isReady = computed(() => Boolean(schemaName.value));
function db() {
if (!schemaName.value) throw new Error('tenant não disponível');
return supabase.schema(schemaName.value);
}
return { db, schemaName, isReady };
}
- Faça find/replace amplo: supabase.from('<tenant_table>') → db().from('<tenant_table>') em todas as
views/components/composables que tocam tabelas tenant.
F4 — Edge functions
Padrão pra qualquer edge function que precisa acessar tabela tenant:
const userClient = createClient(SUPABASE_URL, ANON_KEY, {
global: { headers: { Authorization: authHeader } }
});
const { data: tenantSchema } = await userClient.rpc('current_tenant_schema');
const tenantDb = userClient.schema(tenantSchema as string);
await tenantDb.from('oficios').update(...).eq(...);
Tabelas globais (profiles, tenants, addon_*, support_*, etc.) seguem usando userClient.from(...) direto.
F5 — Expor schemas no PostgREST
Edite supabase/config.toml:
[api]
schemas = ["public", "graphql_public", "tenant_<slug1>", "tenant_<slug2>", ...]
extra_search_path = ["public", "extensions"]
Restart Supabase. Toda criação de tenant novo precisa atualizar este array e restartar PostgREST — automatize via
migration que regenera config.toml, ou aceite gerenciamento manual.
F6 — Rewrite funções + drop tabelas em public (a fase mais perigosa)
Divida em lotes pequenos e teste cada um:
Lote 1 — split de notifications
Caso especial crítico. Antes do split, identifique:
- Tipos de notif que cruzam tenants (dev recebe de todos os tenants, support_reply enviado pelo dev pro tenant,
system_alert global).
- Tipos que são puramente tenant-local (voucher_gerado, os_atribuida, oficio_assinado, prazos).
Decisão estrutural: notifications precisa virar duas tabelas:
- tenant_<slug>.notifications — locais do tenant.
- public.notifications_sistema — cross-tenant (SaaS pro tenant, ou pro dev).
Migration faz:
1. Cria public.notifications_sistema (mesma estrutura + RLS própria + adiciona à publication realtime).
2. Migra dados: INSERT INTO notifications_sistema SELECT ... WHERE type IN (cross_tenant_types), depois loop por
tenant INSERT INTO tenant_X.notifications SELECT ... WHERE tenant_id = X AND type IN (local_types).
3. Refatora todas as funções de notif (notify_user, notify_user_sistema, notify_tenant_admins, notify_all_devs,
mark/archive_*) — duas variantes (_sistema_ em public, outras EXECUTE format pro schema tenant).
4. DROP TABLE public.notifications.
5. Frontend useNotifications.js: lê das duas fontes em paralelo, mescla por created_at DESC, cada item ganha campo
_origem: 'tenant' | 'sistema'. Realtime em 2 canais. markRead/archive roteiam pra RPC correta via _origem.
Lote 2-4 — refator das demais funções
Padrão pra TRIGGER em tabela tenant:
CREATE OR REPLACE FUNCTION public.trg_xxx() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE v_tenant_id uuid;
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA); -- só se precisar
-- ... lógica com tabelas tenant SEM prefixo `public.` ...
END $$;
Padrão pra RPC chamada por user logado em um tenant:
CREATE OR REPLACE FUNCTION public.minha_rpc(...) RETURNS ...
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE v_schema text := public.current_tenant_schema();
BEGIN
IF v_schema IS NULL THEN RAISE EXCEPTION 'sem tenant'; END IF;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
-- ... lógica ...
END $$;
Padrão pra RPC global (cron, dev, varre múltiplos tenants):
FOR t_row IN SELECT id, slug FROM public.tenants WHERE ativo = true LOOP
v_schema := public.tenant_schema_name(t_row.slug);
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN CONTINUE; END IF;
EXECUTE format('UPDATE %I.tabela ...', v_schema);
END LOOP;
Padrão pra função que escreve no schema de OUTRO tenant (notify_user com p_tenant_id, etc.):
v_schema := public.tenant_schema_for(p_tenant_id);
IF v_schema NOT LIKE 'tenant_%' THEN RETURN; END IF;
EXECUTE format('INSERT INTO %I.notifications (...) VALUES ($1, $2, ...)', v_schema)
USING ...;
Lote 4.5 — migração de DADOS (esqueci de avisar primeiro, vai se ferrar)
ESSE É O ERRO MAIS COMUM: o template clona estrutura, mas você esquece dos DADOS. Depois descobre que
tenant_sindspam.os está vazio porque você nunca migrou. Faça uma migration que:
SET session_replication_role = replica; -- desabilita FK checks
DO $$
DECLARE
tenant_id_target uuid := '...';
tenant_schema text := 'tenant_...';
tabs text[] := ARRAY[/* lista */];
t text;
v_cols text;
BEGIN
FOREACH t IN ARRAY tabs LOOP
-- Lista colunas do schema tenant (sem tenant_id já)
SELECT string_agg(quote_ident(column_name), ', ' ORDER BY ordinal_position)
INTO v_cols
FROM information_schema.columns
WHERE table_schema = tenant_schema AND table_name = t;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name=t AND column_name='tenant_id') THEN
EXECUTE format(
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I WHERE tenant_id = %L ON CONFLICT DO NOTHING',
tenant_schema, t, v_cols, v_cols, t, tenant_id_target);
ELSE
EXECUTE format(
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I ON CONFLICT DO NOTHING',
tenant_schema, t, v_cols, v_cols, t);
END IF;
END LOOP;
END $$;
-- Reset sequences:
FOR r IN SELECT t.table_name, c.column_name FROM information_schema.tables t
JOIN information_schema.columns c ON c.table_schema=t.table_schema AND c.table_name=t.table_name
WHERE t.table_schema=tenant_schema AND c.data_type='bigint' AND c.column_default LIKE 'nextval(%' LOOP
v_seq := pg_get_serial_sequence(format('%I.%I', tenant_schema, r.table_name), r.column_name);
EXECUTE format('SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I.%I), 0))',
v_seq, r.column_name, tenant_schema, r.table_name);
END LOOP;
SET session_replication_role = origin;
Lote 5 — DROP CASCADE das tabelas em public
Só depois de TODAS as funções refatoradas e dados migrados:
SET session_replication_role = replica;
DO $$ BEGIN
FOREACH t IN ARRAY tabs LOOP
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=t) THEN
EXECUTE format('DROP TABLE public.%I CASCADE', t);
END IF;
END LOOP;
END $$;
SET session_replication_role = origin;
Limitações conhecidas e workarounds
1. PostgREST não suporta embed FK cross-schema
Você vai pagar esse pato. O PostgREST 14.x não consegue resolver embeds tipo db().from('os').select('*,
profiles!os_solicitante_profile_id_fkey(nome)') quando os está em tenant_X e profiles em public, mesmo com FK física
existindo. Mensagem: PGRST200: Could not find a relationship between 'os' and 'profiles' in the schema cache.
Solução: helper de "fake embed" no frontend. Crie useProfileEmbed.js:
export async function attachProfiles(rows, mappings, columns = 'id, nome, email, role') {
if (!rows?.length) return rows;
const allIds = new Set();
for (const m of mappings) rows.forEach(r => { if (r?.[m.idField]) allIds.add(r[m.idField]); });
const { data } = await supabase.from('profiles').select(columns).in('id', [...allIds]);
const map = new Map((data || []).map(p => [p.id, p]));
return rows.map(r => {
const out = { ...r };
for (const m of mappings) out[m.alias] = r?.[m.idField] ? map.get(r[m.idField]) || null : null;
return out;
});
}
// Variantes: attachProfilesNested(rows, nestedKey, mappings), attachProfilesById(rows, idField, alias)
Faz 2 queries + merge em JS. Toda tela que tinha profiles!fkey(...) precisa virar duas queries + attach.
2. %ROWTYPE de tabelas tenant
Funções que declaravam v_plano public.convenio_planos%ROWTYPE quebram quando a tabela some do public. Troque por
RECORD em todas. Quando precisar retornar tabela (RETURNS os_problemas), troque por RETURNS jsonb e construa via
jsonb_build_object(...).
3. SQL functions com SET search_path TO 'public' declarado
Algumas funções são LANGUAGE sql com declaração estática SET search_path TO 'public'. Não dá pra usar set_config
dinâmico em SQL puro. Converta pra LANGUAGE plpgsql. Atenção: isso exige DROP + CREATE (CREATE OR REPLACE não muda
linguagem) → se tiver policy dependendo da função, drope a policy primeiro.
4. Triggers de notif que filtram cada destinatário
notify_tenant_admins insere em múltiplos owners via SELECT ... FROM profiles WHERE role IN (...). Pra respeitar
preferências individuais, adicione AND public.should_notify(p.id, p_type) no WHERE.
5. Realtime
- A tabela notifications_sistema precisa ser adicionada explicitamente à publication: ALTER PUBLICATION
supabase_realtime ADD TABLE public.notifications_sistema.
- Canais realtime no frontend precisam do schema correto: { event: '*', schema: 'tenant_<slug>', table:
'notifications', filter: 'owner_id=eq.X' } — não mais schema: 'public'.
6. Filtros .eq('tenant_id', X) no frontend
Após o split, qualquer db().from('tabela_tenant').eq('tenant_id', X) quebra com column tenant_id does not exist — a
coluna sumiu. Faça grep e remova esses filtros (o isolamento agora é pelo schema). Mantenha em tabelas que ficam em
public (tenant_features, tenant_audit_log, profiles).
7. session_replication_role na migração de dados
INSERTs em massa com FKs entre tabelas tenant podem falhar por ordem topológica. SET session_replication_role =
replica desabilita checks de FK durante o INSERT. Lembre de voltar pra origin ao final.
8. Reset de sequences
Tabelas tenant com id bigint generated by sequence precisam de setval pós-migração — senão próximo INSERT vai colidir
com PKs existentes.
9. Policies que usam funções refatoradas
unidade_in_current_tenant(uuid) aparecia como USING (...) em policies de public.prestador_unidade_acessos. Antes de
DROP+CREATE da função, dropei as 2 policies. Tabelas que vão sumir não precisam recriar policy. Se a função é usada em
policies de tabelas que ficam, recrie a policy depois.
10. FKs de tabelas que ficam em public apontando pras que saem
Antes de DROP, rode query pra detectar. Se houver, decida: migra a tabela referenciadora pro tenant também, ou
converte FK pra coluna solta sem constraint.
Frontend — refactor sistemático
1. Find/replace em massa: supabase.from('<lista_tabelas_tenant>') → db().from(...). Importe useTenantDb.
2. Caça por .eq('tenant_id': remova nos from('<tenant_table>'), mantenha nos from('<public_table>').
3. Caça por embed profiles!fkey(...) em queries de tabelas tenant: refatore com attachProfiles.
4. Caça por subscribeRealtime com schema: 'public' pra tabelas que viraram tenant — troque pra schema:
tenantSchemaName(slug).
5. Composables/serviços que usam supabase.from(...) em vez de db() direto: idem.
Backups e segurança
Sempre faça backup antes de cada lote:
docker exec supabase_db_<projeto> pg_dump -U postgres -d postgres --schema=public --no-owner --no-acl >
backups/pre-loteN/public.sql
docker exec supabase_db_<projeto> pg_dump -U postgres -d postgres --schema=tenant_<slug> --no-owner --no-acl >
backups/pre-loteN/tenant_<slug>.sql
Pra recarregar cache do PostgREST após mudanças:
docker exec supabase_db_<projeto> psql -U postgres -d postgres -c "NOTIFY pgrst, 'reload schema'"
Se mudou config.toml (schemas expostos), restart obrigatório:
docker restart supabase_rest_<projeto>
Checklist final por lote
Antes de marcar um lote como concluído:
- Migration aplica sem erro (psql -v ON_ERROR_STOP=1)
- Smoke test SQL chamando as funções refatoradas via SET LOCAL request.jwt.claim.sub
- NOTIFY pgrst, 'reload schema' rodado
- Usuário testou as telas do FE que tocam essas funções
- Sem erros novos no console do navegador (network 4xx/5xx, PGRST200, etc.)
Como interagir comigo durante o trabalho
- Antes de codar qualquer fase, mostre o plano resumido e pergunte se prossegue.
- Para decisões estruturais (ex: notifications split, função X retorna jsonb ou record composto, drop CASCADE de
policy órfã), use perguntas múltipla escolha — não decida sozinho.
- Ao terminar um lote, sumarize o que mudou + lista de coisas pra eu testar no FE.
- Não confie em listas pré-feitas (suas ou do usuário). Sempre re-confirme via query no banco.
- Backup antes de cada DROP destrutivo.
- PostgREST cache é teimoso — NOTIFY pgrst resolve tabelas/funções; restart do container pra mudanças de config.toml.