Files
grant-outreach-engine/apps/outreach-worker/src/run-once.ts
Croissant Le Doux 88affbeb4f feat(scoring): federal funder precedent via USASpending (ALN-level)
Precedent was always a valid criterion for federal grants — the index
just didn't cover them. Now: ingest captures ALN/CFDA numbers from
fetchOpportunity (grants.alns); nightly federalPrecedent workflow
(04:45) queries USASpending award search per distinct program (free
official API, 3-year NH lookback) and stamps program_state_award_count
+ sample recipients onto open grants (multi-ALN keeps highest). Match
retrieval feeds the same funderStateGrantCount input and 25-point tiers
foundations use; detail page shows the recipients-evidence table with
an incumbent-renewal caution.

Also: detail refresh now rotates oldest-verified-first
(serverMapGrantVerification) — the Set-based partition re-fetched the
same 200 every pass, leaving 365/565 grants ALN-less.

Live: 564/564 grants ALN-tagged, 156 programs swept, 85 with NH
history, 468 grants carrying precedent, first federal easy-wins (66pts,
25/25 precedent, Aug-24 deadline). 158 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:18:02 -04:00

149 lines
4.2 KiB
TypeScript

/**
* Supervised one-off workflow runner.
*
* DATABASE_URL=... node --import tsx/esm src/run-once.ts <workflow> [...]
*
* where <workflow> is one or more of: ingestGrants, ingestPndRss,
* ingestNhdojOrgs, enrichOrgs, expireGrants — or `all` for the standard
* first-run order (grants → pnd → nhdoj → expire → enrich).
*
* Boots exactly like main.ts (same deps injection, same DBOS launch — see
* that file's boot-order comment), runs the requested workflows through
* their durable DBOS handles sequentially, then shuts down. Scheduled crons
* ARE active while this process lives; runs are short enough that this
* doesn't matter in practice.
*/
import { DBOS } from '@dbos-inc/dbos-sdk';
import { DrizzleDataSource } from '@dbos-inc/drizzle-datasource';
import { schema } from '@novelpad/outreach-core';
import { drizzle } from 'drizzle-orm/node-postgres';
import pg from 'pg';
import {
runEmbedGrantsNow,
setEmbedGrantsDeps,
} from './workflows/embed-grants.js';
import { runEnrichOrgsNow, setEnrichOrgsDeps } from './workflows/enrich-orgs.js';
import {
runExpireGrantsNow,
setExpireGrantsDeps,
} from './workflows/expire-grants.js';
import {
runFederalPrecedentNow,
setFederalPrecedentDeps,
} from './workflows/federal-precedent.js';
import {
runIngestGrantsNow,
setIngestGrantsDeps,
} from './workflows/ingest-grants.js';
import {
runIngest990PfNow,
setIngest990pfDeps,
} from './workflows/ingest-990pf.js';
import {
runIngestNhdojOrgsNow,
setIngestNhdojOrgsDeps,
} from './workflows/ingest-nhdoj-orgs.js';
import {
runMatchGrantsNow,
setMatchGrantsDeps,
} from './workflows/match-grants.js';
import {
runIngestPndRssNow,
setIngestPndRssDeps,
} from './workflows/ingest-pnd-rss.js';
const RUNNERS: Record<string, () => Promise<void>> = {
ingestGrants: runIngestGrantsNow,
ingestPndRss: runIngestPndRssNow,
ingestNhdojOrgs: runIngestNhdojOrgsNow,
enrichOrgs: runEnrichOrgsNow,
expireGrants: runExpireGrantsNow,
embedGrants: runEmbedGrantsNow,
matchGrants: runMatchGrantsNow,
ingest990pf: runIngest990PfNow,
federalPrecedent: runFederalPrecedentNow,
};
const FIRST_RUN_ORDER = [
'ingestGrants',
'ingestPndRss',
'ingestNhdojOrgs',
'expireGrants',
'enrichOrgs',
'ingest990pf',
'federalPrecedent',
'embedGrants',
'matchGrants',
];
if (process.env.DATABASE_URL == null) {
throw new Error('run-once: DATABASE_URL is required');
}
const requested = process.argv.slice(2);
const names = requested.includes('all') ? FIRST_RUN_ORDER : requested;
if (names.length === 0 || names.some((n) => RUNNERS[n] == null)) {
console.error(
`Usage: run-once.ts <workflow...>\nKnown workflows: ${Object.keys(RUNNERS).join(', ')}, all`,
);
process.exit(2);
}
const { Pool } = pg;
const dbosClientConfig = {
connectionString: process.env.DATABASE_URL,
application_name: 'helmdocs-outreach-run-once',
} satisfies pg.ClientConfig;
const pool = new Pool({
...dbosClientConfig,
max: Number(process.env.PG_POOL_MAX ?? 20),
});
const db = drizzle(pool, { schema });
async function main() {
await DrizzleDataSource.initializeDBOSSchema(dbosClientConfig);
setIngestGrantsDeps({ db });
setExpireGrantsDeps({ db });
setIngestPndRssDeps({ db });
setIngestNhdojOrgsDeps({ db });
setEnrichOrgsDeps({ db });
setEmbedGrantsDeps({ db });
setMatchGrantsDeps({ db });
setIngest990pfDeps({ db });
setFederalPrecedentDeps({ db });
DBOS.setConfig({
name: 'helmdocs-outreach-worker',
systemDatabasePool: pool,
runAdminServer: false,
});
await DBOS.launch();
let failed = false;
for (const name of names) {
console.log(`\n=== run-once: ${name} ===`);
const startedAt = Date.now();
try {
await RUNNERS[name]!();
console.log(
`=== run-once: ${name} OK in ${((Date.now() - startedAt) / 1000).toFixed(1)}s ===`,
);
} catch (err) {
failed = true;
console.error(`=== run-once: ${name} FAILED ===`, err);
}
}
// DBOS.shutdown() ends the pool we handed it via systemDatabasePool —
// a second pool.end() here throws "Called end on pool more than once".
await DBOS.shutdown();
process.exit(failed ? 1 : 0);
}
main().catch((err) => {
console.error('[run-once] fatal:', err);
process.exit(1);
});