Skip to content

Commit 5ec7b15

Browse files
melgarafaelclaude
andcommitted
fix(schema): a 0224 sobrescrevia o conserto da 0223, e agora um gate mede isso
A migration 0224 redefine `fn_service_event_origin` — assinatura idêntica, logo `create or replace` puro — com o corpo ANTERIOR ao conserto da 0223. E `20260906120000 > 20260906030000`: na CADEIA de migrations o conserto sumia. No `baseline.sql` não sumia, porque lá o bloco foi editado à mão. Os dois artefatos divergiram, e nenhum gate via: `pnpm test:db` aplica só o baseline. Quem aplica a cadeia (Supabase CLI, `db reset`, clone que migra em vez de re-aplicar o baseline) ficava com o defeito. Reproduzido em Postgres: aplicar o bloco da 0224 sobre o baseline reintroduz `service_stale` no segundo evento. O conserto entrou DENTRO da 0224, que é a versão funcionalmente mais nova (ela acrescenta o ramo de `appointment.outcome_confirmed`) — não numa migration nova, porque a 0224 é deste mesmo PR e nunca foi aplicada em lugar nenhum. E como o custo aqui não é o defeito e sim a INVISIBILIDADE dele, entra o gate: `apendice-do-baseline-nao-diverge-da-cadeia.test.ts` compara, para toda função escrita à mão no apêndice, o corpo da última definição da cadeia com o do baseline. Ele mede SEMÂNTICA, não prosa — três normalizações, cada uma exigida por um falso vermelho que ele mesmo produziu antes de eu confiar nele: · comentários fora (11 funções antigas diferiam só em comentário reescrito); · marca do delimitador uniformizada (`$fn$` do baseline contra `$$` da migration — o corpo é o mesmo, e um parser que procura `$$;` fixo lê ALÉM do fim da função); · espaço colado ou não em `(`, `)`, `,` (o `pg_dump` e a mão humana discordam). Com a régua certa: 88 funções tocadas por este PR, 129 comuns aos dois artefatos, e ZERO divergências — sem allowlist nenhuma, que é o que separa um gate de uma lista de desculpas. Terceira peça: o resumo do dreno passa a carregar `pulados`. O `detail` de um `skipped` já sobrevivia na linha do `event_log`, e isso basta para quem tem psql — não basta para o CI, onde o único artefato que sobra do job é o trace, e o trace guarda o CORPO da resposta. Um e2e que morre porque o gatilho pulou não conseguia dizer QUAL pulo foi: `failed=0` e nenhuma pista, que foi o que travou o diagnóstico de `gatilho-de-etapa.spec.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 1954da1 commit 5ec7b15

3 files changed

Lines changed: 193 additions & 10 deletions

File tree

lib/event-log/drain.ts

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,7 @@
66
* handler no registry e ficam intocados.
77
*/
88
import type { SupabaseClient } from "@supabase/supabase-js";
9-
import {
10-
dispatchEvent,
11-
getRegisteredHandlers,
12-
type EventRow,
13-
} from "@/lib/event-log/dispatcher";
9+
import { dispatchEvent, getRegisteredHandlers, type EventRow } from "@/lib/event-log/dispatcher";
1410
import { logger } from "@/lib/logger";
1511

1612
const MAX_ATTEMPTS = 5;
@@ -19,6 +15,13 @@ const MAX_ATTEMPTS = 5;
1915
const PROCESSING_STALE_MS = 10 * 60 * 1000;
2016

2117
export interface DrainSummary {
18+
/**
19+
* Os `skipped` COM motivo, para o resumo poder ser lido de fora do banco.
20+
*
21+
* Opcional de propósito: quem monta um `DrainSummary` literal (por exemplo
22+
* `tests/unit/event-log-drain-loop.test.ts`) não precisa mudar.
23+
*/
24+
pulados?: string[];
2225
scanned: number;
2326
done: number;
2427
retried: number;
@@ -37,7 +40,14 @@ export async function drainEventLog(
3740
opts: { limit?: number } = {},
3841
): Promise<DrainSummary> {
3942
const limit = opts.limit ?? 50;
40-
const summary: DrainSummary = { scanned: 0, done: 0, retried: 0, failed: 0, dead: 0 };
43+
const summary: DrainSummary = {
44+
scanned: 0,
45+
done: 0,
46+
retried: 0,
47+
failed: 0,
48+
dead: 0,
49+
pulados: [],
50+
};
4151

4252
const handledTypes = [...new Set(getRegisteredHandlers().flatMap((h) => h.events))];
4353
if (!handledTypes.length) return summary;
@@ -95,11 +105,10 @@ export async function drainEventLog(
95105

96106
if (error) {
97107
logger.error("[event-log.drain] select failed", { error: error.message });
98-
108+
99109
return summary;
100110
}
101111

102-
103112
for (const raw of rows ?? []) {
104113
const row = raw as unknown as EventRow;
105114
summary.scanned += 1;
@@ -115,7 +124,9 @@ export async function drainEventLog(
115124

116125
const results = await dispatchEvent(row);
117126

118-
const okKeys = results.filter((r) => r.status === "ok" || r.status === "skipped").map((r) => r.consumer_key);
127+
const okKeys = results
128+
.filter((r) => r.status === "ok" || r.status === "skipped")
129+
.map((r) => r.consumer_key);
119130
const consumedBy = [...new Set([...row.consumed_by, ...okKeys])];
120131
const retry = results.find((r) => r.status === "retry");
121132
const errors = results.filter((r) => r.status === "error");
@@ -136,7 +147,11 @@ export async function drainEventLog(
136147
next_attempt_at: retryAt,
137148
updated_at: new Date().toISOString(),
138149
...(errors.length
139-
? { last_error: errors.map((e) => `${e.consumer_key}: ${e.detail ?? "error"}`).join("; ") }
150+
? {
151+
last_error: errors
152+
.map((e) => `${e.consumer_key}: ${e.detail ?? "error"}`)
153+
.join("; "),
154+
}
140155
: {}),
141156
})
142157
.eq("id", row.id);
@@ -167,6 +182,17 @@ export async function drainEventLog(
167182
//
168183
// Não muda o desfecho do evento; só deixa de jogar fora a resposta.
169184
const pulados = results.filter((r) => r.status === "skipped" && r.detail);
185+
// E o motivo sai também no RESUMO, não só na linha.
186+
//
187+
// A linha basta para quem tem psql; não basta para o CI, onde o único
188+
// artefato que sobrevive ao job é o trace — e o trace guarda o CORPO da
189+
// resposta HTTP. Um e2e que morre porque o gatilho pulou ficava sem poder
190+
// dizer QUAL pulo foi: travou por horas o diagnóstico de
191+
// `gatilho-de-etapa.spec.ts`, com `failed=0` e nenhuma pista.
192+
if (pulados.length)
193+
summary.pulados?.push(
194+
...pulados.map((r) => `${row.event_type}/${r.consumer_key}: ${r.detail}`),
195+
);
170196
await admin
171197
.from("event_log")
172198
.update({

supabase/migrations/20260906120000_0224_presenca_e_recuperacao.sql

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,30 @@ begin
530530
select service_boundary into current_boundary from public.event_service_origins where organization_id=p_org and event_id=root_event and channel_session_id=sid;
531531
if found then boundary:=current_boundary;
532532
elsif boundary is null then
533+
-- PARA UM EVENTO, `absent` E PROCEDENCIA — NAO REIVINDICACAO DE ESTADO.
534+
--
535+
-- O CAS de `fn_service_begin` existe para que dois ATORES com a mesma
536+
-- observacao "ausente" nao ajam os dois: o segundo tem de perder, e o
537+
-- invariante de `fn_service_begin` guarda isso. Um evento e outra coisa: o
538+
-- retrato `absent` diz "quando este evento foi EMITIDO nao havia
539+
-- atendimento", e a resolucao de cada evento ja e idempotente pelo memo
540+
-- `event_service_origins` logo acima — nao ha corrida a arbitrar aqui.
541+
--
542+
-- Sem esta distincao o caminho ORDINARIO morria: um lead criado e depois
543+
-- movido de etapa gera DOIS eventos, cada um com seu retrato `absent`;
544+
-- resolver o primeiro cria a conversa e o segundo levantava 40001 — que
545+
-- `serviceForEvent` engole como `stale_origin`, entao o follow-up de etapa
546+
-- simplesmente nao nascia, sem erro em lugar nenhum.
547+
--
548+
-- Zerar `observed` so quando a conversa JA existe mantem o CAS de pe para o
549+
-- retrato que descreve uma fronteira concreta (esse continua sendo conferido
550+
-- contra a vigente) e para todo chamador direto de `fn_service_begin`.
551+
if observed->>'absent' = 'true' and exists(
552+
select 1 from public.conversations
553+
where organization_id=p_org and contact_id=p_contact
554+
and channel_session_id=sid and not is_group) then
555+
observed:=null;
556+
end if;
533557
boundary:=public.fn_service_begin(p_org,p_contact,sid,observed) - 'status' - 'demanda_fechada_em' - 'service_started_at';
534558
end if;
535559
if boundary->>'organization_id' is distinct from p_org::text or boundary->>'contact_id' is distinct from p_contact::text then
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { readFileSync, readdirSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { describe, expect, it } from "vitest";
4+
5+
/**
6+
* O APÊNDICE DO BASELINE É ESPELHO DA CADEIA — E ESPELHO SE DERIVA, NÃO SE COPIA.
7+
*
8+
* Toda mudança de schema sai em DOIS artefatos: o arquivo em
9+
* `supabase/migrations/` (que o Supabase CLI aplica em ordem) e o apêndice
10+
* idempotente do `supabase/baseline.sql` (que o `install.sh`/`update.sh` do
11+
* self-host aplica). Quando os dois divergem, cada público recebe um produto
12+
* diferente — e o gate não vê, porque `pnpm test:db` aplica só o baseline.
13+
*
14+
* Foi o que aconteceu e o que este arquivo existe para impedir: a migration
15+
* 0224 redefiniu `fn_service_event_origin` com o corpo ANTERIOR ao conserto da
16+
* 0223. Assinatura idêntica, então `create or replace` sobrescreve, e
17+
* `20260906120000 > 20260906030000` — na cadeia o conserto sumia. No baseline
18+
* não, porque lá o bloco foi editado à mão. Verde em todo lugar, e quem aplica
19+
* a cadeia ficava com o defeito.
20+
*
21+
* A regra que este teste cobra: para toda função cuja ÚLTIMA definição no
22+
* baseline está no APÊNDICE (isto é, foi escrita à mão, e não despejada pelo
23+
* `pg_dump`), o corpo tem de ser igual ao da ÚLTIMA definição na cadeia de
24+
* migrations. Funções que só existem no corpo do dump ficam de fora de
25+
* propósito: ali o `pg_dump` reformata, e comparar texto acusaria diferença que
26+
* não é divergência — o modo de falha de medir a FORMA em vez do conteúdo.
27+
*/
28+
const RAIZ = join(process.cwd(), "supabase");
29+
const BASELINE = readFileSync(join(RAIZ, "baseline.sql"), "utf8");
30+
31+
/** Primeira marca de apêndice: daí para baixo, tudo é escrito à mão. */
32+
const INICIO_DO_APENDICE = BASELINE.search(/^-- ---- .* \(migration \d+\) ----/m);
33+
34+
/**
35+
* O que se compara é CÓDIGO, não prosa.
36+
*
37+
* Comentário reescrito de um lado só é divergência de TEXTO, não de
38+
* comportamento — medido: 11 funções antigas diferem exatamente assim (uma
39+
* assinatura quebrada em linhas, um comentário reformulado). Um gate que as
40+
* acusasse nasceria vermelho por ruído, e gate que tolera ruído é gate que
41+
* ninguém lê. Sem os comentários, o que sobra é o que o Postgres executa — e é
42+
* ali que a divergência custa caro.
43+
*
44+
* A varredura de `--` não distingue comentário de hífen duplo dentro de string
45+
* literal. Nenhuma função destes artefatos tem uma, e o custo de errar seria um
46+
* falso VERMELHO (nunca um falso verde), que é o lado certo para errar.
47+
*/
48+
function semComentarios(sql: string): string {
49+
return (
50+
sql
51+
.split("\n")
52+
.map((l) => l.replace(/--.*$/, ""))
53+
.join(" ")
54+
// A MARCA DO DELIMITADOR é escolha de quem escreveu, não semântica: o
55+
// baseline usa `$fn$` onde a migration usa `$$`, e o Postgres executa o
56+
// mesmo corpo. Uniformizar aqui evita um vermelho que não fala de nada.
57+
.replace(/\$[a-z_]*\$/gi, "$$$$")
58+
.replace(/\s+/g, " ")
59+
// Espaço colado ou não em `(`, `)` e `,` também é estilo — o `pg_dump` e a
60+
// mão humana discordam nisso o tempo todo.
61+
.replace(/\s*([(),])\s*/g, "$1")
62+
.trim()
63+
);
64+
}
65+
66+
function definicoes(texto: string): Map<string, { corpo: string; pos: number }> {
67+
const achadas = new Map<string, { corpo: string; pos: number }>();
68+
const re = /create or replace function public\.(fn_[a-z_]+)\s*\(/gi;
69+
for (let m = re.exec(texto); m !== null; m = re.exec(texto)) {
70+
// O DELIMITADOR NÃO É SEMPRE `$$`. O baseline usa `$fn$ … $fn$` em algumas
71+
// funções, e procurar `$$;` fixo faz o corpo ser lido ALÉM do fim — foi
72+
// exatamente assim que esta sonda acusou `fn_agora` e
73+
// `fn_upsert_wa_conversation` como divergentes quando o que divergia era
74+
// ela. A marca de abertura é lida do próprio texto e a de fechamento é a
75+
// igual a ela.
76+
const abertura = /\$([a-z_]*)\$/i.exec(texto.slice(m.index, m.index + 600));
77+
if (!abertura) continue;
78+
const marca = abertura[0];
79+
const fim = texto.indexOf(marca, m.index + abertura.index + marca.length);
80+
if (fim === -1) continue;
81+
// A ÚLTIMA vence, que é a que o Postgres deixa de pé.
82+
achadas.set(m[1]!.toLowerCase(), {
83+
corpo: semComentarios(texto.slice(m.index, fim + marca.length)),
84+
pos: m.index,
85+
});
86+
}
87+
return achadas;
88+
}
89+
90+
const cadeia = (() => {
91+
const acc = new Map<string, { corpo: string; pos: number }>();
92+
for (const arquivo of readdirSync(join(RAIZ, "migrations"))
93+
.filter((f) => f.endsWith(".sql"))
94+
.sort())
95+
for (const [nome, def] of definicoes(readFileSync(join(RAIZ, "migrations", arquivo), "utf8")))
96+
acc.set(nome, def);
97+
return acc;
98+
})();
99+
const noBaseline = definicoes(BASELINE);
100+
101+
describe("apêndice do baseline não diverge da cadeia de migrations", () => {
102+
it("a sonda está viva — o apêndice existe e tem função escrita à mão", () => {
103+
// Sem este controle, um `search` que voltasse -1 faria o conjunto medido
104+
// ficar vazio e o caso abaixo passar por vacuidade.
105+
expect(
106+
INICIO_DO_APENDICE,
107+
"nenhuma marca `-- ---- … (migration N) ----` no baseline",
108+
).toBeGreaterThan(0);
109+
const doApendice = [...noBaseline.entries()].filter(([, d]) => d.pos > INICIO_DO_APENDICE);
110+
expect(
111+
doApendice.length,
112+
"nenhuma função no apêndice — o parser mudou de forma?",
113+
).toBeGreaterThan(5);
114+
});
115+
116+
it("toda função escrita à mão no apêndice tem o mesmo corpo da última definição na cadeia", () => {
117+
const divergentes: string[] = [];
118+
for (const [nome, def] of noBaseline) {
119+
if (def.pos <= INICIO_DO_APENDICE) continue; // veio do pg_dump: reformatado, não comparável
120+
const naCadeia = cadeia.get(nome);
121+
if (!naCadeia) continue; // só no baseline: fora do escopo desta regra
122+
if (naCadeia.corpo !== def.corpo) divergentes.push(nome);
123+
}
124+
expect(
125+
divergentes,
126+
"A última definição na CADEIA difere da do APÊNDICE. Quem aplica as migrations recebe " +
127+
"um corpo, quem aplica o baseline recebe outro — e nenhum gate vê, porque `test:db` só " +
128+
"aplica o baseline. Causa típica: uma migration POSTERIOR redefiniu a função com o corpo " +
129+
"de antes de um conserto (assinatura igual ⇒ `create or replace` sobrescreve). Derive o " +
130+
"apêndice da migration em vez de reescrevê-lo.\n",
131+
).toEqual([]);
132+
});
133+
});

0 commit comments

Comments
 (0)