Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
237 lines
8.1 KiB
JavaScript
237 lines
8.1 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Backfill `parent_document_id` on tickets in table 1708 (ADR-0012 / ADR-154).
|
|
*
|
|
* The doc-scoped filter in POST /api/v3/widgets/:id/resolve-tickets relies on
|
|
* each ticket carrying a `parent_document_id` key inside `data` jsonb. Almost
|
|
* none of the tickets created before this contract had it set, so the filter
|
|
* returns 0 rows for every documents widget.
|
|
*
|
|
* Strategy (two passes per ticket; first match wins):
|
|
*
|
|
* Primary — conversation chain: NOT directly possible because conversations
|
|
* bind to either a ticket OR a document, not both. Skipped here;
|
|
* kept as a documented possibility if a future schema adds a
|
|
* cross-reference. This runner therefore only attempts Secondary.
|
|
*
|
|
* Secondary — content-row ticket_ref: every documents-widget content table
|
|
* may contain rows with `data->>'ticket_ref' = <ticket.id>`. From
|
|
* that content table id, look up the documents_registry row whose
|
|
* `data->>'table_id'` matches — that row's id is the parent
|
|
* document id.
|
|
*
|
|
* Tertiary — project-id heuristic (not implemented; needs explicit user
|
|
* approval per task brief).
|
|
*
|
|
* Output: per-ticket resolution log + final report
|
|
* { totalTickets, hasParentAlready, primaryMatches, secondaryMatches,
|
|
* skipped, sampleSkipped }
|
|
*
|
|
* Usage:
|
|
* node scripts/backfill-parent-document-ids.js # dry-run (default)
|
|
* node scripts/backfill-parent-document-ids.js --apply # writes to DB
|
|
*
|
|
* Connects via env vars (POSTGRES_HOST/PORT/DB/USER/PASSWORD), same as
|
|
* scripts/backfill-widgets-owner.js.
|
|
*/
|
|
|
|
import pg from 'pg';
|
|
const { Pool } = pg;
|
|
|
|
const APPLY = process.argv.includes('--apply');
|
|
const DRY_RUN = !APPLY;
|
|
|
|
const TICKETS_TABLE_ID = 1708;
|
|
|
|
const pool = new Pool({
|
|
host: process.env.POSTGRES_HOST || 'localhost',
|
|
port: parseInt(process.env.POSTGRES_PORT || '5432', 10),
|
|
database: process.env.POSTGRES_DB || 'godcrm_prod',
|
|
user: process.env.POSTGRES_USER || 'godcrm',
|
|
password: process.env.POSTGRES_PASSWORD || 'godcrm_dev_2026',
|
|
});
|
|
|
|
const log = (...a) => console.log('[backfill-parent-document-ids]', ...a);
|
|
|
|
/**
|
|
* Build map: contentTableId (number) -> parent document id (number).
|
|
* Reads every documents_registry row, parses its `data` jsonb, and looks for
|
|
* `table_id` (the canonical key — 657/661 rows on prod) or, defensively,
|
|
* `content_table_id`.
|
|
*/
|
|
async function buildContentTableToDocMap() {
|
|
const sql = `
|
|
SELECT tr.id AS doc_id,
|
|
tr.data->>'table_id' AS content_table_id_a,
|
|
tr.data->>'content_table_id' AS content_table_id_b
|
|
FROM table_rows tr
|
|
JOIN universal_tables ut ON ut.id = tr.table_id
|
|
WHERE ut.table_type = 'documents_registry'
|
|
`;
|
|
const { rows } = await pool.query(sql);
|
|
const map = new Map();
|
|
for (const r of rows) {
|
|
const ct = r.content_table_id_a || r.content_table_id_b;
|
|
if (!ct) continue;
|
|
const ctNum = Number(ct);
|
|
if (!Number.isFinite(ctNum)) continue;
|
|
// First writer wins; if the same content table is referenced by two
|
|
// documents (shouldn't happen, but defensive), we log and skip.
|
|
if (map.has(ctNum) && map.get(ctNum) !== Number(r.doc_id)) {
|
|
log(` WARN: content_table ${ctNum} referenced by both doc ${map.get(ctNum)} and doc ${r.doc_id}; keeping ${map.get(ctNum)}`);
|
|
continue;
|
|
}
|
|
map.set(ctNum, Number(r.doc_id));
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Build map: ticket_id (number) -> { contentTableId, contentRowId }.
|
|
* Scans every table_row whose `data->>'ticket_ref'` is set. The result is
|
|
* the ticket → content-table linkage we need for Secondary resolution.
|
|
*/
|
|
async function buildTicketRefIndex() {
|
|
const sql = `
|
|
SELECT id AS content_row_id,
|
|
table_id AS content_table_id,
|
|
data->>'ticket_ref' AS ticket_ref
|
|
FROM table_rows
|
|
WHERE data->>'ticket_ref' IS NOT NULL
|
|
`;
|
|
const { rows } = await pool.query(sql);
|
|
const map = new Map();
|
|
for (const r of rows) {
|
|
const tid = Number(r.ticket_ref);
|
|
if (!Number.isFinite(tid)) continue;
|
|
map.set(tid, {
|
|
contentTableId: Number(r.content_table_id),
|
|
contentRowId: Number(r.content_row_id),
|
|
});
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Update a batch of tickets in a single transaction.
|
|
* `pairs` is [{ id, parent_document_id }, ...] (≤100 per call).
|
|
*/
|
|
async function applyBatch(client, pairs) {
|
|
if (!pairs.length) return 0;
|
|
await client.query('BEGIN');
|
|
try {
|
|
for (const p of pairs) {
|
|
await client.query(
|
|
`UPDATE table_rows
|
|
SET data = jsonb_set(data, '{parent_document_id}', to_jsonb($1::int)),
|
|
updated_at = NOW()
|
|
WHERE id = $2 AND table_id = $3`,
|
|
[p.parent_document_id, p.id, TICKETS_TABLE_ID]
|
|
);
|
|
}
|
|
await client.query('COMMIT');
|
|
return pairs.length;
|
|
} catch (e) {
|
|
await client.query('ROLLBACK');
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
log(APPLY ? 'APPLY MODE — writing changes' : 'DRY-RUN — no UPDATE will be issued (pass --apply to write)');
|
|
|
|
// 0. counters
|
|
const totalsRes = await pool.query(
|
|
`SELECT
|
|
COUNT(*)::int AS total,
|
|
COUNT(*) FILTER (WHERE data ? 'parent_document_id')::int AS has_parent
|
|
FROM table_rows
|
|
WHERE table_id = $1`,
|
|
[TICKETS_TABLE_ID]
|
|
);
|
|
const totalTickets = totalsRes.rows[0].total;
|
|
const hasParentAlready = totalsRes.rows[0].has_parent;
|
|
log(`tickets in table ${TICKETS_TABLE_ID}: total=${totalTickets} with_parent_already=${hasParentAlready} needs_backfill=${totalTickets - hasParentAlready}`);
|
|
|
|
// 1. lookup tables
|
|
const contentTableToDoc = await buildContentTableToDocMap();
|
|
log(`content_table -> doc map: ${contentTableToDoc.size} entries`);
|
|
const ticketRefIndex = await buildTicketRefIndex();
|
|
log(`ticket_ref index: ${ticketRefIndex.size} entries (rows referencing a ticket)`);
|
|
|
|
// 2. iterate tickets that need backfill
|
|
const ticketsRes = await pool.query(
|
|
`SELECT id FROM table_rows
|
|
WHERE table_id = $1
|
|
AND NOT (data ? 'parent_document_id')
|
|
ORDER BY id`,
|
|
[TICKETS_TABLE_ID]
|
|
);
|
|
const ticketsToProcess = ticketsRes.rows.map(r => Number(r.id));
|
|
|
|
let primaryMatches = 0; // reserved for future direct linkage; always 0 for now
|
|
let secondaryMatches = 0;
|
|
let skipped = 0;
|
|
const sampleSkipped = [];
|
|
const updates = []; // { id, parent_document_id }
|
|
|
|
for (const ticketId of ticketsToProcess) {
|
|
// Primary: not implementable from current schema (conversations bind to
|
|
// either ticket OR doc, never both at once). Stays at 0; documented above.
|
|
|
|
// Secondary: ticket_ref index
|
|
const ref = ticketRefIndex.get(ticketId);
|
|
if (ref) {
|
|
const docId = contentTableToDoc.get(ref.contentTableId);
|
|
if (docId != null) {
|
|
secondaryMatches++;
|
|
updates.push({ id: ticketId, parent_document_id: docId });
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// No match
|
|
skipped++;
|
|
if (sampleSkipped.length < 5) sampleSkipped.push(ticketId);
|
|
}
|
|
|
|
// 3. apply (if --apply)
|
|
let written = 0;
|
|
if (APPLY && updates.length > 0) {
|
|
const client = await pool.connect();
|
|
try {
|
|
const BATCH = 100;
|
|
for (let i = 0; i < updates.length; i += BATCH) {
|
|
const chunk = updates.slice(i, i + BATCH);
|
|
written += await applyBatch(client, chunk);
|
|
log(` applied batch ${i / BATCH + 1}: ${chunk.length} rows (running total ${written})`);
|
|
}
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
// 4. report
|
|
log('');
|
|
log('=== report ===');
|
|
log(` totalTickets : ${totalTickets}`);
|
|
log(` hasParentAlready : ${hasParentAlready}`);
|
|
log(` needsBackfill : ${ticketsToProcess.length}`);
|
|
log(` primaryMatches : ${primaryMatches} (conversation chain — not implemented)`);
|
|
log(` secondaryMatches : ${secondaryMatches} (ticket_ref in content tables)`);
|
|
log(` skipped : ${skipped}`);
|
|
log(` sampleSkipped : [${sampleSkipped.join(', ')}]`);
|
|
if (APPLY) {
|
|
log(` written : ${written}`);
|
|
} else {
|
|
log('');
|
|
log('DRY-RUN — rerun with --apply once you accept this plan.');
|
|
}
|
|
|
|
await pool.end();
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error('[backfill-parent-document-ids] FATAL:', err);
|
|
process.exit(1);
|
|
});
|