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