Skip to content

Database & schema

The system of record is a single PostgreSQL 16 database. The schema is defined once in api/app/models.py and applied through Alembic migrations. This page describes the tables, how they relate, and how to inspect them directly.

Connecting

The Postgres container publishes port 5432 on the host. Credentials come from deploy/.env (defaults shown — change them for a real deployment).

Setting Default
Host localhost
Port 5432
Database mdms
User mdms
Password change-me

Terminal, via the container (no local client needed):

docker exec -it deploy-postgres-1 psql -U mdms -d mdms

Terminal, with a local psql, or any GUI (DBeaver / TablePlus / Postgres.app / pgAdmin):

postgresql://mdms:change-me@localhost:5432/mdms

Useful psql commands: \dt (tables), \d sample (one table), \dv (views), \q (quit).

Entity overview

patient ──1:1── patient_pii            (identifiers, separated for access control)
   │
   │ 1:N
   ▼
 sample ──self─┐                        (parent_sample_id: derivatives)
   │           │
   │ 1:N       └── derivatives
   ▼
observation ──N:1── result_type         (what kind of result this is)
   │
   └──────────N:1── ingest_dataset      (which upload it came from — provenance)

result_type   catalog of result kinds (hr_status, somatic_mutation, …)
ingest_dataset one row per uploaded CSV (deduplicated by sha256)

Two convenience views roll observations up into availability: sample_data_catalog and patient_data_catalog.

Core tables

patient

The cohort roster. One row per study subject.

Column Type Notes
id bigint PK running integer
study_id text, unique human-readable study identifier (e.g. HGSC0001)
created_at timestamptz

patient_pii

Patient identifiers, kept in a separate table so access can be gated by role. 1:1 with patient. Empty in synthetic datasets.

Column Type Notes
patient_id bigint PK/FK → patient.id
hospital_id, full_name, dob text / date only visible to superuser / clinician

sample

A biological sample. Dual identity: a running id and a human-readable name (patientid_phase_site). Samples may be derivatives of other samples (self-reference).

Column Type Notes
id bigint PK
name text, unique HGSC0001_diagnosis_ovary
patient_id bigint FK → patient.id owner
sample_type text fresh_frozen, ffpe, organoid, ascites, plasma
timepoint text diagnosis / interval / relapse
anatomical_site, collection_site text
parent_sample_id bigint FK → sample.id set for derivatives
created_at timestamptz

result_type

Catalog of the kinds of results the system knows about. New codes are auto-registered on first upload.

Column Type Notes
id bigint PK
code text, unique hr_status, somatic_mutation, seq_logistics, …
label, category text category groups the UI (molecular / qc / logistics / clinical)
json_schema jsonb reserved, not currently used
typed_table text for the 4 promoted result kinds (see "Slice-4 tables" below), names the typed table that holds them (e.g. somatic_mutationsomatic_variant); NULL for ad-hoc types that only live in observation

observation

The Slice-1 results surface — one row per landed measurement. Carries a scalar value and the full source row as payload (nothing is dropped).

Column Type Notes
id bigint PK
sample_id bigint FK → sample.id XOR with patient_id
patient_id bigint FK → patient.id subject is a sample or a patient, not both
result_type_id bigint FK → result_type.id not null
value_num / value_text / value_bool the primary value (typed)
payload jsonb the entire source CSV row
unit, observed_at text / timestamptz
ingest_dataset_id bigint FK → ingest_dataset.id provenance
recorded_at timestamptz when it landed

Subject XOR

A CHECK constraint (observation_subject_xor) enforces exactly one of sample_id / patient_id.

ingest_dataset

Provenance: one row per uploaded CSV, deduplicated by sha256 (re-uploading the same file is a no-op).

Column Type Notes
id bigint PK
filename, sha256 text sha256 is unique
storage_pointer text local path or host:path to the raw file
uploaded_by, uploaded_at text / timestamptz
row_count, mapping, status int / jsonb / text the mapping used to interpret the file

Availability views

  • sample_data_catalog — per (sample, result_type): count and latest date.
  • patient_data_catalog — the same rolled up to the patient (via the sample, or a direct patient observation).

These power the "what data is available?" tables in the UI and the SDK available() call.

Reserved / unused tables

assay, result, artifact were reserved for an assay/result/artifact-linked model that Slice 4 ended up not needing — the four typed tables (somatic_variant, germline_variant, copy_number_segment, hrd_score; see "Slice-4 tables" below) each reference sample and ingest_dataset directly instead. These three tables remain empty and unused; they may be removed or repurposed in a later slice.

Auth tables (managed by Better Auth)

Ovcahub's user, session, account, verification are created and owned by the web app's auth library in the same database. FastAPI reads session + user to validate web sessions and derive roles; it does not write them.

Slice-1.5 tables

publication

Papers linked to cohorts, runs, and projects.

Column Type Notes
id bigint PK
title text, not null
journal, authors text
doi, pubmed_id text rendered as external links
year int
readme text markdown
owner_id text → user.id
created_at, updated_at timestamptz

project

Research projects grouping runs, cohorts, and publications.

Column Type Notes
id bigint PK
code text, unique slug (e.g. evolution)
name, description, readme text
owner_id text → user.id

cohort

Manual patient groupings. Many-to-many with patient via cohort_member.

Column Type Notes
id bigint PK
name, description, readme text
owner_id text → user.id

cohort_member

Patient membership in a cohort. Unique (cohort_id, patient_id).

resource

External file catalogue — reference genomes, annotation databases, etc.

Column Type Notes
id bigint PK
key text, unique SDK handle (e.g. hg38)
description, path, sha256 text
filesize bigint bytes
owner_id text → user.id

Free cross-links between any two entities. Canonical ordering (alphabetical type) prevents duplicate A↔B / B↔A rows.

Column Type Notes
id bigint PK
src_type, dst_type text one of: publication, project, cohort, run, patient, sample
src_id, dst_id bigint numeric id in the respective table
created_at timestamptz

No FK integrity

entity_link uses polymorphic ids — there is no database-level foreign key. Existence of both endpoints is validated at the API layer on insert.

Slice-5: discovery

discovery_index

A denormalized search index over the generic observation registry. Materialized view on Postgres (production), refreshed after every ingest; plain view on SQLite (dev/tests, no FTS/trigram — substring search only).

Column Type Notes
id bigint synthetic row id (row_number())
subject_type text sample or patient
subject_id, subject_label bigint, text the sample/patient id and its display name
patient_id, study_id bigint, text owning patient (present even for sample-level rows)
sample_name, sample_type, timepoint, anatomical_site text null for patient-level rows
result_type_code, result_type_label, category text from result_type
source_dataset_id, source_filename bigint, text provenance — the ingest_dataset this came from
n bigint count of observations of this type for this subject
latest timestamptz most recent recorded_at
search_text text concatenated human-readable fields + (Postgres only) top-level payload strings, used for FTS

Indexes (Postgres): a GIN index on to_tsvector('english', search_text) for full-text search, a GIN trigram index (pg_trgm) on search_text for fuzzy/substring matching, a unique index on id (required for REFRESH MATERIALIZED VIEW CONCURRENTLY), plus plain indexes on result_type_code, sample_type, timepoint.

Scope

discovery_index indexes the generic observation table only — not any typed result tables a later slice may add. This keeps the view simple; a future slice can extend it if the corpus needs typed-table search.

Refresh: refresh_discovery_index() (in api/app/db.py) runs REFRESH MATERIALIZED VIEW CONCURRENTLY discovery_index on Postgres (no-op on SQLite). It's called automatically after POST /ingest/csv (wrapped in try/except — a refresh failure never breaks an ingest), and can be triggered manually via POST /discover/refresh (superuser only).

Audit slice: application access log

access_log

One row per HTTP request, written by a FastAPI middleware. Complements pgaudit (a Postgres-level extension that logs writes to the server's text log files, not to a table — see the Audit logging page).

Column Type Notes
id bigint PK
ts timestamptz when the request happened
user_id, user_email, user_role text resolved from the bearer token; null if unauthenticated
via text session / apikey / dev / guest
method, path, query text the request
status_code int the response
ip text client IP (X-Forwarded-For or socket address)
entity_type, entity_id text best-effort, parsed from the path (e.g. /patients/HGSC0001patient, HGSC0001)
pii_access boolean true when the request actually returned PII (set by GET /patients/{id})
duration_ms int request duration

Indexes on ts, user_id, pii_access, status_code support the audit dashboard's filters.

Pruned to a 90-day retention window via prune_access_log() / POST /audit/prune.

Slice-4 tables: typed rich results + verification/versioning

Four typed result tables (in addition to the generic observation registry). Every one of them, plus observation, carries the same verification columns (verification_status, verified_by, verified_at, created_by) and the same versioning columns (result_uid, version, is_current, superseded_by) — see Verification workflow for the semantics.

somatic_variant

Column Type Notes
id bigint PK
sample_id bigint FK → sample.id, NOT NULL
result_type_id bigint FK → result_type.id catalog code somatic_mutation
ingest_dataset_id bigint FK → ingest_dataset.id provenance
gene, hgvs_c, hgvs_p text
chrom, pos, ref, alt text/bigint genomic coordinates
vaf float variant allele fraction 0–1
depth int read depth
caller text e.g. Mutect2
payload jsonb any extra columns from the source

germline_variant

Same shape as somatic_variant, plus:

Column Type Notes
classification text pathogenic / likely_pathogenic / VUS / benign
zygosity text het / hom

Somatic and germline variants are separate tables — different clinical meaning, different classification vocabularies.

copy_number_segment

Column Type Notes
id bigint PK
sample_id bigint FK → sample.id, NOT NULL
chrom, start_pos, end_pos text/bigint segment coordinates
gene text optional gene label for the segment
copy_number float absolute or log2 ratio
cn_type text gain / loss / neutral / amplification / deletion

hrd_score

Column Type Notes
id bigint PK
sample_id bigint FK → sample.id, NOT NULL
score float numeric HRD score
status text HRD / HRP
method text e.g. genomic_scar

observation (extended)

The generic Slice-1 registry gains the same verification + versioning columns described above, so ad-hoc CSV results also participate in the verification workflow (existing rows default to verification_status='draft', version=1, is_current=true, result_uid=NULL).

Verifiable table allow-list

POST /verify only accepts a hardcoded set of table names (somatic_variant, germline_variant, copy_number_segment, hrd_score, observation) — never an arbitrary string — to prevent SQL injection via the table name.