godcrm/scripts/cleanup-bug-mcp-001.mjs
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

141 lines
4.7 KiB
JavaScript

#!/usr/bin/env node
// BUG-MCP-001 cleanup: rows where `data` was stored as a JSON-encoded
// *string* in JSONB (jsonb_typeof = 'string') instead of a proper object.
//
// Root cause (fixed separately): MCP/agent callers occasionally pass `data`
// as a stringified JSON. Without an entry guard, `JSON.stringify(string)`
// at the SQL boundary produced a doubly-encoded string — so JSONB stored a
// quoted string and `data->>'field'` operators returned NULL.
//
// Two contamination sub-shapes observed in the wild:
// A. WELL-FORMED — inner string parses cleanly to an object.
// B. TRUNCATED — inner string is missing its closing `}`. Repair by
// appending `}` (and `}}}` for nested-object tails) until
// JSON.parse succeeds, capped at 4 attempts.
//
// Usage:
// node scripts/cleanup-bug-mcp-001.mjs --dry-run (default)
// node scripts/cleanup-bug-mcp-001.mjs --apply (writes)
// node scripts/cleanup-bug-mcp-001.mjs --apply --table-id 7288
//
// Idempotent.
import { dbAll, dbGet, dbRun } from '../backend/database/connection.js';
const APPLY = process.argv.includes('--apply');
const DRY = !APPLY;
const tableArgIdx = process.argv.indexOf('--table-id');
const TABLE_FILTER = tableArgIdx >= 0 ? Number(process.argv[tableArgIdx + 1]) : null;
const log = (...a) => console.log('[bug-mcp-001]', ...a);
/**
* Try to recover an object from a possibly-truncated JSON string.
* Returns { object, repaired } or null if unrecoverable.
*/
function recoverObject(s) {
// First: clean parse
try {
const parsed = JSON.parse(s);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return { object: parsed, repaired: false };
}
return null;
} catch {}
// Second: maybe missing closing braces — try appending up to 4
for (let n = 1; n <= 4; n++) {
try {
const parsed = JSON.parse(s + '}'.repeat(n));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return { object: parsed, repaired: true };
}
} catch {}
}
return null;
}
async function main() {
log(DRY ? 'DRY-RUN (no writes)' : 'APPLY mode (writes ENABLED)');
if (TABLE_FILTER) log(`scope: table_id = ${TABLE_FILTER}`);
const params = TABLE_FILTER ? [TABLE_FILTER] : [];
const filter = TABLE_FILTER ? 'AND table_id = ?' : '';
const rows = await dbAll(
`SELECT id, table_id, data
FROM table_rows
WHERE jsonb_typeof(data) = 'string' ${filter}
ORDER BY table_id, id`,
params
);
if (rows.length === 0) {
log('no contaminated rows — nothing to do');
return;
}
log(`found ${rows.length} contaminated row(s)`);
let cleanCount = 0;
let repairedCount = 0;
const unrecoverable = [];
for (const row of rows) {
// pg returns jsonb-string as a JS string (the inner value, unescaped).
// SQLite mode would store it as a JSON-encoded text; unwrap one level.
let innerString;
if (typeof row.data === 'string') {
innerString = row.data;
} else {
// Defensive: shouldn't happen for jsonb_typeof=string, but unwrap if needed
innerString = String(row.data);
}
const recovered = recoverObject(innerString);
if (!recovered) {
unrecoverable.push({ id: row.id, table_id: row.table_id, tail: innerString.slice(-60) });
continue;
}
if (recovered.repaired) repairedCount++;
else cleanCount++;
if (!DRY) {
await dbRun(
`UPDATE table_rows SET data = ? WHERE id = ? AND table_id = ?`,
[JSON.stringify(recovered.object), row.id, row.table_id]
);
}
}
log(`clean : ${cleanCount} row(s) (string parsed straight to object)`);
log(`repaired: ${repairedCount} row(s) (truncated tail; appended closing braces)`);
log(`skipped : ${unrecoverable.length} row(s) (could not recover — manual review)`);
for (const u of unrecoverable.slice(0, 10)) log(` skip id=${u.id} table=${u.table_id} tail=${u.tail || u.reason}`);
if (DRY) {
log(`DRY-RUN done. would update ${cleanCount + repairedCount} row(s). re-run with --apply to write.`);
return;
}
// Verify
const remaining = await dbGet(
`SELECT COUNT(*)::int AS n FROM table_rows
WHERE jsonb_typeof(data) = 'string' ${filter}`,
params
);
log(`post-fix contaminated rows remaining: ${remaining?.n ?? 0}`);
if ((remaining?.n ?? 0) === unrecoverable.length) {
log('SUCCESS: all recoverable rows fixed' + (unrecoverable.length ? ' (skipped rows left as-is)' : ''));
} else {
log('WARN: residual count differs from expected — review manually');
}
}
main()
.catch((err) => {
console.error('[bug-mcp-001] FATAL:', err);
process.exit(1);
})
.finally(() => process.exit(0));