mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user