#!/usr/bin/env node /** * ADR-0009 Phase 2: Test-Data Cleanup — DRY-RUN manifest generator. * * Default mode: DRY-RUN. No DELETE, DROP, or UPDATE is issued. * Writes a snapshot directory scripts/snapshots/cleanup-/ containing CSVs * of every row that WOULD be deleted, plus a MANIFEST.md summary. * * Usage: * node scripts/cleanup-test-data.js # dry-run (default) * node scripts/cleanup-test-data.js --execute # destructive — NOT in Phase 2 * * Safety guards (see ADR-0009 §6 Constraints): * 1. Refuses to run --execute without --target=prod AND --owner-totp=XXXXXX. * 2. Refuses to run --execute if POSTGRES_HOST is not localhost (running * the script against a remote PROD DB is not permitted — must be on the * host, where localhost==PROD). * 3. The allow-list (scripts/cleanup-allowlist.json) MUST have * owner_signed: true for --execute to proceed. * * Allow-list semantics: * - Any id present in allowlist.users[].id is NEVER deleted. * - Any id in allowlist.spaces[].id is NEVER deleted. * - Any id in allowlist.tables[].id is NEVER deleted (and its table_rows kept). * - False positive in allow-list is safe; false negative risks deleting * real user data. When in doubt, include. */ import fs from 'node:fs'; import path from 'node:path'; import pg from 'pg'; import dotenv from 'dotenv'; dotenv.config({ path: path.resolve(process.cwd(), '.env') }); // ---------- argv ---------- const argv = process.argv.slice(2); const hasFlag = (f) => argv.includes(f); const getFlag = (f) => { const p = argv.find((a) => a.startsWith(f + '=')); return p ? p.slice(f.length + 1) : null; }; const EXECUTE = hasFlag('--execute'); const ROLLBACK = hasFlag('--rollback'); // Phase 3 safety: run deletes, capture counts, then ROLLBACK. const TARGET = getFlag('--target'); const OWNER_TOTP = getFlag('--owner-totp'); // ---------- safety ---------- const POSTGRES_HOST = process.env.POSTGRES_HOST || 'localhost'; if (EXECUTE) { if (!['prod', 'dev'].includes(TARGET)) { console.error('[FATAL] --execute requires --target=prod or --target=dev (safety gate).'); process.exit(2); } if (TARGET === 'prod' && !OWNER_TOTP) { console.error('[FATAL] --execute --target=prod requires --owner-totp=XXXXXX (owner signs off at runtime).'); process.exit(2); } if (!['localhost', '127.0.0.1', ''].includes(POSTGRES_HOST)) { console.error(`[FATAL] Refusing to --execute against remote host ${POSTGRES_HOST}.`); console.error(` For --target=dev run on DEV server with POSTGRES_HOST=localhost (DEV-local godcrm_prod copy).`); console.error(` For --target=prod run on PROD server (.205) where localhost==PROD.`); process.exit(2); } // ADR-0009 §9: PROD gate lifted 2026-04-22 after DEV Phase 3 succeeded and // owner signed off at runtime (owner_signed=true, owner-totp provided, MANIFEST approved). // Safety: allow-list + localhost + owner_signed still enforced further down. } // ---------- allow-list ---------- const ALLOWLIST_PATH = path.resolve(process.cwd(), 'scripts/cleanup-allowlist.json'); if (!fs.existsSync(ALLOWLIST_PATH)) { console.error(`[FATAL] Missing ${ALLOWLIST_PATH}. Generate it first (see ADR-0009 §9 Phase 2).`); process.exit(2); } const allowlist = JSON.parse(fs.readFileSync(ALLOWLIST_PATH, 'utf-8')); const ALLOW_USER_IDS = new Set(allowlist.users.map((u) => u.id)); const ALLOW_SPACE_IDS = new Set(allowlist.spaces.map((s) => s.id)); const ALLOW_TABLE_IDS = new Set(allowlist.tables.map((t) => t.id)); // ---------- db ---------- const pool = new pg.Pool({ host: POSTGRES_HOST, port: Number(process.env.POSTGRES_PORT || 5432), database: process.env.POSTGRES_DB || 'godcrm_prod', user: process.env.POSTGRES_USER || 'godcrm', password: process.env.POSTGRES_PASSWORD, max: 4, }); async function q(sql, params = []) { const r = await pool.query(sql, params); return r.rows; } // ---------- test signatures (ADR-0009 §4.1) ---------- // Users: email matches any of these patterns OR name matches. // NOTE: regex semantics match PostgreSQL `~*` (case-insensitive regex). const TEST_EMAIL_RX = '@test\\.com|@example\\.com|^test-'; const TEST_NAME_RX = '^test-.*-[0-9]{10,}$|^tables-test-'; // Agents/service users are ALWAYS protected regardless of signature match. // This is enforced by pre-populating the allow-list with every agent/service user. // ---------- snapshot dir ---------- const ts = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-'); const snapDir = path.resolve(process.cwd(), `scripts/snapshots/cleanup-${ts}`); fs.mkdirSync(snapDir, { recursive: true }); console.log(`[ok] snapshot dir: ${snapDir}`); function writeCsv(filename, columns, rows) { const csvEsc = (v) => { if (v === null || v === undefined) return ''; const s = String(v); if (s.includes(',') || s.includes('"') || s.includes('\n')) { return '"' + s.replace(/"/g, '""') + '"'; } return s; }; const header = columns.join(','); const body = rows.map((r) => columns.map((c) => csvEsc(r[c])).join(',')).join('\n'); const content = rows.length ? header + '\n' + body + '\n' : header + '\n'; fs.writeFileSync(path.join(snapDir, filename), content); return rows.length; } // ---------- main ---------- async function main() { console.log(`\n=== ADR-0009 Phase 2 cleanup DRY-RUN ===`); console.log(`allow-list: ${ALLOW_USER_IDS.size} users, ${ALLOW_SPACE_IDS.size} spaces, ${ALLOW_TABLE_IDS.size} tables`); console.log(`owner_signed: ${allowlist.owner_signed}`); // --- 1. Candidate users ------------------------------------------------ // A user is a DELETE candidate if: // (a) email OR name matches test-signature regex, AND // (b) id is NOT in allow-list // Agents/service are automatically in allow-list, so they are filtered out by (b). const userRows = await q(` SELECT id, email, name, user_type, role, status, created_at FROM users WHERE (email ~* $1 OR name ~* $2) `, [TEST_EMAIL_RX, TEST_NAME_RX]); const deleteUsers = userRows.filter((u) => !ALLOW_USER_IDS.has(u.id)); const skippedUsers = userRows.filter((u) => ALLOW_USER_IDS.has(u.id)); const deleteUserIds = new Set(deleteUsers.map((u) => u.id)); writeCsv('users.csv', ['id', 'email', 'name', 'user_type', 'role', 'status', 'created_at'], deleteUsers, ); console.log(`[users] candidates=${deleteUsers.length}, skipped-by-allowlist=${skippedUsers.length}`); // --- 2. Candidate spaces ---------------------------------------------- // Spaces owned by a candidate user, MINUS allow-list spaces. let deleteSpaces = []; let deleteSpaceIds = new Set(); if (deleteUserIds.size > 0) { const rows = await q( `SELECT id, owner_id, name, type, created_at FROM spaces WHERE owner_id = ANY($1::int[])`, [[...deleteUserIds]], ); deleteSpaces = rows.filter((s) => !ALLOW_SPACE_IDS.has(s.id)); deleteSpaceIds = new Set(deleteSpaces.map((s) => s.id)); } writeCsv('spaces.csv', ['id', 'owner_id', 'name', 'type', 'created_at'], deleteSpaces, ); console.log(`[spaces] candidates=${deleteSpaces.length}`); // --- 3. Candidate projects -------------------------------------------- // A project is a candidate if its space is a candidate OR its owner is a candidate user. let deleteProjects = []; let deleteProjectIds = new Set(); const projCond = []; const projParams = []; if (deleteSpaceIds.size > 0) { projCond.push(`space_id = ANY($${projParams.length + 1}::int[])`); projParams.push([...deleteSpaceIds]); } if (deleteUserIds.size > 0) { projCond.push(`owner_id = ANY($${projParams.length + 1}::int[])`); projParams.push([...deleteUserIds]); } if (projCond.length) { deleteProjects = await q( `SELECT id, name, space_id, owner_id, type, created_at FROM projects WHERE ${projCond.join(' OR ')}`, projParams, ); deleteProjectIds = new Set(deleteProjects.map((p) => p.id)); } writeCsv('projects.csv', ['id', 'name', 'space_id', 'owner_id', 'type', 'created_at'], deleteProjects, ); console.log(`[projects] candidates=${deleteProjects.length}`); // --- 4. Candidate universal_tables ------------------------------------ // A table is a candidate if its project is a candidate OR its created_by is a candidate user. // allow-list tables always kept. let deleteTables = []; let deleteTableIds = new Set(); const tblCond = []; const tblParams = []; if (deleteProjectIds.size > 0) { tblCond.push(`project_id = ANY($${tblParams.length + 1}::int[])`); tblParams.push([...deleteProjectIds]); } if (deleteUserIds.size > 0) { tblCond.push(`created_by = ANY($${tblParams.length + 1}::int[])`); tblParams.push([...deleteUserIds]); } if (tblCond.length) { const rows = await q( `SELECT id, name, project_id, created_by, created_at FROM universal_tables WHERE ${tblCond.join(' OR ')}`, tblParams, ); deleteTables = rows.filter((t) => !ALLOW_TABLE_IDS.has(t.id)); deleteTableIds = new Set(deleteTables.map((t) => t.id)); } writeCsv('universal_tables.csv', ['id', 'name', 'project_id', 'created_by', 'created_at'], deleteTables, ); console.log(`[universal_tables] candidates=${deleteTables.length}`); // Check for physical per-table PG tables (table_). None exist in current // schema, but keep the check so Phase 3 rescan catches any that appear. const physicalTables = deleteTableIds.size ? await q( `SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_name ~ '^table_[0-9]+$' AND (regexp_replace(table_name,'^table_',''))::int = ANY($1::int[])`, [[...deleteTableIds]], ) : []; writeCsv('physical_tables_to_drop.csv', ['table_name'], physicalTables, ); console.log(`[physical table_] to drop: ${physicalTables.length}`); // --- 5. table_rows summary (per-table counts) ------------------------- let rowSummary = []; let rowTotal = 0; if (deleteTableIds.size > 0) { rowSummary = await q( `SELECT table_id, COUNT(*) AS row_count FROM table_rows WHERE table_id = ANY($1::int[]) GROUP BY table_id ORDER BY row_count DESC`, [[...deleteTableIds]], ); rowTotal = rowSummary.reduce((s, r) => s + Number(r.row_count), 0); } writeCsv('table_rows_summary.csv', ['table_id', 'row_count'], rowSummary, ); console.log(`[table_rows] candidate rows across ${rowSummary.length} tables: ${rowTotal}`); // --- 6. Dependent FK-by-convention tables ----------------------------- // For each table that references users or spaces by convention, capture // rows that would orphan. See FK inventory in ADR-0009 §11 Appendix B. const dep = []; // spaces-referencing const spaceChildren = [ { table: 'calendar_events', cols: ['id', 'space_id', 'created_by', 'title', 'start_at'], where: 'space_id' }, { table: 'modules', cols: ['id', 'space_id', 'name'], where: 'space_id' }, { table: 'widget_library', cols: ['id', 'space_id', 'name'], where: 'space_id' }, { table: 'fitness_workouts', cols: ['id', 'space_id'], where: 'space_id' }, { table: 'fitness_workout_sets', cols: ['id', 'space_id'], where: 'space_id' }, { table: 'fitness_exercises', cols: ['id', 'space_id'], where: 'space_id' }, { table: 'wellness_profiles', cols: ['id', 'space_id'], where: 'space_id' }, { table: 'wellness_points', cols: ['id', 'space_id'], where: 'space_id' }, { table: 'wellness_levels', cols: ['id', 'space_id'], where: 'space_id' }, { table: 'wellness_streaks', cols: ['id', 'space_id'], where: 'space_id' }, { table: 'wellness_vitals', cols: ['id', 'space_id'], where: 'space_id' }, { table: 'wellness_user_achievements', cols: ['id', 'space_id'], where: 'space_id' }, { table: 'labs', cols: ['id', 'space_id', 'name'], where: 'space_id' }, { table: 'schema_layouts', cols: ['id', 'space_id'], where: 'space_id' }, { table: 'space_invitations', cols: ['id', 'space_id'], where: 'space_id' }, { table: 'conversations', cols: ['id', 'space_id', 'created_by'], where: 'space_id' }, { table: 'files', cols: ['id', 'space_id', 'project_id', 'table_id'], where: 'space_id' }, { table: 'dashboards', cols: ['id', 'space_id', 'project_id', 'user_id'], where: 'space_id' }, { table: 'user_access_permissions', cols: ['id','user_id','space_id','project_id','table_id'], where: 'space_id' }, ]; // users-referencing const userChildren = [ { table: 'agent_jobs', cols: ['id', 'agent_user_id', 'status'], where: 'agent_user_id' }, { table: 'terminal_sessions', cols: ['id', 'user_id'], where: 'user_id' }, { table: 'terminal_commands', cols: ['id'], where: 'user_id', skipIfNoCol: true }, { table: 'tool_approval_rules', cols: ['id', 'created_by'], where: 'created_by' }, { table: 'api_keys', cols: ['id', 'user_id', 'project_id'], where: 'user_id' }, { table: 'audit_log', cols: ['id', 'user_id'], where: 'user_id' }, { table: 'chat_participants', cols: ['user_id', 'conversation_id'], where: 'user_id', noId: true }, { table: 'conversation_participants', cols: ['user_id', 'conversation_id'], where: 'user_id', noId: true }, { table: 'message_reactions', cols: ['id', 'user_id'], where: 'user_id' }, { table: 'monitoring_runs', cols: ['id', 'user_id'], where: 'user_id' }, { table: 'monitoring_threads', cols: ['id', 'user_id'], where: 'user_id' }, { table: 'oidc_access_tokens', cols: ['id', 'user_id'], where: 'user_id' }, { table: 'oidc_auth_codes', cols: ['id', 'user_id'], where: 'user_id' }, { table: 'user_settings', cols: ['user_id'], where: 'user_id', noId: true }, { table: 'user_widget_favorites', cols: ['user_id', 'widget_id'], where: 'user_id', noId: true }, { table: 'user_widget_history', cols: ['user_id', 'widget_id'], where: 'user_id', noId: true }, { table: 'wa_auth_tokens', cols: ['id', 'user_id'], where: 'user_id' }, { table: 'wa_presence', cols: ['user_id'], where: 'user_id', noId: true }, ]; // For robustness: look up actual columns on the target table and only select // columns from `cols` that actually exist. If the target WHERE column is // absent, skip the table with a note. const runChild = async ({ table, cols, where }, idSet, label) => { // First: does table even exist? const tableCheck = await q( `SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=$1`, [table], ); if (!tableCheck.length) { writeCsv(`${table}.csv`, cols, []); dep.push({ table, parent: label, count: 0, note: `table not found — skipped` }); return; } const colRows = await q( `SELECT column_name, data_type FROM information_schema.columns WHERE table_schema='public' AND table_name=$1`, [table], ); const actualCols = new Set(colRows.map((r) => r.column_name)); if (!actualCols.has(where)) { writeCsv(`${table}.csv`, cols, []); dep.push({ table, parent: label, count: 0, note: `column ${where} not found — skipped` }); return; } if (idSet.size === 0) { writeCsv(`${table}.csv`, cols, []); dep.push({ table, parent: label, count: 0 }); return; } const selectCols = cols.filter((c) => actualCols.has(c)); if (selectCols.length === 0) selectCols.push(where); // Guard against text-typed id columns (monitoring_runs.user_id is text, not int). const whereDt = colRows.find((r) => r.column_name === where)?.data_type; const isIntCol = /^(integer|bigint|smallint)$/i.test(whereDt || ''); const castClause = isIntCol ? `${where} = ANY($1::int[])` : `${where} = ANY($1::text[])`; const param = isIntCol ? [...idSet] : [...idSet].map(String); const rows = await q( `SELECT ${selectCols.join(', ')} FROM ${table} WHERE ${castClause}`, [param], ); writeCsv(`${table}.csv`, selectCols, rows); const note = cols.filter((c) => !actualCols.has(c)); dep.push({ table, parent: label, count: rows.length, note: note.length ? `missing cols: ${note.join(',')}` : '' }); }; console.log('\n[dependents: spaces →]'); for (const sc of spaceChildren) { await runChild(sc, deleteSpaceIds, 'spaces'); const d = dep[dep.length - 1]; if (d.count > 0) console.log(` ${d.table}: ${d.count}${d.note ? ' (' + d.note + ')' : ''}`); } console.log('\n[dependents: users →]'); for (const uc of userChildren) { await runChild(uc, deleteUserIds, 'users'); const d = dep[dep.length - 1]; if (d.count > 0) console.log(` ${d.table}: ${d.count}${d.note ? ' (' + d.note + ')' : ''}`); } // --- 6b. "Orphan-suspect" users NOT covered by test signature ---------- // The ADR signature does not match certain test-ish patterns that still // exist in PROD (e.g. register-@hltrn.cc stress-test registrations). // These are NOT candidates for deletion in Phase 2, but the owner should // inspect them before Phase 3 and decide whether to extend the regex. const orphanSuspects = await q(` SELECT id, email, name, user_type, created_at, (SELECT COUNT(*) FROM spaces s WHERE s.owner_id=u.id) AS spaces_owned, (SELECT COUNT(*) FROM projects p WHERE p.owner_id=u.id) AS projects_owned FROM users u WHERE NOT (email ~* $1 OR name ~* $2) AND (email ~ '^register-[0-9]{10,}@hltrn\\.cc$' OR name = 'New User' OR email ~* '\\+test[0-9]*@' OR email ~* 'qa[0-9]+@' ) ORDER BY id `, [TEST_EMAIL_RX, TEST_NAME_RX]); writeCsv('orphan_suspects.csv', ['id', 'email', 'name', 'user_type', 'created_at', 'spaces_owned', 'projects_owned'], orphanSuspects, ); // --- 7. Shape-unexpected warnings ------------------------------------- // A "test" user should have empty or near-empty downstream footprint. // Surface any candidate user with > 10 calendar_events or > 10 audit_log rows. const weird = await q( `SELECT u.id, u.email, u.name, (SELECT COUNT(*) FROM audit_log a WHERE a.user_id=u.id) AS audits, (SELECT COUNT(*) FROM dashboards d WHERE d.user_id=u.id) AS dashboards, (SELECT COUNT(*) FROM spaces s WHERE s.owner_id=u.id) AS spaces_owned FROM users u WHERE u.id = ANY($1::int[]) ORDER BY ( (SELECT COUNT(*) FROM audit_log a WHERE a.user_id=u.id) + (SELECT COUNT(*) FROM dashboards d WHERE d.user_id=u.id) ) DESC LIMIT 20`, [[...deleteUserIds]], ); writeCsv('weird_candidates.csv', ['id', 'email', 'name', 'audits', 'dashboards', 'spaces_owned'], weird, ); // --- 8. pg_total_relation_size estimate ------------------------------- const sizeRows = await q(` SELECT 'users' AS t, pg_total_relation_size('public.users') AS bytes UNION ALL SELECT 'spaces', pg_total_relation_size('public.spaces') UNION ALL SELECT 'projects', pg_total_relation_size('public.projects') UNION ALL SELECT 'universal_tables', pg_total_relation_size('public.universal_tables') UNION ALL SELECT 'table_rows', pg_total_relation_size('public.table_rows'); `); const sizeByTbl = Object.fromEntries(sizeRows.map((r) => [r.t, Number(r.bytes)])); const totalSize = sizeRows.reduce((s, r) => s + Number(r.bytes), 0); // Very rough free estimate: fraction of test rows out of total rows × bytes. const totalUsers = Number((await q(`SELECT COUNT(*) AS c FROM users`))[0].c); const totalSpaces = Number((await q(`SELECT COUNT(*) AS c FROM spaces`))[0].c); const totalProjects = Number((await q(`SELECT COUNT(*) AS c FROM projects`))[0].c); const totalTables = Number((await q(`SELECT COUNT(*) AS c FROM universal_tables`))[0].c); const totalRows = Number((await q(`SELECT COUNT(*) AS c FROM table_rows`))[0].c); const est = (frac, bytes) => Math.round(frac * bytes); const freedEst = est(deleteUsers.length / Math.max(totalUsers, 1), sizeByTbl.users) + est(deleteSpaces.length / Math.max(totalSpaces, 1), sizeByTbl.spaces) + est(deleteProjects.length / Math.max(totalProjects, 1), sizeByTbl.projects) + est(deleteTables.length / Math.max(totalTables, 1), sizeByTbl.universal_tables) + est(rowTotal / Math.max(totalRows, 1), sizeByTbl.table_rows); const fmtMB = (b) => (b / 1024 / 1024).toFixed(2) + ' MB'; // --- 9. MANIFEST ------------------------------------------------------ // Topological delete order — mirrors what Phase 3 --execute must do. const deleteOrder = [ '1. table_rows WHERE table_id IN (candidate universal_tables)', '2. DROP TABLE table_ (physical per-table PG tables for candidate universal_tables) — N/A, none exist', '3. universal_tables (candidate set)', '4. all FK-by-convention children referencing candidate projects', '5. projects (candidate set)', '6. all FK-by-convention children referencing candidate spaces (modules/widget_library/fitness_*/wellness_*/calendar_events/labs/schema_layouts/space_invitations/dashboards/files/conversations/user_access_permissions)', '7. spaces (candidate set)', '8. all FK-by-convention children referencing candidate users (agent_jobs/terminal_*/tool_approval_rules/wa_*/audit_log/monitoring_*/oidc_*/api_keys/user_settings/user_widget_*/chat_participants/conversation_participants/message_reactions)', '9. users (candidate set)', ]; const manifest = []; manifest.push(`# ADR-0009 Phase 2 cleanup DRY-RUN manifest`); manifest.push(``); manifest.push(`- Generated: ${new Date().toISOString()}`); manifest.push(`- Mode: DRY-RUN (no writes)`); manifest.push(`- Target DB: ${POSTGRES_HOST}/${process.env.POSTGRES_DB || 'godcrm_prod'}`); manifest.push(`- Allow-list file: scripts/cleanup-allowlist.json (owner_signed=${allowlist.owner_signed})`); manifest.push(`- Allow-list size: ${ALLOW_USER_IDS.size} users, ${ALLOW_SPACE_IDS.size} spaces, ${ALLOW_TABLE_IDS.size} tables`); manifest.push(``); manifest.push(`## Test signatures`); manifest.push(`- email regex: \`${TEST_EMAIL_RX}\``); manifest.push(`- name regex: \`${TEST_NAME_RX}\``); manifest.push(`- Agents (user_type='agent') and services (user_type='service') are unconditionally allow-listed regardless of regex match.`); manifest.push(``); manifest.push(`## Candidate summary`); manifest.push(`| Entity | Total in DB | To delete | To keep |`); manifest.push(`|---|---:|---:|---:|`); manifest.push(`| users | ${totalUsers} | ${deleteUsers.length} | ${totalUsers - deleteUsers.length} |`); manifest.push(`| spaces | ${totalSpaces} | ${deleteSpaces.length} | ${totalSpaces - deleteSpaces.length} |`); manifest.push(`| projects | ${totalProjects} | ${deleteProjects.length} | ${totalProjects - deleteProjects.length} |`); manifest.push(`| universal_tables | ${totalTables} | ${deleteTables.length} | ${totalTables - deleteTables.length} |`); manifest.push(`| table_rows | ${totalRows} | ${rowTotal} | ${totalRows - rowTotal} |`); manifest.push(`| physical table_ | (n/a) | ${physicalTables.length} | n/a |`); manifest.push(``); manifest.push(`## ADR Appendix A comparison`); manifest.push(`ADR predicted: 5878 users to delete, 5328 spaces to delete.`); manifest.push(`Actual: ${deleteUsers.length} users, ${deleteSpaces.length} spaces.`); const udelta = Math.abs(deleteUsers.length - 5878) / 5878; const sdelta = Math.abs(deleteSpaces.length - 5328) / 5328; manifest.push(`Delta: users ${(udelta * 100).toFixed(1)}%, spaces ${(sdelta * 100).toFixed(1)}%.`); if (udelta > 0.1 || sdelta > 0.1) { manifest.push(`**WARNING:** delta > 10% — regex may be wrong or allow-list may have too many/few entries.`); } else { manifest.push(`Delta within 10% — signature matches ADR audit.`); } manifest.push(``); manifest.push(`## Dependent FK-by-convention child counts`); manifest.push(`| Table | Parent | Candidate rows | Note |`); manifest.push(`|---|---|---:|---|`); for (const d of dep) { manifest.push(`| ${d.table} | ${d.parent} | ${d.count} | ${d.note || ''} |`); } manifest.push(``); manifest.push(`## Biggest individual deletes (by table_rows count)`); manifest.push(`| universal_table_id | rows |`); manifest.push(`|---|---:|`); for (const r of rowSummary.slice(0, 10)) { manifest.push(`| ${r.table_id} | ${r.row_count} |`); } manifest.push(``); manifest.push(`## Orphan-suspect users (NOT covered by current signature)`); manifest.push(`These users look test-ish (\`register-@hltrn.cc\`, name = "New User", etc.) but are NOT covered by the ADR regex, so they will NOT be deleted in the current plan. Owner should decide before Phase 3 whether to extend the regex. See orphan_suspects.csv.`); manifest.push(`Total: ${orphanSuspects.length}`); manifest.push(`| id | email | name | spaces_owned | projects_owned |`); manifest.push(`|---|---|---|---:|---:|`); for (const o of orphanSuspects.slice(0, 10)) { manifest.push(`| ${o.id} | ${o.email} | ${o.name} | ${o.spaces_owned} | ${o.projects_owned} |`); } manifest.push(``); manifest.push(`## Shape-unexpected warnings`); manifest.push(`Candidate users with non-trivial downstream activity (see weird_candidates.csv):`); manifest.push(`| id | email | audits | dashboards | spaces_owned |`); manifest.push(`|---|---|---:|---:|---:|`); for (const w of weird.slice(0, 10)) { manifest.push(`| ${w.id} | ${w.email} | ${w.audits} | ${w.dashboards} | ${w.spaces_owned} |`); } manifest.push(``); manifest.push(`## Estimated bytes freed`); manifest.push(`Rough proportional estimate (rows_to_delete / total_rows × table_size):`); manifest.push(`- users: ${fmtMB(est(deleteUsers.length / Math.max(totalUsers, 1), sizeByTbl.users))} (of ${fmtMB(sizeByTbl.users)})`); manifest.push(`- spaces: ${fmtMB(est(deleteSpaces.length / Math.max(totalSpaces, 1), sizeByTbl.spaces))} (of ${fmtMB(sizeByTbl.spaces)})`); manifest.push(`- projects: ${fmtMB(est(deleteProjects.length / Math.max(totalProjects, 1), sizeByTbl.projects))} (of ${fmtMB(sizeByTbl.projects)})`); manifest.push(`- universal_tables: ${fmtMB(est(deleteTables.length / Math.max(totalTables, 1), sizeByTbl.universal_tables))} (of ${fmtMB(sizeByTbl.universal_tables)})`); manifest.push(`- table_rows: ${fmtMB(est(rowTotal / Math.max(totalRows, 1), sizeByTbl.table_rows))} (of ${fmtMB(sizeByTbl.table_rows)})`); manifest.push(`- **TOTAL estimate freed:** ~${fmtMB(freedEst)} (of DB main-table footprint ${fmtMB(totalSize)})`); manifest.push(`(Does not include per-index bloat or dependent child tables; actual free may be higher after VACUUM FULL.)`); manifest.push(``); manifest.push(`## Topological delete order (Phase 3 --execute)`); for (const step of deleteOrder) manifest.push(`- ${step}`); manifest.push(``); manifest.push(`All wrapped in a single \`BEGIN; ... COMMIT;\` transaction; any error triggers \`ROLLBACK\`.`); manifest.push(``); manifest.push(`## Files in this snapshot`); for (const f of fs.readdirSync(snapDir).sort()) { manifest.push(`- ${f}`); } fs.writeFileSync(path.join(snapDir, 'MANIFEST.md'), manifest.join('\n') + '\n'); console.log(`\n[ok] manifest: ${path.join(snapDir, 'MANIFEST.md')}`); if (!EXECUTE) { console.log(`\nDRY-RUN COMPLETE — no rows modified`); return; } // ============================================================ // Phase 3 destructive path (--execute --target=dev|prod) // ============================================================ console.log(`\n=== PHASE 3 DESTRUCTIVE EXECUTION ===`); console.log(`Target: ${TARGET} Mode: ${ROLLBACK ? 'BEGIN; ... ROLLBACK (dry-transaction)' : 'BEGIN; ... COMMIT (destructive)'}`); if (!allowlist.owner_signed) { console.error('[FATAL] owner_signed=false — set to true in cleanup-allowlist.json after MANIFEST review.'); process.exit(2); } const client = await pool.connect(); const counts = {}; let committed = false; try { await client.query('BEGIN'); const candidateUsers = [...deleteUserIds]; const candidateSpaces = [...deleteSpaceIds]; const candidateProjects = [...deleteProjectIds]; const candidateTables = [...deleteTableIds]; // Pre-compute conversation IDs belonging to candidate spaces — their children // CASCADE on spaces delete, but agent_jobs.conversation_id is FK=NO ACTION // and must be nulled/cleared first. let candidateConvIds = []; if (candidateSpaces.length) { const r = await client.query( `SELECT id FROM conversations WHERE space_id = ANY($1::int[])`, [candidateSpaces], ); candidateConvIds = r.rows.map((x) => x.id); } console.log(` conversations in candidate spaces (will cascade on space delete): ${candidateConvIds.length}`); // Helper: run DELETE, capture rowCount. const del = async (label, sql, params) => { const r = await client.query(sql, params); counts[label] = r.rowCount; console.log(` [-] ${label}: ${r.rowCount}`); return r.rowCount; }; // --- Step A: break NO ACTION FKs referencing candidate users / conversations // (agent_jobs, terminal_*, tool_approval_rules, wa_*) if (candidateUsers.length || candidateConvIds.length) { const conds = []; const params = []; if (candidateUsers.length) { conds.push(`agent_user_id = ANY($${params.length + 1}::int[])`); params.push(candidateUsers); conds.push(`trigger_user_id = ANY($${params.length + 1}::int[])`); params.push(candidateUsers); } if (candidateConvIds.length) { conds.push(`conversation_id = ANY($${params.length + 1}::int[])`); params.push(candidateConvIds); } await del('agent_jobs', `DELETE FROM agent_jobs WHERE ${conds.join(' OR ')}`, params); } if (candidateUsers.length) { await del('terminal_commands', `DELETE FROM terminal_commands WHERE approved_by = ANY($1::int[])`, [candidateUsers]); await del('terminal_sessions', `DELETE FROM terminal_sessions WHERE user_id = ANY($1::int[])`, [candidateUsers]); await del('tool_approval_rules', `DELETE FROM tool_approval_rules WHERE created_by = ANY($1::int[])`, [candidateUsers]); await del('wa_auth_tokens', `DELETE FROM wa_auth_tokens WHERE user_id = ANY($1::int[])`, [candidateUsers]); await del('wa_presence', `DELETE FROM wa_presence WHERE user_id = ANY($1::int[])`, [candidateUsers]); } // --- Step B: drop per-table rows + universal_tables + projects if (candidateTables.length) { await del('table_rows', `DELETE FROM table_rows WHERE table_id = ANY($1::int[])`, [candidateTables]); await del('universal_tables', `DELETE FROM universal_tables WHERE id = ANY($1::int[])`, [candidateTables]); } if (candidateProjects.length) { await del('projects', `DELETE FROM projects WHERE id = ANY($1::int[])`, [candidateProjects]); } // --- Step C: space-children (NO ACTION FKs must delete first) if (candidateSpaces.length) { await del('calendar_events', `DELETE FROM calendar_events WHERE space_id = ANY($1::int[])`, [candidateSpaces]); await del('modules', `DELETE FROM modules WHERE space_id = ANY($1::int[])`, [candidateSpaces]); await del('widget_library', `DELETE FROM widget_library WHERE space_id = ANY($1::int[])`, [candidateSpaces]); // fitness_workout_sets is a VIEW joining fitness_workouts + fitness_sets. // fitness_sets CASCADES via workout_id → fitness_workouts FK (confdeltype=c). // Delete order: fitness_workouts (→ cascades fitness_sets) → fitness_exercises. await del('fitness_workouts', `DELETE FROM fitness_workouts WHERE space_id = ANY($1::int[])`, [candidateSpaces]); await del('fitness_exercises', `DELETE FROM fitness_exercises WHERE space_id = ANY($1::int[])`, [candidateSpaces]); await del('wellness_levels', `DELETE FROM wellness_levels WHERE space_id = ANY($1::int[])`, [candidateSpaces]); await del('wellness_points', `DELETE FROM wellness_points WHERE space_id = ANY($1::int[])`, [candidateSpaces]); await del('wellness_profiles', `DELETE FROM wellness_profiles WHERE space_id = ANY($1::int[])`, [candidateSpaces]); await del('wellness_streaks', `DELETE FROM wellness_streaks WHERE space_id = ANY($1::int[])`, [candidateSpaces]); await del('wellness_vitals', `DELETE FROM wellness_vitals WHERE space_id = ANY($1::int[])`, [candidateSpaces]); await del('wellness_user_achievements', `DELETE FROM wellness_user_achievements WHERE space_id = ANY($1::int[])`, [candidateSpaces]); await del('labs', `DELETE FROM labs WHERE space_id = ANY($1::int[])`, [candidateSpaces]); await del('schema_layouts', `DELETE FROM schema_layouts WHERE space_id = ANY($1::int[])`, [candidateSpaces]); await del('files', `DELETE FROM files WHERE space_id = ANY($1::int[])`, [candidateSpaces]); await del('dashboards', `DELETE FROM dashboards WHERE space_id = ANY($1::int[])`, [candidateSpaces]); await del('user_access_permissions_by_space', `DELETE FROM user_access_permissions WHERE space_id = ANY($1::int[])`, [candidateSpaces]); // conversations + space_invitations CASCADE from spaces delete. await del('spaces', `DELETE FROM spaces WHERE id = ANY($1::int[])`, [candidateSpaces]); } // --- Step D: users (+ CASCADE children) if (candidateUsers.length) { await del('user_access_permissions_by_user', `DELETE FROM user_access_permissions WHERE user_id = ANY($1::int[])`, [candidateUsers]); await del('user_settings', `DELETE FROM user_settings WHERE user_id = ANY($1::int[])`, [candidateUsers]); await del('audit_log', `DELETE FROM audit_log WHERE user_id = ANY($1::int[])`, [candidateUsers]); // conversation_participants, message_reactions, oidc_*, scheduled_messages, // user_widget_favorites, user_widget_history — all CASCADE on user delete. // conversations.created_by / messages.sender_id — SET NULL. await del('users', `DELETE FROM users WHERE id = ANY($1::int[])`, [candidateUsers]); } // --- Verify remaining row totals (in-transaction) --- const verify = await client.query(` SELECT (SELECT COUNT(*) FROM users) AS users_remaining, (SELECT COUNT(*) FROM spaces) AS spaces_remaining, (SELECT COUNT(*) FROM projects) AS projects_remaining, (SELECT COUNT(*) FROM universal_tables) AS tables_remaining, (SELECT COUNT(*) FROM table_rows) AS rows_remaining `); console.log('\n[post-delete row totals, pre-commit]'); console.log(verify.rows[0]); if (ROLLBACK) { await client.query('ROLLBACK'); console.log('\n[ok] --rollback — transaction rolled back. No rows persisted.'); } else { await client.query('COMMIT'); committed = true; console.log('\n[ok] COMMIT — cleanup persisted.'); } // Write execution report const reportPath = path.join(snapDir, committed ? 'EXECUTION_REPORT.md' : 'ROLLBACK_REPORT.md'); const report = [ `# ADR-0009 Phase 3 execution report`, ``, `- Timestamp: ${new Date().toISOString()}`, `- Target: ${TARGET}`, `- Mode: ${ROLLBACK ? 'ROLLBACK (dry-transaction)' : 'COMMIT (destructive)'}`, `- DB: ${POSTGRES_HOST}/${process.env.POSTGRES_DB || 'godcrm_prod'}`, ``, `## Delete counts per table`, `| Table | rowCount |`, `|---|---:|`, ...Object.entries(counts).map(([k, v]) => `| ${k} | ${v} |`), ``, `## Post-delete totals (in-transaction)`, `| metric | value |`, `|---|---:|`, ...Object.entries(verify.rows[0]).map(([k, v]) => `| ${k} | ${v} |`), ``, ].join('\n'); fs.writeFileSync(reportPath, report); console.log(`[ok] report: ${reportPath}`); } catch (err) { try { await client.query('ROLLBACK'); } catch {} console.error('\n[FATAL] destructive step failed — ROLLBACK issued:'); console.error(err); throw err; } finally { client.release(); } } main() .then(() => pool.end().then(() => process.exit(0))) .catch((err) => { console.error(err); pool.end().finally(() => process.exit(1)); });