teqo-backup/scripts/migrate-signature-orphan.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

283 lines
11 KiB
JavaScript

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