From 1a97bc37c7c005fd083359235c338a538ac8ddef Mon Sep 17 00:00:00 2001 From: Pessoa Date: Wed, 16 Sep 2026 13:43:55 -0300 Subject: [PATCH] =?UTF-8?q?fix(kit):=20a=20revis=C3=A3o=20adversarial=20do?= =?UTF-8?q?=20conserto=20=E2=80=94=20tela,=20pipe,=20conex=C3=A3o=20e=20a?= =?UTF-8?q?=20classe=20inteira?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onze agentes: 8 sabotagens bateram com a previsão; ensaio contra Postgres 17 real (deadlock com psql saindo 0; pg_terminate_backend, restart e "too many clients" com psql saindo 2 — todos curados na 2ª passada); gates verdes; três revisores com um cético cada. Confirmado e consertado aqui: - Mesma classe, dano de acesso: 19 policies criadas (17 no corpo do dump, 2 em blocos antigos do apêndice) e derrubadas adiante sem recriação. Em autocommit, cada update fazia a policy ampla antiga valer até o drop, e cada passada extra reabria a janela. Saem as 19 criações (156 linhas). Nenhuma tabela usa FORCE RLS, então as correções de dados do apêndice (rodam como dono) não dependem delas. Estado final igual. - A régua (baseline-nao-constroi-o-que-derruba) passa a ler policy por nome+tabela, drop sem `if exists`, PRIMARY KEY e EXCLUDE, e trata a volta como índice único do mesmo nome como redefinição. Controle sintético dividido: régua cega e guarda contada como condição reprovam com mensagens diferentes (as duas sabotagens davam o mesmo vermelho). - pipefail: `printf | head -20` matava o update.sh com 141 numa lista grande ("must be owner" de role sem dono) antes do aviso de PERMISSÃO, e `printf | grep -q` deixava de reconhecer a disputa. Here-string e sed -n. - Conexão que nem abre (recusada, DNS, timeout, servidor subindo, SSL, limite do pooler) entra na regra; o veredito cita a causa, não a linha de dica "Is the server running…". - O ✓ depois de disputa não é mudo: cada nova passada imprime o que não aplicou, e o update.sh diz "✓ banco atualizado na passada N". - Banco que não termina limpo é repetido no FIM da saída (na v1.27.3 o aviso do passo 4 ficou soterrado pelo docker pull e a última frase era "Atualização concluída"), com a dica de --force. - "Refaz só o que faltou" era falso (--force refaz a atualização inteira), e "o app está rodando uma imagem antiga" mentia no caminho --force na mesma tag. - Docs de autoridade (README pt/en/es, docs/ATUALIZANDO.md, skill de instalação nos dois espelhos) e o fragmento: sem prometer que a tela mostra o aviso — pelo botão ele ainda fica só no .update.log (estado próprio na tela exige migration + UI: PR separado). Fica de fora, por regra: o comentário do invariante novo ainda diz "meses" (são ~3 semanas desde a 0181); tests/invariants/** é congelado pelo pre-commit depois do primeiro commit. Testes: função 37 checks (era 23), update-guard 76 (era 65), test-validators 270, vitest de 10 arquivos que leem baseline/docs 62 verdes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SnbnUnNbYsGPqswKz7918g --- .../references/problemas-e-armadilhas.md | 13 ++ .../references/scripts-do-kit.md | 2 +- ...o-refaz-o-banco-quando-ele-esta-ocupado.md | 6 +- .../references/problemas-e-armadilhas.md | 13 ++ .../references/scripts-do-kit.md | 2 +- README.en.md | 7 +- README.es.md | 7 +- README.md | 7 +- docs/ATUALIZANDO.md | 11 +- hostgator-setup-kit/_common.sh | 47 +++-- hostgator-setup-kit/install.sh | 4 +- hostgator-setup-kit/update.sh | 54 ++++-- supabase/baseline.sql | 156 ---------------- .../baseline-reaplica-apos-disputa.test.sh | 63 ++++++- tests/shell/update-guard.test.sh | 24 +++ tests/unit/ai-knowledge-sources-post.test.ts | 2 +- ...aseline-nao-constroi-o-que-derruba.test.ts | 171 +++++++++++++----- tests/unit/baseline-reaplicavel.test.ts | 14 +- 18 files changed, 355 insertions(+), 248 deletions(-) diff --git a/.agents/skills/deskcomm-instalar/references/problemas-e-armadilhas.md b/.agents/skills/deskcomm-instalar/references/problemas-e-armadilhas.md index 386a6c087..8c29e18d7 100644 --- a/.agents/skills/deskcomm-instalar/references/problemas-e-armadilhas.md +++ b/.agents/skills/deskcomm-instalar/references/problemas-e-armadilhas.md @@ -66,6 +66,19 @@ seu pooler; o instalador testa a conexão de verdade, então o erro aparece na h `baseline.sql` é re-aplicado inteiro e é idempotente; o `update.sh` filtra esse ruído e só alerta erro de verdade. +**"deadlock detected" ou "connection to server was lost" ao atualizar.** O banco perdeu uma disputa +com o CRM no ar, ou a conexão caiu. Quem atualiza a partir de uma versão que já tem a nova passada +(posterior à v1.28.0) vê o `update.sh` aplicar de novo sozinho, até 3 passadas; só sobra aviso se não +curar, e aí o fim da saída diz "banco NÃO terminou limpo" e mostra +`bash hostgator-setup-kit/update.sh --to --force` — repetir num horário calmo completa o banco +(refaz a atualização inteira, com backup). Não restaure o backup por causa disso. Pelo botão da tela +o aviso não aparece: leia `.update.log` na pasta do projeto. + +**Três "could not create unique index" (`ai_kbv_version_unique`, `ai_kbv_one_active_per_agent`, +`ai_knowledge_sources_unique_per_agent`).** Aviso falso de atualizações até a v1.28.0, quando um +agente tem mais de um material no acervo: o instalador tentava recriar regras antigas que ele mesmo +apaga logo depois. Nenhum dado está errado — não apague nada. Some na atualização seguinte. + **Seletor de modelo vazio ao criar agente de IA.** O seed de modelos não entrou (instalação antiga). `bash hostgator-setup-kit/update.sh` re-aplica o baseline, que traz o insert. diff --git a/.agents/skills/deskcomm-instalar/references/scripts-do-kit.md b/.agents/skills/deskcomm-instalar/references/scripts-do-kit.md index 6a89ba70c..569fa1876 100644 --- a/.agents/skills/deskcomm-instalar/references/scripts-do-kit.md +++ b/.agents/skills/deskcomm-instalar/references/scripts-do-kit.md @@ -8,7 +8,7 @@ Todos vivem em `hostgator-setup-kit/` e rodam **de dentro da VPS**, a partir da | `install.sh` | "instala", "troca a configuração", "coloquei um dado errado" | instala do zero **ou** re-roda sobre o que existe (idempotente): retoma respostas, corrige pelo número da conferência, re-aplica o schema, sobe tudo | termina em "Instalação concluída!" com a pendência de e-mails (se sem token). `--yes` = sem perguntas, exige `.env` preenchido — a chave de IA pode ficar vazia | | `healthcheck.sh` | "está tudo no ar?", "o site caiu?" | lista os contêineres, chama `/api/v1/health` **de dentro** do contêiner do app, confere o cron do agente e o log dele | `✓ app saudável` se o JSON traz `"status":"ok"`; `⚠` nomeia o subsistema (supabase/redis/waha) degradado | | `diagnostico.sh` | "o agente parou de melhorar", "a versão está solta", "atualizei e nada mudou" | **só lê** (nada de escrever, puxar ou reiniciar): diz se a instalação foi afetada pelo worker que nunca era atualizado. Roda até avulso: `curl -fsSL https://raw.githubusercontent.com/melgarafael/DeskcommCRM/main/hostgator-setup-kit/diagnostico.sh \| bash` | código de saída 1 = afetada; explica o conserto | -| `update.sh` | "atualiza", "tem versão nova?" | confere se há versão nova (senão sai na hora), **faz backup antes**, puxa o código, re-aplica o `baseline.sql` (idempotente e auto-curativo; muitos "já existe" são esperados), puxa as imagens da tag, confere a saúde | código 3 = recusou e **nada foi tocado** (ex.: sem internet para confirmar o que é mais novo, ou a versão pedida é anterior). `--force` volta no tempo de propósito; `--to ` fixa uma versão; `--skip-backup` não recomendado | +| `update.sh` | "atualiza", "tem versão nova?" | confere se há versão nova (senão sai na hora), **faz backup antes**, puxa o código, re-aplica o `baseline.sql` (idempotente e auto-curativo; muitos "já existe" são esperados), puxa as imagens da tag, confere a saúde | código 3 = recusou e **nada foi tocado** (ex.: sem internet para confirmar o que é mais novo, ou a versão pedida é anterior). `--force` volta no tempo de propósito, e na MESMA versão refaz a atualização inteira — é a saída que o próprio script mostra quando o banco não termina limpo; `--to ` fixa uma versão; `--skip-backup` não recomendado. Erro de disputa ou conexão no banco é re-aplicado sozinho, até 3 passadas | | `backup.sh` | "faz backup", "antes de mexer" | dump do banco pela conexão de schema (o app usa uma role menor e o dump sairia parcial) + snapshot do volume do WhatsApp; guarda 14 | `backups/db-.sql.gz` e `backups/waha-.tgz` **na própria VPS** | | `restore.sh ` | "restaura o backup" | **sobrescreve** o banco com o dump; pede para digitar `RESTAURAR` | restaura **só o banco**: se o volume do WhatsApp se perdeu, é parear de novo por QR (ou restaurar o `.tgz` à mão) | | `reset-password.sh ` | "esqueci a senha", "me tranquei fora" | redefine a senha pela admin API do Supabase | pede a senha nova no terminal, sem ecoar | diff --git a/.changes/atualizacao-refaz-o-banco-quando-ele-esta-ocupado.md b/.changes/atualizacao-refaz-o-banco-quando-ele-esta-ocupado.md index 784a8002f..753204d75 100644 --- a/.changes/atualizacao-refaz-o-banco-quando-ele-esta-ocupado.md +++ b/.changes/atualizacao-refaz-o-banco-quando-ele-esta-ocupado.md @@ -3,8 +3,8 @@ impacto: nada_mudou secao: corrigido titulo: A atualização refaz o banco quando ele está ocupado, e para de mostrar três avisos falsos --- -Toda atualização de um CRM que já tinha materiais no acervo dos agentes terminava com três avisos de banco (`could not create unique index`), mesmo com tudo certo. Não havia dado errado: o instalador tentava recriar três regras antigas do acervo, que ele mesmo apaga logo depois, e elas não cabem mais no jeito atual de guardar os materiais. Essas três tentativas saíram. Nenhum dado foi apagado ou alterado. +Quem tinha um agente com mais de um material no acervo via, a cada atualização, três avisos de banco (`could not create unique index`), mesmo com tudo certo. Não havia dado errado: o instalador tentava recriar três regras antigas do acervo que ele mesmo apaga logo depois, e que não cabem mais no jeito atual de guardar os materiais. Essas tentativas saíram, e nenhum dado foi apagado ou alterado. Pelo mesmo motivo, a atualização deixa de recriar por alguns instantes 19 regras de acesso antigas que ela mesma apagava em seguida. Algumas eram mais largas que as atuais: nesse intervalo, um usuário só de leitura conseguia alterar dados que as regras de hoje protegem. -Esse ruído escondia um problema de verdade. Com o CRM atendendo, o banco às vezes recusa um comando da atualização por disputa com o próprio app (`deadlock detected`). A atualização avisava e seguia, e o que não aplicou ficava para trás: numa instalação real, o acervo dos agentes ficou sem a regra que permite lê-lo. Agora, quando isso acontece, a atualização aplica o banco de novo, em até três passadas no total, antes de a tela dizer `✓ banco atualizado`, e só aparece aviso se o problema continuar. Nesse caso, a própria tela mostra o comando que refaz só o que faltou. +O ruído escondia um problema de verdade. Com o CRM atendendo, o banco às vezes recusa um comando da atualização por disputa com o próprio app (`deadlock detected`), ou a conexão cai no meio. A atualização avisava e seguia, e o que não aplicou ficava para trás: numa instalação real, o acervo dos agentes ficou sem a regra que permite lê-lo. Agora a atualização aplica o banco de novo, em até três passadas no total, e mostra na tela o que precisou refazer. Se o problema continuar, o fim da saída diz que o banco não terminou limpo e mostra o comando que repete a atualização (`bash hostgator-setup-kit/update.sh --to --force`). Na atualização pelo botão da tela esse aviso ainda não aparece: ele fica registrado em `.update.log`, na pasta do projeto no servidor. -A nova tentativa vale a partir da atualização seguinte a esta: quem executa uma atualização é o instalador que já está no servidor. Os três avisos falsos, esses, somem já nesta. +A nova tentativa vale a partir da atualização seguinte a esta, porque quem executa uma atualização é o instalador que já está no servidor. Os três avisos falsos e as regras de acesso antigas saem já nesta. diff --git a/.claude/skills/deskcomm-instalar/references/problemas-e-armadilhas.md b/.claude/skills/deskcomm-instalar/references/problemas-e-armadilhas.md index 386a6c087..8c29e18d7 100644 --- a/.claude/skills/deskcomm-instalar/references/problemas-e-armadilhas.md +++ b/.claude/skills/deskcomm-instalar/references/problemas-e-armadilhas.md @@ -66,6 +66,19 @@ seu pooler; o instalador testa a conexão de verdade, então o erro aparece na h `baseline.sql` é re-aplicado inteiro e é idempotente; o `update.sh` filtra esse ruído e só alerta erro de verdade. +**"deadlock detected" ou "connection to server was lost" ao atualizar.** O banco perdeu uma disputa +com o CRM no ar, ou a conexão caiu. Quem atualiza a partir de uma versão que já tem a nova passada +(posterior à v1.28.0) vê o `update.sh` aplicar de novo sozinho, até 3 passadas; só sobra aviso se não +curar, e aí o fim da saída diz "banco NÃO terminou limpo" e mostra +`bash hostgator-setup-kit/update.sh --to --force` — repetir num horário calmo completa o banco +(refaz a atualização inteira, com backup). Não restaure o backup por causa disso. Pelo botão da tela +o aviso não aparece: leia `.update.log` na pasta do projeto. + +**Três "could not create unique index" (`ai_kbv_version_unique`, `ai_kbv_one_active_per_agent`, +`ai_knowledge_sources_unique_per_agent`).** Aviso falso de atualizações até a v1.28.0, quando um +agente tem mais de um material no acervo: o instalador tentava recriar regras antigas que ele mesmo +apaga logo depois. Nenhum dado está errado — não apague nada. Some na atualização seguinte. + **Seletor de modelo vazio ao criar agente de IA.** O seed de modelos não entrou (instalação antiga). `bash hostgator-setup-kit/update.sh` re-aplica o baseline, que traz o insert. diff --git a/.claude/skills/deskcomm-instalar/references/scripts-do-kit.md b/.claude/skills/deskcomm-instalar/references/scripts-do-kit.md index 6a89ba70c..569fa1876 100644 --- a/.claude/skills/deskcomm-instalar/references/scripts-do-kit.md +++ b/.claude/skills/deskcomm-instalar/references/scripts-do-kit.md @@ -8,7 +8,7 @@ Todos vivem em `hostgator-setup-kit/` e rodam **de dentro da VPS**, a partir da | `install.sh` | "instala", "troca a configuração", "coloquei um dado errado" | instala do zero **ou** re-roda sobre o que existe (idempotente): retoma respostas, corrige pelo número da conferência, re-aplica o schema, sobe tudo | termina em "Instalação concluída!" com a pendência de e-mails (se sem token). `--yes` = sem perguntas, exige `.env` preenchido — a chave de IA pode ficar vazia | | `healthcheck.sh` | "está tudo no ar?", "o site caiu?" | lista os contêineres, chama `/api/v1/health` **de dentro** do contêiner do app, confere o cron do agente e o log dele | `✓ app saudável` se o JSON traz `"status":"ok"`; `⚠` nomeia o subsistema (supabase/redis/waha) degradado | | `diagnostico.sh` | "o agente parou de melhorar", "a versão está solta", "atualizei e nada mudou" | **só lê** (nada de escrever, puxar ou reiniciar): diz se a instalação foi afetada pelo worker que nunca era atualizado. Roda até avulso: `curl -fsSL https://raw.githubusercontent.com/melgarafael/DeskcommCRM/main/hostgator-setup-kit/diagnostico.sh \| bash` | código de saída 1 = afetada; explica o conserto | -| `update.sh` | "atualiza", "tem versão nova?" | confere se há versão nova (senão sai na hora), **faz backup antes**, puxa o código, re-aplica o `baseline.sql` (idempotente e auto-curativo; muitos "já existe" são esperados), puxa as imagens da tag, confere a saúde | código 3 = recusou e **nada foi tocado** (ex.: sem internet para confirmar o que é mais novo, ou a versão pedida é anterior). `--force` volta no tempo de propósito; `--to ` fixa uma versão; `--skip-backup` não recomendado | +| `update.sh` | "atualiza", "tem versão nova?" | confere se há versão nova (senão sai na hora), **faz backup antes**, puxa o código, re-aplica o `baseline.sql` (idempotente e auto-curativo; muitos "já existe" são esperados), puxa as imagens da tag, confere a saúde | código 3 = recusou e **nada foi tocado** (ex.: sem internet para confirmar o que é mais novo, ou a versão pedida é anterior). `--force` volta no tempo de propósito, e na MESMA versão refaz a atualização inteira — é a saída que o próprio script mostra quando o banco não termina limpo; `--to ` fixa uma versão; `--skip-backup` não recomendado. Erro de disputa ou conexão no banco é re-aplicado sozinho, até 3 passadas | | `backup.sh` | "faz backup", "antes de mexer" | dump do banco pela conexão de schema (o app usa uma role menor e o dump sairia parcial) + snapshot do volume do WhatsApp; guarda 14 | `backups/db-.sql.gz` e `backups/waha-.tgz` **na própria VPS** | | `restore.sh ` | "restaura o backup" | **sobrescreve** o banco com o dump; pede para digitar `RESTAURAR` | restaura **só o banco**: se o volume do WhatsApp se perdeu, é parear de novo por QR (ou restaurar o `.tgz` à mão) | | `reset-password.sh ` | "esqueci a senha", "me tranquei fora" | redefine a senha pela admin API do Supabase | pede a senha nova no terminal, sem ecoar | diff --git a/README.en.md b/README.en.md index b4e86616e..a5bae0ebf 100644 --- a/README.en.md +++ b/README.en.md @@ -176,8 +176,11 @@ turn off things you already have); `--force` exists for that, deliberately. **Normal things you will see:** a pile of `already exists` / `multiple primary keys` during the database step — **expected and harmless**, those are things that already existed. The script -filters that noise and prints `✓ banco atualizado`. If you see `⚠ avisos que não são os -esperados`, that one is worth keeping. +filters that noise and prints `✓ banco atualizado`. If the database is busy with the CRM serving +customers, it applies again on its own (up to 3 passes) and says so. If you see +`⚠ Apareceram avisos no banco que NÃO são os esperados`, that one is worth keeping — and if the end +of the output says the database did not finish clean, run the command it shows +(`update.sh --to --force`) before considering a backup restore. **Something went wrong?** `bash hostgator-setup-kit/restore.sh` returns to the backup. **Just want a diagnosis?** `bash hostgator-setup-kit/healthcheck.sh`. diff --git a/README.es.md b/README.es.md index 784b062f5..effc3cffa 100644 --- a/README.es.md +++ b/README.es.md @@ -176,8 +176,11 @@ cosas que ya tienes); para eso existe `--force`, a propósito. **Cosas normales que vas a ver:** un montón de `already exists` / `multiple primary keys` en la parte de la base — **es esperado e inofensivo**, son cosas que ya existían. El script filtra ese -ruido y muestra `✓ banco atualizado`. Si aparece `⚠ avisos que não são os esperados`, ahí sí -guarda el mensaje. +ruido y muestra `✓ banco atualizado`. Si la base está ocupada con el CRM atendiendo, la aplica de +nuevo sola (hasta 3 pasadas) y lo dice en pantalla. Si aparece +`⚠ Apareceram avisos no banco que NÃO são os esperados`, ahí sí guarda el mensaje — y, si el final +de la salida dice que la base no terminó limpia, ejecuta el comando que muestra +(`update.sh --to --force`) antes de pensar en restaurar el backup. **¿Salió mal?** `bash hostgator-setup-kit/restore.sh` vuelve al backup. **¿Solo quieres diagnosticar?** `bash hostgator-setup-kit/healthcheck.sh`. diff --git a/README.md b/README.md index 13c7b6507..ce74905ae 100644 --- a/README.md +++ b/README.md @@ -178,8 +178,11 @@ pra isso existe `--force`, de propósito. **Coisas normais que você vai ver:** um monte de `already exists` / `multiple primary keys` na parte do banco — **é esperado e inofensivo**, são coisas que já existiam. O script filtra esse -ruído e mostra `✓ banco atualizado`. Se aparecer `⚠ avisos que não são os esperados`, aí sim -guarde a mensagem. +ruído e mostra `✓ banco atualizado`. Se o banco estiver ocupado com o CRM atendendo, ele aplica de +novo sozinho (até 3 passadas) e conta isso na tela. Se aparecer +`⚠ Apareceram avisos no banco que NÃO são os esperados`, aí sim guarde a mensagem — e, se o fim da +saída disser que o banco não terminou limpo, rode o comando que ela mostra +(`update.sh --to --force`) antes de pensar em restaurar o backup. **Deu ruim?** `bash hostgator-setup-kit/restore.sh` volta pro backup. **Quer só diagnosticar?** `bash hostgator-setup-kit/healthcheck.sh`. diff --git a/docs/ATUALIZANDO.md b/docs/ATUALIZANDO.md index bb819c7d1..4076767e7 100644 --- a/docs/ATUALIZANDO.md +++ b/docs/ATUALIZANDO.md @@ -32,9 +32,14 @@ recente**, é porque não havia nada novo pra baixar; está tudo certo. - **Um monte de linhas com "already exists" / "multiple primary keys"** durante a parte do banco: **é esperado e inofensivo** — são coisas que já existiam. O comando filtra esse ruído e, se estiver tudo certo, mostra **`✓ banco atualizado`**. -- Se aparecer **`⚠ avisos que não são os esperados`**, aí sim vale prestar atenção: o app - provavelmente ainda funciona, mas guarde a mensagem. Em último caso, dá pra voltar ao - estado anterior com o backup: `bash hostgator-setup-kit/restore.sh`. +- Se o banco estiver ocupado com o CRM atendendo, o comando **aplica de novo sozinho** (até + 3 passadas) e mostra na tela o que precisou refazer. Isso é normal. +- Se aparecer **`⚠ Apareceram avisos no banco que NÃO são os esperados`**, aí sim vale prestar + atenção: o app provavelmente ainda funciona, mas guarde a mensagem. Se o fim da saída disser + que **o banco NÃO terminou limpo**, rode, num horário de pouco movimento, o comando que ela + mostra (`bash hostgator-setup-kit/update.sh --to --force`) — ele repete a + atualização e completa o banco. Só em último caso volte ao estado anterior com o backup: + `bash hostgator-setup-kit/restore.sh`. ## Dicas diff --git a/hostgator-setup-kit/_common.sh b/hostgator-setup-kit/_common.sh index fe29cf806..2253139d5 100755 --- a/hostgator-setup-kit/_common.sh +++ b/hostgator-setup-kit/_common.sh @@ -449,46 +449,67 @@ psql_run() { docker run --rm -i postgres:17-alpine psql "$(url_do_schema)" -v ON # comandos perderam um `deadlock detected`, e um deles era o `create policy` logo # depois do `drop policy` da mesma policy — `ai_knowledge_sources` ficou sem a # policy de leitura até alguém refazer o bloco à mão. O aviso saiu na tela, no -# meio das três linhas de ruído que toda atualização daquela VPS mostrava. +# meio das três linhas de ruído das atualizações daquela VPS (v1.27.2 e v1.27.3). # # O arquivo é idempotente (o job `invariants` o re-aplica com ON_ERROR_STOP=1), # então a cura de uma disputa é aplicá-lo de novo, inteiro. O veredito é o da # ÚLTIMA passada: o comando que perdeu na primeira rodou outra vez na seguinte, # e é o estado dela que fica no banco. Só re-aplica por erro de disputa ou de -# conexão — erro de permissão ou de dado se repetiria igual, só mais tarde. +# conexão — a que cai no meio e a que nem chega a abrir. Erro de permissão ou de +# dado se repetiria igual, só mais tarde. Medido contra um Postgres 17 real: +# deadlock (psql sai 0), `pg_terminate_backend`, restart do servidor e +# "too many clients" (psql sai 2) — todos curados na 2ª passada. +# +# O limite da cura, e por que cada nova passada imprime o que não aplicou: um +# comando que COPIA dado guardado por uma checagem de catálogo, e que perde a +# disputa enquanto o comando seguinte (o que destrói a origem) passa, não tem o +# que copiar na passada seguinte — ela sai limpa e o dado não veio. O ✓ depois +# de uma disputa nunca é mudo: as linhas perdidas ficam na tela e no log. +# +# Nada de `printf | grep -q` nem `| head` aqui: com `pipefail`, o leitor que sai +# cedo mata o `printf` com SIGPIPE quando a saída passa do buffer do pipe (os +# milhares de "must be owner" de uma role sem dono passam), e o pipeline inteiro +# vira falha — medido: a disputa deixava de ser reconhecida. Here-string e +# `sed -n` leem até o fim. # # reaplicar_baseline [log] # 0 → a última passada não teve erro fora dos benignos # 1 → teve; as linhas ficam em BASELINE_INESPERADO +# BASELINE_PASSADAS diz quantas passadas foram feitas. # O log, quando dado, recebe a saída de TODAS as passadas, cada uma com cabeçalho. # BASELINE_TENTATIVAS (padrão 3) e BASELINE_ESPERA_S (padrão 10, vezes o número # da passada) existem para a suíte de shell não esperar de verdade. BASELINE_ERROS_BENIGNOS='already exists|multiple primary keys|multiple default values|is already a member|already a partition' -BASELINE_ERROS_DE_DISPUTA='deadlock detected|could not serialize access|lock timeout|could not obtain lock|terminating connection|server closed the connection|connection to server was lost|remaining connection slots|too many clients|Max client connections' +BASELINE_ERROS_DE_DISPUTA='deadlock detected|could not serialize access|lock timeout|could not obtain lock|terminating connection|server closed the connection|connection to server was lost|SSL connection has been closed unexpectedly|SSL SYSCALL error|remaining connection slots|too many clients|max client(s| connections) reached|the database system is (starting up|shutting down|in recovery mode|not yet accepting connections)|could not translate host name|Connection refused|Connection timed out|timeout expired|Network (is )?unreachable' reaplicar_baseline() { local arquivo="$1" log="${2:-}" tentativas="${BASELINE_TENTATIVAS:-3}" espera="${BASELINE_ESPERA_S:-10}" - local passada=1 raw rc + local raw rc causa + BASELINE_PASSADAS=1 [ -z "$log" ] || : > "$log" while :; do rc=0 raw="$(docker run --rm -i -v "$arquivo:/b.sql:ro" postgres:17-alpine \ psql "$(url_do_schema)" -q -f /b.sql 2>&1)" || rc=$? - [ -z "$log" ] || printf '── passada %s de %s (saída %s) ──\n%s\n' "$passada" "$tentativas" "$rc" "$raw" >> "$log" - BASELINE_INESPERADO="$(printf '%s\n' "$raw" | grep -iE 'ERROR|FATAL' | grep -viE "$BASELINE_ERROS_BENIGNOS" || true)" + [ -z "$log" ] || printf '── passada %s de %s (saída %s) ──\n%s\n' "$BASELINE_PASSADAS" "$tentativas" "$rc" "$raw" >> "$log" + BASELINE_INESPERADO="$(grep -iE 'ERROR|FATAL' <<<"$raw" | grep -viE "$BASELINE_ERROS_BENIGNOS" || true)" # Sem ON_ERROR_STOP o psql sai 0 mesmo com erro de SQL: saída diferente de # zero é o psql (ou o docker) que NÃO chegou ao fim do arquivo. Sem isto, uma # conexão que cai no meio sem imprimir a palavra ERROR terminaria em - # "✓ banco atualizado" com metade do arquivo aplicada. + # "✓ banco atualizado" com metade do arquivo aplicada. A causa citada é a + # última linha que não é continuação indentada — a última de todas costuma ser + # a dica "Is the server running…", e não o motivo. if [ "$rc" -ne 0 ]; then + causa="$(awk 'NF && !/^[[:space:]]/ { l = $0 } END { print l }' <<<"$raw")" BASELINE_INESPERADO="$(printf '%s\n' "$BASELINE_INESPERADO" \ - "a aplicação parou antes do fim do arquivo (saída $rc): $(printf '%s\n' "$raw" | tail -1)" | sed '/^$/d')" + "a aplicação não chegou ao fim do arquivo (o psql saiu com código $rc): $causa" | sed '/^$/d')" fi [ -n "$BASELINE_INESPERADO" ] || return 0 - [ "$passada" -lt "$tentativas" ] || return 1 - printf '%s\n' "$BASELINE_INESPERADO" | grep -qiE "$BASELINE_ERROS_DE_DISPUTA" || return 1 - c_ylw "• parte do banco não aplicou (disputa com o app no ar ou conexão instável) — aplicando de novo, é seguro (passada $((passada + 1)) de $tentativas)" - sleep "$((espera * passada))" - passada=$((passada + 1)) + [ "$BASELINE_PASSADAS" -lt "$tentativas" ] || return 1 + grep -qiE "$BASELINE_ERROS_DE_DISPUTA" <<<"$BASELINE_INESPERADO" || return 1 + c_ylw "• parte do banco não aplicou (disputa com o app no ar ou conexão instável) — aplicando de novo, é seguro (passada $((BASELINE_PASSADAS + 1)) de $tentativas). O que não aplicou:" + sed -n '1,10s/^/ /p' <<<"$BASELINE_INESPERADO" + sleep "$((espera * BASELINE_PASSADAS))" + BASELINE_PASSADAS=$((BASELINE_PASSADAS + 1)) done } diff --git a/hostgator-setup-kit/install.sh b/hostgator-setup-kit/install.sh index 19336b879..41b1288d8 100755 --- a/hostgator-setup-kit/install.sh +++ b/hostgator-setup-kit/install.sh @@ -1812,7 +1812,9 @@ if [ -f supabase/baseline.sql ]; then c_grn "✓ schema re-aplicado (apêndice de migrations incluído)" else c_ylw "⚠ Erros no banco que NÃO são os esperados (log completo: $SCHEMA_LOG):" - printf '%s\n' "$BASELINE_INESPERADO" | head -20 + # `sed -n`, e não `| head`: com pipefail, o head que fecha cedo mata o printf + # com SIGPIPE numa lista grande, e o set -e derrubava o instalador aqui. + sed -n '1,20p' <<<"$BASELINE_INESPERADO" fi else if docker run --rm -i -v "$PROJECT_DIR/supabase/baseline.sql:/baseline.sql:ro" \ diff --git a/hostgator-setup-kit/update.sh b/hostgator-setup-kit/update.sh index 997ea68e7..ce762de68 100755 --- a/hostgator-setup-kit/update.sh +++ b/hostgator-setup-kit/update.sh @@ -103,7 +103,12 @@ if [ -z "$FORCE" ] && [ -z "$MESMA_TAG" ]; then bash hostgator-setup-kit/update.sh --to $TARGET_TAG --force" ;; esac fi -if [ -n "$MESMA_TAG" ]; then +if [ -n "$MESMA_TAG" ] && [ -n "$FORCE" ]; then + # Com --force na mesma tag ninguém conferiu a imagem: quem chega aqui pediu + # para refazer (é a saída que a própria atualização ensina quando o banco não + # termina limpo). Dizer "o app está rodando uma imagem antiga" seria inventar. + c_ylw "Refazendo a versão $TARGET_TAG, como pedido (--force): backup, banco e app de novo." +elif [ -n "$MESMA_TAG" ]; then c_ylw "O código já está na $TARGET_TAG, mas o app está rodando uma imagem antiga. Vou atualizar a imagem." else c_ylw "Vou atualizar para a versão $TARGET_TAG com segurança." @@ -147,6 +152,18 @@ fi # `.env` como recomendamos, este passo passava a falhar em silêncio a cada # atualização — e é o update.sh que entrega migration nova ao clone (issue #192). step "Atualizando o banco de dados" +# O que sobrou de errado no banco, para ser repetido no FIM da execução — ver o +# passo 6. Vazio = o banco terminou limpo (ou não havia baseline para aplicar). +BANCO_INCOMPLETO="" +# A saída que re-aplica, dita no passo 4 e repetida no fim. Rodar o update.sh de +# novo sem --force responderia "já está na versão mais recente" e não tocaria no +# banco. E ele refaz a atualização INTEIRA (backup, banco, app), não só o que faltou. +dica_de_repetir() { + c_ylw " O banco seguiu ocupado ou fora de alcance nas $BASELINE_PASSADAS passadas, e parte dele pode ter" + c_ylw " ficado para trás. Para completar, repita a atualização num horário de pouco movimento — ela faz" + c_ylw " o backup de novo e reinicia o app por alguns segundos:" + c_ylw " bash hostgator-setup-kit/update.sh --to $TARGET_TAG --force" +} if [ -f supabase/baseline.sql ]; then # Extensões que o schema exige (idempotente; iguais ao install.sh). docker run --rm postgres:17-alpine psql "$(url_do_schema)" -c \ @@ -154,22 +171,26 @@ if [ -f supabase/baseline.sql ]; then >/dev/null 2>&1 || true if reaplicar_baseline "$PROJECT_DIR/supabase/baseline.sql"; then - c_grn "✓ banco atualizado (e conversas reorganizadas, se havia bagunça)." + if [ "$BASELINE_PASSADAS" -gt 1 ]; then + c_grn "✓ banco atualizado na passada $BASELINE_PASSADAS — as anteriores perderam para o app no ar (o que não aplicou está listado acima)." + else + c_grn "✓ banco atualizado (e conversas reorganizadas, se havia bagunça)." + fi else - unexpected="$BASELINE_INESPERADO" + BANCO_INCOMPLETO="$BASELINE_INESPERADO" c_ylw "⚠ Apareceram avisos no banco que NÃO são os esperados:" - printf '%s\n' "$unexpected" | head -20 + # `sed -n`, e não `| head`: com pipefail, o head que fecha cedo mata o printf + # com SIGPIPE numa lista grande — e o set -e derrubava o script aqui, antes + # do aviso de PERMISSÃO logo abaixo, que foi escrito justamente para ela. + sed -n '1,20p' <<<"$BANCO_INCOMPLETO" c_ylw " O app pode ainda funcionar. Se algo estiver errado, restaure o backup (restore.sh)." - case "$unexpected" in + case "$BANCO_INCOMPLETO" in *permission\ denied*|*must\ be\ owner*|*permissão\ negada*) c_ylw " Os erros são de PERMISSÃO: a conexão do .env não é o dono do banco. Num Supabase" c_ylw " próprio, declare SUPABASE_DB_ADMIN_URL no .env — é ela que roda o schema." ;; esac - # Rodar o update.sh de novo sem --force responderia "já está na versão mais - # recente" e não tocaria no banco: a saída que re-aplica é esta. - if printf '%s\n' "$unexpected" | grep -qiE "$BASELINE_ERROS_DE_DISPUTA"; then - c_ylw " Parte do banco pode ter ficado para trás porque ele seguiu ocupado. Repetir é seguro" - c_ylw " e refaz só o que faltou: bash hostgator-setup-kit/update.sh --to $TARGET_TAG --force" + if grep -qiE "$BASELINE_ERROS_DE_DISPUTA" <<<"$BANCO_INCOMPLETO"; then + dica_de_repetir fi fi else @@ -306,7 +327,18 @@ step "Conferindo se o app voltou no ar" ok="" wait_app_healthy 20 3 >/dev/null && ok=1 if [ -n "$ok" ]; then - c_grn "✓ Atualização concluída — app no ar e saudável." + if [ -n "$BANCO_INCOMPLETO" ]; then + # Repetido aqui porque é no fim que o dono lê: na v1.27.3 de uma VPS real o + # aviso do passo 4 ficou soterrado por centenas de linhas do docker pull, e a + # última frase da tela era "Atualização concluída". + c_ylw "⚠ App no ar e saudável, mas o banco NÃO terminou limpo — os avisos estão no passo" + c_ylw " \"Atualizando o banco de dados\", acima." + if grep -qiE "$BASELINE_ERROS_DE_DISPUTA" <<<"$BANCO_INCOMPLETO"; then + dica_de_repetir + fi + else + c_grn "✓ Atualização concluída — app no ar e saudável." + fi # Dito no fim, e não no início, porque é aqui que o dono lê. Se a execução # anterior deixou o pin pela metade, ele nunca soube — a tela dizia "concluída" # e o worker seguia um canal móvel. Agora ele sabe que existiu e que acabou. diff --git a/supabase/baseline.sql b/supabase/baseline.sql index f5d68ad5a..be0cf2272 100644 --- a/supabase/baseline.sql +++ b/supabase/baseline.sql @@ -4039,28 +4039,12 @@ ALTER TABLE "public"."channel_session_warmup" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."channel_sessions" ENABLE ROW LEVEL SECURITY; -DO $baseline_guard$ BEGIN -IF NOT EXISTS (SELECT 1 FROM pg_policy - WHERE polname = 'channel_sessions_tenant_isolation_all' AND polrelid = '"public"."channel_sessions"'::regclass) THEN -CREATE POLICY "channel_sessions_tenant_isolation_all" ON "public"."channel_sessions" USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())) WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); -END IF; END $baseline_guard$; - - - ALTER TABLE "public"."contacts" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."conversations" ENABLE ROW LEVEL SECURITY; -DO $baseline_guard$ BEGIN -IF NOT EXISTS (SELECT 1 FROM pg_policy - WHERE polname = 'conversations_tenant_isolation_all' AND polrelid = '"public"."conversations"'::regclass) THEN -CREATE POLICY "conversations_tenant_isolation_all" ON "public"."conversations" USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())) WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); -END IF; END $baseline_guard$; - - - ALTER TABLE "public"."crm_lead_activities" ENABLE ROW LEVEL SECURITY; @@ -4142,14 +4126,6 @@ END IF; END $baseline_guard$; ALTER TABLE "public"."messages" ENABLE ROW LEVEL SECURITY; -DO $baseline_guard$ BEGIN -IF NOT EXISTS (SELECT 1 FROM pg_policy - WHERE polname = 'messages_tenant_isolation_all' AND polrelid = '"public"."messages"'::regclass) THEN -CREATE POLICY "messages_tenant_isolation_all" ON "public"."messages" USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())) WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); -END IF; END $baseline_guard$; - - - ALTER TABLE "public"."nuvemshop_products" ENABLE ROW LEVEL SECURITY; @@ -4258,48 +4234,6 @@ END IF; END $baseline_guard$; -DO $baseline_guard$ BEGIN -IF NOT EXISTS (SELECT 1 FROM pg_policy - WHERE polname = 'tenant_isolation_ai_agent_versions_all' AND polrelid = '"public"."ai_agent_versions"'::regclass) THEN -CREATE POLICY "tenant_isolation_ai_agent_versions_all" ON "public"."ai_agent_versions" USING (("organization_id" IN ( SELECT "fn_user_org_ids"."fn_user_org_ids" - FROM "public"."fn_user_org_ids"() "fn_user_org_ids"("fn_user_org_ids")))) WITH CHECK (("organization_id" IN ( SELECT "fn_user_org_ids"."fn_user_org_ids" - FROM "public"."fn_user_org_ids"() "fn_user_org_ids"("fn_user_org_ids")))); -END IF; END $baseline_guard$; - - - -DO $baseline_guard$ BEGIN -IF NOT EXISTS (SELECT 1 FROM pg_policy - WHERE polname = 'tenant_isolation_ai_agents_all' AND polrelid = '"public"."ai_agents"'::regclass) THEN -CREATE POLICY "tenant_isolation_ai_agents_all" ON "public"."ai_agents" USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())) WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); -END IF; END $baseline_guard$; - - - -DO $baseline_guard$ BEGIN -IF NOT EXISTS (SELECT 1 FROM pg_policy - WHERE polname = 'tenant_isolation_ai_budgets_all' AND polrelid = '"public"."ai_budgets"'::regclass) THEN -CREATE POLICY "tenant_isolation_ai_budgets_all" ON "public"."ai_budgets" USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())) WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); -END IF; END $baseline_guard$; - - - -DO $baseline_guard$ BEGIN -IF NOT EXISTS (SELECT 1 FROM pg_policy - WHERE polname = 'tenant_isolation_ai_chunks_all' AND polrelid = '"public"."ai_chunks"'::regclass) THEN -CREATE POLICY "tenant_isolation_ai_chunks_all" ON "public"."ai_chunks" USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())) WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); -END IF; END $baseline_guard$; - - - -DO $baseline_guard$ BEGIN -IF NOT EXISTS (SELECT 1 FROM pg_policy - WHERE polname = 'tenant_isolation_ai_faq_items_all' AND polrelid = '"public"."ai_faq_items"'::regclass) THEN -CREATE POLICY "tenant_isolation_ai_faq_items_all" ON "public"."ai_faq_items" USING (("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids"))) WITH CHECK (("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids"))); -END IF; END $baseline_guard$; - - - DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'tenant_isolation_ai_invocations_all' AND polrelid = '"public"."ai_invocations"'::regclass) THEN @@ -4308,32 +4242,6 @@ END IF; END $baseline_guard$; -DO $baseline_guard$ BEGIN -IF NOT EXISTS (SELECT 1 FROM pg_policy - WHERE polname = 'tenant_isolation_ai_kbv_all' AND polrelid = '"public"."ai_knowledge_versions"'::regclass) THEN -CREATE POLICY "tenant_isolation_ai_kbv_all" ON "public"."ai_knowledge_versions" USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())) WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); -END IF; END $baseline_guard$; - - - -DO $baseline_guard$ BEGIN -IF NOT EXISTS (SELECT 1 FROM pg_policy - WHERE polname = 'tenant_isolation_ai_knowledge_sources_all' AND polrelid = '"public"."ai_knowledge_sources"'::regclass) THEN -CREATE POLICY "tenant_isolation_ai_knowledge_sources_all" ON "public"."ai_knowledge_sources" USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())) WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); -END IF; END $baseline_guard$; - - - -DO $baseline_guard$ BEGIN -IF NOT EXISTS (SELECT 1 FROM pg_policy - WHERE polname = 'tenant_isolation_ai_provider_credentials_modify' AND polrelid = '"public"."ai_provider_credentials"'::regclass) THEN -CREATE POLICY "tenant_isolation_ai_provider_credentials_modify" ON "public"."ai_provider_credentials" USING (("organization_id" IN ( SELECT "fn_user_org_ids"."fn_user_org_ids" - FROM "public"."fn_user_org_ids"() "fn_user_org_ids"("fn_user_org_ids")))) WITH CHECK (("organization_id" IN ( SELECT "fn_user_org_ids"."fn_user_org_ids" - FROM "public"."fn_user_org_ids"() "fn_user_org_ids"("fn_user_org_ids")))); -END IF; END $baseline_guard$; - - - DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'tenant_isolation_ai_provider_credentials_select' AND polrelid = '"public"."ai_provider_credentials"'::regclass) THEN @@ -4351,54 +4259,6 @@ END IF; END $baseline_guard$; -DO $baseline_guard$ BEGIN -IF NOT EXISTS (SELECT 1 FROM pg_policy - WHERE polname = 'tenant_isolation_crm_lead_activities_insert' AND polrelid = '"public"."crm_lead_activities"'::regclass) THEN -CREATE POLICY "tenant_isolation_crm_lead_activities_insert" ON "public"."crm_lead_activities" FOR INSERT WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); -END IF; END $baseline_guard$; - - - -DO $baseline_guard$ BEGIN -IF NOT EXISTS (SELECT 1 FROM pg_policy - WHERE polname = 'tenant_isolation_crm_lead_activities_select' AND polrelid = '"public"."crm_lead_activities"'::regclass) THEN -CREATE POLICY "tenant_isolation_crm_lead_activities_select" ON "public"."crm_lead_activities" FOR SELECT USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); -END IF; END $baseline_guard$; - - - -DO $baseline_guard$ BEGIN -IF NOT EXISTS (SELECT 1 FROM pg_policy - WHERE polname = 'tenant_isolation_crm_lead_links_all' AND polrelid = '"public"."crm_lead_links"'::regclass) THEN -CREATE POLICY "tenant_isolation_crm_lead_links_all" ON "public"."crm_lead_links" USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())) WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); -END IF; END $baseline_guard$; - - - -DO $baseline_guard$ BEGIN -IF NOT EXISTS (SELECT 1 FROM pg_policy - WHERE polname = 'tenant_isolation_crm_leads_all' AND polrelid = '"public"."crm_leads"'::regclass) THEN -CREATE POLICY "tenant_isolation_crm_leads_all" ON "public"."crm_leads" USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())) WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); -END IF; END $baseline_guard$; - - - -DO $baseline_guard$ BEGIN -IF NOT EXISTS (SELECT 1 FROM pg_policy - WHERE polname = 'tenant_isolation_crm_pipelines_all' AND polrelid = '"public"."crm_pipelines"'::regclass) THEN -CREATE POLICY "tenant_isolation_crm_pipelines_all" ON "public"."crm_pipelines" USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())) WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); -END IF; END $baseline_guard$; - - - -DO $baseline_guard$ BEGIN -IF NOT EXISTS (SELECT 1 FROM pg_policy - WHERE polname = 'tenant_isolation_crm_stages_all' AND polrelid = '"public"."crm_stages"'::regclass) THEN -CREATE POLICY "tenant_isolation_crm_stages_all" ON "public"."crm_stages" USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())) WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); -END IF; END $baseline_guard$; - - - DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'tenant_isolation_storage_redaction_queue_all' AND polrelid = '"public"."storage_redaction_queue"'::regclass) THEN @@ -5199,18 +5059,6 @@ create policy "conversations_select" on public.conversations or public.fn_is_platform_admin() ); -create policy "conversations_agent_write" on public.conversations - using ( - public.fn_is_platform_admin() - or ((organization_id in (select public.fn_user_org_ids())) - and public.fn_role_at_least(organization_id, 'agent')) - ) - with check ( - public.fn_is_platform_admin() - or ((organization_id in (select public.fn_user_org_ids())) - and public.fn_role_at_least(organization_id, 'agent')) - ); - -- ---- Auditoria de atribuição de conversas + fn_conversation_assign (migration 0031) ---- -- G3-01 (gov-loop): toda mudança de dono de conversa vira evento estruturado -- (spec 13 §3.1) e as rotas de claim/transfer/release passam a mudar o dono via @@ -11059,10 +10907,6 @@ alter table public.ai_purpose_bindings alter table public.ai_purpose_bindings enable row level security; drop policy if exists tenant_isolation_ai_purpose_bindings_all on public.ai_purpose_bindings; -create policy tenant_isolation_ai_purpose_bindings_all on public.ai_purpose_bindings - using (organization_id in (select public.fn_user_org_ids())) - with check (organization_id in (select public.fn_user_org_ids())); - drop trigger if exists ai_purpose_bindings_updated_at on public.ai_purpose_bindings; create trigger ai_purpose_bindings_updated_at before update on public.ai_purpose_bindings diff --git a/tests/shell/baseline-reaplica-apos-disputa.test.sh b/tests/shell/baseline-reaplica-apos-disputa.test.sh index 335c27056..8cc06ea9c 100644 --- a/tests/shell/baseline-reaplica-apos-disputa.test.sh +++ b/tests/shell/baseline-reaplica-apos-disputa.test.sh @@ -19,7 +19,10 @@ # daria o mesmo erro, mais tarde; # 4. o psql que não chega ao fim do arquivo NUNCA é lido como sucesso, mesmo # quando a mensagem de conexão perdida não traz a palavra ERROR; -# 5. o ruído benigno de sempre não dispara passada nenhuma. +# 5. o ruído benigno de sempre não dispara passada nenhuma; +# 6. uma lista de erros maior que o buffer do pipe não cega a função (pipefail); +# 7. conexão que nem chega a abrir também é refeita, e o veredito cita a causa, +# não a linha de dica do psql. set -uo pipefail RAIZ="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -83,6 +86,7 @@ rodar() { if reaplicar_baseline "$1/baseline.sql" "$1/apply.log"; then rc=0; else rc=1; fi printf "%s" "$rc" > "$1/rc" printf "%s" "${BASELINE_INESPERADO-}" > "$1/inesperado" + printf "%s" "${BASELINE_PASSADAS-}" > "$1/passadas-declaradas" ' _ "$WORK" "$ROTEIRO" "$RAIZ" > "$WORK/tela" 2>&1 } @@ -90,8 +94,11 @@ rc() { cat "$WORK/rc"; } passadas() { grep -c -- '-f /b.sql' "$WORK/docker.log"; } inesperado() { cat "$WORK/inesperado"; } e_igual() { [ "$1" = "$2" ]; } -contem() { printf '%s' "$1" | grep -qiE -- "$2"; } -nao_contem() { ! printf '%s' "$1" | grep -qiE -- "$2"; } +declaradas() { cat "$WORK/passadas-declaradas"; } +# Here-string, não `printf | grep -q`: o caso 6 passa listas maiores que o buffer +# do pipe, e sob pipefail o próprio instrumento cegaria. +contem() { grep -qiE -- "$2" <<<"$1"; } +nao_contem() { ! grep -qiE -- "$2" <<<"$1"; } echo "── 0. O dublê é o que a prova pensa que é" # Controle positivo: sem ele, uma função que nunca chama o docker daria @@ -113,6 +120,9 @@ check "devolve sucesso" e_igual "$(rc)" 0 check "aplicou o arquivo duas vezes" e_igual "$(passadas)" 2 check "o veredito não carrega o deadlock da 1ª passada" e_igual "$(inesperado)" "" check "a tela diz que está aplicando de novo, e que é seguro" grep -q "aplicando de novo, é seguro (passada 2 de 3)" "$WORK/tela" +check " e mostra O QUE não aplicou — o ✓ depois de uma disputa não é mudo" grep -q "psql:/b.sql:16766: ERROR: deadlock detected" "$WORK/tela" +check " sem repetir na tela o ruído benigno" nao_contem "$(cat "$WORK/tela")" "multiple primary keys" +check "BASELINE_PASSADAS diz 2, para quem chama contar ao dono" e_igual "$(declaradas)" 2 check "o log guarda as DUAS passadas, com cabeçalho" e_igual "$(grep -c '^── passada ' "$WORK/apply.log")" 2 check " e o deadlock da 1ª continua lá para quem investigar" grep -q "deadlock detected" "$WORK/apply.log" @@ -123,6 +133,7 @@ rodar check "devolve falha" e_igual "$(rc)" 1 check "parou no teto de 3 passadas (não tentou a 4ª)" e_igual "$(passadas)" 3 check "o veredito traz o deadlock" contem "$(inesperado)" "deadlock detected" +check "BASELINE_PASSADAS diz 3" e_igual "$(declaradas)" 3 echo "── 3. Erro que não é de disputa: uma passada só" novo_caso permissao @@ -145,13 +156,24 @@ rodar check "conexão que cai no meio vira nova passada" e_igual "$(passadas)" 2 check " e, limpa a 2ª, devolve sucesso" e_igual "$(rc)" 0 +novo_caso conexao-cai-sempre +# O par que faltava: sem ele, o "devolve sucesso" acima ficava verde também numa +# função que nem lê o código de saída (ela devolveria 0 já na 1ª passada). +for n in 1 2 3; do + roteiro "$n" 'psql:/b.sql:9000: server closed the connection unexpectedly +psql:/b.sql:9000: connection to server was lost' 2 +done +rodar +check "conexão que cai em todas as passadas devolve falha" e_igual "$(rc)" 1 +check " depois das 3 passadas" e_igual "$(passadas)" 3 + novo_caso docker-falha roteiro 1 'Unable to find image postgres:17-alpine locally' 125 roteiro 2 "" rodar check "saída 125 sem mensagem reconhecível devolve falha" e_igual "$(rc)" 1 check " sem nova passada (não é disputa)" e_igual "$(passadas)" 1 -check " e o veredito diz que parou antes do fim, com o código" contem "$(inesperado)" "parou antes do fim do arquivo \(saída 125\)" +check " e o veredito diz que não chegou ao fim, com o código" contem "$(inesperado)" "não chegou ao fim do arquivo \(o psql saiu com código 125\)" echo "── 5. Ruído benigno não é aviso nem motivo para aplicar de novo" novo_caso benigno @@ -163,5 +185,38 @@ check "uma passada só" e_igual "$(passadas)" 1 check "veredito vazio" e_igual "$(inesperado)" "" check "a tela não fala em aplicar de novo" nao_contem "$(cat "$WORK/tela")" "aplicando de novo" +echo "── 6. Lista de erros maior que o buffer do pipe (pipefail) não cega a função" +# Uma role sem dono gera milhares de "must be owner". Com `printf | grep -q`, o +# grep achava o deadlock na 1ª linha e saía; o printf levava SIGPIPE e, sob +# pipefail, o pipeline virava falha — a disputa deixava de ser reconhecida. +novo_caso lista-grande +GRANDE="$(printf '%s\n' "$DEADLOCK"; for i in $(seq 1 4000); do printf 'psql:/b.sql:%s: ERROR: must be owner of table tabela_%s\n' "$i" "$i"; done)" +roteiro 1 "$GRANDE" +roteiro 2 "" +check "o roteiro é mesmo maior que o buffer de um pipe (64 KB)" test "$(wc -c < "$ROTEIRO/passada.1" | tr -d ' ')" -gt 65536 +rodar +check "a disputa no topo de uma lista grande ainda é reconhecida (aplicou de novo)" e_igual "$(passadas)" 2 +check " e a 2ª passada limpa devolve sucesso" e_igual "$(rc)" 0 + +echo "── 7. Conexão que nem abre: nova passada, e o veredito cita a causa" +novo_caso conexao-recusada +# Saída real do psql 17 com o servidor parado (medida em 2026-09-16). +RECUSADA='psql: error: connection to server at "db.exemplo" (192.168.65.254), port 5432 failed: Connection refused + Is the server running on that host and accepting TCP/IP connections? +connection to server at "db.exemplo" (fdc4:f303:9324::254), port 5432 failed: Network unreachable + Is the server running on that host and accepting TCP/IP connections?' +roteiro 1 "$RECUSADA" 2 +roteiro 2 "" +rodar +check "servidor que recusa a conexão vira nova passada" e_igual "$(passadas)" 2 +check " e, no ar na 2ª, devolve sucesso" e_igual "$(rc)" 0 + +novo_caso conexao-recusada-sempre +for n in 1 2 3; do roteiro "$n" "$RECUSADA" 2; done +rodar +check "recusada nas 3 passadas devolve falha" e_igual "$(rc)" 1 +check " o veredito cita a causa (Network unreachable)" contem "$(inesperado)" "não chegou ao fim do arquivo \(o psql saiu com código 2\): connection to server .*Network unreachable" +check " e não a linha de dica do psql" nao_contem "$(inesperado)" "Is the server running" + if [ "$FAILS" -gt 0 ]; then printf '\n%d falha(s)\n' "$FAILS"; exit 1; fi printf '\ntudo verde\n' diff --git a/tests/shell/update-guard.test.sh b/tests/shell/update-guard.test.sh index 51705eda4..db3d7cf19 100644 --- a/tests/shell/update-guard.test.sh +++ b/tests/shell/update-guard.test.sh @@ -269,7 +269,13 @@ BASELINE_ROTEIRO="$ROTEIRO_UG" BASELINE_ESPERA_S=0 run_update --to v1.1.0 --forc check "a atualização termina com sucesso" test "$RC" -eq 0 check "o update.sh aplicou o baseline duas vezes" test "$(grep -c -- '-f /b.sql' "$DOCKER_LOG")" -eq 2 check "e diz ✓ banco atualizado" grep -q "✓ banco atualizado" "$OUTFILE" +check " contando que foi na 2ª passada (o ✓ depois de disputa não é mudo)" grep -q "✓ banco atualizado na passada 2" "$OUTFILE" check " sem o aviso de banco" test -z "$(grep 'NÃO são os esperados' "$OUTFILE" || true)" +check " e o fim diz Atualização concluída" grep -q "✓ Atualização concluída" "$OUTFILE" +# Com --force na mesma tag ninguém conferiu a imagem: a frase antiga ("o app está +# rodando uma imagem antiga") mentia justo para quem seguiu a dica de repetir. +check "--force na mesma tag diz que está refazendo, sem inventar imagem antiga" grep -q "Refazendo a versão v1.1.0" "$OUTFILE" +check " (a frase da imagem antiga não aparece)" test -z "$(grep 'imagem antiga' "$OUTFILE" || true)" rm -rf "$ROTEIRO_UG"; mkdir -p "$ROTEIRO_UG" for n in 1 2 3; do printf '%s\n' "$DEADLOCK_UG" > "$ROTEIRO_UG/passada.$n"; done @@ -281,6 +287,24 @@ check " a tela mostra o deadlock" grep -q "deadlock detected" "$OUTFILE" # Sem --force, repetir o update.sh responderia "já está na versão mais recente" # e não tocaria no banco. check " e ensina a repetir de um jeito que re-aplica" grep -qF "update.sh --to v1.1.0 --force" "$OUTFILE" +# Na v1.27.3 de uma VPS real o aviso do passo 4 ficou soterrado pelo docker pull, +# e a última frase da tela era "Atualização concluída". +check " o FIM da tela repete que o banco NÃO terminou limpo" grep -q "banco NÃO terminou limpo" "$OUTFILE" +check " e não diz Atualização concluída" test -z "$(grep 'Atualização concluída' "$OUTFILE" || true)" +check " a dica aparece no passo do banco E no fim" test "$(grep -cF 'update.sh --to v1.1.0 --force' "$OUTFILE")" -eq 2 + +# Lista grande (role sem dono: milhares de "must be owner") com a disputa no topo. +# `printf | head -20` sob pipefail levava SIGPIPE e o set -e matava o update.sh +# com 141 — antes do aviso de PERMISSÃO, que existe para este caso, e antes do pull. +rm -rf "$ROTEIRO_UG"; mkdir -p "$ROTEIRO_UG" +{ printf '%s\n' "$DEADLOCK_UG"; for i in $(seq 1 4000); do printf 'psql:/b.sql:%s: ERROR: must be owner of table tabela_%s\n' "$i" "$i"; done; } > "$ROTEIRO_UG/passada.1" +cp "$ROTEIRO_UG/passada.1" "$ROTEIRO_UG/passada.2"; cp "$ROTEIRO_UG/passada.1" "$ROTEIRO_UG/passada.3" +: > "$DOCKER_LOG" +BASELINE_ROTEIRO="$ROTEIRO_UG" BASELINE_ESPERA_S=0 run_update --to v1.1.0 --force +check "lista de erros maior que o buffer do pipe não mata o update.sh" test "$RC" -eq 0 +check " a disputa no topo foi reconhecida (3 passadas)" test "$(grep -c -- '-f /b.sql' "$DOCKER_LOG")" -eq 3 +check " o aviso de PERMISSÃO chegou à tela" grep -q "Os erros são de PERMISSÃO" "$OUTFILE" +check " e o fim diz que o banco NÃO terminou limpo" grep -q "banco NÃO terminou limpo" "$OUTFILE" # ── Clone RASO: a topologia que o install.sh realmente entrega ─────────────── # `install.sh` instala com `git clone --depth 1`. Num repositório raso o diff --git a/tests/unit/ai-knowledge-sources-post.test.ts b/tests/unit/ai-knowledge-sources-post.test.ts index c7238414d..27872e1d1 100644 --- a/tests/unit/ai-knowledge-sources-post.test.ts +++ b/tests/unit/ai-knowledge-sources-post.test.ts @@ -108,7 +108,7 @@ beforeEach(() => { }); describe("POST /api/v1/ai/knowledge/sources — colisão do índice único", () => { - it("23505 vira 409 knowledge_source_type_in_use, em português e sem texto do Postgres", async () => { + it("23505 vira 409 knowledge_source_name_in_use, em português e sem texto do Postgres", async () => { sessaoOk(); dublarBanco({ erroDoInsert: { diff --git a/tests/unit/baseline-nao-constroi-o-que-derruba.test.ts b/tests/unit/baseline-nao-constroi-o-que-derruba.test.ts index c83bd7629..aed6a4c20 100644 --- a/tests/unit/baseline-nao-constroi-o-que-derruba.test.ts +++ b/tests/unit/baseline-nao-constroi-o-que-derruba.test.ts @@ -31,7 +31,7 @@ import { describe, expect, it } from "vitest"; * só tempo: o modelo novo PERMITE o que o índice velho proibia (várias fontes por * agente, cada uma com a sua versão 1), então num clone que usa o acervo a * recriação falhava por duplicata a cada atualização. Medido numa VPS real: os - * três erros em todo `update.sh` desde que ela tinha materiais, e o + * três erros nos dois `update.sh` com log guardado (v1.27.2 e v1.27.3), e o * `deadlock detected` que apagou uma policy na v1.27.3 saiu na tela no meio * deles. A prova em banco é `tests/invariants/baseline-reaplica-sobre-acervo-real.test.ts`. * @@ -40,6 +40,24 @@ import { describe, expect, it } from "vitest"; * `IF NOT EXISTS`, o mesmo que o `create unique index if not exists` que esta * regra já reprova. * + * ## E para policy — a mesma classe, com dano de acesso + * + * A revisão da mesma correção achou 19 policies criadas (17 no corpo do dump, 2 + * em blocos antigos do apêndice) e derrubadas adiante sem nunca serem recriadas + * com o mesmo nome. Aplicado em autocommit, cada `update.sh` fazia a policy + * AMPLA antiga (só "é da organização") valer de novo até o drop — policies + * permissivas somam com OR, então nessa janela um `viewer` gravava e apagava o + * que as policies novas negam; e cada passada extra de `reaplicar_baseline` + * reabria a janela. A chave é nome + tabela: o mesmo nome em outra tabela é + * outra policy. + * + * ## Escopo, escrito para não ser lido maior do que é + * + * Drop por nome LITERAL, com ou sem `if exists`. `execute format('… %I', …)` + * resolve o nome em tempo de execução e fica fora. CHECK e FOREIGN KEY ficam + * fora de propósito: não constroem índice, e as instâncias medidas validam + * coluna recriada vazia. + * * Lê texto; que o ciclo install→update sai 0 é o job `invariants` quem mede, e * `tests/invariants/indices-redundantes-saem.test.ts` mede o estado final. */ @@ -52,6 +70,11 @@ interface Par { condicional: boolean; } +interface Ocorrencia { + chave: string; + pos: number; +} + function linhaDe(sql: string, pos: number): number { return sql.slice(0, pos).split("\n").length; } @@ -71,45 +94,63 @@ function dentroDeBlocoCondicional(sql: string, pos: number): boolean { return !trecho.toLowerCase().includes(`end ${marca.toLowerCase()}`); } +function ocorrencias(sql: string, rx: RegExp, chave: (m: RegExpMatchArray) => string): Ocorrencia[] { + return [...sql.matchAll(rx)].map((m) => ({ chave: chave(m).toLowerCase(), pos: m.index! })); +} + /** - * Pares cria→derruba cujo objeto NÃO sobrevive ao arquivo. Criação do mesmo - * nome depois do último drop é REDEFINIÇÃO (trocar predicado de índice parcial, - * ou o `drop constraint if exists` + `add` do apêndice) e fica fora. + * Pares cria→derruba cujo objeto NÃO sobrevive ao arquivo. Uma recriação da + * mesma chave depois do último drop é REDEFINIÇÃO (trocar predicado de índice + * parcial, o `drop … if exists` + `create` do apêndice, a constraint que volta + * como índice único do mesmo nome) e fica fora. */ -function pares(sql: string, drop: RegExp, criacao: (nome: string) => RegExp, recriacao: (nome: string) => RegExp): Par[] { - const drops = new Map(); - for (const d of sql.matchAll(drop)) { - drops.set(d[1]!.toLowerCase(), d.index!); // o ÚLTIMO drop de cada nome - } +function pares(sql: string, drops: Ocorrencia[], criacoes: Ocorrencia[], recriacoes: Ocorrencia[]): Par[] { + const ultimoDrop = new Map(); + for (const d of drops) ultimoDrop.set(d.chave, d.pos); const achados: Par[] = []; - for (const [nome, posDrop] of drops) { - if ([...sql.matchAll(recriacao(nome))].some((c) => c.index! > posDrop)) continue; - for (const c of sql.matchAll(criacao(nome))) { + for (const [chave, posDrop] of ultimoDrop) { + if (recriacoes.some((r) => r.chave === chave && r.pos > posDrop)) continue; + for (const c of criacoes.filter((c) => c.chave === chave && c.pos < posDrop)) { achados.push({ - nome, - linhaDaCriacao: linhaDe(sql, c.index!), + nome: chave, + linhaDaCriacao: linhaDe(sql, c.pos), linhaDoDrop: linhaDe(sql, posDrop), - condicional: dentroDeBlocoCondicional(sql, c.index!), + condicional: dentroDeBlocoCondicional(sql, c.pos), }); } } return achados; } +const nome = (m: RegExpMatchArray) => m[1]!; +const nomeNaTabela = (m: RegExpMatchArray) => `${m[1]} on ${m[2]}`; + +function criacoesDeIndice(sql: string): Ocorrencia[] { + return ocorrencias(sql, /create (?:unique )?index (?:concurrently )?(?:if not exists )?"?([a-z0-9_]+)"?(?=\s|$)/gi, nome); +} + function paresDeIndice(sql: string): Par[] { - const criacao = (nome: string) => - new RegExp(`create (?:unique )?index (?:concurrently )?(?:if not exists )?"?${nome}"?(?=\\s|$)`, "gi"); - return pares(sql, /drop index (?:concurrently )?if exists (?:"?public"?\.)?"?([a-z0-9_]+)"?/gi, criacao, criacao); + const criacoes = criacoesDeIndice(sql); + const drops = ocorrencias(sql, /drop index (?:concurrently )?(?:if exists )?(?:"?public"?\.)?"?([a-z0-9_]+)"?/gi, nome); + return pares(sql, drops, criacoes, criacoes); } /** Só as que constroem índice: é o índice que custa lock e que falha por duplicata. */ function paresDeConstraint(sql: string): Par[] { - return pares( - sql, - /drop constraint if exists\s+"?([a-z0-9_]+)"?/gi, - (nome) => new RegExp(`add constraint\\s+"?${nome}"?\\s+(?:unique|primary key|exclude)\\b`, "gi"), - (nome) => new RegExp(`add constraint\\s+"?${nome}"?(?=\\s)`, "gi"), - ); + const drops = ocorrencias(sql, /drop constraint (?:if exists )?"?([a-z0-9_]+)"?/gi, nome); + const criacoes = ocorrencias(sql, /add constraint\s+"?([a-z0-9_]+)"?\s+(?:unique|primary key|exclude)\b/gi, nome); + const recriacoes = [ + ...ocorrencias(sql, /add constraint\s+"?([a-z0-9_]+)"?(?=\s)/gi, nome), + ...criacoesDeIndice(sql), + ]; + return pares(sql, drops, criacoes, recriacoes); +} + +function paresDePolicy(sql: string): Par[] { + const alvo = String.raw`"?([a-z0-9_]+)"?\s+on\s+(?:"?public"?\.)?"?([a-z0-9_]+)"?`; + const criacoes = ocorrencias(sql, new RegExp(String.raw`create policy\s+` + alvo, "gi"), nomeNaTabela); + const drops = ocorrencias(sql, new RegExp(String.raw`drop policy\s+(?:if exists\s+)?` + alvo, "gi"), nomeNaTabela); + return pares(sql, drops, criacoes, criacoes); } function proibidos(achados: Par[]): string[] { @@ -118,10 +159,14 @@ function proibidos(achados: Par[]): string[] { .map((p) => `${p.nome}: criado na linha ${p.linhaDaCriacao}, derrubado na ${p.linhaDoDrop}`); } +const nomes = (achados: Par[]) => achados.map((p) => p.nome).sort(); + /** - * As três formas que o arquivo tinha até 2026-09-16, e as duas que NÃO são defeito. + * As formas que o arquivo teve até 2026-09-16, e as que NÃO são defeito. * Controle do instrumento: sem ele, um regex que parasse de casar devolveria - * lista vazia, e "nenhum par proibido" ficaria verde vigiando nada. + * lista vazia, e "nenhum par proibido" ficaria verde vigiando nada. Cada + * propriedade tem o seu caso, para que uma régua cega e uma guarda contada como + * condição não reprovem com a mesma mensagem. */ const SINTETICO = ` CREATE UNIQUE INDEX IF NOT EXISTS "velho_idx" ON "public"."t" USING "btree" ("a") WHERE "ativo"; @@ -134,6 +179,21 @@ ALTER TABLE ONLY "public"."t" ADD CONSTRAINT "velha_uk" UNIQUE ("a", "b"); END IF; END $baseline_guard$; +ALTER TABLE ONLY "public"."t" + ADD CONSTRAINT "velha_pk" PRIMARY KEY ("a"); + +alter table public.t add constraint velha_ex exclude using gist (a with =); + +alter table public.t add constraint vira_indice unique (c); + +DO $baseline_guard$ BEGIN +IF NOT EXISTS (SELECT 1 FROM pg_policy + WHERE polname = 'velha_pol' AND polrelid = '"public"."t"'::regclass) THEN +CREATE POLICY "velha_pol" ON "public"."t" USING (true); +END IF; END $baseline_guard$; + +create policy "mesmo_nome" on public.outra using (true); + do $$ begin if not exists (select 1 from pg_constraint where conname = 'outra_uk') then @@ -145,27 +205,46 @@ end $$; drop index if exists public.velho_idx; drop index if exists public.cond_idx; alter table public.t drop constraint if exists velha_uk; +alter table public.t drop constraint velha_pk; +alter table public.t drop constraint if exists velha_ex; +alter table public.t drop constraint vira_indice; +create unique index if not exists vira_indice on public.t (c); alter table public.t drop constraint if exists redefinida_uk; alter table public.t add constraint redefinida_uk unique (b); +drop policy if exists velha_pol on public.t; +drop policy if exists "mesmo_nome" on public.t; `; -describe("baseline.sql não constrói índice que ele mesmo derruba", () => { - it("o instrumento acha as formas proibidas e poupa as que não são defeito", () => { - expect(proibidos(paresDeIndice(SINTETICO))).toEqual(["velho_idx: criado na linha 2, derrubado na 20"]); - expect( - paresDeIndice(SINTETICO).find((p) => p.nome === "cond_idx")?.condicional, - "criação dentro de `do $$ if … end if` é condicional", - ).toBe(true); - expect( - proibidos(paresDeConstraint(SINTETICO)), - "a guarda de existência do dump não pode contar como condição", - ).toEqual(["velha_uk: criado na linha 9, derrubado na 22"]); - expect( - paresDeConstraint(SINTETICO).some((p) => p.nome === "redefinida_uk"), - "drop + add do mesmo nome é redefinição", - ).toBe(false); +describe("o instrumento, contra formas conhecidas", () => { + it("índice: acha a criação incondicional e poupa a condicional", () => { + expect(proibidos(paresDeIndice(SINTETICO))).toEqual([ + expect.stringMatching(/^velho_idx: criado na linha 2, derrubado na \d+$/), + ]); + expect(paresDeIndice(SINTETICO).find((p) => p.nome === "cond_idx")?.condicional).toBe(true); }); + it("constraint: a régua enxerga UNIQUE, PRIMARY KEY e EXCLUDE, com drop com ou sem if exists", () => { + expect(nomes(paresDeConstraint(SINTETICO))).toEqual(["velha_ex", "velha_pk", "velha_uk"]); + }); + + it("constraint: a guarda de existência do dump não conta como condição", () => { + expect(paresDeConstraint(SINTETICO).find((p) => p.nome === "velha_uk")?.condicional).toBe(false); + }); + + it("constraint: voltar como índice único do mesmo nome é redefinição, e drop + add também", () => { + const achados = nomes(paresDeConstraint(SINTETICO)); + expect(achados).not.toContain("vira_indice"); + expect(achados).not.toContain("redefinida_uk"); + }); + + it("policy: casa nome E tabela, e a guarda de existência não conta como condição", () => { + expect(proibidos(paresDePolicy(SINTETICO))).toEqual([ + expect.stringMatching(/^velha_pol on t: criado na linha \d+, derrubado na \d+$/), + ]); + }); +}); + +describe("baseline.sql não constrói o que ele mesmo derruba", () => { it("o instrumento está vivo no arquivo real: acha drops", () => { expect(paresDeIndice(SQL).length, "nenhum par cria→derruba encontrado — o parser mudou?").toBeGreaterThan(0); }); @@ -182,7 +261,15 @@ describe("baseline.sql não constrói índice que ele mesmo derruba", () => { expect( proibidos(paresDeConstraint(SQL)), "Constraint UNIQUE/PK/EXCLUDE construída e derrubada a cada install/update — e num clone " + - "com dados do modelo novo ela falha por duplicata. Tire a criação do corpo.\n", + "com dados do modelo novo ela falha por duplicata. Tire a criação.\n", + ).toEqual([]); + }); + + it("nenhuma policy é criada antes do próprio drop", () => { + expect( + proibidos(paresDePolicy(SQL)), + "Policy recriada e derrubada a cada install/update: entre as duas, a regra antiga volta a " + + "valer somada às novas. Tire a criação.\n", ).toEqual([]); }); diff --git a/tests/unit/baseline-reaplicavel.test.ts b/tests/unit/baseline-reaplicavel.test.ts index 17d5ae9de..2e4cdd17f 100644 --- a/tests/unit/baseline-reaplicavel.test.ts +++ b/tests/unit/baseline-reaplicavel.test.ts @@ -27,7 +27,7 @@ * - **Apêndice (4 policies): `drop` + `create`.** É o único guard em que uma MUDANÇA * de corpo chega ao clone, e é o defeito desta issue. É também a convenção que as * outras ~89 policies do apêndice já seguem. - * - **Corpo (49 policies): guarda `IF NOT EXISTS`.** Aqui o `drop`+`create` seria + * - **Corpo (32 policies; eram 49 até 2026-09-16): guarda `IF NOT EXISTS`.** Aqui o `drop`+`create` seria * troca ruim: o benefício é nulo (mudança de policy neste repo entra pelo * apêndice, ninguém reescreve o dump) e o custo foi MEDIDO — um `update.sh` que * morre entre o `DROP` e o `CREATE` deixa a tabela **sem** a policy, de forma @@ -35,7 +35,7 @@ * guarda `IF NOT EXISTS` -> policy SOBREVIVEU * `drop` + `create` -> policy APAGADA * É o mesmo modo de falha que `baseline-constraint-reconstruida.test.ts` condena - * para constraints; generalizá-lo para 49 policies seria trocar um erro benigno + * para constraints; generalizá-lo para as policies do corpo seria trocar um erro benigno * filtrado por um buraco de isolamento. * * ## O que este teste NÃO cobre @@ -185,10 +185,12 @@ describe("baseline.sql é re-aplicável", () => { expect(problemas).toEqual([]); // Guardas de vacuidade, com números MEDIDOS e não chutados (49 no corpo, 80 no - // apêndice em 2026-08-13): se o laço parar de achar policies, `problemas` fica - // vazio e o `toEqual([])` acima passa por omissão. A margem é folgada de - // propósito — este par existe para pegar regex morto, não para cravar contagem. - expect(guardadasNoCorpo, "policies guardadas no corpo").toBeGreaterThan(40); + // apêndice em 2026-08-13; 32 no corpo em 2026-09-16, quando saíram as 17 que o + // apêndice derruba — ver baseline-nao-constroi-o-que-derruba.test.ts): se o laço + // parar de achar policies, `problemas` fica vazio e o `toEqual([])` acima passa + // por omissão. A margem é folgada de propósito — este par existe para pegar + // regex morto, não para cravar contagem. + expect(guardadasNoCorpo, "policies guardadas no corpo").toBeGreaterThan(25); expect(guardadasNoApendice, "policies guardadas no apêndice").toBeGreaterThan(70); }); });