ops(OPS79): última migração de dados em produção — reconciliação da vertical campanha (Neon→teqo_1313) + migração do residual público (Closes #797)

- scripts/reconcile-campaign-vertical.mjs: verificação read-only repetível (contagens + junta de IDs das 13 coleções da vertical + conteúdo semântico das rels); absorve os 3 links de edição de portfolio pós-OPS51; nunca imprime PII
- scripts/migrate-signature-orphan.mjs: migração one-off com guard OPS79_MIGRATE_CONFIRM=1, dry-run/apply, transação única, PII mascarada
- Execução 100% no homeserver: aplicado contact 2225 + signature 1486 preservada + subscription 1492; sequences ajustadas; pós-verificação signature 1485=1485 e reconcile 19/19
- Runbook (docs/ops/teqo-1313-deploy.md §OPS79): execução + rollback (DELETE 3 linhas + setval)
- docs: plano de intenção restaurado (deletado acidentalmente em C140) + impl plan + changelog
This commit is contained in:
Francisco Solla 2026-08-23 22:11:54 -03:00
parent 7de6c01b10
commit bddbd5c085
8 changed files with 712 additions and 0 deletions

View file

@ -6,6 +6,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**E2E-FULL-FLAKE-C142MUN — endurecer write-chrome assertions sob 4 workers (2026-08-23):** as 3 asserts de `campaignPermissionProfile.e2e.spec.ts:85` (carteira FAB visível), `:101` (tudo FAB visível) e `campaignMunicipalities.e2e.spec.ts:115` (checkbox de advisor no `/editar`) falhavam consistentemente no `deploy.yml → verify` (suíte full, 4 workers, prod build) por latência de render/hydration do shell streamed sob carga — a sonda SSR prova que o FAB está correto no HTML; o falha é que `networkidle` finda antes do chunk `S:` commitar, e a assert `toBeVisible`/`.check()` usa o budget default que é insuficiente no runner hosted. Fix: adicionar `page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)` (padrão C106 já consolidado nos irmãos do arquivo) antes das asserts de presença — dois bursts de spec, sem mudança de produto/access/migration. Verificado: `gate:fast` (2439 unit tests, all green); e2e das 2 specs isoladas + repeat-each=3 em 4 workers: 21/21 passed. Sem alteração de access, sem migration, sem redução de workers do full.
**OPS79 — última migração de dados em produção: plataforma antiga → nova (vertical campanha) (2026-08-23):** com a fonte congelada pelo OPS80 (zero escritas no Neon desde 2026-08-23 22:30 UTC), reconciliação e migração do residual. **Descoberta-chave:** a vertical campanha JÁ estava 100% íntegra no `teo_1313` (aceite do OPS51 confirmado por reconciliação repetível — diff 0 em contagens e junta de IDs das 13 coleções; `municipality_rels` só 3 links de edição de portfólio pós-OPS51, absorvidos como não-dado). O único residual real entre Neon e target era a `signature` pública 1486 (petition `fim-escala-6x1`) com seu contato + subscription, cujo contato originário (Juares) **não existia** no target (o id 2221 do target é outra pessoa). **Ferramentas:** `scripts/reconcile-campaign-vertical.mjs` (read-only, 19/19 PASS, nunca imprime PII) + `scripts/migrate-signature-orphan.mjs` (dry-run/apply, guard `OPS79_MIGRATE_CONFIRM=1`, transação única, PII mascarada nos logs). **Execução 100% no homeserver:** aplicado contact novo **2225** + signature **1486** preservada + subscription **1492** + sequences ajustadas; pós-verificação `signature` 1485=1485, reconcile 19/19, smoke `/` `/campanha` `/admin` 200. Runbook (rollback = DELETE das 3 linhas + setval) em `docs/ops/teqo-1313-deploy.md` §OPS79. Sem migration de schema, sem access/Consent novo, sem UI. Closes #797.
**C145 — editor de permissão do assessor em modal central (desktop) + bottom drawer (mobile) no lugar do popover (2026-08-23):** na lista de assessores (`/campanha/assessores`), o badge "Permissão" abria o editor Visão × Edição num popover ancorado que estourava o viewport (tabela `min-w-[56rem]` com scroll horizontal). Troca do invólucro contida em `AdvisorPermissionBadge.tsx` (ponto único dos dois usos — linha de dados com `advisorId`+`formAction` e linha de rascunho com `onDraftChange` local): o badge agora abre um **Dialog central** no desktop e um **bottom drawer** no celular, selecionados por `useNarrowMeasured(640)` vivendo no próprio badge (não na tabela). O `AdvisorPermissionEditor` entra com props idênticas às de hoje — mesmos combos Visão/Edição (`advisor-permission-visibility`/`advisor-permission-editing`), nota, footer Cancelar/Salvar e regras de coerência intactos; `onSaved`/`onCancel` fecham o overlay e o badge reflete o novo perfil; a linha de rascunho segue via `onDraftChange` local, sem escrita nova no servidor. A11y: `DialogTitle`/`DrawerTitle` sr-only "Permissão da conta" cumprem o Radix sem duplicar o heading visível do editor; guards de altura (`max-h-[calc(100dvh-2rem)]`+scroll no dialog, capa do drawer) garantem o "nunca estoura" do aceite. Editor embutido no detalhe `assessores/[id]/page.tsx` intocado; import de `Popover` removido. Débito adiado com gatilho: o padrão "Dialog/Drawer keyed em `isNarrow`" se repete em >3 overlays — extrair primitivo `ResponsiveSheet` só quando uma 3ª superfície precisar do mesmo wrapper. Verificação: lint/typecheck/unit + int 830 verdes; `gate:push` na entrega; PR em main com auto-merge.
**OPS76-DOCS — banner histórico em `docs/plans/paradigma-agentes-paralelos.md` (plano órfão pré-main com refs mortas) (2026-08-23):** docs-only, zero mudança de comportamento. Débito capturado no OPS47 (#735, triage score 2/5, defer_trigger) resolvido: o plano de intenção `docs/plans/paradigma-agentes-paralelos.md` (escrito 2026-07-30, era pré-main, todos os todos `status: pending` — **nunca implementado nessa forma**) era órfão (zero referências vivas; só a prosa dos changelogs do OPS47 o citava) e carregava referências mortas da era stage/Neon: `scripts/refresh-stage.mjs` (deletado 2026-08-01), Neon/stage (`STAGE_DATABASE_URL`, branch git `stage`, `ci-stage.yml`), job `migration-lock` (removido 2026-08-12), `gh pr create --base stage`, `pnpm agent:promote`. **Decisão da Issue #800: banner histórico, não remoção** — o assunto (paradigma de agentes paralelos) foi realizado em outra forma (claim→PR→`main`→deploy gated; precedentes de remoção em `docs/plans/` são de planos cancelados/dropados, classe diferente), e a convenção dominante do repo preserva planos superseded como registro histórico (precedente canônico `docs/plans/remodelagem-pracas.md:3`). **Edição única:** blockquote `> **HISTÓRICO (2026-08-23):**` self-contained inserido entre o fechamento do frontmatter YAML (L30) e o título `# Teqo ops` (L48, [L32–L46 do arquivo final]) — data o plano como pré-main (2026-07-30), aponta o doc canônico vivo `docs/AGENT-OPS.md` (ladder de 2 ambientes: mínimo + prod, sem smoke stage), lista as refs mortas em bullets com as datas de remoção e declara explicitamente que nada do corpo é instrução corrente. **Corpo do plano congelado** (nenhuma edição linha-a-linha: sem consumidor vivo — grep global zero —, diferente do caso OPS47/agent-pool-orchestrator que tinha a SKILL.md como referência viva legítima de edição); arquivo não removido; frontmatter YAML intacto; changelogs do OPS47 insert-only intocados. Sem migration/access/Consent/UI/schema. Verificação: `git diff` = 1 bloco de adição; `pnpm format:check` verde; grep pós-zero refs vivas ao plano; guard `plans-only-closes` não bloqueia (diff inclui `docs/changelog/` + agregado — não é plans-only); `pnpm gate:fast` verde; `pnpm push` com PR `--base main` e `Closes #800` + auto-merge.

View file

@ -0,0 +1 @@
**OPS79 — última migração de dados em produção: plataforma antiga → nova (vertical campanha) (2026-08-23):** com a fonte congelada pelo OPS80 (zero escritas no Neon desde 2026-08-23 22:30 UTC), reconciliação e migração do residual. **Descoberta-chave:** a vertical campanha JÁ estava 100% íntegra no `teo_1313` (aceite do OPS51 confirmado por reconciliação repetível — diff 0 em contagens e junta de IDs das 13 coleções; `municipality_rels` só 3 links de edição de portfólio pós-OPS51, absorvidos como não-dado). O único residual real entre Neon e target era a `signature` pública 1486 (petition `fim-escala-6x1`) com seu contato + subscription, cujo contato originário (Juares) **não existia** no target (o id 2221 do target é outra pessoa). **Ferramentas:** `scripts/reconcile-campaign-vertical.mjs` (read-only, 19/19 PASS, nunca imprime PII) + `scripts/migrate-signature-orphan.mjs` (dry-run/apply, guard `OPS79_MIGRATE_CONFIRM=1`, transação única, PII mascarada nos logs). **Execução 100% no homeserver:** aplicado contact novo **2225** + signature **1486** preservada + subscription **1492** + sequences ajustadas; pós-verificação `signature` 1485=1485, reconcile 19/19, smoke `/` `/campanha` `/admin` 200. Runbook (rollback = DELETE das 3 linhas + setval) em `docs/ops/teqo-1313-deploy.md` §OPS79. Sem migration de schema, sem access/Consent novo, sem UI. Closes #797.

View file

@ -168,6 +168,72 @@ automaticamente — **não serve push, não recebe PRs, é somente leitura**.
- Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` /
build-secrets; envs só no homeserver.
## OPS79 — última migração da plataforma antiga → nova (vertical campanha)
Operação de dados executada em 2026-08-23. Ver assistência lógica completa:
`docs/plans/ops79-ultima-migracao-dados-campanha.md` (intenção) e
`…campanha-impl.md` (impl). **A fonte (Neon, plataforma antiga) está congelada
desde o OPS80** — zero escritas desde 2026-08-23 22:30 UTC; a URL antiga segue
viva na Vercel apenas tecnicamente (desligamento de infra é o OPS81).
**Descoberta-chave da reconciliação:** a vertical campanha JÁ estava 100% íntegra
no `teo_1313` após o OPS51 (dump Neon→homeserver) — nenhum dado de operação de
campanha ficou órfão; o time de campanha não escreveu na URL antiga depois do
OPS51. O único residual real entre as plataformas era a `signature` pública
1486 (e seu contato/subscription), cujo contato originário **não existia** no
target (o id 2221 do target é OUTRA pessoa, Jorge Solla).
### Reconciliador (read-only, repetível)
Compara contagens + junta de IDs das 13 coleções da vertical e o conteúdo
semântico das join-tables de relações contra a fonte Neon.
```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)
# o host `postgres` só resolve dentro da rede stack_default; do host usa-se o
# proxy socat do build (127.0.0.1:5433):
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:reconcile
```
Esperado: `19/19 PASS` (a `municipality_rels` reporta `+3 absorvida pós-OPS51` —
edições de portfólio na plataforma nova, não-dado). Exit `1` em qualquer nova
divergência. Nunca imprime PII.
### Migração do residual (one-off, com guard de confirmação)
Copia a `signature` 1486 + seu contato + subscription (a transação que o
`submitPetitionSignature` faz junto), reusando petition/consent já presentes e
criando o contato com **id novo** (nunca o 2221, ocupado por outra pessoa).
```bash
# dry-run (valida referentes e imprime o plano sem escrever):
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:migrate --dry-run
# aplica (ESCREVE em prod — exige a flag de intenção explícita):
OPS79_MIGRATE_CONFIRM=1 NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:migrate --apply
```
Resultado registrado em 2026-08-23: contact novo **2225** (Juares Lagimar de
Souza), signature **1486** preservada (petition `fim-escala-6x1`, consent 2),
subscription **1492**; sequences ajustadas (`setval` com `true` → próximo id =
último+1). Sequências conferidas após: contact 2225 / signature 1486 /
subscription 1492. Pós-verificação: `signature` 1485=1485 (Neon×target) e
reconcile 19/19; smoke `/`, `/campanha`, `/admin` 200.
### Rollback
A migração é idempotente (fail-closed: aborta se referentes/ids divergirem). Se
precisar desfazer, DELETar as 3 linhas + ajustar sequences de volta:
`DELETE FROM signature WHERE id=1486; DELETE FROM subscription WHERE id=1492;
DELETE FROM contact_phones WHERE _parent_id=2225; DELETE FROM contact WHERE id=2225;`
e `setval('contact_id_seq',2224)`, `setval('signature_id_seq',1485)`,
`setval('subscription_id_seq',1491)`.
## Referências
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo)

View file

@ -0,0 +1,112 @@
# Impl: Última migração de dados em produção: plataforma antiga → nova (vertical campanha)
Status: aprovado (executado em 2026-08-23)
Atualizado em: 2026-08-23
Issue: #797
Intenção: docs/plans/ops79-ultima-migracao-dados-campanha.md
Appetite restante: herdado (~2–3 dias; aqui cabe em 1 sessão — corte explícito do escopo à luz dos achados empíricos)
## Leitura da intenção
- **Outcome:** nenhum dado de operação de campanha (lideranças, pledges, apoiadores, atividades, demandas, alocação, usuários) viver só na plataforma antiga no momento do desligamento; contagens conferem; nada órfão, nada duplicado; `/campanha` preserva o acesso dos usuários.
- **O que NÃO negociar:** PII/LGPD só com fail-closed; municípios/seed nunca duplicados; vertical sem regressão (assimetria de votos, lockdown de liderança, leitura relativa/local); anti-goals — não re-migrar tudo, não "consertar" IDs históricos, não criar superfície nova de edição.
- **O que reavaliar:** a hipótese da intenção — "o time seguiu usando `/campanha` na antiga pós-OPS51" — é empiricamente FALSA: reconciliação ID-a-ID ao vivo (Neon × `teqo_1313`, 13 tabelas da vertical) dá diff 0 na vertical inteira. O residual real entre as plataformas é 1 `signature` (id 1486, pública — não-campanha) cujo contato (Juares, `jlagimar@gmail.com`) está AUSENTE do target, e o id 2221 colide com outro contato no target (Jorge Solla). A "verificação viva" da questão em aberto está respondida (Neon é a base; quiescida desde o freeze OPS80). A entrega deixa de ser "migrar delta incerto" → é **verificação formal da vertical + decisão pontual sobre o residual público**.
## Abordagem recomendada
```mermaid
flowchart LR
F[Neon fonte — congelada OPS80] --> R[reconcile-campaign-vertical<br/>contagem + junta de IDs · homeserver · read-only]
T[teqo_1313 target] --> R
R -->|report: 0 diff na vertical| E[residual: signature 1486 pública<br/>contato Juares ausente no target]
E --> D[decisão humana sobre o residual<br/>migrar-orpão público vs registrar exceção]
D -->|se migrar| M[migrate-signature-orphan<br/>cria contato + signature · OPS79_MIGRATE_CONFIRM=1]
D -->|absorver| A[registrar exceção + runbook]
M --> V[re-reconcile + smoke]
```
**Opções consideradas:** A | B | C
**Recomendação:** B — reconciliar formalmente a vertical inteira por contagem/IDs (baseline: dump OPS51; já diff 0) e tratar o residual único verificável (uma `signature` pública) com decisão explícita (migrar o contato+signature com id novo vs registrar como exceção). É o único caminho com o aceite "nada órfão, nada duplicado" sem re-migrar nada e sem violar o anti-goal de IDs.
**Rejeitadas:** A — migrar "só o delta" sem a reconciliação formal deixaria o aceite "nada órfão" sem evidência repetível. C — re-migração completa: anti-goal explícito; a vertical já está íntegra.
### Decisões de engenharia
- **(i) O que copiar.** Opções: A) nada (só verificação + exceção) | B) 1 signature pública (1486) + seu contato (Juares) com id novo | C) rels/snapshot/sessions. **Recomendação:** verificação formal da vertical (já diff 0) + _decisão humana_ entre A e B para o residual público — ver raia "Gate humano". `municipality_rels` (churn de row-id do Payload — edição de portfólio pós-OPS51), snapshot (cache derivado regenerável) e sessions (efêmeras) são **absolvidos** como não-dado. `contact`/`subscription`/`post`/`media`/`social_feed_settings`: target já tem mais; fora de escopo. **Rejeitadas:** C.
- **(ii) Via de cópia (se migrar o residual público).** Opções: A) SQL direto `psql INSERT` | B) Payload Local API (script one-off, `overrideAccess`) | C) `pg_dump`/`pg_restore` parcial de 1 linha. **Recomendação:** B — validação de `required`/relationships da collection, `createdAt`/`updatedAt`/versionado geridos pelo Payload; segue precedente `seed-consent` + padrão `recover-media` (guard `OPS79_MIGRATE_CONFIRM=1`, `--dry-run`/`--verify`). **Rejeitadas:** A (burlaria camada de domínio e o versionado; sem guard de referentes) e C (overkill para 1 linha; PK/relationships fragmentadas).
- **(iii) Contato ausente / ID colide.** Opções: A) criar o contato Juares no target com id NOVO (o 2221 está ocupado por Jorge Solla) e inserir a signature apontando para ele | B) reusar o contato target 2221 (Jorge Solla) — ERRADO, pessoa diferente | C) não migrar. **Recomendação:** A (nunca B — anexaria a assinatura de uma pessoa ao contato de outra = corrupção de PII/LGPD). Prép-check fail-closed por _email_ no target (se `jlagimar@gmail.com` já existe lá, reusar; senão criar) e por _conteúdo_ do consent (`whatsapp-inscricao` id 2 — já alinhado). Aborto com relatório em ambiguidade (0/>1 match de email/consent).
- **(iv) Verificação pós.** Opções: A) contagem por coleção | B) contagem + junta de IDs + smoke | C) só smoke. **Recomendação:** B — re-roda o reconciliador (vertical 0 diff; target `signature` 1484→1485, `contact` 1923→1924); counts de `petition`/`consent` **inalterados** (prova de reuso sem duplicação); smoke adm/API + `/campanha` intacto + quiescência re-checada.
- **(v) Runbook.** Opções: A) seção em `docs/ops/teqo-1313-deploy.md` + falhas conhecidas + rollback | B) só changelog | C) nada. **Recomendação:** A — operação passo-a-passo (envs, echo de target, guards), falhas (ambiguidade de consent/email, colisão de id), rollback = `DELETE` das linhas inseridas (single/2-row).
### Componentes / mudanças
- **`scripts/reconcile-campaign-vertical.mjs`** (novo, read-only): contagens + junta de IDs das 13 tabelas da vertical (Neon × `teqo_1313`), baseline dump OPS51 cross-ref, report de diff, exit ≠ 0 em divergência; **nunca imprime PII**. Reusa `scripts/lib/cli.mjs` (`loadCliEnv`, `dieWithLabel`).
- **`scripts/migrate-signature-orphan.mjs`** (novo, one-off; somente se decisão = migrar): pré-valida id 1486 livre + contato por email no target; cria contato com id novo se ausente; insert via Payload Local API idempotente (pre-check `(contact, petition, consent)`). Guard `OPS79_MIGRATE_CONFIRM=1` no `--apply`; `--dry-run` sem guard. Reusa padrão `recover-media`/`seed-consent`.
- **Runbook:** seção OPS79 em `docs/ops/teqo-1313-deploy.md`.
- **Changelog:** `docs/changelog/2026-08-23-ops79.md` + `pnpm changelog:build`.
- **Migration:** sem migration (nenhum schema).
- **Access / Consent:** N/A — fail-closed via pré-check de referentes e aborto em ambiguidade; nenhuma chave `Consent` nova.
- **UI:** Impeccable A — N/A (operação de dados).
### Dados → forma (se aplicável)
Não há superfície de decisão. A "forma" de verificação é o relatório do reconciliador (contagem/IDs, mesmo registro do OPS51) — não dashboard novo.
## Fases verificáveis
1. **Tracer (read-only)** — `reconcile-campaign-vertical.mjs` no homeserver: report da vertical (já diff 0). **Gate humano:** confirmar report == recon ao vivo.
2. **Decisão do residual** — apresentar ao humano: migrar a signature pública 1486 (+ contato Juares com id novo) vs registrar como exceção documentada. **Gate humano duro.**
3. **(Se migrar)** `--dry-run` (plano: contato a criar, mapeamentos, id livre, consent por email/conteúdo) → **Gate humano** → `OPS79_MIGRATE_CONFIRM=1` + `--apply`: cria contato + inserir signature; re-reconcile (vertical 0; signature 1485, contact 1924; petition/consent inalterados).
4. **Smoke em prod** — signature visível no admin/API; `/campanha` intacto; quiescência re-verificada. **Gate humano.**
5. **Docs + gates** — runbook, changelog, `pnpm changelog:build`, `pnpm gate:fast`, push → `Closes #797`.
## Rabbit holes / Não escopo (engenharia)
- Re-migração da vertical inteira (anti-goal; já íntegra e verificada).
- Copiar `municipality_rels`/`campaign_vote_summary_snapshot`/`campaign_user_sessions` (não-dado: churn/derivado/efêmero).
- Tocar `contact`(existente)/`subscription`/`post`/`media`/`social_feed_settings` — target já tem mais.
- Corrigir/reescrever IDs históricos divergentes; reescrever números de votos (leitura relativa/local intacta).
- Desativar/cancelar infra da URL antiga ou Neon (OPS81); redirect de DNS.
- SQL/insert sem validação de referentes — nunca criar órfão.
- Criar sistema de sync genérico — dois scripts one-off, não módulo de runtime.
## Riscos e mitigação
- **Operar em prod (PII/LGPD)** — 100% no homeserver; PII nunca sai dele; `OPS79_MIGRATE_CONFIRM=1`; dry-run antes de apply; echo de target/host; `ALLOW_REMOTE_DB` explícito (operação de prod, padrão `recover-media`).
- **Anexar a signature ao contato errado (Jorge Solla)** — mitigação: nunca reusar target id 2221; resolver por `email` com pré-check fail-closed; abortar em ambiguidade.
- **Sem duplicate-guard** — pre-check `(contact, petition, consent)` + pós-contagem; signature não é unique no schema → guard é na operação.
- **Rollback** — insert idempotente; single/2-row → `DELETE` documentado no runbook.
- **Colisão de id/sequência do target** — pré-check de id livre; contato criado com id NOVO (não 2221).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (contagens conferem; nada órfão/duplicado; `/campanha` preservado; leitura relativa/local intacta)
- [ ] Invariantes AGENTS/engineering-standards (100% homeserver; PII fora da workstation; guards/dry-run; sem migration de schema; sem re-migração; anti-goals respeitados)
- [ ] Testes de domínio previstos: sem mudança de access/write paths — verificação do report do reconciliador (0 diff) + dry-run do migrador; `pnpm gate:fast` verde
## Adiado com gatilho (débitos da sessão)
- **`connectReadOnly` duplicado (2 scripts one-off):** DRY prematuro com <3 call
sites — **gatilho**: um 3º script conectando em Postgres passa a viver em
`scripts/lib/cli.mjs`.
- **`MAX(id)+1` fora da transação (janela de corrida):** aceitável p/ one-shot com
operador único no homeserver — **gatilho**: reutilizar `migrate-signature-orphan.mjs`
para outra série de linhas → mover o cálculo para dentro do `BEGIN` (serial/nextval).
- **`ABSOLVED_MUNICIPALITY_RELS` hardcoded:** baseline válido p/ reconciliação
one-time; re-run cega mascara divergência futura de fato — **gatilho**: reaproveitar
o `reconcile` como ferramenta contínua → exigir `--dry-run`/confirmação explícita
(padrão `MEDIA_RECOVER_CONFIRM`).
- **URL antiga `pt.jorgesolla.com.br` segue viva na Vercel (200) e aceitou escritas
públicas no Neon até 2026-08-23 03:05 (pré-freeze OPS80):** fora do escopo da
sessão (OPS80 só limpou refs in-repo; desligamento de infra é OPS81) — **absorvido**
como check de conclusão do OPS81 (desligar Vercel/Neon; registrar a janela de
escritas pós-OPS51 pré-freeze como known-gap histórico).
## Self-score decision-quality (gate ≥4)
1. Decisões caras têm rejeitadas? Sim — (i)–(v) com Opções/Recomendação/Rejeitadas. **1/1**
2. Abordagem cabe no appetite? Sim — 1 sessão; corte do escopo por evidência empírica. **1/1**
3. Rabbit holes nomeados? Sim. **1/1**
4. Depth check? Sim — reusa `cli.mjs`, padrão `recover-media`, Local API do `seed-consent`; scripts one-off. **1/1**
5. Intenção satisfeita? Sim — não reescreve o outcome. **1/1**
**Self-score: 5/5**

View file

@ -0,0 +1,73 @@
# Última migração de dados em produção: plataforma antiga → nova (vertical campanha)
Status: concluído (executado em 2026-08-23 — impl em `ops79-ultima-migracao-dados-campanha-impl.md`)
Atualizado em: 2026-08-23
Issue: #797
Priority: P1
Impeccable: A — sem UI
Rascunho UI: N/A — sem UI
Appetite: ~2–3 dias eng; um outcome verificável (nenhum dado de operação de campanha viver só na plataforma antiga antes do desligamento)
Responsável: —
## Intenção
Durante a transição, o site continuou vivo em DUAS plataformas: a antiga `pt.jorgesolla.com.br` (Vercel + banco Neon) e a nova `jorgesolla1313.com.br` (homeserver, banco próprio). O time de campanha seguiu usando `/campanha` na URL antiga; depois do OPS51 (migração Neon→homeserver, 2026-08-17), qualquer dado gravado na plataforma antiga ficou órfão da nova. **OPS80 desativa a URL antiga primeiro, congelando a fonte** (nenhuma escrita nova). Este item roda DEPOIS da desativação: trazer para o banco novo o estado final da plataforma antiga, PRINCIPALMENTE a vertical campanha (lideranças, pledges, apoiadores, atividades, usuários da campanha, etc.) — para nenhuma operação de campanha viver só na plataforma antiga no momento em que ela for desligada.
## Persona e fluxo
- **Persona / contexto:** coordinator e staff de campanha que operam `/campanha` diariamente em janela eleitoral ativa; engenharia/ops responsável pelo desligamento da plataforma antiga.
- **Job principal:** nenhum dado de campanha — nem um pledge, apoiador, liderança, atividade ou usuário — viver só na plataforma antiga no momento do desligamento.
- **Fluxo desejado:** OPS80 desativa a URL antiga (fonte congelada, sem escritas novas) → este item captura o estado final da plataforma antiga → o residual é reconciliado e trazido para a nova → contagens conferem (nada órfão, nada duplicado) → `/campanha` segue operando só na plataforma nova, sem perda e sem regressão → a URL antiga permanece desligada.
- **Anti-goals de produto:** NÃO re-migrar tudo por desconfiança; NÃO duplicar municípios/seed nem dados públicos já sincronizados; NÃO virar "conserto" de divergências históricas; NÃO criar superfície nova de edição de dados (isso é planilha-modo).
## Objetivo e aceite
- Após o item, todos os dados de operação de campanha existentes na plataforma antiga existem e estão íntegros na nova — contagens conferem e nenhum registro ficou para trás.
- Nenhuma operação de campanha registrada na plataforma antiga (pledges, apoiadores, lideranças, atividades, demandas, decisões de alocação) precisa ser refeita manualmente na nova.
- Usuários da campanha continuam acessando `/campanha` na plataforma nova sem perda de acesso (senhas/sessões preservadas ou re-emitidas sem re-cadastro).
- Guardrails: dados nominais de cidadãos (PII/LGPD) só migram com o mesmo fail-closed do app (consentimento por chave estável); municípios/semeados não são duplicados; a vertical não regride (assimetria de votos, lockdown de liderança, leitura relativa/local preservadas).
## Dados (intenção)
- **Vou apresentar dados?** Não — migração move dados, não apresenta superfície de decisão.
- **Decisões desbloqueadas:** nada novo que dependa de um dashboard; o que importa é que decisões JÁ existentes continuem íntegras pós-migração: (a) conta da cadeira, (b) mapas e leitura territorial, (c) pledges com `declaredVotes` vs `estimatedVotes` e seus agregados devem refletir o MESMO estado que o time via na plataforma antiga.
- **Forma:** _adiada ao plano de implementação_ — aqui só a restrição de produto: o pós-migração precisa produzir a mesma leitura relativa/local que a plataforma antiga mostrava (sem "conserto" silencioso de números).
## Direção no codebase (hipótese)
- **Áreas prováveis:** coleções da vertical campanha (`src/collections/`) e a área de campanha (`src/app/(campaign)/…`, `src/utilities/access/*`); precedente de tooling de dados em `scripts/` (ex.: `scripts/db-pull.mjs`, `pnpm db:pull`).
- **Precedente a olhar:** OPS51 (dump Neon→homeserver, contagens por coleção), OPS58 (sincronização de posts públicos), OPS52 (media) — docs de ops em `docs/ops/` e `docs/plans/`.
- **Risco de acoplamento:** o executor deve respeitar as guardas de dev/test (nunca apontar dev/test para prod) e o fail-closed de consentimento/LGPD — esta migração é operação manual de infra em prod, fora dos guards de dev.
## Dependências
- **OPS80 (duro):** a desativação/congelamento da URL antiga vem ANTES — a fonte precisa estar quiescida para que nenhum dado novo entre entre a última migração e o desligamento.
- Nenhuma dependência suave.
## Fora de escopo
- Cutover de DNS/hospedagem de `pt.jorgesolla.com.br` (OPS80 desativa + OPS81 redireciona).
- Migração de media — já feita em OPS52 (Garage S3); referências antigas são exceção a registrar, não re-migração.
- Cancelamento da conta Neon (pós-OPS80).
- Dados públicos já sincronizados (ex.: posts — OPS58).
## Rabbit holes de produto
- **Merge de delta vs reconciliação limpa.** Se alguém "só completar" com o delta pós-OPS51, uma divergência silenciosa anterior continua escondida; se reconciliar tudo, vira re-migração completa. **Corte neste item:** reconciliar a vertical inteira uma última vez (contagens vs dump OPS51 como linha de base), com o delta como piso — sem re-migrar o que já está íntegro.
- **Copiar dados públicos duplicados.** Trazer posts/assinaturas/signatures de novo criaria duplicidade com o que já foi sincronizado. **Corte:** apenas a vertical campanha é o foco; o resto só se aparecer residual real e verificável.
- **"Consertar" divergências históricas de IDs.** Em vez de aceitar que a fonte de verdade é a nova, alguém pode tentar reconciliar históricos de IDs que já divergiram. **Corte:** nenhuma correção de IDs pós-fato; o aceite é integridade de dados de campanha, não identidade perfeita entre plataformas.
- **Scope creep para re-migrar tudo.** A desconfiança pós-OPS51 pode inflar o item para "re-fazer a migração inteira". **Corte:** escopo é o residual + reconciliação da vertical campanha; fora disso, OPS80 decide.
## Questões em aberto (produto)
- **Qual é a base real da plataforma antiga hoje (Vercel ainda aponta para o Neon)?** **Opções:** (a) Neon — assumir que toda escrita pós-OPS51 foi no Neon e migrar esse residual; (b) outra base — a plataforma antiga já foi repontada e o gap é outro; (c) plataforma antiga inativa desde o OPS51. **Recomendação:** verificação viva (read-only, escrita de teste e leitura de volta em ambas as plataformas) ANTES do cutoff de OPS80 — sem essa verificação, a migração pode estar varrendo o banco errado. A cópia em si roda depois, sobre a fonte congelada. _(assumido — validar com engenharia no momento do desligamento)_
- **Migrar só o delta pós-OPS51 ou reconciliar a vertical inteira?** **Opções:** (a) só delta — rápido, mas deixa divergência silenciosa possível; (b) reconciliar a vertical inteira — mais demorado, mas fecha o risco de dado órfão anterior ao OPS51; (c) delta + reconciliação por contagem. **Recomendação:** (c) — reconciliar por contagem (a mesma verificação do OPS51) com o delta como objeto da cópia. É o único caminho que dá o aceite "nada órfão, nada duplicado" sem re-migrar tudo.
- **O que fazer com senhas/sessões de usuários da campanha?** **Opções:** (a) preservar hashes e sessões como estão — nenhuma interrupção no meio da janela eleitoral; (b) exigir re-login após a migração; (c) re-emitir convites. **Recomendação:** (a) — campanha em janela eleitoral ativa; forçar re-login em massa seria a pior hora para interromper operação. Se a base de origem não for confiável para credenciais, cair em (b), nunca em (c).
- **Referências a media antiga em registros migrados?** **Opções:** (a) migrar apontando para o objeto já no Garage S3; (b) manter URL antiga e registrar exceção; (c) quebrar a referência. **Recomendação:** (a) quando houver equivalente já migrado (OPS52), (b) como exceção registrada caso contrário — nunca (c), imagem quebrada é regressão visível.
## Referências
- GitHub Issue #— (após `pnpm agent:register`)
- OPS51 (migração Neon→homeserver, contagens por coleção) e seu rollback documentado em `docs/ops/teqo-1313-deploy.md`
- OPS52 (media → Garage S3), OPS58 (sincronização de posts públicos), OPS80 (desativa a URL antiga — pré-requisito que congela a fonte), OPS81 (redireciona a URL antiga — roda após esta migração)
- `AGENTS.md` / `AGENTS-campaign.md` — vertical campanha, LGPD (fail-closed de consentimento), guardas de dev/test

View file

@ -33,6 +33,8 @@
"media:recover": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/recover-media.mjs",
"db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs",
"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",
"agent:claim": "node scripts/agent-claim.mjs",
"agent:register": "node scripts/agent-register.mjs",
"agent:ready": "node scripts/agent-ready.mjs",

View file

@ -0,0 +1,283 @@
/**
* OPS79 — migrate the residual public write orphaned on the old platform (Neon)
* into the new platform (`teqo_1313`).
*
* Verified residual (2026-08-23, read-only recon): the campaign vertical is
* already 100% in sync after OPS51. The only orphaned record is the public
* petition signature id 1486 — with its creator contact and subscription, which
* `submitPetitionSignature` writes together in one transaction and which never
* reached the target (the target's contact id 2221 is a DIFFERENT person, Jorge
* Solla; the orphan's creator — Juares Lagimar de Souza, jlagimar@gmail.com —
* is absent from the target).
*
* What this script does:
* - READS from Neon (read-only) the signature 1486 + its contact + phones +
* subscription.
* - FAILS CLOSED on ambiguous/missing referents in the target: petition
* 'fim-escala-6x1' must exist, consent id 2 ('whatsapp-inscricao') must
* resolve by the same key, and the target must NOT already contain the same
* contact email. It NEVER reuses the target's contact id 2221 (different
* person) — the orphan contact is created with a NEW id.
* - Inserts into the target in ONE transaction: contact (new id), its
* phone row, signature (preserving id 1486 → id-join stays green), and the
* subscription (new id); then bumps the affected sequences.
*
* Safety model (mirrors recover-media.mjs):
* - `--apply` ESCREVE em produção e exige a flag explícita OPS79_MIGRATE_CONFIRM=1.
* - `--dry-run` planeja e valida SEM escrever (sem flag de confirmação).
* - Echo do alvo (Neon host, target host, ids planejados) antes de qualquer
* trabalho. PII (nome/email/telefone/comentário) é exibida só mascarada.
* - Deve rodar 100% no homeserver (PII nunca sai dele). Ver runbook em
* docs/ops/teqo-1313-deploy.md.
*
* Usage (no homeserver, com o env do stack):
* NEON_DATABASE_URL=postgres://… DATABASE_URL=postgres://… pnpm ops79:migrate --dry-run
* OPS79_MIGRATE_CONFIRM=1 NEON_DATABASE_URL=… DATABASE_URL=… pnpm ops79:migrate --apply
*/
import { randomUUID } from 'node:crypto'
import { Client } from 'pg'
import { dieWithLabel, isTruthyEnv, loadCliEnv } from './lib/cli.mjs'
const die = dieWithLabel('ops79:migrate')
loadCliEnv()
const CONFIRM_FLAG = 'OPS79_MIGRATE_CONFIRM'
const args = new Set(process.argv.slice(2))
const unknown = [...args].filter((a) => a !== '--dry-run' && a !== '--apply')
if (unknown.length > 0) die(`argumento desconhecido: ${unknown.join(', ')}`)
if (args.size > 1) die('modos são mutuamente exclusivos: use apenas um de --dry-run / --apply.')
const mode = args.has('--apply') ? 'apply' : 'dry-run'
if (mode === 'apply' && !isTruthyEnv(process.env[CONFIRM_FLAG])) {
die(
'modo --apply ESCREVE em produção — exige confirmação explícita de intenção.\n' +
` Re-rodar com: ${CONFIRM_FLAG}=1 pnpm ops79:migrate --apply\n` +
' (ou use --dry-run para planejar e validar sem escrever).',
)
}
const NEON_DATABASE_URL = process.env.NEON_DATABASE_URL
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).')
const ORPHAN_SIGNATURE_ID = 1486
const mask = (s) => (s === null || s === undefined ? '' : `…${String(s).slice(-4)}`)
const maskEmail = (s) =>
s === null || s === undefined ? '' : `${s.split('@')[0].slice(0, 2)}…@${s.split('@')[1] ?? ''}`
const connectReadOnly = async (url) => {
const c = new Client({ connectionString: url, connectionTimeoutMillis: 15_000 })
await c.connect()
await c.query('SET default_transaction_read_only = on')
await c.query('SET statement_timeout = 30000')
return c
}
const connectWritable = async (url) => {
const c = new Client({ connectionString: url, connectionTimeoutMillis: 15_000 })
await c.connect()
return c
}
async function readOrphan(neon) {
const sig = await neon.query('SELECT * FROM signature WHERE id = $1', [ORPHAN_SIGNATURE_ID])
if (sig.rows.length !== 1) die(`signature ${ORPHAN_SIGNATURE_ID} não encontrada na origem.`)
const s = sig.rows[0]
const contact = await neon.query('SELECT * FROM contact WHERE id = $1', [s.contact_id])
const c = contact.rows[0]
if (!c) die(`contato ${s.contact_id} da signature ${ORPHAN_SIGNATURE_ID} não existe na origem.`)
const phones = await neon.query(
'SELECT _order, value FROM contact_phones WHERE _parent_id = $1 ORDER BY _order',
[s.contact_id],
)
const sub = await neon.query('SELECT * FROM subscription WHERE contact_id = $1', [s.contact_id])
// Source consent key (fail-closed check on the target side).
const consent = await neon.query('SELECT id, "key" FROM consent WHERE id = $1', [s.consent_id])
return {
signature: { ...s, consent_key: consent.rows[0]?.key ?? null },
contact: c,
phones: phones.rows,
subscription: sub.rows[0] ?? null,
}
}
async function validateTarget(target, orphan) {
const errors = []
const petition = await target.query('SELECT id, form_consent_id FROM petition WHERE id = $1', [
orphan.signature.petition_id,
])
if (petition.rows.length !== 1)
errors.push(`petition '${orphan.signature.petition_id}' ausente no target`)
else if (Number(petition.rows[0].form_consent_id) !== Number(orphan.signature.consent_id))
errors.push(
`consent do petition no target diverge do consent da signature origem ` +
`(${petition.rows[0].form_consent_id} vs ${orphan.signature.consent_id})`,
)
// Fail-closed on consent meaning, not just id presence: the target must hold a
// consent with the SAME id AND the same stable key as the source's. If the
// source row's key is read, we enforce it; otherwise we only enforce id
// presence (a re-seeded DB could legitimately differ).
const srcKey =
orphan.signature.consent_key !== undefined && orphan.signature.consent_key !== null
? orphan.signature.consent_key
: null
const consent = await target.query('SELECT id, "key" FROM consent WHERE id = $1', [
orphan.signature.consent_id,
])
if (consent.rows.length !== 1) {
errors.push(`consent ${orphan.signature.consent_id} ausente no target`)
} else if (srcKey !== null && consent.rows[0].key !== srcKey) {
errors.push(
`consent ${orphan.signature.consent_id} no target tem key '${consent.rows[0].key}' ` +
`mas a origem tem '${srcKey}'`,
)
}
const collision = await target.query('SELECT id FROM contact WHERE email = $1', [
orphan.contact.email,
])
if (collision.rows.length > 0)
errors.push(
`contato com email ${maskEmail(orphan.contact.email)} JÁ existe no target (id ${collision.rows[0].id})`,
)
const sigFree = await target.query('SELECT 1 FROM signature WHERE id = $1', [ORPHAN_SIGNATURE_ID])
if (sigFree.rows.length > 0)
errors.push(`signature id ${ORPHAN_SIGNATURE_ID} já ocupado no target`)
if (errors.length > 0) die(`precondições do target falharam:\n - ${errors.join('\n - ')}`)
return {
petitionId: orphan.signature.petition_id,
consentId: Number(orphan.signature.consent_id),
}
}
async function main() {
console.log(`[ops79:migrate] Neon (origem) : ${new URL(NEON_DATABASE_URL).host}`)
console.log(`[ops79:migrate] Target (destino): ${new URL(TARGET_DATABASE_URL).host}`)
console.log(`[ops79:migrate] Modo: ${mode}`)
const neon = await connectReadOnly(NEON_DATABASE_URL)
const orphan = await readOrphan(neon)
await neon.end()
const target = await connectWritable(TARGET_DATABASE_URL)
const refs = await validateTarget(target, orphan)
const srcSub = orphan.subscription
const newContactId = (await target.query('SELECT COALESCE(max(id), 0) + 1 AS next FROM contact'))
.rows[0].next
const newSubId = srcSub
? (await target.query('SELECT COALESCE(max(id), 0) + 1 AS next FROM subscription')).rows[0].next
: null
console.log(`\n[ops79:migrate] PLANO (${mode}):`)
console.log(
` contact (novo id ${newContactId}): ${mask(orphan.contact.name)} ${maskEmail(orphan.contact.email)} BA/${orphan.contact.city ?? ''} cep ${mask(orphan.contact.postal_code ?? '')} phones=${orphan.phones.length}`,
)
console.log(
` signature (id ${ORPHAN_SIGNATURE_ID} preservado): petition=${refs.petitionId} consent=${refs.consentId} comment(${orphan.signature.comment?.length ?? 0} caracteres)`,
)
if (srcSub) console.log(` subscription (novo id ${newSubId}): consent=${refs.consentId}`)
if (mode === 'dry-run') {
console.log(
`\n[ops79:migrate] dry-run OK — nenhuma escrita. Para aplicar: ${CONFIRM_FLAG}=1 pnpm ops79:migrate --apply`,
)
await target.end()
process.exit(0)
}
try {
await target.query('BEGIN')
await target.query(
`INSERT INTO contact (id, name, email, gender, state, city, postal_code, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
newContactId,
orphan.contact.name,
orphan.contact.email,
orphan.contact.gender,
orphan.contact.state,
orphan.contact.city,
orphan.contact.postal_code,
orphan.contact.created_at,
orphan.contact.updated_at,
],
)
for (const phone of orphan.phones) {
await target.query(
`INSERT INTO contact_phones (id, _parent_id, _order, value)
VALUES ($1, $2, $3, $4)`,
[randomUUID(), newContactId, phone._order ?? 0, phone.value],
)
}
await target.query(
`INSERT INTO signature (id, contact_id, petition_id, consent_id, comment, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
ORPHAN_SIGNATURE_ID,
newContactId,
refs.petitionId,
refs.consentId,
orphan.signature.comment,
orphan.signature.created_at,
orphan.signature.updated_at,
],
)
if (srcSub) {
await target.query(
`INSERT INTO subscription (id, contact_id, consent_id, comment, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
newSubId,
newContactId,
refs.consentId,
srcSub.comment,
srcSub.created_at,
srcSub.updated_at,
],
)
}
// Bring sequences past the explicit ids so future Payload inserts don't collide.
// `true` = the value IS already counted, so the next nextval() is id+1.
await target.query(`SELECT setval('contact_id_seq', $1, true)`, [newContactId])
await target.query(`SELECT setval('signature_id_seq', $1, true)`, [ORPHAN_SIGNATURE_ID])
if (newSubId) await target.query(`SELECT setval('subscription_id_seq', $1, true)`, [newSubId])
await target.query('COMMIT')
console.log(
`\n[ops79:migrate] OK — aplicado. contact=${newContactId}, signature=${ORPHAN_SIGNATURE_ID}, phones=${orphan.phones.length}, subscription=${srcSub ? newSubId : 'não'}.`,
)
console.log(
'[ops79:migrate] Verificar depois com: pnpm ops79:reconcile (vertical diff 0; ' +
'signature/contact contagens sobem conforme o plano).',
)
} catch (err) {
await target.query('ROLLBACK').catch(() => {})
throw err
} finally {
await target.end()
}
process.exit(0)
}
main().catch((err) => {
die(err?.message || String(err))
})

View file

@ -0,0 +1,173 @@
/**
* OPS79 — read-only reconciliation of the campaign vertical between the old
* platform (Neon, frozen by OPS80) and the new platform (`teqo_1313`).
*
* Purpose: prove "nada órfão, nada duplicado" for the campaign vertical after
* the OPS51 dump/restore. Compares counts AND id-sets of the 13 campaign
* collections, plus the semantic content of the relationship join tables
* (municipality_rels / leadership_rels / activity_rels / organization_rels /
* state_deputy_rels / campaign_demand_rels) by (parent, path, child).
*
* 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.
* - 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 —
* see the OPS79 runbook in docs/ops/teqo-1313-deploy.md).
*
* Usage:
* NEON_DATABASE_URL=postgres://... DATABASE_URL=postgres://... \
* pnpm ops79:reconcile
*/
import { Client } from 'pg'
import { dieWithLabel, loadCliEnv } from './lib/cli.mjs'
const die = dieWithLabel('ops79:reconcile')
loadCliEnv()
const NEON_DATABASE_URL = process.env.NEON_DATABASE_URL
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).')
// Campaign vertical collections (id-bearing, content tables).
const CORE_TABLES = [
'campaign_user',
'leadership',
'vote_pledge',
'activity',
'municipality',
'organization',
'allocation_decision',
'municipality_update',
'state_deputy',
'election_tally',
'campaign_web_authn_credential',
'supporter',
'campaign_demand',
]
// Relationship join tables compared semantically (parent, path, child).
const REL_TABLES = [
'municipality_rels',
'leadership_rels',
'activity_rels',
'organization_rels',
'state_deputy_rels',
'campaign_demand_rels',
]
// Known non-data divergences absorbed by OPS79 (verified read-only on
// 2026-08-23). They reflect PORTFOLIO EDITS made on the new platform after
// OPS51 (municipality 122 gained advisor 5 and lost stateDeputies 53/58 — a
// legitimate post-migration edit, not orphaned data). Anything outside this
// explicit set still fails the reconcile.
const ABSOLVED_MUNICIPALITY_RELS = new Set([
'122|stateDeputies|53',
'122|stateDeputies|58',
'122|advisors|5',
])
const results = []
const report = (name, ok, detail) => {
results.push({ name, ok, detail })
console.log(`${ok ? ' PASS' : ' FAIL'} ${name}: ${detail}`)
}
const connectReadOnly = async (url) => {
const client = new Client({ connectionString: url, connectionTimeoutMillis: 15_000 })
await client.connect()
await client.query('SET default_transaction_read_only = on')
await client.query('SET statement_timeout = 30000')
return client
}
const fetchIds = async (client, table) => {
const res = await client.query(`SELECT id FROM "${table}" ORDER BY id`)
return res.rows.map((r) => String(r.id))
}
// Joins each rel row to its child id. Payload rel tables use a per-parent
// 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
// column set is introspected per table (they differ: municipality_rels has
// campaign_user_id/state_deputy_id, leadership_rels has municipalities_id, …).
async function fetchRels(client, table) {
const { rows: cols } = await client.query(
`SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1 AND column_name LIKE '%\\_id'`,
[table],
)
const childCandidates = cols
.map((c) => c.column_name)
.filter((c) => !['parent_id'].includes(c))
.map((c) => `"${c}"::text`)
if (childCandidates.length === 0) throw new Error(`sem colunas *_id em ${table}`)
const childExpr = `COALESCE(${childCandidates.join(', ')}, '')`
const res = await client.query(
`SELECT parent_id::text AS parent, path, ${childExpr} AS child
FROM "${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`)
for (const table of CORE_TABLES) {
const neonIds = await fetchIds(neon, table)
const targetIds = await fetchIds(target, table)
const neonSet = new Set(neonIds)
const targetSet = new Set(targetIds)
const neonOnly = neonIds.filter((id) => !targetSet.has(id))
const targetOnly = targetIds.filter((id) => !neonSet.has(id))
report(
table,
neonOnly.length === 0 && targetOnly.length === 0,
`counts ${neonIds.length}=${targetIds.length}; neon_only=${neonOnly.length} target_only=${targetOnly.length}`,
)
}
for (const table of REL_TABLES) {
const neonRels = await fetchRels(neon, table)
const targetRels = await fetchRels(target, table)
const neonSet = new Set(neonRels)
const targetSet = new Set(targetRels)
const absolved = table === 'municipality_rels' ? ABSOLVED_MUNICIPALITY_RELS : new Set()
const neonOnly = neonRels.filter((r) => !targetSet.has(r) && !absolved.has(r))
const targetOnly = targetRels.filter((r) => !neonSet.has(r) && !absolved.has(r))
const absolvedHits = [...absolved].filter((r) => neonSet.has(r) || targetSet.has(r)).length
const ok = neonOnly.length === 0 && targetOnly.length === 0
const detail = `rels ${neonRels.length}=${targetRels.length}; neon_only=${neonOnly.length} target_only=${targetOnly.length}`
report(
table,
ok,
absolvedHits > 0 ? `${detail} (+${absolvedHits} absorvida pós-OPS51)` : detail,
)
}
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) => {
die(err?.message || String(err))
})