docs: runbook de deploy da schema-per-tenant (hosted)

docs/DEPLOY_SCHEMA_PER_TENANT.md — pre-requisito do freemium. Cutover em fases:
- estrategia copia-nao-move (public + schemas coexistem ate o DROP)
- Risco #1 hosted: exposicao dinamica de schemas no PostgREST (ALTER ROLE
  authenticator) + fallback Exposed schemas no dashboard
- Fase A migrations aditivas / B manual privilegiados / C pgrst dinamico (testar
  cedo) / D migracao de dados (janela) / E frontend+edge / F smoke+soak /
  G F6.3 DROP (gated, irreversivel)
- rollback por fase (botao de panico = redeploy do codigo antigo ate o DROP)
- freemium pode entrar apos as Fases A-F, sem depender do DROP

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-06-13 20:46:56 -03:00
parent 1082123967
commit 91b89b7b5d
+212
View File
@@ -0,0 +1,212 @@
# Deploy — Migração Schema-per-Tenant (hosted)
> Runbook de produção da migração RLS-only → schema físico por tenant
> (branch `feat/schema-per-tenant`). **Pré-requisito do freemium** (ver
> `docs/DEPLOY_FREEMIUM_F4.md`). Gerado em 2026-06-13.
>
> ⚠️ Esta é a migração **mais delicada do projeto**: envolve migração de DADOS,
> exposição dinâmica de schemas no PostgREST e um DROP **irreversível** no fim.
> Faça em **janela de manutenção**, com backup fresco, um passo de cada vez.
---
## Estratégia de cutover (por que é seguro)
O desenho **COPIA** os dados (não move) de `public` pros schemas `tenant_<slug>` e
só remove o espelho de `public` no **último** passo (F6.3 DROP). Durante a transição,
os dados existem nos **dois lugares** → o código antigo (lê `public`) e o novo
(lê `tenant_<slug>`) funcionam simultaneamente. Isso permite:
```
estrutura aditiva → migra dados (copia) → sobe código novo → valida → (só então) DROP
```
Se algo der errado **antes do DROP**, é só voltar o frontend/edge pra versão antiga
(que lê `public`, intacto). O DROP é o único ponto de não-retorno.
---
## ⚠️ Risco hosted #1 — exposição dinâmica de schemas no PostgREST
Local: `refresh_pgrst_schemas()` faz `ALTER ROLE authenticator SET pgrst.db_schemas=...`
+ `NOTIFY pgrst, 'reload config'` (config in-database, persiste em `pg_db_role_setting`).
Um trigger em `public.tenant_schemas` re-roda isso a cada clone/drop.
No **Supabase hosted** isso precisa ser confirmado:
- O hosted suporta a config in-DB do PostgREST, MAS a permissão de `ALTER ROLE
authenticator` pode estar restrita à role de serviço. **Teste cedo** (Fase C):
rode `select public.refresh_pgrst_schemas();` e cheque se os schemas tenant
passam a responder via REST.
- Fallback se o `ALTER ROLE` falhar no hosted: adicionar os schemas em
**Dashboard → Project Settings → API → Exposed schemas** (lista). Problema: é
**estática** — cada signup novo cria um schema que precisaria entrar na lista.
Mitigação: manter o trigger in-DB (se funcionar) OU automatizar via Management API.
**Decidir isso ANTES de abrir pra signup self-service.**
> Sem exposição dos schemas tenant, o app novo recebe 404/empty nas tabelas tenant.
---
## Inventário (branch `feat/schema-per-tenant`)
### Migrations (aditivas — rodam como `postgres` / `supabase db push`)
Ordem natural por timestamp:
```
20260612000001_f1_tenants_slug.sql # tenants.slug + generate_tenant_slug + trigger
20260612000002_f1_tenant_schema_helpers.sql # tenant_schema_name, tenant_id_for_schema, ...
20260612000003_f1_tenant_template.sql # _tenant_template (78 tabelas, views, seeds)
20260612000004_f1_clone_drop_functions.sql # clone_tenant_template, drop_tenant_schema, tenant_schemas, channel_routing
20260612000005_f1_template_seed_whitelist.sql # limpa seeds órfãos
20260612000006_f2_provision_clone.sql # provision_* chamam clone
20260612000007_f3_my_tenants_slug.sql # my_tenants() retorna slug
20260613000001_f1b_keep_anon_tables_public.sql# 6 tabelas anon ficam em public
20260613000002_f5_pgrst_schemas_trigger.sql # trigger pgrst refresh em tenant_schemas
20260613000003_f6_0_clone_existing_tenants.sql# clona os tenants já existentes
20260613000004_f6_2a_attach_agnostic_triggers.sql # Lote A (triggers agnósticos)
```
> As 3 migrations `*_freemium_*` / `*_fix_audit_*` (000005/06/07) são do **freemium** —
> aplicar só no deploy do freemium (depois). A `fix_audit` pode (e deve) vir já aqui se
> for testar provisionamento, mas é inócua antes.
### Manual `supabase_admin` (privilegiadas — ordem obrigatória)
```
f5_pgrst_refresh_schemas.supabase_admin.sql # refresh_pgrst_schemas (ALTER ROLE authenticator)
f6_2b_schema_aware_triggers.supabase_admin.sql# Lote B (14 trigger funcs schema-aware)
f6_2c_notifications_split.supabase_admin.sql # Lote C (notifications_sistema + triggers)
f6_2d_user_rpcs.supabase_admin.sql # Lote D (14 user RPCs + _tenant_route)
f6_2e_cron_rpcs.supabase_admin.sql # Lote E (cron RPCs + _tenant_schema_unchecked)
f6_2f_anon_token_rpcs.supabase_admin.sql # Lote F (anon/token RPCs)
f6_2g_sql_to_plpgsql.supabase_admin.sql # Lote G (5 SQL→plpgsql)
f6_2h_clone_wiring.supabase_admin.sql # wiring: tenants novos nascem com triggers
f6_4_saas_admin_rpcs.supabase_admin.sql # SaaS-admin RPCs (feriados/notif/whatsapp)
# DADOS:
f6_1_migrate_data.supabase_admin.sql # cutover: COPIA dados public→schemas
# DROP (último, gated):
f6_3_drop_public_tenant_tables.supabase_admin.sql # 🛑 ponto de não-retorno
```
Rollback do DROP documentado em `database-novo/manual/f6_3_ROLLBACK.md`.
### Frontend / Edge (vão no rebuild + deploy)
- `src/lib/supabase/tenantClient.js`, `src/composables/useTenantDb.js`, `tenantStore` (slug/schema getters), `notificationStore` (dual-source), e os `supabase.from(...)` → `tenantDb().from(...)` espalhados.
- `supabase/functions/_shared/tenant.ts` + os webhooks/crons que passaram a rotear por schema.
### Config
- `supabase/config.toml [api] schemas` permanece `["public","graphql_public"]` — os
tenant são expostos **dinamicamente** (não na lista). Confirmar no hosted (Risco #1).
---
## Passo a passo
### Fase 0 — Pré-flight
- [ ] **Backup completo** do hosted (dashboard → Database → Backups, ou `pg_dump`).
- [ ] Confirmar que o hosted está no baseline (branch `main`/RLS) e estável.
- [ ] Janela de manutenção combinada (a Fase D é cutover de dados).
- [ ] Ter a connection string de **serviço** em mãos (algumas etapas exigem role elevada).
### Fase A — Estrutura aditiva (migrations)
Aplicar as 11 migrations `20260612*`/`20260613000001..000004` (e a `fix_audit` 000006).
Via `supabase db push` (com a branch linkada) ou colando no **SQL Editor** na ordem.
São **aditivas** — criam slug, helpers, `_tenant_template`, funções de clone, registry
`tenant_schemas`, e **clonam os tenants existentes** (000003 = f6_0). Não tocam dados.
**Verificar:**
```sql
select count(*) from public.tenant_schemas; -- = nº de tenants
select tenant_schema_name((select id from tenants limit 1)); -- 'tenant_<slug>'
select count(*) from information_schema.schemata where schema_name like 'tenant_%';
```
### Fase B — Funções/triggers privilegiados (manual)
Aplicar, **na ordem**, via connection string de serviço (ou SQL Editor se permitir):
`f6_2b → f6_2c → f6_2d → f6_2e → f6_2f → f6_2g → f6_2h → f6_4`.
(São CREATE OR REPLACE / idempotentes.)
> Vários fazem `ALTER FUNCTION ... OWNER TO supabase_admin`. Se a role disponível no
> hosted não permitir, troque pra `OWNER TO postgres` (sem perda funcional) — mesma
> nota do runbook do freemium.
### Fase C — PostgREST dinâmico (CRÍTICO — testar cedo)
Aplicar `f5_pgrst_refresh_schemas.supabase_admin.sql` e disparar:
```sql
select public.refresh_pgrst_schemas(); -- seta pgrst.db_schemas + NOTIFY reload
```
**Teste real:** via REST (anon/auth key do hosted), bater numa tabela de um schema tenant
(ex.: `GET /rest/v1/patients` com header `Accept-Profile: tenant_<slug>`). Deve responder
(200/empty), não 404 "schema not exposed".
- ✅ funcionou → seguir.
- ❌ falhou (`ALTER ROLE authenticator` negado) → aplicar o **fallback** do Risco #1
(Exposed schemas no dashboard) antes de prosseguir, e planejar a automação por signup.
### Fase D — Migração de DADOS (cutover, janela de manutenção)
Aplicar `f6_1_migrate_data.supabase_admin.sql` (precisa `session_replication_role=replica`
→ role de serviço). **COPIA** os dados public→schemas (idempotente, ON CONFLICT DO NOTHING).
**Verificar paridade** (por tabela/tenant — exemplo com `patients`):
```sql
-- public (origem) vs schema (destino) devem bater por tenant
select t.slug,
(select count(*) from public.patients p where p.tenant_id=t.id) as em_public,
-- ajuste o schema dinamicamente / rode por tenant:
null as em_schema
from public.tenants t order by t.slug;
-- e por schema: select count(*) from tenant_<slug>.patients;
```
Repetir o spot-check nas tabelas de maior volume (conversation_messages, financial_records, agenda_eventos).
### Fase E — Frontend + Edge (sobe o código novo)
- Deploy das **edge functions** alteradas (`supabase functions deploy <nome>` pras que
mudaram: webhooks twilio/evolution inbound, crons de fila, `_shared/tenant.ts` é embutido).
- **Rebuild + publish do frontend** da branch (agora `tenantDb().from(...)` lê os schemas).
- A partir daqui o app **lê/escreve nos schemas tenant**. Como os dados foram copiados na
Fase D e `public` ainda existe, nada quebra mesmo se algum ponto antigo escapar.
### Fase F — Smoke test (app no modelo novo)
- [ ] Login em 2-3 tenants distintos → agenda, pacientes, financeiro, conversas carregam.
- [ ] Criar/editar registros → conferir que gravam em `tenant_<slug>` (não em `public`).
- [ ] Notificações (sino) — dual-source (tenant + `notifications_sistema`).
- [ ] Webhook inbound (twilio/evolution) grava no schema certo (roteamento por canal).
- [ ] Crons (fila de notificação/email) varrem os tenants.
- [ ] Provisionar um tenant NOVO de teste → nasce com schema + triggers (wiring f6_2h).
- [ ] **Deixar rodando alguns dias** com os dados ainda espelhados em public (rede de segurança).
### Fase G — F6.3 DROP (🛑 PONTO DE NÃO-RETORNO)
**Só depois** de F validada por dias + sem incidentes. Sequência:
1. **Backup fresco obrigatório** (o header do f6_3 traz o `pg_dump --schema=public`).
2. Reler `database-novo/manual/f6_3_ROLLBACK.md`.
3. Aplicar `f6_3_drop_public_tenant_tables.supabase_admin.sql` (role de serviço):
pré-flight asserts → 2 FK→coluna solta → drop 9 views → DROP CASCADE 78 tabelas public.
4. Smoke test final. A partir daqui `public` não tem mais as tabelas tenant — só schemas.
---
## Rollback por fase
- **Fases AC** (estrutura/funções/pgrst): aditivas. Reverter = dropar os schemas/funções
novos; `public` intacto, app antigo segue. Sem perda.
- **Fase D** (dados): só copiou; reverter = ignorar/limpar schemas. `public` é a verdade.
- **Fase E** (código): **rollback = redeploy do frontend/edge da versão antiga** (lê public).
Esse é o botão de pânico até o DROP.
- **Fase G** (DROP): irreversível sem restore. Rollback = restaurar do backup (ver
`f6_3_ROLLBACK.md`). Por isso só após dias de validação.
---
## Ordem geral dos dois épicos
```
schema-per-tenant Fases AF → (rodar dias) → schema-per-tenant Fase G (DROP)
└─ freemium (DEPLOY_FREEMIUM_F4.md) pode entrar
logo após as Fases AF (não depende do DROP)
```
> O freemium **não** depende do DROP (F6.3) — depende da infra (Fases AF). Dá pra subir
> o freemium assim que o schema-per-tenant estiver validado no hosted, mantendo o espelho
> em public como rede de segurança, e fazer o DROP com calma depois.
## Checklist
- [ ] Fase 0: backup + janela + baseline confirmado
- [ ] Fase A: 11 migrations aplicadas + verificação
- [ ] Fase B: 9 manual (b→4) na ordem
- [ ] Fase C: pgrst dinâmico testado via REST (ou fallback decidido)
- [ ] Fase D: f6_1 + paridade de contagens conferida
- [ ] Fase E: edges + frontend novos publicados
- [ ] Fase F: smoke test + dias de soak
- [ ] Fase G: backup fresco → DROP → smoke final