← back to Ca Donations
scripts/refresh-political-agg.mjs
101 lines
// Rebuild the materialized k-anon political rollup (political_agg) from
// political_contributions. Precomputes SUM/COUNT/distinct-donor per group for all
// four dimensions so /api/political/agg reads an indexed table instead of a
// 15.7M-row full scan. The k-anon floor is BAKED IN via HAVING count(distinct
// donor_name) >= 5, so political_agg can never contain a group of < 5 donors.
//
// ATOMIC: builds every dimension into a temp table, then swaps into political_agg
// inside ONE transaction (TRUNCATE + INSERT ... SELECT), so a reader never sees a
// half-built table. Runs standalone (`node scripts/refresh-political-agg.mjs`) and
// is called at the END of every political ingest.
//
// $0 — local aggregation, no paid API.
import { pool } from '../lib/db.js';
const POL_CLEAN =
`donor_name IS NOT NULL AND donor_name <> '' AND (contribution_date IS NULL OR contribution_date <= CURRENT_DATE)`;
// dimension -> source column. MUST match server.js AGG_GROUP.
const DIMENSIONS = {
recipient: 'recipient_name',
employer: 'donor_employer',
city: 'donor_city',
jurisdiction: 'jurisdiction',
};
export async function refreshPoliticalAgg(client) {
const own = !client;
const c = client || (await pool.connect());
try {
await c.query('BEGIN');
// Build every dimension into a temp table first (heavy scans stay outside the
// swap window as much as possible; the swap itself is TRUNCATE + INSERT).
await c.query(`DROP TABLE IF EXISTS political_agg_build`);
await c.query(`
CREATE TEMP TABLE political_agg_build (
dimension TEXT NOT NULL,
group_key TEXT NOT NULL,
total_amount NUMERIC(14,2),
contribution_count BIGINT,
distinct_donors INT
) ON COMMIT DROP`);
for (const [dimension, col] of Object.entries(DIMENSIONS)) {
await c.query(
`INSERT INTO political_agg_build
(dimension, group_key, total_amount, contribution_count, distinct_donors)
SELECT $1 AS dimension,
${col} AS group_key,
sum(amount)::numeric(14,2) AS total_amount,
count(*)::bigint AS contribution_count,
count(distinct donor_name)::int AS distinct_donors
FROM political_contributions
WHERE ${POL_CLEAN} AND ${col} IS NOT NULL AND ${col} <> ''
GROUP BY ${col}
HAVING count(distinct donor_name) >= 5`,
[dimension],
);
}
// Swap: clear the served table and repopulate from the build table + stamp.
await c.query(`TRUNCATE political_agg`);
await c.query(`
INSERT INTO political_agg
(dimension, group_key, total_amount, contribution_count, distinct_donors, refreshed_at)
SELECT dimension, group_key, total_amount, contribution_count, distinct_donors, now()
FROM political_agg_build`);
await c.query('COMMIT');
} catch (e) {
try { await c.query('ROLLBACK'); } catch (_) {}
throw e;
} finally {
if (own) c.release();
}
const counts = await pool.query(
`SELECT dimension, count(*)::int AS groups, min(distinct_donors)::int AS min_donors
FROM political_agg GROUP BY dimension ORDER BY dimension`);
return counts.rows;
}
// Standalone entrypoint.
if (import.meta.url === `file://${process.argv[1]}`) {
(async () => {
try {
const t0 = Date.now();
const rows = await refreshPoliticalAgg();
console.log(`political_agg refreshed in ${((Date.now() - t0) / 1000).toFixed(1)}s:`);
for (const r of rows) {
console.log(` ${r.dimension.padEnd(12)} ${String(r.groups).padStart(8)} groups min_donors=${r.min_donors}`);
}
console.log('$0 (local aggregation).');
} catch (e) {
console.error('FAILED:', e.stack || e.message);
process.exitCode = 1;
} finally {
await pool.end();
}
})();
}