jbrowse-plugin-msaview 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +14 -34
  2. package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.d.ts +15 -0
  3. package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.js +67 -0
  4. package/dist/LaunchMsaView/index.js +19 -1
  5. package/dist/MsaViewPanel/doLaunchOrthologs.js +43 -12
  6. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +93 -19
  7. package/dist/MsaViewPanel/model.d.ts +19 -8
  8. package/dist/jbrowse-plugin-msaview.umd.production.min.js +30 -32
  9. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  10. package/dist/utils/ebiJobDispatcher.js +26 -1
  11. package/dist/utils/ebiJobDispatcher.test.d.ts +1 -0
  12. package/dist/utils/ebiJobDispatcher.test.js +46 -0
  13. package/dist/utils/eutils.d.ts +9 -0
  14. package/dist/utils/eutils.js +18 -0
  15. package/dist/utils/ncbiOrthologs.d.ts +17 -75
  16. package/dist/utils/ncbiOrthologs.js +67 -63
  17. package/dist/utils/ncbiOrthologs.test.js +82 -2
  18. package/dist/utils/ncbiTaxonomy.d.ts +11 -0
  19. package/dist/utils/ncbiTaxonomy.js +33 -0
  20. package/dist/version.d.ts +1 -1
  21. package/dist/version.js +1 -1
  22. package/package.json +3 -3
  23. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +33 -71
  24. package/src/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.tsx +92 -0
  25. package/src/LaunchMsaView/index.ts +20 -2
  26. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +109 -21
  27. package/src/MsaViewPanel/doLaunchOrthologs.ts +56 -13
  28. package/src/MsaViewPanel/model.ts +9 -2
  29. package/src/utils/ebiJobDispatcher.test.ts +54 -0
  30. package/src/utils/ebiJobDispatcher.ts +32 -3
  31. package/src/utils/eutils.ts +19 -0
  32. package/src/utils/ncbiOrthologs.test.ts +96 -2
  33. package/src/utils/ncbiOrthologs.ts +83 -71
  34. package/src/utils/ncbiTaxonomy.ts +37 -0
  35. package/src/version.ts +1 -1
@@ -31,14 +31,39 @@ export async function submitEbiJob({ tool, params, }) {
31
31
  });
32
32
  return jobId.trim();
33
33
  }
34
+ /**
35
+ * A status check that could not reach EBI at all says nothing about the job, so
36
+ * it is not a reason to abandon one. A job the server has accepted keeps running
37
+ * whatever happens to the poller's connection, and the poll is the long part: an
38
+ * alignment of a hundred sequences runs for minutes and is checked every ten
39
+ * seconds, so a single blip anywhere in that window used to throw away a job
40
+ * that went on to finish.
41
+ *
42
+ * Consecutive failures still end it, because an endpoint that has genuinely gone
43
+ * away must not be polled forever.
44
+ */
45
+ const MAX_CONSECUTIVE_STATUS_FAILURES = 5;
34
46
  export async function waitForEbiJob({ tool, jobId, intervalSeconds = 10, onCountdown, }) {
47
+ let consecutiveFailures = 0;
35
48
  await pollLoop({
36
49
  intervalSeconds,
37
50
  onCountdown,
38
51
  check: async () => {
52
+ let status;
53
+ try {
54
+ status = (await textfetch(`${EBI_BASE}/${tool}/status/${jobId}`)).trim();
55
+ }
56
+ catch (e) {
57
+ consecutiveFailures += 1;
58
+ if (consecutiveFailures >= MAX_CONSECUTIVE_STATUS_FAILURES) {
59
+ throw new Error(`Could not reach EBI to check ${tool} job ${jobId} after ${consecutiveFailures} tries`, { cause: e });
60
+ }
61
+ console.warn(`[msaview] EBI status check ${consecutiveFailures} failed, retrying:`, e);
62
+ return false;
63
+ }
64
+ consecutiveFailures = 0;
39
65
  // exact match, not includes(): a job whose status is ERROR must not be
40
66
  // able to poll forever waiting for a FINISHED that will never arrive
41
- const status = (await textfetch(`${EBI_BASE}/${tool}/status/${jobId}`)).trim();
42
67
  if (status === 'FINISHED') {
43
68
  return true;
44
69
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import { afterEach, describe, expect, test, vi } from 'vitest';
2
+ import { waitForEbiJob } from './ebiJobDispatcher';
3
+ // A long alignment is polled every ten seconds for minutes, so the chance of one
4
+ // failed status check somewhere in that window is not small -- and the job is
5
+ // running at EBI regardless of what happens to the poller's connection.
6
+ describe('waitForEbiJob', () => {
7
+ afterEach(() => {
8
+ vi.unstubAllGlobals();
9
+ });
10
+ const statuses = (seq) => {
11
+ let i = 0;
12
+ vi.stubGlobal('fetch', () => {
13
+ const next = seq[Math.min(i++, seq.length - 1)];
14
+ return next instanceof Error
15
+ ? Promise.reject(next)
16
+ : Promise.resolve({
17
+ ok: true,
18
+ status: 200,
19
+ text: () => Promise.resolve(next),
20
+ });
21
+ });
22
+ };
23
+ const wait = () => waitForEbiJob({
24
+ tool: 'clustalo',
25
+ jobId: 'job1',
26
+ intervalSeconds: 0,
27
+ onCountdown: () => { },
28
+ });
29
+ test('a transient failure mid-poll does not abandon a job that then finishes', async () => {
30
+ statuses(['RUNNING', new TypeError('Failed to fetch'), 'FINISHED']);
31
+ await expect(wait()).resolves.toBeUndefined();
32
+ });
33
+ test('failures that do not persist never accumulate to the limit', async () => {
34
+ const blip = new TypeError('Failed to fetch');
35
+ statuses([blip, 'RUNNING', blip, 'RUNNING', blip, 'RUNNING', blip, 'FINISHED']);
36
+ await expect(wait()).resolves.toBeUndefined();
37
+ });
38
+ test('an endpoint that has genuinely gone away is not polled forever', async () => {
39
+ statuses([new TypeError('Failed to fetch')]);
40
+ await expect(wait()).rejects.toThrow(/Could not reach EBI/);
41
+ });
42
+ test('a job EBI reports as failed still ends the poll immediately', async () => {
43
+ statuses(['ERROR']);
44
+ await expect(wait()).rejects.toThrow(/returned status ERROR/);
45
+ });
46
+ });
@@ -1,3 +1,12 @@
1
1
  export declare const NCBI_TOOL = "jbrowse-plugin-msaview";
2
2
  export declare const NCBI_EMAIL = "colin.diesh@gmail.com";
3
3
  export declare function efetchUrl(params: Record<string, string>): string;
4
+ /**
5
+ * The same request as a POST body. An `id` list of a few hundred accessions
6
+ * exceeds what a URL carries — 865 of them is ~13KB — and NCBI documents POST
7
+ * as the route above about 200 ids. eutils sends `ACAO: *` on both verbs.
8
+ */
9
+ export declare function efetchPost(params: Record<string, string>): readonly ["https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi", {
10
+ readonly method: "POST";
11
+ readonly body: URLSearchParams;
12
+ }];
@@ -12,3 +12,21 @@ export function efetchUrl(params) {
12
12
  });
13
13
  return `${EUTILS}/efetch.fcgi?${search.toString()}`;
14
14
  }
15
+ /**
16
+ * The same request as a POST body. An `id` list of a few hundred accessions
17
+ * exceeds what a URL carries — 865 of them is ~13KB — and NCBI documents POST
18
+ * as the route above about 200 ids. eutils sends `ACAO: *` on both verbs.
19
+ */
20
+ export function efetchPost(params) {
21
+ return [
22
+ `${EUTILS}/efetch.fcgi`,
23
+ {
24
+ method: 'POST',
25
+ body: new URLSearchParams({
26
+ ...params,
27
+ tool: NCBI_TOOL,
28
+ email: NCBI_EMAIL,
29
+ }),
30
+ },
31
+ ];
32
+ }
@@ -1,74 +1,4 @@
1
- export declare const COMMON_SPECIES: readonly [{
2
- readonly label: "Human";
3
- readonly taxId: 9606;
4
- }, {
5
- readonly label: "Chimpanzee";
6
- readonly taxId: 9598;
7
- }, {
8
- readonly label: "Gorilla";
9
- readonly taxId: 9595;
10
- }, {
11
- readonly label: "Rhesus macaque";
12
- readonly taxId: 9544;
13
- }, {
14
- readonly label: "Marmoset";
15
- readonly taxId: 9483;
16
- }, {
17
- readonly label: "Mouse";
18
- readonly taxId: 10090;
19
- }, {
20
- readonly label: "Rat";
21
- readonly taxId: 10116;
22
- }, {
23
- readonly label: "Guinea pig";
24
- readonly taxId: 10141;
25
- }, {
26
- readonly label: "Rabbit";
27
- readonly taxId: 9986;
28
- }, {
29
- readonly label: "Cat";
30
- readonly taxId: 9685;
31
- }, {
32
- readonly label: "Dog";
33
- readonly taxId: 9615;
34
- }, {
35
- readonly label: "Horse";
36
- readonly taxId: 9796;
37
- }, {
38
- readonly label: "Pig";
39
- readonly taxId: 9823;
40
- }, {
41
- readonly label: "Cow";
42
- readonly taxId: 9913;
43
- }, {
44
- readonly label: "Sheep";
45
- readonly taxId: 9940;
46
- }, {
47
- readonly label: "Opossum";
48
- readonly taxId: 13616;
49
- }, {
50
- readonly label: "Chicken";
51
- readonly taxId: 9031;
52
- }, {
53
- readonly label: "Frog";
54
- readonly taxId: 8364;
55
- }, {
56
- readonly label: "Zebrafish";
57
- readonly taxId: 7955;
58
- }, {
59
- readonly label: "Fruitfly";
60
- readonly taxId: 7227;
61
- }, {
62
- readonly label: "C. elegans";
63
- readonly taxId: 6239;
64
- }, {
65
- readonly label: "Yeast";
66
- readonly taxId: 4932;
67
- }, {
68
- readonly label: "Arabidopsis";
69
- readonly taxId: 3702;
70
- }];
71
- export declare const COMMON_TAX_RANK: Map<number, number>;
1
+ export declare const defaultMaxSpecies = 100;
72
2
  export interface OrthologRow {
73
3
  taxId: number;
74
4
  /** single-token id used identically in the FASTA, the tree and the domain GFF */
@@ -91,8 +21,18 @@ export declare function resolveGeneId(candidates: string[], taxId: number): Prom
91
21
  geneId: string;
92
22
  matched: string;
93
23
  } | undefined>;
94
- /** One ortholog gene per species, restricted to the requested taxa. */
95
- export declare function fetchOrthologGenes(geneId: string, taxa: Set<number>): Promise<{
24
+ /**
25
+ * One ortholog gene per species, in NCBI's report order, capped at `limit`.
26
+ *
27
+ * `taxa` narrows the set when a caller wants specific species; omitted, every
28
+ * species NCBI has an ortholog for is a candidate. `exclude` drops the query
29
+ * taxon, which the QUERY row already represents.
30
+ */
31
+ export declare function fetchOrthologGenes(geneId: string, { taxa, exclude, limit, }?: {
32
+ taxa?: Set<number>;
33
+ exclude?: number;
34
+ limit?: number;
35
+ }): Promise<{
96
36
  taxId: number;
97
37
  geneId: string;
98
38
  scientificName: string;
@@ -128,8 +68,10 @@ export declare function fetchProteinForGene(geneId: string): Promise<{
128
68
  * accessions and sequences. Everything here is a precomputed lookup, so this
129
69
  * returns in seconds rather than the 10+ minutes a BLAST submission costs.
130
70
  */
131
- export declare function fetchOrthologRows({ geneId, taxa, onProgress, }: {
71
+ export declare function fetchOrthologRows({ geneId, taxa, exclude, limit, onProgress, }: {
132
72
  geneId: string;
133
- taxa: Set<number>;
73
+ taxa?: Set<number>;
74
+ exclude?: number;
75
+ limit?: number;
134
76
  onProgress: (arg: string) => void;
135
77
  }): Promise<OrthologRow[]>;
@@ -14,68 +14,41 @@
14
14
  //
15
15
  // Mirrors jb2hubs' website/src/components/proteinMsa.ts assembler, trimmed to
16
16
  // what the launch dialog needs and using this plugin's fetch/eutils helpers.
17
- import { NCBI_EMAIL, NCBI_TOOL } from './eutils';
17
+ import { NCBI_EMAIL, NCBI_TOOL, efetchPost } from './eutils';
18
18
  import { jsonfetch, textfetch } from './fetch';
19
19
  // v2, not v2alpha: the alpha path still answers /orthologs but 404s
20
20
  // /product_report, so an assembler pointed at it silently resolves zero
21
21
  // representative proteins and reports "no orthologs" for every gene.
22
22
  const DATASETS = 'https://api.ncbi.nlm.nih.gov/datasets/v2';
23
23
  const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
24
- // The species panel offered in the launch dialog, ordered from the reference
25
- // outward so a run that finds only close relatives still reads as a ladder.
26
- // Orthologs absent for a given gene are skipped rather than erroring.
24
+ // NCBI's ortholog report IS the species panel. There is no list here to keep in
25
+ // step with what NCBI knows, and the panel widens by itself as NCBI annotates
26
+ // more genomes.
27
27
  //
28
- // The index order is the order the sequences are SUBMITTED in
29
- // (`COMMON_TAX_RANK` below sorts `fetchOrthologGenes`' return), not the order the
30
- // rows are drawn in: the view lays rows out by the guide tree the aligner returns,
31
- // so a run on this list comes out grouped by relatedness rather than by this
32
- // list's own sequence. Reordering here changes what Clustal is handed, not the
33
- // picture.
28
+ // It used to be a hand-written 23-species list intersected with the report, and
29
+ // the intersection is what made the alignment thin: NCBI publishes 165 orthologs
30
+ // for human NLRP1 and 865 for CFTR, so the list kept 12 of the first and 19 of
31
+ // the second. It also carried four species -- fruitfly, yeast, C. elegans and
32
+ // arabidopsis -- that the endpoint has never once returned for a human gene:
33
+ // checked against NLRP1, TP53, ACTB, BRCA1, PIK3CA, APOE and NOTCH1, whose fly
34
+ // ortholog is famous and still absent. NCBI's ortholog sets are vertebrate
35
+ // scoped.
34
36
  //
35
- // THE MAMMALS EARN THEIR PLACE, and the reason is measured rather than aesthetic.
36
- // The thirteen this list used to hold were one per major clade, which reads well
37
- // on a gene conserved to yeast and produces almost nothing on a gene that is not:
38
- // NCBI publishes 165 orthologs for human NLRP1 and every one of them is a mammal,
39
- // so of the old thirteen only Human, Mouse, Cow, Pig and Dog returned a row --
40
- // five, and Rat not among them, since NLRP1 is absent in Rattus norvegicus. The
41
- // same query against this list returns twelve. An inflammasome gene is not an
42
- // unusual case; anything immune, reproductive or lineage-specific behaves the
43
- // same way, and those are the genes a person opens an ortholog alignment on.
37
+ // The report's OWN order is the ladder the list was hand-built to approximate,
38
+ // and it is per gene. CFTR opens human, mouse, rat, zebrafish, pig, sheep,
39
+ // rabbit, chicken, cattle, ferret, dog, rhesus; NLRP1, which has no ortholog
40
+ // outside placental mammals, opens human, mouse, rhesus, shrew mouse, chimp,
41
+ // dog, cattle, horse. So `limit` takes a prefix and never needs a rank table.
44
42
  //
45
- // Cat, rabbit and opossum are here despite contributing nothing to that gene.
46
- // They are the three that most often separate "absent in this clade" from
47
- // "absent in this species", which is the question a gap in the alignment raises.
43
+ // The rows are SUBMITTED in that order, not drawn in it -- the view lays rows out
44
+ // by the guide tree the aligner returns.
48
45
  //
49
- // The cost is the run, and it is roughly linear: one NCBI protein fetch per
50
- // species and a Clustal Omega job over what comes back, so ~23 rows is about
51
- // twice the ~13-row wait. Still seconds rather than the minutes BLAST takes,
52
- // which is the comparison the panel's own text makes.
53
- export const COMMON_SPECIES = [
54
- { label: 'Human', taxId: 9606 },
55
- { label: 'Chimpanzee', taxId: 9598 },
56
- { label: 'Gorilla', taxId: 9595 },
57
- { label: 'Rhesus macaque', taxId: 9544 },
58
- { label: 'Marmoset', taxId: 9483 },
59
- { label: 'Mouse', taxId: 10090 },
60
- { label: 'Rat', taxId: 10116 },
61
- { label: 'Guinea pig', taxId: 10141 },
62
- { label: 'Rabbit', taxId: 9986 },
63
- { label: 'Cat', taxId: 9685 },
64
- { label: 'Dog', taxId: 9615 },
65
- { label: 'Horse', taxId: 9796 },
66
- { label: 'Pig', taxId: 9823 },
67
- { label: 'Cow', taxId: 9913 },
68
- { label: 'Sheep', taxId: 9940 },
69
- { label: 'Opossum', taxId: 13616 },
70
- { label: 'Chicken', taxId: 9031 },
71
- { label: 'Frog', taxId: 8364 },
72
- { label: 'Zebrafish', taxId: 7955 },
73
- { label: 'Fruitfly', taxId: 7227 },
74
- { label: 'C. elegans', taxId: 6239 },
75
- { label: 'Yeast', taxId: 4932 },
76
- { label: 'Arabidopsis', taxId: 3702 },
77
- ];
78
- export const COMMON_TAX_RANK = new Map(COMMON_SPECIES.map((s, i) => [s.taxId, i]));
46
+ // What limits the row count is the aligner, and it is linear in rows at roughly
47
+ // half a second each for a ~1400aa protein: 165 NLRP1 orthologs align at EBI in
48
+ // 88s, 865 CFTR orthologs in 407s. `defaultMaxSpecies` keeps a default run under
49
+ // a minute; EBI's own ceiling is 4000 sequences and 4MB, which even CFTR's full
50
+ // set (1.3MB) sits inside.
51
+ export const defaultMaxSpecies = 100;
79
52
  function ncbiUrl(url) {
80
53
  const sep = url.includes('?') ? '&' : '?';
81
54
  return `${url}${sep}tool=${NCBI_TOOL}&email=${encodeURIComponent(NCBI_EMAIL)}`;
@@ -107,13 +80,22 @@ export async function resolveGeneId(candidates, taxId) {
107
80
  }
108
81
  return undefined;
109
82
  }
110
- /** One ortholog gene per species, restricted to the requested taxa. */
111
- export async function fetchOrthologGenes(geneId, taxa) {
83
+ /**
84
+ * One ortholog gene per species, in NCBI's report order, capped at `limit`.
85
+ *
86
+ * `taxa` narrows the set when a caller wants specific species; omitted, every
87
+ * species NCBI has an ortholog for is a candidate. `exclude` drops the query
88
+ * taxon, which the QUERY row already represents.
89
+ */
90
+ export async function fetchOrthologGenes(geneId, { taxa, exclude, limit = defaultMaxSpecies, } = {}) {
112
91
  const json = await jsonfetch(ncbiUrl(`${DATASETS}/gene/id/${geneId}/orthologs?returned_content=COMPLETE`));
113
92
  const byTaxon = new Map();
114
93
  for (const { gene } of json.reports ?? []) {
115
94
  const taxId = Number(gene?.tax_id);
116
- if (gene?.gene_id && taxa.has(taxId) && !byTaxon.has(taxId)) {
95
+ if (gene?.gene_id &&
96
+ taxId !== exclude &&
97
+ (taxa?.has(taxId) ?? true) &&
98
+ !byTaxon.has(taxId)) {
117
99
  byTaxon.set(taxId, {
118
100
  taxId,
119
101
  geneId: gene.gene_id,
@@ -121,10 +103,26 @@ export async function fetchOrthologGenes(geneId, taxa) {
121
103
  commonName: gene.common_name,
122
104
  });
123
105
  }
106
+ if (byTaxon.size >= limit) {
107
+ break;
108
+ }
124
109
  }
125
- return [...byTaxon.values()].sort((a, b) => (COMMON_TAX_RANK.get(a.taxId) ?? Infinity) -
126
- (COMMON_TAX_RANK.get(b.taxId) ?? Infinity));
110
+ return [...byTaxon.values()];
127
111
  }
112
+ // Two ceilings sit between a gene id list and its product report, and both fail
113
+ // by returning less rather than by erroring, so a caller that ignores them just
114
+ // draws a thinner alignment.
115
+ //
116
+ // The ids go in the URL PATH, and NCBI answers HTTP 414 above roughly 8KB of
117
+ // them -- CFTR's 865 orthologs join to 8609 characters and 414 on the nose.
118
+ // `PRODUCT_REPORT_CHUNK` keeps a request well inside that.
119
+ //
120
+ // Then the endpoint paginates at 20 with the count in `total_count` and the rest
121
+ // behind `next_page_token`, which is invisible to a caller reading `reports`.
122
+ // `page_size` covers a chunk in one request. The old 23-species panel never
123
+ // reached this: the query taxon is excluded and NCBI has no ortholog for the
124
+ // four invertebrate entries, so its ceiling was 19.
125
+ const PRODUCT_REPORT_CHUNK = 150;
128
126
  /**
129
127
  * geneId -> representative protein accession: MANE Select where flagged, else
130
128
  * the longest isoform. A stable, comparable choice across species — picking
@@ -132,8 +130,9 @@ export async function fetchOrthologGenes(geneId, taxa) {
132
130
  */
133
131
  export async function fetchRepresentativeProteins(geneIds) {
134
132
  const byGene = new Map();
135
- if (geneIds.length > 0) {
136
- const json = await jsonfetch(ncbiUrl(`${DATASETS}/gene/id/${geneIds.join(',')}/product_report`));
133
+ for (let i = 0; i < geneIds.length; i += PRODUCT_REPORT_CHUNK) {
134
+ const chunk = geneIds.slice(i, i + PRODUCT_REPORT_CHUNK);
135
+ const json = await jsonfetch(ncbiUrl(`${DATASETS}/gene/id/${chunk.join(',')}/product_report?page_size=${chunk.length}`));
137
136
  for (const { product } of json.reports ?? []) {
138
137
  const candidates = (product?.transcripts ?? [])
139
138
  .map(t => ({
@@ -210,11 +209,11 @@ export async function fetchProteinForGene(geneId) {
210
209
  * accessions and sequences. Everything here is a precomputed lookup, so this
211
210
  * returns in seconds rather than the 10+ minutes a BLAST submission costs.
212
211
  */
213
- export async function fetchOrthologRows({ geneId, taxa, onProgress, }) {
212
+ export async function fetchOrthologRows({ geneId, taxa, exclude, limit, onProgress, }) {
214
213
  onProgress('Finding orthologs across species...');
215
- const genes = await fetchOrthologGenes(geneId, taxa);
214
+ const genes = await fetchOrthologGenes(geneId, { taxa, exclude, limit });
216
215
  if (genes.length < 2) {
217
- throw new Error(`Only ${genes.length} ortholog(s) found among the selected species — not enough to align`);
216
+ throw new Error(`Only ${genes.length} ortholog(s) found for this gene — not enough to align`);
218
217
  }
219
218
  onProgress('Selecting a representative protein per species...');
220
219
  const proteinByGene = await fetchRepresentativeProteins(genes.map(g => g.geneId));
@@ -224,7 +223,12 @@ export async function fetchOrthologRows({ geneId, taxa, onProgress, }) {
224
223
  }
225
224
  onProgress(`Fetching ${withProtein.length} protein sequences...`);
226
225
  const accessions = withProtein.map(g => proteinByGene.get(g.geneId));
227
- const seqByAcc = parseFasta(await textfetch(ncbiUrl(`${EUTILS}/efetch.fcgi?db=protein&id=${accessions.join(',')}&rettype=fasta&retmode=text`)));
226
+ const seqByAcc = parseFasta(await textfetch(...efetchPost({
227
+ db: 'protein',
228
+ id: accessions.join(','),
229
+ rettype: 'fasta',
230
+ retmode: 'text',
231
+ })));
228
232
  const labels = dedupeLabels(withProtein.map(g => g.commonName ?? g.scientificName));
229
233
  const rows = withProtein
230
234
  .map((g, i) => {
@@ -1,5 +1,5 @@
1
- import { describe, expect, test } from 'vitest';
2
- import { dedupeLabels, parseFasta } from './ncbiOrthologs';
1
+ import { afterEach, describe, expect, test, vi } from 'vitest';
2
+ import { dedupeLabels, fetchOrthologGenes, fetchRepresentativeProteins, parseFasta, } from './ncbiOrthologs';
3
3
  describe('dedupeLabels', () => {
4
4
  test('sanitizes to single tokens', () => {
5
5
  // labels are used identically as FASTA headers, Newick leaf names and GFF
@@ -39,3 +39,83 @@ describe('parseFasta', () => {
39
39
  expect(parseFasta('Error: CEFetchPApplication::proxy_stream()').size).toBe(0);
40
40
  });
41
41
  });
42
+ // The two ceilings that make a widened species set silently return fewer rows.
43
+ // Both are shaped like a successful response, so only a test that counts what
44
+ // came back sees them.
45
+ describe('the NCBI request ceilings', () => {
46
+ const stubFetch = (handler) => {
47
+ const seen = [];
48
+ vi.stubGlobal('fetch', (url) => {
49
+ seen.push(url);
50
+ return Promise.resolve({
51
+ ok: true,
52
+ status: 200,
53
+ json: () => Promise.resolve(handler(url)),
54
+ text: () => Promise.resolve(JSON.stringify(handler(url))),
55
+ });
56
+ });
57
+ return seen;
58
+ };
59
+ afterEach(() => {
60
+ vi.unstubAllGlobals();
61
+ });
62
+ const orthologReport = (n) => ({
63
+ total_count: n,
64
+ reports: Array.from({ length: n }, (_, i) => ({
65
+ gene: {
66
+ gene_id: String(1000 + i),
67
+ tax_id: String(2000 + i),
68
+ taxname: `Species ${i}`,
69
+ },
70
+ })),
71
+ });
72
+ test('fetchOrthologGenes takes a prefix of NCBI report order, not a filtered intersection', async () => {
73
+ stubFetch(() => orthologReport(165));
74
+ const genes = await fetchOrthologGenes('22861', { limit: 10 });
75
+ expect(genes.length).toBe(10);
76
+ // the report's own order, which leads with the reference organisms
77
+ expect(genes.map(g => g.geneId)).toEqual(Array.from({ length: 10 }, (_, i) => String(1000 + i)));
78
+ });
79
+ test('fetchOrthologGenes drops the query taxon without spending a row on it', async () => {
80
+ stubFetch(() => orthologReport(165));
81
+ const genes = await fetchOrthologGenes('22861', { exclude: 2000, limit: 3 });
82
+ expect(genes.map(g => g.taxId)).toEqual([2001, 2002, 2003]);
83
+ });
84
+ test('fetchRepresentativeProteins chunks the gene ids below the URI length NCBI 414s at', async () => {
85
+ // 865 CFTR ortholog gene ids join to 8609 characters, which NCBI answers
86
+ // with HTTP 414 rather than a short result
87
+ const ids = Array.from({ length: 400 }, (_, i) => String(100000 + i));
88
+ const seen = stubFetch(url => ({
89
+ reports: (/id\/([^/]+)\//.exec(url)?.[1] ?? '').split(',').map(id => ({
90
+ product: {
91
+ gene_id: id,
92
+ transcripts: [{ protein: { accession_version: `NP_${id}.1` } }],
93
+ },
94
+ })),
95
+ }));
96
+ const byGene = await fetchRepresentativeProteins(ids);
97
+ expect(byGene.size).toBe(400);
98
+ expect(seen.length).toBeGreaterThan(1);
99
+ expect(Math.max(...seen.map(u => u.length))).toBeLessThan(8000);
100
+ });
101
+ test('fetchRepresentativeProteins asks for a page big enough to hold its chunk', async () => {
102
+ // the endpoint paginates at 20 by default and hides the rest behind
103
+ // next_page_token, so a caller reading only `reports` loses everything past
104
+ // the first page and reports no protein for those genes
105
+ const ids = Array.from({ length: 50 }, (_, i) => String(100000 + i));
106
+ const seen = stubFetch(url => ({
107
+ reports: (/id\/([^/]+)\//.exec(url)?.[1] ?? '')
108
+ .split(',')
109
+ .slice(0, Number(/page_size=(\d+)/.exec(url)?.[1] ?? 20))
110
+ .map(id => ({
111
+ product: {
112
+ gene_id: id,
113
+ transcripts: [{ protein: { accession_version: `NP_${id}.1` } }],
114
+ },
115
+ })),
116
+ }));
117
+ const byGene = await fetchRepresentativeProteins(ids);
118
+ expect(byGene.size).toBe(50);
119
+ expect(seen.every(u => /page_size=\d+/.test(u))).toBe(true);
120
+ });
121
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Free text -> NCBI taxon id. A bare number is taken as the id itself; anything
3
+ * else is searched against db=taxonomy, which resolves a scientific name
4
+ * (`Danio rerio`), a common name (`zebrafish`) and a genus alike.
5
+ *
6
+ * This replaces a fixed list of species the dialog used to offer. The query
7
+ * taxon has to match the assembly the user is browsing -- `resolveGeneId`
8
+ * searches `SYMBOL[Gene Name] AND <taxid>[taxid]` -- so a list that stops at 23
9
+ * species silently resolves the wrong organism's gene for anyone outside it.
10
+ */
11
+ export declare function resolveTaxId(query: string): Promise<number | undefined>;
@@ -0,0 +1,33 @@
1
+ import { NCBI_EMAIL, NCBI_TOOL } from './eutils';
2
+ import { jsonfetch } from './fetch';
3
+ const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
4
+ /**
5
+ * Free text -> NCBI taxon id. A bare number is taken as the id itself; anything
6
+ * else is searched against db=taxonomy, which resolves a scientific name
7
+ * (`Danio rerio`), a common name (`zebrafish`) and a genus alike.
8
+ *
9
+ * This replaces a fixed list of species the dialog used to offer. The query
10
+ * taxon has to match the assembly the user is browsing -- `resolveGeneId`
11
+ * searches `SYMBOL[Gene Name] AND <taxid>[taxid]` -- so a list that stops at 23
12
+ * species silently resolves the wrong organism's gene for anyone outside it.
13
+ */
14
+ export async function resolveTaxId(query) {
15
+ const term = query.trim();
16
+ if (!term) {
17
+ return undefined;
18
+ }
19
+ if (/^\d+$/.test(term)) {
20
+ return Number(term);
21
+ }
22
+ const search = new URLSearchParams({
23
+ db: 'taxonomy',
24
+ term,
25
+ retmode: 'json',
26
+ retmax: '1',
27
+ tool: NCBI_TOOL,
28
+ email: NCBI_EMAIL,
29
+ });
30
+ const json = await jsonfetch(`${EUTILS}/esearch.fcgi?${search.toString()}`);
31
+ const id = json.esearchresult?.idlist?.[0];
32
+ return id ? Number(id) : undefined;
33
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const version = "2.9.0";
1
+ export declare const version = "2.10.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = '2.9.0';
1
+ export const version = '2.10.0';
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.9.0",
2
+ "version": "2.10.0",
3
3
  "license": "MIT",
4
4
  "name": "jbrowse-plugin-msaview",
5
5
  "repository": {
@@ -43,7 +43,7 @@
43
43
  "eslint-plugin-unicorn": "^72.0.0",
44
44
  "mobx": "^6.16.1",
45
45
  "mobx-react": "^9.2.2",
46
- "msa-parsers": "^5.7.1",
46
+ "msa-parsers": "^5.9.0",
47
47
  "pixelmatch": "^7.2.0",
48
48
  "pngjs": "^7.0.0",
49
49
  "prettier": "^3.9.6",
@@ -51,7 +51,7 @@
51
51
  "puppeteer": "^25.3.0",
52
52
  "react": "^19.2.8",
53
53
  "react-dom": "^19.2.8",
54
- "react-msaview": "^5.7.2",
54
+ "react-msaview": "^5.9.0",
55
55
  "rimraf": "^6.1.3",
56
56
  "rxjs": "^7.8.2",
57
57
  "serve": "^14.2.6",