fix(kit): a revisão adversarial do conserto — tela, pipe, conexão e a classe inteira

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnbnUnNbYsGPqswKz7918g
This commit is contained in:
Pessoa
2026-09-16 13:43:55 -03:00
co-authored by Claude Opus 5
parent f0ccfe8d05
commit 1a97bc37c7
18 changed files with 355 additions and 248 deletions
@@ -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 <tag> --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.
@@ -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 <tag>` 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 <tag>` 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-<data>.sql.gz` e `backups/waha-<data>.tgz` **na própria VPS** |
| `restore.sh <arquivo>` | "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 <email>` | "esqueci a senha", "me tranquei fora" | redefine a senha pela admin API do Supabase | pede a senha nova no terminal, sem ecoar |
@@ -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 <versão> --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.
@@ -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 <tag> --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.
@@ -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 <tag>` 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 <tag>` 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-<data>.sql.gz` e `backups/waha-<data>.tgz` **na própria VPS** |
| `restore.sh <arquivo>` | "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 <email>` | "esqueci a senha", "me tranquei fora" | redefine a senha pela admin API do Supabase | pede a senha nova no terminal, sem ecoar |
+5 -2
View File
@@ -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 <version> --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`.
+5 -2
View File
@@ -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 <versión> --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`.
+5 -2
View File
@@ -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 <versão> --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`.
+8 -3
View File
@@ -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 <versão> --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
+34 -13
View File
@@ -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 <baseline.sql> [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
}
+3 -1
View File
@@ -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" \
+43 -11
View File
@@ -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.
-156
View File
@@ -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
@@ -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-<nunca definido>}" > "$1/inesperado"
printf "%s" "${BASELINE_PASSADAS-<nunca definido>}" > "$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'
+24
View File
@@ -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
+1 -1
View File
@@ -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: {
@@ -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<string, number>();
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<string, number>();
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([]);
});
+8 -6
View File
@@ -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);
});
});