mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
fix(kit): baseline re-aplicado some com o ruído e refaz o que perdeu disputa
Medido na hg-vps (update-1.27.2.log e update-1.27.3.log): toda atualização mostrava três `could not create unique index` — ai_kbv_version_unique, ai_kbv_one_active_per_agent, ai_knowledge_sources_unique_per_agent — e na v1.27.3 dois `deadlock detected` saíram no meio deles. Um era o create policy logo depois do drop da policy SELECT de ai_knowledge_sources; a tabela ficou sem ela até o reparo manual. Os três índices: o corpo do dump os criava e o apêndice (0181, 0205) os derruba adiante. O modelo novo guarda versão POR MATERIAL, então dois materiais do mesmo agente com a sua versão 1 são dado válido — na VPS, 5 materiais de um agente, versões 1 e 2 de cada, e os três índices ausentes do banco. Nenhum dado errado; sai a criação do corpo (18 linhas), como o #874 fez com idx_crm_lead_links_lead. Estado final idêntico para banco novo, clone anterior à 0181 (o apêndice derruba) e clone posterior. O deadlock: `reaplicar_baseline` em _common.sh, chamada pelo update.sh e pelo install.sh re-executado. Erro de disputa ou de conexão re-aplica o arquivo inteiro (idempotente), até 3 passadas; o veredito é o da última. psql que sai != 0 nunca vira ✓, mesmo sem a palavra ERROR. Se a disputa persiste, a tela ensina `update.sh --to <tag> --force` (sem --force o script diria "já está na versão mais recente" e não tocaria no banco). Vale a partir da atualização seguinte: quem atualiza é o update.sh que já está no disco. Testes: - tests/shell/baseline-reaplica-apos-disputa.test.sh (novo, no test:shell): 23 checks — disputa que passa, que persiste, permissão, conexão caída sem ERROR, docker 125, ruído benigno; controle do dublê. - update-guard.test.sh caso 4c e test-validators.sh: os dois call sites (65 e 270 checks verdes). - baseline-nao-constroi-o-que-derruba.test.ts: dívida quitada; a régua passa a ler constraint UNIQUE/PK/EXCLUDE, e o DO $baseline_guard$ deixa de contar como condicional; controle sintético das formas. - tests/invariants/baseline-reaplica-sobre-acervo-real.test.ts: fixture na forma de produção, controle de que os três índices recusam a fixture, e re-aplicar o baseline inteiro sem ON_ERROR_STOP sai com zero erro e o acervo intacto. pnpm test:db com este + rag-acervo + indices-redundantes: install ok, update ok, os 2 casos novos 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:
@@ -0,0 +1,10 @@
|
||||
---
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -439,6 +439,59 @@ url_do_schema() {
|
||||
# alcance de uma role de app com grants só em `public`.
|
||||
psql_run() { docker run --rm -i postgres:17-alpine psql "$(url_do_schema)" -v ON_ERROR_STOP=1 "$@"; }
|
||||
|
||||
# ── Re-aplicar o baseline num banco que JÁ existe ────────────────────────────
|
||||
# Chamado pelo `update.sh` e pelo `install.sh` re-executado. Sem `ON_ERROR_STOP`,
|
||||
# de propósito: com a flag, o primeiro "já existe" de um clone antigo pararia o
|
||||
# arquivo, e o apêndice com as migrations novas nunca chegaria.
|
||||
#
|
||||
# O preço é que o psql segue depois de QUALQUER erro, inclusive dos que não vêm
|
||||
# do arquivo. Medido numa VPS real, na v1.27.3: com o app atendendo, dois
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# reaplicar_baseline <baseline.sql> [log]
|
||||
# 0 → a última passada não teve erro fora dos benignos
|
||||
# 1 → teve; as linhas ficam em BASELINE_INESPERADO
|
||||
# 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'
|
||||
reaplicar_baseline() {
|
||||
local arquivo="$1" log="${2:-}" tentativas="${BASELINE_TENTATIVAS:-3}" espera="${BASELINE_ESPERA_S:-10}"
|
||||
local passada=1 raw rc
|
||||
[ -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)"
|
||||
# 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.
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
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')"
|
||||
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))
|
||||
done
|
||||
}
|
||||
|
||||
# ── As três imagens que NÓS publicamos ───────────────────────────────────────
|
||||
# O namespace é constante e literal de propósito: ele está gravado no .env de
|
||||
# toda instalação viva, e derivá-lo de variável faria o kit antigo (que já está
|
||||
|
||||
@@ -1806,16 +1806,13 @@ if [ -f supabase/baseline.sql ]; then
|
||||
|
||||
if [ "$has_schema" = "1" ]; then
|
||||
c_ylw "• schema já existe — re-aplicando em modo update (erros 'já existe' são esperados e ficam no log)"
|
||||
raw="$(docker run --rm -i -v "$PROJECT_DIR/supabase/baseline.sql:/baseline.sql:ro" \
|
||||
postgres:17-alpine psql "$(url_do_schema)" -q -f /baseline.sql 2>&1 || true)"
|
||||
printf '%s\n' "$raw" > "$SCHEMA_LOG"
|
||||
benign='already exists|multiple primary keys|multiple default values|is already a member|already a partition'
|
||||
unexpected="$(printf '%s\n' "$raw" | grep -iE 'ERROR|FATAL' | grep -viE "$benign" || true)"
|
||||
if [ -n "$unexpected" ]; then
|
||||
c_ylw "⚠ Erros no banco que NÃO são os esperados (log completo: $SCHEMA_LOG):"
|
||||
printf '%s\n' "$unexpected" | head -20
|
||||
else
|
||||
# Mesmo contrato do update.sh, inclusive a nova passada quando o banco está
|
||||
# em disputa: `reaplicar_baseline` em _common.sh.
|
||||
if reaplicar_baseline "$PROJECT_DIR/supabase/baseline.sql" "$SCHEMA_LOG"; 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
|
||||
fi
|
||||
else
|
||||
if docker run --rm -i -v "$PROJECT_DIR/supabase/baseline.sql:/baseline.sql:ro" \
|
||||
|
||||
@@ -2479,6 +2479,56 @@ NEXT_PUBLIC_APP_URL='https://crm.exemplo.com.br'")"
|
||||
) || fail=1
|
||||
rm -rf "$TMP_DDL_C"
|
||||
|
||||
echo "install.sh re-executado: deadlock no baseline faz o arquivo ser aplicado de novo"
|
||||
# Sobre um banco que JÁ tem o schema, o install.sh segue o contrato do update.sh
|
||||
# (sem ON_ERROR_STOP) e tinha o mesmo buraco: um `deadlock detected` com o app no
|
||||
# ar deixava um `drop policy` sem o `create` seguinte — medido numa VPS real na
|
||||
# v1.27.3, pelo update.sh. A função é uma só (`reaplicar_baseline`, _common.sh),
|
||||
# com suíte própria em tests/shell/baseline-reaplica-apos-disputa.test.sh; este
|
||||
# caso prova que o install.sh passa por ela.
|
||||
TMP_REAPLICA="$(mktemp -d)"
|
||||
(
|
||||
montar_vps "$TMP_REAPLICA" "crmreaplica" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >> "$DOCKER_LOG"
|
||||
case "$1" in
|
||||
compose) case "$*" in *" exec "*) printf 'healthy\n{"data":{"status":"healthy"}}\n' ;; esac; exit 0 ;;
|
||||
esac
|
||||
case "$*" in
|
||||
# A sonda "o schema já existe?" responde que sim: é o ramo sob prova.
|
||||
*"table_name='organizations'"*) printf '1\n' ;;
|
||||
# A 1ª aplicação do baseline perde uma disputa; as seguintes saem limpas.
|
||||
*" -f /b.sql")
|
||||
marca="$(dirname "$DOCKER_LOG")/baseline-passadas"
|
||||
printf x >> "$marca"
|
||||
[ "$(wc -c < "$marca" | tr -d ' ')" = 1 ] && printf 'psql:/b.sql:16766: ERROR: deadlock detected\n' ;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
mkdir -p "$VPS_PROJ/supabase"; : > "$VPS_PROJ/supabase/baseline.sql"
|
||||
export BASELINE_ESPERA_S=0
|
||||
saida="$(rodar install.sh --yes)"
|
||||
|
||||
# Vacuidade: sem o ramo de schema existente, não há re-aplicação para medir.
|
||||
if ! printf '%s' "$saida" | grep -q "schema já existe"; then
|
||||
printf ' ✗ o install.sh não entrou no ramo de schema existente — teste inconclusivo, não verde\n'; exit 1
|
||||
fi
|
||||
n="$(grep -c -- '-f /b.sql' "$VPS_LOG")"
|
||||
if [ "$n" != 2 ]; then
|
||||
printf ' ✗ esperava o baseline aplicado 2 vezes (deadlock, depois limpo); foram %s\n' "$n"; exit 1
|
||||
fi
|
||||
printf ' ✓ o deadlock da 1ª passada fez o install.sh aplicar o baseline de novo\n'
|
||||
if ! printf '%s' "$saida" | grep -q "✓ schema re-aplicado"; then
|
||||
printf ' ✗ a 2ª passada saiu limpa e a tela não disse ✓ schema re-aplicado:\n'
|
||||
printf '%s\n' "$saida" | grep -iE "schema|banco|deadlock" | sed 's/^/ /'; exit 1
|
||||
fi
|
||||
if printf '%s' "$saida" | grep -q "NÃO são os esperados"; then
|
||||
printf ' ✗ o deadlock da 1ª passada virou aviso, embora a 2ª tenha curado\n'; exit 1
|
||||
fi
|
||||
printf ' ✓ e o veredito é o da última passada: ✓ schema re-aplicado, sem aviso\n'
|
||||
) || fail=1
|
||||
rm -rf "$TMP_REAPLICA"
|
||||
|
||||
echo "e-mails de acesso: quem JÁ instalou também é avisado — uma vez só"
|
||||
# A população realmente quebrada hoje é quem instalou ANTES de a entrevista pedir
|
||||
# o token: o Site URL do projeto dela está em `localhost:3000` e nada a alcança.
|
||||
|
||||
@@ -140,7 +140,8 @@ fi
|
||||
# O baseline é idempotente e auto-curativo. Re-aplicar numa base que JÁ existe
|
||||
# gera erros do tipo "já existe" / "multiple primary keys" — isso é ESPERADO e
|
||||
# inofensivo (são objetos que já estavam lá). Filtramos esse ruído e só
|
||||
# mostramos problemas de verdade.
|
||||
# mostramos problemas de verdade. Erro de disputa com o app no ar (deadlock)
|
||||
# faz o arquivo ser aplicado de novo: ver `reaplicar_baseline` em _common.sh.
|
||||
# Re-aplicar o baseline é DDL, então vai por `url_do_schema` (_common.sh) e não
|
||||
# pela string do app: numa instalação em Supabase próprio, com a role menor no
|
||||
# `.env` como recomendamos, este passo passava a falhar em silêncio a cada
|
||||
@@ -152,14 +153,10 @@ if [ -f supabase/baseline.sql ]; then
|
||||
"create extension if not exists vector with schema public; create extension if not exists citext with schema public; create extension if not exists pg_trgm with schema public;" \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
raw="$(docker run --rm -i -v "$PROJECT_DIR/supabase/baseline.sql:/b.sql:ro" \
|
||||
postgres:17-alpine psql "$(url_do_schema)" -f /b.sql 2>&1 || true)"
|
||||
|
||||
# Erros benignos ao re-aplicar sobre uma base existente:
|
||||
benign='already exists|multiple primary keys|multiple default values|is already a member|already a partition'
|
||||
unexpected="$(printf '%s\n' "$raw" | grep -iE 'ERROR|FATAL' | grep -viE "$benign" || true)"
|
||||
|
||||
if [ -n "$unexpected" ]; then
|
||||
if reaplicar_baseline "$PROJECT_DIR/supabase/baseline.sql"; then
|
||||
c_grn "✓ banco atualizado (e conversas reorganizadas, se havia bagunça)."
|
||||
else
|
||||
unexpected="$BASELINE_INESPERADO"
|
||||
c_ylw "⚠ Apareceram avisos no banco que NÃO são os esperados:"
|
||||
printf '%s\n' "$unexpected" | head -20
|
||||
c_ylw " O app pode ainda funcionar. Se algo estiver errado, restaure o backup (restore.sh)."
|
||||
@@ -168,8 +165,12 @@ if [ -f supabase/baseline.sql ]; then
|
||||
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
|
||||
else
|
||||
c_grn "✓ banco atualizado (e conversas reorganizadas, se havia bagunça)."
|
||||
# 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"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
c_ylw "⚠ supabase/baseline.sql não encontrado — pulei a parte do banco."
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ export const ApiErrorCodes = {
|
||||
next_action_absent: "next_action_absent", // decisão sobre proposta que não existe (mais) [wave 4]
|
||||
next_action_changed: "next_action_changed", // o agente reescreveu a proposta entre o render e o clique
|
||||
channel_archived: "channel_archived", // ação sobre canal que o usuário excluiu (a linha só sobrevive como âncora das FKs)
|
||||
knowledge_source_type_in_use: "knowledge_source_type_in_use", // já existe fonte ATIVA daquele tipo para o agente (índice ai_knowledge_sources_unique_per_agent)
|
||||
knowledge_source_type_in_use: "knowledge_source_type_in_use", // fonte ATIVA do mesmo tipo no agente — era o índice ai_knowledge_sources_unique_per_agent, que a 0181 derrubou; nenhuma rota emite mais este código
|
||||
voice_already_paired: "voice_already_paired", // POST /voice/sessions/pair com aparelho já vinculado — a saída é DELETE /voice/sessions, nunca re-parear por cima (ver a rota)
|
||||
|
||||
// 422 — semântica
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
"test:journeys": "playwright test -c tests/journeys/playwright.config.ts",
|
||||
"test:unit": "vitest run",
|
||||
"test:db": "bash scripts/test-db.sh",
|
||||
"test:shell": "bash tests/shell/update-guard.test.sh && bash tests/shell/dono-do-projeto.test.sh && bash tests/shell/scheduler-entrypoint.test.sh && bash tests/shell/cron-sem-crontab-previo.test.sh && bash tests/shell/owner-id-por-email.test.sh && bash tests/shell/deskcomm-contribuir.test.sh && bash hostgator-setup-kit/test-validators.sh",
|
||||
"test:shell": "bash tests/shell/update-guard.test.sh && bash tests/shell/baseline-reaplica-apos-disputa.test.sh && bash tests/shell/dono-do-projeto.test.sh && bash tests/shell/scheduler-entrypoint.test.sh && bash tests/shell/cron-sem-crontab-previo.test.sh && bash tests/shell/owner-id-por-email.test.sh && bash tests/shell/deskcomm-contribuir.test.sh && bash hostgator-setup-kit/test-validators.sh",
|
||||
"test:invariants": "bash scripts/test-db.sh",
|
||||
"lint:channels": "tsx scripts/lint-channels.ts",
|
||||
"lint:role-rank": "tsx scripts/lint-role-rank.ts",
|
||||
|
||||
@@ -1993,16 +1993,6 @@ END IF; END $baseline_guard$;
|
||||
|
||||
|
||||
|
||||
DO $baseline_guard$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ai_kbv_version_unique' AND conrelid = '"public"."ai_knowledge_versions"'::regclass)
|
||||
AND to_regclass('"public"."ai_kbv_version_unique"') IS NULL THEN
|
||||
ALTER TABLE ONLY "public"."ai_knowledge_versions"
|
||||
ADD CONSTRAINT "ai_kbv_version_unique" UNIQUE ("agent_id", "version_number");
|
||||
END IF; END $baseline_guard$;
|
||||
|
||||
|
||||
|
||||
DO $baseline_guard$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ai_knowledge_sources_pkey' AND conrelid = '"public"."ai_knowledge_sources"'::regclass)
|
||||
@@ -2551,18 +2541,10 @@ CREATE INDEX IF NOT EXISTS "ai_invocations_org_created_idx" ON "public"."ai_invo
|
||||
|
||||
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ai_kbv_one_active_per_agent" ON "public"."ai_knowledge_versions" USING "btree" ("agent_id") WHERE "is_active";
|
||||
|
||||
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "ai_knowledge_sources_agent_idx" ON "public"."ai_knowledge_sources" USING "btree" ("agent_id", "is_active");
|
||||
|
||||
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ai_knowledge_sources_unique_per_agent" ON "public"."ai_knowledge_sources" USING "btree" ("agent_id", "source_type") WHERE "is_active";
|
||||
|
||||
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ai_models_one_default_per_provider" ON "public"."ai_models" USING "btree" ("provider") WHERE "is_default_for_provider";
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { GOV_ORG, lastLine, seedGov, sql } from "./gov-helpers";
|
||||
|
||||
/**
|
||||
* RE-APLICAR O BASELINE SOBRE UM ACERVO DE VERDADE NÃO ERRA.
|
||||
*
|
||||
* O `update.sh` re-aplica o `baseline.sql` inteiro num banco que tem DADOS. O
|
||||
* modo update do `scripts/test-db.sh` re-aplica com ON_ERROR_STOP=1, mas sobre o
|
||||
* molde vazio — e erro que depende de dado não aparece em banco vazio.
|
||||
*
|
||||
* Foi assim que três criações únicas do corpo do dump passaram meses falhando
|
||||
* em produção sem nenhum gate ver: `ai_kbv_version_unique` (agente, número),
|
||||
* `ai_kbv_one_active_per_agent` e `ai_knowledge_sources_unique_per_agent`
|
||||
* (agente, tipo). As migrations 0181 e 0205 trocaram o modelo — a versão passou
|
||||
* a ser contada POR MATERIAL — e o apêndice as derruba logo adiante, mas o corpo
|
||||
* seguia tentando construí-las antes. Num agente com dois materiais, cada um com
|
||||
* a sua versão 1, o índice por agente não constrói. Medido numa VPS real
|
||||
* (5 materiais de um agente, versões 1 e 2 de cada): os três
|
||||
* `could not create unique index` em todo `update.sh` desde que ela tinha
|
||||
* materiais; e o `deadlock detected` que apagou uma policy na v1.27.3 saiu na
|
||||
* tela misturado a eles, onde ninguém o distinguiu do ruído de sempre.
|
||||
*
|
||||
* Nenhum dado estava errado — a fixture abaixo é o que o produto grava hoje. A
|
||||
* regra de texto que pega a forma do defeito é
|
||||
* `tests/unit/baseline-nao-constroi-o-que-derruba.test.ts`; esta é a prova em
|
||||
* banco de que re-aplicar sobre dado real sai limpo.
|
||||
*/
|
||||
|
||||
const BASELINE = readFileSync(join(process.cwd(), "supabase", "baseline.sql"), "utf8");
|
||||
|
||||
const AGENTE = "eeeeeeee-1111-4000-8000-000000000001";
|
||||
const FONTE_1 = "eeeeeeee-2222-4000-8000-000000000001";
|
||||
const FONTE_2 = "eeeeeeee-2222-4000-8000-000000000002";
|
||||
|
||||
/** Os três índices únicos do modelo por agente, na forma exata do corpo do dump. */
|
||||
const INDICES_DO_MODELO_ANTIGO = [
|
||||
{
|
||||
nome: "ai_kbv_version_unique",
|
||||
ddl: `alter table only public.ai_knowledge_versions
|
||||
add constraint ai_kbv_version_unique unique (agent_id, version_number);`,
|
||||
},
|
||||
{
|
||||
nome: "ai_kbv_one_active_per_agent",
|
||||
ddl: `create unique index ai_kbv_one_active_per_agent
|
||||
on public.ai_knowledge_versions using btree (agent_id) where is_active;`,
|
||||
},
|
||||
{
|
||||
nome: "ai_knowledge_sources_unique_per_agent",
|
||||
ddl: `create unique index ai_knowledge_sources_unique_per_agent
|
||||
on public.ai_knowledge_sources using btree (agent_id, source_type) where is_active;`,
|
||||
},
|
||||
];
|
||||
|
||||
function contagemDoAcervo(): string {
|
||||
return lastLine(
|
||||
sql(`select (select count(*) from public.ai_knowledge_sources where agent_id = '${AGENTE}')
|
||||
|| '|' || (select count(*) from public.ai_knowledge_versions where agent_id = '${AGENTE}');`),
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
seedGov();
|
||||
// A forma de produção: UM agente, DOIS materiais do mesmo tipo, e cada material
|
||||
// com a própria história (versão 1 aposentada, versão 2 ativa).
|
||||
sql(`
|
||||
insert into public.ai_agents (id, organization_id, name, system_prompt, kind, is_default)
|
||||
values ('${AGENTE}', '${GOV_ORG}', 'Acervo reaplicado', 'p', 'mcp_agent', false)
|
||||
on conflict do nothing;
|
||||
|
||||
insert into public.ai_knowledge_sources
|
||||
(id, organization_id, agent_id, source_type, name, status, is_active)
|
||||
values ('${FONTE_1}', '${GOV_ORG}', '${AGENTE}', 'documento', 'Catálogo de produtos', 'ready', true),
|
||||
('${FONTE_2}', '${GOV_ORG}', '${AGENTE}', 'documento', 'Política de trocas', 'ready', true)
|
||||
on conflict do nothing;
|
||||
|
||||
insert into public.ai_knowledge_versions
|
||||
(organization_id, agent_id, knowledge_source_id, version_number, status, is_active)
|
||||
values ('${GOV_ORG}', '${AGENTE}', '${FONTE_1}', 1, 'ready', false),
|
||||
('${GOV_ORG}', '${AGENTE}', '${FONTE_1}', 2, 'ready', true),
|
||||
('${GOV_ORG}', '${AGENTE}', '${FONTE_2}', 1, 'ready', false),
|
||||
('${GOV_ORG}', '${AGENTE}', '${FONTE_2}', 2, 'ready', true);
|
||||
`);
|
||||
}, 60_000);
|
||||
|
||||
/**
|
||||
* Tenta construir cada índice do modelo antigo sobre a fixture, cada um na sua
|
||||
* subtransação, e devolve `nome=desfecho` de todos. UMA sessão de psql e tudo
|
||||
* desfeito no fim: o banco do arquivo volta ao que era.
|
||||
*/
|
||||
function construirModeloAntigo(): string {
|
||||
const tentativas = INDICES_DO_MODELO_ANTIGO.map(
|
||||
({ nome, ddl }) => `
|
||||
begin
|
||||
${ddl}
|
||||
insert into pg_temp.desfecho values ('${nome}', 'construiu');
|
||||
exception when others then
|
||||
insert into pg_temp.desfecho values ('${nome}', sqlerrm);
|
||||
end;`,
|
||||
).join("\n");
|
||||
const saida = sql(`begin;
|
||||
create temporary table desfecho (nome text, resultado text) on commit drop;
|
||||
do $$ begin ${tentativas} end $$;
|
||||
select string_agg(nome || '=' || resultado, ' | ' order by nome) from pg_temp.desfecho;
|
||||
rollback;`);
|
||||
return lastLine(saida.replace(/\nROLLBACK$/, ""));
|
||||
}
|
||||
|
||||
describe("baseline re-aplicado sobre acervo do modelo por material", () => {
|
||||
it(
|
||||
"a fixture é mesmo o que os índices do modelo antigo recusam",
|
||||
() => {
|
||||
// Controle positivo. Sem ele, uma fixture que deixasse de violar o modelo
|
||||
// antigo (um material só, números distintos) deixaria o caso de baixo verde
|
||||
// com os três índices de volta no corpo — medindo um banco onde eles cabem.
|
||||
const desfechos = construirModeloAntigo();
|
||||
for (const { nome } of INDICES_DO_MODELO_ANTIGO) {
|
||||
expect(desfechos, `${nome} construiu sobre a fixture — ela não reproduz o acervo de produção`).toContain(
|
||||
`${nome}=could not create unique index "${nome}"`,
|
||||
);
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"re-aplicar o baseline inteiro, como o update.sh faz, não produz erro nenhum",
|
||||
() => {
|
||||
const antes = contagemDoAcervo();
|
||||
expect(antes, "fixture incompleta").toBe("2|4");
|
||||
|
||||
// SEM ON_ERROR_STOP, como o update.sh: o psql segue depois de cada erro, e a
|
||||
// lista abaixo traz TODOS, não só o primeiro. Os NOTICE de "já existe,
|
||||
// pulando" saem do caminho; erro continua sendo impresso.
|
||||
const r = spawnSync(
|
||||
"docker",
|
||||
[
|
||||
"exec",
|
||||
"-i",
|
||||
"-e",
|
||||
"PGOPTIONS=-c client_min_messages=warning",
|
||||
process.env.TEST_DB_CONTAINER!,
|
||||
"psql",
|
||||
"-U",
|
||||
"postgres",
|
||||
"-d",
|
||||
"postgres",
|
||||
"-q",
|
||||
"-f",
|
||||
"-",
|
||||
],
|
||||
{ input: BASELINE, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
|
||||
);
|
||||
expect(r.error, "não consegui rodar o psql no contêiner").toBeUndefined();
|
||||
expect(r.status, `o psql não chegou ao fim do arquivo:\n${r.stderr.slice(-2000)}`).toBe(0);
|
||||
|
||||
const erros = r.stderr.split("\n").filter((l) => /\b(ERROR|FATAL)\b/.test(l));
|
||||
expect(erros, "re-aplicar sobre dado real errou — é o que o update.sh mostraria ao dono").toEqual([]);
|
||||
|
||||
// Sair limpo não pode ser por ter apagado o que atrapalhava.
|
||||
expect(contagemDoAcervo(), "re-aplicar o baseline mexeu no acervo").toBe(antes);
|
||||
const nomes = INDICES_DO_MODELO_ANTIGO.map((i) => `'${i.nome}'`).join(",");
|
||||
expect(lastLine(sql(`select count(*) from pg_class where relname in (${nomes});`))).toBe("0");
|
||||
expect(
|
||||
lastLine(
|
||||
sql(`select string_agg(indexname, ',' order by indexname) from pg_indexes
|
||||
where schemaname = 'public' and indexname in ('ai_kbv_uma_ativa_por_fonte', 'ai_kbv_version_por_fonte');`),
|
||||
),
|
||||
"o modelo por material perdeu a própria unicidade",
|
||||
).toBe("ai_kbv_uma_ativa_por_fonte,ai_kbv_version_por_fonte");
|
||||
},
|
||||
300_000,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prova de `reaplicar_baseline` (hostgator-setup-kit/_common.sh), com `docker`
|
||||
# substituído por um dublê que devolve a saída do psql passada a passada.
|
||||
#
|
||||
# bash tests/shell/baseline-reaplica-apos-disputa.test.sh
|
||||
#
|
||||
# O defeito, medido numa VPS real na v1.27.3: o update.sh re-aplicou o baseline
|
||||
# com o app atendendo, dois comandos perderam um `deadlock detected`, e um deles
|
||||
# era o `create policy` logo depois do `drop policy` da mesma policy. O psql
|
||||
# seguiu (é o contrato sem ON_ERROR_STOP), o script avisou e seguiu também, e
|
||||
# `ai_knowledge_sources` ficou sem a policy de leitura até alguém refazer o
|
||||
# bloco à mão.
|
||||
#
|
||||
# O que está sob prova:
|
||||
# 1. erro de disputa numa passada → o arquivo é aplicado DE NOVO, e o veredito
|
||||
# é o da última passada (a que fica no banco);
|
||||
# 2. disputa que não passa → desiste no teto e devolve o erro, sem "✓";
|
||||
# 3. erro que não é de disputa (permissão, dado) → uma passada só: repetir
|
||||
# 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.
|
||||
set -uo pipefail
|
||||
|
||||
RAIZ="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
FAILS=0
|
||||
check() { # check <descrição> <comando de verificação...>
|
||||
if "${@:2}"; then printf ' ✓ %s\n' "$1"; else printf ' ✗ %s\n' "$1"; FAILS=$((FAILS + 1)); fi
|
||||
}
|
||||
|
||||
# ── Dublê de `docker` ────────────────────────────────────────────────────────
|
||||
# Cada chamada que aplica o baseline consome a próxima passada do roteiro:
|
||||
# `$ROTEIRO/passada.N` é o que o psql imprime, `$ROTEIRO/saida.N` o código com
|
||||
# que ele sai (0 quando o arquivo não existe). Passada sem roteiro sai limpa.
|
||||
mkdir -p "$WORK/bin"
|
||||
cat > "$WORK/bin/docker" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >> "$DOCKER_LOG"
|
||||
case " $* " in
|
||||
*" -f /b.sql "*)
|
||||
n=$(( $(cat "$ROTEIRO/n" 2>/dev/null || echo 0) + 1 ))
|
||||
printf '%s' "$n" > "$ROTEIRO/n"
|
||||
[ -f "$ROTEIRO/passada.$n" ] && cat "$ROTEIRO/passada.$n"
|
||||
exit "$(cat "$ROTEIRO/saida.$n" 2>/dev/null || echo 0)" ;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$WORK/bin/docker"
|
||||
|
||||
printf 'select 1;\n' > "$WORK/baseline.sql"
|
||||
|
||||
# Saídas de psql copiadas da forma real (update-1.27.3.log da VPS, 2026-09-15).
|
||||
BENIGNO='psql:/b.sql:1918: ERROR: multiple primary keys for table "ai_agent_runs" are not allowed
|
||||
psql:/b.sql:2554: ERROR: relation "ai_invocations_org_created_idx" already exists'
|
||||
DEADLOCK='psql:/b.sql:16766: ERROR: deadlock detected
|
||||
DETAIL: Process 4121 waits for AccessExclusiveLock on relation 29187 of database 5; blocked by process 3987.
|
||||
HINT: See server log for query details.'
|
||||
|
||||
# roteiro <passada> <saída do psql> [código de saída]
|
||||
roteiro() {
|
||||
printf '%s\n' "$2" > "$ROTEIRO/passada.$1"
|
||||
[ $# -ge 3 ] && printf '%s' "$3" > "$ROTEIRO/saida.$1"
|
||||
return 0
|
||||
}
|
||||
|
||||
novo_caso() {
|
||||
ROTEIRO="$WORK/roteiro.$1"
|
||||
rm -rf "$ROTEIRO"; mkdir -p "$ROTEIRO"
|
||||
: > "$WORK/docker.log"
|
||||
}
|
||||
|
||||
# Roda a função num bash filho, com o `set -euo pipefail` que o _common.sh liga
|
||||
# nos scripts de verdade, e guarda o que a prova lê: código, veredito, tela, log.
|
||||
rodar() {
|
||||
bash -c '
|
||||
export PATH="$1/bin:$PATH" DOCKER_LOG="$1/docker.log" ROTEIRO="$2"
|
||||
export SUPABASE_DB_URL="postgresql://postgres:x@db.exemplo:5432/postgres"
|
||||
export BASELINE_ESPERA_S=0
|
||||
source "$3/hostgator-setup-kit/_common.sh" >/dev/null 2>&1
|
||||
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"
|
||||
' _ "$WORK" "$ROTEIRO" "$RAIZ" > "$WORK/tela" 2>&1
|
||||
}
|
||||
|
||||
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"; }
|
||||
|
||||
echo "── 0. O dublê é o que a prova pensa que é"
|
||||
# Controle positivo: sem ele, uma função que nunca chama o docker daria
|
||||
# "uma passada só" por zero passadas, e os casos 3 e 5 ficariam verdes medindo nada.
|
||||
novo_caso controle
|
||||
roteiro 1 "$DEADLOCK"
|
||||
PATH="$WORK/bin:$PATH" DOCKER_LOG="$WORK/docker.log" ROTEIRO="$ROTEIRO" \
|
||||
docker run --rm -i postgres:17-alpine psql x -q -f /b.sql > "$WORK/controle" 2>&1
|
||||
check "uma chamada do psql consome a passada 1 do roteiro" grep -q "deadlock detected" "$WORK/controle"
|
||||
check "e o log do dublê conta a chamada" e_igual "$(passadas)" 1
|
||||
|
||||
echo "── 1. Deadlock na 1ª passada, limpa na 2ª: aplica de novo e o veredito é o da 2ª"
|
||||
novo_caso disputa-passa
|
||||
roteiro 1 "$BENIGNO
|
||||
$DEADLOCK"
|
||||
roteiro 2 "$BENIGNO"
|
||||
rodar
|
||||
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 "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"
|
||||
|
||||
echo "── 2. Disputa que não passa: desiste no teto e devolve o erro"
|
||||
novo_caso disputa-persiste
|
||||
roteiro 1 "$DEADLOCK"; roteiro 2 "$DEADLOCK"; roteiro 3 "$DEADLOCK"; roteiro 4 ""
|
||||
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"
|
||||
|
||||
echo "── 3. Erro que não é de disputa: uma passada só"
|
||||
novo_caso permissao
|
||||
roteiro 1 'psql:/b.sql:88: ERROR: permission denied for schema public'
|
||||
roteiro 2 ""
|
||||
rodar
|
||||
check "devolve falha" e_igual "$(rc)" 1
|
||||
check "não re-aplicou (repetir daria o mesmo erro)" e_igual "$(passadas)" 1
|
||||
check "o veredito traz o erro de permissão" contem "$(inesperado)" "permission denied"
|
||||
|
||||
echo "── 4. psql que não chega ao fim do arquivo nunca é sucesso"
|
||||
novo_caso conexao-cai
|
||||
# Sem a palavra ERROR de propósito: é o que isola a leitura do código de saída.
|
||||
roteiro 1 'psql:/b.sql:9000: server closed the connection unexpectedly
|
||||
This probably means the server terminated abnormally
|
||||
before or while processing the request.
|
||||
psql:/b.sql:9000: connection to server was lost' 2
|
||||
roteiro 2 ""
|
||||
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 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\)"
|
||||
|
||||
echo "── 5. Ruído benigno não é aviso nem motivo para aplicar de novo"
|
||||
novo_caso benigno
|
||||
roteiro 1 "$BENIGNO"
|
||||
roteiro 2 "$DEADLOCK"
|
||||
rodar
|
||||
check "devolve sucesso" e_igual "$(rc)" 0
|
||||
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"
|
||||
|
||||
if [ "$FAILS" -gt 0 ]; then printf '\n%d falha(s)\n' "$FAILS"; exit 1; fi
|
||||
printf '\ntudo verde\n'
|
||||
@@ -83,6 +83,14 @@ case " $* " in
|
||||
# devolver algo: com PREV_IMAGE vazio o rollback nem seria tentado, e o teste
|
||||
# do agente passaria mesmo com o defeito de volta.
|
||||
*" images "*) printf 'sha256:deadbeef\n' ;;
|
||||
# Aplicação do baseline. Só com BASELINE_ROTEIRO no ambiente (caso 4c): cada
|
||||
# chamada imprime a próxima passada do roteiro. Fora dele, sai limpa como antes.
|
||||
*" -f /b.sql "*)
|
||||
if [ -n "${BASELINE_ROTEIRO:-}" ]; then
|
||||
n=$(( $(cat "$BASELINE_ROTEIRO/n" 2>/dev/null || echo 0) + 1 ))
|
||||
printf '%s' "$n" > "$BASELINE_ROTEIRO/n"
|
||||
[ -f "$BASELINE_ROTEIRO/passada.$n" ] && cat "$BASELINE_ROTEIRO/passada.$n"
|
||||
fi ;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
@@ -246,6 +254,34 @@ check "o scheduler herda a política da tag imutável" \
|
||||
check "nenhuma das chaves novas duplicou" \
|
||||
test "$(grep -cE '^(WORKER|SCHEDULER)_(IMAGE|PULL_POLICY)=' .env)" -eq 4
|
||||
|
||||
echo "── 4c. Deadlock com o app no ar: o banco é aplicado de novo antes do ✓"
|
||||
# Medido numa VPS real na v1.27.3: com o app atendendo, o `create policy` logo
|
||||
# depois de um `drop policy` perdeu um deadlock, o script avisou e seguiu, e a
|
||||
# tabela ficou sem a policy de leitura. A função tem a própria suíte
|
||||
# (baseline-reaplica-apos-disputa.test.sh); este caso prova que o update.sh
|
||||
# passa por ela e que a tela diz a verdade nos dois desfechos.
|
||||
ROTEIRO_UG="$WORK/roteiro-baseline"
|
||||
DEADLOCK_UG='psql:/b.sql:16766: ERROR: deadlock detected'
|
||||
mkdir -p "$ROTEIRO_UG"
|
||||
printf '%s\n' "$DEADLOCK_UG" > "$ROTEIRO_UG/passada.1"
|
||||
: > "$DOCKER_LOG"
|
||||
BASELINE_ROTEIRO="$ROTEIRO_UG" BASELINE_ESPERA_S=0 run_update --to v1.1.0 --force
|
||||
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 " sem o aviso de banco" test -z "$(grep 'NÃO são os esperados' "$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
|
||||
: > "$DOCKER_LOG"
|
||||
BASELINE_ROTEIRO="$ROTEIRO_UG" BASELINE_ESPERA_S=0 run_update --to v1.1.0 --force
|
||||
check "deadlock que não passa aplica 3 vezes e desiste" test "$(grep -c -- '-f /b.sql' "$DOCKER_LOG")" -eq 3
|
||||
check " NÃO vira ✓ banco atualizado" test -z "$(grep '✓ banco atualizado' "$OUTFILE" || true)"
|
||||
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"
|
||||
|
||||
# ── Clone RASO: a topologia que o install.sh realmente entrega ───────────────
|
||||
# `install.sh` instala com `git clone --depth 1`. Num repositório raso o
|
||||
# `merge-base --is-ancestor` responde "não é ancestral" para QUALQUER coisa
|
||||
|
||||
@@ -19,27 +19,32 @@ import { describe, expect, it } from "vitest";
|
||||
* baseline até o rótulo da 0259: os três existiam naquele ponto.
|
||||
*
|
||||
* A regra: o índice que o arquivo derruba e não recria depois não pode ter um
|
||||
* `create … index … X` antes do drop — a não ser dentro de um bloco `do`
|
||||
* (condicional por construção neste arquivo), ou na lista de dívida abaixo, que
|
||||
* só encolhe. Drop seguido de nova criação é redefinição e fica fora.
|
||||
* `create … index … X` antes do drop — a não ser dentro de um bloco `do` que
|
||||
* decide por uma condição de verdade. Drop seguido de nova criação é redefinição
|
||||
* e fica fora.
|
||||
*
|
||||
* ## Vale também para constraint que constrói índice (UNIQUE, PRIMARY KEY, EXCLUDE)
|
||||
*
|
||||
* Até 2026-09-16 a régua só lia `create index`. Das três criações únicas que a
|
||||
* 0181 e a 0205 derrubam, duas estavam congeladas aqui como dívida e a terceira —
|
||||
* a constraint `ai_kbv_version_unique` — nem aparecia. E o custo deixou de ser
|
||||
* 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
|
||||
* `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`.
|
||||
*
|
||||
* O `DO $baseline_guard$` do dump NÃO conta como condicional: ele só pergunta se
|
||||
* a própria constraint já existe — é o `IF NOT EXISTS` de quem não tem
|
||||
* `IF NOT EXISTS`, o mesmo que o `create unique index if not exists` que esta
|
||||
* regra já reprova.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
const SQL = readFileSync(join(process.cwd(), "supabase/baseline.sql"), "utf8");
|
||||
|
||||
/**
|
||||
* Pares que já existiam antes deste teste, de outra migration, fora do escopo do
|
||||
* conserto que o criou. SÓ ENCOLHE: consertar um é tirá-lo daqui.
|
||||
*/
|
||||
const DIVIDA_ANTERIOR = [
|
||||
// 0181 (o acervo é da organização): índices ÚNICOS do dump que o apêndice
|
||||
// derruba. Além do custo, a recriação no `update.sh` de um clone com dados
|
||||
// posteriores à 0181 pode falhar por duplicata — em silêncio, sem ON_ERROR_STOP.
|
||||
"ai_kbv_one_active_per_agent",
|
||||
"ai_knowledge_sources_unique_per_agent",
|
||||
];
|
||||
|
||||
interface Par {
|
||||
nome: string;
|
||||
linhaDaCriacao: number;
|
||||
@@ -47,81 +52,145 @@ interface Par {
|
||||
condicional: boolean;
|
||||
}
|
||||
|
||||
function linhaDe(pos: number): number {
|
||||
return SQL.slice(0, pos).split("\n").length;
|
||||
function linhaDe(sql: string, pos: number): number {
|
||||
return sql.slice(0, pos).split("\n").length;
|
||||
}
|
||||
|
||||
/** A posição está dentro de um `do $x$ … end $x$;` aberto e ainda não fechado? */
|
||||
function dentroDeBlocoDo(pos: number): boolean {
|
||||
const antes = SQL.slice(0, pos);
|
||||
/**
|
||||
* A posição está dentro de um `do $x$ … end $x$;` aberto e ainda não fechado,
|
||||
* que não seja a guarda de existência do dump?
|
||||
*/
|
||||
function dentroDeBlocoCondicional(sql: string, pos: number): boolean {
|
||||
const antes = sql.slice(0, pos);
|
||||
const aberturas = [...antes.matchAll(/^\s*do\s+\$([a-z_]*)\$/gim)];
|
||||
const ultima = aberturas.at(-1);
|
||||
if (!ultima || ultima.index === undefined) return false;
|
||||
if (ultima[1]!.toLowerCase() === "baseline_guard") return false;
|
||||
const marca = `$${ultima[1]}$`;
|
||||
const trecho = antes.slice(ultima.index + ultima[0].length);
|
||||
return !trecho.toLowerCase().includes(`end ${marca.toLowerCase()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pares cria→derruba cujo índice NÃO sobrevive ao arquivo. `drop` seguido de
|
||||
* nova criação do mesmo nome é REDEFINIÇÃO (trocar predicado de índice parcial
|
||||
* só se faz assim) e o índice termina de pé — fica fora, é outra regra.
|
||||
* 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.
|
||||
*/
|
||||
function paresCriaDerruba(): Par[] {
|
||||
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 index (?:concurrently )?if exists (?:"?public"?\.)?"?([a-z0-9_]+)"?/gi)) {
|
||||
for (const d of sql.matchAll(drop)) {
|
||||
drops.set(d[1]!.toLowerCase(), d.index!); // o ÚLTIMO drop de cada nome
|
||||
}
|
||||
const pares: Par[] = [];
|
||||
const achados: Par[] = [];
|
||||
for (const [nome, posDrop] of drops) {
|
||||
const criacoes = [
|
||||
...SQL.matchAll(
|
||||
new RegExp(`create (?:unique )?index (?:concurrently )?(?:if not exists )?"?${nome}"?(?=\\s|$)`, "gi"),
|
||||
),
|
||||
].map((c) => c.index!);
|
||||
if (criacoes.some((pos) => pos > posDrop)) continue; // redefinido: termina de pé
|
||||
for (const pos of criacoes) {
|
||||
pares.push({
|
||||
if ([...sql.matchAll(recriacao(nome))].some((c) => c.index! > posDrop)) continue;
|
||||
for (const c of sql.matchAll(criacao(nome))) {
|
||||
achados.push({
|
||||
nome,
|
||||
linhaDaCriacao: linhaDe(pos),
|
||||
linhaDoDrop: linhaDe(posDrop),
|
||||
condicional: dentroDeBlocoDo(pos),
|
||||
linhaDaCriacao: linhaDe(sql, c.index!),
|
||||
linhaDoDrop: linhaDe(sql, posDrop),
|
||||
condicional: dentroDeBlocoCondicional(sql, c.index!),
|
||||
});
|
||||
}
|
||||
}
|
||||
return pares;
|
||||
return achados;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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"),
|
||||
);
|
||||
}
|
||||
|
||||
function proibidos(achados: Par[]): string[] {
|
||||
return achados
|
||||
.filter((p) => !p.condicional)
|
||||
.map((p) => `${p.nome}: criado na linha ${p.linhaDaCriacao}, derrubado na ${p.linhaDoDrop}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* As três formas que o arquivo tinha até 2026-09-16, e as duas 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.
|
||||
*/
|
||||
const SINTETICO = `
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "velho_idx" ON "public"."t" USING "btree" ("a") WHERE "ativo";
|
||||
|
||||
DO $baseline_guard$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'velha_uk' AND conrelid = '"public"."t"'::regclass)
|
||||
AND to_regclass('"public"."velha_uk"') IS NULL THEN
|
||||
ALTER TABLE ONLY "public"."t"
|
||||
ADD CONSTRAINT "velha_uk" UNIQUE ("a", "b");
|
||||
END IF; END $baseline_guard$;
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (select 1 from pg_constraint where conname = 'outra_uk') then
|
||||
create unique index if not exists cond_idx on public.t (a);
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- ---- apêndice (migration 9999) ----
|
||||
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 if exists redefinida_uk;
|
||||
alter table public.t add constraint redefinida_uk unique (b);
|
||||
`;
|
||||
|
||||
describe("baseline.sql não constrói índice que ele mesmo derruba", () => {
|
||||
it("o instrumento está vivo: acha drops, e acha a dívida conhecida", () => {
|
||||
// Controle positivo. Um regex que parasse de casar devolveria lista vazia, e
|
||||
// "nenhum par proibido" ficaria verde vigiando nada.
|
||||
const pares = paresCriaDerruba();
|
||||
expect(pares.length, "nenhum par cria→derruba encontrado — o parser mudou?").toBeGreaterThan(0);
|
||||
for (const nome of DIVIDA_ANTERIOR) {
|
||||
expect(
|
||||
pares.some((p) => p.nome === nome),
|
||||
`"${nome}" saiu da dívida: tire-o de DIVIDA_ANTERIOR`,
|
||||
).toBe(true);
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
it("nenhuma criação incondicional antes do drop, fora da dívida anterior", () => {
|
||||
const proibidos = paresCriaDerruba()
|
||||
.filter((p) => !p.condicional && !DIVIDA_ANTERIOR.includes(p.nome))
|
||||
.map((p) => `${p.nome}: criado na linha ${p.linhaDaCriacao}, derrubado na ${p.linhaDoDrop}`);
|
||||
it("o instrumento está vivo no arquivo real: acha drops", () => {
|
||||
expect(paresDeIndice(SQL).length, "nenhum par cria→derruba encontrado — o parser mudou?").toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("nenhuma criação incondicional de índice antes do drop", () => {
|
||||
expect(
|
||||
proibidos,
|
||||
proibidos(paresDeIndice(SQL)),
|
||||
"Índice construído e jogado fora a cada install/update. Tire a criação (ou a torne " +
|
||||
"condicional ao mesmo predicado do drop, invertido).\n",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("nenhuma constraint que constrói índice é criada antes do próprio drop", () => {
|
||||
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",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("a criação condicional de ai_models_provider_model_unique depende da AUSÊNCIA da constraint", () => {
|
||||
// O bloco `do` sozinho não prova nada: um `if true then create …` passaria no
|
||||
// caso acima. O que torna a criação inofensiva é o predicado ser o inverso do
|
||||
// guard do drop da 0259 — os dois nunca agem sobre o mesmo banco.
|
||||
const par = paresCriaDerruba().find((p) => p.nome === "ai_models_provider_model_unique");
|
||||
const par = paresDeIndice(SQL).find((p) => p.nome === "ai_models_provider_model_unique");
|
||||
expect(par, "a criação da 0127 sumiu — confira se a unicidade ainda tem fallback").toBeDefined();
|
||||
const linhas = SQL.split("\n");
|
||||
const janela = linhas.slice(Math.max(0, par!.linhaDaCriacao - 8), par!.linhaDaCriacao).join("\n");
|
||||
|
||||
Reference in New Issue
Block a user