Files
agenciapsilmno/docs/DEPLOY_SCHEMA_PER_TENANT.md
T
Leonardo 91b89b7b5d 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>
2026-06-13 20:46:56 -03:00

213 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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