SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
7.3 KB · 160 lines typescript
Raw Blame History
1import 'server-only';2import { run, sql, safe } from '@/lib/db';34export interface SourceRow {5  id: string;6  slug: string;7  name: string;8  organization: string | null;9  category: string;10  description: string | null;11  homepage: string | null;12  docs_url: string | null;13  terms_url: string | null;14  access_type: string;15  access_auth: string;16  license: string | null;17  license_status: string;18  commercial_use: string;19  redistribution: string;20  attribution: string | null;21  license_reviewed_at: Date | null;22  approved_for_production: boolean;23  update_frequency: string | null;24  supports_incremental: boolean;25  entities: string[];26  metrics: string[];27  rate_limit: string | null;28  status: string;29  tier: number;30  manifest: Record<string, unknown>;31  updated_at: Date;32  // joined33  health: string | null;34  health_detail: string | null;35  last_success_at: Date | null;36  last_attempt_at: Date | null;37  paused: boolean | null;38  record_count: string | number | null;39  last_run_id: string | null;40  last_run_status: string | null;41  last_run_finished_at: Date | null;42  last_run_fetched: number | null;43  last_run_created: number | null;44  last_run_updated: number | null;45  last_run_rejected: number | null;46  last_run_drift: unknown[] | null;47  last_dataset_version: string | null;48}4950const SOURCE_SELECT = sql`51  SELECT s.*, cc.health, cc.health_detail, cc.last_success_at, cc.last_attempt_at, cc.paused,52         (SELECT count(*) FROM source_records r WHERE r.source_id = s.id) AS record_count,53         lr.id AS last_run_id, lr.status AS last_run_status, lr.finished_at AS last_run_finished_at,54         lr.records_fetched AS last_run_fetched, lr.records_created AS last_run_created, lr.records_updated AS last_run_updated,55         lr.records_rejected AS last_run_rejected, lr.schema_drift AS last_run_drift, lr.dataset_version AS last_dataset_version56  FROM sources s57  LEFT JOIN connector_cursors cc ON cc.connector_id = s.slug58  LEFT JOIN LATERAL (SELECT * FROM ingest_runs ir WHERE ir.source_id = s.id ORDER BY ir.started_at DESC LIMIT 1) lr ON true`;5960export async function listSources(): Promise<SourceRow[]> {61  return safe(() => run<SourceRow>(sql`${SOURCE_SELECT} ORDER BY s.tier, s.category, s.name`), [] as SourceRow[]);62}6364export async function getSourceBySlug(slug: string): Promise<SourceRow | null> {65  const rows = await safe(() => run<SourceRow>(sql`${SOURCE_SELECT} WHERE s.slug = ${slug} LIMIT 1`), [] as SourceRow[]);66  return rows[0] ?? null;67}6869export interface RunRow {70  id: string;71  connector_id: string;72  source_id: string;73  mode: string;74  status: string;75  started_at: Date;76  finished_at: Date | null;77  duration_ms: number | null;78  records_fetched: number;79  records_created: number;80  records_updated: number;81  records_unchanged: number;82  records_rejected: number;83  http_requests: number;84  http_failures: number;85  rate_limit_events: number;86  validation_failures: number;87  schema_drift: unknown[];88  cursor_before: unknown;89  cursor_after: unknown;90  error: string | null;91  log: Array<{ t: string; level: string; msg: string }>;92  dataset_version: string | null;93  anomaly: string | null;94  source_slug?: string;95  source_name?: string;96}9798export async function listRuns(opts: { sourceId?: string; connectorId?: string; limit?: number } = {}): Promise<RunRow[]> {99  const limit = opts.limit ?? 25;100  return safe(101    () =>102      run<RunRow>(sql`103        SELECT r.*, s.slug AS source_slug, s.name AS source_name FROM ingest_runs r LEFT JOIN sources s ON s.id = r.source_id104        WHERE ${opts.sourceId ? sql`r.source_id = ${opts.sourceId}` : sql`true`} AND ${opts.connectorId ? sql`r.connector_id = ${opts.connectorId}` : sql`true`}105        ORDER BY r.started_at DESC LIMIT ${limit}`),106    [] as RunRow[],107  );108}109110export async function getRun(id: string): Promise<RunRow | null> {111  const rows = await safe(() => run<RunRow>(sql`SELECT r.*, s.slug AS source_slug, s.name AS source_name FROM ingest_runs r LEFT JOIN sources s ON s.id = r.source_id WHERE r.id = ${id}`), [] as RunRow[]);112  return rows[0] ?? null;113}114115export async function recordCountsByKind(sourceId: string): Promise<Array<{ entity_kind: string; n: number; status: string; last_retrieved: Date | null }>> {116  const rows = await safe(117    () => run<{ entity_kind: string; n: string; status: string; last_retrieved: Date | null }>(sql`SELECT entity_kind, status, count(*) AS n, max(retrieved_at) AS last_retrieved FROM source_records WHERE source_id = ${sourceId} GROUP BY entity_kind, status ORDER BY entity_kind, status`),118    [] as Array<{ entity_kind: string; n: string; status: string; last_retrieved: Date | null }>,119  );120  return rows.map((r) => ({ ...r, n: Number(r.n) }));121}122123/** Domains for the public coverage matrix (§307-308). */124export const COVERAGE_DOMAINS = ['Taxonomy', 'Epidemiology', 'Genomics', 'Variants', 'Trials', 'Drugs', 'Literature'] as const;125export type CoverageDomain = (typeof COVERAGE_DOMAINS)[number];126127/** Which domains a source contributes to, from its declared category/entities/metrics (manifest). */128export function sourceDomains(s: Pick<SourceRow, 'category' | 'entities' | 'metrics'>): Set<CoverageDomain> {129  const out = new Set<CoverageDomain>();130  const ents = new Set([...(s.entities ?? []), ...(s.metrics ?? [])].map((e) => e.toLowerCase()));131  const has = (...keys: string[]) => keys.some((k) => [...ents].some((e) => e.includes(k)));132  if (s.category === 'terminology' || has('cancer', 'tumor_type', 'concept', 'disease')) out.add('Taxonomy');133  if (s.category === 'epidemiology' || has('incidence', 'mortality', 'survival', 'prevalence')) out.add('Epidemiology');134  if (s.category === 'genomics' || has('gene', 'cohort', 'frequency', 'mutation')) out.add('Genomics');135  if (s.category === 'variants' || has('variant', 'evidence', 'clinical_significance')) out.add('Variants');136  if (s.category === 'trials' || has('trial', 'study')) out.add('Trials');137  if (s.category === 'drugs' || s.category === 'regulatory' || has('drug', 'therapy', 'approval')) out.add('Drugs');138  if (s.category === 'literature' || has('publication', 'literature', 'pubmed')) out.add('Literature');139  return out;140}141142/** Plain-language meaning of the license status (§308). */143export function licenseMeaning(status: string, redistribution: string, commercial: string): string {144  const base: Record<string, string> = {145    approved: 'Reviewed: CancerIndex may ingest and display this data with attribution.',146    review: 'Under review: terms are being assessed. No records are shown publicly until the review completes.',147    restricted: 'Restricted: data may be displayed with limits (e.g. no bulk redistribution).',148    blocked: 'Blocked: the license does not permit use by CancerIndex.',149  };150  const red: Record<string, string> = {151    allowed: 'Redistribution allowed.',152    attribution: 'Redistribution allowed with attribution.',153    restricted: 'Redistribution restricted — not included in downloads.',154    prohibited: 'Redistribution prohibited — not included in downloads.',155    unknown: 'Redistribution terms not yet determined.',156  };157  const com: Record<string, string> = { allowed: 'Commercial use allowed.', restricted: 'Commercial use restricted.', prohibited: 'Commercial use prohibited.', unknown: 'Commercial-use terms not yet determined.' };158  return [base[status] ?? status, red[redistribution] ?? '', com[commercial] ?? ''].filter(Boolean).join(' ');159}160