From 91b89b7b5d4c49aa9bc3c7358f7e44dcef087922 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Sat, 13 Jun 2026 20:46:56 -0300 Subject: [PATCH] docs: runbook de deploy da schema-per-tenant (hosted) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/DEPLOY_SCHEMA_PER_TENANT.md | 212 +++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 docs/DEPLOY_SCHEMA_PER_TENANT.md diff --git a/docs/DEPLOY_SCHEMA_PER_TENANT.md b/docs/DEPLOY_SCHEMA_PER_TENANT.md new file mode 100644 index 0000000..1d940ef --- /dev/null +++ b/docs/DEPLOY_SCHEMA_PER_TENANT.md @@ -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_` 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_`) 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_' +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_`). 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_.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 ` 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_` (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 A–C** (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 A–F → (rodar dias) → schema-per-tenant Fase G (DROP) + └─ freemium (DEPLOY_FREEMIUM_F4.md) pode entrar + logo após as Fases A–F (não depende do DROP) +``` +> O freemium **não** depende do DROP (F6.3) — depende da infra (Fases A–F). 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