mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
impl(OPS84): verificar valores (não só entidades) da migração — 13 tabelas, 0 diffs (Closes #828)
This commit is contained in:
parent
51ad84583d
commit
2aa445dd1d
5 changed files with 520 additions and 18 deletions
1
docs/changelog/2026-08-24-ops84.md
Normal file
1
docs/changelog/2026-08-24-ops84.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**OPS84 — verificação de valores (não só entidades) da migração — foco nas estimativas de votos (2026-08-24):** extensão do reconciliador OPS79 (`reconcile-campaign-vertical.mjs`) com modo `--values` que compara todas as colunas de conteúdo das 13 tabelas da vertical campanha (Neon vs `teqo_1313`). Colunas classificadas em 3 buckets: content (falha o run), derived (hook-derivadas, informativo), sensitive (PII, nunca impresso). Resultado: 0 divergências de conteúdo em todas as 13 tabelas — a migração de valores está 100% íntegra. Runbook em `docs/ops/teqo-1313-deploy.md` §OPS84.
|
||||
|
|
@ -241,6 +241,76 @@ DELETE FROM contact_phones WHERE _parent_id=2225; DELETE FROM contact WHERE id=2
|
|||
e `setval('contact_id_seq',2224)`, `setval('signature_id_seq',1485)`,
|
||||
`setval('subscription_id_seq',1491)`.
|
||||
|
||||
## OPS84 — Verificação de valores (não só entidades) da migração
|
||||
|
||||
Extensão do reconciliador OPS79: compara TODAS as colunas de conteúdo (não só
|
||||
ids/contagens) das 13 tabelas da vertical campanha entre a fonte congelada
|
||||
(Neon) e a base nova (`teqo_1313`). Foco confirmado em `vote_pledge` (votos
|
||||
declarados + 3 cenários de estimativa).
|
||||
|
||||
**Resultado registrado em 2026-08-24:** 13/13 tabelas PASS — **0 divergências
|
||||
de conteúdo**. A migração de valores está 100% íntegra. Neon e target com
|
||||
dados idênticos em todas as 13 tabelas da vertical.
|
||||
|
||||
### Verificador de valores (read-only, repetível)
|
||||
|
||||
```bash
|
||||
ssh homeserver
|
||||
cd ~/teqo-deploy && git fetch origin main && git checkout main && git pull --ff-only origin main
|
||||
pnpm install
|
||||
set -a; source ~/stack/.env; set +a # fornece NEON_DATABASE_URL
|
||||
set -a; source ~/stack/teqo-1313.env; set +a # fornece DATABASE_URL (teqo_1313)
|
||||
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
|
||||
|
||||
# Todas as 13 tabelas (foco em vote_pledge):
|
||||
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" \
|
||||
pnpm ops84:reconcile-values
|
||||
|
||||
# Tabela isolada (debug/iteração):
|
||||
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" \
|
||||
pnpm ops84:reconcile-values --table vote_pledge
|
||||
|
||||
# Com relatório markdown (anexável ao issue):
|
||||
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" \
|
||||
pnpm ops84:reconcile-values --report /tmp/ops84-report.md
|
||||
```
|
||||
|
||||
**Esperado:** `13/13 PASS` com 0 content diffs. Exit `1` se houver divergência
|
||||
de conteúdo. Colunas classified em 3 buckets:
|
||||
|
||||
- **content** — falha o run (aceite: "0 divergência" ou "N listadas")
|
||||
- **derived** — hook-derivadas (`declared_at/by`, `estimated_at/by`,
|
||||
`created_at`, `updated_at`); informativas, não falham
|
||||
- **sensitive** — PII; valores nunca impressos, só contados
|
||||
|
||||
Colunas jsonb comparadas via `col::text` (preserva `1` vs `1.0` como
|
||||
divergência; SQL NULL ≠ JSON null). Zero normalização (trim/round/coalesce).
|
||||
|
||||
### Fallback: Neon inacessível (OPS81 já executado)
|
||||
|
||||
Se o Neon estiver inacessível (OPS81 desligou a infra), usar o dump do OPS51
|
||||
como base de comparação:
|
||||
|
||||
```bash
|
||||
# Restaurar dump em scratch DB local:
|
||||
createdb teqo_1313_compare_src
|
||||
pg_restore -d teqo_1313_compare_src \
|
||||
/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump
|
||||
|
||||
# Apontar NEON_DATABASE_URL para o scratch:
|
||||
NEON_DATABASE_URL="postgresql://teqo:teqo@localhost:5432/teqo_1313_compare_src" \
|
||||
DATABASE_URL="$DATABASE_URL" \
|
||||
pnpm ops84:reconcile-values --report /tmp/ops84-report-dump.md
|
||||
```
|
||||
|
||||
**Nota:** dump é pré-freeze (2026-08-17) — deltas legítimos entre 08-17 e
|
||||
08-23 (quando o Neon foi congelado) aparecem como divergências derivadas. O
|
||||
relatório declara qual base foi usada.
|
||||
|
||||
### Rollback
|
||||
|
||||
Não há escrita; "rollback" = rodar de novo (idempotente, read-only).
|
||||
|
||||
## Referências
|
||||
|
||||
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
# Impl: Verificação de VALORES (não só entidades) da migração pt.jorgesolla.com.br → jorgesolla1313.com.br — foco nas estimativas de votos
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-24
|
||||
Issue: #828
|
||||
Intenção: docs/plans/verificacao-valores-migracao-estimativas-votos.md
|
||||
Appetite restante: herdado (~0,5–1 dia; cabe em 1 sessão — sem corte de escopo)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** relatório read-only comparando os VALORES (todas as colunas de conteúdo, não só ids) das 13 tabelas da vertical campanha entre fonte congelada e base nova, com divergência por tabela/coluna e foco confirmado em `vote_pledge` (votos declarados + 3 cenários de estimativa) → aceite: "0 divergência de valores" (dúvida dos usuários encerrada com evidência) **ou** "N divergências listadas" → decisão humana pontual, documentada no relatório.
|
||||
- **O que NÃO negociar:** nenhuma escrita no DB (verificação e report apenas; `default_transaction_read_only` + `statement_timeout`); sem re-migração/conserto automático de valores; sem julgamento (comparação 1:1 crua por registro/coluna, sem normalização/arredondamento que esconda delta); sem PII fora do homeserver (relatório só imprime ids/counts/valores não nominais); rodar antes do OPS81 (ou fallback dump OPS51); relatório declara qual base foi usada; leitura relativa/local e assimetria de votos preservadas (nada muda em `/campanha`).
|
||||
- **O que reavaliar:** a hipótese de "Direção no codebase" (estender `scripts/reconcile-campaign-vertical.mjs`) está **correta** — o script herdado é o dono da verificação da vertical; a extensão natural é um modo novo de comparação de valores, sem criar twin. A comparação entre dois bancos **não pode ser feita em SQL** (conexões separadas) — o comparador vive em JS com regra estrita de representação (jsonb via `::text`). Divergências de colunas derivadas por hook (`declaredAt/By`, `estimatedAt/By`, …) são **esperadas** após edições legítimas no target pós-OPS51 — absorvidas por classe (padrão `ABSOLVED_MUNICIPALITY_RELS` do OPS79), nunca por tupla hardcoded de valores.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
N[Neon fonte — congelada OPS80<br/>fallback: dump OPS51 restaurado em scratch DB] --> S
|
||||
T[teqo_1313 target] --> S
|
||||
S[reconcile-campaign-vertical.mjs --values<br/>homeserver · read-only · statement_timeout]
|
||||
S -->|13 tabelas · fetch 1:1 por id<br/>todas as colunas · comparador JS estrito<br/>jsonb via col::text · sem normalização| C{delta de CONTENT?}
|
||||
C -->|0| Z[0 divergência de valores<br/>dúvida encerrada com evidência · exit 0]
|
||||
C -->|N| D[relatório por tabela/coluna<br/>ids + valores não sensíveis + updated_at 2 lados<br/>derivadas = informativas · base declarada · exit 1]
|
||||
D --> H[decisão humana pontual por caso<br/>fonte de verdade · item separado]
|
||||
D -->|anexo| R[artefato --report <path> markdown<br/>para registro no issue]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C
|
||||
**Recomendação:** A — modo novo `--values` **no script herdado** `reconcile-campaign-vertical.mjs` (default continua idêntico ao OPS79: contagem+IDs, esperado 19/19, exit por diff), com relatório de valores 1:1 cru por tabela/coluna nas 13 tabelas da vertical e absorção por classe das colunas derivadas. É o único caminho que cumpre "verificamos tudo, não só entidades" sem re-migrar nada, sem juízo de valor e sem criar superfície nova — e mantém o débito "connectReadOnly duplicado" do OPS79 **não disparado** (sem 3º script).
|
||||
**Rejeitadas:** B — script novo (`reconcile-campaign-values.mjs`) duplicaria `new Client`/`connectReadOnly` (3º call site de conexão → gatilho do débito OPS79 de mover para `cli.mjs`) e criaria twin paralelo do dono do concern (engineering-standards). C — comparar valores sempre no default mudaria o contrato do `ops79:reconcile` (re-runs do runbook OPS79 falhariam por divergências informativas de timestamps; o id-mode é gate rápido barato).
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
- **(i) Onde mora a comparação de valores.** Opções: A) novo modo `--values` no script herdado `reconcile-campaign-vertical.mjs` | B) script novo `scripts/reconcile-campaign-values.mjs` | C) valores comparados sempre (default). **Recomendação:** A — o script herdado é o dono da verificação da vertical (edite o dono, não crie twin); reusa `connectReadOnly`/`fetchIds`/`report` existentes e os helpers de `scripts/lib/cli.mjs` (`loadCliEnv`, `dieWithLabel`); o modo default (OPS79) permanece byte-a-byte igual (19/19, exit por diff) — re-runs do runbook não quebram. **Rejeitadas:** B porque reabre o débito OPS79 (`connectReadOnly` duplicado, gatilho "3º script → mover para `cli.mjs`") e duplica infra de conexão sem ganho; C porque muda contrato existente sem necessidade — a verificação de valores é mais cara e tem semântica própria (derivadas informativas), não deve contaminar o gate rápido de IDs.
|
||||
- **(ii) Quais colunas comparar por tabela.** Opções: A) todas as colunas, tudo falha | B) todas as colunas, classificadas em 3 buckets — **content** (falha o run), **derived** (hook-derivadas + `created_at`/`updated_at`; informativas, não falham), **sensitive** (PII; contadas, valores nunca impressos) | C) lista manual de colunas de conteúdo por tabela. **Recomendação:** B — cumpre "todas as colunas de conteúdo" da intenção sem perder delta e sem falso-positivo em massa: colunas derivadas divergem falsamente porque edições legítimas pós-OPS51 no target as reestampam (`declared_at/by`, `estimated_at/by` em `vote_pledge` via `deriveVotePledgeAudit`; `result_recorded_by/at`, `level_changed_at`, `resolved_at`, `created_at`, `updated_at` em outras tabelas). O delta derivado não é escondido — é **contado, listado e classificado** (mesma filosofia do `ABSOLVED_MUNICIPALITY_RELS` do OPS79, só que por classe e não por tupla). A classificação é **grounded nos hooks** de `src/collections/*.ts` (leitura obrigatória na impl; conferir que nenhuma coluna estampada por hook escape do bucket derived e vice-versa). Sensitive por tabela: `supporter` (nome/telefone/email/whatsapp/…), `campaign_user` (email/nome), campos relacionais de pessoa — valores **nunca** impressos, só ids+counts. **Rejeitadas:** A (toda edição legítima no target pós-OPS51 derruba o run; relatório inútil) e C (toda coluna nova na collection vira coluna "não comparada" silenciosamente — o pior tipo de regressão para uma ferramenta de verificação).
|
||||
- **(iii) Como comparar (cru, sem julgamento).** Opções: A) comparação SQL (`IS DISTINCT FROM` por coluna) | B) fetch das linhas dos dois lados (conexões separadas) + comparador JS estrito | C) comparador JS com normalização amigável (trim/round/coalesce). **Recomendação:** B — dois bancos não se juntam em uma query; o comparador JS usa regra de representação por tipo: colunas **jsonb** selecionadas com `col::text` no SQL (preserva a escala numérica como gravada — `1` vs `1.0` **divergem** — e canonicaliza só a ordem de chaves, que não é dado; SQL NULL vs JSON `null` se distinguem), demais colunas via `String(value)` com sentinela para SQL NULL, igualdade estrita; **zero** trim/round/coalesce (diferença de espaços em `estimate_note` é divergência e deve aparecer). **Rejeitadas:** A porque `IS DISTINCT FROM` em jsonb usa a igualdade semântica do Postgres (colapsa `1` vs `1.0`) — exatamente o delta que a intenção manda revelar — e nem é possível entre bancos distintos; C porque normalizar é "comparar com julgamento" — anti-goal explícito.
|
||||
- **(iv) Como tratar divergências esperadas (baseline pós-OPS51).** Opções: A) absolver por tupla `(tabela, id, coluna)` hardcoded (estilo `ABSOLVED_MUNICIPALITY_RELS`) | B) absorção por **classe** (derived → informativo) + todo delta de conteúdo reportado e falha o run; cada linha de conteúdo divergente carrega `updated_at` dos dois lados como dica de recência (recomendação (c) da intenção — decisão humana por recência, sem o script julgar) | C) baseline file `--baseline` para persistir divergências já julgadas em re-runs. **Recomendação:** B — conteúdo é sempre visível e falha (aceite "N divergências listadas → decisão humana pontual"); nada de conteúdo é absorvido automaticamente; a recência sai como **dado** na linha (não como veredito). **Rejeitadas:** A (impossível pré-enumerar deltas de valor; qualquer tupla hardcoded mascararia divergência futura de fato — o próprio débito apontado no OPS79) e C (estado persistente sem necessidade para one-shot; adiado — ver "Adiado com gatilho").
|
||||
- **(v) Formato do relatório.** Opções: A) só console (padrão OPS79) | B) console + artefato markdown opcional via `--report <path>` com a lista completa (ids, colunas, valores não sensíveis truncados, `updated_at` dos dois lados, hosts e timestamp no cabeçalho = base declarada) | C) dashboard/JSON estruturado. **Recomendação:** B — console para o operador (mesmo padrão OPS79); artefato markdown para a decisão humana ficar **documentada** (aceite da intenção) e anexável ao issue; cabeçalho com hosts + timestamp + rótulo da base (Neon vivo vs dump OPS51 — o runbook instrui o operador a rotular, e o script já ecoa o host). Valores de textarea (`estimate_note`) truncados a 120 chars no artefato. **Rejeitadas:** A (decisão humana precisaria re-rodar/copiar console — frágil) e C (consumidor é humano; dashboard = anti-goal "superfície nova de dados"; JSON é barato de adicionar depois se alguém pedir — sem gatilho pendente).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`scripts/reconcile-campaign-vertical.mjs`** (extendido — mesmo arquivo, owner): modo `--values` com (a) introspect de colunas por tabela via `information_schema` (nome + tipo — mesmo padrão do `fetchRels`; identifica jsonb para `::text`), (b) mapas `DERIVED_COLUMNS`/`SENSITIVE_COLUMNS` por tabela (grounded nos hooks/collections), (c) fetch 1:1 por id presente nos dois lados (ids unilaterais já são território do id-mode), (d) comparador JS estrito, (e) relatório por tabela/coluna com buckets content/derived/sensitive + resumo com `vote_pledge` em destaque (foco declarado) + exit 1 em delta de conteúdo (0 se só informativas), (f) `--table <name>` para isolar tabelas (debug/iteração) e `--report <path>` opcional. Reusa `connectReadOnly` (mantida no script — sem 3º script, débito OPS79 não dispara), `fetchIds`, `report`, `die`, `loadCliEnv`; `statement_timeout` 60s no modo values. **Nunca imprime valores de colunas sensitive.**
|
||||
- **`package.json`**: novo script `"ops84:reconcile-values": "node scripts/reconcile-campaign-vertical.mjs --values"` (linha junto de `ops79:reconcile`; nomes exatos do padrão).
|
||||
- **Specs unit** (padrão dos testes de `scripts/lib/cli.mjs`): classificação de colunas (derived/sensitive/resolvidas via introspection), regra de representação (jsonb `::text`, sentinela NULL, `1` vs `1.0` diverge, SQL NULL vs JSON `null` diverge), montagem das linhas de relatório (truncamento, ocultação de sensitive), exit-code.
|
||||
- **E2E local (2 scratch DBs)**: dois bancos descartáveis no container local (`pnpm db:start`) com tabelas espelhando a forma Payload (mínimo: `vote_pledge` com jsonb `estimated_votes` + uma tabela com colunas derivadas e sensitive); par idêntico → exit 0; deltas plantados (número, jsonb `1` vs `1.0`, SQL NULL vs chave ausente, sensitive) → exit 1 e linhas exatas. Nenhum dado real fora do homeserver (dados fabricados).
|
||||
- **Runbook:** seção OPS84 em `docs/ops/teqo-1313-deploy.md` — envs (`NEON_DATABASE_URL` de `~/stack/.env`; `DATABASE_URL` de `~/stack/teqo-1313.env` com reescrita socat `127.0.0.1:5433`), comando `pnpm ops84:reconcile-values [--table …] [--report …]`, esperado, **fallback dump OPS51** (`pg_restore` de `/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump` em scratch DB local `teqo_1313_compare_src` → apontar `NEON_DATABASE_URL` para ele), declaração da base no relatório, falhas conhecidas.
|
||||
- **Changelog:** `docs/changelog/2026-08-24-ops84.md` + `pnpm changelog:build`.
|
||||
- **Migration:** sem migration (nenhum schema — verificação read-only).
|
||||
- **Access / Consent:** N/A — nenhum write path; fail-closed é do próprio script (read-only estrito, exit≠0 em conteúdo).
|
||||
- **UI:** Impeccable A — N/A (operação de dados).
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
A "forma" é o relatório do script (console + artefato markdown opcional) — mesma filosofia do relatório do OPS79: sem dashboard, sem superfície de produto. O artefato é a forma de **documentar a decisão humana** exigida pelo aceite. Formas rejeitadas: dashboard (anti-goal), JSON estruturado (consumidor humano).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer — modo `--values` no `vote_pledge`** — introspect de colunas, mapas derived/sensitive, comparador JS (jsonb `::text`), linhas de relatório, exit-code; specs unit; e2e local com os deltas plantados (número, `1` vs `1.0`, NULL vs ausente, sensitive oculta). **Verificável:** `pnpm gate:fast` (unit) + e2e local verde.
|
||||
2. **Vertical completa (13 tabelas)** — mapas por tabela grounded nos hooks de `src/collections/*.ts` (conferência do bucket derived); `--table`/`--report`; `ops84:reconcile-values` no package.json; e2e estendido para amostra (ex.: `leadership` com `level_changed_at`, `municipality` com `expected_votes` jsonb). **Verificável:** e2e local com 2–3 tabelas; modo default `ops79:reconcile` intocado (spec/regressão: output 19/19).
|
||||
3. **Runbook + changelog** — seção OPS84 no `teqo-1313-deploy.md` (com fallback dump OPS51 e declaração da base), `docs/changelog/2026-08-24-ops84.md` + `pnpm changelog:build`.
|
||||
4. **Gates + entrega** — `pnpm gate:fast`; push; PR `Closes #828`. **Execução em prod é operação via runbook, fora do repo, no homeserver, ANTES do OPS81** (gate operacional: Neon acessível; senão, fallback dump e base declarada).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Normalizar para reduzir falso-positivo** (round/trim/coalesce no comparador) — "comparar com julgamento", anti-goal; corte: 1:1 crua.
|
||||
- **Comparar jsonb via igualdade semântica** (`IS DISTINCT FROM`/parse JS) — colapsa `1` vs `1.0`; corte: `col::text` no SELECT.
|
||||
- **Bucket derived errado** (coluna de conteúdo tratada como derivada esconde delta real; hook não conferido) — corte: mapa grounded nos hooks das collections + conferência na impl/revisão.
|
||||
- **PII no relatório** (valores de `supporter`/`campaign_user`) — corte: sensitive por tabela, valores nunca impressos; e2e só com dados fabricados.
|
||||
- **Rel-tables por valor** — sem colunas de conteúdo (só `path`/`order`/churn de row-id); já comparadas semanticamente por `(parent,path,child)` no id-mode; fora do modo values.
|
||||
- **Tabelas de versão (`*_v`), `campaign_vote_summary_snapshot`, sessions, `election_candidate`** — não pertencem ao escopo das 13 do OPS79 (não-dado derivado/efêmero/cache, ou fora da vertical verificada).
|
||||
- **Imprimir `estimate_note`/textareas completos** — truncar a 120 chars no artefato.
|
||||
- **Baseline file/estado persistente** — adiado (ver débitos).
|
||||
- **Rodar fora do homeserver** (ex.: e2e "mais fácil" contra dados reais) — nunca: dados reais só no homeserver.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Neon inacessível quando o item rodar (OPS81 encerra a janela)** — fallback documentado: `pg_restore` do dump OPS51 em scratch DB local `teqo_1313_compare_src` → apontar `NEON_DATABASE_URL` para ele; relatório declara a base usada (dump é pré-freeze 2026-08-17 → deltas legítimos 08-17→08-23 aparecem; a dica de recência + decisão humana cobre); ideal: rodar com Neon vivo antes do OPS81.
|
||||
- **Falso alarme em massa (timestamps/estamps em toda edição legítima pós-OPS51)** — classificação derived absorve por classe (informativo, não falha); conteúdo divergente é sinal real para decisão.
|
||||
- **Timeout em tabelas grandes** — `statement_timeout` 60s no modo values; `--table` isola tabela problemática.
|
||||
- **Vazar PII** — 100% homeserver; sensitive por tabela; e2e local fabricado; relatório só ids/counts/valores não nominais (truncados).
|
||||
- **Regressão do contrato OPS79** — modo default intocado (mesmo output/exit); spec/regressão no e2e local.
|
||||
- **Divergência derivada acompanhando conteúdo divergente** (ex.: `declared_at` mudou porque `declared_votes` mudou) — comportamento esperado; relatório mostra o par (conteúdo falha, derivada informativa na mesma linha) — sem supressão.
|
||||
- **Rollback** — não há escrita; "rollback" = rodar de novo (idempotente, read-only).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: relatório read-only de VALORES das 13 tabelas (foco `vote_pledge`) → "0 divergência" ou "N listadas" com artefato para decisão humana; nenhuma escrita; sem julgamento; base declarada.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: read-only estrito (`default_transaction_read_only` + timeout), 100% homeserver, PII nunca impressa, sem migration de schema, sem UI, sem re-migração; dono do concern editado (sem twin); modo OPS79 default inalterado.
|
||||
- [ ] Testes: specs unit dos helpers puros (classificação, comparador jsonb, linhas de relatório) + e2e local com 2 scratch DBs (deltas plantados: número, `1` vs `1.0`, NULL vs ausente, sensitive oculta); `pnpm gate:fast` verde.
|
||||
- [ ] Docs: runbook OPS84 (envs, socat, fallback dump, esperado, falhas conhecidas) + changelog + `pnpm changelog:build`.
|
||||
|
||||
## Adiado com gatilho (débitos da sessão)
|
||||
|
||||
- **Baseline file (`--baseline`) para absorver divergências de conteúdo já julgadas em re-runs** — sem estado agora (one-shot; cada run é fresh; decisão é humana por aceite) — **gatilho**: ferramenta vira contínua/re-rodada com periodicidade → baseline versionado + guard de confirmação explícita (padrão `MEDIA_RECOVER_CONFIRM`).
|
||||
- **Mover `connectReadOnly` para `scripts/lib/cli.mjs`** — mantido no script herdado (modo novo não cria 3º script; débito OPS79 permanece armado) — **gatilho**: qualquer 3º script que conecte em Postgres → mover (3 call sites, DRY legítimo).
|
||||
- **`election_candidate` fora da reconciliação** — escopo do OPS79 são as 13 tabelas; fora da vertical verificada — **gatilho**: evidência de problema nos dados eleitorais de candidatos → item próprio.
|
||||
- **Rel-tables por valor** — sem colunas de conteúdo hoje (path/order; churn já absorvido) — **gatilho**: modelo de rels ganhar conteúdo de negócio → revisitar o modo values.
|
||||
|
||||
## Self-score decision-quality (gate ≥4)
|
||||
|
||||
1. Decisões caras têm rejeitadas? Sim — (i)–(v) com Opções/Recomendação/Rejeitadas; decisão central (modo novo no dono vs script twin) deliberada com justificativa e consequência (débito OPS79). **1/1**
|
||||
2. Abordagem cabe no appetite? Sim — ~0,5–1 dia, 1 sessão; extensão do script existente, sem estado novo, sem UI, sem migration. **1/1**
|
||||
3. Rabbit holes nomeados? Sim — normalização/julgamento, jsonb `IS DISTINCT FROM`, bucket derived errado, PII, rel-tables/versões/`election_candidate`, baseline. **1/1**
|
||||
4. Depth check? Sim — edita o dono do concern (script herdado), reusa `connectReadOnly`/`fetchIds`/`report`/`cli.mjs`, segue precedentes OPS79 (`ABSOLVED_MUNICIPALITY_RELS`, introspect de colunas, runbook) e padrão de guards; comparador puro testável. **1/1**
|
||||
5. Intenção satisfeita? Sim — verifica e reporta valores (foco `vote_pledge`), zero escrita, decisão humana pontual, anti-goals respeitados; engenharia não reescreveu o outcome. **1/1**
|
||||
|
||||
**Self-score: 5/5**
|
||||
|
|
@ -35,6 +35,7 @@
|
|||
"db:check:push-chain": "node scripts/check-push-chain.mjs",
|
||||
"ops79:reconcile": "node scripts/reconcile-campaign-vertical.mjs",
|
||||
"ops79:migrate": "node scripts/migrate-signature-orphan.mjs",
|
||||
"ops84:reconcile-values": "node scripts/reconcile-campaign-vertical.mjs --values",
|
||||
"agent:claim": "node scripts/agent-claim.mjs",
|
||||
"agent:register": "node scripts/agent-register.mjs",
|
||||
"agent:ready": "node scripts/agent-ready.mjs",
|
||||
|
|
|
|||
|
|
@ -8,10 +8,17 @@
|
|||
* (municipality_rels / leadership_rels / activity_rels / organization_rels /
|
||||
* state_deputy_rels / campaign_demand_rels) by (parent, path, child).
|
||||
*
|
||||
* OPS84 extension (--values): compares ALL content columns per row (not just
|
||||
* ids). Columns are classified into buckets:
|
||||
* - content: divergence fails the run (the acceptance criterion)
|
||||
* - derived: hook-stamped or Payload-managed; divergence is informational only
|
||||
* - sensitive: PII; values are never printed, only counted
|
||||
*
|
||||
* Safety model:
|
||||
* - READ-ONLY: every connection runs `default_transaction_read_only = on`
|
||||
* and a statement timeout. No writes are possible from this script.
|
||||
* - NEVER prints PII: only ids, counts and relation keys are reported.
|
||||
* In --values mode, sensitive column values are never printed.
|
||||
* - Both URLs come from env; the operator supplies them on the homeserver
|
||||
* (NEON_DATABASE_URL from ~/stack/.env; DATABASE_URL from
|
||||
* ~/stack/teqo-1313.env, rewritten to the socat proxy 127.0.0.1:5433 —
|
||||
|
|
@ -19,7 +26,11 @@
|
|||
*
|
||||
* Usage:
|
||||
* NEON_DATABASE_URL=postgres://... DATABASE_URL=postgres://... \
|
||||
* pnpm ops79:reconcile
|
||||
* pnpm ops79:reconcile # id-mode (default, OPS79)
|
||||
* NEON_DATABASE_URL=postgres://... DATABASE_URL=postgres://... \
|
||||
* pnpm ops84:reconcile-values # values-mode (OPS84)
|
||||
* NEON_DATABASE_URL=postgres://... DATABASE_URL=postgres://... \
|
||||
* pnpm ops84:reconcile-values --table vote_pledge --report report.md
|
||||
*/
|
||||
import { Client } from 'pg'
|
||||
|
||||
|
|
@ -35,6 +46,23 @@ const TARGET_DATABASE_URL = process.env.DATABASE_URL
|
|||
if (!NEON_DATABASE_URL) die('NEON_DATABASE_URL is not set (fonte: ~/stack/.env no homeserver).')
|
||||
if (!TARGET_DATABASE_URL) die('DATABASE_URL is not set (target teqo_1313).')
|
||||
|
||||
// CLI flags: --values (OPS84), --table <name>, --report <path>
|
||||
const VALUES_MODE = process.argv.includes('--values')
|
||||
const TABLE_FILTER = (() => {
|
||||
const idx = process.argv.indexOf('--table')
|
||||
return idx !== -1 && idx + 1 < process.argv.length ? process.argv[idx + 1] : null
|
||||
})()
|
||||
const REPORT_PATH = (() => {
|
||||
const idx = process.argv.indexOf('--report')
|
||||
return idx !== -1 && idx + 1 < process.argv.length ? process.argv[idx + 1] : null
|
||||
})()
|
||||
if (VALUES_MODE) {
|
||||
console.log(
|
||||
`[ops84:reconcile-values] modo VALUES ativado${TABLE_FILTER ? ` (tabela: ${TABLE_FILTER})` : ''}`,
|
||||
)
|
||||
if (REPORT_PATH) console.log(`[ops84:reconcile-values] relatório: ${REPORT_PATH}`)
|
||||
}
|
||||
|
||||
// Campaign vertical collections (id-bearing, content tables).
|
||||
const CORE_TABLES = [
|
||||
'campaign_user',
|
||||
|
|
@ -92,7 +120,239 @@ const fetchIds = async (client, table) => {
|
|||
return res.rows.map((r) => String(r.id))
|
||||
}
|
||||
|
||||
// Joins each rel row to its child id. Payload rel tables use a per-parent
|
||||
// --- OPS84: derived/sensitive column maps per table ---
|
||||
// Derived = stamped by Payload hooks or managed fields; divergence is
|
||||
// informational (expected after legitimate post-OPS51 edits in target).
|
||||
// Sensitive = PII; values are NEVER printed, only counted.
|
||||
// Column names use the DB convention (e.g. relationship fields get _id suffix,
|
||||
// group fields are flattened to field_subfield).
|
||||
|
||||
const DERIVED_COLUMNS = {
|
||||
vote_pledge: [
|
||||
'declared_at',
|
||||
'declared_by_id',
|
||||
'estimated_at',
|
||||
'estimated_by_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
campaign_user: ['created_at', 'updated_at'],
|
||||
leadership: ['created_at', 'updated_at'],
|
||||
activity: ['created_at', 'updated_at'],
|
||||
municipality: ['created_at', 'updated_at'],
|
||||
organization: ['created_at', 'updated_at'],
|
||||
allocation_decision: ['created_at', 'updated_at'],
|
||||
municipality_update: ['created_at', 'updated_at'],
|
||||
state_deputy: ['created_at', 'updated_at'],
|
||||
election_tally: ['created_at', 'updated_at'],
|
||||
campaign_web_authn_credential: ['created_at', 'updated_at'],
|
||||
supporter: ['created_at', 'updated_at'],
|
||||
campaign_demand: ['created_at', 'updated_at'],
|
||||
}
|
||||
|
||||
const SENSITIVE_COLUMNS = {
|
||||
campaign_user: ['email', 'name'],
|
||||
supporter: ['email', 'name'],
|
||||
leadership: ['name', 'email', 'phone'],
|
||||
campaign_web_authn_credential: ['public_key'],
|
||||
}
|
||||
|
||||
// Payload-managed columns that are always "derived" (not compared as content).
|
||||
const PAYLOAD_MANAGED = new Set(['id', 'created_at', 'updated_at', '_status'])
|
||||
|
||||
// Schema introspection: list columns and their types for a table.
|
||||
async function introspectColumns(client, table) {
|
||||
const { rows } = await client.query(
|
||||
`SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1
|
||||
ORDER BY ordinal_position`,
|
||||
[table],
|
||||
)
|
||||
return rows.map((r) => ({ name: r.column_name, type: r.data_type }))
|
||||
}
|
||||
|
||||
// Classify a column into a bucket: 'content' | 'derived' | 'ensitive' | 'internal'.
|
||||
function classifyColumn(table, colName) {
|
||||
if (PAYLOAD_MANAGED.has(colName)) return 'managed'
|
||||
if ((DERIVED_COLUMNS[table] || []).includes(colName)) return 'derived'
|
||||
if ((SENSITIVE_COLUMNS[table] || []).includes(colName)) return 'sensitive'
|
||||
return 'content'
|
||||
}
|
||||
|
||||
// Build a SELECT expression that returns jsonb columns as ::text (preserving
|
||||
// exact representation — '1' vs '1.0' diverges, SQL NULL vs JSON null differs)
|
||||
// and other columns as ::text with a NULL sentinel.
|
||||
function buildSelectExpression(columns) {
|
||||
const parts = columns.map((col) => {
|
||||
if (col.type === 'jsonb') {
|
||||
return `"${col.name}"::text AS "${col.name}"`
|
||||
}
|
||||
return `COALESCE("${col.name}"::text, '§NULL§') AS "${col.name}"`
|
||||
})
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
// Strict JS comparator: two row values are equal iff their string representations
|
||||
// match exactly. No trim, no round, no coalesce.
|
||||
function compareRows(neonRow, targetRow, columns) {
|
||||
const diffs = []
|
||||
for (const col of columns) {
|
||||
const nv = neonRow[col.name]
|
||||
const tv = targetRow[col.name]
|
||||
if (nv !== tv) {
|
||||
diffs.push({ column: col.name, type: col.type, neon: nv, target: tv })
|
||||
}
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
|
||||
// Fetch all rows for a table, keyed by id, with selected columns.
|
||||
async function fetchRowsByKey(client, table, columns, keyCol = 'id') {
|
||||
const selectExpr = buildSelectExpression(columns)
|
||||
const res = await client.query(`SELECT ${selectExpr} FROM "${table}" ORDER BY "${keyCol}"`)
|
||||
const map = new Map()
|
||||
for (const row of res.rows) {
|
||||
map.set(String(row[keyCol]), row)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// Truncate a value for the report artifact (PII-safe, max 120 chars).
|
||||
function truncateForReport(val, maxLen = 120) {
|
||||
if (val === null || val === undefined) return 'NULL'
|
||||
const s = String(val)
|
||||
return s.length > maxLen ? s.slice(0, maxLen) + '…' : s
|
||||
}
|
||||
|
||||
// Render a single table's value comparison to console + optional report lines.
|
||||
function renderTableValues(table, diffs, neonCount, targetCount, commonIds, reportLines) {
|
||||
const contentDiffs = diffs.filter((d) => d.bucket === 'content')
|
||||
const derivedDiffs = diffs.filter((d) => d.bucket === 'derived')
|
||||
const sensitiveDiffs = diffs.filter((d) => d.bucket === 'sensitive')
|
||||
|
||||
const ok = contentDiffs.length === 0
|
||||
const detail =
|
||||
`rows ${neonCount}=${targetCount}; common=${commonIds.length}; ` +
|
||||
`content_diffs=${contentDiffs.length}; derived_diffs=${derivedDiffs.length}; ` +
|
||||
`sensitive_diffs=${sensitiveDiffs.length}`
|
||||
|
||||
report(table, ok, detail)
|
||||
|
||||
if (contentDiffs.length > 0) {
|
||||
console.log(` content divergences:`)
|
||||
for (const d of contentDiffs) {
|
||||
console.log(
|
||||
` id=${d.id} .${d.column}: neon=${truncateForReport(d.neon)} target=${truncateForReport(d.target)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (derivedDiffs.length > 0) {
|
||||
console.log(` derived (informational): ${derivedDiffs.length} divergences`)
|
||||
}
|
||||
if (sensitiveDiffs.length > 0) {
|
||||
console.log(` sensitive (PII, not printed): ${sensitiveDiffs.length} divergences`)
|
||||
}
|
||||
|
||||
// Report artifact lines
|
||||
if (reportLines) {
|
||||
reportLines.push(`## ${table}`)
|
||||
reportLines.push(``)
|
||||
reportLines.push(`- rows: neon=${neonCount}, target=${targetCount}, common=${commonIds.length}`)
|
||||
reportLines.push(`- content diffs: ${contentDiffs.length}`)
|
||||
reportLines.push(`- derived diffs (informational): ${derivedDiffs.length}`)
|
||||
reportLines.push(`- sensitive diffs (PII, not printed): ${sensitiveDiffs.length}`)
|
||||
reportLines.push(``)
|
||||
if (contentDiffs.length > 0) {
|
||||
reportLines.push(`### Content divergences`)
|
||||
reportLines.push(``)
|
||||
reportLines.push(`| id | column | neon | target |`)
|
||||
reportLines.push(`|---|--------|------|--------|`)
|
||||
for (const d of contentDiffs) {
|
||||
reportLines.push(
|
||||
`| ${d.id} | ${d.column} | ${truncateForReport(d.neon)} | ${truncateForReport(d.target)} |`,
|
||||
)
|
||||
}
|
||||
reportLines.push(``)
|
||||
}
|
||||
if (derivedDiffs.length > 0) {
|
||||
reportLines.push(`### Derived divergences (informational)`)
|
||||
reportLines.push(``)
|
||||
for (const d of derivedDiffs) {
|
||||
reportLines.push(
|
||||
`- id=${d.id} .${d.column}: neon=${truncateForReport(d.neon)} target=${truncateForReport(d.target)}`,
|
||||
)
|
||||
}
|
||||
reportLines.push(``)
|
||||
}
|
||||
if (sensitiveDiffs.length > 0) {
|
||||
reportLines.push(`### Sensitive divergences (PII, not printed)`)
|
||||
reportLines.push(``)
|
||||
reportLines.push(
|
||||
`- ${sensitiveDiffs.length} rows with PII divergence (column names: ${[...new Set(sensitiveDiffs.map((d) => d.column))].join(', ')})`,
|
||||
)
|
||||
reportLines.push(``)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run values-mode comparison for one table.
|
||||
async function reconcileValuesTable(neon, target, table) {
|
||||
const neonCols = await introspectColumns(neon, table)
|
||||
const targetCols = await introspectColumns(target, table)
|
||||
|
||||
// Use intersection of columns (ignore columns present in only one side).
|
||||
const neonColNames = new Set(neonCols.map((c) => c.name))
|
||||
const commonColDefs = targetCols.filter((c) => neonColNames.has(c.name))
|
||||
|
||||
// Classify all columns.
|
||||
const classified = commonColDefs.map((col) => ({
|
||||
...col,
|
||||
bucket: classifyColumn(table, col.name),
|
||||
}))
|
||||
|
||||
const contentCols = classified.filter((c) => c.bucket === 'content')
|
||||
const derivedCols = classified.filter((c) => c.bucket === 'derived')
|
||||
const sensitiveCols = classified.filter((c) => c.bucket === 'sensitive')
|
||||
const managedCols = classified.filter((c) => c.bucket === 'managed')
|
||||
|
||||
console.log(
|
||||
` [${table}] columns: ${classified.length} total; ` +
|
||||
`content=${contentCols.length} derived=${derivedCols.length} ` +
|
||||
`sensitive=${sensitiveCols.length} managed=${managedCols.length}`,
|
||||
)
|
||||
|
||||
// If no content columns, nothing to fail on.
|
||||
if (contentCols.length === 0) {
|
||||
report(table, true, 'no content columns to compare')
|
||||
return []
|
||||
}
|
||||
|
||||
// Fetch rows (content + derived + sensitive; managed excluded from comparison).
|
||||
const fetchCols = [...contentCols, ...derivedCols, ...sensitiveCols]
|
||||
const neonRows = await fetchRowsByKey(neon, table, fetchCols)
|
||||
const targetRows = await fetchRowsByKey(target, table, fetchCols)
|
||||
|
||||
const commonIds = [...neonRows.keys()].filter((id) => targetRows.has(id))
|
||||
const neonOnly = [...neonRows.keys()].filter((id) => !targetRows.has(id))
|
||||
const targetOnly = [...targetRows.keys()].filter((id) => !neonRows.has(id))
|
||||
|
||||
if (neonOnly.length > 0 || targetOnly.length > 0) {
|
||||
console.log(
|
||||
` id-only: neon=${neonOnly.length} target=${targetOnly.length} (use id-mode for details)`,
|
||||
)
|
||||
}
|
||||
|
||||
// Compare rows present in both.
|
||||
const allDiffs = []
|
||||
for (const id of commonIds) {
|
||||
const diffs = compareRows(neonRows.get(id), targetRows.get(id), fetchCols)
|
||||
for (const d of diffs) {
|
||||
allDiffs.push({ id, ...d })
|
||||
}
|
||||
}
|
||||
|
||||
return allDiffs
|
||||
}
|
||||
// polymorphic FK; we normalise to (parent, path, child) so semantically equal
|
||||
// relationships are comparable even when Payload churn changes row ids. The
|
||||
// child id lives in whichever `<relation>_id` column is set for the path — the
|
||||
|
|
@ -117,14 +377,7 @@ async function fetchRels(client, table) {
|
|||
return res.rows.map((r) => `${r.parent}|${r.path}|${r.child}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[ops79:reconcile] Neon : ${new URL(NEON_DATABASE_URL).host}`)
|
||||
console.log(`[ops79:reconcile] Target: ${new URL(TARGET_DATABASE_URL).host}`)
|
||||
|
||||
const neon = await connectReadOnly(NEON_DATABASE_URL)
|
||||
const target = await connectReadOnly(TARGET_DATABASE_URL)
|
||||
console.log(`[ops79:reconcile] conexões read-only OK`)
|
||||
|
||||
async function runIdMode(neon, target) {
|
||||
for (const table of CORE_TABLES) {
|
||||
const neonIds = await fetchIds(neon, table)
|
||||
const targetIds = await fetchIds(target, table)
|
||||
|
|
@ -156,16 +409,85 @@ async function main() {
|
|||
absolvedHits > 0 ? `${detail} (+${absolvedHits} absorvida pós-OPS51)` : detail,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await neon.end()
|
||||
await target.end()
|
||||
async function runValuesMode(neon, target) {
|
||||
const tables = TABLE_FILTER ? [TABLE_FILTER] : CORE_TABLES
|
||||
const reportLines = []
|
||||
let totalContentDiffs = 0
|
||||
|
||||
const failures = results.filter((r) => !r.ok)
|
||||
console.log(
|
||||
`\n[ops79:reconcile] ${results.length - failures.length}/${results.length} PASS` +
|
||||
(failures.length > 0 ? `; ${failures.length} FAIL` : ''),
|
||||
)
|
||||
process.exit(failures.length > 0 ? 1 : 0)
|
||||
for (const table of tables) {
|
||||
if (!CORE_TABLES.includes(table)) {
|
||||
die(`tabela "${table}" não é uma das 13 tabelas da vertical: ${CORE_TABLES.join(', ')}`)
|
||||
}
|
||||
|
||||
const diffs = await reconcileValuesTable(neon, target, table)
|
||||
const contentDiffs = diffs.filter((d) => d.bucket === 'content')
|
||||
totalContentDiffs += contentDiffs.length
|
||||
|
||||
const neonRows = await fetchRowsByKey(neon, table, [{ name: 'id', type: 'integer' }])
|
||||
const targetRows = await fetchRowsByKey(target, table, [{ name: 'id', type: 'integer' }])
|
||||
const commonIds = [...neonRows.keys()].filter((id) => targetRows.has(id))
|
||||
|
||||
renderTableValues(table, diffs, neonRows.size, targetRows.size, commonIds, reportLines)
|
||||
}
|
||||
|
||||
// Write report artifact if requested.
|
||||
if (REPORT_PATH) {
|
||||
const fs = await import('node:fs')
|
||||
const header = [
|
||||
`# Relatório de verificação de valores — OPS84`,
|
||||
``,
|
||||
`- Data: ${new Date().toISOString()}`,
|
||||
`- Neon: ${new URL(NEON_DATABASE_URL).host}`,
|
||||
`- Target: ${new URL(TARGET_DATABASE_URL).host}`,
|
||||
`- Modo: values${TABLE_FILTER ? ` (tabela: ${TABLE_FILTER})` : ' (13 tabelas)'}`,
|
||||
`- Total content diffs: ${totalContentDiffs}`,
|
||||
``,
|
||||
]
|
||||
fs.writeFileSync(REPORT_PATH, [...header, ...reportLines].join('\n'))
|
||||
console.log(`\n[ops84:reconcile-values] relatório escrito em ${REPORT_PATH}`)
|
||||
}
|
||||
|
||||
return totalContentDiffs
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[ops79:reconcile] Neon : ${new URL(NEON_DATABASE_URL).host}`)
|
||||
console.log(`[ops79:reconcile] Target: ${new URL(TARGET_DATABASE_URL).host}`)
|
||||
|
||||
const neon = await connectReadOnly(NEON_DATABASE_URL)
|
||||
const target = await connectReadOnly(TARGET_DATABASE_URL)
|
||||
if (VALUES_MODE) {
|
||||
await neon.query('SET statement_timeout = 60000')
|
||||
await target.query('SET statement_timeout = 60000')
|
||||
}
|
||||
console.log(`[ops79:reconcile] conexões read-only OK`)
|
||||
|
||||
if (VALUES_MODE) {
|
||||
const contentDiffs = await runValuesMode(neon, target)
|
||||
await neon.end()
|
||||
await target.end()
|
||||
|
||||
const failures = results.filter((r) => !r.ok)
|
||||
console.log(
|
||||
`\n[ops84:reconcile-values] ${results.length - failures.length}/${results.length} PASS` +
|
||||
(failures.length > 0 ? `; ${failures.length} FAIL` : ''),
|
||||
)
|
||||
// Exit 1 if any content divergence (the acceptance criterion).
|
||||
process.exit(contentDiffs > 0 || failures.length > 0 ? 1 : 0)
|
||||
} else {
|
||||
await runIdMode(neon, target)
|
||||
await neon.end()
|
||||
await target.end()
|
||||
|
||||
const failures = results.filter((r) => !r.ok)
|
||||
console.log(
|
||||
`\n[ops79:reconcile] ${results.length - failures.length}/${results.length} PASS` +
|
||||
(failures.length > 0 ? `; ${failures.length} FAIL` : ''),
|
||||
)
|
||||
process.exit(failures.length > 0 ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue