godcrm/scripts/cleanup-test-data.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

736 lines
37 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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-<ts>/ 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_<N>). 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_<N>] 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-<timestamp>@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_<N> (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> | (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-<ts>@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));
});