jbrowse-plugin-msaview 2.8.2 → 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 (108) hide show
  1. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastRIDPanel.d.ts → BlastQuery/BlastAutomaticPanel.d.ts} +2 -3
  2. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastAutomaticPanel.js → BlastQuery/BlastAutomaticPanel.js} +6 -25
  3. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastManualPanel.d.ts → BlastQuery/BlastManualPanel.d.ts} +2 -3
  4. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastManualPanel.js → BlastQuery/BlastManualPanel.js} +7 -4
  5. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastMethodSelector.d.ts → BlastQuery/BlastMethodSelector.d.ts} +2 -2
  6. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastMethodSelector.js → BlastQuery/BlastMethodSelector.js} +1 -2
  7. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastPanel.d.ts → BlastQuery/BlastPanel.d.ts} +3 -12
  8. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastPanel.js → BlastQuery/BlastPanel.js} +14 -16
  9. package/dist/LaunchMsaView/components/BlastQuery/BlastSettingsDialog.d.ts +8 -0
  10. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBISettingsDialog.js → BlastQuery/BlastSettingsDialog.js} +13 -9
  11. package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/CachedBlastResults.js +1 -1
  12. package/dist/LaunchMsaView/components/BlastQuery/consts.d.ts +12 -0
  13. package/dist/LaunchMsaView/components/BlastQuery/consts.js +17 -0
  14. package/dist/LaunchMsaView/components/LaunchMsaViewDialog.js +3 -3
  15. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +15 -35
  16. package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.d.ts +15 -0
  17. package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.js +67 -0
  18. package/dist/LaunchMsaView/index.js +19 -1
  19. package/dist/MsaViewPanel/components/JobLink.d.ts +5 -0
  20. package/dist/MsaViewPanel/components/JobLink.js +13 -0
  21. package/dist/MsaViewPanel/components/LoadingBLAST.d.ts +1 -2
  22. package/dist/MsaViewPanel/components/LoadingBLAST.js +8 -15
  23. package/dist/MsaViewPanel/components/MsaViewPanel.js +1 -1
  24. package/dist/MsaViewPanel/doLaunchBlast.js +11 -19
  25. package/dist/MsaViewPanel/doLaunchOrthologs.js +43 -12
  26. package/dist/MsaViewPanel/doLaunchOrthologs.test.d.ts +1 -0
  27. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +201 -0
  28. package/dist/MsaViewPanel/model.d.ts +20 -12
  29. package/dist/jbrowse-plugin-msaview.umd.production.min.js +31 -33
  30. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  31. package/dist/utils/blastCache.d.ts +8 -4
  32. package/dist/utils/blastCache.js +4 -5
  33. package/dist/utils/ebiBlast.d.ts +52 -0
  34. package/dist/utils/ebiBlast.js +63 -0
  35. package/dist/utils/ebiJobDispatcher.d.ts +33 -0
  36. package/dist/utils/ebiJobDispatcher.js +80 -0
  37. package/dist/utils/ebiJobDispatcher.test.d.ts +1 -0
  38. package/dist/utils/ebiJobDispatcher.test.js +46 -0
  39. package/dist/utils/eutils.d.ts +9 -0
  40. package/dist/utils/eutils.js +18 -0
  41. package/dist/utils/fetch.js +24 -1
  42. package/dist/utils/msa.d.ts +1 -1
  43. package/dist/utils/msa.js +25 -32
  44. package/dist/utils/ncbiOrthologs.d.ts +17 -75
  45. package/dist/utils/ncbiOrthologs.js +67 -63
  46. package/dist/utils/ncbiOrthologs.test.js +82 -2
  47. package/dist/utils/ncbiTaxonomy.d.ts +11 -0
  48. package/dist/utils/ncbiTaxonomy.js +33 -0
  49. package/dist/utils/types.d.ts +9 -14
  50. package/dist/utils/useLocalStorage.d.ts +1 -0
  51. package/dist/utils/useLocalStorage.js +1 -1
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/package.json +3 -3
  55. package/src/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastAutomaticPanel.tsx → BlastQuery/BlastAutomaticPanel.tsx} +13 -59
  56. package/src/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastManualPanel.tsx → BlastQuery/BlastManualPanel.tsx} +7 -6
  57. package/src/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastMethodSelector.tsx → BlastQuery/BlastMethodSelector.tsx} +2 -7
  58. package/src/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastPanel.tsx → BlastQuery/BlastPanel.tsx} +21 -25
  59. package/src/LaunchMsaView/components/{NCBIBlastQuery/NCBISettingsDialog.tsx → BlastQuery/BlastSettingsDialog.tsx} +26 -13
  60. package/src/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/CachedBlastResults.tsx +1 -1
  61. package/src/LaunchMsaView/components/BlastQuery/consts.ts +22 -0
  62. package/src/LaunchMsaView/components/LaunchMsaViewDialog.tsx +5 -7
  63. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +35 -73
  64. package/src/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.tsx +92 -0
  65. package/src/LaunchMsaView/index.ts +20 -2
  66. package/src/MsaViewPanel/components/JobLink.tsx +17 -0
  67. package/src/MsaViewPanel/components/LoadingBLAST.tsx +10 -41
  68. package/src/MsaViewPanel/components/MsaViewPanel.tsx +1 -1
  69. package/src/MsaViewPanel/doLaunchBlast.ts +12 -28
  70. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +247 -0
  71. package/src/MsaViewPanel/doLaunchOrthologs.ts +56 -13
  72. package/src/MsaViewPanel/model.ts +10 -7
  73. package/src/utils/blastCache.ts +8 -9
  74. package/src/utils/ebiBlast.ts +114 -0
  75. package/src/utils/ebiJobDispatcher.test.ts +54 -0
  76. package/src/utils/ebiJobDispatcher.ts +120 -0
  77. package/src/utils/eutils.ts +19 -0
  78. package/src/utils/fetch.ts +26 -1
  79. package/src/utils/msa.ts +26 -47
  80. package/src/utils/ncbiOrthologs.test.ts +96 -2
  81. package/src/utils/ncbiOrthologs.ts +83 -71
  82. package/src/utils/ncbiTaxonomy.ts +37 -0
  83. package/src/utils/types.ts +8 -13
  84. package/src/utils/useLocalStorage.ts +1 -1
  85. package/src/version.ts +1 -1
  86. package/dist/LaunchMsaView/components/NCBIBlastQuery/NCBIBlastAutomaticPanel.d.ts +0 -10
  87. package/dist/LaunchMsaView/components/NCBIBlastQuery/NCBIBlastRIDPanel.js +0 -74
  88. package/dist/LaunchMsaView/components/NCBIBlastQuery/NCBISettingsDialog.d.ts +0 -5
  89. package/dist/LaunchMsaView/components/NCBIBlastQuery/consts.d.ts +0 -7
  90. package/dist/LaunchMsaView/components/NCBIBlastQuery/consts.js +0 -4
  91. package/dist/MsaViewPanel/components/RIDLink.d.ts +0 -6
  92. package/dist/MsaViewPanel/components/RIDLink.js +0 -12
  93. package/dist/utils/ncbiBlast.d.ts +0 -30
  94. package/dist/utils/ncbiBlast.js +0 -84
  95. package/src/LaunchMsaView/components/NCBIBlastQuery/NCBIBlastRIDPanel.tsx +0 -137
  96. package/src/LaunchMsaView/components/NCBIBlastQuery/consts.ts +0 -10
  97. package/src/MsaViewPanel/components/RIDLink.tsx +0 -19
  98. package/src/utils/ncbiBlast.ts +0 -143
  99. /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/CachedBlastResults.d.ts +0 -0
  100. /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/MsaAlgorithmSelect.d.ts +0 -0
  101. /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/MsaAlgorithmSelect.js +0 -0
  102. /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/blastLaunchView.d.ts +0 -0
  103. /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/blastLaunchView.js +0 -0
  104. /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/useCachedBlastResults.d.ts +0 -0
  105. /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/useCachedBlastResults.js +0 -0
  106. /package/src/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/MsaAlgorithmSelect.tsx +0 -0
  107. /package/src/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/blastLaunchView.ts +0 -0
  108. /package/src/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/useCachedBlastResults.ts +0 -0
@@ -0,0 +1,54 @@
1
+ import { afterEach, describe, expect, test, vi } from 'vitest'
2
+
3
+ import { waitForEbiJob } from './ebiJobDispatcher'
4
+
5
+ // A long alignment is polled every ten seconds for minutes, so the chance of one
6
+ // failed status check somewhere in that window is not small -- and the job is
7
+ // running at EBI regardless of what happens to the poller's connection.
8
+ describe('waitForEbiJob', () => {
9
+ afterEach(() => {
10
+ vi.unstubAllGlobals()
11
+ })
12
+
13
+ const statuses = (seq: (string | Error)[]) => {
14
+ let i = 0
15
+ vi.stubGlobal('fetch', () => {
16
+ const next = seq[Math.min(i++, seq.length - 1)]!
17
+ return next instanceof Error
18
+ ? Promise.reject(next)
19
+ : Promise.resolve({
20
+ ok: true,
21
+ status: 200,
22
+ text: () => Promise.resolve(next),
23
+ })
24
+ })
25
+ }
26
+ const wait = () =>
27
+ waitForEbiJob({
28
+ tool: 'clustalo',
29
+ jobId: 'job1',
30
+ intervalSeconds: 0,
31
+ onCountdown: () => {},
32
+ })
33
+
34
+ test('a transient failure mid-poll does not abandon a job that then finishes', async () => {
35
+ statuses(['RUNNING', new TypeError('Failed to fetch'), 'FINISHED'])
36
+ await expect(wait()).resolves.toBeUndefined()
37
+ })
38
+
39
+ test('failures that do not persist never accumulate to the limit', async () => {
40
+ const blip = new TypeError('Failed to fetch')
41
+ statuses([blip, 'RUNNING', blip, 'RUNNING', blip, 'RUNNING', blip, 'FINISHED'])
42
+ await expect(wait()).resolves.toBeUndefined()
43
+ })
44
+
45
+ test('an endpoint that has genuinely gone away is not polled forever', async () => {
46
+ statuses([new TypeError('Failed to fetch')])
47
+ await expect(wait()).rejects.toThrow(/Could not reach EBI/)
48
+ })
49
+
50
+ test('a job EBI reports as failed still ends the poll immediately', async () => {
51
+ statuses(['ERROR'])
52
+ await expect(wait()).rejects.toThrow(/returned status ERROR/)
53
+ })
54
+ })
@@ -0,0 +1,120 @@
1
+ import { textfetch } from './fetch'
2
+ import { pollLoop } from './poll'
3
+ import { readLocalStorage } from './useLocalStorage'
4
+
5
+ /**
6
+ * EBI's Job Dispatcher REST services (clustalo, muscle, ncbiblast, ...) all
7
+ * speak the same run/status/result protocol, so the transport lives here and
8
+ * each tool only supplies its own parameters and result types.
9
+ *
10
+ * Unlike NCBI's Blast.cgi these endpoints send `Access-Control-Allow-Origin: *`,
11
+ * which is why the BLAST backend moved here — see docs/blast.md.
12
+ */
13
+ export const EBI_BASE = 'https://www.ebi.ac.uk/Tools/services/rest'
14
+
15
+ /**
16
+ * EBI asks for a contact address on every submission so they can reach whoever
17
+ * is generating the load. A deployment that sends real volume should point this
18
+ * at its own maintainer via the BLAST settings dialog — otherwise every
19
+ * msaview job in the world is attributed to one person.
20
+ */
21
+ export const EBI_EMAIL_STORAGE_KEY = 'msa-ebiContactEmail'
22
+ export const DEFAULT_EBI_EMAIL = 'colin.diesh@gmail.com'
23
+
24
+ export function getEbiEmail() {
25
+ const configured = readLocalStorage(
26
+ EBI_EMAIL_STORAGE_KEY,
27
+ DEFAULT_EBI_EMAIL,
28
+ ).trim()
29
+ return configured || DEFAULT_EBI_EMAIL
30
+ }
31
+
32
+ /** Statuses that mean the job is over and produced no result. */
33
+ const FAILED_STATUSES = new Set(['ERROR', 'FAILURE', 'NOT_FOUND'])
34
+
35
+ export async function submitEbiJob({
36
+ tool,
37
+ params,
38
+ }: {
39
+ tool: string
40
+ params: Record<string, string>
41
+ }) {
42
+ const jobId = await textfetch(`${EBI_BASE}/${tool}/run`, {
43
+ method: 'POST',
44
+ body: new URLSearchParams({ email: getEbiEmail(), ...params }),
45
+ })
46
+ return jobId.trim()
47
+ }
48
+
49
+ /**
50
+ * A status check that could not reach EBI at all says nothing about the job, so
51
+ * it is not a reason to abandon one. A job the server has accepted keeps running
52
+ * whatever happens to the poller's connection, and the poll is the long part: an
53
+ * alignment of a hundred sequences runs for minutes and is checked every ten
54
+ * seconds, so a single blip anywhere in that window used to throw away a job
55
+ * that went on to finish.
56
+ *
57
+ * Consecutive failures still end it, because an endpoint that has genuinely gone
58
+ * away must not be polled forever.
59
+ */
60
+ const MAX_CONSECUTIVE_STATUS_FAILURES = 5
61
+
62
+ export async function waitForEbiJob({
63
+ tool,
64
+ jobId,
65
+ intervalSeconds = 10,
66
+ onCountdown,
67
+ }: {
68
+ tool: string
69
+ jobId: string
70
+ intervalSeconds?: number
71
+ onCountdown: (secondsRemaining: number) => void
72
+ }) {
73
+ let consecutiveFailures = 0
74
+ await pollLoop({
75
+ intervalSeconds,
76
+ onCountdown,
77
+ check: async () => {
78
+ let status: string
79
+ try {
80
+ status = (await textfetch(`${EBI_BASE}/${tool}/status/${jobId}`)).trim()
81
+ } catch (e) {
82
+ consecutiveFailures += 1
83
+ if (consecutiveFailures >= MAX_CONSECUTIVE_STATUS_FAILURES) {
84
+ throw new Error(
85
+ `Could not reach EBI to check ${tool} job ${jobId} after ${consecutiveFailures} tries`,
86
+ { cause: e },
87
+ )
88
+ }
89
+ console.warn(
90
+ `[msaview] EBI status check ${consecutiveFailures} failed, retrying:`,
91
+ e,
92
+ )
93
+ return false
94
+ }
95
+ consecutiveFailures = 0
96
+ // exact match, not includes(): a job whose status is ERROR must not be
97
+ // able to poll forever waiting for a FINISHED that will never arrive
98
+ if (status === 'FINISHED') {
99
+ return true
100
+ }
101
+ if (FAILED_STATUSES.has(status)) {
102
+ throw new Error(`EBI ${tool} job ${jobId} returned status ${status}`)
103
+ }
104
+ // RUNNING, QUEUED, PENDING and anything else EBI adds later
105
+ return false
106
+ },
107
+ })
108
+ }
109
+
110
+ export async function fetchEbiResult({
111
+ tool,
112
+ jobId,
113
+ type,
114
+ }: {
115
+ tool: string
116
+ jobId: string
117
+ type: string
118
+ }) {
119
+ return textfetch(`${EBI_BASE}/${tool}/result/${jobId}/${type}`)
120
+ }
@@ -14,3 +14,22 @@ export function efetchUrl(params: Record<string, string>) {
14
14
  })
15
15
  return `${EUTILS}/efetch.fcgi?${search.toString()}`
16
16
  }
17
+
18
+ /**
19
+ * The same request as a POST body. An `id` list of a few hundred accessions
20
+ * exceeds what a URL carries — 865 of them is ~13KB — and NCBI documents POST
21
+ * as the route above about 200 ids. eutils sends `ACAO: *` on both verbs.
22
+ */
23
+ export function efetchPost(params: Record<string, string>) {
24
+ return [
25
+ `${EUTILS}/efetch.fcgi`,
26
+ {
27
+ method: 'POST',
28
+ body: new URLSearchParams({
29
+ ...params,
30
+ tool: NCBI_TOOL,
31
+ email: NCBI_EMAIL,
32
+ }),
33
+ },
34
+ ] as const
35
+ }
@@ -1,5 +1,30 @@
1
+ function hostOf(url: string) {
2
+ try {
3
+ return new URL(url).host
4
+ } catch {
5
+ // a relative url, e.g. a same-origin proxy path
6
+ return url
7
+ }
8
+ }
9
+
1
10
  export async function handleFetch(url: string, args?: RequestInit) {
2
- const response = await fetch(url, args)
11
+ let response: Response
12
+ try {
13
+ response = await fetch(url, args)
14
+ } catch (e) {
15
+ // fetch rejects with a bare "TypeError: Failed to fetch" for every network
16
+ // level failure, including a cross-origin response the browser refused to
17
+ // hand over. That is not a hypothetical: NCBI's Blast.cgi stopped sending
18
+ // Access-Control-Allow-Origin to third-party origins, and the raw TypeError
19
+ // told users nothing at all about why. See docs/blast.md.
20
+ if (e instanceof TypeError) {
21
+ throw new Error(
22
+ `Could not reach ${hostOf(url)} — the request failed at the network level. This is usually a CORS restriction (the server refused to let this site read the response), an offline connection, or a blocked request.`,
23
+ { cause: e },
24
+ )
25
+ }
26
+ throw e
27
+ }
3
28
 
4
29
  if (!response.ok) {
5
30
  throw new Error(
package/src/utils/msa.ts CHANGED
@@ -1,10 +1,6 @@
1
- import { textfetch } from './fetch'
2
- import { pollLoop } from './poll'
1
+ import { fetchEbiResult, submitEbiJob, waitForEbiJob } from './ebiJobDispatcher'
3
2
 
4
- import type { MsaAlgorithm } from '../LaunchMsaView/components/NCBIBlastQuery/consts'
5
-
6
- const base = `https://www.ebi.ac.uk/Tools/services/rest`
7
- const email = 'colin.diesh@gmail.com'
3
+ import type { MsaAlgorithm } from '../LaunchMsaView/components/BlastQuery/consts'
8
4
 
9
5
  const algorithms: Record<
10
6
  MsaAlgorithm,
@@ -15,54 +11,27 @@ const algorithms: Record<
15
11
  }
16
12
  > = {
17
13
  clustalo: {
18
- params: { email },
14
+ params: {},
19
15
  msaResult: 'aln-clustal_num',
20
16
  treeResult: 'phylotree',
21
17
  },
22
18
  muscle: {
23
- params: { email, format: 'clw', tree: 'tree1' },
19
+ params: { format: 'clw', tree: 'tree1' },
24
20
  msaResult: 'fa',
25
21
  treeResult: 'phylotree',
26
22
  },
27
23
  kalign: {
28
- params: { email, stype: 'protein' },
24
+ params: { stype: 'protein' },
29
25
  msaResult: 'fa',
30
26
  treeResult: 'phylotree',
31
27
  },
32
28
  mafft: {
33
- params: { email, stype: 'protein' },
29
+ params: { stype: 'protein' },
34
30
  msaResult: 'fa',
35
31
  treeResult: 'phylotree',
36
32
  },
37
33
  }
38
34
 
39
- async function wait({
40
- onProgress,
41
- jobId,
42
- algorithm,
43
- }: {
44
- jobId: string
45
- algorithm: MsaAlgorithm
46
- onProgress: (arg: string) => void
47
- }) {
48
- await pollLoop({
49
- intervalSeconds: 10,
50
- onCountdown: s => {
51
- onProgress(`Re-checking MSA status in... ${s}`)
52
- },
53
- check: async () => {
54
- const result = await textfetch(`${base}/${algorithm}/status/${jobId}`)
55
- if (result.includes('FINISHED')) {
56
- return true
57
- }
58
- if (result.includes('FAILURE')) {
59
- throw new Error(`Failed to run: jobId ${jobId}`)
60
- }
61
- return false
62
- },
63
- })
64
- }
65
-
66
35
  export async function launchMSA({
67
36
  algorithm,
68
37
  sequence,
@@ -76,17 +45,27 @@ export async function launchMSA({
76
45
 
77
46
  onProgress(`Launching ${algorithm} MSA...`)
78
47
 
79
- const jobId = await textfetch(`${base}/${algorithm}/run`, {
80
- method: 'POST',
81
- body: new URLSearchParams({ ...config.params, sequence }),
48
+ const jobId = await submitEbiJob({
49
+ tool: algorithm,
50
+ params: { ...config.params, sequence },
51
+ })
52
+ await waitForEbiJob({
53
+ tool: algorithm,
54
+ jobId,
55
+ onCountdown: s => {
56
+ onProgress(`Re-checking MSA status in... ${s}`)
57
+ },
82
58
  })
83
- await wait({ jobId, algorithm, onProgress })
84
59
  return {
85
- msa: await textfetch(
86
- `${base}/${algorithm}/result/${jobId}/${config.msaResult}`,
87
- ),
88
- tree: await textfetch(
89
- `${base}/${algorithm}/result/${jobId}/${config.treeResult}`,
90
- ),
60
+ msa: await fetchEbiResult({
61
+ tool: algorithm,
62
+ jobId,
63
+ type: config.msaResult,
64
+ }),
65
+ tree: await fetchEbiResult({
66
+ tool: algorithm,
67
+ jobId,
68
+ type: config.treeResult,
69
+ }),
91
70
  }
92
71
  }
@@ -1,6 +1,11 @@
1
- import { describe, expect, test } from 'vitest'
1
+ import { afterEach, describe, expect, test, vi } from 'vitest'
2
2
 
3
- import { dedupeLabels, parseFasta } from './ncbiOrthologs'
3
+ import {
4
+ dedupeLabels,
5
+ fetchOrthologGenes,
6
+ fetchRepresentativeProteins,
7
+ parseFasta,
8
+ } from './ncbiOrthologs'
4
9
 
5
10
  describe('dedupeLabels', () => {
6
11
  test('sanitizes to single tokens', () => {
@@ -54,3 +59,92 @@ describe('parseFasta', () => {
54
59
  )
55
60
  })
56
61
  })
62
+
63
+ // The two ceilings that make a widened species set silently return fewer rows.
64
+ // Both are shaped like a successful response, so only a test that counts what
65
+ // came back sees them.
66
+ describe('the NCBI request ceilings', () => {
67
+ const stubFetch = (handler: (url: string) => unknown) => {
68
+ const seen: string[] = []
69
+ vi.stubGlobal('fetch', (url: string) => {
70
+ seen.push(url)
71
+ return Promise.resolve({
72
+ ok: true,
73
+ status: 200,
74
+ json: () => Promise.resolve(handler(url)),
75
+ text: () => Promise.resolve(JSON.stringify(handler(url))),
76
+ })
77
+ })
78
+ return seen
79
+ }
80
+
81
+ afterEach(() => {
82
+ vi.unstubAllGlobals()
83
+ })
84
+
85
+ const orthologReport = (n: number) => ({
86
+ total_count: n,
87
+ reports: Array.from({ length: n }, (_, i) => ({
88
+ gene: {
89
+ gene_id: String(1000 + i),
90
+ tax_id: String(2000 + i),
91
+ taxname: `Species ${i}`,
92
+ },
93
+ })),
94
+ })
95
+
96
+ test('fetchOrthologGenes takes a prefix of NCBI report order, not a filtered intersection', async () => {
97
+ stubFetch(() => orthologReport(165))
98
+ const genes = await fetchOrthologGenes('22861', { limit: 10 })
99
+ expect(genes.length).toBe(10)
100
+ // the report's own order, which leads with the reference organisms
101
+ expect(genes.map(g => g.geneId)).toEqual(
102
+ Array.from({ length: 10 }, (_, i) => String(1000 + i)),
103
+ )
104
+ })
105
+
106
+ test('fetchOrthologGenes drops the query taxon without spending a row on it', async () => {
107
+ stubFetch(() => orthologReport(165))
108
+ const genes = await fetchOrthologGenes('22861', { exclude: 2000, limit: 3 })
109
+ expect(genes.map(g => g.taxId)).toEqual([2001, 2002, 2003])
110
+ })
111
+
112
+ test('fetchRepresentativeProteins chunks the gene ids below the URI length NCBI 414s at', async () => {
113
+ // 865 CFTR ortholog gene ids join to 8609 characters, which NCBI answers
114
+ // with HTTP 414 rather than a short result
115
+ const ids = Array.from({ length: 400 }, (_, i) => String(100000 + i))
116
+ const seen = stubFetch(url => ({
117
+ reports: (/id\/([^/]+)\//.exec(url)?.[1] ?? '').split(',').map(id => ({
118
+ product: {
119
+ gene_id: id,
120
+ transcripts: [{ protein: { accession_version: `NP_${id}.1` } }],
121
+ },
122
+ })),
123
+ }))
124
+ const byGene = await fetchRepresentativeProteins(ids)
125
+ expect(byGene.size).toBe(400)
126
+ expect(seen.length).toBeGreaterThan(1)
127
+ expect(Math.max(...seen.map(u => u.length))).toBeLessThan(8000)
128
+ })
129
+
130
+ test('fetchRepresentativeProteins asks for a page big enough to hold its chunk', async () => {
131
+ // the endpoint paginates at 20 by default and hides the rest behind
132
+ // next_page_token, so a caller reading only `reports` loses everything past
133
+ // the first page and reports no protein for those genes
134
+ const ids = Array.from({ length: 50 }, (_, i) => String(100000 + i))
135
+ const seen = stubFetch(url => ({
136
+ reports: (/id\/([^/]+)\//.exec(url)?.[1] ?? '')
137
+ .split(',')
138
+ .slice(0, Number(/page_size=(\d+)/.exec(url)?.[1] ?? 20))
139
+ .map(id => ({
140
+ product: {
141
+ gene_id: id,
142
+ transcripts: [{ protein: { accession_version: `NP_${id}.1` } }],
143
+ },
144
+ })),
145
+ }))
146
+ const byGene = await fetchRepresentativeProteins(ids)
147
+ expect(byGene.size).toBe(50)
148
+ expect(seen.every(u => /page_size=\d+/.test(u))).toBe(true)
149
+ })
150
+ })
@@ -15,7 +15,7 @@
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
17
 
18
- import { NCBI_EMAIL, NCBI_TOOL } from './eutils'
18
+ import { NCBI_EMAIL, NCBI_TOOL, efetchPost } from './eutils'
19
19
  import { jsonfetch, textfetch } from './fetch'
20
20
 
21
21
  // v2, not v2alpha: the alpha path still answers /orthologs but 404s
@@ -24,64 +24,34 @@ import { jsonfetch, textfetch } from './fetch'
24
24
  const DATASETS = 'https://api.ncbi.nlm.nih.gov/datasets/v2'
25
25
  const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils'
26
26
 
27
- // The species panel offered in the launch dialog, ordered from the reference
28
- // outward so a run that finds only close relatives still reads as a ladder.
29
- // Orthologs absent for a given gene are skipped rather than erroring.
27
+ // NCBI's ortholog report IS the species panel. There is no list here to keep in
28
+ // step with what NCBI knows, and the panel widens by itself as NCBI annotates
29
+ // more genomes.
30
30
  //
31
- // The index order is the order the sequences are SUBMITTED in
32
- // (`COMMON_TAX_RANK` below sorts `fetchOrthologGenes`' return), not the order the
33
- // rows are drawn in: the view lays rows out by the guide tree the aligner returns,
34
- // so a run on this list comes out grouped by relatedness rather than by this
35
- // list's own sequence. Reordering here changes what Clustal is handed, not the
36
- // picture.
31
+ // It used to be a hand-written 23-species list intersected with the report, and
32
+ // the intersection is what made the alignment thin: NCBI publishes 165 orthologs
33
+ // for human NLRP1 and 865 for CFTR, so the list kept 12 of the first and 19 of
34
+ // the second. It also carried four species -- fruitfly, yeast, C. elegans and
35
+ // arabidopsis -- that the endpoint has never once returned for a human gene:
36
+ // checked against NLRP1, TP53, ACTB, BRCA1, PIK3CA, APOE and NOTCH1, whose fly
37
+ // ortholog is famous and still absent. NCBI's ortholog sets are vertebrate
38
+ // scoped.
37
39
  //
38
- // THE MAMMALS EARN THEIR PLACE, and the reason is measured rather than aesthetic.
39
- // The thirteen this list used to hold were one per major clade, which reads well
40
- // on a gene conserved to yeast and produces almost nothing on a gene that is not:
41
- // NCBI publishes 165 orthologs for human NLRP1 and every one of them is a mammal,
42
- // so of the old thirteen only Human, Mouse, Cow, Pig and Dog returned a row --
43
- // five, and Rat not among them, since NLRP1 is absent in Rattus norvegicus. The
44
- // same query against this list returns twelve. An inflammasome gene is not an
45
- // unusual case; anything immune, reproductive or lineage-specific behaves the
46
- // same way, and those are the genes a person opens an ortholog alignment on.
40
+ // The report's OWN order is the ladder the list was hand-built to approximate,
41
+ // and it is per gene. CFTR opens human, mouse, rat, zebrafish, pig, sheep,
42
+ // rabbit, chicken, cattle, ferret, dog, rhesus; NLRP1, which has no ortholog
43
+ // outside placental mammals, opens human, mouse, rhesus, shrew mouse, chimp,
44
+ // dog, cattle, horse. So `limit` takes a prefix and never needs a rank table.
47
45
  //
48
- // Cat, rabbit and opossum are here despite contributing nothing to that gene.
49
- // They are the three that most often separate "absent in this clade" from
50
- // "absent in this species", which is the question a gap in the alignment raises.
46
+ // The rows are SUBMITTED in that order, not drawn in it -- the view lays rows out
47
+ // by the guide tree the aligner returns.
51
48
  //
52
- // The cost is the run, and it is roughly linear: one NCBI protein fetch per
53
- // species and a Clustal Omega job over what comes back, so ~23 rows is about
54
- // twice the ~13-row wait. Still seconds rather than the minutes BLAST takes,
55
- // which is the comparison the panel's own text makes.
56
- export const COMMON_SPECIES = [
57
- { label: 'Human', taxId: 9606 },
58
- { label: 'Chimpanzee', taxId: 9598 },
59
- { label: 'Gorilla', taxId: 9595 },
60
- { label: 'Rhesus macaque', taxId: 9544 },
61
- { label: 'Marmoset', taxId: 9483 },
62
- { label: 'Mouse', taxId: 10090 },
63
- { label: 'Rat', taxId: 10116 },
64
- { label: 'Guinea pig', taxId: 10141 },
65
- { label: 'Rabbit', taxId: 9986 },
66
- { label: 'Cat', taxId: 9685 },
67
- { label: 'Dog', taxId: 9615 },
68
- { label: 'Horse', taxId: 9796 },
69
- { label: 'Pig', taxId: 9823 },
70
- { label: 'Cow', taxId: 9913 },
71
- { label: 'Sheep', taxId: 9940 },
72
- { label: 'Opossum', taxId: 13616 },
73
- { label: 'Chicken', taxId: 9031 },
74
- { label: 'Frog', taxId: 8364 },
75
- { label: 'Zebrafish', taxId: 7955 },
76
- { label: 'Fruitfly', taxId: 7227 },
77
- { label: 'C. elegans', taxId: 6239 },
78
- { label: 'Yeast', taxId: 4932 },
79
- { label: 'Arabidopsis', taxId: 3702 },
80
- ] as const
81
-
82
- export const COMMON_TAX_RANK = new Map(
83
- COMMON_SPECIES.map((s, i) => [s.taxId as number, i]),
84
- )
49
+ // What limits the row count is the aligner, and it is linear in rows at roughly
50
+ // half a second each for a ~1400aa protein: 165 NLRP1 orthologs align at EBI in
51
+ // 88s, 865 CFTR orthologs in 407s. `defaultMaxSpecies` keeps a default run under
52
+ // a minute; EBI's own ceiling is 4000 sequences and 4MB, which even CFTR's full
53
+ // set (1.3MB) sits inside.
54
+ export const defaultMaxSpecies = 100
85
55
 
86
56
  export interface OrthologRow {
87
57
  taxId: number
@@ -148,8 +118,21 @@ interface OrthologReport {
148
118
  }[]
149
119
  }
150
120
 
151
- /** One ortholog gene per species, restricted to the requested taxa. */
152
- export async function fetchOrthologGenes(geneId: string, taxa: Set<number>) {
121
+ /**
122
+ * One ortholog gene per species, in NCBI's report order, capped at `limit`.
123
+ *
124
+ * `taxa` narrows the set when a caller wants specific species; omitted, every
125
+ * species NCBI has an ortholog for is a candidate. `exclude` drops the query
126
+ * taxon, which the QUERY row already represents.
127
+ */
128
+ export async function fetchOrthologGenes(
129
+ geneId: string,
130
+ {
131
+ taxa,
132
+ exclude,
133
+ limit = defaultMaxSpecies,
134
+ }: { taxa?: Set<number>; exclude?: number; limit?: number } = {},
135
+ ) {
153
136
  const json = await jsonfetch<OrthologReport>(
154
137
  ncbiUrl(
155
138
  `${DATASETS}/gene/id/${geneId}/orthologs?returned_content=COMPLETE`,
@@ -166,7 +149,12 @@ export async function fetchOrthologGenes(geneId: string, taxa: Set<number>) {
166
149
  >()
167
150
  for (const { gene } of json.reports ?? []) {
168
151
  const taxId = Number(gene?.tax_id)
169
- if (gene?.gene_id && taxa.has(taxId) && !byTaxon.has(taxId)) {
152
+ if (
153
+ gene?.gene_id &&
154
+ taxId !== exclude &&
155
+ (taxa?.has(taxId) ?? true) &&
156
+ !byTaxon.has(taxId)
157
+ ) {
170
158
  byTaxon.set(taxId, {
171
159
  taxId,
172
160
  geneId: gene.gene_id,
@@ -174,12 +162,11 @@ export async function fetchOrthologGenes(geneId: string, taxa: Set<number>) {
174
162
  commonName: gene.common_name,
175
163
  })
176
164
  }
165
+ if (byTaxon.size >= limit) {
166
+ break
167
+ }
177
168
  }
178
- return [...byTaxon.values()].sort(
179
- (a, b) =>
180
- (COMMON_TAX_RANK.get(a.taxId) ?? Infinity) -
181
- (COMMON_TAX_RANK.get(b.taxId) ?? Infinity),
182
- )
169
+ return [...byTaxon.values()]
183
170
  }
184
171
 
185
172
  interface ProductReport {
@@ -194,6 +181,21 @@ interface ProductReport {
194
181
  }[]
195
182
  }
196
183
 
184
+ // Two ceilings sit between a gene id list and its product report, and both fail
185
+ // by returning less rather than by erroring, so a caller that ignores them just
186
+ // draws a thinner alignment.
187
+ //
188
+ // The ids go in the URL PATH, and NCBI answers HTTP 414 above roughly 8KB of
189
+ // them -- CFTR's 865 orthologs join to 8609 characters and 414 on the nose.
190
+ // `PRODUCT_REPORT_CHUNK` keeps a request well inside that.
191
+ //
192
+ // Then the endpoint paginates at 20 with the count in `total_count` and the rest
193
+ // behind `next_page_token`, which is invisible to a caller reading `reports`.
194
+ // `page_size` covers a chunk in one request. The old 23-species panel never
195
+ // reached this: the query taxon is excluded and NCBI has no ortholog for the
196
+ // four invertebrate entries, so its ceiling was 19.
197
+ const PRODUCT_REPORT_CHUNK = 150
198
+
197
199
  /**
198
200
  * geneId -> representative protein accession: MANE Select where flagged, else
199
201
  * the longest isoform. A stable, comparable choice across species — picking
@@ -201,9 +203,12 @@ interface ProductReport {
201
203
  */
202
204
  export async function fetchRepresentativeProteins(geneIds: string[]) {
203
205
  const byGene = new Map<string, string>()
204
- if (geneIds.length > 0) {
206
+ for (let i = 0; i < geneIds.length; i += PRODUCT_REPORT_CHUNK) {
207
+ const chunk = geneIds.slice(i, i + PRODUCT_REPORT_CHUNK)
205
208
  const json = await jsonfetch<ProductReport>(
206
- ncbiUrl(`${DATASETS}/gene/id/${geneIds.join(',')}/product_report`),
209
+ ncbiUrl(
210
+ `${DATASETS}/gene/id/${chunk.join(',')}/product_report?page_size=${chunk.length}`,
211
+ ),
207
212
  )
208
213
  for (const { product } of json.reports ?? []) {
209
214
  const candidates = (product?.transcripts ?? [])
@@ -297,17 +302,21 @@ export async function fetchProteinForGene(geneId: string) {
297
302
  export async function fetchOrthologRows({
298
303
  geneId,
299
304
  taxa,
305
+ exclude,
306
+ limit,
300
307
  onProgress,
301
308
  }: {
302
309
  geneId: string
303
- taxa: Set<number>
310
+ taxa?: Set<number>
311
+ exclude?: number
312
+ limit?: number
304
313
  onProgress: (arg: string) => void
305
314
  }): Promise<OrthologRow[]> {
306
315
  onProgress('Finding orthologs across species...')
307
- const genes = await fetchOrthologGenes(geneId, taxa)
316
+ const genes = await fetchOrthologGenes(geneId, { taxa, exclude, limit })
308
317
  if (genes.length < 2) {
309
318
  throw new Error(
310
- `Only ${genes.length} ortholog(s) found among the selected species — not enough to align`,
319
+ `Only ${genes.length} ortholog(s) found for this gene — not enough to align`,
311
320
  )
312
321
  }
313
322
 
@@ -326,9 +335,12 @@ export async function fetchOrthologRows({
326
335
  const accessions = withProtein.map(g => proteinByGene.get(g.geneId)!)
327
336
  const seqByAcc = parseFasta(
328
337
  await textfetch(
329
- ncbiUrl(
330
- `${EUTILS}/efetch.fcgi?db=protein&id=${accessions.join(',')}&rettype=fasta&retmode=text`,
331
- ),
338
+ ...efetchPost({
339
+ db: 'protein',
340
+ id: accessions.join(','),
341
+ rettype: 'fasta',
342
+ retmode: 'text',
343
+ }),
332
344
  ),
333
345
  )
334
346