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.
- package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastRIDPanel.d.ts → BlastQuery/BlastAutomaticPanel.d.ts} +2 -3
- package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastAutomaticPanel.js → BlastQuery/BlastAutomaticPanel.js} +6 -25
- package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastManualPanel.d.ts → BlastQuery/BlastManualPanel.d.ts} +2 -3
- package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastManualPanel.js → BlastQuery/BlastManualPanel.js} +7 -4
- package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastMethodSelector.d.ts → BlastQuery/BlastMethodSelector.d.ts} +2 -2
- package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastMethodSelector.js → BlastQuery/BlastMethodSelector.js} +1 -2
- package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastPanel.d.ts → BlastQuery/BlastPanel.d.ts} +3 -12
- package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastPanel.js → BlastQuery/BlastPanel.js} +14 -16
- package/dist/LaunchMsaView/components/BlastQuery/BlastSettingsDialog.d.ts +8 -0
- package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBISettingsDialog.js → BlastQuery/BlastSettingsDialog.js} +13 -9
- package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/CachedBlastResults.js +1 -1
- package/dist/LaunchMsaView/components/BlastQuery/consts.d.ts +12 -0
- package/dist/LaunchMsaView/components/BlastQuery/consts.js +17 -0
- package/dist/LaunchMsaView/components/LaunchMsaViewDialog.js +3 -3
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +15 -35
- package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.d.ts +15 -0
- package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.js +67 -0
- package/dist/LaunchMsaView/index.js +19 -1
- package/dist/MsaViewPanel/components/JobLink.d.ts +5 -0
- package/dist/MsaViewPanel/components/JobLink.js +13 -0
- package/dist/MsaViewPanel/components/LoadingBLAST.d.ts +1 -2
- package/dist/MsaViewPanel/components/LoadingBLAST.js +8 -15
- package/dist/MsaViewPanel/components/MsaViewPanel.js +1 -1
- package/dist/MsaViewPanel/doLaunchBlast.js +11 -19
- package/dist/MsaViewPanel/doLaunchOrthologs.js +43 -12
- package/dist/MsaViewPanel/doLaunchOrthologs.test.d.ts +1 -0
- package/dist/MsaViewPanel/doLaunchOrthologs.test.js +201 -0
- package/dist/MsaViewPanel/model.d.ts +20 -12
- package/dist/jbrowse-plugin-msaview.umd.production.min.js +31 -33
- package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
- package/dist/utils/blastCache.d.ts +8 -4
- package/dist/utils/blastCache.js +4 -5
- package/dist/utils/ebiBlast.d.ts +52 -0
- package/dist/utils/ebiBlast.js +63 -0
- package/dist/utils/ebiJobDispatcher.d.ts +33 -0
- package/dist/utils/ebiJobDispatcher.js +80 -0
- package/dist/utils/ebiJobDispatcher.test.d.ts +1 -0
- package/dist/utils/ebiJobDispatcher.test.js +46 -0
- package/dist/utils/eutils.d.ts +9 -0
- package/dist/utils/eutils.js +18 -0
- package/dist/utils/fetch.js +24 -1
- package/dist/utils/msa.d.ts +1 -1
- package/dist/utils/msa.js +25 -32
- package/dist/utils/ncbiOrthologs.d.ts +17 -75
- package/dist/utils/ncbiOrthologs.js +67 -63
- package/dist/utils/ncbiOrthologs.test.js +82 -2
- package/dist/utils/ncbiTaxonomy.d.ts +11 -0
- package/dist/utils/ncbiTaxonomy.js +33 -0
- package/dist/utils/types.d.ts +9 -14
- package/dist/utils/useLocalStorage.d.ts +1 -0
- package/dist/utils/useLocalStorage.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/src/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastAutomaticPanel.tsx → BlastQuery/BlastAutomaticPanel.tsx} +13 -59
- package/src/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastManualPanel.tsx → BlastQuery/BlastManualPanel.tsx} +7 -6
- package/src/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastMethodSelector.tsx → BlastQuery/BlastMethodSelector.tsx} +2 -7
- package/src/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastPanel.tsx → BlastQuery/BlastPanel.tsx} +21 -25
- package/src/LaunchMsaView/components/{NCBIBlastQuery/NCBISettingsDialog.tsx → BlastQuery/BlastSettingsDialog.tsx} +26 -13
- package/src/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/CachedBlastResults.tsx +1 -1
- package/src/LaunchMsaView/components/BlastQuery/consts.ts +22 -0
- package/src/LaunchMsaView/components/LaunchMsaViewDialog.tsx +5 -7
- package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +35 -73
- package/src/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.tsx +92 -0
- package/src/LaunchMsaView/index.ts +20 -2
- package/src/MsaViewPanel/components/JobLink.tsx +17 -0
- package/src/MsaViewPanel/components/LoadingBLAST.tsx +10 -41
- package/src/MsaViewPanel/components/MsaViewPanel.tsx +1 -1
- package/src/MsaViewPanel/doLaunchBlast.ts +12 -28
- package/src/MsaViewPanel/doLaunchOrthologs.test.ts +247 -0
- package/src/MsaViewPanel/doLaunchOrthologs.ts +56 -13
- package/src/MsaViewPanel/model.ts +10 -7
- package/src/utils/blastCache.ts +8 -9
- package/src/utils/ebiBlast.ts +114 -0
- package/src/utils/ebiJobDispatcher.test.ts +54 -0
- package/src/utils/ebiJobDispatcher.ts +120 -0
- package/src/utils/eutils.ts +19 -0
- package/src/utils/fetch.ts +26 -1
- package/src/utils/msa.ts +26 -47
- package/src/utils/ncbiOrthologs.test.ts +96 -2
- package/src/utils/ncbiOrthologs.ts +83 -71
- package/src/utils/ncbiTaxonomy.ts +37 -0
- package/src/utils/types.ts +8 -13
- package/src/utils/useLocalStorage.ts +1 -1
- package/src/version.ts +1 -1
- package/dist/LaunchMsaView/components/NCBIBlastQuery/NCBIBlastAutomaticPanel.d.ts +0 -10
- package/dist/LaunchMsaView/components/NCBIBlastQuery/NCBIBlastRIDPanel.js +0 -74
- package/dist/LaunchMsaView/components/NCBIBlastQuery/NCBISettingsDialog.d.ts +0 -5
- package/dist/LaunchMsaView/components/NCBIBlastQuery/consts.d.ts +0 -7
- package/dist/LaunchMsaView/components/NCBIBlastQuery/consts.js +0 -4
- package/dist/MsaViewPanel/components/RIDLink.d.ts +0 -6
- package/dist/MsaViewPanel/components/RIDLink.js +0 -12
- package/dist/utils/ncbiBlast.d.ts +0 -30
- package/dist/utils/ncbiBlast.js +0 -84
- package/src/LaunchMsaView/components/NCBIBlastQuery/NCBIBlastRIDPanel.tsx +0 -137
- package/src/LaunchMsaView/components/NCBIBlastQuery/consts.ts +0 -10
- package/src/MsaViewPanel/components/RIDLink.tsx +0 -19
- package/src/utils/ncbiBlast.ts +0 -143
- /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/CachedBlastResults.d.ts +0 -0
- /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/MsaAlgorithmSelect.d.ts +0 -0
- /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/MsaAlgorithmSelect.js +0 -0
- /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/blastLaunchView.d.ts +0 -0
- /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/blastLaunchView.js +0 -0
- /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/useCachedBlastResults.d.ts +0 -0
- /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/useCachedBlastResults.js +0 -0
- /package/src/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/MsaAlgorithmSelect.tsx +0 -0
- /package/src/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/blastLaunchView.ts +0 -0
- /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
|
+
}
|
package/src/utils/eutils.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/fetch.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
2
|
-
import { pollLoop } from './poll'
|
|
1
|
+
import { fetchEbiResult, submitEbiJob, waitForEbiJob } from './ebiJobDispatcher'
|
|
3
2
|
|
|
4
|
-
import type { MsaAlgorithm } from '../LaunchMsaView/components/
|
|
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: {
|
|
14
|
+
params: {},
|
|
19
15
|
msaResult: 'aln-clustal_num',
|
|
20
16
|
treeResult: 'phylotree',
|
|
21
17
|
},
|
|
22
18
|
muscle: {
|
|
23
|
-
params: {
|
|
19
|
+
params: { format: 'clw', tree: 'tree1' },
|
|
24
20
|
msaResult: 'fa',
|
|
25
21
|
treeResult: 'phylotree',
|
|
26
22
|
},
|
|
27
23
|
kalign: {
|
|
28
|
-
params: {
|
|
24
|
+
params: { stype: 'protein' },
|
|
29
25
|
msaResult: 'fa',
|
|
30
26
|
treeResult: 'phylotree',
|
|
31
27
|
},
|
|
32
28
|
mafft: {
|
|
33
|
-
params: {
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 {
|
|
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
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
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
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
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
|
-
//
|
|
49
|
-
//
|
|
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
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
/**
|
|
152
|
-
|
|
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 (
|
|
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()]
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
338
|
+
...efetchPost({
|
|
339
|
+
db: 'protein',
|
|
340
|
+
id: accessions.join(','),
|
|
341
|
+
rettype: 'fasta',
|
|
342
|
+
retmode: 'text',
|
|
343
|
+
}),
|
|
332
344
|
),
|
|
333
345
|
)
|
|
334
346
|
|