spb/cancerindex
Public
1#!/usr/bin/env tsx2/**3 * CancerIndex operator CLI.4 *5 * pnpm cix connectors list connectors + health6 * pnpm cix run <id> [--mode full|incremental|dry_run] [--max-records N] [--max-minutes M] [--reset-cursor]7 * pnpm cix run-all [--max-minutes M] run every active connector in registry order8 * pnpm cix health <id> source liveness probe9 * pnpm cix sources:sync manifests → sources table10 * pnpm cix counters recompute entity counters11 * pnpm cix rank [--scope …] recompute ranking snapshots12 * pnpm cix stats table counts13 * pnpm cix trace <table> <id> lineage of a value (rankings/epidemiology/frequency)14 */15import { existsSync } from 'node:fs';16import path from 'node:path';17import { sql } from 'drizzle-orm';1819for (const candidate of [path.resolve(process.cwd(), '.env')]) {20 if (existsSync(candidate)) {21 try {22 process.loadEnvFile(candidate);23 } catch {24 /* ignore */25 }26 }27}2829const [, , cmd, ...rest] = process.argv;30const flags: Record<string, string | boolean> = {};31const positional: string[] = [];32for (let i = 0; i < rest.length; i++) {33 const a = rest[i]!;34 if (a.startsWith('--')) {35 const k = a.slice(2);36 const next = rest[i + 1];37 if (next && !next.startsWith('--')) {38 flags[k] = next;39 i++;40 } else flags[k] = true;41 } else positional.push(a);42}4344async function main() {45 const { getDb, closeDb } = await import('@cancerindex/database');46 const db = getDb();47 try {48 switch (cmd) {49 case 'connectors': {50 const { CONNECTORS } = await import('@cancerindex/connectors');51 const rows = await db.execute<{ connector_id: string; health: string; last_success_at: string | null; cursor: unknown }>(sql`SELECT connector_id, health, last_success_at, cursor FROM connector_cursors`);52 const byId = new Map(rows.map((r) => [r.connector_id, r]));53 for (const c of CONNECTORS) {54 const h = byId.get(c.manifest.id);55 console.log(`${c.manifest.id.padEnd(18)} tier${c.manifest.tier} ${c.manifest.status.padEnd(20)} license=${c.manifest.licenseStatus.padEnd(10)} health=${(h?.health ?? 'never-run').padEnd(12)} last_ok=${h?.last_success_at ?? '-'}`);56 }57 break;58 }59 case 'sources:sync': {60 const { syncSources, PLANNED_MANIFESTS } = await import('@cancerindex/connectors');61 const r = await syncSources(db, PLANNED_MANIFESTS);62 console.log(`[sources] created=${r.created} updated=${r.updated}`);63 break;64 }65 case 'health': {66 const { getConnector, RunContext } = await import('@cancerindex/connectors');67 const c = getConnector(positional[0] ?? '');68 if (!c) throw new Error(`unknown connector ${positional[0]}`);69 const [src] = await db.execute<{ id: string }>(sql`SELECT id FROM sources WHERE slug = ${c.manifest.id}`);70 const ctx = new RunContext(db, c.manifest, src?.id ?? 'CI-SOURCE-00000000', 'probe', { maxMinutes: 1 }, 0);71 console.log(await c.healthCheck(ctx));72 break;73 }74 case 'run': {75 const { getConnector, runConnector } = await import('@cancerindex/connectors');76 const c = getConnector(positional[0] ?? '');77 if (!c) throw new Error(`unknown connector ${positional[0]}`);78 const r = await runConnector(db, c, {79 mode: (flags.mode as never) ?? undefined,80 maxRecords: flags['max-records'] ? Number(flags['max-records']) : undefined,81 maxMinutes: flags['max-minutes'] ? Number(flags['max-minutes']) : undefined,82 resetCursor: !!flags['reset-cursor'],83 });84 console.log(`[run] ${r.runId} ${r.status}`, r.counters);85 if (r.status === 'failed') process.exitCode = 1;86 break;87 }88 case 'run-all': {89 const { CONNECTORS, runConnector } = await import('@cancerindex/connectors');90 for (const c of CONNECTORS) {91 if (c.manifest.status !== 'active') {92 console.log(`[skip] ${c.manifest.id} (${c.manifest.status})`);93 continue;94 }95 const r = await runConnector(db, c, { maxMinutes: flags['max-minutes'] ? Number(flags['max-minutes']) : undefined });96 console.log(`[run] ${r.runId} ${r.status}`, r.counters);97 }98 break;99 }100 case 'counters': {101 const { refreshCounters } = await import('@cancerindex/ranking');102 const n = await refreshCounters(db);103 console.log(`[counters] refreshed ${n} entities`);104 break;105 }106 case 'rank': {107 const { computeAllRankings } = await import('@cancerindex/ranking');108 const res = await computeAllRankings(db);109 for (const r of res) console.log(`[rank] ${r.metricSlug} ${r.scopeKey} eligible=${r.eligible}`);110 break;111 }112 case 'stats': {113 const tables = ['sources', 'ingest_runs', 'source_records', 'provenance', 'cancers', 'cancer_aliases', 'cancer_hierarchy', 'cancer_codes', 'genes', 'variants', 'drugs', 'clinical_trials', 'trial_conditions', 'publications', 'literature_counts', 'civic_evidence_items', 'genomic_cohorts', 'cancer_gene_frequencies', 'epidemiology_observations', 'survival_observations', 'knowledge_edges', 'unresolved_labels', 'rankings', 'ranking_snapshots'];114 for (const t of tables) {115 const [r] = await db.execute<{ n: string }>(sql.raw(`SELECT count(*)::text AS n FROM ${t}`));116 console.log(`${t.padEnd(28)} ${r?.n}`);117 }118 break;119 }120 case 'trace': {121 const { traceValue } = await import('@cancerindex/ranking');122 console.log(JSON.stringify(await traceValue(db, positional[0] ?? '', positional[1] ?? ''), null, 2));123 break;124 }125 default:126 console.log('usage: pnpm cix <connectors|run|run-all|health|sources:sync|counters|rank|stats|trace>');127 }128 } finally {129 await closeDb();130 }131}132133main().catch((e) => {134 console.error(e);135 process.exit(1);136});137