SPB Git

spb/cancerindex

Public

Foundation: monorepo, schema, provenance SDK, ontology resolver, ranking engine, OncoTree connector

Simon-Pierre Boucher committed (Sep 8, 2026) Browse files

74 changed files +13,851 −0

74 changed files
added .env.example +34 −0
@@ -0,0 +1,34 @@
1+# CancerIndex — environment (never commit real values)
2+NODE_ENV=development
3+DATABASE_URL=postgres://localhost:5432/cancerindex
4+DB_POOL_MAX=10
5+
6+WEB_PORT=8250
7+API_PORT=8251
8+API_HOST=127.0.0.1
9+CI_API_URL=http://127.0.0.1:8251
10+NEXT_PUBLIC_SITE_URL=http://localhost:8250
11+NEXT_TELEMETRY_DISABLED=1
12+
13+# Data lake (raw connector payloads, gzip JSON) — outside git
14+CI_DATA_DIR=./data
15+
16+# Admin console (/admin) bearer token
17+ADMIN_TOKEN=change-me
18+
19+# NCBI E-utilities (PubMed, ClinVar) — tool/email are mandatory, api key raises the limit to 10 req/s
20+NCBI_TOOL=cancerindex
21+NCBI_EMAIL=
22+NCBI_API_KEY=
23+
24+# SEER API (free key from https://api.seer.cancer.gov/) — connector stays "awaiting_credentials" without it
25+SEER_API_KEY=
26+
27+# Optional AI provider for /ask (Phase 5) — the database works without it
28+ANTHROPIC_API_KEY=
29+OPENAI_API_KEY=
30+OPENAI_BASE_URL=
31+
32+WORKER_CONCURRENCY=4
33+CI_MAX_RUN_MINUTES=45
34+LOG_LEVEL=info
added .gitignore +15 −0
@@ -0,0 +1,15 @@
1+node_modules/
2+.next/
3+dist/
4+*.tsbuildinfo
5+.env
6+.env.*
7+!.env.example
8+data/raw/
9+data/cache/
10+data/tmp/
11+logs/
12+tmp/
13+coverage/
14+.DS_Store
15+.claude/
added .npmrc +2 −0
@@ -0,0 +1,2 @@
1+auto-install-peers=true
2+strict-peer-dependencies=false
added CLAUDE.md +82 −0
@@ -0,0 +1,82 @@
1+# CLAUDE.md — CancerIndex.io (repository guide)
2+
3+The full product specification (364 sections) lives in `docs/SPEC-original.md`. This file is the
4+condensed, operational version every contributor (human or agent) must follow.
5+
6+## Mission
7+Build **the global index of cancer**: a provenance-first, continuously updated, transparently
8+sourced oncology ontology connecting epidemiology, genomics, biomarkers, therapies, trials,
9+regulatory evidence and literature — for every recognized malignant disease entity, not a list of
10+30 common cancers. Not a physician, not a diagnostic tool, no treatment recommendations.
11+
12+## Non-negotiables (from the spec)
13+1. **Provenance first** — no scientific number without a `provenance` row; derived values carry a
14+ `formula_version` and their inputs (§2, §250-253). Raw payloads go to the data lake (§26).
15+2. **Layers stay separable**: RAW → NORMALIZED → CANONICAL → DERIVED → RANKED → AI (§2).
16+3. **Never fake data**: no mock/hardcoded statistics in UI; show "Data not yet available" (§281-283).
17+4. **Identifiers are first-class**: keep every upstream ID in `*_codes` / dedicated columns (§347);
18+ public IDs are `CI-<NS>-00000001` minted via `mintId` (§6), never DB integers.
19+5. **Reconciliation before ingestion**: IDs → curated aliases → normalized strings; LLM only as a
20+ *candidate* generator; unknown labels go to `unresolved_labels`, never dropped (§69, §222, §340).
21+6. **Time-aware observations**: never overwrite year X with year Y; store both (§71).
22+7. **Context on every edge**: cancer context, direction, evidence level, provenance (§29, §244).
23+8. **Licensing gate**: no connector goes live before manifest `licenseStatus` is reviewed; IARC/
24+ GLOBOCAN stays `review`, SEER `awaiting_credentials` until keys/terms are settled (§10.4, §142).
25+9. **Verify docs before coding a connector** — official docs, smoke test with curl, fixture, tests,
26+ prove on 10/100/1000 records before a full sync (§224-227). Record `documentationVerifiedAt`.
27+10. **Rate limits, idempotency, restartability, anomaly guard** (never mass-delete on a shrunken
28+ response) (§90-92, §171, §229).
29+11. **Rankings need scope + year + source + formula version + "Why this rank?"** (§33, §183).
30+ Composite scores are Phase 2+ (§353).
31+12. **Scientific safety labels**: observed / published / curated / regulatory / guideline /
32+ computed / AI-generated — never merged (§3). Population survival ≠ individual prognosis (§325).
33+
34+## Repository layout
35+```
36+apps/web Next.js 16 (webpack build, --webpack), Tailwind v4, server components, /api/v1 proxied to apps/api
37+apps/api Fastify /v1 public API (JSON envelope { data, sources, dataRelease })
38+workers/ pg-boss scheduler: connector schedules, counters, rankings
39+packages/shared ids, provenance types, normalization, logger
40+packages/database Drizzle schema (snake_case casing), migrations, seed (metrics + geographies only)
41+packages/ontology qualifier rules, CancerResolver (alias/code reconciliation), TOP_LEVEL_CANCERS
42+packages/connectors SDK (manifest, HttpClient, RawLake, RunContext, validators) + connectors/<id>/{manifest.ts,index.ts,fixtures/,*.test.ts}
43+packages/ranking counters (entity_counters), ranking engine (snapshots + lineage), trace
44+scripts/ci.ts operator CLI: `pnpm cix connectors|run <id>|run-all|health|sources:sync|counters|rank|stats|trace`
45+docs/ architecture, data model, methodology, source policy, ADRs, connector docs
46+data/raw data lake (gitignored) — {source}/{date}/{entity}/{runId}-{part}.jsonl.gz
47+```
48+
49+## Conventions
50+- TypeScript strict, ESM, NodeNext imports with `.js` suffix inside packages; web app uses
51+ `moduleResolution: bundler` + webpack `extensionAlias` (never import `@cancerindex/connectors` in web).
52+- Database: `getDb()` singleton; Drizzle `casing: 'snake_case'`; raw SQL via `sql` template for
53+ aggregates. Schema changes = new migration via `pnpm db:generate` (one per integration), never
54+ manual prod mutation. Agents developing in parallel use `drizzle-kit push` on their own DB and do
55+ **not** commit migration files; the integrator generates the migration.
56+- Connector = `class extends Connector` with `manifest`, `healthCheck(ctx)`, `sync(ctx)`; register
57+ in `packages/connectors/src/registry.ts`. Use `ctx.http` (rate-limited), `ctx.upsertSourceRecord`
58+ (idempotent + raw lake), `ctx.addProvenance`, `ctx.recordUnresolved`, `ctx.cursor` (restart),
59+ `ctx.shouldStop()` between pages (time budget), validators from `sdk/validate.ts`.
60+- Reconciliation: `new CancerResolver(db)` → `warm()` → `byCode()/byLabel()/resolve()`; store
61+ `match_type` on every mapping; enqueue misses with `ctx.recordUnresolved`.
62+- Tests: vitest; connector tests run against **fixtures** (sanitized JSON in `fixtures/`), never
63+ live APIs; cover normal / empty / pagination / rate-limit / server error / malformed record.
64+- UI: scientific-editorial aesthetic (off-white, charcoal, muted neutrals, dense tables, no
65+ gradients/cards-everywhere), WCAG-minded, mobile first-class. Every number shows source badge,
66+ unit, population, period and a freshness line. Empty state = "Data not yet available".
67+- Language: English UI; code comments English; commit messages English.
68+
69+## Running locally
70+```
71+createdb cancerindex && cp .env.example .env
72+pnpm install && pnpm db:migrate && pnpm db:seed && pnpm cix sources:sync
73+pnpm cix run oncotree --mode dry_run # smoke; then without --mode
74+pnpm cix counters && pnpm cix rank
75+pnpm dev:api & pnpm dev:web
76+```
77+
78+## Deployment
79+MacLustr cluster via `mld` (gateway M1M32): manifest `~/dispatch/apps/cancerindex.json`,
80+`mld stage <dir> cancerindex` then `mld deploy cancerindex`. Web :8250, API :8251, PM2 processes
81+`cancerindex-web|api|worker|ngrok`, domain www.cancerindex.io (ngrok reserved). Postgres 17 +
82+pgvector on the node, database `cancerindex`. Source of truth for the code: spbgit `cancerindex.git`.
added package.json +40 −0
@@ -0,0 +1,40 @@
1+{
2+ "name": "cancerindex",
3+ "version": "0.1.0",
4+ "private": true,
5+ "description": "CancerIndex.io — the global index of cancer: provenance-first oncology ontology, epidemiology, genomics, trials, literature, rankings",
6+ "type": "module",
7+ "packageManager": "pnpm@11.1.2",
8+ "engines": {
9+ "node": ">=22"
10+ },
11+ "scripts": {
12+ "build": "pnpm -r --filter './packages/**' run build && pnpm --filter @cancerindex/api run build && pnpm --filter @cancerindex/web run build",
13+ "typecheck": "pnpm -r run typecheck",
14+ "test": "pnpm -r run test",
15+ "db:generate": "pnpm --filter @cancerindex/database run generate",
16+ "db:migrate": "pnpm --filter @cancerindex/database run migrate",
17+ "db:seed": "pnpm --filter @cancerindex/database run seed",
18+ "dev:web": "pnpm --filter @cancerindex/web run dev",
19+ "dev:api": "pnpm --filter @cancerindex/api run dev",
20+ "worker": "tsx workers/main.ts",
21+ "cix": "tsx scripts/ci.ts",
22+ "registry": "tsx scripts/build-registry.ts",
23+ "registry:check": "tsx scripts/build-registry.ts --check"
24+ },
25+ "dependencies": {
26+ "@cancerindex/connectors": "workspace:*",
27+ "@cancerindex/database": "workspace:*",
28+ "@cancerindex/ontology": "workspace:*",
29+ "@cancerindex/ranking": "workspace:*",
30+ "@cancerindex/shared": "workspace:*",
31+ "drizzle-orm": "^0.45.0",
32+ "postgres": "^3.4.7"
33+ },
34+ "devDependencies": {
35+ "@types/node": "^24.0.0",
36+ "tsx": "^4.20.0",
37+ "typescript": "^5.9.3",
38+ "vitest": "^3.2.0"
39+ }
40+}
added packages/connectors/package.json +36 −0
@@ -0,0 +1,36 @@
1+{
2+ "name": "@cancerindex/connectors",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "exports": {
7+ ".": {
8+ "types": "./src/index.ts",
9+ "default": "./src/index.ts"
10+ },
11+ "./sdk": {
12+ "types": "./src/sdk/index.ts",
13+ "default": "./src/sdk/index.ts"
14+ }
15+ },
16+ "scripts": {
17+ "build": "tsc -p tsconfig.json --noEmit",
18+ "typecheck": "tsc -p tsconfig.json --noEmit",
19+ "test": "vitest run --passWithNoTests"
20+ },
21+ "dependencies": {
22+ "@cancerindex/database": "workspace:*",
23+ "@cancerindex/ontology": "workspace:*",
24+ "@cancerindex/shared": "workspace:*",
25+ "drizzle-orm": "^0.45.0",
26+ "fast-xml-parser": "^5.2.0",
27+ "postgres": "^3.4.7",
28+ "yaml": "^2.8.0",
29+ "zod": "^4.0.0"
30+ },
31+ "devDependencies": {
32+ "@types/node": "^24.0.0",
33+ "typescript": "^5.9.3",
34+ "vitest": "^3.2.0"
35+ }
36+}
added packages/connectors/src/connectors/oncotree/index.ts +185 −0
@@ -0,0 +1,185 @@
1+import { eq, sql } from 'drizzle-orm';
2+import { z } from 'zod';
3+import { normalizeLabel, slugify } from '@cancerindex/shared';
4+import { anatomicalSites, cancerAliases, cancerAnatomy, cancerCodes, cancerHierarchy, cancers, mintId } from '@cancerindex/database';
5+import { CancerResolver } from '@cancerindex/ontology';
6+import { Connector, type ConnectorHealth, type RunContext } from '../../sdk/run.js';
7+import { manifest } from './manifest.js';
8+
9+const TumorType = z.object({
10+ code: z.string(),
11+ name: z.string(),
12+ mainType: z.string().nullable().optional(),
13+ tissue: z.string().nullable().optional(),
14+ parent: z.string().nullable().optional(),
15+ level: z.number().int(),
16+ externalReferences: z.record(z.string(), z.array(z.string())).default({}),
17+ history: z.array(z.string()).default([]),
18+ revocations: z.array(z.string()).default([]),
19+ precursors: z.array(z.string()).default([]),
20+ color: z.string().nullable().optional(),
21+});
22+type TumorType = z.infer<typeof TumorType>;
23+
24+const HEME_RE = /lymphoid|myeloid|blood|leukemia|lymphoma|myeloma|histiocyt|mastocyt|plasma cell/i;
25+const BENIGN_RE = /\bbenign\b|borderline|\bnevus\b|\bnevi\b|\badenoma\b|hyperplasia|\bcyst\b|hamartoma|\blipoma\b|leiomyoma\b|\bfibroma\b|hemangioma\b|papilloma\b|dysplasia|in situ|\bpolyp\b|schwannoma\b|meningioma\b|paraganglioma\b|pheochromocytoma\b|teratoma\b|thymoma\b/i;
26+
27+/**
28+ * OncoTree connector: maps every OncoTree node onto the canonical cancer set.
29+ * - level-1 "tissue" nodes (parent TISSUE) are anatomical sites, not cancers → anatomical_sites
30+ * - NCIt cross-reference → EXACT_IDENTIFIER match to the NCIt-derived canonical entity
31+ * - otherwise preferred-name match → ONTOLOGY_EXACT
32+ * - otherwise a new canonical entity is created *only* because OncoTree is a recognized ontology
33+ * (CLAUDE.md §340) — flagged with primaryOncotreeCode and no NCIt code.
34+ * The OncoTree parent/child structure is stored as hierarchy_type = "oncotree" (levels ≥ 2), and
35+ * level-2 nodes are linked to their tissue via cancer_anatomy.
36+ */
37+export class OncotreeConnector extends Connector {
38+ readonly manifest = manifest;
39+
40+ async healthCheck(ctx: RunContext): Promise<ConnectorHealth> {
41+ try {
42+ const versions = await ctx.http.json<Array<{ api_identifier: string; release_date: string }>>(`${manifest.access.baseUrl}/versions`);
43+ const stable = versions.find((v) => v.api_identifier === 'oncotree_latest_stable');
44+ return { status: 'healthy', detail: `latest stable ${stable?.release_date ?? '?'}` };
45+ } catch (e) {
46+ return { status: 'failing', detail: (e as Error).message };
47+ }
48+ }
49+
50+ async sync(ctx: RunContext): Promise<void> {
51+ const versions = await ctx.http.json<Array<{ api_identifier: string; release_date: string; visible: boolean }>>(`${manifest.access.baseUrl}/versions`);
52+ const stable = versions.find((v) => v.api_identifier === 'oncotree_latest_stable');
53+ // Pin to the dated release identifier so the dataset version is reproducible.
54+ const dated = versions.find((v) => v.release_date === stable?.release_date && v.api_identifier !== 'oncotree_latest_stable');
55+ const version = dated?.api_identifier ?? 'oncotree_latest_stable';
56+ ctx.datasetVersion = version;
57+ ctx.info(`OncoTree version ${version}`);
58+
59+ const raw = await ctx.http.json<unknown[]>(`${manifest.access.baseUrl}/tumorTypes?version=${encodeURIComponent(version)}`);
60+ const nodes: TumorType[] = [];
61+ for (const r of raw) {
62+ const parsed = TumorType.safeParse(r);
63+ if (!parsed.success) {
64+ ctx.counters.validationFailures++;
65+ ctx.counters.rejected++;
66+ ctx.warn(`invalid tumor type record: ${parsed.error.issues[0]?.message}`);
67+ continue;
68+ }
69+ nodes.push(parsed.data);
70+ }
71+ ctx.info(`fetched ${nodes.length} tumor types`);
72+ if (nodes.length < 500) throw new Error(`anomaly: only ${nodes.length} OncoTree nodes returned (expected ~900) — refusing to persist (CLAUDE.md §171)`);
73+ if (ctx.mode === 'dry_run') {
74+ ctx.counters.fetched = nodes.length;
75+ return;
76+ }
77+
78+ const resolver = new CancerResolver(ctx.db);
79+ await resolver.warm();
80+ const byCode = new Map(nodes.map((n) => [n.code, n]));
81+ const canonicalByOncotree = new Map<string, string>();
82+ const siteByOncotree = new Map<string, string>();
83+ await ctx.addProvenance({ dataset: 'OncoTree tumor types', datasetVersion: version, sourceUrl: `${manifest.access.baseUrl}/tumorTypes?version=${version}`, evidenceType: 'expert_curation', accessLevel: 'open', methodology: 'OncoTree curated tumor type hierarchy' });
84+
85+ // Pass 0: tissue roots → anatomical sites
86+ for (const node of nodes.filter((n) => n.parent === 'TISSUE')) {
87+ await ctx.upsertSourceRecord('tissue', node.code, node);
88+ const slug = slugify(node.name);
89+ const ncit = node.externalReferences['NCI']?.[0] ?? null;
90+ const [existing] = await ctx.db.select({ id: anatomicalSites.id }).from(anatomicalSites).where(eq(anatomicalSites.slug, slug)).limit(1);
91+ let siteId = existing?.id;
92+ if (!siteId) {
93+ siteId = await mintId(ctx.db, 'ANAT');
94+ await ctx.db.insert(anatomicalSites).values({ id: siteId, name: node.name, slug, ncitCode: ncit, system: node.tissue ?? node.name });
95+ } else await ctx.db.update(anatomicalSites).set({ name: node.name, ncitCode: ncit }).where(eq(anatomicalSites.id, siteId));
96+ siteByOncotree.set(node.code, siteId);
97+ }
98+
99+ // Pass 1: resolve / create canonical entities (parents before children thanks to level ordering).
100+ for (const node of [...nodes].filter((n) => n.parent !== 'TISSUE').sort((a, b) => a.level - b.level)) {
101+ const rec = await ctx.upsertSourceRecord('tumor_type', node.code, node);
102+ let cancerId: string | null = null;
103+ let matchType = 'UNRESOLVED';
104+ const ncitCodes = node.externalReferences['NCI'] ?? [];
105+ for (const c of ncitCodes) {
106+ const hit = resolver.byCode('ncit', c);
107+ if (hit) {
108+ cancerId = hit.cancerId;
109+ matchType = hit.matchType;
110+ break;
111+ }
112+ }
113+ if (!cancerId) {
114+ const hit = resolver.byCode('oncotree', node.code);
115+ if (hit) {
116+ cancerId = hit.cancerId;
117+ matchType = hit.matchType;
118+ }
119+ }
120+ if (!cancerId) {
121+ const hit = resolver.byLabel(node.name);
122+ if (hit && hit.matchType === 'ONTOLOGY_EXACT') {
123+ cancerId = hit.cancerId;
124+ matchType = hit.matchType;
125+ }
126+ }
127+ if (!cancerId) {
128+ // Recognized ontology node without NCIt anchor → new canonical entity (CLAUDE.md §340 satisfied: OncoTree is a recognized ontology).
129+ const slugBase = slugify(node.name);
130+ let slug = slugBase;
131+ for (let i = 2; ; i++) {
132+ const [clash] = await ctx.db.select({ id: cancers.id }).from(cancers).where(eq(cancers.slug, slug)).limit(1);
133+ if (!clash) break;
134+ slug = `${slugBase}-${i}`;
135+ }
136+ const heme = HEME_RE.test(`${node.tissue ?? ''} ${node.mainType ?? ''} ${node.name}`);
137+ cancerId = await mintId(ctx.db, 'CAN');
138+ await ctx.db.insert(cancers).values({
139+ id: cancerId,
140+ slug,
141+ canonicalName: node.name,
142+ entityType: node.level >= 3 ? 'subtype' : heme ? 'hematologic_malignancy' : 'cancer',
143+ malignant: !BENIGN_RE.test(node.name),
144+ solidTumor: !heme,
145+ hematologic: heme,
146+ primaryOncotreeCode: node.code,
147+ depth: node.level,
148+ classificationVersion: version,
149+ });
150+ await ctx.db.insert(cancerAliases).values({ cancerId, alias: node.name, normalized: normalizeLabel(node.name), aliasType: 'preferred', sourceId: ctx.sourceId, sourceTerminology: 'OncoTree' }).onConflictDoNothing();
151+ matchType = 'EXACT_IDENTIFIER';
152+ await ctx.recordChange('cancer', cancerId, 'created', `Created from OncoTree ${node.code} (${node.name})`);
153+ } else {
154+ await ctx.db.insert(cancerAliases).values({ cancerId, alias: node.name, normalized: normalizeLabel(node.name), aliasType: 'synonym', sourceId: ctx.sourceId, sourceTerminology: 'OncoTree' }).onConflictDoNothing();
155+ }
156+ canonicalByOncotree.set(node.code, cancerId);
157+ await ctx.db.insert(cancerCodes).values({ cancerId, system: 'oncotree', code: node.code, matchType, sourceId: ctx.sourceId, validFrom: stable?.release_date }).onConflictDoNothing();
158+ for (const u of node.externalReferences['UMLS'] ?? []) await ctx.db.insert(cancerCodes).values({ cancerId, system: 'umls', code: u, matchType: 'EXACT_IDENTIFIER', sourceId: ctx.sourceId }).onConflictDoNothing();
159+ for (const c of ncitCodes) await ctx.db.insert(cancerCodes).values({ cancerId, system: 'ncit', code: c, matchType: 'EXACT_IDENTIFIER', sourceId: ctx.sourceId }).onConflictDoNothing();
160+ // Record the OncoTree code on the entity only when it has none yet (first mapping wins; others stay in cancer_codes).
161+ await ctx.db.execute(sql`UPDATE cancers SET primary_oncotree_code = ${node.code} WHERE id = ${cancerId} AND primary_oncotree_code IS NULL`);
162+ // Level-2 nodes hang directly under a tissue → anatomical relation.
163+ if (node.parent && siteByOncotree.has(node.parent)) {
164+ await ctx.db.insert(cancerAnatomy).values({ cancerId, siteId: siteByOncotree.get(node.parent)!, relation: 'primary', sourceId: ctx.sourceId }).onConflictDoNothing();
165+ }
166+ void rec;
167+ }
168+
169+ // Pass 2: hierarchy edges (oncotree dimension), levels ≥ 2 only.
170+ let edges = 0;
171+ for (const node of nodes) {
172+ if (!node.parent || node.parent === 'TISSUE' || siteByOncotree.has(node.parent)) continue;
173+ const childId = canonicalByOncotree.get(node.code);
174+ const parentId = canonicalByOncotree.get(node.parent);
175+ if (!childId || !parentId || childId === parentId) continue;
176+ if (!byCode.has(node.parent)) continue;
177+ await ctx.db.insert(cancerHierarchy).values({ parentId, childId, hierarchyType: 'oncotree', sourceId: ctx.sourceId }).onConflictDoNothing();
178+ edges++;
179+ }
180+ ctx.info(`tissues ${siteByOncotree.size}; canonical mapped ${canonicalByOncotree.size}; hierarchy edges ${edges}`);
181+ ctx.cursor = { version, syncedAt: new Date().toISOString() };
182+ }
183+}
184+
185+export const connector = new OncotreeConnector();
added packages/connectors/src/connectors/oncotree/manifest.ts +34 −0
@@ -0,0 +1,34 @@
1+import { defineManifest } from '../../sdk/manifest.js';
2+
3+/**
4+ * Docs verified 2026-09-08: https://oncotree.mskcc.org (API tab) — GET /api/tumorTypes?version=…,
5+ * GET /api/versions, GET /api/mainTypes. Response fields: code, name, mainType, tissue, parent,
6+ * level, externalReferences{UMLS[],NCI[]}, history[], revocations[], precursors[], color.
7+ * License (repo README, cBioPortal/oncotree): CC BY 4.0.
8+ */
9+export const manifest = defineManifest({
10+ id: 'oncotree',
11+ name: 'OncoTree',
12+ organization: 'Memorial Sloan Kettering Cancer Center / cBioPortal',
13+ category: 'terminology',
14+ tier: 0,
15+ description: 'Open cancer classification used by cBioPortal and clinical sequencing programs. Provides a tissue-based tumor type hierarchy with NCIt and UMLS cross-references. Used as a second hierarchy dimension and to anchor cohort/genomic sources.',
16+ homepage: 'https://oncotree.mskcc.org',
17+ docsUrl: 'https://oncotree.mskcc.org/#/home?tab=api',
18+ termsUrl: 'https://github.com/cBioPortal/oncotree/blob/master/LICENSE',
19+ access: { type: 'api', auth: 'none', baseUrl: 'https://oncotree.mskcc.org/api' },
20+ license: 'CC BY 4.0',
21+ licenseStatus: 'approved',
22+ commercialUse: 'allowed',
23+ redistribution: 'attribution',
24+ attribution: 'OncoTree (Memorial Sloan Kettering Cancer Center), CC BY 4.0, https://oncotree.mskcc.org',
25+ termsReviewedAt: '2026-09-08',
26+ updateFrequency: 'Stable releases a few times per year',
27+ supportsIncrementalSync: false,
28+ entities: ['cancer', 'cancer_hierarchy', 'cancer_codes'],
29+ rateLimits: { requestsPerSecond: 2, maxConcurrency: 1 },
30+ rawRetention: 'full',
31+ documentationVerifiedAt: '2026-09-08',
32+ status: 'active',
33+ schedule: '0 4 * * 1',
34+});
added packages/connectors/src/index.ts +4 −0
@@ -0,0 +1,4 @@
1+export * from './sdk/index.js';
2+export * from './registry.js';
3+export * from './sources-sync.js';
4+export * from './planned.js';
added packages/connectors/src/planned.ts +60 −0
@@ -0,0 +1,60 @@
1+import { defineManifest, type ConnectorManifest } from './sdk/manifest.js';
2+
3+/**
4+ * Sources that are registered (visible on /sources with their compliance status) but have no
5+ * ingesting connector yet — either because license review is pending (CLAUDE.md §10.4, §142) or
6+ * because credentials are required. Nothing here ingests data.
7+ */
8+export const PLANNED_MANIFESTS: ConnectorManifest[] = [
9+ defineManifest({
10+ id: 'iarc-globocan',
11+ name: 'IARC Global Cancer Observatory (GLOBOCAN)',
12+ organization: 'International Agency for Research on Cancer (WHO)',
13+ category: 'epidemiology',
14+ tier: 0,
15+ description: 'Global estimates of cancer incidence, mortality and prevalence by country, sex, age and cancer site. The intended global epidemiology layer of CancerIndex.',
16+ homepage: 'https://gco.iarc.who.int/',
17+ docsUrl: 'https://gco.iarc.who.int/today/en/about',
18+ termsUrl: 'https://gco.iarc.who.int/en/terms-of-use',
19+ access: { type: 'api', auth: 'none', baseUrl: 'https://gco.iarc.who.int/' },
20+ license: 'IARC/WHO terms of use — redistribution rights under review',
21+ licenseStatus: 'review',
22+ commercialUse: 'unknown',
23+ redistribution: 'unknown',
24+ attribution: 'Ferlay J, Ervik M, Lam F, et al. Global Cancer Observatory: Cancer Today. Lyon, France: IARC.',
25+ termsNotes:
26+ 'Terms page is a JavaScript application; automated review on 2026-09-08 could not extract the text. Per CLAUDE.md §10.4 no automated bulk ingestion or republication of GLOBOCAN estimates until a human has reviewed the terms and, if required, obtained permission from IARC. Country/global rankings therefore remain unavailable and are shown as "awaiting license review".',
27+ updateFrequency: 'Major releases every ~2 years (GLOBOCAN 2022 current)',
28+ supportsIncrementalSync: false,
29+ entities: ['epidemiology_observations'],
30+ metrics: ['incidence_count', 'mortality_count', 'as_incidence_rate', 'as_mortality_rate', 'prevalence_5y'],
31+ rateLimits: { requestsPerSecond: 1, maxConcurrency: 1 },
32+ documentationVerifiedAt: '2026-09-08',
33+ status: 'review',
34+ }),
35+ defineManifest({
36+ id: 'seer',
37+ name: 'SEER (Surveillance, Epidemiology, and End Results)',
38+ organization: 'National Cancer Institute',
39+ category: 'epidemiology',
40+ tier: 0,
41+ description: 'US population-based registry statistics: incidence, mortality, survival, stage, age, sex, race/ethnicity. Authoritative for US relative survival.',
42+ homepage: 'https://seer.cancer.gov/',
43+ docsUrl: 'https://api.seer.cancer.gov/',
44+ termsUrl: 'https://seer.cancer.gov/about/terms.html',
45+ access: { type: 'api', auth: 'api_key', baseUrl: 'https://api.seer.cancer.gov/rest/' },
46+ license: 'US Government work (public domain); SEER*Explorer statistics citable with attribution; API requires a free key',
47+ licenseStatus: 'review',
48+ commercialUse: 'unknown',
49+ redistribution: 'attribution',
50+ attribution: 'Surveillance, Epidemiology, and End Results (SEER) Program (www.seer.cancer.gov), National Cancer Institute.',
51+ termsNotes: 'Connector is implemented against the SEER API only once a key is present (SEER_API_KEY). Status awaiting_credentials until then. Never present SEER (US) estimates as global.',
52+ updateFrequency: 'Annual (April)',
53+ supportsIncrementalSync: false,
54+ entities: ['epidemiology_observations', 'survival_observations'],
55+ metrics: ['incidence_count', 'as_incidence_rate', 'mortality_count', 'as_mortality_rate', 'five_year_survival'],
56+ rateLimits: { requestsPerSecond: 2, maxConcurrency: 1 },
57+ documentationVerifiedAt: '2026-09-08',
58+ status: 'awaiting_credentials',
59+ }),
60+];
added packages/connectors/src/registry.ts +13 −0
@@ -0,0 +1,13 @@
1+import type { Connector } from './sdk/run.js';
2+import { connector as oncotree } from './connectors/oncotree/index.js';
3+
4+/**
5+ * Connector registry. Order = recommended first-run order (CLAUDE.md §351): terminology first,
6+ * then genes, then genomics/trials/literature/variants/evidence, then epidemiology.
7+ * Add new connectors here; `pnpm cix sources:sync` seeds their manifests into `sources`.
8+ */
9+export const CONNECTORS: Connector[] = [oncotree];
10+
11+export function getConnector(id: string): Connector | undefined {
12+ return CONNECTORS.find((c) => c.manifest.id === id);
13+}
added packages/connectors/src/sdk/http.ts +162 −0
@@ -0,0 +1,162 @@
1+import { setTimeout as sleep } from 'node:timers/promises';
2+import type { ConnectorManifest } from './manifest.js';
3+
4+export interface HttpStats {
5+ requests: number;
6+ failures: number;
7+ rateLimitEvents: number;
8+ retries: number;
9+}
10+
11+export class HttpError extends Error {
12+ constructor(
13+ public readonly status: number,
14+ public readonly url: string,
15+ public readonly bodySnippet: string,
16+ ) {
17+ super(`HTTP ${status} for ${url}: ${bodySnippet.slice(0, 200)}`);
18+ }
19+}
20+
21+/** Token bucket rate limiter (CLAUDE.md §229). */
22+class TokenBucket {
23+ private tokens: number;
24+ private last = Date.now();
25+ constructor(
26+ private readonly ratePerSec: number,
27+ private readonly burst: number,
28+ ) {
29+ this.tokens = burst;
30+ }
31+ async take(): Promise<void> {
32+ for (;;) {
33+ const now = Date.now();
34+ this.tokens = Math.min(this.burst, this.tokens + ((now - this.last) / 1000) * this.ratePerSec);
35+ this.last = now;
36+ if (this.tokens >= 1) {
37+ this.tokens -= 1;
38+ return;
39+ }
40+ await sleep(Math.ceil(((1 - this.tokens) / this.ratePerSec) * 1000));
41+ }
42+ }
43+}
44+
45+class Semaphore {
46+ private queue: Array<() => void> = [];
47+ private active = 0;
48+ constructor(private readonly max: number) {}
49+ async acquire(): Promise<() => void> {
50+ if (this.active >= this.max) await new Promise<void>((resolve) => this.queue.push(resolve));
51+ this.active++;
52+ let released = false;
53+ return () => {
54+ if (released) return;
55+ released = true;
56+ this.active--;
57+ const next = this.queue.shift();
58+ if (next) next();
59+ };
60+ }
61+}
62+
63+export interface HttpClientOptions {
64+ userAgent?: string;
65+ timeoutMs?: number;
66+ headers?: Record<string, string>;
67+}
68+
69+/**
70+ * Source-aware HTTP client: token bucket, bounded concurrency, exponential backoff with jitter,
71+ * Retry-After respect, request accounting for the ingest run.
72+ */
73+export class HttpClient {
74+ readonly stats: HttpStats = { requests: 0, failures: 0, rateLimitEvents: 0, retries: 0 };
75+ private readonly bucket: TokenBucket | null;
76+ private readonly sem: Semaphore;
77+ private readonly retry: NonNullable<ConnectorManifest['retryPolicy']>;
78+ private readonly ua: string;
79+ private readonly timeoutMs: number;
80+ private readonly headers: Record<string, string>;
81+
82+ constructor(manifest: ConnectorManifest, opts: HttpClientOptions = {}) {
83+ const rps = manifest.rateLimits.requestsPerSecond ?? (manifest.rateLimits.requestsPerMinute ? manifest.rateLimits.requestsPerMinute / 60 : undefined);
84+ this.bucket = rps ? new TokenBucket(rps, Math.max(1, Math.ceil(rps))) : null;
85+ this.sem = new Semaphore(manifest.rateLimits.maxConcurrency);
86+ this.retry = manifest.retryPolicy;
87+ // Plain product token only: some WAFs (e.g. oncotree.mskcc.org, Akamai) reject UAs containing URLs/parentheses.
88+ this.ua = opts.userAgent ?? `CancerIndex/0.1`;
89+ this.timeoutMs = opts.timeoutMs ?? 60_000;
90+ this.headers = opts.headers ?? {};
91+ }
92+
93+ /** One attempt: returns the Response (any status) or throws on network error. */
94+ private async attempt(url: string, init: RequestInit): Promise<Response> {
95+ const release = await this.sem.acquire();
96+ try {
97+ if (this.bucket) await this.bucket.take();
98+ this.stats.requests++;
99+ const controller = new AbortController();
100+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
101+ try {
102+ return await fetch(url, {
103+ ...init,
104+ headers: { 'user-agent': this.ua, accept: 'application/json, text/plain, */*', ...this.headers, ...(init.headers as Record<string, string> | undefined) },
105+ signal: controller.signal,
106+ });
107+ } finally {
108+ clearTimeout(timer);
109+ }
110+ } finally {
111+ release();
112+ }
113+ }
114+
115+ async request(url: string, init: RequestInit = {}): Promise<Response> {
116+ for (let attempt = 0; ; attempt++) {
117+ let res: Response;
118+ try {
119+ res = await this.attempt(url, init);
120+ } catch (err) {
121+ if (attempt >= this.retry.maxRetries) {
122+ this.stats.failures++;
123+ throw err;
124+ }
125+ this.stats.retries++;
126+ await sleep(this.backoff(attempt));
127+ continue;
128+ }
129+ if (res.ok) return res;
130+ const body = await res.text().catch(() => '');
131+ if (res.status === 429) this.stats.rateLimitEvents++;
132+ const retryable = res.status === 429 || res.status >= 500 || res.status === 408;
133+ if (!retryable || attempt >= this.retry.maxRetries) {
134+ this.stats.failures++;
135+ throw new HttpError(res.status, url, body);
136+ }
137+ const retryAfter = Number(res.headers.get('retry-after'));
138+ const delay = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter * 1000, 120_000) : this.backoff(attempt);
139+ this.stats.retries++;
140+ await sleep(delay);
141+ }
142+ }
143+
144+ async json<T = unknown>(url: string, init?: RequestInit): Promise<T> {
145+ const res = await this.request(url, init);
146+ return (await res.json()) as T;
147+ }
148+
149+ async text(url: string, init?: RequestInit): Promise<string> {
150+ const res = await this.request(url, init);
151+ return await res.text();
152+ }
153+
154+ async postJson<T = unknown>(url: string, body: unknown, init: RequestInit = {}): Promise<T> {
155+ return this.json<T>(url, { ...init, method: 'POST', headers: { 'content-type': 'application/json', ...(init.headers as Record<string, string> | undefined) }, body: JSON.stringify(body) });
156+ }
157+
158+ private backoff(attempt: number): number {
159+ const base = Math.min(this.retry.maxDelayMs, this.retry.baseDelayMs * 2 ** attempt);
160+ return Math.round(base / 2 + Math.random() * (base / 2));
161+ }
162+}
added packages/connectors/src/sdk/index.ts +5 −0
@@ -0,0 +1,5 @@
1+export * from './manifest.js';
2+export * from './http.js';
3+export * from './lake.js';
4+export * from './run.js';
5+export * from './validate.js';
added packages/connectors/src/sdk/lake.ts +105 −0
@@ -0,0 +1,105 @@
1+import { createWriteStream, existsSync, mkdirSync, createReadStream } from 'node:fs';
2+import { mkdir, stat } from 'node:fs/promises';
3+import path from 'node:path';
4+import { createGzip, createGunzip } from 'node:zlib';
5+import { pipeline } from 'node:stream/promises';
6+import { createInterface } from 'node:readline';
7+import { dataDir } from '@cancerindex/shared';
8+
9+/**
10+ * Raw data lake (CLAUDE.md §26): every source payload retained when licensing allows.
11+ * Layout: {CI_DATA_DIR}/raw/{source}/{YYYY-MM-DD}/{entity}/{runId}-{part}.jsonl.gz
12+ * A record's `rawPath` is "<file>#<line>" so any canonical value can be traced to its raw payload.
13+ */
14+export class RawLake {
15+ private readonly root: string;
16+ private writers = new Map<string, { path: string; stream: ReturnType<typeof createWriteStream>; gz: ReturnType<typeof createGzip>; lines: number; part: number; bytes: number }>();
17+ constructor(
18+ private readonly source: string,
19+ private readonly runId: string,
20+ private readonly date = new Date(),
21+ root?: string,
22+ ) {
23+ this.root = root ?? path.join(dataDir(), 'raw');
24+ }
25+
26+ dir(entity: string): string {
27+ const day = this.date.toISOString().slice(0, 10);
28+ return path.join(this.root, this.source, day, entity);
29+ }
30+
31+ /** Append one raw JSON payload; returns its rawPath reference. */
32+ async put(entity: string, payload: unknown): Promise<string> {
33+ let w = this.writers.get(entity);
34+ if (!w || w.bytes > 64 * 1024 * 1024) {
35+ if (w) await this.closeWriter(entity);
36+ const part = (w?.part ?? 0) + 1;
37+ const dir = this.dir(entity);
38+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
39+ const file = path.join(dir, `${this.runId}-${String(part).padStart(3, '0')}.jsonl.gz`);
40+ const stream = createWriteStream(file);
41+ const gz = createGzip({ level: 6 });
42+ gz.pipe(stream);
43+ w = { path: file, stream, gz, lines: 0, part, bytes: 0 };
44+ this.writers.set(entity, w);
45+ }
46+ const line = JSON.stringify(payload) + '\n';
47+ w.bytes += line.length;
48+ const ref = `${w.path}#${w.lines}`;
49+ w.lines++;
50+ if (!w.gz.write(line)) await new Promise<void>((r) => w!.gz.once('drain', () => r()));
51+ return ref;
52+ }
53+
54+ private async closeWriter(entity: string): Promise<void> {
55+ const w = this.writers.get(entity);
56+ if (!w) return;
57+ await new Promise<void>((resolve, reject) => {
58+ w.stream.once('finish', () => resolve());
59+ w.stream.once('error', reject);
60+ w.gz.end();
61+ });
62+ this.writers.delete(entity);
63+ }
64+
65+ async close(): Promise<void> {
66+ for (const entity of [...this.writers.keys()]) await this.closeWriter(entity);
67+ }
68+
69+ /** Read a raw payload back by reference (admin TRACE, reprocessing). */
70+ static async read(ref: string): Promise<unknown | null> {
71+ const [file, lineStr] = ref.split('#');
72+ if (!file || lineStr === undefined) return null;
73+ const target = Number(lineStr);
74+ const rl = createInterface({ input: createReadStream(file).pipe(createGunzip()) });
75+ let i = 0;
76+ for await (const line of rl) {
77+ if (i === target) {
78+ rl.close();
79+ return JSON.parse(line);
80+ }
81+ i++;
82+ }
83+ return null;
84+ }
85+}
86+
87+/** Download a bulk file to the lake (CLAUDE.md §228-230) with checksum + size recorded. */
88+export async function downloadBulk(url: string, dest: string, fetchImpl: typeof fetch = fetch, headers: Record<string, string> = {}): Promise<{ path: string; bytes: number; sha256: string }> {
89+ await mkdir(path.dirname(dest), { recursive: true });
90+ const res = await fetchImpl(url, { headers: { 'user-agent': 'CancerIndex/0.1 (+https://www.cancerindex.io)', ...headers } });
91+ if (!res.ok || !res.body) throw new Error(`bulk download failed ${res.status} ${url}`);
92+ const { createHash } = await import('node:crypto');
93+ const hash = createHash('sha256');
94+ const { Transform } = await import('node:stream');
95+ const tap = new Transform({
96+ transform(chunk, _enc, cb) {
97+ hash.update(chunk);
98+ cb(null, chunk);
99+ },
100+ });
101+ const { Readable } = await import('node:stream');
102+ await pipeline(Readable.fromWeb(res.body as never), tap, createWriteStream(dest));
103+ const s = await stat(dest);
104+ return { path: dest, bytes: s.size, sha256: hash.digest('hex') };
105+}
added packages/connectors/src/sdk/manifest.ts +61 −0
@@ -0,0 +1,61 @@
1+import { z } from 'zod';
2+
3+/** Connector manifest (CLAUDE.md §9). Seeds the `sources` table; drives the public /source pages. */
4+export const ConnectorManifest = z.object({
5+ id: z.string().regex(/^[a-z0-9-]+$/),
6+ name: z.string(),
7+ organization: z.string(),
8+ category: z.enum(['terminology', 'genes', 'genomics', 'variants', 'epidemiology', 'trials', 'literature', 'drugs', 'regulatory', 'evidence', 'pathways', 'funding', 'guidelines']),
9+ tier: z.number().int().min(0).max(11),
10+ description: z.string(),
11+ homepage: z.string().url(),
12+ docsUrl: z.string().url().optional(),
13+ termsUrl: z.string().url().optional(),
14+ access: z.object({
15+ type: z.enum(['api', 'bulk', 'rss', 'ftp', 'graphql', 'rest', 'scrape', 'manual']),
16+ auth: z.enum(['none', 'api_key', 'oauth', 'account', 'controlled']),
17+ baseUrl: z.string().url().optional(),
18+ }),
19+ license: z.string(),
20+ licenseStatus: z.enum(['review', 'approved', 'restricted', 'blocked']),
21+ commercialUse: z.enum(['allowed', 'restricted', 'prohibited', 'unknown']),
22+ redistribution: z.enum(['allowed', 'attribution', 'restricted', 'prohibited', 'unknown']),
23+ attribution: z.string().optional(),
24+ termsReviewedAt: z.string().optional(), // YYYY-MM-DD, human review date
25+ termsNotes: z.string().optional(),
26+ updateFrequency: z.string(),
27+ expectedLatency: z.string().optional(),
28+ supportsIncrementalSync: z.boolean(),
29+ entities: z.array(z.string()),
30+ metrics: z.array(z.string()).default([]),
31+ rateLimits: z.object({
32+ requestsPerSecond: z.number().positive().optional(),
33+ requestsPerMinute: z.number().positive().optional(),
34+ maxConcurrency: z.number().int().positive().default(1),
35+ notes: z.string().optional(),
36+ }),
37+ retryPolicy: z
38+ .object({
39+ maxRetries: z.number().int().min(0).default(4),
40+ baseDelayMs: z.number().int().min(0).default(500),
41+ maxDelayMs: z.number().int().min(0).default(30_000),
42+ })
43+ .default({ maxRetries: 4, baseDelayMs: 500, maxDelayMs: 30_000 }),
44+ rawRetention: z.enum(['full', 'summary', 'none']).default('full'),
45+ schemaVersion: z.string().default('1'),
46+ documentationVerifiedAt: z.string(), // YYYY-MM-DD, the date official docs were checked (CLAUDE.md §224-225)
47+ owner: z.string().default('cancerindex'),
48+ status: z.enum(['planned', 'active', 'paused', 'awaiting_credentials', 'review', 'retired']),
49+ schedule: z.string().optional(), // cron expression for the worker scheduler
50+ anomalyGuard: z
51+ .object({
52+ /** Refuse to mark records source_missing when the fetched count drops below this ratio of the previous run. */
53+ minRatioOfPrevious: z.number().min(0).max(1).default(0.5),
54+ })
55+ .default({ minRatioOfPrevious: 0.5 }),
56+});
57+export type ConnectorManifest = z.infer<typeof ConnectorManifest>;
58+
59+export function defineManifest(m: z.input<typeof ConnectorManifest>): ConnectorManifest {
60+ return ConnectorManifest.parse(m);
61+}
added packages/connectors/src/sdk/run.ts +0 −0

Binary file not shown.

added packages/connectors/src/sdk/validate.ts +49 −0
@@ -0,0 +1,49 @@
1+/** Scientific invariants applied to normalized records (CLAUDE.md §110, §154). */
2+
3+export interface ValidationIssue {
4+ field: string;
5+ message: string;
6+}
7+
8+export function validateEpidemiology(o: { metric: string; value: number; year: number; lowerCi?: number | null; upperCi?: number | null; unit: string }): ValidationIssue[] {
9+ const issues: ValidationIssue[] = [];
10+ if (!Number.isFinite(o.value)) issues.push({ field: 'value', message: 'not finite' });
11+ if (o.value < 0) issues.push({ field: 'value', message: 'negative count/rate' });
12+ if (o.year < 1900 || o.year > new Date().getFullYear() + 30) issues.push({ field: 'year', message: `implausible year ${o.year}` });
13+ if (o.lowerCi != null && o.lowerCi > o.value) issues.push({ field: 'lowerCi', message: 'lowerCI > estimate' });
14+ if (o.upperCi != null && o.upperCi < o.value) issues.push({ field: 'upperCi', message: 'upperCI < estimate' });
15+ if (o.metric.endsWith('_count') && o.unit !== 'count') issues.push({ field: 'unit', message: 'count metric must use unit=count' });
16+ if (o.metric.endsWith('_rate') && o.unit !== 'per_100k') issues.push({ field: 'unit', message: 'rate metric must use unit=per_100k' });
17+ return issues;
18+}
19+
20+export function validateSurvival(o: { probability?: number | null; durationMonths: number; lowerCi?: number | null; upperCi?: number | null; cohortSize?: number | null }): ValidationIssue[] {
21+ const issues: ValidationIssue[] = [];
22+ if (o.probability != null && (o.probability < 0 || o.probability > 1)) issues.push({ field: 'probability', message: 'survival must be within [0,1]' });
23+ if (o.durationMonths <= 0) issues.push({ field: 'durationMonths', message: 'duration must be positive' });
24+ if (o.probability != null && o.lowerCi != null && o.lowerCi > o.probability) issues.push({ field: 'lowerCi', message: 'lowerCI > estimate' });
25+ if (o.probability != null && o.upperCi != null && o.upperCi < o.probability) issues.push({ field: 'upperCi', message: 'upperCI < estimate' });
26+ if (o.cohortSize != null && o.cohortSize < 0) issues.push({ field: 'cohortSize', message: 'negative cohort' });
27+ return issues;
28+}
29+
30+export const TRIAL_PHASES = new Set(['EARLY_PHASE1', 'PHASE1', 'PHASE2', 'PHASE3', 'PHASE4', 'NA']);
31+export const TRIAL_STATUSES = new Set(['ACTIVE_NOT_RECRUITING', 'COMPLETED', 'ENROLLING_BY_INVITATION', 'NOT_YET_RECRUITING', 'RECRUITING', 'SUSPENDED', 'TERMINATED', 'WITHDRAWN', 'AVAILABLE', 'NO_LONGER_AVAILABLE', 'TEMPORARILY_NOT_AVAILABLE', 'APPROVED_FOR_MARKETING', 'WITHHELD', 'UNKNOWN']);
32+
33+export function validateTrial(o: { nctId: string; phases: string[]; overallStatus?: string | null }): ValidationIssue[] {
34+ const issues: ValidationIssue[] = [];
35+ if (!/^NCT\d{8}$/.test(o.nctId)) issues.push({ field: 'nctId', message: 'malformed NCT id' });
36+ for (const p of o.phases) if (!TRIAL_PHASES.has(p)) issues.push({ field: 'phases', message: `unknown phase enum ${p}` });
37+ if (o.overallStatus && !TRIAL_STATUSES.has(o.overallStatus)) issues.push({ field: 'overallStatus', message: `unknown status enum ${o.overallStatus}` });
38+ return issues;
39+}
40+
41+export function validateFrequency(o: { casesAffected: number; casesProfiled: number }): ValidationIssue[] {
42+ const issues: ValidationIssue[] = [];
43+ if (o.casesProfiled <= 0) issues.push({ field: 'casesProfiled', message: 'denominator must be positive' });
44+ if (o.casesAffected < 0 || o.casesAffected > o.casesProfiled) issues.push({ field: 'casesAffected', message: 'numerator out of range' });
45+ return issues;
46+}
47+
48+/** Gene symbol sanity (HGNC style). */
49+export const GENE_SYMBOL_RE = /^[A-Z][A-Z0-9-]{0,15}(?:@|orf\d+)?$/i;
added packages/connectors/src/sources-sync.ts +51 −0
@@ -0,0 +1,51 @@
1+import { eq } from 'drizzle-orm';
2+import { type Database, sources, mintId } from '@cancerindex/database';
3+import { CONNECTORS } from './registry.js';
4+import type { ConnectorManifest } from './sdk/manifest.js';
5+
6+/** Sync connector manifests into the `sources` registry (CLAUDE.md §95, §142, §307). Idempotent. */
7+export async function syncSources(db: Database, extra: ConnectorManifest[] = []): Promise<{ created: number; updated: number }> {
8+ let created = 0;
9+ let updated = 0;
10+ const manifests = [...CONNECTORS.map((c) => c.manifest), ...extra];
11+ for (const m of manifests) {
12+ const values = {
13+ slug: m.id,
14+ name: m.name,
15+ organization: m.organization,
16+ category: m.category,
17+ description: m.description,
18+ homepage: m.homepage,
19+ docsUrl: m.docsUrl ?? null,
20+ termsUrl: m.termsUrl ?? null,
21+ accessType: m.access.type,
22+ accessAuth: m.access.auth,
23+ license: m.license,
24+ licenseStatus: m.licenseStatus,
25+ commercialUse: m.commercialUse,
26+ redistribution: m.redistribution,
27+ attribution: m.attribution ?? null,
28+ licenseReviewedAt: m.termsReviewedAt ? new Date(m.termsReviewedAt) : null,
29+ approvedForProduction: m.licenseStatus === 'approved',
30+ updateFrequency: m.updateFrequency,
31+ supportsIncremental: m.supportsIncrementalSync,
32+ entities: m.entities,
33+ metrics: m.metrics,
34+ rateLimit: m.rateLimits.requestsPerSecond ? `${m.rateLimits.requestsPerSecond} req/s` : m.rateLimits.requestsPerMinute ? `${m.rateLimits.requestsPerMinute} req/min` : null,
35+ tier: m.tier,
36+ manifest: m as unknown as Record<string, unknown>,
37+ updatedAt: new Date(),
38+ };
39+ const [existing] = await db.select({ id: sources.id, status: sources.status }).from(sources).where(eq(sources.slug, m.id)).limit(1);
40+ if (existing) {
41+ // Keep runtime status (active/degraded) unless manifest declares a gating status.
42+ const gating = ['awaiting_credentials', 'review', 'retired', 'paused'].includes(m.status);
43+ await db.update(sources).set({ ...values, ...(gating ? { status: m.status } : {}) }).where(eq(sources.id, existing.id));
44+ updated++;
45+ } else {
46+ await db.insert(sources).values({ id: await mintId(db, 'SOURCE'), ...values, status: m.status });
47+ created++;
48+ }
49+ }
50+ return { created, updated };
51+}
added packages/connectors/tsconfig.json +8 −0
@@ -0,0 +1,8 @@
1+{
2+ "extends": "../../tsconfig.base.json",
3+ "compilerOptions": {
4+ "rootDir": ".",
5+ "noEmit": true
6+ },
7+ "include": ["src", "test"]
8+}
added packages/connectors/vitest.config.ts +8 −0
@@ -0,0 +1,8 @@
1+import { defineConfig } from 'vitest/config';
2+
3+export default defineConfig({
4+ test: {
5+ include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
6+ testTimeout: 20_000,
7+ },
8+});
added packages/database/drizzle.config.ts +11 −0
@@ -0,0 +1,11 @@
1+import { defineConfig } from 'drizzle-kit';
2+
3+export default defineConfig({
4+ dialect: 'postgresql',
5+ schema: './src/schema/index.ts',
6+ out: './migrations',
7+ dbCredentials: { url: process.env.DATABASE_URL ?? 'postgres://localhost:5432/cancerindex' },
8+ casing: 'snake_case',
9+ strict: true,
10+ verbose: true,
11+});
added packages/database/migrations/0000_small_blazing_skull.sql +936 −0
@@ -0,0 +1,936 @@
1+CREATE TABLE "audit_log" (
2+ "id" bigserial PRIMARY KEY NOT NULL,
3+ "actor" text NOT NULL,
4+ "action" text NOT NULL,
5+ "entity_type" text,
6+ "entity_id" text,
7+ "before" jsonb,
8+ "after" jsonb,
9+ "reason" text,
10+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
11+);
12+--> statement-breakpoint
13+CREATE TABLE "change_events" (
14+ "id" bigserial PRIMARY KEY NOT NULL,
15+ "entity_type" text NOT NULL,
16+ "entity_id" text NOT NULL,
17+ "kind" text NOT NULL,
18+ "summary" text NOT NULL,
19+ "before" jsonb,
20+ "after" jsonb,
21+ "ingest_run_id" text,
22+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
23+);
24+--> statement-breakpoint
25+CREATE TABLE "connector_cursors" (
26+ "connector_id" text PRIMARY KEY NOT NULL,
27+ "cursor" jsonb DEFAULT '{}'::jsonb NOT NULL,
28+ "last_success_at" timestamp with time zone,
29+ "last_attempt_at" timestamp with time zone,
30+ "paused" boolean DEFAULT false NOT NULL,
31+ "health" text DEFAULT 'unknown' NOT NULL,
32+ "health_detail" text,
33+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
34+);
35+--> statement-breakpoint
36+CREATE TABLE "connector_field_stats" (
37+ "id" bigserial PRIMARY KEY NOT NULL,
38+ "connector_id" text NOT NULL,
39+ "entity" text NOT NULL,
40+ "field" text NOT NULL,
41+ "types" text[] DEFAULT '{}' NOT NULL,
42+ "seen_count" integer DEFAULT 0 NOT NULL,
43+ "null_count" integer DEFAULT 0 NOT NULL,
44+ "first_seen_run" text,
45+ "last_seen_run" text,
46+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
47+);
48+--> statement-breakpoint
49+CREATE TABLE "entity_merges" (
50+ "id" bigserial PRIMARY KEY NOT NULL,
51+ "entity_type" text NOT NULL,
52+ "keep_id" text NOT NULL,
53+ "merge_id" text NOT NULL,
54+ "evidence" jsonb DEFAULT '{}'::jsonb NOT NULL,
55+ "status" text DEFAULT 'proposed' NOT NULL,
56+ "decided_by" text,
57+ "decided_at" timestamp with time zone,
58+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
59+);
60+--> statement-breakpoint
61+CREATE TABLE "id_sequences" (
62+ "namespace" varchar(16) PRIMARY KEY NOT NULL,
63+ "next" bigint DEFAULT 1 NOT NULL
64+);
65+--> statement-breakpoint
66+CREATE TABLE "ingest_runs" (
67+ "id" text PRIMARY KEY NOT NULL,
68+ "connector_id" text NOT NULL,
69+ "source_id" varchar(32) NOT NULL,
70+ "mode" text DEFAULT 'incremental' NOT NULL,
71+ "status" text DEFAULT 'running' NOT NULL,
72+ "started_at" timestamp with time zone DEFAULT now() NOT NULL,
73+ "finished_at" timestamp with time zone,
74+ "duration_ms" integer,
75+ "records_fetched" integer DEFAULT 0 NOT NULL,
76+ "records_created" integer DEFAULT 0 NOT NULL,
77+ "records_updated" integer DEFAULT 0 NOT NULL,
78+ "records_unchanged" integer DEFAULT 0 NOT NULL,
79+ "records_rejected" integer DEFAULT 0 NOT NULL,
80+ "http_requests" integer DEFAULT 0 NOT NULL,
81+ "http_failures" integer DEFAULT 0 NOT NULL,
82+ "rate_limit_events" integer DEFAULT 0 NOT NULL,
83+ "validation_failures" integer DEFAULT 0 NOT NULL,
84+ "schema_drift" jsonb DEFAULT '[]'::jsonb NOT NULL,
85+ "cursor_before" jsonb,
86+ "cursor_after" jsonb,
87+ "error" text,
88+ "log" jsonb DEFAULT '[]'::jsonb NOT NULL,
89+ "dataset_version" text,
90+ "anomaly" text
91+);
92+--> statement-breakpoint
93+CREATE TABLE "provenance" (
94+ "id" bigserial PRIMARY KEY NOT NULL,
95+ "public_id" varchar(32),
96+ "source_id" varchar(32) NOT NULL,
97+ "source_record_id" text,
98+ "source_url" text,
99+ "dataset" text,
100+ "dataset_version" text,
101+ "publication_id" varchar(32),
102+ "pmid" text,
103+ "doi" text,
104+ "retrieved_at" timestamp with time zone NOT NULL,
105+ "published_at" text,
106+ "updated_at_source" text,
107+ "geography" text,
108+ "population" text,
109+ "cohort_size" integer,
110+ "methodology" text,
111+ "evidence_type" text NOT NULL,
112+ "access_level" text DEFAULT 'open' NOT NULL,
113+ "confidence" real,
114+ "license" text,
115+ "ingest_run_id" text,
116+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
117+);
118+--> statement-breakpoint
119+CREATE TABLE "source_records" (
120+ "id" bigserial PRIMARY KEY NOT NULL,
121+ "source_id" varchar(32) NOT NULL,
122+ "entity_kind" text NOT NULL,
123+ "source_record_id" text NOT NULL,
124+ "payload_hash" text NOT NULL,
125+ "raw_path" text,
126+ "status" text DEFAULT 'active' NOT NULL,
127+ "first_seen_run" text,
128+ "last_seen_run" text,
129+ "retrieved_at" timestamp with time zone DEFAULT now() NOT NULL,
130+ "source_updated_at" timestamp with time zone,
131+ "canonical_type" text,
132+ "canonical_id" text,
133+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
134+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
135+);
136+--> statement-breakpoint
137+CREATE TABLE "sources" (
138+ "id" varchar(32) PRIMARY KEY NOT NULL,
139+ "slug" text NOT NULL,
140+ "name" text NOT NULL,
141+ "organization" text,
142+ "category" text NOT NULL,
143+ "description" text,
144+ "homepage" text,
145+ "docs_url" text,
146+ "terms_url" text,
147+ "access_type" text NOT NULL,
148+ "access_auth" text NOT NULL,
149+ "license" text,
150+ "license_status" text DEFAULT 'review' NOT NULL,
151+ "commercial_use" text DEFAULT 'unknown' NOT NULL,
152+ "redistribution" text DEFAULT 'unknown' NOT NULL,
153+ "attribution" text,
154+ "license_reviewed_at" timestamp with time zone,
155+ "approved_for_production" boolean DEFAULT false NOT NULL,
156+ "update_frequency" text,
157+ "supports_incremental" boolean DEFAULT false NOT NULL,
158+ "entities" text[] DEFAULT '{}' NOT NULL,
159+ "metrics" text[] DEFAULT '{}' NOT NULL,
160+ "rate_limit" text,
161+ "status" text DEFAULT 'planned' NOT NULL,
162+ "tier" integer DEFAULT 0 NOT NULL,
163+ "manifest" jsonb DEFAULT '{}'::jsonb NOT NULL,
164+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
165+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
166+);
167+--> statement-breakpoint
168+CREATE TABLE "unresolved_labels" (
169+ "id" bigserial PRIMARY KEY NOT NULL,
170+ "source_id" varchar(32) NOT NULL,
171+ "entity_kind" text NOT NULL,
172+ "source_text" text NOT NULL,
173+ "normalized" text NOT NULL,
174+ "context" jsonb DEFAULT '{}'::jsonb NOT NULL,
175+ "count" integer DEFAULT 1 NOT NULL,
176+ "status" text DEFAULT 'open' NOT NULL,
177+ "suggested_id" text,
178+ "suggested_match_type" text,
179+ "suggested_score" real,
180+ "resolved_id" text,
181+ "resolved_by" text,
182+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
183+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
184+);
185+--> statement-breakpoint
186+CREATE TABLE "anatomical_sites" (
187+ "id" varchar(32) PRIMARY KEY NOT NULL,
188+ "name" text NOT NULL,
189+ "slug" text NOT NULL,
190+ "ncit_code" text,
191+ "uberon_id" text,
192+ "parent_id" varchar(32),
193+ "system" text
194+);
195+--> statement-breakpoint
196+CREATE TABLE "cancer_aliases" (
197+ "id" bigserial PRIMARY KEY NOT NULL,
198+ "cancer_id" varchar(32) NOT NULL,
199+ "alias" text NOT NULL,
200+ "normalized" text NOT NULL,
201+ "alias_type" text DEFAULT 'synonym' NOT NULL,
202+ "source_id" varchar(32),
203+ "source_terminology" text,
204+ "language" text DEFAULT 'en' NOT NULL
205+);
206+--> statement-breakpoint
207+CREATE TABLE "cancer_anatomy" (
208+ "id" bigserial PRIMARY KEY NOT NULL,
209+ "cancer_id" varchar(32) NOT NULL,
210+ "site_id" varchar(32) NOT NULL,
211+ "relation" text DEFAULT 'primary' NOT NULL,
212+ "source_id" varchar(32)
213+);
214+--> statement-breakpoint
215+CREATE TABLE "cancer_codes" (
216+ "id" bigserial PRIMARY KEY NOT NULL,
217+ "cancer_id" varchar(32) NOT NULL,
218+ "system" text NOT NULL,
219+ "code" text NOT NULL,
220+ "match_type" text DEFAULT 'EXACT_IDENTIFIER' NOT NULL,
221+ "source_id" varchar(32),
222+ "valid_from" text,
223+ "valid_to" text
224+);
225+--> statement-breakpoint
226+CREATE TABLE "cancer_hierarchy" (
227+ "id" bigserial PRIMARY KEY NOT NULL,
228+ "parent_id" varchar(32) NOT NULL,
229+ "child_id" varchar(32) NOT NULL,
230+ "hierarchy_type" text NOT NULL,
231+ "source_id" varchar(32)
232+);
233+--> statement-breakpoint
234+CREATE TABLE "cancers" (
235+ "id" varchar(32) PRIMARY KEY NOT NULL,
236+ "slug" text NOT NULL,
237+ "canonical_name" text NOT NULL,
238+ "short_name" text,
239+ "entity_type" text DEFAULT 'cancer' NOT NULL,
240+ "malignant" boolean DEFAULT true NOT NULL,
241+ "solid_tumor" boolean DEFAULT true NOT NULL,
242+ "hematologic" boolean DEFAULT false NOT NULL,
243+ "pediatric_relevant" boolean DEFAULT false NOT NULL,
244+ "rare_cancer" boolean,
245+ "top_level" boolean DEFAULT false NOT NULL,
246+ "description" text,
247+ "description_provenance_id" integer,
248+ "primary_ncit_code" text,
249+ "primary_oncotree_code" text,
250+ "depth" integer DEFAULT 0 NOT NULL,
251+ "status" text DEFAULT 'active' NOT NULL,
252+ "merged_into" varchar(32),
253+ "deprecated_reason" text,
254+ "classification_version" text,
255+ "semantic_types" text[] DEFAULT '{}' NOT NULL,
256+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
257+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
258+);
259+--> statement-breakpoint
260+CREATE TABLE "cohort_definitions" (
261+ "id" bigserial PRIMARY KEY NOT NULL,
262+ "name" text NOT NULL,
263+ "cancer_id" varchar(32) NOT NULL,
264+ "biomarker_ids" text[] DEFAULT '{}' NOT NULL,
265+ "variant_ids" text[] DEFAULT '{}' NOT NULL,
266+ "stage" text,
267+ "attributes" jsonb DEFAULT '{}'::jsonb NOT NULL,
268+ "confidence" real,
269+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
270+);
271+--> statement-breakpoint
272+CREATE TABLE "geographies" (
273+ "id" varchar(32) PRIMARY KEY NOT NULL,
274+ "slug" text NOT NULL,
275+ "name" text NOT NULL,
276+ "kind" text NOT NULL,
277+ "iso2" text,
278+ "iso3" text,
279+ "parent_id" varchar(32),
280+ "who_region" text,
281+ "population" integer,
282+ "population_year" integer
283+);
284+--> statement-breakpoint
285+CREATE TABLE "biomarkers" (
286+ "id" varchar(32) PRIMARY KEY NOT NULL,
287+ "slug" text NOT NULL,
288+ "name" text NOT NULL,
289+ "kind" text NOT NULL,
290+ "gene_id" varchar(32),
291+ "ncit_code" text,
292+ "description" text,
293+ "measurement" jsonb DEFAULT '{}'::jsonb NOT NULL,
294+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
295+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
296+);
297+--> statement-breakpoint
298+CREATE TABLE "cancer_gene_frequencies" (
299+ "id" bigserial PRIMARY KEY NOT NULL,
300+ "cohort_id" varchar(32) NOT NULL,
301+ "cancer_id" varchar(32),
302+ "gene_id" varchar(32),
303+ "gene_symbol" text NOT NULL,
304+ "alteration_type" text DEFAULT 'ssm' NOT NULL,
305+ "cases_affected" integer NOT NULL,
306+ "cases_profiled" integer NOT NULL,
307+ "frequency" real NOT NULL,
308+ "rank" integer,
309+ "data_release" text,
310+ "provenance_id" integer NOT NULL,
311+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
312+);
313+--> statement-breakpoint
314+CREATE TABLE "entity_embeddings" (
315+ "id" bigserial PRIMARY KEY NOT NULL,
316+ "entity_type" text NOT NULL,
317+ "entity_id" text NOT NULL,
318+ "model" text NOT NULL,
319+ "dimensions" integer NOT NULL,
320+ "text_hash" text NOT NULL,
321+ "embedding" text,
322+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
323+);
324+--> statement-breakpoint
325+CREATE TABLE "gene_aliases" (
326+ "id" bigserial PRIMARY KEY NOT NULL,
327+ "gene_id" varchar(32) NOT NULL,
328+ "alias" text NOT NULL,
329+ "alias_type" text NOT NULL,
330+ "source_id" varchar(32)
331+);
332+--> statement-breakpoint
333+CREATE TABLE "genes" (
334+ "id" varchar(32) PRIMARY KEY NOT NULL,
335+ "hgnc_id" text,
336+ "symbol" text NOT NULL,
337+ "name" text,
338+ "locus_type" text,
339+ "locus_group" text,
340+ "location" text,
341+ "chromosome" text,
342+ "ensembl_gene_id" text,
343+ "ncbi_gene_id" text,
344+ "omim_ids" text[] DEFAULT '{}' NOT NULL,
345+ "uniprot_ids" text[] DEFAULT '{}' NOT NULL,
346+ "refseq_accession" text,
347+ "prev_symbols" text[] DEFAULT '{}' NOT NULL,
348+ "alias_symbols" text[] DEFAULT '{}' NOT NULL,
349+ "gene_families" text[] DEFAULT '{}' NOT NULL,
350+ "status" text DEFAULT 'Approved' NOT NULL,
351+ "is_cancer_gene" boolean DEFAULT false NOT NULL,
352+ "civic_gene_id" integer,
353+ "description" text,
354+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
355+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
356+);
357+--> statement-breakpoint
358+CREATE TABLE "genomic_cohorts" (
359+ "id" varchar(32) PRIMARY KEY NOT NULL,
360+ "source_id" varchar(32) NOT NULL,
361+ "study_id" text NOT NULL,
362+ "name" text NOT NULL,
363+ "program" text,
364+ "primary_sites" text[] DEFAULT '{}' NOT NULL,
365+ "disease_types" text[] DEFAULT '{}' NOT NULL,
366+ "cancer_id" varchar(32),
367+ "cancer_match_type" text,
368+ "case_count" integer,
369+ "cases_with_ssm" integer,
370+ "data_release" text,
371+ "access_level" text DEFAULT 'open' NOT NULL,
372+ "url" text,
373+ "provenance_id" integer,
374+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
375+);
376+--> statement-breakpoint
377+CREATE TABLE "variant_aliases" (
378+ "id" bigserial PRIMARY KEY NOT NULL,
379+ "variant_id" varchar(32) NOT NULL,
380+ "alias" text NOT NULL,
381+ "source_id" varchar(32)
382+);
383+--> statement-breakpoint
384+CREATE TABLE "variant_clinical_significance" (
385+ "id" bigserial PRIMARY KEY NOT NULL,
386+ "variant_id" varchar(32) NOT NULL,
387+ "clinvar_variation_id" text NOT NULL,
388+ "clinical_significance" text NOT NULL,
389+ "review_status" text,
390+ "star_rating" integer,
391+ "last_evaluated" text,
392+ "conditions" text[] DEFAULT '{}' NOT NULL,
393+ "condition_cancer_ids" text[] DEFAULT '{}' NOT NULL,
394+ "origin_simple" text,
395+ "number_submitters" integer,
396+ "provenance_id" integer NOT NULL,
397+ "ingest_run_id" text,
398+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
399+);
400+--> statement-breakpoint
401+CREATE TABLE "variants" (
402+ "id" varchar(32) PRIMARY KEY NOT NULL,
403+ "slug" text NOT NULL,
404+ "gene_id" varchar(32),
405+ "gene_symbol" text,
406+ "name" text NOT NULL,
407+ "variant_type" text,
408+ "hgvs_g" text,
409+ "hgvs_c" text,
410+ "hgvs_p" text,
411+ "assembly" text,
412+ "chromosome" text,
413+ "start" integer,
414+ "end" integer,
415+ "reference_bases" text,
416+ "alternate_bases" text,
417+ "coordinates" jsonb DEFAULT '[]'::jsonb NOT NULL,
418+ "clinvar_variation_id" text,
419+ "civic_variant_id" integer,
420+ "dbsnp_ids" text[] DEFAULT '{}' NOT NULL,
421+ "fusion_partners" text[] DEFAULT '{}' NOT NULL,
422+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
423+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
424+);
425+--> statement-breakpoint
426+CREATE TABLE "drug_aliases" (
427+ "id" bigserial PRIMARY KEY NOT NULL,
428+ "drug_id" varchar(32) NOT NULL,
429+ "alias" text NOT NULL,
430+ "normalized" text NOT NULL,
431+ "alias_type" text DEFAULT 'synonym' NOT NULL,
432+ "source_id" varchar(32)
433+);
434+--> statement-breakpoint
435+CREATE TABLE "drug_approvals" (
436+ "id" bigserial PRIMARY KEY NOT NULL,
437+ "drug_id" varchar(32) NOT NULL,
438+ "cancer_id" varchar(32),
439+ "biomarker_ids" text[] DEFAULT '{}' NOT NULL,
440+ "tumor_agnostic" boolean DEFAULT false NOT NULL,
441+ "jurisdiction" text NOT NULL,
442+ "authority" text NOT NULL,
443+ "indication" text NOT NULL,
444+ "line_of_therapy" text,
445+ "disease_stage" text,
446+ "approval_type" text,
447+ "accelerated" boolean,
448+ "conditional" boolean,
449+ "approval_date" text,
450+ "withdrawal_date" text,
451+ "status" text NOT NULL,
452+ "application_number" text,
453+ "source_id" varchar(32) NOT NULL,
454+ "provenance_id" integer NOT NULL,
455+ "raw" jsonb,
456+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
457+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
458+);
459+--> statement-breakpoint
460+CREATE TABLE "drugs" (
461+ "id" varchar(32) PRIMARY KEY NOT NULL,
462+ "slug" text NOT NULL,
463+ "name" text NOT NULL,
464+ "kind" text,
465+ "ncit_code" text,
466+ "chembl_id" text,
467+ "civic_therapy_id" integer,
468+ "drugbank_id" text,
469+ "pubchem_cid" text,
470+ "unii" text,
471+ "mechanism" text,
472+ "target_gene_ids" text[] DEFAULT '{}' NOT NULL,
473+ "development_status" text,
474+ "description" text,
475+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
476+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
477+);
478+--> statement-breakpoint
479+CREATE TABLE "treatment_regimens" (
480+ "id" varchar(32) PRIMARY KEY NOT NULL,
481+ "slug" text NOT NULL,
482+ "name" text NOT NULL,
483+ "component_drug_ids" text[] DEFAULT '{}' NOT NULL,
484+ "modality" text DEFAULT 'drug_combination' NOT NULL,
485+ "description" text,
486+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
487+);
488+--> statement-breakpoint
489+CREATE TABLE "clinical_trials" (
490+ "id" varchar(32) PRIMARY KEY NOT NULL,
491+ "nct_id" text NOT NULL,
492+ "brief_title" text NOT NULL,
493+ "official_title" text,
494+ "acronym" text,
495+ "study_type" text,
496+ "phases" text[] DEFAULT '{}' NOT NULL,
497+ "overall_status" text,
498+ "why_stopped" text,
499+ "start_date" text,
500+ "primary_completion_date" text,
501+ "completion_date" text,
502+ "first_posted_date" text,
503+ "last_update_posted_date" text,
504+ "results_first_posted_date" text,
505+ "has_results" boolean DEFAULT false NOT NULL,
506+ "enrollment_count" integer,
507+ "enrollment_type" text,
508+ "lead_sponsor" text,
509+ "lead_sponsor_class" text,
510+ "collaborators" text[] DEFAULT '{}' NOT NULL,
511+ "conditions" text[] DEFAULT '{}' NOT NULL,
512+ "keywords" text[] DEFAULT '{}' NOT NULL,
513+ "interventions" jsonb DEFAULT '[]'::jsonb NOT NULL,
514+ "arms" jsonb DEFAULT '[]'::jsonb NOT NULL,
515+ "primary_outcomes" jsonb DEFAULT '[]'::jsonb NOT NULL,
516+ "secondary_outcomes" jsonb DEFAULT '[]'::jsonb NOT NULL,
517+ "eligibility" jsonb DEFAULT '{}'::jsonb NOT NULL,
518+ "sex" text,
519+ "minimum_age" text,
520+ "maximum_age" text,
521+ "countries" text[] DEFAULT '{}' NOT NULL,
522+ "locations_count" integer DEFAULT 0 NOT NULL,
523+ "references" jsonb DEFAULT '[]'::jsonb NOT NULL,
524+ "brief_summary" text,
525+ "is_oncology" boolean DEFAULT true NOT NULL,
526+ "source_record_id" integer,
527+ "ingest_run_id" text,
528+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
529+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
530+);
531+--> statement-breakpoint
532+CREATE TABLE "trial_conditions" (
533+ "id" bigserial PRIMARY KEY NOT NULL,
534+ "trial_id" varchar(32) NOT NULL,
535+ "condition_text" text NOT NULL,
536+ "normalized" text NOT NULL,
537+ "cancer_id" varchar(32),
538+ "match_type" text DEFAULT 'UNRESOLVED' NOT NULL,
539+ "confidence" real
540+);
541+--> statement-breakpoint
542+CREATE TABLE "trial_interventions" (
543+ "id" bigserial PRIMARY KEY NOT NULL,
544+ "trial_id" varchar(32) NOT NULL,
545+ "name" text NOT NULL,
546+ "normalized" text NOT NULL,
547+ "intervention_type" text,
548+ "drug_id" varchar(32),
549+ "match_type" text DEFAULT 'UNRESOLVED' NOT NULL
550+);
551+--> statement-breakpoint
552+CREATE TABLE "trial_locations" (
553+ "id" bigserial PRIMARY KEY NOT NULL,
554+ "trial_id" varchar(32) NOT NULL,
555+ "facility" text,
556+ "city" text,
557+ "state" text,
558+ "zip" text,
559+ "country" text,
560+ "status" text,
561+ "lat" real,
562+ "lng" real
563+);
564+--> statement-breakpoint
565+CREATE TABLE "trial_pulse" (
566+ "id" bigserial PRIMARY KEY NOT NULL,
567+ "day" date NOT NULL,
568+ "cancer_id" varchar(32),
569+ "phase" text,
570+ "new_trials" integer NOT NULL,
571+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
572+);
573+--> statement-breakpoint
574+CREATE TABLE "literature_counts" (
575+ "id" bigserial PRIMARY KEY NOT NULL,
576+ "cancer_id" varchar(32) NOT NULL,
577+ "window_key" text NOT NULL,
578+ "window_start" text,
579+ "window_end" text,
580+ "query" text NOT NULL,
581+ "count" integer NOT NULL,
582+ "provenance_id" integer NOT NULL,
583+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
584+);
585+--> statement-breakpoint
586+CREATE TABLE "publication_entity_edges" (
587+ "id" bigserial PRIMARY KEY NOT NULL,
588+ "publication_id" varchar(32) NOT NULL,
589+ "entity_type" text NOT NULL,
590+ "entity_id" text NOT NULL,
591+ "method" text NOT NULL,
592+ "confidence" real,
593+ "status" text DEFAULT 'candidate' NOT NULL,
594+ "source_id" varchar(32),
595+ "ingest_run_id" text,
596+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
597+);
598+--> statement-breakpoint
599+CREATE TABLE "publications" (
600+ "id" varchar(32) PRIMARY KEY NOT NULL,
601+ "pmid" text,
602+ "doi" text,
603+ "pmcid" text,
604+ "title" text NOT NULL,
605+ "abstract" text,
606+ "journal" text,
607+ "journal_iso" text,
608+ "pub_date" text,
609+ "pub_year" integer,
610+ "publication_types" text[] DEFAULT '{}' NOT NULL,
611+ "mesh_terms" jsonb DEFAULT '[]'::jsonb NOT NULL,
612+ "authors" jsonb DEFAULT '[]'::jsonb NOT NULL,
613+ "language" text,
614+ "is_preprint" boolean DEFAULT false NOT NULL,
615+ "retracted" boolean DEFAULT false NOT NULL,
616+ "retraction_notice" text,
617+ "nct_ids" text[] DEFAULT '{}' NOT NULL,
618+ "cited_by_count" integer,
619+ "source_record_id" integer,
620+ "ingest_run_id" text,
621+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
622+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
623+);
624+--> statement-breakpoint
625+CREATE TABLE "civic_evidence_items" (
626+ "id" bigserial PRIMARY KEY NOT NULL,
627+ "civic_id" integer NOT NULL,
628+ "name" text,
629+ "molecular_profile_id" integer,
630+ "molecular_profile_name" text,
631+ "gene_symbols" text[] DEFAULT '{}' NOT NULL,
632+ "gene_ids" text[] DEFAULT '{}' NOT NULL,
633+ "variant_ids" text[] DEFAULT '{}' NOT NULL,
634+ "civic_variant_ids" integer[] DEFAULT '{}' NOT NULL,
635+ "disease_name" text,
636+ "doid" text,
637+ "cancer_id" varchar(32),
638+ "cancer_match_type" text,
639+ "therapy_names" text[] DEFAULT '{}' NOT NULL,
640+ "therapy_ids" text[] DEFAULT '{}' NOT NULL,
641+ "therapy_interaction_type" text,
642+ "evidence_type" text,
643+ "evidence_level" text,
644+ "evidence_direction" text,
645+ "significance" text,
646+ "evidence_rating" integer,
647+ "status" text,
648+ "description" text,
649+ "pmid" text,
650+ "source_citation" text,
651+ "phenotypes" text[] DEFAULT '{}' NOT NULL,
652+ "provenance_id" integer NOT NULL,
653+ "ingest_run_id" text,
654+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
655+);
656+--> statement-breakpoint
657+CREATE TABLE "knowledge_edges" (
658+ "id" bigserial PRIMARY KEY NOT NULL,
659+ "source_entity_type" text NOT NULL,
660+ "source_entity_id" text NOT NULL,
661+ "target_entity_type" text NOT NULL,
662+ "target_entity_id" text NOT NULL,
663+ "relationship_type" text NOT NULL,
664+ "cancer_context_ids" text[] DEFAULT '{}' NOT NULL,
665+ "predictive" boolean,
666+ "prognostic" boolean,
667+ "diagnostic" boolean,
668+ "predisposing" boolean,
669+ "direction" text,
670+ "evidence_level" text,
671+ "evidence_score" real,
672+ "evidence_category" text DEFAULT 'curated_evidence' NOT NULL,
673+ "status" text DEFAULT 'active' NOT NULL,
674+ "source_id" varchar(32) NOT NULL,
675+ "source_record_id" text,
676+ "provenance_ids" integer[] DEFAULT '{}' NOT NULL,
677+ "support_count" integer DEFAULT 1 NOT NULL,
678+ "first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
679+ "last_seen_at" timestamp with time zone DEFAULT now() NOT NULL
680+);
681+--> statement-breakpoint
682+CREATE TABLE "risk_factors" (
683+ "id" bigserial PRIMARY KEY NOT NULL,
684+ "slug" text NOT NULL,
685+ "name" text NOT NULL,
686+ "kind" text NOT NULL,
687+ "classification_authority" text,
688+ "classification" text,
689+ "description" text,
690+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
691+);
692+--> statement-breakpoint
693+CREATE TABLE "epidemiology_observations" (
694+ "id" bigserial PRIMARY KEY NOT NULL,
695+ "cancer_id" varchar(32) NOT NULL,
696+ "geography_id" varchar(32) NOT NULL,
697+ "year" integer NOT NULL,
698+ "year_end" integer,
699+ "sex" text DEFAULT 'all' NOT NULL,
700+ "age_group" text DEFAULT 'all' NOT NULL,
701+ "metric" text NOT NULL,
702+ "value" double precision NOT NULL,
703+ "unit" text NOT NULL,
704+ "lower_ci" double precision,
705+ "upper_ci" double precision,
706+ "standard_population" text,
707+ "estimate_type" text DEFAULT 'observed' NOT NULL,
708+ "site_definition" text,
709+ "source_id" varchar(32) NOT NULL,
710+ "provenance_id" integer NOT NULL,
711+ "ingest_run_id" text,
712+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
713+);
714+--> statement-breakpoint
715+CREATE TABLE "survival_observations" (
716+ "id" bigserial PRIMARY KEY NOT NULL,
717+ "cancer_id" varchar(32) NOT NULL,
718+ "geography_id" varchar(32),
719+ "stage" text,
720+ "staging_system" text,
721+ "sex" text DEFAULT 'all' NOT NULL,
722+ "age_group" text DEFAULT 'all' NOT NULL,
723+ "diagnosis_period" text,
724+ "survival_type" text NOT NULL,
725+ "duration_months" integer NOT NULL,
726+ "probability" real,
727+ "median_months" real,
728+ "cohort_size" integer,
729+ "lower_ci" real,
730+ "upper_ci" real,
731+ "method" text,
732+ "source_id" varchar(32) NOT NULL,
733+ "provenance_id" integer NOT NULL,
734+ "ingest_run_id" text,
735+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
736+);
737+--> statement-breakpoint
738+CREATE TABLE "ai_answers" (
739+ "id" bigserial PRIMARY KEY NOT NULL,
740+ "kind" text NOT NULL,
741+ "subject_id" text,
742+ "question_hash" text NOT NULL,
743+ "question" text,
744+ "answer" jsonb NOT NULL,
745+ "model" text NOT NULL,
746+ "prompt_version" text NOT NULL,
747+ "source_snapshot" jsonb DEFAULT '{}'::jsonb NOT NULL,
748+ "data_as_of" timestamp with time zone NOT NULL,
749+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
750+);
751+--> statement-breakpoint
752+CREATE TABLE "api_keys" (
753+ "id" bigserial PRIMARY KEY NOT NULL,
754+ "key_hash" text NOT NULL,
755+ "prefix" text NOT NULL,
756+ "label" text,
757+ "owner_email" text,
758+ "tier" text DEFAULT 'free' NOT NULL,
759+ "rate_limit_per_minute" integer DEFAULT 60 NOT NULL,
760+ "active" boolean DEFAULT true NOT NULL,
761+ "last_used_at" timestamp with time zone,
762+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
763+);
764+--> statement-breakpoint
765+CREATE TABLE "entity_counters" (
766+ "id" bigserial PRIMARY KEY NOT NULL,
767+ "entity_type" text NOT NULL,
768+ "entity_id" text NOT NULL,
769+ "trial_count" integer DEFAULT 0 NOT NULL,
770+ "active_trial_count" integer DEFAULT 0 NOT NULL,
771+ "recruiting_trial_count" integer DEFAULT 0 NOT NULL,
772+ "phase3_trial_count" integer DEFAULT 0 NOT NULL,
773+ "publication_count" integer DEFAULT 0 NOT NULL,
774+ "publication_count_5y" integer DEFAULT 0 NOT NULL,
775+ "publication_count_12m" integer DEFAULT 0 NOT NULL,
776+ "gene_count" integer DEFAULT 0 NOT NULL,
777+ "variant_count" integer DEFAULT 0 NOT NULL,
778+ "drug_count" integer DEFAULT 0 NOT NULL,
779+ "approved_drug_count" integer DEFAULT 0 NOT NULL,
780+ "evidence_count" integer DEFAULT 0 NOT NULL,
781+ "cohort_count" integer DEFAULT 0 NOT NULL,
782+ "subtype_count" integer DEFAULT 0 NOT NULL,
783+ "descendant_count" integer DEFAULT 0 NOT NULL,
784+ "epidemiology_obs_count" integer DEFAULT 0 NOT NULL,
785+ "survival_obs_count" integer DEFAULT 0 NOT NULL,
786+ "completeness" jsonb DEFAULT '{}'::jsonb NOT NULL,
787+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
788+);
789+--> statement-breakpoint
790+CREATE TABLE "metric_definitions" (
791+ "id" varchar(32) PRIMARY KEY NOT NULL,
792+ "slug" text NOT NULL,
793+ "name" text NOT NULL,
794+ "description" text NOT NULL,
795+ "formula" text NOT NULL,
796+ "formula_version" text NOT NULL,
797+ "unit" text NOT NULL,
798+ "higher_is_worse" boolean,
799+ "aggregation" text,
800+ "valid_dimensions" text[] DEFAULT '{}' NOT NULL,
801+ "source_slugs" text[] DEFAULT '{}' NOT NULL,
802+ "category" text NOT NULL,
803+ "eligibility" jsonb DEFAULT '{}'::jsonb NOT NULL,
804+ "experimental" boolean DEFAULT false NOT NULL,
805+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
806+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
807+);
808+--> statement-breakpoint
809+CREATE TABLE "ranking_snapshots" (
810+ "id" bigserial PRIMARY KEY NOT NULL,
811+ "metric_id" varchar(32) NOT NULL,
812+ "metric_slug" text NOT NULL,
813+ "scope_key" text NOT NULL,
814+ "geography" text DEFAULT 'WORLD' NOT NULL,
815+ "sex" text DEFAULT 'all' NOT NULL,
816+ "age_group" text DEFAULT 'all' NOT NULL,
817+ "year" integer,
818+ "entity_level" text DEFAULT 'top' NOT NULL,
819+ "formula_version" text NOT NULL,
820+ "eligible_entities" integer NOT NULL,
821+ "inputs_hash" text NOT NULL,
822+ "source_ids" text[] DEFAULT '{}' NOT NULL,
823+ "is_current" boolean DEFAULT true NOT NULL,
824+ "generated_at" timestamp with time zone DEFAULT now() NOT NULL
825+);
826+--> statement-breakpoint
827+CREATE TABLE "rankings" (
828+ "id" bigserial PRIMARY KEY NOT NULL,
829+ "snapshot_id" integer NOT NULL,
830+ "metric_slug" text NOT NULL,
831+ "scope_key" text NOT NULL,
832+ "cancer_id" varchar(32) NOT NULL,
833+ "rank" integer NOT NULL,
834+ "eligible_entities" integer NOT NULL,
835+ "percentile" real NOT NULL,
836+ "value" double precision NOT NULL,
837+ "unit" text NOT NULL,
838+ "confidence" text DEFAULT 'MEDIUM' NOT NULL,
839+ "inputs" jsonb DEFAULT '{}'::jsonb NOT NULL,
840+ "breakdown" jsonb,
841+ "previous_rank" integer,
842+ "generated_at" timestamp with time zone DEFAULT now() NOT NULL
843+);
844+--> statement-breakpoint
845+CREATE INDEX "change_events_entity_idx" ON "change_events" USING btree ("entity_type","entity_id","created_at");--> statement-breakpoint
846+CREATE UNIQUE INDEX "connector_field_stats_uq" ON "connector_field_stats" USING btree ("connector_id","entity","field");--> statement-breakpoint
847+CREATE INDEX "ingest_runs_connector_idx" ON "ingest_runs" USING btree ("connector_id","started_at");--> statement-breakpoint
848+CREATE INDEX "provenance_source_idx" ON "provenance" USING btree ("source_id","source_record_id");--> statement-breakpoint
849+CREATE INDEX "provenance_pmid_idx" ON "provenance" USING btree ("pmid");--> statement-breakpoint
850+CREATE UNIQUE INDEX "source_records_uq" ON "source_records" USING btree ("source_id","entity_kind","source_record_id");--> statement-breakpoint
851+CREATE INDEX "source_records_canonical_idx" ON "source_records" USING btree ("canonical_type","canonical_id");--> statement-breakpoint
852+CREATE UNIQUE INDEX "sources_slug_uq" ON "sources" USING btree ("slug");--> statement-breakpoint
853+CREATE UNIQUE INDEX "unresolved_labels_uq" ON "unresolved_labels" USING btree ("source_id","entity_kind","normalized");--> statement-breakpoint
854+CREATE INDEX "unresolved_labels_count_idx" ON "unresolved_labels" USING btree ("status","count");--> statement-breakpoint
855+CREATE UNIQUE INDEX "anatomical_sites_slug_uq" ON "anatomical_sites" USING btree ("slug");--> statement-breakpoint
856+CREATE UNIQUE INDEX "cancer_aliases_uq" ON "cancer_aliases" USING btree ("cancer_id","normalized","alias_type");--> statement-breakpoint
857+CREATE INDEX "cancer_aliases_norm_idx" ON "cancer_aliases" USING btree ("normalized");--> statement-breakpoint
858+CREATE UNIQUE INDEX "cancer_anatomy_uq" ON "cancer_anatomy" USING btree ("cancer_id","site_id","relation");--> statement-breakpoint
859+CREATE UNIQUE INDEX "cancer_codes_uq" ON "cancer_codes" USING btree ("cancer_id","system","code");--> statement-breakpoint
860+CREATE INDEX "cancer_codes_lookup_idx" ON "cancer_codes" USING btree ("system","code");--> statement-breakpoint
861+CREATE UNIQUE INDEX "cancer_hierarchy_uq" ON "cancer_hierarchy" USING btree ("parent_id","child_id","hierarchy_type");--> statement-breakpoint
862+CREATE INDEX "cancer_hierarchy_child_idx" ON "cancer_hierarchy" USING btree ("child_id");--> statement-breakpoint
863+CREATE UNIQUE INDEX "cancers_slug_uq" ON "cancers" USING btree ("slug");--> statement-breakpoint
864+CREATE UNIQUE INDEX "cancers_ncit_uq" ON "cancers" USING btree ("primary_ncit_code");--> statement-breakpoint
865+CREATE INDEX "cancers_name_idx" ON "cancers" USING btree ("canonical_name");--> statement-breakpoint
866+CREATE INDEX "cancers_type_idx" ON "cancers" USING btree ("entity_type","malignant","top_level");--> statement-breakpoint
867+CREATE UNIQUE INDEX "geographies_slug_uq" ON "geographies" USING btree ("slug");--> statement-breakpoint
868+CREATE INDEX "geographies_iso3_idx" ON "geographies" USING btree ("iso3");--> statement-breakpoint
869+CREATE UNIQUE INDEX "biomarkers_slug_uq" ON "biomarkers" USING btree ("slug");--> statement-breakpoint
870+CREATE UNIQUE INDEX "cancer_gene_freq_uq" ON "cancer_gene_frequencies" USING btree ("cohort_id","gene_symbol","alteration_type");--> statement-breakpoint
871+CREATE INDEX "cancer_gene_freq_cancer_idx" ON "cancer_gene_frequencies" USING btree ("cancer_id","frequency");--> statement-breakpoint
872+CREATE INDEX "cancer_gene_freq_gene_idx" ON "cancer_gene_frequencies" USING btree ("gene_id");--> statement-breakpoint
873+CREATE UNIQUE INDEX "entity_embeddings_uq" ON "entity_embeddings" USING btree ("entity_type","entity_id","model");--> statement-breakpoint
874+CREATE UNIQUE INDEX "gene_aliases_uq" ON "gene_aliases" USING btree ("gene_id","alias","alias_type");--> statement-breakpoint
875+CREATE INDEX "gene_aliases_alias_idx" ON "gene_aliases" USING btree ("alias");--> statement-breakpoint
876+CREATE UNIQUE INDEX "genes_symbol_uq" ON "genes" USING btree ("symbol");--> statement-breakpoint
877+CREATE UNIQUE INDEX "genes_hgnc_uq" ON "genes" USING btree ("hgnc_id");--> statement-breakpoint
878+CREATE INDEX "genes_ensembl_idx" ON "genes" USING btree ("ensembl_gene_id");--> statement-breakpoint
879+CREATE INDEX "genes_ncbi_idx" ON "genes" USING btree ("ncbi_gene_id");--> statement-breakpoint
880+CREATE UNIQUE INDEX "genomic_cohorts_uq" ON "genomic_cohorts" USING btree ("source_id","study_id");--> statement-breakpoint
881+CREATE INDEX "genomic_cohorts_cancer_idx" ON "genomic_cohorts" USING btree ("cancer_id");--> statement-breakpoint
882+CREATE UNIQUE INDEX "variant_aliases_uq" ON "variant_aliases" USING btree ("variant_id","alias");--> statement-breakpoint
883+CREATE UNIQUE INDEX "variant_clinsig_uq" ON "variant_clinical_significance" USING btree ("clinvar_variation_id");--> statement-breakpoint
884+CREATE UNIQUE INDEX "variants_slug_uq" ON "variants" USING btree ("slug");--> statement-breakpoint
885+CREATE INDEX "variants_gene_idx" ON "variants" USING btree ("gene_id");--> statement-breakpoint
886+CREATE INDEX "variants_clinvar_idx" ON "variants" USING btree ("clinvar_variation_id");--> statement-breakpoint
887+CREATE INDEX "variants_civic_idx" ON "variants" USING btree ("civic_variant_id");--> statement-breakpoint
888+CREATE UNIQUE INDEX "drug_aliases_uq" ON "drug_aliases" USING btree ("drug_id","normalized","alias_type");--> statement-breakpoint
889+CREATE INDEX "drug_aliases_norm_idx" ON "drug_aliases" USING btree ("normalized");--> statement-breakpoint
890+CREATE INDEX "drug_approvals_drug_idx" ON "drug_approvals" USING btree ("drug_id");--> statement-breakpoint
891+CREATE INDEX "drug_approvals_cancer_idx" ON "drug_approvals" USING btree ("cancer_id");--> statement-breakpoint
892+CREATE UNIQUE INDEX "drugs_slug_uq" ON "drugs" USING btree ("slug");--> statement-breakpoint
893+CREATE INDEX "drugs_ncit_idx" ON "drugs" USING btree ("ncit_code");--> statement-breakpoint
894+CREATE INDEX "drugs_civic_idx" ON "drugs" USING btree ("civic_therapy_id");--> statement-breakpoint
895+CREATE INDEX "drugs_chembl_idx" ON "drugs" USING btree ("chembl_id");--> statement-breakpoint
896+CREATE UNIQUE INDEX "treatment_regimens_slug_uq" ON "treatment_regimens" USING btree ("slug");--> statement-breakpoint
897+CREATE UNIQUE INDEX "clinical_trials_nct_uq" ON "clinical_trials" USING btree ("nct_id");--> statement-breakpoint
898+CREATE INDEX "clinical_trials_status_idx" ON "clinical_trials" USING btree ("overall_status");--> statement-breakpoint
899+CREATE INDEX "clinical_trials_updated_idx" ON "clinical_trials" USING btree ("last_update_posted_date");--> statement-breakpoint
900+CREATE INDEX "clinical_trials_sponsor_idx" ON "clinical_trials" USING btree ("lead_sponsor");--> statement-breakpoint
901+CREATE UNIQUE INDEX "trial_conditions_uq" ON "trial_conditions" USING btree ("trial_id","normalized");--> statement-breakpoint
902+CREATE INDEX "trial_conditions_cancer_idx" ON "trial_conditions" USING btree ("cancer_id");--> statement-breakpoint
903+CREATE INDEX "trial_conditions_norm_idx" ON "trial_conditions" USING btree ("normalized");--> statement-breakpoint
904+CREATE UNIQUE INDEX "trial_interventions_uq" ON "trial_interventions" USING btree ("trial_id","normalized");--> statement-breakpoint
905+CREATE INDEX "trial_interventions_drug_idx" ON "trial_interventions" USING btree ("drug_id");--> statement-breakpoint
906+CREATE INDEX "trial_locations_trial_idx" ON "trial_locations" USING btree ("trial_id");--> statement-breakpoint
907+CREATE INDEX "trial_locations_country_idx" ON "trial_locations" USING btree ("country");--> statement-breakpoint
908+CREATE UNIQUE INDEX "trial_pulse_uq" ON "trial_pulse" USING btree ("day","cancer_id","phase");--> statement-breakpoint
909+CREATE UNIQUE INDEX "literature_counts_uq" ON "literature_counts" USING btree ("cancer_id","window_key");--> statement-breakpoint
910+CREATE INDEX "literature_counts_window_idx" ON "literature_counts" USING btree ("window_key","count");--> statement-breakpoint
911+CREATE UNIQUE INDEX "pub_entity_edges_uq" ON "publication_entity_edges" USING btree ("publication_id","entity_type","entity_id","method");--> statement-breakpoint
912+CREATE INDEX "pub_entity_edges_entity_idx" ON "publication_entity_edges" USING btree ("entity_type","entity_id");--> statement-breakpoint
913+CREATE UNIQUE INDEX "publications_pmid_uq" ON "publications" USING btree ("pmid");--> statement-breakpoint
914+CREATE INDEX "publications_doi_idx" ON "publications" USING btree ("doi");--> statement-breakpoint
915+CREATE INDEX "publications_year_idx" ON "publications" USING btree ("pub_year");--> statement-breakpoint
916+CREATE INDEX "publications_retracted_idx" ON "publications" USING btree ("retracted");--> statement-breakpoint
917+CREATE UNIQUE INDEX "civic_evidence_uq" ON "civic_evidence_items" USING btree ("civic_id");--> statement-breakpoint
918+CREATE INDEX "civic_evidence_cancer_idx" ON "civic_evidence_items" USING btree ("cancer_id");--> statement-breakpoint
919+CREATE INDEX "civic_evidence_gene_idx" ON "civic_evidence_items" USING btree ("gene_symbols");--> statement-breakpoint
920+CREATE UNIQUE INDEX "knowledge_edges_uq" ON "knowledge_edges" USING btree ("source_entity_type","source_entity_id","target_entity_type","target_entity_id","relationship_type","source_id","source_record_id");--> statement-breakpoint
921+CREATE INDEX "knowledge_edges_source_idx" ON "knowledge_edges" USING btree ("source_entity_type","source_entity_id","relationship_type");--> statement-breakpoint
922+CREATE INDEX "knowledge_edges_target_idx" ON "knowledge_edges" USING btree ("target_entity_type","target_entity_id","relationship_type");--> statement-breakpoint
923+CREATE UNIQUE INDEX "risk_factors_slug_uq" ON "risk_factors" USING btree ("slug");--> statement-breakpoint
924+CREATE UNIQUE INDEX "epi_obs_uq" ON "epidemiology_observations" USING btree ("cancer_id","geography_id","year","sex","age_group","metric","source_id","site_definition");--> statement-breakpoint
925+CREATE INDEX "epi_obs_lookup_idx" ON "epidemiology_observations" USING btree ("metric","geography_id","year","sex");--> statement-breakpoint
926+CREATE INDEX "epi_obs_cancer_idx" ON "epidemiology_observations" USING btree ("cancer_id","metric");--> statement-breakpoint
927+CREATE INDEX "survival_obs_cancer_idx" ON "survival_observations" USING btree ("cancer_id","survival_type","duration_months");--> statement-breakpoint
928+CREATE UNIQUE INDEX "ai_answers_uq" ON "ai_answers" USING btree ("kind","question_hash","prompt_version");--> statement-breakpoint
929+CREATE UNIQUE INDEX "api_keys_hash_uq" ON "api_keys" USING btree ("key_hash");--> statement-breakpoint
930+CREATE UNIQUE INDEX "entity_counters_uq" ON "entity_counters" USING btree ("entity_type","entity_id");--> statement-breakpoint
931+CREATE INDEX "entity_counters_trials_idx" ON "entity_counters" USING btree ("entity_type","active_trial_count");--> statement-breakpoint
932+CREATE UNIQUE INDEX "metric_definitions_slug_uq" ON "metric_definitions" USING btree ("slug");--> statement-breakpoint
933+CREATE INDEX "ranking_snapshots_lookup_idx" ON "ranking_snapshots" USING btree ("metric_slug","scope_key","is_current");--> statement-breakpoint
934+CREATE UNIQUE INDEX "rankings_uq" ON "rankings" USING btree ("snapshot_id","cancer_id");--> statement-breakpoint
935+CREATE INDEX "rankings_cancer_idx" ON "rankings" USING btree ("cancer_id","metric_slug");--> statement-breakpoint
936+CREATE INDEX "rankings_lookup_idx" ON "rankings" USING btree ("metric_slug","scope_key","rank");
\ No newline at end of file
added packages/database/migrations/meta/0000_snapshot.json +6918 −0
Large diff (6,919 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/migrations/meta/_journal.json +13 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/package.json +37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/client.ts +42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/ids.ts +25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/index.ts +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/migrate.ts +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/schema/_common.ts +23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/schema/cancers.ts +139 −0
Large diff (139 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/schema/drugs.ts +84 −0
Large diff (84 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/schema/epidemiology.ts +61 −0
Large diff (61 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/schema/evidence.ts +89 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/schema/genomics.ts +184 −0
Large diff (184 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/schema/index.ts +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/schema/publications.ts +68 −0
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/schema/rankings.ts +138 −0
Large diff (138 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/schema/sources.ts +228 −0
Large diff (228 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/schema/trials.ts +115 −0
Large diff (115 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/seed-data/geographies.ts +101 −0
Large diff (101 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/seed-data/metrics.ts +285 −0
Large diff (285 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/seed.ts +49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/tsconfig.json +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added packages/ontology/package.json +27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added packages/ontology/src/index.ts +3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added packages/ontology/src/qualifiers.ts +76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added packages/ontology/src/reconcile.ts +106 −0
Large diff (106 lines) is not loaded by default to keep the page fast.Load diff
added packages/ontology/src/top-level.ts +75 −0
Large diff (75 lines) is not loaded by default to keep the page fast.Load diff
added packages/ontology/test/qualifiers.test.ts +55 −0
Large diff (55 lines) is not loaded by default to keep the page fast.Load diff
added packages/ontology/tsconfig.json +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added packages/ontology/vitest.config.ts +3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added packages/ranking/package.json +28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added packages/ranking/src/counters.ts +140 −0
Large diff (140 lines) is not loaded by default to keep the page fast.Load diff
added packages/ranking/src/engine.ts +173 −0
Large diff (173 lines) is not loaded by default to keep the page fast.Load diff
added packages/ranking/src/index.ts +4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added packages/ranking/src/rank.ts +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added packages/ranking/src/trace.ts +51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added packages/ranking/test/rank.test.ts +33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added packages/ranking/tsconfig.json +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added packages/ranking/vitest.config.ts +3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added packages/shared/package.json +26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added packages/shared/src/env.ts +16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added packages/shared/src/ids.ts +70 −0
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
added packages/shared/src/index.ts +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added packages/shared/src/logger.ts +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added packages/shared/src/normalize.ts +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added packages/shared/src/provenance.ts +85 −0
Large diff (85 lines) is not loaded by default to keep the page fast.Load diff
added packages/shared/tsconfig.json +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added pnpm-lock.yaml +2023 −0
Large diff (2,023 lines) is not loaded by default to keep the page fast.Load diff
added pnpm-workspace.yaml +4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added scripts/ci.ts +136 −0
Large diff (136 lines) is not loaded by default to keep the page fast.Load diff
added tsconfig.base.json +22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added vitest.workspace.ts +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme