teqo-backup/scripts/reconcile-campaign-vertical.mjs
Francisco Solla bddbd5c085 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
2026-08-23 22:45:43 -03:00

173 lines
6.5 KiB
JavaScript

/**
* 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))
})