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:
Pessoa
2026-09-16 12:41:19 -03:00
co-authored by Claude Opus 5
parent cf35c94448
commit f0ccfe8d05
12 changed files with 638 additions and 95 deletions
@@ -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.
+53
View File
@@ -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á
+6 -9
View File
@@ -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" \
+50
View File
@@ -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.
+12 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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",
-18
View File
@@ -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'
+36
View File
@@ -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");