From 57c6a5f97078308c5301d428c6584db444afdb9c Mon Sep 17 00:00:00 2001 From: David Reinhart Date: Wed, 5 Aug 2026 15:00:06 -0700 Subject: [PATCH 01/22] feat(search): add gnomAD variant ID search Add gnomAD IDs (e.g. 17-7676154-G-C) as a search type on the MaveMD variant search screen and in the homepage hero search. A gnomAD ID does not name the reference genome its coordinates belong to, so getAlleleByGnomad translates it into genomic HGVS and resolves it against GRCh38 first, falling back to GRCh37 when the registry rejects the position with IncorrectReferenceAllele. Resolving HGVS is used in preference to the registry's gnomAD cross-reference index, which only covers variants ClinGen has ingested a gnomAD record for and so misses registered alleles such as X-41334274-A-C. - Add lib/gnomad with a GRCh38/GRCh37 RefSeq chromosome accession table and VCF-style to HGVS conversion covering substitutions, insertions, deletions and delins - Add getAlleleByGnomad to the ClinGen API module - Add gnomadIdRegex alongside the other search identifier patterns - Add the gnomAD search type to SEARCH_TYPE_OPTIONS, and its type, colors and placeholder to the homepage hero search - Add the gnomAD color tokens to the theme - Correct getAlleleByDbSnp and getAlleleByClinVar return types to ClinGenAllele[], which the /alleles endpoint has always returned Every accession in the table was verified against NCBI (accession to chromosome and assembly) and against the ClinGen registry (that each GRCh38/GRCh37 pair describes the same locus). --- src/api/clingen/alleles.ts | 35 ++++- src/assets/app.css | 2 + .../screens/SearchVariantsScreen.vue | 21 ++- src/data/mavemd.ts | 3 +- src/data/search.ts | 9 +- src/lib/gnomad.test.ts | 125 +++++++++++++++ src/lib/gnomad.ts | 147 ++++++++++++++++++ src/lib/mavemd.ts | 8 + 8 files changed, 342 insertions(+), 8 deletions(-) create mode 100644 src/lib/gnomad.test.ts create mode 100644 src/lib/gnomad.ts diff --git a/src/api/clingen/alleles.ts b/src/api/clingen/alleles.ts index b67f4a68..8217248f 100644 --- a/src/api/clingen/alleles.ts +++ b/src/api/clingen/alleles.ts @@ -1,4 +1,5 @@ -import axios from 'axios' +import axios, {isAxiosError} from 'axios' +import {gnomadIdToHgvsCandidates} from '@/lib/gnomad' import type {ClinGenAllele, ClinGenGene} from './types' const CLINGEN_BASE_URL = 'https://reg.genome.network' @@ -15,14 +16,14 @@ export async function getAlleleByHgvs(hgvs: string): Promise { return response.data } -export async function getAlleleByDbSnp(rsId: string): Promise { +export async function getAlleleByDbSnp(rsId: string): Promise { const response = await axios.get(`${CLINGEN_BASE_URL}/alleles`, { params: {'dbSNP.rs': rsId} }) return response.data } -export async function getAlleleByClinVar(variationId: string): Promise { +export async function getAlleleByClinVar(variationId: string): Promise { const response = await axios.get(`${CLINGEN_BASE_URL}/alleles`, { params: {'ClinVar.variationId': variationId} }) @@ -35,3 +36,31 @@ export async function getGeneBySymbol(symbol: string): Promise { }) return response.data } + +/** + * Look up an allele by gnomAD variant ID (e.g. 1-11796321-G-A). + * + * The ID is translated into genomic HGVS and resolved as such, rather than looked up among the registry's gnomAD + * cross-references: the registry computes an allele from any coordinates that match the reference, whereas its gnomAD + * index only covers variants it has ingested a gnomAD record for. + * + * A gnomAD ID doesn't name the reference genome its coordinates belong to, so GRCh38 is tried first and GRCh37 + * second. A 4xx from the first attempt is how coordinates announce they belong to the older assembly — the registry + * rejects a position whose reference allele doesn't match with `IncorrectReferenceAllele`. + */ +export async function getAlleleByGnomad(gnomadId: string): Promise { + const [grch38Hgvs, grch37Hgvs] = gnomadIdToHgvsCandidates(gnomadId) + if (!grch38Hgvs) { + throw new Error(`Not a valid gnomAD variant ID: ${gnomadId}`) + } + + try { + return await getAlleleByHgvs(grch38Hgvs) + } catch (error) { + // Only a rejection of these coordinates warrants retrying; a network failure or registry outage should surface. + if (!isAxiosError(error) || !error.response || error.response.status >= 500) { + throw error + } + } + return await getAlleleByHgvs(grch37Hgvs) +} diff --git a/src/assets/app.css b/src/assets/app.css index 49b1e8b6..e757d760 100644 --- a/src/assets/app.css +++ b/src/assets/app.css @@ -119,6 +119,8 @@ --color-clinvar-light: #c8ece5; --color-ga4gh: #0f6ca4; --color-ga4gh-light: #e3f0f9; + --color-gnomad: #5b4ba8; + --color-gnomad-light: #ece9f7; /* ── Measurement Types (variant screen) ────────────────────── */ --color-nucleotide: #2e7d32; diff --git a/src/components/screens/SearchVariantsScreen.vue b/src/components/screens/SearchVariantsScreen.vue index 07b2eaa7..31d0bdc1 100644 --- a/src/components/screens/SearchVariantsScreen.vue +++ b/src/components/screens/SearchVariantsScreen.vue @@ -676,6 +676,7 @@ import { type AlleleResult, clinGenAlleleIdRegex, clinVarVariationIdRegex, + gnomadIdRegex, rsIdRegex, vrsDigestRegex, scoreSetUrnFromVariantUrn, @@ -695,7 +696,14 @@ import { VARIANT_TYPE_OPTIONS, ALLELE_OPTIONS } from '@/data/mavemd' -import {getAlleleByCaId, getAlleleByHgvs, getAlleleByDbSnp, getAlleleByClinVar, getGeneBySymbol} from '@/api/clingen' +import { + getAlleleByCaId, + getAlleleByHgvs, + getAlleleByDbSnp, + getAlleleByClinVar, + getAlleleByGnomad, + getGeneBySymbol +} from '@/api/clingen' import {getCollection, getErrorResponse, lookupVariantsByClingenId} from '@/api/mavedb' import {lookupVariantsByVrsDigest} from '@/api/mavedb/variants' import {useEntityCache} from '@/composables/entity-cache' @@ -1087,6 +1095,17 @@ export default defineComponent({ return } responseData = await getAlleleByClinVar(searchStr) + } else if (searchType === 'gnomadId') { + if (!gnomadIdRegex.test(searchStr)) { + this.toast.add({ + severity: 'error', + summary: 'Invalid search', + detail: `Please provide a valid gnomAD variant ID (e.g. ${this.searchTypeOptions.find((o) => o.code === searchType)?.examples?.join(', ')})`, + life: 10000 + }) + return + } + responseData = await getAlleleByGnomad(searchStr) } else if (searchType === 'vrsDigest') { if (!vrsDigestRegex.test(searchStr)) { this.toast.add({ diff --git a/src/data/mavemd.ts b/src/data/mavemd.ts index d97dec84..10ff5c95 100644 --- a/src/data/mavemd.ts +++ b/src/data/mavemd.ts @@ -9,7 +9,8 @@ export const SEARCH_TYPE_OPTIONS = [ {code: 'clinGenAlleleId', name: 'ClinGen Allele ID', examples: ['CA10590195', 'PA2579983208']}, {code: 'dbSnpRsId', name: 'dbSNP rsID', examples: ['rs900082291', '900082291']}, {code: 'clinVarVariationId', name: 'ClinVar Variation ID', examples: ['869058']}, - {code: 'vrsDigest', name: 'VRS Digest', examples: ['ga4gh:VA.-US8Ap1kUYvW3DzeFEYrNXgk3Xk9toKy']} + {code: 'vrsDigest', name: 'VRS Digest', examples: ['ga4gh:VA.-US8Ap1kUYvW3DzeFEYrNXgk3Xk9toKy']}, + {code: 'gnomadId', name: 'gnomAD ID', examples: ['17-7676154-G-C', '17-7579472-G-C']} ] /** Variant type options for guided search. */ diff --git a/src/data/search.ts b/src/data/search.ts index b5b266b4..95774022 100644 --- a/src/data/search.ts +++ b/src/data/search.ts @@ -10,7 +10,8 @@ export const SEARCH_TYPES = [ {value: 'dbSnpRsId', label: 'dbSNP'}, {value: 'clinVarVariationId', label: 'ClinVar'}, {value: 'clinGenAlleleId', label: 'ClinGen'}, - {value: 'vrsDigest', label: 'VRS'} + {value: 'vrsDigest', label: 'VRS'}, + {value: 'gnomadId', label: 'gnomAD'} ] export const SEARCH_COLORS: Record = { @@ -18,7 +19,8 @@ export const SEARCH_COLORS: Record = { @@ -26,5 +28,6 @@ export const SEARCH_PLACEHOLDERS: Record dbSnpRsId: {full: 'Search by dbSNP rsID, e.g. rs28897672', short: 'Search by dbSNP rsID'}, clinVarVariationId: {full: 'Search by ClinVar Variation ID, e.g. 37610', short: 'Search by ClinVar ID'}, clinGenAlleleId: {full: 'Search by ClinGen Allele ID, e.g. CA003746', short: 'Search by ClinGen Allele ID'}, - vrsDigest: {full: 'Search by VRS ID, e.g. ga4gh:VA.n9ax-9x6gOC0OEt73VMYqCBfqfxG1XUH', short: 'Search by VRS ID'} + vrsDigest: {full: 'Search by VRS ID, e.g. ga4gh:VA.n9ax-9x6gOC0OEt73VMYqCBfqfxG1XUH', short: 'Search by VRS ID'}, + gnomadId: {full: 'Search by gnomAD ID, e.g. 17-7676154-G-C', short: 'Search by gnomAD ID'} } diff --git a/src/lib/gnomad.test.ts b/src/lib/gnomad.test.ts new file mode 100644 index 00000000..556df613 --- /dev/null +++ b/src/lib/gnomad.test.ts @@ -0,0 +1,125 @@ +import {describe, expect, it} from 'vitest' + +import {CHROMOSOME_REFSEQ_IDS, gnomadIdToHgvsCandidates, parseGnomadId} from './gnomad' + +/** The GRCh38 translation of a gnomAD ID, which is the one tried first. */ +function grch38Hgvs(gnomadId: string): string | undefined { + return gnomadIdToHgvsCandidates(gnomadId)[0] +} + +describe('parseGnomadId', () => { + it('parses a substitution', () => { + expect(parseGnomadId('X-41334274-A-C')).toEqual({ + chromosome: 'X', + position: 41334274, + referenceAllele: 'A', + alternateAllele: 'C' + }) + }) + + it('uppercases alleles and ignores surrounding whitespace', () => { + expect(parseGnomadId(' 1-11796321-g-a ')).toEqual({ + chromosome: '1', + position: 11796321, + referenceAllele: 'G', + alternateAllele: 'A' + }) + }) + + it('normalizes the mitochondrion to a single key', () => { + expect(parseGnomadId('M-8993-T-G')?.chromosome).toBe('M') + expect(parseGnomadId('MT-8993-T-G')?.chromosome).toBe('M') + }) + + it('rejects malformed IDs', () => { + for (const id of ['X-41334274-A', 'X:41334274:A:C', 'chr1-11796321-G-A', '1-11796321-N-A', 'rs1801133', '']) { + expect(parseGnomadId(id), id).toBeNull() + } + }) + + it('rejects chromosomes that do not exist', () => { + for (const id of ['0-100-A-C', '23-100-A-C', 'Z-100-A-C']) { + expect(parseGnomadId(id), id).toBeNull() + } + }) +}) + +describe('gnomadIdToHgvsCandidates', () => { + it('translates a substitution', () => { + expect(grch38Hgvs('X-41334274-A-C')).toBe('NC_000023.11:g.41334274A>C') + }) + + it('translates an insertion', () => { + expect(grch38Hgvs('1-55516888-G-GA')).toBe('NC_000001.11:g.55516888_55516889insA') + }) + + it('translates a multi-base insertion', () => { + expect(grch38Hgvs('1-55516888-G-GATC')).toBe('NC_000001.11:g.55516888_55516889insATC') + }) + + it('translates a single-base deletion', () => { + expect(grch38Hgvs('1-55516888-GA-G')).toBe('NC_000001.11:g.55516889del') + }) + + it('translates a multi-base deletion', () => { + expect(grch38Hgvs('1-55516888-GATC-G')).toBe('NC_000001.11:g.55516889_55516891del') + }) + + it('translates a delins', () => { + expect(grch38Hgvs('1-55516888-GA-CT')).toBe('NC_000001.11:g.55516888_55516889delinsCT') + }) + + it('translates a single-base delins', () => { + expect(grch38Hgvs('1-55516888-G-CT')).toBe('NC_000001.11:g.55516888delinsCT') + }) + + it('trims a common suffix as well as a common prefix', () => { + // AGA>AA leaves only the G at 55516889 deleted. + expect(grch38Hgvs('1-55516888-AGA-AA')).toBe('NC_000001.11:g.55516889del') + // AT>T leaves only the A at 55516888 deleted. + expect(grch38Hgvs('1-55516888-AT-T')).toBe('NC_000001.11:g.55516888del') + }) + + it('returns GRCh38 first, then GRCh37', () => { + expect(gnomadIdToHgvsCandidates('17-7676154-G-C')).toEqual([ + 'NC_000017.11:g.7676154G>C', + 'NC_000017.10:g.7676154G>C' + ]) + }) + + it('uses the shared rCRS accession for the mitochondrion in both assemblies', () => { + expect(gnomadIdToHgvsCandidates('M-8993-T-G')).toEqual(['NC_012920.1:g.8993T>G', 'NC_012920.1:g.8993T>G']) + }) + + it('returns no candidates for an unparseable ID', () => { + expect(gnomadIdToHgvsCandidates('not-an-id')).toEqual([]) + }) + + it('returns no candidates when the alleles describe no change', () => { + expect(gnomadIdToHgvsCandidates('1-55516888-A-A')).toEqual([]) + expect(gnomadIdToHgvsCandidates('1-55516888-AT-AT')).toEqual([]) + }) +}) + +describe('CHROMOSOME_REFSEQ_IDS', () => { + it('covers all 24 chromosomes plus the mitochondrion', () => { + expect(Object.keys(CHROMOSOME_REFSEQ_IDS)).toHaveLength(25) + }) + + it('assigns a distinct accession to every chromosome within an assembly', () => { + for (const assembly of ['grch38', 'grch37'] as const) { + const accessions = Object.values(CHROMOSOME_REFSEQ_IDS).map((ids) => ids[assembly]) + expect(new Set(accessions).size, assembly).toBe(accessions.length) + } + }) + + it('uses different accessions per assembly for every chromosome but the mitochondrion', () => { + for (const [chromosome, ids] of Object.entries(CHROMOSOME_REFSEQ_IDS)) { + if (chromosome === 'M') { + expect(ids.grch38).toBe(ids.grch37) + } else { + expect(ids.grch38, chromosome).not.toBe(ids.grch37) + } + } + }) +}) diff --git a/src/lib/gnomad.ts b/src/lib/gnomad.ts new file mode 100644 index 00000000..72d73aaa --- /dev/null +++ b/src/lib/gnomad.ts @@ -0,0 +1,147 @@ +import {gnomadIdRegex} from './mavemd' + +/** + * Translation of gnomAD variant IDs (e.g. 1-11796321-G-A) into genomic HGVS. + * + * A gnomAD ID is a VCF-style chromosome-position-reference-alternate tuple that doesn't name the reference genome its + * coordinates belong to, so it is translated against each supported assembly in turn and the ClinGen Allele Registry + * decides which one the coordinates actually match. + */ + +/** Assemblies tried, in order, when resolving a gnomAD ID. */ +export const GNOMAD_ASSEMBLY_SEARCH_ORDER = ['grch38', 'grch37'] as const + +export type GenomeAssembly = (typeof GNOMAD_ASSEMBLY_SEARCH_ORDER)[number] + +/** + * RefSeq chromosome accessions per assembly, keyed by gnomAD chromosome name. + * + * These are GRCh37 accessions rather than UCSC hg19 ones, which is a distinction that matters only for the + * mitochondrion: GRCh37 and GRCh38 both use the revised Cambridge Reference Sequence, so it shares one accession here, + * whereas UCSC hg19 uses the older NC_001807 sequence. gnomAD's mitochondrial calls are GRCh38/rCRS in any case. + */ +export const CHROMOSOME_REFSEQ_IDS: Record> = { + '1': {grch38: 'NC_000001.11', grch37: 'NC_000001.10'}, + '2': {grch38: 'NC_000002.12', grch37: 'NC_000002.11'}, + '3': {grch38: 'NC_000003.12', grch37: 'NC_000003.11'}, + '4': {grch38: 'NC_000004.12', grch37: 'NC_000004.11'}, + '5': {grch38: 'NC_000005.10', grch37: 'NC_000005.9'}, + '6': {grch38: 'NC_000006.12', grch37: 'NC_000006.11'}, + '7': {grch38: 'NC_000007.14', grch37: 'NC_000007.13'}, + '8': {grch38: 'NC_000008.11', grch37: 'NC_000008.10'}, + '9': {grch38: 'NC_000009.12', grch37: 'NC_000009.11'}, + '10': {grch38: 'NC_000010.11', grch37: 'NC_000010.10'}, + '11': {grch38: 'NC_000011.10', grch37: 'NC_000011.9'}, + '12': {grch38: 'NC_000012.12', grch37: 'NC_000012.11'}, + '13': {grch38: 'NC_000013.11', grch37: 'NC_000013.10'}, + '14': {grch38: 'NC_000014.9', grch37: 'NC_000014.8'}, + '15': {grch38: 'NC_000015.10', grch37: 'NC_000015.9'}, + '16': {grch38: 'NC_000016.10', grch37: 'NC_000016.9'}, + '17': {grch38: 'NC_000017.11', grch37: 'NC_000017.10'}, + '18': {grch38: 'NC_000018.10', grch37: 'NC_000018.9'}, + '19': {grch38: 'NC_000019.10', grch37: 'NC_000019.9'}, + '20': {grch38: 'NC_000020.11', grch37: 'NC_000020.10'}, + '21': {grch38: 'NC_000021.9', grch37: 'NC_000021.8'}, + '22': {grch38: 'NC_000022.11', grch37: 'NC_000022.10'}, + X: {grch38: 'NC_000023.11', grch37: 'NC_000023.10'}, + Y: {grch38: 'NC_000024.10', grch37: 'NC_000024.9'}, + M: {grch38: 'NC_012920.1', grch37: 'NC_012920.1'} +} + +/** A gnomAD variant ID parsed into its parts, with the chromosome normalized to a {@link CHROMOSOME_REFSEQ_IDS} key. */ +export interface GnomadVariant { + chromosome: string + position: number + referenceAllele: string + alternateAllele: string +} + +/** Parse a gnomAD variant ID. Returns null if it is malformed or names a chromosome we have no accessions for. */ +export function parseGnomadId(gnomadId: string): GnomadVariant | null { + const match = gnomadIdRegex.exec(gnomadId.trim()) + if (!match) { + return null + } + const [, chromosome, position, referenceAllele, alternateAllele] = match + // gnomAD writes the mitochondrion as either M or MT. + const normalizedChromosome = chromosome.toUpperCase() === 'MT' ? 'M' : chromosome.toUpperCase() + if (!(normalizedChromosome in CHROMOSOME_REFSEQ_IDS)) { + return null + } + return { + chromosome: normalizedChromosome, + position: parseInt(position), + referenceAllele: referenceAllele.toUpperCase(), + alternateAllele: alternateAllele.toUpperCase() + } +} + +/** + * Build the HGVS description (the part after the colon) for a VCF-style change at a genomic position. + * + * gnomAD anchors indels on the base preceding the change, so the alleles are first reduced to a minimal + * representation: the common prefix is trimmed before any common suffix, which keeps the resulting coordinates as far + * 3' as the input allows. The registry applies full HGVS 3' normalization on its end. + * + * @returns The description, or null if the reference and alternate alleles describe no change. + */ +function describeGenomicChange(position: number, referenceAllele: string, alternateAllele: string): string | null { + let reference = referenceAllele + let alternate = alternateAllele + let start = position + + let prefixLength = 0 + while ( + prefixLength < reference.length && + prefixLength < alternate.length && + reference[prefixLength] === alternate[prefixLength] + ) { + prefixLength++ + } + reference = reference.slice(prefixLength) + alternate = alternate.slice(prefixLength) + start += prefixLength + + while ( + reference.length > 0 && + alternate.length > 0 && + reference[reference.length - 1] === alternate[alternate.length - 1] + ) { + reference = reference.slice(0, -1) + alternate = alternate.slice(0, -1) + } + + const end = start + reference.length - 1 + + if (reference.length === 0 && alternate.length === 0) { + return null + } + if (reference.length === 0) { + return `g.${start - 1}_${start}ins${alternate}` + } + if (alternate.length === 0) { + return reference.length === 1 ? `g.${start}del` : `g.${start}_${end}del` + } + if (reference.length === 1 && alternate.length === 1) { + return `g.${start}${reference}>${alternate}` + } + return reference.length === 1 ? `g.${start}delins${alternate}` : `g.${start}_${end}delins${alternate}` +} + +/** + * Translate a gnomAD variant ID into genomic HGVS strings, one per supported assembly, in the order they should be + * tried. Returns an empty array if the ID cannot be translated. + */ +export function gnomadIdToHgvsCandidates(gnomadId: string): string[] { + const variant = parseGnomadId(gnomadId) + if (!variant) { + return [] + } + const description = describeGenomicChange(variant.position, variant.referenceAllele, variant.alternateAllele) + if (!description) { + return [] + } + return GNOMAD_ASSEMBLY_SEARCH_ORDER.map( + (assembly) => `${CHROMOSOME_REFSEQ_IDS[variant.chromosome][assembly]}:${description}` + ) +} diff --git a/src/lib/mavemd.ts b/src/lib/mavemd.ts index f945e25c..7c5a7eb8 100644 --- a/src/lib/mavemd.ts +++ b/src/lib/mavemd.ts @@ -33,6 +33,14 @@ export const clinVarVariationIdRegex = /^[0-9]+$/m */ export const rsIdRegex = /^rs[0-9]+$/im +/** + * Regular expression for valid gnomAD variant IDs that can be used in ClinGen searches. + * + * gnomAD writes these as chromosome-position-reference-alternate (e.g. 1-11796321-G-A). The capture groups are those + * four parts, in that order, which parseGnomadId relies on to translate an ID into HGVS. + */ +export const gnomadIdRegex = /^(1[0-9]|2[0-2]|[1-9]|X|Y|MT?)-([0-9]+)-([ACGT]+)-([ACGT]+)$/i + /** A single MANE coordinate extracted from a ClinGen transcript allele. */ export interface ManeCoordinate { sequenceType: string From 879b39048cf4253fee2c064ca08b1aeb31aad49f Mon Sep 17 00:00:00 2001 From: David Reinhart Date: Wed, 5 Aug 2026 15:59:54 -0700 Subject: [PATCH 02/22] feat(search): show which reference genome a gnomAD ID was read under A gnomAD ID does not name the reference genome its coordinates belong to, and the same coordinates can be valid under both assemblies while naming a different variant in each: 1-1000001-G-T resolves under GRCh38 and GRCh37 to loci 64,620 bp apart. Searching one silently took the first assembly that resolved, so an ID taken from gnomAD v2 could return a confidently wrong answer with nothing to indicate it. Results for a gnomAD ID search now report the assembly the coordinates were read under, alongside the HGVS that was resolved, with a button to re-read the same ID under the other assembly. - Return the resolving assembly from getAlleleByGnomad, and accept one to read under, which skips the fallback and lets failure propagate - Thread a forced assembly through defaultSearch so switching reuses the existing search and MaveDB lookup path - Report coordinates that do not match an assembly's reference sequence as a warning naming the base actually found there, rather than surfacing the registry's IncorrectReferenceAllele as an error - Show alleles the registry resolved but has not registered, which it answers with a blank node id rather than a CA id, instead of discarding them as no result; this also affects HGVS searches for unregistered variants - Render one card header for registered and unregistered alleles, so both show their GRCh38 and GRCh37 coordinates --- src/api/clingen/alleles.ts | 45 +++-- .../screens/SearchVariantsScreen.vue | 178 +++++++++++++++--- src/lib/gnomad.test.ts | 33 +++- src/lib/gnomad.ts | 31 ++- 4 files changed, 239 insertions(+), 48 deletions(-) diff --git a/src/api/clingen/alleles.ts b/src/api/clingen/alleles.ts index 8217248f..b17d00dc 100644 --- a/src/api/clingen/alleles.ts +++ b/src/api/clingen/alleles.ts @@ -1,5 +1,5 @@ import axios, {isAxiosError} from 'axios' -import {gnomadIdToHgvsCandidates} from '@/lib/gnomad' +import {type GenomeAssembly, gnomadIdToHgvsCandidates} from '@/lib/gnomad' import type {ClinGenAllele, ClinGenGene} from './types' const CLINGEN_BASE_URL = 'https://reg.genome.network' @@ -37,6 +37,12 @@ export async function getGeneBySymbol(symbol: string): Promise { return response.data } +/** An allele resolved from a gnomAD ID, with the assembly whose coordinates it was read under. */ +export interface GnomadAlleleResult { + allele: ClinGenAllele + assembly: GenomeAssembly +} + /** * Look up an allele by gnomAD variant ID (e.g. 1-11796321-G-A). * @@ -44,23 +50,34 @@ export async function getGeneBySymbol(symbol: string): Promise { * cross-references: the registry computes an allele from any coordinates that match the reference, whereas its gnomAD * index only covers variants it has ingested a gnomAD record for. * - * A gnomAD ID doesn't name the reference genome its coordinates belong to, so GRCh38 is tried first and GRCh37 - * second. A 4xx from the first attempt is how coordinates announce they belong to the older assembly — the registry - * rejects a position whose reference allele doesn't match with `IncorrectReferenceAllele`. + * A gnomAD ID doesn't name the reference genome its coordinates belong to, so by default GRCh38 is tried first and + * GRCh37 second. A 4xx from the first attempt is how coordinates announce they belong to the older assembly — the + * registry rejects a position whose reference allele doesn't match with `IncorrectReferenceAllele`. Note that a + * position can be valid under both assemblies while naming a different variant in each, in which case the earlier + * assembly wins; pass `assembly` to read the ID under a specific one instead, and let any failure propagate. + * + * @returns The allele, and the assembly its coordinates were read under. */ -export async function getAlleleByGnomad(gnomadId: string): Promise { - const [grch38Hgvs, grch37Hgvs] = gnomadIdToHgvsCandidates(gnomadId) - if (!grch38Hgvs) { +export async function getAlleleByGnomad(gnomadId: string, assembly?: GenomeAssembly): Promise { + const candidates = gnomadIdToHgvsCandidates(gnomadId).filter( + (candidate) => assembly == undefined || candidate.assembly === assembly + ) + if (candidates.length === 0) { throw new Error(`Not a valid gnomAD variant ID: ${gnomadId}`) } - try { - return await getAlleleByHgvs(grch38Hgvs) - } catch (error) { - // Only a rejection of these coordinates warrants retrying; a network failure or registry outage should surface. - if (!isAxiosError(error) || !error.response || error.response.status >= 500) { - throw error + for (const [index, candidate] of candidates.entries()) { + const isLastCandidate = index === candidates.length - 1 + try { + return {allele: await getAlleleByHgvs(candidate.hgvs), assembly: candidate.assembly} + } catch (error) { + // Only a rejection of these coordinates warrants trying the next assembly; a network failure or registry outage + // should surface, as should the final attempt's failure. + if (isLastCandidate || !isAxiosError(error) || !error.response || error.response.status >= 500) { + throw error + } } } - return await getAlleleByHgvs(grch37Hgvs) + // Unreachable: the loop either returns or throws on its final iteration. + throw new Error(`Could not resolve gnomAD variant ID: ${gnomadId}`) } diff --git a/src/components/screens/SearchVariantsScreen.vue b/src/components/screens/SearchVariantsScreen.vue index 31d0bdc1..b9d53c75 100644 --- a/src/components/screens/SearchVariantsScreen.vue +++ b/src/components/screens/SearchVariantsScreen.vue @@ -68,7 +68,7 @@ class="min-w-0 flex-1 !rounded-none !border-none !shadow-none placeholder:font-mono placeholder:text-xs md:placeholder:text-sm" :placeholder="currentPlaceholder" type="search" - @keyup.enter="defaultSearch" + @keyup.enter="defaultSearch()" /> @@ -276,15 +276,59 @@ New search + +
+
+
+ Read as + {{ gnomadInterpretation.name }} + coordinates +
+ + {{ gnomadInterpretation.hgvs }} + +
+ +
+
-
No variants found
-
- No matching variants were found in MaveDB. Try a different identifier or search type. -
+ + +
- +
- + {{ allele.canonicalAlleleName }}
@@ -322,17 +375,14 @@
- + View variant → -
-
-
- - {{ allele.canonicalAlleleName }} - -
-
+ Not in the ClinGen registry + @@ -686,6 +736,7 @@ import { import {getTargetGeneName} from '@/lib/target-genes' import {components} from '@/schema/openapi' import {getScoreSetShortName} from '@/lib/score-sets' +import {type GenomeAssembly, GENOME_ASSEMBLY_NAMES, gnomadIdToHgvs, otherAssembly} from '@/lib/gnomad' import {clinVarHgvsSearchStringRegex, hgvsSearchStringRegex} from '@/lib/mave-hgvs' import {SEARCH_COLORS} from '@/data/search' import {AVE_CLINICAL_APPLICATION} from '@/lib/links' @@ -756,6 +807,8 @@ export default defineComponent({ inputAlternateAllele: null as string | null, allAlleleOptions: ALLELE_OPTIONS, alleles: [] as AlleleResult[], + /** Which assembly the current gnomAD ID was read under; null when the search wasn't a gnomAD ID search. */ + gnomadAssembly: null as GenomeAssembly | null, nucleotideScoreSetListIsExpanded: [] as Array, proteinScoreSetListIsExpanded: [] as Array, associatedNucleotideScoreSetListIsExpanded: [] as Array, @@ -813,6 +866,17 @@ export default defineComponent({ currentPlaceholder(): string { const option = this.searchTypeOptions.find((o) => o.code === this.searchType) return option?.examples?.[0] || 'Enter a value' + }, + /** How the current gnomAD ID was read, and how it would read under the other assembly. */ + gnomadInterpretation(): {name: string; hgvs: string | null; otherName: string} | null { + if (!this.gnomadAssembly || !this.searchText) { + return null + } + return { + name: GENOME_ASSEMBLY_NAMES[this.gnomadAssembly], + hgvs: gnomadIdToHgvs(this.searchText, this.gnomadAssembly), + otherName: GENOME_ASSEMBLY_NAMES[otherAssembly(this.gnomadAssembly)] + } } }, @@ -1009,6 +1073,7 @@ export default defineComponent({ this.inputAlternateAllele = null this.searchResultsVisible = false this.alleles = [] + this.gnomadAssembly = null this.router.replace({query: {}}) }, showSearch(searchMethod: 'guided' | 'default' = 'default') { @@ -1032,9 +1097,12 @@ export default defineComponent({ this.router.replace({query}) this.clearSearch() }, - defaultSearch: async function () { + defaultSearch: async function (forcedAssembly?: GenomeAssembly) { if (this.searchText) this.searchText = this.searchText.trim() this.searchResultsVisible = true + // Show the assembly being attempted, so a forced reading that fails to resolve still reports what was tried + // rather than leaving the previous, now-discarded result on screen. + this.gnomadAssembly = forcedAssembly ?? null const query = {...this.route.query} delete query.mode delete query.gene @@ -1051,12 +1119,27 @@ export default defineComponent({ this.alleles = [] this.loading = true if (this.searchText !== null && this.searchText !== '') { - await this.fetchDefaultSearchResults(this.searchText) + await this.fetchDefaultSearchResults(this.searchText, null, undefined, forcedAssembly) } this.loading = false await this.searchVariants() }, - fetchDefaultSearchResults: async function (searchString: string, maneStatus: string | null = null, forcedSearchType?: string) { + /** + * Re-read the current gnomAD ID under the other assembly. + * + * The same coordinates can be valid under both assemblies while naming a different variant in each, so this lets + * the user correct an ID that resolved under the wrong one. + */ + switchGnomadAssembly: async function () { + if (!this.gnomadAssembly) return + await this.defaultSearch(otherAssembly(this.gnomadAssembly)) + }, + fetchDefaultSearchResults: async function ( + searchString: string, + maneStatus: string | null = null, + forcedSearchType?: string, + forcedAssembly?: GenomeAssembly | null + ) { const searchType = forcedSearchType ?? this.searchType let searchStr = searchString.trim() @@ -1105,7 +1188,45 @@ export default defineComponent({ }) return } - responseData = await getAlleleByGnomad(searchStr) + try { + const gnomadResult = await getAlleleByGnomad(searchStr, forcedAssembly ?? undefined) + this.gnomadAssembly = gnomadResult.assembly + responseData = gnomadResult.allele + } catch (error: unknown) { + // A gnomAD ID that doesn't match an assembly's reference sequence isn't a failure to report as one: it is + // the answer, and the useful thing to say is which base is actually there. + const {data} = getErrorResponse(error) + if (data?.errorType !== 'IncorrectReferenceAllele') { + throw error + } + const actualAllele = data.actualAllele as string | undefined + const givenAllele = data.givenAllele as string | undefined + const baseNote = + actualAllele && givenAllele + ? ` The reference base at that position is ${actualAllele}, not ${givenAllele}.` + : '' + this.toast.add( + forcedAssembly + ? { + severity: 'warn', + summary: `Not ${GENOME_ASSEMBLY_NAMES[forcedAssembly]} coordinates`, + detail: + `${searchStr} cannot be read as ${GENOME_ASSEMBLY_NAMES[forcedAssembly]}.${baseNote} ` + + `It is only valid as ${GENOME_ASSEMBLY_NAMES[otherAssembly(forcedAssembly)]}.`, + life: 10000 + } + : { + severity: 'warn', + summary: 'Variant not found', + detail: + `${searchStr} does not match the reference sequence in either ` + + `${GENOME_ASSEMBLY_NAMES.grch38} or ${GENOME_ASSEMBLY_NAMES.grch37}.${baseNote} ` + + 'Check the position and reference allele.', + life: 10000 + } + ) + return + } } else if (searchType === 'vrsDigest') { if (!vrsDigestRegex.test(searchStr)) { this.toast.add({ @@ -1198,6 +1319,15 @@ export default defineComponent({ } break } + } else if (result.genomicAlleles?.length || result.transcriptAlleles?.length) { + // The registry resolved these coordinates against the reference but holds no registered allele for them, + // so it answers with a blank node id (_:CA) rather than a CA id. Show what the coordinates resolve to + // instead of discarding it; an unregistered allele can have no MaveDB measurements either way. + const unregisteredAllele = createAlleleResult(result, maneStatus) + unregisteredAllele.clingenAlleleId = undefined + unregisteredAllele.clingenAlleleUrl = undefined + unregisteredAllele.variantsStatus = 'Loaded' + this.alleles.push(unregisteredAllele) } } if (this.alleles.length > 0) { diff --git a/src/lib/gnomad.test.ts b/src/lib/gnomad.test.ts index 556df613..944aa0da 100644 --- a/src/lib/gnomad.test.ts +++ b/src/lib/gnomad.test.ts @@ -1,10 +1,10 @@ import {describe, expect, it} from 'vitest' -import {CHROMOSOME_REFSEQ_IDS, gnomadIdToHgvsCandidates, parseGnomadId} from './gnomad' +import {CHROMOSOME_REFSEQ_IDS, gnomadIdToHgvs, gnomadIdToHgvsCandidates, otherAssembly, parseGnomadId} from './gnomad' /** The GRCh38 translation of a gnomAD ID, which is the one tried first. */ function grch38Hgvs(gnomadId: string): string | undefined { - return gnomadIdToHgvsCandidates(gnomadId)[0] + return gnomadIdToHgvsCandidates(gnomadId)[0]?.hgvs } describe('parseGnomadId', () => { @@ -80,15 +80,18 @@ describe('gnomadIdToHgvsCandidates', () => { expect(grch38Hgvs('1-55516888-AT-T')).toBe('NC_000001.11:g.55516888del') }) - it('returns GRCh38 first, then GRCh37', () => { + it('returns GRCh38 first, then GRCh37, each labelled with its assembly', () => { expect(gnomadIdToHgvsCandidates('17-7676154-G-C')).toEqual([ - 'NC_000017.11:g.7676154G>C', - 'NC_000017.10:g.7676154G>C' + {assembly: 'grch38', hgvs: 'NC_000017.11:g.7676154G>C'}, + {assembly: 'grch37', hgvs: 'NC_000017.10:g.7676154G>C'} ]) }) it('uses the shared rCRS accession for the mitochondrion in both assemblies', () => { - expect(gnomadIdToHgvsCandidates('M-8993-T-G')).toEqual(['NC_012920.1:g.8993T>G', 'NC_012920.1:g.8993T>G']) + expect(gnomadIdToHgvsCandidates('M-8993-T-G')).toEqual([ + {assembly: 'grch38', hgvs: 'NC_012920.1:g.8993T>G'}, + {assembly: 'grch37', hgvs: 'NC_012920.1:g.8993T>G'} + ]) }) it('returns no candidates for an unparseable ID', () => { @@ -101,6 +104,24 @@ describe('gnomadIdToHgvsCandidates', () => { }) }) +describe('gnomadIdToHgvs', () => { + it('reads an ID under the requested assembly', () => { + expect(gnomadIdToHgvs('17-7676154-G-C', 'grch38')).toBe('NC_000017.11:g.7676154G>C') + expect(gnomadIdToHgvs('17-7676154-G-C', 'grch37')).toBe('NC_000017.10:g.7676154G>C') + }) + + it('returns null for an ID it cannot translate', () => { + expect(gnomadIdToHgvs('not-an-id', 'grch38')).toBeNull() + }) +}) + +describe('otherAssembly', () => { + it('pairs the two assemblies', () => { + expect(otherAssembly('grch38')).toBe('grch37') + expect(otherAssembly('grch37')).toBe('grch38') + }) +}) + describe('CHROMOSOME_REFSEQ_IDS', () => { it('covers all 24 chromosomes plus the mitochondrion', () => { expect(Object.keys(CHROMOSOME_REFSEQ_IDS)).toHaveLength(25) diff --git a/src/lib/gnomad.ts b/src/lib/gnomad.ts index 72d73aaa..303c49c9 100644 --- a/src/lib/gnomad.ts +++ b/src/lib/gnomad.ts @@ -13,6 +13,17 @@ export const GNOMAD_ASSEMBLY_SEARCH_ORDER = ['grch38', 'grch37'] as const export type GenomeAssembly = (typeof GNOMAD_ASSEMBLY_SEARCH_ORDER)[number] +/** Display names for the assemblies, for use in user-facing text. */ +export const GENOME_ASSEMBLY_NAMES: Record = { + grch38: 'GRCh38', + grch37: 'GRCh37' +} + +/** The assembly to offer as an alternative interpretation of the same gnomAD ID. */ +export function otherAssembly(assembly: GenomeAssembly): GenomeAssembly { + return assembly === 'grch38' ? 'grch37' : 'grch38' +} + /** * RefSeq chromosome accessions per assembly, keyed by gnomAD chromosome name. * @@ -128,11 +139,17 @@ function describeGenomicChange(position: number, referenceAllele: string, altern return reference.length === 1 ? `g.${start}delins${alternate}` : `g.${start}_${end}delins${alternate}` } +/** An HGVS reading of a gnomAD ID, valid only if the ID's coordinates belong to `assembly`. */ +export interface GnomadHgvsCandidate { + assembly: GenomeAssembly + hgvs: string +} + /** * Translate a gnomAD variant ID into genomic HGVS strings, one per supported assembly, in the order they should be * tried. Returns an empty array if the ID cannot be translated. */ -export function gnomadIdToHgvsCandidates(gnomadId: string): string[] { +export function gnomadIdToHgvsCandidates(gnomadId: string): GnomadHgvsCandidate[] { const variant = parseGnomadId(gnomadId) if (!variant) { return [] @@ -141,7 +158,13 @@ export function gnomadIdToHgvsCandidates(gnomadId: string): string[] { if (!description) { return [] } - return GNOMAD_ASSEMBLY_SEARCH_ORDER.map( - (assembly) => `${CHROMOSOME_REFSEQ_IDS[variant.chromosome][assembly]}:${description}` - ) + return GNOMAD_ASSEMBLY_SEARCH_ORDER.map((assembly) => ({ + assembly, + hgvs: `${CHROMOSOME_REFSEQ_IDS[variant.chromosome][assembly]}:${description}` + })) +} + +/** The HGVS reading of a gnomAD ID under one assembly, or null if the ID cannot be translated. */ +export function gnomadIdToHgvs(gnomadId: string, assembly: GenomeAssembly): string | null { + return gnomadIdToHgvsCandidates(gnomadId).find((candidate) => candidate.assembly === assembly)?.hgvs ?? null } From ce6c02ea4294728a1dc3623652070508457334b1 Mon Sep 17 00:00:00 2001 From: David Reinhart Date: Wed, 5 Aug 2026 17:51:59 -0700 Subject: [PATCH 03/22] feat(search): add an "Any" search type that detects the identifier Make "Any" the default search type on the MaveMD variant search screen and the homepage hero. It matches the search string against the identifier patterns already defined for the specific types, then takes that type's usual path, so nothing about the individual searches changes. Detection order matters where those patterns overlap. A VRS digest also satisfies the deliberately loose HGVS pattern, which asks only for an identifier, a colon and a description, so it is recognized first. A bare number is a ClinVar Variation ID only once the more specific forms have been ruled out, so a dbSNP rsID written without its rs prefix reads as ClinVar and still needs the dbSNP type chosen explicitly. Because the selected type stays "Any", the results report which type the string was taken to be, as a chip in that type's colour, so a reading like that one is visible rather than silent. - Add detectSearchType alongside the identifier patterns it tries - Resolve "Any" to a concrete type at the top of fetchDefaultSearchResults, leaving every existing branch untouched - Add the "Any" type, colours and placeholder to both search screens, and point the search type fallbacks at it - Show the detected type in the results header, inside the existing live region so it is announced with the result count - Size the search type tabs to match the example chips below the search bar --- src/assets/app.css | 2 + src/components/screens/HomeScreen.vue | 6 +-- .../screens/SearchVariantsScreen.vue | 52 ++++++++++++++++--- src/data/mavemd.ts | 1 + src/data/search.ts | 3 ++ src/lib/mavemd.ts | 27 ++++++++++ 6 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/assets/app.css b/src/assets/app.css index e757d760..fd89cdc4 100644 --- a/src/assets/app.css +++ b/src/assets/app.css @@ -113,6 +113,8 @@ --color-badge-alert: #fef3c7; /* ── Search Types ──────────────────────────────────────────── */ + --color-any: #5c6b7a; + --color-any-light: #eceff2; --color-dbsnp: #e6b84d; --color-dbsnp-light: #fdf3d7; --color-clinvar: #5aafa0; diff --git a/src/components/screens/HomeScreen.vue b/src/components/screens/HomeScreen.vue index 535dac8c..341fd57f 100644 --- a/src/components/screens/HomeScreen.vue +++ b/src/components/screens/HomeScreen.vue @@ -259,7 +259,7 @@ export default defineComponent({ data() { return { - searchType: 'hgvs', + searchType: 'any', searchText: '', isDesktop: false, mdQuery: null as MediaQueryList | null, @@ -271,10 +271,10 @@ export default defineComponent({ computed: { activeSearchColor(): {accent: string; bg: string; activeText?: string} { - return SEARCH_COLORS[this.searchType] || SEARCH_COLORS.hgvs + return SEARCH_COLORS[this.searchType] || SEARCH_COLORS.any }, activeSearchPlaceholder(): string { - const p = SEARCH_PLACEHOLDERS[this.searchType] || SEARCH_PLACEHOLDERS.hgvs + const p = SEARCH_PLACEHOLDERS[this.searchType] || SEARCH_PLACEHOLDERS.any return this.isDesktop ? p.full : p.short } }, diff --git a/src/components/screens/SearchVariantsScreen.vue b/src/components/screens/SearchVariantsScreen.vue index b9d53c75..7b25c408 100644 --- a/src/components/screens/SearchVariantsScreen.vue +++ b/src/components/screens/SearchVariantsScreen.vue @@ -44,7 +44,7 @@ v-for="option in searchTypeOptions" :key="option.code" :aria-selected="searchType === option.code" - class="cursor-pointer rounded-full border-[1.5px] px-3 py-1 text-xs font-semibold transition-all md:px-4 md:py-1.5 md:text-sm" + class="cursor-pointer rounded-full border-[1.5px] px-3 py-1 text-xs font-semibold transition-all md:px-4 md:py-1.5 md:text-xs" role="tab" :style="searchColorStyle(option.code, searchType === option.code ? 'active' : 'inactive')" :tabindex="searchType === option.code ? 0 : -1" @@ -267,6 +267,16 @@
{{ alleles.length }} allele{{ alleles.length !== 1 ? 's' : '' }} found
+ +
+ Searched as + + {{ detectedSearchTypeOption.name }} + +
- +
@@ -320,11 +323,18 @@ A resolved reading always yields an allele, registered or not, so an empty result alongside an attempted reading means the coordinates don't exist in that assembly rather than that MaveDB lacks the data. --> - diff --git a/src/composables/use-csv-namespaces.test.ts b/src/composables/use-csv-namespaces.test.ts new file mode 100644 index 00000000..95261021 --- /dev/null +++ b/src/composables/use-csv-namespaces.test.ts @@ -0,0 +1,559 @@ +import {CanceledError} from 'axios' +import {beforeEach, describe, expect, it, vi} from 'vitest' +import {ref} from 'vue' + +import {useCsvNamespaces, type AvailableCsvNamespace, type CsvNamespaceSection} from './use-csv-namespaces' + +const getScoreSetCsvNamespaces = vi.fn() +const getVariantCsvNamespaces = vi.fn() + +vi.mock('@/api/mavedb', () => ({ + getScoreSetCsvNamespaces: (...args: unknown[]) => getScoreSetCsvNamespaces(...args), + getVariantCsvNamespaces: (...args: unknown[]) => getVariantCsvNamespaces(...args) +})) + +function entry( + overrides: Partial & { + selectedByDefault?: boolean + scoreSet?: {urn: string; title: string} + } +): AvailableCsvNamespace { + return { + namespace: 'scores', + label: 'Scores', + group: 'data', + ...overrides + } as AvailableCsvNamespace +} + +const SCORE_SET_ENTRIES: AvailableCsvNamespace[] = [ + entry({namespace: 'scores', label: 'Scores', group: 'data'}), + entry({namespace: 'gnomad', label: 'gnomAD allele frequency', group: 'annotation'}), + entry({namespace: 'clinvar.2024_11', label: 'ClinVar significance (November 2024)', group: 'annotation'}), + entry({namespace: 'calibration.urn:mavedb:calibration-1', label: 'Brnich et al. 2019', group: 'calibration'}), + entry({namespace: 'score_set', label: 'Score set and publications', group: 'provenance'}) +] + +beforeEach(() => { + getScoreSetCsvNamespaces.mockReset() + getVariantCsvNamespaces.mockReset() +}) + +describe('useCsvNamespaces', () => { + it('does not fetch until load is called', () => { + useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + expect(getScoreSetCsvNamespaces).not.toHaveBeenCalled() + }) + + it('loads score set namespaces from the score set endpoint', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {namespaces, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(getScoreSetCsvNamespaces).toHaveBeenCalledOnce() + expect(getVariantCsvNamespaces).not.toHaveBeenCalled() + expect(namespaces.value).toHaveLength(5) + }) + + it('loads variant namespaces from the variant endpoint', async () => { + getVariantCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1#1'), kind: 'variant'}) + + await load() + + expect(getVariantCsvNamespaces).toHaveBeenCalledOnce() + expect(getScoreSetCsvNamespaces).not.toHaveBeenCalled() + }) + + it('groups namespaces into ordered sections, omitting empty ones', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(sections.value.map((section) => section.group)).toEqual(['data', 'annotation', 'calibration', 'provenance']) + expect(sections.value[1].namespaces.map((entry) => entry.namespace)).toEqual(['gnomad', 'clinvar.2024_11']) + expect(sections.value[2].title).toBe('Clinical interpretation') + }) + + it('omits sections with no namespaces', async () => { + getScoreSetCsvNamespaces.mockResolvedValue([entry({namespace: 'scores', group: 'data'})]) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(sections.value.map((section) => section.group)).toEqual(['data']) + }) + + it('serves labels from the API rather than deriving them', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {namespaces, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + const calibration = namespaces.value.find((n) => n.namespace.startsWith('calibration.')) + expect(calibration?.label).toBe('Brnich et al. 2019') + const clinvar = namespaces.value.find((n) => n.namespace.startsWith('clinvar.')) + expect(clinvar?.label).toBe('ClinVar significance (November 2024)') + }) + + it('caches per URN so reopening a dialog does not refetch', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + await load() + + expect(getScoreSetCsvNamespaces).toHaveBeenCalledOnce() + }) + + it('refetches when the URN changes', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const urn = ref('urn:mavedb:00000001-a-1') + const {load} = useCsvNamespaces({urn, kind: 'scoreSet'}) + + await load() + urn.value = 'urn:mavedb:00000001-a-2' + await load() + + expect(getScoreSetCsvNamespaces).toHaveBeenCalledTimes(2) + }) + + it('does not fetch without a URN', async () => { + const {load} = useCsvNamespaces({urn: ref(null), kind: 'scoreSet'}) + + await load() + + expect(getScoreSetCsvNamespaces).not.toHaveBeenCalled() + }) + + it('reports an error and offers nothing when the request fails', async () => { + getScoreSetCsvNamespaces.mockRejectedValue(new Error('boom')) + const {namespaces, hasNamespaces, error, loading, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet' + }) + + await load() + + expect(error.value).toBeTruthy() + expect(namespaces.value).toEqual([]) + expect(hasNamespaces.value).toBe(false) + expect(loading.value).toBe(false) + }) + + it('retries after a failure rather than caching the empty result', async () => { + getScoreSetCsvNamespaces.mockRejectedValueOnce(new Error('boom')).mockResolvedValue(SCORE_SET_ENTRIES) + const {hasNamespaces, error, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + await load() + + expect(getScoreSetCsvNamespaces).toHaveBeenCalledTimes(2) + expect(error.value).toBeNull() + expect(hasNamespaces.value).toBe(true) + }) + + it('ignores an aborted request instead of reporting it as an error', async () => { + getScoreSetCsvNamespaces.mockRejectedValue(new CanceledError('canceled')) + const {error, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(error.value).toBeNull() + }) + + it('a superseded request neither clears the spinner nor drops the live request', async () => { + // The abort lands while the second request is still in flight. If the first one's cleanup ran + // unguarded, the dialog would stop showing a spinner and reset() could no longer abort what is + // actually loading. + let resolveSecond: (entries: AvailableCsvNamespace[]) => void = () => {} + getScoreSetCsvNamespaces + .mockRejectedValueOnce(new CanceledError('canceled')) + .mockReturnValueOnce(new Promise((resolve) => (resolveSecond = resolve))) + + const urn = ref('urn:mavedb:00000001-a-1') + const {hasNamespaces, loading, load, reset} = useCsvNamespaces({urn, kind: 'scoreSet'}) + + const first = load() + urn.value = 'urn:mavedb:00000001-a-2' + const second = load() + await first + + expect(loading.value).toBe(true) + + reset() + resolveSecond(SCORE_SET_ENTRIES) + await second + + // reset() aborted the live request, so its result must not land after the fact. + expect(hasNamespaces.value).toBe(false) + }) + + it('does not count a formatting extra as a column group', async () => { + // The Download button is enabled from this count, and a formatting flag produces no columns — that is + // how an empty `namespaces` request used to slip through and come back as the API's default. + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const extraOptions = ref([{label: "Omit HGVS columns this score set doesn't use", value: 'dropUnusedHgvs'}]) + const {selected, selectedExtras, selectedColumnGroups, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet', + extraOptions + }) + + await load() + selected.value = [] + selectedExtras.value = ['dropUnusedHgvs'] + + expect(selectedColumnGroups.value).toBe(0) + }) + + it('reset clears loaded namespaces', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {hasNamespaces, load, reset} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + expect(hasNamespaces.value).toBe(true) + + reset() + + expect(hasNamespaces.value).toBe(false) + }) +}) + +describe('useCsvNamespaces selection', () => { + it('selects everything once the list loads', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, allSelected, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(allSelected.value).toBe(true) + expect(selected.value).toEqual(SCORE_SET_ENTRIES.map((entry) => entry.namespace)) + }) + + it('starts with nothing selected before loading', () => { + const {selected, allSelected} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + expect(selected.value).toEqual([]) + expect(allSelected.value).toBe(false) + }) + + it('toggleAll clears a full selection', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, allSelected, toggleAll, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet' + }) + await load() + + toggleAll() + + expect(selected.value).toEqual([]) + expect(allSelected.value).toBe(false) + }) + + it('toggleAll restores everything from an empty selection', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, toggleAll, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + await load() + + toggleAll() + toggleAll() + + expect(selected.value).toHaveLength(SCORE_SET_ENTRIES.length) + }) + + it('toggleAll selects everything from a partial selection', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, toggleAll, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + await load() + selected.value = ['scores'] + + toggleAll() + + expect(selected.value).toHaveLength(SCORE_SET_ENTRIES.length) + }) + + it('keeps a narrowed selection when the dialog is reopened for the same record', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + await load() + selected.value = ['scores'] + + await load() + + expect(selected.value).toEqual(['scores']) + }) + + it('reselects everything when the record changes', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const urn = ref('urn:mavedb:00000001-a-1') + const {selected, load} = useCsvNamespaces({urn, kind: 'scoreSet'}) + await load() + selected.value = ['scores'] + + urn.value = 'urn:mavedb:00000001-a-2' + await load() + + expect(selected.value).toHaveLength(SCORE_SET_ENTRIES.length) + }) + + it('clears the selection when loading fails', async () => { + getScoreSetCsvNamespaces.mockResolvedValueOnce(SCORE_SET_ENTRIES).mockRejectedValueOnce(new Error('boom')) + const urn = ref('urn:mavedb:00000001-a-1') + const {selected, load} = useCsvNamespaces({urn, kind: 'scoreSet'}) + await load() + + urn.value = 'urn:mavedb:00000001-a-2' + await load() + + expect(selected.value).toEqual([]) + }) + + it('summarizes the selection so the picker states it outright', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, selectionSummary, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet' + }) + await load() + expect(selectionSummary.value).toBe('All columns selected') + + selected.value = ['scores', 'gnomad'] + expect(selectionSummary.value).toBe('2 of 5 column groups selected') + + selected.value = [] + expect(selectionSummary.value).toBe('No columns selected') + }) +}) + +describe('useCsvNamespaces default selection', () => { + const WITH_RESEARCH_USE_ONLY: AvailableCsvNamespace[] = [ + entry({namespace: 'scores', label: 'Scores', group: 'data'}), + entry({ + namespace: 'calibration.urn:mavedb:calibration-1', + label: 'Brnich et al. 2019', + group: 'calibration' + }), + entry({ + namespace: 'calibration.urn:mavedb:calibration-2', + label: 'Research Use Only: Provisional', + group: 'calibration', + selectedByDefault: false + }) + ] + + it('leaves research-use-only calibrations unchecked on load', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(WITH_RESEARCH_USE_ONLY) + const {selected, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(selected.value).toEqual(['scores', 'calibration.urn:mavedb:calibration-1']) + }) + + it('still offers them, so a user can opt in', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(WITH_RESEARCH_USE_ONLY) + const {namespaces, sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(namespaces.value).toHaveLength(3) + const calibrationSection = sections.value.find((section) => section.group === 'calibration') + expect(calibrationSection?.namespaces.map((entry) => entry.label)).toContain('Research Use Only: Provisional') + }) + + it('is not fully selected on load when a group is excluded by default', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(WITH_RESEARCH_USE_ONLY) + const {allSelected, selectionSummary, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet' + }) + + await load() + + expect(allSelected.value).toBe(false) + expect(selectionSummary.value).toBe('2 of 3 column groups selected') + }) + + it('select all reaches research-use-only groups, since that is an explicit act', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(WITH_RESEARCH_USE_ONLY) + const {selected, allSelected, toggleAll, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet' + }) + await load() + + toggleAll() + + expect(allSelected.value).toBe(true) + expect(selected.value).toContain('calibration.urn:mavedb:calibration-2') + }) +}) + +describe('useCsvNamespaces extras', () => { + const NAMESPACE_ENTRIES: AvailableCsvNamespace[] = [ + entry({namespace: 'scores', label: 'Score', group: 'data'}), + entry({namespace: 'scores_custom', label: 'Investigator-provided score columns', group: 'data'}), + entry({namespace: 'gnomad', label: 'gnomAD allele frequency', group: 'annotation'}) + ] + + const EXTRAS = [{label: "Omit HGVS columns this score set doesn't use", value: 'dropUnusedHgvsColumns'}] + + function withExtras() { + return useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet', + extraOptions: ref(EXTRAS) + }) + } + + it('counts only namespaces as column groups', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {totalColumnGroups, load} = withExtras() + + await load() + + // The investigator's score columns are a namespace now, so all three count; the flag does not. + expect(totalColumnGroups.value).toBe(3) + }) + + it('treats the investigator score columns as an ordinary namespace', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {selected, load} = withExtras() + + await load() + + expect(selected.value).toContain('scores') + expect(selected.value).toContain('scores_custom') + }) + + it('is still "all selected" while a formatting extra is unchecked', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {selectedExtras, allSelected, load} = withExtras() + await load() + + selectedExtras.value = [] + + expect(allSelected.value).toBe(true) + }) + + it('leaves formatting extras unchecked on load', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {selectedExtras, load} = withExtras() + + await load() + + expect(selectedExtras.value).toEqual([]) + }) + + it('select all does not disturb formatting options', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {selectedExtras, toggleAll, load} = withExtras() + await load() + selectedExtras.value = ['dropUnusedHgvsColumns'] + + toggleAll() + toggleAll() + + expect(selectedExtras.value).toEqual(['dropUnusedHgvsColumns']) + }) + + it('exposes formatting options for the Options section', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {formattingExtraOptions, load} = withExtras() + + await load() + + expect(formattingExtraOptions.value.map((o) => o.value)).toEqual(['dropUnusedHgvsColumns']) + }) +}) + +describe('useCsvNamespaces score set subdivision', () => { + const ONE_SCORE_SET: AvailableCsvNamespace[] = [ + entry({namespace: 'scores', group: 'data'}), + entry({ + namespace: 'calibration.urn:mavedb:calibration-1', + label: 'First Assay Calibration', + group: 'calibration', + scoreSet: {urn: 'urn:mavedb:00000001-a-1', title: 'First Assay'} + }) + ] + + const TWO_SCORE_SETS: AvailableCsvNamespace[] = [ + ...ONE_SCORE_SET, + entry({ + namespace: 'calibration.urn:mavedb:calibration-2', + label: 'Second Assay Calibration', + group: 'calibration', + scoreSet: {urn: 'urn:mavedb:00000001-a-2', title: 'Second Assay'} + }) + ] + + function sectionFor(sections: CsvNamespaceSection[], group: string) { + return sections.find((section) => section.group === group) + } + + it('does not subdivide when only one score set is represented', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(ONE_SCORE_SET) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + const calibrations = sectionFor(sections.value, 'calibration') + expect(calibrations?.subsections).toHaveLength(1) + expect(calibrations?.subsections[0].label).toBeNull() + }) + + it('subdivides by score set once more than one is represented', async () => { + getVariantCsvNamespaces.mockResolvedValue(TWO_SCORE_SETS) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1#1'), kind: 'variant'}) + + await load() + + const calibrations = sectionFor(sections.value, 'calibration') + // Headed by title, not URN — but the URN comes along for disambiguation. + expect(calibrations?.subsections.map((s) => s.label)).toEqual(['First Assay', 'Second Assay']) + expect(calibrations?.subsections.map((s) => s.urn)).toEqual(['urn:mavedb:00000001-a-1', 'urn:mavedb:00000001-a-2']) + expect(calibrations?.subsections[0].namespaces.map((e) => e.label)).toEqual(['First Assay Calibration']) + expect(calibrations?.subsections[1].namespaces.map((e) => e.label)).toEqual(['Second Assay Calibration']) + }) + + it('never subdivides sections whose namespaces have no owning score set', async () => { + getVariantCsvNamespaces.mockResolvedValue(TWO_SCORE_SETS) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1#1'), kind: 'variant'}) + + await load() + + const data = sectionFor(sections.value, 'data') + expect(data?.subsections).toHaveLength(1) + expect(data?.subsections[0].label).toBeNull() + }) + + it('keeps the flat list alongside the subdivision', async () => { + getVariantCsvNamespaces.mockResolvedValue(TWO_SCORE_SETS) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1#1'), kind: 'variant'}) + + await load() + + const calibrations = sectionFor(sections.value, 'calibration') + expect(calibrations?.namespaces).toHaveLength(2) + expect(calibrations?.subsections.flatMap((s) => s.namespaces)).toHaveLength(2) + }) + + it('still selects everything by default across score sets', async () => { + getVariantCsvNamespaces.mockResolvedValue(TWO_SCORE_SETS) + const {selected, allSelected, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1#1'), + kind: 'variant' + }) + + await load() + + expect(allSelected.value).toBe(true) + expect(selected.value).toHaveLength(3) + }) +}) diff --git a/src/composables/use-csv-namespaces.ts b/src/composables/use-csv-namespaces.ts new file mode 100644 index 00000000..6ee757bf --- /dev/null +++ b/src/composables/use-csv-namespaces.ts @@ -0,0 +1,220 @@ +import axios from 'axios' +import {computed, ref, type Ref} from 'vue' + +import {getScoreSetCsvNamespaces, getVariantCsvNamespaces} from '@/api/mavedb' +import type {components} from '@/schema/openapi' + +export type AvailableCsvNamespace = components['schemas']['AvailableCsvNamespace'] +export type CsvNamespaceGroup = components['schemas']['CsvNamespaceGroup'] + +/** Section headings for a namespace picker, in the order they should be shown. */ +const GROUP_ORDER: CsvNamespaceGroup[] = ['data', 'annotation', 'calibration', 'provenance'] + +const GROUP_TITLES: Record = { + data: 'Measurements', + annotation: 'Annotations', + calibration: 'Clinical interpretation', + provenance: 'Provenance' +} + +export interface CsvNamespaceSubsection { + /** A heading for this run of namespaces, or null when the section needs no subdivision. */ + label: string | null + /** The URN behind the heading, for a tooltip or secondary text. Null when there is no heading. */ + urn: string | null + namespaces: AvailableCsvNamespace[] +} + +export interface CsvNamespaceSection { + group: CsvNamespaceGroup + title: string + /** Every namespace in the section, regardless of subdivision. */ + namespaces: AvailableCsvNamespace[] + /** + * The namespaces split by owning score set, when more than one is represented; otherwise a single + * null-labelled subsection that renders as an undivided list. A variant's calibrations can span score + * sets, and one means nothing against another's scores. + */ + subsections: CsvNamespaceSubsection[] +} + +/** + * Split a section's namespaces by owning score set, but only when more than one is represented. + * Headings use the title; the URN comes along for disambiguation, since titles can collide. + */ +function subdivideByScoreSet(namespaces: AvailableCsvNamespace[]): CsvNamespaceSubsection[] { + const owners = new Map() + for (const entry of namespaces) { + if (entry.scoreSet) owners.set(entry.scoreSet.urn, entry.scoreSet.title) + } + if (owners.size < 2) return [{label: null, urn: null, namespaces}] + + const subsections = [...owners.entries()] + .sort(([, titleA], [, titleB]) => titleA.localeCompare(titleB)) + .map(([urn, title]) => ({ + label: title, + urn, + namespaces: namespaces.filter((entry) => entry.scoreSet?.urn === urn) + })) + + // Anything without an owner still has to appear somewhere. + const unowned = namespaces.filter((entry) => !entry.scoreSet) + return unowned.length > 0 ? [...subsections, {label: null, urn: null, namespaces: unowned}] : subsections +} + +/** A query flag that is not a column group, rendered under "Options" and returned as an `extra`. */ +export interface CsvExtraOption { + label: string + value: string +} + +interface UseCsvNamespacesOptions { + /** The record whose namespaces to offer. Not watched; `load()` refetches when it sees a new URN. */ + urn: Ref + /** Which endpoint to ask. Variants widen over equivalent measurements; score sets do not. */ + kind: 'scoreSet' | 'variant' + /** Formatting flags. Which ones an endpoint accepts is the caller's business. */ + extraOptions?: Ref +} + +/** + * Load the CSV column namespaces a record has data for, ready to render as a picker. + * + * Fetched lazily — call `load()` when a dialog opens — and cached per URN. Labels and grouping come from + * the API, which alone knows calibration titles and release dates. + */ +export function useCsvNamespaces({urn, kind, extraOptions}: UseCsvNamespacesOptions) { + const namespaces = ref([]) + const loading = ref(false) + const error = ref(null) + const loadedUrn = ref(null) + const controller = ref(null) + const selected = ref([]) + const selectedExtras = ref([]) + + const formattingExtraOptions = computed(() => extraOptions?.value ?? []) + + const sections = computed(() => + GROUP_ORDER.map((group) => { + const inGroup = namespaces.value.filter((entry) => entry.group === group) + return { + group, + title: GROUP_TITLES[group], + namespaces: inGroup, + subsections: subdivideByScoreSet(inGroup) + } + }).filter((section) => section.namespaces.length > 0) + ) + + /** Whether the record has anything at all to offer. False while loading and on error. */ + const hasNamespaces = computed(() => namespaces.value.length > 0) + + /** Column groups on offer. Only namespaces count; checking an extra alone produces no file. */ + const totalColumnGroups = computed(() => namespaces.value.length) + + const selectedColumnGroups = computed(() => selected.value.length) + + const allSelected = computed( + () => totalColumnGroups.value > 0 && selectedColumnGroups.value === totalColumnGroups.value + ) + + /** Describes the current selection, so the picker never has to explain an implicit rule. */ + const selectionSummary = computed(() => { + if (selectedColumnGroups.value === 0) return 'No columns selected' + if (allSelected.value) return 'All columns selected' + return `${selectedColumnGroups.value} of ${totalColumnGroups.value} column groups selected` + }) + + /** Formatting options are left alone: "Select all" is about columns. */ + function selectAll(): void { + selected.value = namespaces.value.map((entry) => entry.namespace) + } + + /** + * Select the groups the API marks as defaults, and every refinement of them. Research-use-only and + * rangeless calibrations are offered but excluded, so checking one is a deliberate act. + */ + function selectDefaults(): void { + selected.value = namespaces.value + .filter((entry) => entry.selectedByDefault !== false) + .map((entry) => entry.namespace) + } + + /** Select everything, or clear when everything is already selected. */ + function toggleAll(): void { + if (!allSelected.value) { + selectAll() + return + } + selected.value = [] + } + + async function load(): Promise { + if (!urn.value) return + // Already have this record's list; a namespace set only changes when the record does. + if (loadedUrn.value === urn.value && namespaces.value.length > 0) return + + controller.value?.abort() + // Held locally as well: a superseded request must not clear the spinner or drop the live request's + // controller on its way out, or reset() would no longer be able to abort what is actually in flight. + const ownController = new AbortController() + controller.value = ownController + + loading.value = true + error.value = null + try { + const fetcher = kind === 'variant' ? getVariantCsvNamespaces : getScoreSetCsvNamespaces + const entries = await fetcher(urn.value, ownController.signal) + if (controller.value !== ownController) return + namespaces.value = entries + loadedUrn.value = urn.value + // Open on the common case and let the user narrow from there. Reopening for the same record + // short-circuits above, so a previous selection survives rather than being reset. + selectDefaults() + } catch (e: unknown) { + // An aborted request is a superseded one, not a failure to report. + if (axios.isCancel(e) || controller.value !== ownController) return + namespaces.value = [] + selected.value = [] + selectedExtras.value = [] + loadedUrn.value = null + error.value = 'Could not load the available download options.' + } finally { + if (controller.value === ownController) { + loading.value = false + controller.value = null + } + } + } + + function reset(): void { + controller.value?.abort() + controller.value = null + namespaces.value = [] + selected.value = [] + selectedExtras.value = [] + loadedUrn.value = null + error.value = null + loading.value = false + } + + return { + namespaces, + sections, + hasNamespaces, + loading, + error, + selected, + selectedExtras, + formattingExtraOptions, + totalColumnGroups, + selectedColumnGroups, + allSelected, + selectionSummary, + selectAll, + selectDefaults, + toggleAll, + load, + reset + } +} diff --git a/src/composables/use-variant-lookup.ts b/src/composables/use-variant-lookup.ts index f7b6bd57..5ffacf8f 100644 --- a/src/composables/use-variant-lookup.ts +++ b/src/composables/use-variant-lookup.ts @@ -1,7 +1,7 @@ -import axios from 'axios' import {computed, ref, shallowRef, watch, type ComputedRef, type Ref} from 'vue' import { + downloadVariantCsv, getVariantAnnotation, getVariantDetail, getHistogramVariantData, @@ -17,6 +17,7 @@ import { getPrimaryCalibration } from '@/lib/calibrations' import {triggerDownload} from '@/lib/downloads' +import {describeRequestError} from '@/lib/errors' import {getExperimentKeyword} from '@/lib/experiments' import {parseScoreSetVariantData, type Variant} from '@/lib/variants' import type {MeasurementType} from '@/lib/measurement-types' @@ -84,6 +85,9 @@ export interface UseVariantLookupReturn { // Downloads fetchVariantAnnotations: (annotationType: string) => Promise + downloadVariantCsvFile: (namespaces?: string[]) => Promise + /** What download is in flight, or null when idle. Indeterminate; see use-score-set-downloads. */ + downloadInProgressLabel: Ref } /** @@ -115,6 +119,7 @@ export function useVariantLookup( const variants = ref([]) const variantsStatus = ref<'NotLoaded' | 'Loading' | 'Loaded' | 'Error'>('NotLoaded') const selectedVariantUrn = ref(null) + const downloadInProgressLabel = ref(null) const showNucleotide = ref(true) const showProtein = ref(true) const showAssociatedNucleotide = ref(true) @@ -284,27 +289,45 @@ export function useVariantLookup( async function fetchVariantAnnotations(annotationType: string) { const activeVariant = selectedVariantDetail.value - if (!activeVariant?.urn) return + if (!activeVariant?.urn || downloadInProgressLabel.value !== null) return + downloadInProgressLabel.value = 'annotations' try { const data = await getVariantAnnotation(activeVariant.urn, annotationType) triggerDownload(JSON.stringify(data), activeVariant.urn + '_' + annotationType + '.json', 'text/json') } catch (error: unknown) { - let serverMessage = '' - if (axios.isAxiosError(error) && error.response?.data) { - const data = error.response.data - if (typeof data === 'string') serverMessage = data - else if (typeof data === 'object' && data !== null && 'detail' in data) serverMessage = String(data.detail) - else serverMessage = JSON.stringify(data) - } else { - serverMessage = error instanceof Error ? error.message : 'Unknown error.' - } options?.toast?.add({ severity: 'error', summary: 'Download failed', - detail: `Could not fetch variant annotation: ${serverMessage}`, + detail: `Could not fetch variant annotation: ${describeRequestError(error)}`, + life: 4000 + }) + } finally { + downloadInProgressLabel.value = null + } + } + + /** + * Download the selected measurement's clinical CSV — the flat counterpart to + * `fetchVariantAnnotations`. Omitting `namespaces` asks the server for its default set. + */ + async function downloadVariantCsvFile(namespaces?: string[]) { + const activeVariant = selectedVariantDetail.value + if (!activeVariant?.urn || downloadInProgressLabel.value !== null) return + + downloadInProgressLabel.value = 'variant CSV' + try { + const data = await downloadVariantCsv(activeVariant.urn, namespaces) + triggerDownload(data, `${activeVariant.urn}.csv`, 'text/csv') + } catch (error: unknown) { + options?.toast?.add({ + severity: 'error', + summary: 'Download failed', + detail: `Could not download the variant table: ${describeRequestError(error)}`, life: 4000 }) + } finally { + downloadInProgressLabel.value = null } } @@ -414,6 +437,8 @@ export function useVariantLookup( getKeyword, geneName, uniqueAssayCount, - fetchVariantAnnotations + fetchVariantAnnotations, + downloadVariantCsvFile, + downloadInProgressLabel } } diff --git a/src/schema/openapi.d.ts b/src/schema/openapi.d.ts index d07f1dbc..a745ac0e 100644 --- a/src/schema/openapi.d.ts +++ b/src/schema/openapi.d.ts @@ -747,6 +747,32 @@ export interface paths { */ delete: operations["delete_score_set_api_v1_score_sets__urn__delete"]; }; + "/api/v1/score-sets/{urn}/csv-namespaces": { + /** + * List the CSV column namespaces this score set has data for + * @description List the CSV column namespaces this score set has data for, labeled and grouped for a picker. + * + * Each entry's `namespace` is a value accepted by the `namespaces` parameter of the CSV endpoints. + * Deliberately a separate request rather than a field on the score set: it costs several queries and is + * only needed when a user opens a download dialog, so it should not sit on the score-set page's + * critical path. + * + * Parameters + * __________ + * urn : str + * The URN of the score set to inspect. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * list[AvailableCsvNamespace] + * The namespaces with data, each with a human-readable label and group. + */ + get: operations["get_score_set_csv_namespaces_api_v1_score_sets__urn__csv_namespaces_get"]; + }; "/api/v1/score-sets/{urn}/variants/data": { /** * Get score set variant data in CSV format @@ -765,11 +791,19 @@ export interface paths { * The maximum number of variants to return. If None, returns all variants. * namespaces: List[str] * The namespaces of all columns except for accession, hgvs_nt, hgvs_pro, and hgvs_splice. - * Supported values: "scores", "counts", "vep", "gnomad", "clingen", and ClinVar-versioned - * namespaces of the form "clinvar.YEAR_MONTH" (e.g. "clinvar.2024_01" for January 2024). - * Multiple ClinVar namespaces with different YEAR_MONTH values may be requested simultaneously. + * Supported values: "scores" (the required score column), "scores_custom" (the investigator's + * remaining score columns, emitted under the "scores" prefix), "counts", "mavedb", "vep", "gnomad", + * "clingen", "score_set", and ClinVar- and calibration-parameterized namespaces. Multiple ClinVar + * and calibration namespaces may be requested simultaneously. + * drop_unused_hgvs_columns : bool, optional + * Whether to omit the HGVS coordinate columns this score set does not use, e.g. hgvs_nt for a + * protein-only score set. Defaults to False. * drop_na_columns : bool, optional - * Whether to drop columns that contain only NA values. Defaults to False. + * Deprecated spelling of drop_unused_hgvs_columns, accepted for one release. + * include_post_mapped_hgvs : bool, optional + * Deprecated: equivalent to requesting the "mavedb" namespace. Accepted for one release. + * include_custom_columns : bool, optional + * Deprecated: equivalent to requesting the "scores_custom" namespace. Accepted for one release. * db : Session * The database session to use. * user_data : Optional[UserData] @@ -1328,6 +1362,62 @@ export interface paths { */ get: operations["get_variant_api_v1_variants__urn__get"]; }; + "/api/v1/variants/{urn}/csv-namespaces": { + /** + * List the CSV column namespaces this variant has data for + * @description List the CSV column namespaces this variant has data for, labeled and grouped for a picker. + * + * Widens over the variant's equivalent measurements the same way the CSV does, so a calibration + * belonging to another score set that also measured this allele is offered here too. + * + * Parameters + * __________ + * urn : str + * The URN of the variant to inspect. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * list[AvailableCsvNamespace] + * The namespaces with data, each with a human-readable label and group. + */ + get: operations["get_variant_csv_namespaces_api_v1_variants__urn__csv_namespaces_get"]; + }; + "/api/v1/variants/{urn}/csv": { + /** + * Get variant data in CSV format + * @description Return tabular data for a single variant, identified by URN, in CSV format. + * + * Where the variant-level annotation endpoints return nested VA-Spec objects, this flattens the same + * interpretation into columns a clinical information system can consume: ACMG criteria, evidence + * strengths, and evidence outcome codes alongside the measurement they were derived from. + * + * A row is emitted for every current measurement of the variant's ClinGen allele, so a variant assayed + * in several score sets yields several rows. The requested variant is always first. + * + * Parameters + * __________ + * urn : str + * The URN of the variant to fetch. + * namespaces : Optional[List[str]] + * The groups of columns to include. When omitted, the response includes the fixed groups plus one + * namespace per calibration eligible to annotate these measurements and the most recent ClinVar + * release covering them. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * Any + * StreamingResponse containing the CSV data. + */ + get: operations["get_variant_csv_data_api_v1_variants__urn__csv_get"]; + }; "/api/v1/alphafold-files/version": { /** * Proxy Alphafold Index @@ -1628,6 +1718,28 @@ export interface components { /** Version */ version: string; }; + /** + * AvailableCsvNamespace + * @description One CSV column namespace a record has data for, ready to be offered as a choice. + * + * Labels are served rather than derived client-side: only the server knows a calibration's title or a + * ClinVar release date. + */ + AvailableCsvNamespace: { + /** Recordtype */ + recordType?: string; + /** Namespace */ + namespace: string; + /** Label */ + label: string; + group: components["schemas"]["CsvNamespaceGroup"]; + scoreSet?: components["schemas"]["ShorterScoreSet"] | null; + /** + * Selectedbydefault + * @default true + */ + selectedByDefault?: boolean; + }; /** Body_create_score_calibration_route_api_v1_score_calibrations__post */ Body_create_score_calibration_route_api_v1_score_calibrations__post: { /** @@ -2391,6 +2503,12 @@ export interface components { */ copies: components["schemas"]["Range"] | number; }; + /** + * CsvNamespaceGroup + * @description Presentational grouping, so a client can section a namespace picker. + * @enum {string} + */ + CsvNamespaceGroup: "data" | "annotation" | "calibration" | "provenance"; /** * CurrentUser * @description User view model for information about the current user. @@ -5132,10 +5250,15 @@ export interface components { /** Uniprotidfrommappedmetadata */ uniprotIdFromMappedMetadata?: string | null; }; - /** ShorterScoreSet */ + /** + * ShorterScoreSet + * @description A score set's identity: enough to name it in a UI without rooting the display on its URN. + */ ShorterScoreSet: { /** Urn */ urn: string; + /** Title */ + title: string; /** Recordtype */ recordType?: string; }; @@ -10128,6 +10251,69 @@ export interface operations { }; }; }; + /** + * List the CSV column namespaces this score set has data for + * @description List the CSV column namespaces this score set has data for, labeled and grouped for a picker. + * + * Each entry's `namespace` is a value accepted by the `namespaces` parameter of the CSV endpoints. + * Deliberately a separate request rather than a field on the score set: it costs several queries and is + * only needed when a user opens a download dialog, so it should not sit on the score-set page's + * critical path. + * + * Parameters + * __________ + * urn : str + * The URN of the score set to inspect. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * list[AvailableCsvNamespace] + * The namespaces with data, each with a human-readable label and group. + */ + get_score_set_csv_namespaces_api_v1_score_sets__urn__csv_namespaces_get: { + parameters: { + header?: { + "x-active-roles"?: string | null; + }; + path: { + urn: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["AvailableCsvNamespace"][]; + }; + }; + /** @description Authentication required. */ + 401: { + content: never; + }; + /** @description Forbidden. Insufficient permissions. */ + 403: { + content: never; + }; + /** @description Resource not found. */ + 404: { + content: never; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + /** @description Internal server error. */ + 500: { + content: never; + }; + }; + }; /** * Get score set variant data in CSV format * @description Return tabular variant data from a score set, identified by URN, in CSV format. @@ -10145,11 +10331,19 @@ export interface operations { * The maximum number of variants to return. If None, returns all variants. * namespaces: List[str] * The namespaces of all columns except for accession, hgvs_nt, hgvs_pro, and hgvs_splice. - * Supported values: "scores", "counts", "vep", "gnomad", "clingen", and ClinVar-versioned - * namespaces of the form "clinvar.YEAR_MONTH" (e.g. "clinvar.2024_01" for January 2024). - * Multiple ClinVar namespaces with different YEAR_MONTH values may be requested simultaneously. + * Supported values: "scores" (the required score column), "scores_custom" (the investigator's + * remaining score columns, emitted under the "scores" prefix), "counts", "mavedb", "vep", "gnomad", + * "clingen", "score_set", and ClinVar- and calibration-parameterized namespaces. Multiple ClinVar + * and calibration namespaces may be requested simultaneously. + * drop_unused_hgvs_columns : bool, optional + * Whether to omit the HGVS coordinate columns this score set does not use, e.g. hgvs_nt for a + * protein-only score set. Defaults to False. * drop_na_columns : bool, optional - * Whether to drop columns that contain only NA values. Defaults to False. + * Deprecated spelling of drop_unused_hgvs_columns, accepted for one release. + * include_post_mapped_hgvs : bool, optional + * Deprecated: equivalent to requesting the "mavedb" namespace. Accepted for one release. + * include_custom_columns : bool, optional + * Deprecated: equivalent to requesting the "scores_custom" namespace. Accepted for one release. * db : Session * The database session to use. * user_data : Optional[UserData] @@ -10167,11 +10361,24 @@ export interface operations { start?: number; /** @description Maximum number of variants to return */ limit?: number; - /** @description One or more data types to include: "scores", "counts", "vep", "gnomad", "clingen", and/or ClinVar-versioned namespaces of the form "clinvar.YEAR_MONTH" (e.g. "clinvar.2024_01" for January 2024). */ + /** @description One or more groups of columns to include. Naming any group replaces the default set rather than adding to it, so list every group you want. Fixed groups: "scores", "scores_custom", "counts", "mavedb", "vep", "gnomad", "clingen", "score_set", "relationship". Versioned groups: "clinvar.YEAR_MONTH" (e.g. "clinvar.2024_01") for one ClinVar release, and "calibration." for one score calibration's functional and ACMG interpretation. Several ClinVar and calibration namespaces may be requested at once; each carries its release or URN in the column header. To discover which namespaces are available for a record, query the `csv-namespaces` endpoint. */ namespaces?: string[]; + drop_unused_hgvs_columns?: boolean | null; + /** + * @deprecated + * @description Deprecated: use `drop_unused_hgvs_columns`, which names what it actually does. This parameter only ever dropped the HGVS coordinate columns a score set does not use, never every NA column. It will be removed in a future release; `drop_unused_hgvs_columns` wins if both are given. + */ drop_na_columns?: boolean | null; - include_custom_columns?: boolean | null; + /** + * @deprecated + * @description Deprecated: request the `mavedb` namespace instead, e.g. `?namespaces=scores&namespaces=mavedb`. Passing true here is equivalent to appending that namespace. It will be removed in a future release. + */ include_post_mapped_hgvs?: boolean | null; + /** + * @deprecated + * @description Deprecated: request the `scores_custom` namespace instead. Passing true here is equivalent to appending that namespace, whose columns are emitted under the `scores` prefix as before. It will be removed in a future release. + */ + include_custom_columns?: boolean | null; }; header?: { "x-active-roles"?: string | null; @@ -10287,6 +10494,11 @@ export interface operations { start?: number; /** @description Number of variants to return */ limit?: number; + drop_unused_hgvs_columns?: boolean | null; + /** + * @deprecated + * @description Deprecated: use `drop_unused_hgvs_columns`, which names what it actually does. This parameter only ever dropped the HGVS coordinate columns a score set does not use, never every NA column. It will be removed in a future release; `drop_unused_hgvs_columns` wins if both are given. + */ drop_na_columns?: boolean | null; }; header?: { @@ -10348,6 +10560,11 @@ export interface operations { start?: number; /** @description Number of variants to return */ limit?: number; + drop_unused_hgvs_columns?: boolean | null; + /** + * @deprecated + * @description Deprecated: use `drop_unused_hgvs_columns`, which names what it actually does. This parameter only ever dropped the HGVS coordinate columns a score set does not use, never every NA column. It will be removed in a future release; `drop_unused_hgvs_columns` wins if both are given. + */ drop_na_columns?: boolean | null; }; header?: { @@ -12467,6 +12684,145 @@ export interface operations { }; }; }; + /** + * List the CSV column namespaces this variant has data for + * @description List the CSV column namespaces this variant has data for, labeled and grouped for a picker. + * + * Widens over the variant's equivalent measurements the same way the CSV does, so a calibration + * belonging to another score set that also measured this allele is offered here too. + * + * Parameters + * __________ + * urn : str + * The URN of the variant to inspect. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * list[AvailableCsvNamespace] + * The namespaces with data, each with a human-readable label and group. + */ + get_variant_csv_namespaces_api_v1_variants__urn__csv_namespaces_get: { + parameters: { + header?: { + "x-active-roles"?: string | null; + }; + path: { + urn: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["AvailableCsvNamespace"][]; + }; + }; + /** @description Authentication required. */ + 401: { + content: never; + }; + /** @description Forbidden. Insufficient permissions. */ + 403: { + content: never; + }; + /** @description Resource not found. */ + 404: { + content: never; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + /** @description Internal server error. */ + 500: { + content: never; + }; + }; + }; + /** + * Get variant data in CSV format + * @description Return tabular data for a single variant, identified by URN, in CSV format. + * + * Where the variant-level annotation endpoints return nested VA-Spec objects, this flattens the same + * interpretation into columns a clinical information system can consume: ACMG criteria, evidence + * strengths, and evidence outcome codes alongside the measurement they were derived from. + * + * A row is emitted for every current measurement of the variant's ClinGen allele, so a variant assayed + * in several score sets yields several rows. The requested variant is always first. + * + * Parameters + * __________ + * urn : str + * The URN of the variant to fetch. + * namespaces : Optional[List[str]] + * The groups of columns to include. When omitted, the response includes the fixed groups plus one + * namespace per calibration eligible to annotate these measurements and the most recent ClinVar + * release covering them. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * Any + * StreamingResponse containing the CSV data. + */ + get_variant_csv_data_api_v1_variants__urn__csv_get: { + parameters: { + query?: { + /** @description One or more groups of columns to include. Naming any group replaces the default set rather than adding to it, so list every group you want. Fixed groups: "scores", "scores_custom", "counts", "mavedb", "vep", "gnomad", "clingen", "score_set", "relationship". Versioned groups: "clinvar.YEAR_MONTH" (e.g. "clinvar.2024_01") for one ClinVar release, and "calibration." for one score calibration's functional and ACMG interpretation. Several ClinVar and calibration namespaces may be requested at once; each carries its release or URN in the column header. To discover which namespaces are available for a record, query the `csv-namespaces` endpoint. */ + namespaces?: string[] | null; + }; + header?: { + "x-active-roles"?: string | null; + }; + path: { + urn: string; + }; + }; + responses: { + /** @description Variant data in CSV format, one row per measurement of the variant's allele. Columns cover identity, mapped coordinates, the measured score, external annotations, and each requested calibration's functional and ACMG interpretation. */ + 200: { + content: { + "application/json": unknown; + "text/csv": unknown; + }; + }; + /** @description Bad request. Check parameters and payload. */ + 400: { + content: never; + }; + /** @description Authentication required. */ + 401: { + content: never; + }; + /** @description Forbidden. Insufficient permissions. */ + 403: { + content: never; + }; + /** @description Resource not found. */ + 404: { + content: never; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + /** @description Internal server error. */ + 500: { + content: never; + }; + }; + }; /** * Proxy Alphafold Index * @description Proxy the AlphaFold files index (XML document). From 9277f0fd5d9108fd94bd99711e897ed9f7db14bb Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Thu, 6 Aug 2026 10:38:56 -0700 Subject: [PATCH 15/22] feat(ui): add a download indicator and fix annotation-stream memory use Fix streamAnnotationsInto (use-score-set-downloads.ts) accumulating NDJSON chunks as decoded strings: decoding to UTF-16, then joining, then building a Blob from the result meant a large pathogenicity-statement download held roughly five copies of the payload in memory at once and could run the tab out of it. Accumulate raw Uint8Array chunks instead and build the Blob directly from them, and count newline bytes rather than decoding to track progress. Also make a truncated stream (fewer lines than X-Total-Count) throw instead of silently saving a partial file, since the response has already started by the time that could be detected. Replace the per-feature annotatedDownloadInProgress/Progress state with a single fileDownloadLabel/fileDownloadProgress pair shared by every download this composable offers (scores, counts, mapped variants, custom data, and the VA-Spec streams), via a withIndicator() wrapper that also prevents two downloads running concurrently. Progress is indeterminate except for the VA-Spec streams, which can count records against X-Total-Count. Wire the shared indicator into ScoreSetDownloads.vue as one progress bar under the button row instead of one hung off the annotated-variants split button, disable every download button while any download is in flight, and add reportingFailure() so a rejected download surfaces a toast instead of an unhandled promise rejection in the console. Switch the custom-data dialog to MvCsvColumnDialog, matching MvVariantPreview and VariantScreen. --- .../score-set/ScoreSetDownloads.vue | 152 +++++++---- .../use-score-set-downloads.test.ts | 255 ++++++++++++++++++ src/composables/use-score-set-downloads.ts | 168 +++++++----- 3 files changed, 452 insertions(+), 123 deletions(-) create mode 100644 src/composables/use-score-set-downloads.test.ts diff --git a/src/components/score-set/ScoreSetDownloads.vue b/src/components/score-set/ScoreSetDownloads.vue index 79b98bf9..09a31cbd 100644 --- a/src/components/score-set/ScoreSetDownloads.vue +++ b/src/components/score-set/ScoreSetDownloads.vue @@ -2,57 +2,70 @@

Download files

- + + @click="reportingFailure('counts', () => downloadFile('counts'))" /> + @click="downloadMetadata" /> - -
- -
- -
-
+ @click="reportingFailure('mapped variants', downloadMappedVariantsFile)" /> + + + @click="customDialogVisible = true" /> +
+ + +
+ + + Preparing {{ fileDownloadLabel }}… + +
@@ -65,37 +78,24 @@
- - -
- -
- -
+ :extra-options="extraDownloadOptions" + header="Custom data download" + kind="scoreSet" + :urn="scoreSet.urn" + @confirm="handleCustomDownload" />
+ + diff --git a/src/composables/use-score-set-downloads.test.ts b/src/composables/use-score-set-downloads.test.ts new file mode 100644 index 00000000..2c9639df --- /dev/null +++ b/src/composables/use-score-set-downloads.test.ts @@ -0,0 +1,255 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest' +import {ref, watch, type Ref} from 'vue' + +import {useScoreSetDownloads} from './use-score-set-downloads' + +const downloadScoreSetFile = vi.fn() +const downloadScoreSetVariantData = vi.fn() +const downloadMappedVariants = vi.fn() + +vi.mock('@/api/mavedb', () => ({ + downloadScoreSetFile: (...args: unknown[]) => downloadScoreSetFile(...args), + downloadScoreSetVariantData: (...args: unknown[]) => downloadScoreSetVariantData(...args), + downloadMappedVariants: (...args: unknown[]) => downloadMappedVariants(...args) +})) + +// The real one reaches for `document`; these tests run in the node environment. +vi.mock('@/lib/downloads', () => ({triggerDownload: vi.fn()})) + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const SCORE_SET = ref({urn: 'urn:mavedb:00000001-a-1'} as any) + +/** Collect every value the progress ref takes; it is back to null by the time a download resolves. */ +function recordProgress(source: Ref): {values: (number | null)[]; stop: () => void} { + const values: (number | null)[] = [] + const stop = watch(source, (value) => values.push(value), {flush: 'sync'}) + return {values, stop} +} + +beforeEach(() => { + downloadScoreSetFile.mockReset() + downloadScoreSetVariantData.mockReset() + downloadMappedVariants.mockReset() +}) + +describe('useScoreSetDownloads download indicator', () => { + it('is idle before anything is requested', () => { + const {fileDownloadInProgress, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + expect(fileDownloadInProgress.value).toBe(false) + expect(fileDownloadLabel.value).toBeNull() + }) + + it('names the file being prepared while the request is in flight', async () => { + let release: (csv: string) => void = () => {} + downloadScoreSetFile.mockReturnValueOnce(new Promise((resolve) => (release = resolve))) + const {downloadFile, fileDownloadInProgress, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + const pending = downloadFile('counts') + expect(fileDownloadInProgress.value).toBe(true) + expect(fileDownloadLabel.value).toBe('Counts') + + release('accession,c_0\n') + await pending + + expect(fileDownloadInProgress.value).toBe(false) + expect(fileDownloadLabel.value).toBeNull() + }) + + it('leaves a CSV indeterminate, since a gzipped body has no measurable total', async () => { + let release: (csv: string) => void = () => {} + downloadScoreSetFile.mockReturnValueOnce(new Promise((resolve) => (release = resolve))) + const {downloadFile, fileDownloadProgress} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + const pending = downloadFile('scores') + expect(fileDownloadProgress.value).toBeNull() + + release('accession,score\n') + await pending + }) + + it('clears the indicator when the request fails', async () => { + // Otherwise a failed 10MB download leaves the buttons disabled behind a bar that never stops. + downloadScoreSetFile.mockRejectedValueOnce(new Error('boom')) + const {downloadFile, fileDownloadInProgress} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + await expect(downloadFile('scores')).rejects.toThrow('boom') + + expect(fileDownloadInProgress.value).toBe(false) + }) + + it('ignores a second request while one is already running', async () => { + let release: (csv: string) => void = () => {} + downloadScoreSetFile.mockReturnValueOnce(new Promise((resolve) => (release = resolve))) + const {downloadFile, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + const pending = downloadFile('scores') + await downloadFile('counts') + + // The first request still owns the indicator, and the second never reached the API. + expect(fileDownloadLabel.value).toBe('Scores') + expect(downloadScoreSetFile).toHaveBeenCalledTimes(1) + + release('accession,score\n') + await pending + }) + + it('covers the custom-columns download too', async () => { + let release: (csv: string) => void = () => {} + downloadScoreSetVariantData.mockReturnValueOnce(new Promise((resolve) => (release = resolve))) + const {downloadMultipleData, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + const pending = downloadMultipleData({namespaces: ['scores'], extras: []}) + expect(fileDownloadLabel.value).toBe('Custom data') + + release('accession,score\n') + await pending + + expect(fileDownloadLabel.value).toBeNull() + }) + + it('covers the mapped-variants download too', async () => { + let release: (data: unknown) => void = () => {} + downloadMappedVariants.mockReturnValueOnce(new Promise((resolve) => (release = resolve))) + const {downloadMappedVariantsFile, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + const pending = downloadMappedVariantsFile() + expect(fileDownloadLabel.value).toBe('Mapped variants') + + release([]) + await pending + + expect(fileDownloadLabel.value).toBeNull() + }) + + it('does nothing without a score set', async () => { + const {downloadFile, fileDownloadInProgress} = useScoreSetDownloads({scoreSet: ref(null)}) + + await downloadFile('scores') + + expect(downloadScoreSetFile).not.toHaveBeenCalled() + expect(fileDownloadInProgress.value).toBe(false) + }) +}) + +describe('useScoreSetDownloads annotation streaming shares the indicator', () => { + /** Serve an NDJSON body in the given chunks, with an optional X-Total-Count. */ + function mockStream(chunks: string[], totalCount: string | null = '4') { + const encoder = new TextEncoder() + let index = 0 + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + headers: {get: (name: string) => (name === 'X-Total-Count' ? totalCount : null)}, + body: { + getReader: () => ({ + read: async () => + index < chunks.length ? {done: false, value: encoder.encode(chunks[index++])} : {done: true} + }) + } + }) + ) + } + + beforeEach(() => { + // The stream saves its file through Blob + an anchor click rather than `triggerDownload`, so the + // node environment needs both stubbed for the completion path to run. + vi.stubGlobal('Blob', class {}) + vi.stubGlobal('URL', {createObjectURL: () => 'blob:stub', revokeObjectURL: () => {}}) + vi.stubGlobal('document', {createElement: () => ({click: () => {}})}) + }) + + it('reports a percentage, since a VA-Spec stream can count records', async () => { + mockStream(['{"a":1}\n', '{"a":2}\n'], '2') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + const {values, stop} = recordProgress(downloads.fileDownloadProgress) + + const pending = downloads.streamVariantAnnotations('study-result', 'Functional Study Result') + expect(downloads.fileDownloadLabel.value).toBe('Functional Study Result') + await pending + stop() + + // One record then both, then released. + expect(values).toEqual([50, 100, null]) + }) + + it('refuses to save a truncated download', async () => { + // The bar stalling partway is the visible symptom of the server generator raising mid-stream: the 200 + // and its headers are long gone, so a short body is all the client ever sees. Saving it would hand + // the user a silently incomplete file. + mockStream(['{"a":1}\n'], '5') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + + await expect(downloads.streamVariantAnnotations('functional-statement')).rejects.toThrow('received 1 of 5 records') + expect(downloads.fileDownloadInProgress.value).toBe(false) + }) + + it('counts records by byte, so a multi-byte character cannot skew the total', async () => { + // The old implementation decoded each chunk to a string first; retaining bytes is both exact and what + // keeps a large download out of the tab's memory. + mockStream(['{"p":"p.Trp26€"}\n', '{"p":"p.Met1?"}\n'], '2') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + const {values, stop} = recordProgress(downloads.fileDownloadProgress) + + await downloads.streamVariantAnnotations('study-result') + stop() + + expect(values).toEqual([50, 100, null]) + }) + + it('does not overshoot 100% across chunk boundaries', async () => { + // `split('\n').length` counted one extra per chunk, so a record split across chunks was double + // counted and the old bar sailed past 100%. + mockStream(['{"a":1}\n{"a":', '2}\n{"a":3}\n{"a":4}\n'], '4') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + const {values, stop} = recordProgress(downloads.fileDownloadProgress) + + await downloads.streamVariantAnnotations('study-result') + stop() + + expect(Math.max(...values.map((value) => value ?? 0))).toBe(100) + }) + + it('stays indeterminate when the total count header is absent', async () => { + // Previously this divided by zero and set the bar to Infinity. + mockStream(['{"a":1}\n'], null) + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + const {values, stop} = recordProgress(downloads.fileDownloadProgress) + + await downloads.streamVariantAnnotations('study-result') + stop() + + expect(values.every((value) => value === null)).toBe(true) + }) + + it('blocks a CSV download while a stream is running, since they share one indicator', async () => { + let releaseRead: () => void = () => {} + const encoder = new TextEncoder() + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + headers: {get: () => '1'}, + body: { + getReader: () => ({ + read: () => + new Promise((resolve) => { + releaseRead = () => resolve({done: false, value: encoder.encode('{"a":1}\n')}) + }) + }) + } + }) + ) + const {streamVariantAnnotations, downloadFile, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + void streamVariantAnnotations('study-result', 'Functional Study Result') + await Promise.resolve() + await Promise.resolve() + await downloadFile('scores') + + expect(fileDownloadLabel.value).toBe('Functional Study Result') + expect(downloadScoreSetFile).not.toHaveBeenCalled() + releaseRead() + }) +}) diff --git a/src/composables/use-score-set-downloads.ts b/src/composables/use-score-set-downloads.ts index 44385d67..c0431ab9 100644 --- a/src/composables/use-score-set-downloads.ts +++ b/src/composables/use-score-set-downloads.ts @@ -1,5 +1,7 @@ import {computed, ref, type Ref} from 'vue' +import type {CsvExtraOption} from '@/composables/use-csv-namespaces' + import {downloadScoreSetFile, downloadScoreSetVariantData, downloadMappedVariants} from '@/api/mavedb' import config from '@/config' import {triggerDownload} from '@/lib/downloads' @@ -11,55 +13,75 @@ export const TEXT_COLUMNS = ['hgvs_nt', 'hgvs_splice', 'hgvs_pro'] interface UseScoreSetDownloadsOptions { scoreSet: Ref - hasCounts?: Ref } -export function useScoreSetDownloads({scoreSet, hasCounts}: UseScoreSetDownloadsOptions) { +export function useScoreSetDownloads({scoreSet}: UseScoreSetDownloadsOptions) { const customDialogVisible = ref(false) - const selectedDataOptions = ref([]) - const annotatedDownloadInProgress = ref(false) - const annotatedDownloadProgress = ref(0) const streamController = ref(null) - const dataTypeOptions = computed(() => { - const options = [ - {label: 'Scores', value: 'scores'}, - {label: 'Mapped HGVS', value: 'mappedHgvs'}, - {label: 'Custom columns', value: 'includeCustomColumns'}, - {label: 'Without NA columns', value: 'dropNaColumns'} - ] - if (hasCounts?.value) { - options.splice(1, 0, {label: 'Counts', value: 'counts'}) + /** What file is currently being prepared, or null when idle. One indicator for every download here. */ + const fileDownloadLabel = ref(null) + + /** + * Percent complete, or null when the download cannot report progress. + * + * Only the VA-Spec streams can: they carry `X-Total-Count` and emit one NDJSON record per line, so + * records can be tallied as they arrive. A CSV arrives as a single gzipped body whose `Content-Length` + * is the *compressed* size, which browsers compare against decompressed bytes received, so no usable + * percentage exists — and most of that wait is the server building the file before any byte is sent. + */ + const fileDownloadProgress = ref(null) + + const fileDownloadInProgress = computed(() => fileDownloadLabel.value !== null) + + /** Run *download* with the indicator showing, clearing it even if the request fails. */ + async function withIndicator(label: string, download: () => Promise): Promise { + if (fileDownloadLabel.value !== null) return + fileDownloadLabel.value = label + try { + return await download() + } finally { + fileDownloadLabel.value = null + fileDownloadProgress.value = null } - return options - }) + } + + /** Formatting flags. Column groups come from the csv-namespaces endpoint via MvCsvColumnDialog. */ + const extraDownloadOptions: CsvExtraOption[] = [ + {label: "Omit HGVS columns this score set doesn't use", value: 'dropUnusedHgvsColumns'} + ] async function downloadFile(type: 'scores' | 'counts') { if (!scoreSet.value) return - const data = await downloadScoreSetFile(scoreSet.value.urn, type) - triggerDownload(data, `${scoreSet.value.urn}_${type}.csv`) + await withIndicator(type === 'scores' ? 'Scores' : 'Counts', async () => { + const data = await downloadScoreSetFile(scoreSet.value!.urn, type) + triggerDownload(data, `${scoreSet.value!.urn}_${type}.csv`) + }) } - async function downloadMultipleData() { + /** + * Download the score set's variant data with the chosen column groups. Namespaces pass through + * verbatim — they are the API's own vocabulary. (This previously sent an unsupported `data_type` + * parameter, so every selection was silently ignored.) + */ + async function downloadMultipleData({namespaces, extras}: {namespaces: string[]; extras: string[]}) { if (!scoreSet.value) return const params = new URLSearchParams() - for (const opt of selectedDataOptions.value) { - if (opt === 'scores') params.append('data_type', 'scores') - else if (opt === 'counts') params.append('data_type', 'counts') - else if (opt === 'mappedHgvs') params.append('include_post_mapped_hgvs', 'true') - else if (opt === 'includeCustomColumns') params.append('include_custom_columns', 'true') - else if (opt === 'dropNaColumns') params.append('drop_na_columns', 'true') - } - if (!params.has('data_type')) params.append('data_type', 'scores') - const data = await downloadScoreSetVariantData(scoreSet.value.urn, params) - triggerDownload(data, `${scoreSet.value.urn}_custom.csv`) - customDialogVisible.value = false + for (const namespace of namespaces) params.append('namespaces', namespace) + if (extras.includes('dropUnusedHgvsColumns')) params.append('drop_unused_hgvs_columns', 'true') + await withIndicator('Custom data', async () => { + const data = await downloadScoreSetVariantData(scoreSet.value!.urn, params) + triggerDownload(data, `${scoreSet.value!.urn}_custom.csv`) + customDialogVisible.value = false + }) } async function downloadMappedVariantsFile() { if (!scoreSet.value) return - const data = await downloadMappedVariants(scoreSet.value.urn) - triggerDownload(JSON.stringify(data), `${scoreSet.value.urn}_mapped_variants.json`, 'text/json') + await withIndicator('Mapped variants', async () => { + const data = await downloadMappedVariants(scoreSet.value!.urn) + triggerDownload(JSON.stringify(data), `${scoreSet.value!.urn}_mapped_variants.json`, 'text/json') + }) } function downloadMetadata() { @@ -71,22 +93,25 @@ export function useScoreSetDownloads({scoreSet, hasCounts}: UseScoreSetDownloads function abortStream() { if (streamController.value) { streamController.value.abort() - annotatedDownloadInProgress.value = false - annotatedDownloadProgress.value = 0 + fileDownloadLabel.value = null + fileDownloadProgress.value = null } } - async function streamVariantAnnotations(annotationType: string) { - if (!scoreSet.value) return - abortStream() + async function streamVariantAnnotations(annotationType: string, label = 'annotations') { + const urn = scoreSet.value?.urn + if (!urn) return + await withIndicator(label, () => streamAnnotationsInto(urn, annotationType)) + } + + async function streamAnnotationsInto(urn: string, annotationType: string) { streamController.value = new AbortController() try { - annotatedDownloadInProgress.value = true - const response = await fetch( - `${config.apiBaseUrl}/score-sets/${scoreSet.value.urn}/annotated-variants/${annotationType}`, - {signal: streamController.value.signal, headers: {Accept: 'application/x-ndjson'}} - ) + const response = await fetch(`${config.apiBaseUrl}/score-sets/${urn}/annotated-variants/${annotationType}`, { + signal: streamController.value.signal, + headers: {Accept: 'application/x-ndjson'} + }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) @@ -96,27 +121,45 @@ export function useScoreSetDownloads({scoreSet, hasCounts}: UseScoreSetDownloads const reader = response.body?.getReader() if (!reader) throw new Error('Response body is not readable') - const decoder = new TextDecoder() - const chunks: string[] = [] + // Held as raw bytes rather than decoded strings. Accumulating strings and then joining them cost + // roughly five times the payload — JS strings are UTF-16, so the decoded chunks alone doubled it, + // the join doubled that again, and the Blob copied the result. A large pathogenicity download ran + // the tab out of memory. + const parts: Uint8Array[] = [] let processedCount = 0 while (true) { const {done, value} = await reader.read() - if (done) { - const blob = new Blob([chunks.join('')], {type: 'application/x-ndjson'}) - const url = URL.createObjectURL(blob) - const anchor = document.createElement('a') - anchor.href = url - anchor.download = `${scoreSet.value!.urn}_annotated_variants_${annotationType}.ndjson` - anchor.click() - URL.revokeObjectURL(url) - break + if (done) break + + parts.push(value) + // NDJSON terminates every record with a newline, and 0x0A cannot appear inside a multi-byte UTF-8 + // sequence, so counting the byte is exact and needs no decoding or chunk-boundary bookkeeping. + for (const byte of value) { + if (byte === 0x0a) processedCount += 1 } - const chunk = decoder.decode(value) - chunks.push(chunk) - processedCount += chunk.split('\n').length - annotatedDownloadProgress.value = Math.round((processedCount / totalCount) * 100) + // Without a total there is nothing to divide by; stay indeterminate rather than report Infinity. + fileDownloadProgress.value = + totalCount > 0 ? Math.min(100, Math.round((processedCount / totalCount) * 100)) : null } + + // The server generator yields one line per variant, so a short body means it stopped early — + // status and headers went out with the first chunk, so a truncated stream is the only symptom it + // can produce. Refuse to save a file that is quietly missing records. + if (totalCount > 0 && processedCount < totalCount) { + throw new Error( + `Download incomplete: received ${processedCount} of ${totalCount} records.` + + ' The server stopped sending partway through; check the API logs.' + ) + } + + const blob = new Blob(parts as BlobPart[], {type: 'application/x-ndjson'}) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = `${urn}_annotated_variants_${annotationType}.ndjson` + anchor.click() + URL.revokeObjectURL(url) } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error' if (message !== 'The user aborted a request.') { @@ -124,20 +167,17 @@ export function useScoreSetDownloads({scoreSet, hasCounts}: UseScoreSetDownloads } } finally { streamController.value = null - annotatedDownloadInProgress.value = false - annotatedDownloadProgress.value = 0 } } return { // State customDialogVisible, - selectedDataOptions, - annotatedDownloadInProgress, - annotatedDownloadProgress, + fileDownloadInProgress, + fileDownloadLabel, + fileDownloadProgress, - // Computed - dataTypeOptions, + extraDownloadOptions, // Methods downloadFile, From 038957c80933a7f52d46dfb3afa91255639eb79e Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 12 Aug 2026 09:37:56 -0700 Subject: [PATCH 16/22] fix(score-set-downloads): label custom download button as CSV Also includes incidental reformatting. --- .../score-set/ScoreSetDownloads.vue | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/components/score-set/ScoreSetDownloads.vue b/src/components/score-set/ScoreSetDownloads.vue index 09a31cbd..612c827f 100644 --- a/src/components/score-set/ScoreSetDownloads.vue +++ b/src/components/score-set/ScoreSetDownloads.vue @@ -8,7 +8,8 @@ label="Scores" severity="secondary" size="small" - @click="reportingFailure('scores', () => downloadFile('scores'))" /> + @click="reportingFailure('scores', () => downloadFile('scores'))" + /> + @click="reportingFailure('counts', () => downloadFile('counts'))" + /> + @click="downloadMetadata" + /> + @click="reportingFailure('mapped variants', downloadMappedVariantsFile)" + /> + " + /> + @click="customDialogVisible = true" + /> @@ -84,7 +88,8 @@ active-border="var(--color-nucleotide-border)" color="var(--color-nucleotide)" :count="lookup.nucleotideCount.value" - label="Nucleotide level" /> + label="Nucleotide level" + /> + label="Protein level" + /> + label="Synonymous nucleotide" + /> @@ -118,7 +125,8 @@ :normal-odds-path="lookup.getNormalOddsPath(variant.content.urn)" :study-title="variant.content.scoreSet?.title || 'Untitled score set'" :type="variant.type" - @select="lookup.selectVariant(variant.content.urn)" /> + @select="lookup.selectVariant(variant.content.urn)" + />
@@ -128,7 +136,8 @@ option-label="label" option-value="urn" :options="measurementOptions" - @update:model-value="lookup.selectVariant($event)" /> + @update:model-value="lookup.selectVariant($event)" + />
@@ -136,7 +145,8 @@
+ class="mave-gradient-bar relative mt-6 rounded-lg border border-border bg-surface px-[18px] py-3.5" + >
@@ -188,26 +205,36 @@ lookup.selectedVariantScore.value !== 'NA' ? Number(lookup.selectedVariantScore.value).toPrecision(4) : undefined - " /> + " + /> + :code="lookup.calibrationResolution.formattedEvidenceCode.value" + /> + :value="lookup.calibrationResolution.scoreRange.value?.oddspathsRatio ?? undefined" + />
- +
+ class="flex flex-col border-t border-border-light pt-4 tablet:border-t-0 tablet:pt-0 tablet:border-l tablet:border-border-light tablet:px-[18px]" + >
Population Frequency
-

Data coming soon

+ +

No gnomAD record for this variant

+
+ class="border-t border-border-light pt-4 tablet:border-t-0 tablet:pt-0 tablet:border-l tablet:border-border-light tablet:pl-[18px]" + >
Splicing Predictions
@@ -219,7 +246,8 @@
+ class="flex flex-wrap items-center justify-between gap-3 border-b border-border-light px-4 tablet:px-5 py-3.5" + >
+ }" + > {{ lookup.selectedScoreSet.value.title }}
@@ -244,7 +273,8 @@ :selected-calibration="lookup.selectedCalibration.value || undefined" :variants="lookup.scores.value" @calibration-changed="lookup.selectedCalibration.value = $event" - @selection-changed="() => {}" /> + @selection-changed="() => {}" + />
@@ -253,7 +283,8 @@
+ :score-calibration="lookup.selectedCalibrationObject.value" + />
@@ -263,7 +294,8 @@ header="Download clinical table" kind="variant" :urn="lookup.selectedVariantUrn.value" - @confirm="downloadSelectedCsv" /> + @confirm="downloadSelectedCsv" + /> @@ -287,6 +319,7 @@ import MvAssayFactsCard from '@/components/common/MvAssayFactsCard.vue' import MvCsvColumnDialog from '@/components/common/MvCsvColumnDialog.vue' import MvBadgeToggle from '@/components/common/MvBadgeToggle.vue' import ScoreSetHistogram from '@/components/score-set/ScoreSetHistogram.vue' +import MvGnomadSummary from '@/components/variant/MvGnomadSummary.vue' import MvMeasurementCard from '@/components/variant/MvMeasurementCard.vue' import MvRowActionMenu, {type RowAction} from '@/components/common/MvRowActionMenu.vue' import VariantInfoSection from '@/components/variant/VariantInfoSection.vue' @@ -311,6 +344,7 @@ export default defineComponent({ MvEmptyState, MvErrorState, MvEvidenceTag, + MvGnomadSummary, MvLayout, MvLoader, MvMeasurementCard, diff --git a/src/components/variant/MvGnomadSummary.vue b/src/components/variant/MvGnomadSummary.vue new file mode 100644 index 00000000..237bf187 --- /dev/null +++ b/src/components/variant/MvGnomadSummary.vue @@ -0,0 +1,56 @@ + + + diff --git a/src/composables/use-csv-namespaces.test.ts b/src/composables/use-csv-namespaces.test.ts index 95261021..2287ff6b 100644 --- a/src/composables/use-csv-namespaces.test.ts +++ b/src/composables/use-csv-namespaces.test.ts @@ -28,7 +28,7 @@ function entry( const SCORE_SET_ENTRIES: AvailableCsvNamespace[] = [ entry({namespace: 'scores', label: 'Scores', group: 'data'}), - entry({namespace: 'gnomad', label: 'gnomAD allele frequency', group: 'annotation'}), + entry({namespace: 'gnomad', label: 'gnomAD population frequency', group: 'annotation'}), entry({namespace: 'clinvar.2024_11', label: 'ClinVar significance (November 2024)', group: 'annotation'}), entry({namespace: 'calibration.urn:mavedb:calibration-1', label: 'Brnich et al. 2019', group: 'calibration'}), entry({namespace: 'score_set', label: 'Score set and publications', group: 'provenance'}) @@ -398,7 +398,7 @@ describe('useCsvNamespaces extras', () => { const NAMESPACE_ENTRIES: AvailableCsvNamespace[] = [ entry({namespace: 'scores', label: 'Score', group: 'data'}), entry({namespace: 'scores_custom', label: 'Investigator-provided score columns', group: 'data'}), - entry({namespace: 'gnomad', label: 'gnomAD allele frequency', group: 'annotation'}) + entry({namespace: 'gnomad', label: 'gnomAD population frequency', group: 'annotation'}) ] const EXTRAS = [{label: "Omit HGVS columns this score set doesn't use", value: 'dropUnusedHgvsColumns'}] diff --git a/src/composables/use-variant-lookup.ts b/src/composables/use-variant-lookup.ts index 5ffacf8f..194499dd 100644 --- a/src/composables/use-variant-lookup.ts +++ b/src/composables/use-variant-lookup.ts @@ -4,7 +4,7 @@ import { downloadVariantCsv, getVariantAnnotation, getVariantDetail, - getHistogramVariantData, + getVariantPageScoreSetData, lookupVariantsByClingenId } from '@/api/mavedb/variants' import {useCalibrationResolution, type UseCalibrationResolutionReturn} from '@/composables/use-calibration-resolution' @@ -19,6 +19,7 @@ import { import {triggerDownload} from '@/lib/downloads' import {describeRequestError} from '@/lib/errors' import {getExperimentKeyword} from '@/lib/experiments' +import {gnomadFromVariantRow, type GnomadFrequency} from '@/lib/gnomad' import {parseScoreSetVariantData, type Variant} from '@/lib/variants' import type {MeasurementType} from '@/lib/measurement-types' import type {components} from '@/schema/openapi' @@ -66,6 +67,7 @@ export interface UseVariantLookupReturn { scores: ComputedRef variantScoreRow: ComputedRef selectedVariantScore: ComputedRef + selectedVariantGnomad: ComputedRef // Calibration selectedCalibration: Ref @@ -170,6 +172,7 @@ export function useVariantLookup( }) const variantScoreRow = computed(() => (scores.value || []).find((s) => s.accession === selectedVariantUrn.value)) const selectedVariantScore = computed(() => variantScoreRow.value?.scores?.score ?? null) + const selectedVariantGnomad = computed(() => gnomadFromVariantRow(variantScoreRow.value)) // ── Calibration ─────────────────────────────────────────── const selectedCalibrationObject = computed(() => { @@ -222,7 +225,7 @@ export function useVariantLookup( async function fetchScores(scoreSetUrn: string) { if (scoresCache.value[scoreSetUrn]) return try { - const data = await getHistogramVariantData(scoreSetUrn) + const data = await getVariantPageScoreSetData(scoreSetUrn) scoresCache.value = { ...scoresCache.value, [scoreSetUrn]: parseScoreSetVariantData(data) @@ -427,6 +430,7 @@ export function useVariantLookup( scores, variantScoreRow, selectedVariantScore, + selectedVariantGnomad, selectedCalibration, selectedCalibrationObject, calibrationResolution, diff --git a/src/lib/gnomad.test.ts b/src/lib/gnomad.test.ts index 944aa0da..c5eb4ee7 100644 --- a/src/lib/gnomad.test.ts +++ b/src/lib/gnomad.test.ts @@ -1,6 +1,16 @@ -import {describe, expect, it} from 'vitest' - -import {CHROMOSOME_REFSEQ_IDS, gnomadIdToHgvs, gnomadIdToHgvsCandidates, otherAssembly, parseGnomadId} from './gnomad' +import {describe, expect, it, test} from 'vitest' +import { + CHROMOSOME_REFSEQ_IDS, + gnomadIdToHgvs, + gnomadIdToHgvsCandidates, + otherAssembly, + parseGnomadId, + formatFrequency, + gnomadFromVariantRow, + gnomadVariantUrl, + type GnomadFrequency +} from './gnomad' +import type {RawVariant} from '@/lib/variants' /** The GRCh38 translation of a gnomAD ID, which is the one tried first. */ function grch38Hgvs(gnomadId: string): string | undefined { @@ -144,3 +154,112 @@ describe('CHROMOSOME_REFSEQ_IDS', () => { } }) }) + +/** A variant data row whose gnomad namespace is fully populated; overrides replace individual cells. */ +function row(overrides: Partial> = {}): RawVariant { + return { + accession: 'urn:mavedb:00000001-a-1#1', + scores: {score: 0.5}, + gnomad: { + gnomad_af: 1.86e-6, + gnomad_ac: 3, + gnomad_an: 1613510, + gnomad_faf95_max: 6.8e-7, + gnomad_faf95_max_ancestry: 'nfe', + gnomad_id: '10-87961093-A-G', + gnomad_version: 'v4.1', + ...overrides + } + } +} + +const frequency: GnomadFrequency = { + alleleFrequency: 1.86e-6, + alleleCount: 3, + alleleNumber: 1613510, + faf95Max: 6.8e-7, + faf95MaxAncestry: 'nfe', + dbIdentifier: '10-87961093-A-G', + dbVersion: 'v4.1' +} + +describe('gnomadFromVariantRow', () => { + test('reads a populated namespace into the display shape', () => { + expect(gnomadFromVariantRow(row())).toEqual(frequency) + }) + + test('nullish row or absent namespace → null', () => { + expect(gnomadFromVariantRow(null)).toBeNull() + expect(gnomadFromVariantRow(undefined)).toBeNull() + expect(gnomadFromVariantRow({accession: 'x', scores: {score: 0.5}})).toBeNull() + }) + + test("a variant with no gnomAD record reports 'NA' across the namespace → null", () => { + const unannotated = row({ + gnomad_af: 'NA', + gnomad_ac: 'NA', + gnomad_an: 'NA', + gnomad_faf95_max: 'NA', + gnomad_faf95_max_ancestry: 'NA', + gnomad_id: 'NA', + gnomad_version: 'NA' + }) + expect(gnomadFromVariantRow(unannotated)).toBeNull() + }) + + test.each(['gnomad_af', 'gnomad_ac', 'gnomad_an', 'gnomad_id'] as const)( + 'a missing %s makes the record unusable → null', + (field) => { + expect(gnomadFromVariantRow(row({[field]: 'NA'}))).toBeNull() + } + ) + + test('FAF95 is optional — absent leaves the rest intact', () => { + const result = gnomadFromVariantRow(row({gnomad_faf95_max: 'NA', gnomad_faf95_max_ancestry: 'NA'})) + expect(result).toMatchObject({alleleFrequency: 1.86e-6, faf95Max: null, faf95MaxAncestry: null}) + }) + + test('a zero allele frequency is a real value, not a missing one', () => { + expect(gnomadFromVariantRow(row({gnomad_af: 0, gnomad_ac: 0}))).toMatchObject({ + alleleFrequency: 0, + alleleCount: 0 + }) + }) + + test('an absent version degrades gracefully rather than dropping the record', () => { + expect(gnomadFromVariantRow(row({gnomad_version: 'NA'}))?.dbVersion).toBe('unknown') + }) +}) + +describe('formatFrequency', () => { + test('nullish → em dash', () => { + expect(formatFrequency(null)).toBe('—') + expect(formatFrequency(undefined)).toBe('—') + }) + + test('very rare (< 1e-4) → scientific notation, else 3 significant figures', () => { + expect(formatFrequency(0.00001)).toBe('1.00e-5') + expect(formatFrequency(0.0123456)).toBe('0.0123') + expect(formatFrequency(0)).toBe('0.00e+0') + }) +}) + +describe('gnomadVariantUrl — dataset matches the record version', () => { + test.each([ + ['v4.1', 'gnomad_r4'], + ['v3.1.2', 'gnomad_r3'], + ['v2.1.1', 'gnomad_r2_1'], + // Bare majors, as older records and fixtures carry them. + ['4', 'gnomad_r4'], + ['3', 'gnomad_r3'], + ['2', 'gnomad_r2_1'] + ])('version %s → %s', (dbVersion, dataset) => { + const url = gnomadVariantUrl({dbIdentifier: '1-55051215-G-A', dbVersion}) + expect(url).toContain(`dataset=${dataset}`) + expect(url).toContain('/variant/1-55051215-G-A') + }) + + test('an unrecognisable version falls back to the current dataset', () => { + expect(gnomadVariantUrl({dbIdentifier: 'x', dbVersion: 'unknown'})).toContain('dataset=gnomad_r4') + }) +}) diff --git a/src/lib/gnomad.ts b/src/lib/gnomad.ts index 303c49c9..e3283600 100644 --- a/src/lib/gnomad.ts +++ b/src/lib/gnomad.ts @@ -1,4 +1,36 @@ +/** + * @fileoverview + * gnomAD population frequency annotations and related utilities. + * + * gnomAD is a population-scale variant frequency database. MaveDB links each mapped variant to the + * single gnomAD record sharing its ClinGen allele ID, so a variant's frequency is a direct assertion + * about that variant — there is no projection or pooling to reason about. + * + * Frequencies reach the client as the `gnomad` namespace of the score-set variant data CSV, where + * every field arrives as a number or the string `'NA'`. {@link gnomadFromVariantRow} is the seam that + * turns one of those rows into the shape the display components consume. + */ import {gnomadIdRegex} from './mavemd' +import type {components} from '@/schema/openapi' +import type {RawVariant} from '@/lib/variants' + +/** + * One gnomAD frequency record, as consumed by the display components. + * + * Picked from the generated schema rather than restated, so renaming or retyping a field on the API's + * model breaks compilation here. + * + * Caveat: the CSV columns come from the API's namespace specs, a different code path from the view + * model. Both project the same `GnomADVariant` ORM columns, so this tracks names and types but is not + * a guarantee that the two stay column-for-column aligned. + */ +export type GnomadFrequency = Pick< + components['schemas']['GnomADVariantWithMappedVariants'], + 'alleleFrequency' | 'alleleCount' | 'alleleNumber' | 'faf95Max' | 'faf95MaxAncestry' | 'dbIdentifier' | 'dbVersion' +> + +/** A CSV cell from the `gnomad` namespace: a number, the `'NA'` sentinel, or absent. */ +type GnomadCell = number | string | null | undefined /** * Translation of gnomAD variant IDs (e.g. 1-11796321-G-A) into genomic HGVS. @@ -168,3 +200,58 @@ export function gnomadIdToHgvsCandidates(gnomadId: string): GnomadHgvsCandidate[ export function gnomadIdToHgvs(gnomadId: string, assembly: GenomeAssembly): string | null { return gnomadIdToHgvsCandidates(gnomadId).find((candidate) => candidate.assembly === assembly)?.hgvs ?? null } + +function numberOrNull(value: GnomadCell): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function stringOrNull(value: GnomadCell): string | null { + if (typeof value === 'number') return String(value) + return value && value.toUpperCase() !== 'NA' ? value : null +} + +/** + * Read a variant's gnomAD frequency out of its score-set data row. + * + * Returns null unless the row carries the fields the display depends on — the frequency itself, the + * AC/AN behind it, and the gnomAD variant id used to link out. Variants with no gnomAD record report + * `'NA'` across the namespace and yield null here. + * + * Requires the `gnomad` namespace to have been requested; see `variantPageVariantDataUrl`. + */ +export function gnomadFromVariantRow(variant: RawVariant | null | undefined): GnomadFrequency | null { + const gnomad = variant?.gnomad + if (!gnomad) return null + + const alleleFrequency = numberOrNull(gnomad.gnomad_af) + const alleleCount = numberOrNull(gnomad.gnomad_ac) + const alleleNumber = numberOrNull(gnomad.gnomad_an) + const dbIdentifier = stringOrNull(gnomad.gnomad_id) + if (alleleFrequency == null || alleleCount == null || alleleNumber == null || dbIdentifier == null) { + return null + } + + return { + alleleFrequency, + alleleCount, + alleleNumber, + faf95Max: numberOrNull(gnomad.gnomad_faf95_max), + faf95MaxAncestry: stringOrNull(gnomad.gnomad_faf95_max_ancestry), + dbIdentifier, + dbVersion: stringOrNull(gnomad.gnomad_version) ?? 'unknown' + } +} + +/** Deep link to a gnomAD variant page, choosing the dataset that matches the record's version. */ +export function gnomadVariantUrl(gnomad: {dbIdentifier: string; dbVersion: string}): string { + // Versions are stored with a leading "v" (e.g. "v4.1"), so strip non-digits before reading the major. + const major = parseInt(gnomad.dbVersion.replace(/^\D+/, ''), 10) + const dataset = major === 3 ? 'gnomad_r3' : major === 2 ? 'gnomad_r2_1' : 'gnomad_r4' + return `https://gnomad.broadinstitute.org/variant/${encodeURIComponent(gnomad.dbIdentifier)}?dataset=${dataset}` +} + +/** A frequency (e.g. gnomAD AF): scientific notation for the very rare, else 3 significant figures. */ +export function formatFrequency(value: number | null | undefined): string { + if (value == null) return '—' + return value < 0.0001 ? value.toExponential(2) : value.toPrecision(3) +} diff --git a/src/lib/variants.ts b/src/lib/variants.ts index 1046fd77..82243a3f 100644 --- a/src/lib/variants.ts +++ b/src/lib/variants.ts @@ -53,6 +53,17 @@ export interface RawVariant { clingen?: { clingen_allele_id?: string } + // The `gnomad` namespace. Numeric fields arrive as numbers via the CSV parser's dynamic typing, or + // as the string 'NA' where the variant has no gnomAD record. Read via `gnomadFromVariantRow`. + gnomad?: { + gnomad_af?: number | string + gnomad_ac?: number | string + gnomad_an?: number | string + gnomad_faf95_max?: number | string + gnomad_faf95_max_ancestry?: string + gnomad_id?: string + gnomad_version?: string + } control?: ClinicalControlVariant mavedb_label?: string From 36c43e529bc90eb12bbf2069b145b6d7df21a8ad Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Mon, 10 Aug 2026 21:25:25 -0700 Subject: [PATCH 18/22] feat(gnomad): remove ancestry from gnomad faf95 display --- src/components/variant/MvGnomadSummary.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/variant/MvGnomadSummary.vue b/src/components/variant/MvGnomadSummary.vue index 237bf187..f806df54 100644 --- a/src/components/variant/MvGnomadSummary.vue +++ b/src/components/variant/MvGnomadSummary.vue @@ -11,7 +11,6 @@
FAF95: {{ formatFrequency(gnomad.faf95Max) }} - ({{ gnomad.faf95MaxAncestry }}) FAF95 —
From 9d0eba7e688c9b8552c7f4c78c7509f00da615b6 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Mon, 10 Aug 2026 18:02:51 -0700 Subject: [PATCH 19/22] feat(ui): report annotation-stream variants the server could not annotate The API now reports a variant it cannot annotate as a record carrying an error object instead of letting the exception truncate the body. A download containing those records is complete and worth saving, but treating it as a clean export would misrepresent it. Tally error records as the stream arrives and raise a warning toast naming the count. Each chunk is decoded to scan its lines and then dropped, so only one chunk plus a partial line is ever live and the body is still retained as bytes -- decoding the whole payload is what previously ran the tab out of memory. Detection prefilters on a substring and parses only candidates, since these records nest deeply and parsing every one of a large stream is slow. --- .../score-set/ScoreSetDownloads.vue | 38 +++++++---- .../use-score-set-downloads.test.ts | 38 ++++++++++- src/composables/use-score-set-downloads.ts | 63 +++++++++++++++---- 3 files changed, 113 insertions(+), 26 deletions(-) diff --git a/src/components/score-set/ScoreSetDownloads.vue b/src/components/score-set/ScoreSetDownloads.vue index 612c827f..e390e843 100644 --- a/src/components/score-set/ScoreSetDownloads.vue +++ b/src/components/score-set/ScoreSetDownloads.vue @@ -144,28 +144,19 @@ export default defineComponent({ if (this.hasPathogenicityCalibrations) { options.push({ label: 'Pathogenicity Statement', - command: () => - this.reportingFailure('Pathogenicity Statement', () => - this.streamVariantAnnotations('pathogenicity-statement', 'Pathogenicity Statement') - ) + command: () => this.streamAnnotations('pathogenicity-statement', 'Pathogenicity Statement') }) } if (this.hasFunctionalImpactCalibrations) { options.push({ label: 'Functional Impact Statement', - command: () => - this.reportingFailure('Functional Impact Statement', () => - this.streamVariantAnnotations('functional-statement', 'Functional Impact Statement') - ) + command: () => this.streamAnnotations('functional-statement', 'Functional Impact Statement') }) } options.push({ label: 'Functional Study Result', - command: () => - this.reportingFailure('Functional Study Result', () => - this.streamVariantAnnotations('study-result', 'Functional Study Result') - ) + command: () => this.streamAnnotations('study-result', 'Functional Study Result') }) return options @@ -196,6 +187,29 @@ export default defineComponent({ } }, + /** + * Download an annotation stream, reporting a failure as an error and a partial one as a warning. + * + * A stream in which some variants could not be annotated still completes: the file is whole, and the + * failed variants are in it as records carrying an `error` field. Saying nothing would let a user + * treat a partial export as a full one. + */ + async streamAnnotations(annotationType: string, what: string) { + await this.reportingFailure(what, async () => { + const outcome = await this.streamVariantAnnotations(annotationType, what) + if (outcome && outcome.errored > 0) { + this.$toast.add({ + severity: 'warn', + summary: `${what} downloaded with errors`, + detail: + `${outcome.errored} of ${outcome.received} variants could not be annotated.` + + ' Those records carry an "error" field instead of an annotation.', + life: 10000 + }) + } + }) + }, + async handleCustomDownload(selection: {namespaces: string[]; extras: string[]}) { await this.reportingFailure('custom data', () => this.downloadMultipleData(selection)) }, diff --git a/src/composables/use-score-set-downloads.test.ts b/src/composables/use-score-set-downloads.test.ts index 2c9639df..a077faa8 100644 --- a/src/composables/use-score-set-downloads.test.ts +++ b/src/composables/use-score-set-downloads.test.ts @@ -185,9 +185,41 @@ describe('useScoreSetDownloads annotation streaming shares the indicator', () => expect(downloads.fileDownloadInProgress.value).toBe(false) }) - it('counts records by byte, so a multi-byte character cannot skew the total', async () => { - // The old implementation decoded each chunk to a string first; retaining bytes is both exact and what - // keeps a large download out of the tab's memory. + it('tallies variants the server could not annotate, so a caller can report them', async () => { + const errorRecord = '{"variant_urn":"urn:1","annotation":null,"error":{"type":"ValueError","detail":"bad"}}\n' + mockStream([errorRecord, '{"variant_urn":"urn:2","annotation":{"type":"Stub"}}\n'], '2') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + + expect(await downloads.streamVariantAnnotations('study-result')).toEqual({received: 2, errored: 1}) + }) + + it('saves a stream containing error records, since the file is complete', async () => { + // A variant the server could not annotate is reported in-band. The download is not a failure. + const errorRecord = '{"variant_urn":"urn:1","annotation":null,"error":{"type":"KeyError","detail":"score"}}\n' + mockStream([errorRecord], '1') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + + await expect(downloads.streamVariantAnnotations('study-result')).resolves.toEqual({received: 1, errored: 1}) + }) + + it('counts an error record split across chunks exactly once', async () => { + // Records are scanned per chunk, so a boundary landing mid-record must not lose or double it. + mockStream(['{"variant_urn":"urn:1","annotation":null,"err', 'or":{"type":"KeyError","detail":"s"}}\n'], '1') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + + expect(await downloads.streamVariantAnnotations('study-result')).toEqual({received: 1, errored: 1}) + }) + + it('does not mistake an "error" nested inside an annotation for a failed record', async () => { + // The substring test is only a prefilter; the record is parsed to confirm the key is its own. + mockStream(['{"variant_urn":"urn:1","annotation":{"notes":"see \\"error\\" handling"}}\n'], '1') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + + expect(await downloads.streamVariantAnnotations('study-result')).toEqual({received: 1, errored: 0}) + }) + + it('is not skewed by a multi-byte character', async () => { + // The body is still retained as bytes — only one chunk at a time is decoded, to scan its lines. mockStream(['{"p":"p.Trp26€"}\n', '{"p":"p.Met1?"}\n'], '2') const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) const {values, stop} = recordProgress(downloads.fileDownloadProgress) diff --git a/src/composables/use-score-set-downloads.ts b/src/composables/use-score-set-downloads.ts index c0431ab9..51b024f6 100644 --- a/src/composables/use-score-set-downloads.ts +++ b/src/composables/use-score-set-downloads.ts @@ -11,6 +11,34 @@ type ScoreSet = components['schemas']['ScoreSet'] export const TEXT_COLUMNS = ['hgvs_nt', 'hgvs_splice', 'hgvs_pro'] +/** What a completed annotation stream contained, tallied as it arrived. */ +export interface AnnotationStreamOutcome { + /** Records received. Equals `X-Total-Count` for a complete stream — the server emits one per variant. */ + received: number + /** + * Variants the server could not annotate. Those records are in the saved file, carrying an `error` + * object in place of an annotation. Variants with no mapping data are not counted here: a null + * annotation is an expected absence, not a failure. + */ + errored: number +} + +/** + * Whether an NDJSON line is a record the server marked as failed. + * + * The substring test is a prefilter, not the decision: `"error"` can appear anywhere inside a large + * annotation, so a candidate line is parsed to confirm the key is the record's own. Parsing only + * candidates matters — these records nest deeply, and parsing every one of a large stream is slow. + */ +function isErrorRecord(line: string): boolean { + if (!line.includes('"error"')) return false + try { + return JSON.parse(line)?.error != null + } catch { + return false + } +} + interface UseScoreSetDownloadsOptions { scoreSet: Ref } @@ -98,10 +126,11 @@ export function useScoreSetDownloads({scoreSet}: UseScoreSetDownloadsOptions) { } } + /** Resolves to what the stream contained, so the caller can report partial failures. */ async function streamVariantAnnotations(annotationType: string, label = 'annotations') { const urn = scoreSet.value?.urn if (!urn) return - await withIndicator(label, () => streamAnnotationsInto(urn, annotationType)) + return await withIndicator(label, () => streamAnnotationsInto(urn, annotationType)) } async function streamAnnotationsInto(urn: string, annotationType: string) { @@ -121,30 +150,40 @@ export function useScoreSetDownloads({scoreSet}: UseScoreSetDownloadsOptions) { const reader = response.body?.getReader() if (!reader) throw new Error('Response body is not readable') - // Held as raw bytes rather than decoded strings. Accumulating strings and then joining them cost - // roughly five times the payload — JS strings are UTF-16, so the decoded chunks alone doubled it, - // the join doubled that again, and the Blob copied the result. A large pathogenicity download ran - // the tab out of memory. + // The body is retained as raw bytes, never as accumulated strings. Accumulating decoded chunks and + // joining them cost roughly five times the payload — JS strings are UTF-16, so the chunks alone + // doubled it, the join doubled that again, and the Blob copied the result. A large pathogenicity + // download ran the tab out of memory. Each chunk is decoded to scan its lines and then dropped, so + // only one chunk plus a partial line is ever live. const parts: Uint8Array[] = [] + const decoder = new TextDecoder() + let partialLine = '' let processedCount = 0 + let erroredCount = 0 while (true) { const {done, value} = await reader.read() if (done) break parts.push(value) - // NDJSON terminates every record with a newline, and 0x0A cannot appear inside a multi-byte UTF-8 - // sequence, so counting the byte is exact and needs no decoding or chunk-boundary bookkeeping. - for (const byte of value) { - if (byte === 0x0a) processedCount += 1 + + // `stream: true` carries a multi-byte character split across chunks into the next decode, and + // popping the final element keeps a record split across chunks from being counted twice. + const lines = (partialLine + decoder.decode(value, {stream: true})).split('\n') + partialLine = lines.pop() ?? '' + for (const line of lines) { + if (!line) continue + processedCount += 1 + if (isErrorRecord(line)) erroredCount += 1 } + // Without a total there is nothing to divide by; stay indeterminate rather than report Infinity. fileDownloadProgress.value = totalCount > 0 ? Math.min(100, Math.round((processedCount / totalCount) * 100)) : null } - // The server generator yields one line per variant, so a short body means it stopped early — - // status and headers went out with the first chunk, so a truncated stream is the only symptom it + // The server emits exactly one record per variant, so a short body means it stopped early — status + // and headers went out with the first chunk, so truncation is the only symptom a mid-stream failure // can produce. Refuse to save a file that is quietly missing records. if (totalCount > 0 && processedCount < totalCount) { throw new Error( @@ -160,6 +199,8 @@ export function useScoreSetDownloads({scoreSet}: UseScoreSetDownloadsOptions) { anchor.download = `${urn}_annotated_variants_${annotationType}.ndjson` anchor.click() URL.revokeObjectURL(url) + + return {received: processedCount, errored: erroredCount} } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error' if (message !== 'The user aborted a request.') { From 04f99681d64806ac1f2746676bfc988d97a8c941 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 12 Aug 2026 13:40:54 -0700 Subject: [PATCH 20/22] fix(variants): rename post_mapped_vrs_digest to post_mapped_vrs_id --- src/lib/variants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/variants.ts b/src/lib/variants.ts index 82243a3f..c6c89da2 100644 --- a/src/lib/variants.ts +++ b/src/lib/variants.ts @@ -45,7 +45,7 @@ export interface RawVariant { mavedb?: { post_mapped_hgvs_c?: string post_mapped_hgvs_p?: string - post_mapped_vrs_digest?: string + post_mapped_vrs_id?: string } vep?: { vep_functional_consequence?: string From 1cfa73a73f968bf26b668f76c9e69c376b537304 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 12 Aug 2026 14:49:59 -0700 Subject: [PATCH 21/22] chore(package-lock): bump minor package versions --- package-lock.json | 361 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 308 insertions(+), 53 deletions(-) diff --git a/package-lock.json b/package-lock.json index d3fc69d7..4c2a8e06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -620,7 +620,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", @@ -662,7 +661,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -681,7 +679,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -702,7 +699,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -723,7 +719,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -744,7 +739,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -765,7 +759,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -786,7 +779,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -807,7 +799,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -828,7 +819,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -849,7 +839,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -870,7 +859,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -891,7 +879,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -912,7 +899,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -927,7 +913,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=12" }, @@ -2075,9 +2060,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -2947,9 +2932,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -3003,9 +2988,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -3350,7 +3335,6 @@ "version": "4.0.3", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -4354,9 +4338,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -5545,9 +5529,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -5895,7 +5879,9 @@ } }, "node_modules/immutable": { - "version": "5.1.5", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", "devOptional": true, "license": "MIT" }, @@ -6522,9 +6508,9 @@ "peer": true }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -7718,9 +7704,9 @@ } }, "node_modules/molstar/node_modules/immutable": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", - "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==", + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz", + "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==", "license": "MIT" }, "node_modules/moment": { @@ -7740,9 +7726,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -7778,8 +7764,7 @@ "node_modules/node-addon-api": { "version": "7.1.1", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/node-fetch": { "version": "2.7.0", @@ -8274,9 +8259,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -8293,7 +8278,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8606,7 +8591,6 @@ "version": "4.1.2", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 14.18.0" }, @@ -8952,7 +8936,6 @@ "version": "1.97.3", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -9008,6 +8991,86 @@ "sass-embedded-win32-x64": "1.97.3" } }, + "node_modules/sass-embedded-all-unknown": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.97.3.tgz", + "integrity": "sha512-t6N46NlPuXiY3rlmG6/+1nwebOBOaLFOOVqNQOC2cJhghOD4hh2kHNQQTorCsbY9S1Kir2la1/XLBwOJfui0xg==", + "cpu": [ + "!arm", + "!arm64", + "!riscv64", + "!x64" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "sass": "1.97.3" + } + }, + "node_modules/sass-embedded-android-arm": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.97.3.tgz", + "integrity": "sha512-cRTtf/KV/q0nzGZoUzVkeIVVFv3L/tS1w4WnlHapphsjTXF/duTxI8JOU1c/9GhRPiMdfeXH7vYNcMmtjwX7jg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-arm64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.97.3.tgz", + "integrity": "sha512-aiZ6iqiHsUsaDx0EFbbmmA0QgxicSxVVN3lnJJ0f1RStY0DthUkquGT5RJ4TPdaZ6ebeJWkboV4bra+CP766eA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-riscv64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.97.3.tgz", + "integrity": "sha512-zVEDgl9JJodofGHobaM/q6pNETG69uuBIGQHRo789jloESxxZe82lI3AWJQuPmYCOG5ElfRthqgv89h3gTeLYA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-x64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.97.3.tgz", + "integrity": "sha512-3ke0le7ZKepyXn/dKKspYkpBC0zUk/BMciyP5ajQUDy4qJwobd8zXdAq6kOkdiMB+d9UFJOmEkvgFJHl3lqwcw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/sass-embedded-darwin-arm64": { "version": "1.97.3", "cpu": [ @@ -9022,6 +9085,198 @@ "node": ">=14.0.0" } }, + "node_modules/sass-embedded-darwin-x64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.97.3.tgz", + "integrity": "sha512-b/2RBs/2bZpP8lMkyZ0Px0vkVkT8uBd0YXpOwK7iOwYkAT8SsO4+WdVwErsqC65vI5e1e5p1bb20tuwsoQBMVA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.97.3.tgz", + "integrity": "sha512-2lPQ7HQQg4CKsH18FTsj2hbw5GJa6sBQgDsls+cV7buXlHjqF8iTKhAQViT6nrpLK/e8nFCoaRgSqEC8xMnXuA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.97.3.tgz", + "integrity": "sha512-IP1+2otCT3DuV46ooxPaOKV1oL5rLjteRzf8ldZtfIEcwhSgSsHgA71CbjYgLEwMY9h4jeal8Jfv3QnedPvSjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.97.3.tgz", + "integrity": "sha512-cBTMU68X2opBpoYsSZnI321gnoaiMBEtc+60CKCclN6PCL3W3uXm8g4TLoil1hDD6mqU9YYNlVG6sJ+ZNef6Lg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.97.3.tgz", + "integrity": "sha512-Lij0SdZCsr+mNRSyDZ7XtJpXEITrYsaGbOTz5e6uFLJ9bmzUbV7M8BXz2/cA7bhfpRPT7/lwRKPdV4+aR9Ozcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-riscv64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.97.3.tgz", + "integrity": "sha512-sBeLFIzMGshR4WmHAD4oIM7WJVkSoCIEwutzptFtGlSlwfNiijULp+J5hA2KteGvI6Gji35apR5aWj66wEn/iA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-x64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.97.3.tgz", + "integrity": "sha512-/oWJ+OVrDg7ADDQxRLC/4g1+Nsz1g4mkYS2t6XmyMJKFTFK50FVI2t5sOdFH+zmMp+nXHKM036W94y9m4jjEcw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-riscv64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.97.3.tgz", + "integrity": "sha512-l3IfySApLVYdNx0Kjm7Zehte1CDPZVcldma3dZt+TfzvlAEerM6YDgsk5XEj3L8eHBCgHgF4A0MJspHEo2WNfA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-x64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.97.3.tgz", + "integrity": "sha512-Kwqwc/jSSlcpRjULAOVbndqEy2GBzo6OBmmuBVINWUaJLJ8Kczz3vIsDUWLfWz/kTEw9FHBSiL0WCtYLVAXSLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-unknown-all": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.97.3.tgz", + "integrity": "sha512-/GHajyYJmvb0IABUQHbVHf1nuHPtIDo/ClMZ81IDr59wT5CNcMe7/dMNujXwWugtQVGI5UGmqXWZQCeoGnct8Q==", + "license": "MIT", + "optional": true, + "os": [ + "!android", + "!darwin", + "!linux", + "!win32" + ], + "dependencies": { + "sass": "1.97.3" + } + }, + "node_modules/sass-embedded-win32-arm64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.97.3.tgz", + "integrity": "sha512-RDGtRS1GVvQfMGAmVXNxYiUOvPzn9oO1zYB/XUM9fudDRnieYTcUytpNTQZLs6Y1KfJxgt5Y+giRceC92fT8Uw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-win32-x64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.97.3.tgz", + "integrity": "sha512-SFRa2lED9UEwV6vIGeBXeBOLKF+rowF3WmNfb/BzhxmdAsKofCXrJ8ePW7OcDVrvNEbTOGwhsReIsF5sH8fVaw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/sass-embedded/node_modules/supports-color": { "version": "8.1.1", "devOptional": true, @@ -9936,16 +10191,16 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/test-exclude/node_modules/minimatch": { From 1b6e56c913e5085fc878c5784711c70fdf9081d3 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 12 Aug 2026 14:50:32 -0700 Subject: [PATCH 22/22] chore: bump version to 2026.2.4.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4c2a8e06..ba5ad802 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mavedb-ui", - "version": "2026.2.4", + "version": "2026.2.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mavedb-ui", - "version": "2026.2.4", + "version": "2026.2.4.1", "dependencies": { "@fontsource/exo-2": "^5.2.8", "@fontsource/raleway": "^5.0.16", diff --git a/package.json b/package.json index 1a04a075..85df4518 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mavedb-ui", - "version": "2026.2.4", + "version": "2026.2.4.1", "private": true, "type": "module", "scripts": {