jbrowse-plugin-msaview 2.4.5 → 2.5.1

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 (47) hide show
  1. package/dist/LaunchMsaView/util.js +1 -1
  2. package/dist/LaunchMsaViewExtensionPoint/index.js +2 -1
  3. package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +15 -0
  4. package/dist/MsaViewPanel/afterCreateAutoruns.js +53 -0
  5. package/dist/MsaViewPanel/loadProteinDomains.d.ts +16 -0
  6. package/dist/MsaViewPanel/loadProteinDomains.js +33 -0
  7. package/dist/MsaViewPanel/model.d.ts +69 -399
  8. package/dist/MsaViewPanel/model.js +17 -39
  9. package/dist/MsaViewPanel/pairwiseAlignment.js +2 -9
  10. package/dist/MsaViewPanel/structureConnection.d.ts +0 -6
  11. package/dist/MsaViewPanel/structureConnection.js +0 -16
  12. package/dist/MsaViewPanel/structureConnection.test.js +1 -51
  13. package/dist/MsaViewPanel/syncGenomeHoverToMsaColumn.test.d.ts +1 -0
  14. package/dist/MsaViewPanel/syncGenomeHoverToMsaColumn.test.js +92 -0
  15. package/dist/jbrowse-plugin-msaview.umd.production.min.js +30 -30
  16. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  17. package/dist/utils/domainCache.d.ts +8 -0
  18. package/dist/utils/domainCache.js +28 -0
  19. package/dist/utils/eutils.d.ts +3 -0
  20. package/dist/utils/eutils.js +14 -0
  21. package/dist/utils/msa.js +23 -19
  22. package/dist/utils/ncbiBlast.js +23 -25
  23. package/dist/utils/ncbiDomains.d.ts +39 -0
  24. package/dist/utils/ncbiDomains.js +154 -0
  25. package/dist/utils/poll.d.ts +11 -0
  26. package/dist/utils/poll.js +19 -0
  27. package/dist/utils/taxonomyNames.js +2 -1
  28. package/dist/version.d.ts +1 -1
  29. package/dist/version.js +1 -1
  30. package/package.json +17 -14
  31. package/src/LaunchMsaView/util.ts +1 -3
  32. package/src/LaunchMsaViewExtensionPoint/index.ts +3 -0
  33. package/src/MsaViewPanel/afterCreateAutoruns.ts +54 -0
  34. package/src/MsaViewPanel/loadProteinDomains.ts +57 -0
  35. package/src/MsaViewPanel/model.ts +23 -45
  36. package/src/MsaViewPanel/pairwiseAlignment.ts +2 -7
  37. package/src/MsaViewPanel/structureConnection.test.ts +1 -61
  38. package/src/MsaViewPanel/structureConnection.ts +0 -22
  39. package/src/MsaViewPanel/syncGenomeHoverToMsaColumn.test.ts +112 -0
  40. package/src/utils/domainCache.ts +44 -0
  41. package/src/utils/eutils.ts +16 -0
  42. package/src/utils/msa.ts +23 -19
  43. package/src/utils/ncbiBlast.ts +28 -33
  44. package/src/utils/ncbiDomains.ts +171 -0
  45. package/src/utils/poll.ts +28 -0
  46. package/src/utils/taxonomyNames.ts +3 -1
  47. package/src/version.ts +1 -1
@@ -0,0 +1,57 @@
1
+ import { fetchProteinDomains } from '../utils/ncbiDomains'
2
+
3
+ import type { InterProScanResults } from 'react-msaview'
4
+
5
+ // structural subset of the MSA model: the full model type can't be used here
6
+ // because it references this very action, creating a self-referential cycle
7
+ interface DomainModel {
8
+ data: { treeMetadata?: string }
9
+ setProgress: (arg: string) => void
10
+ setDomains: (data: Record<string, InterProScanResults>) => void
11
+ }
12
+
13
+ /**
14
+ * Overlay protein domains on the alignment using NCBI's pre-computed CDD
15
+ * annotations. The BLAST workflow stores each hit's accession in treeMetadata,
16
+ * so we look those up via efetch and key the results by MSA row name (which is
17
+ * what react-msaview matches domains against).
18
+ */
19
+ export async function loadProteinDomains(self: DomainModel) {
20
+ const metadataJson = self.data.treeMetadata
21
+ if (!metadataJson) {
22
+ throw new Error('No sequence metadata available to look up domains')
23
+ }
24
+ const metadata = JSON.parse(metadataJson) as Record<
25
+ string,
26
+ Record<string, string>
27
+ >
28
+
29
+ const rowAccessions = Object.entries(metadata)
30
+ .map(([rowName, meta]) => ({ rowName, accession: meta.Accession }))
31
+ .filter((r): r is { rowName: string; accession: string } => !!r.accession)
32
+
33
+ if (rowAccessions.length === 0) {
34
+ throw new Error('No NCBI accessions found in alignment rows')
35
+ }
36
+
37
+ self.setProgress(
38
+ `Fetching protein domains from NCBI for ${rowAccessions.length} sequences...`,
39
+ )
40
+ const byAccession = await fetchProteinDomains(
41
+ rowAccessions.map(r => r.accession),
42
+ )
43
+
44
+ const annotations: Record<string, InterProScanResults> = {}
45
+ for (const { rowName, accession } of rowAccessions) {
46
+ const matches = byAccession.get(accession)
47
+ if (matches && matches.length > 0) {
48
+ annotations[rowName] = { matches, xref: [{ id: rowName }] }
49
+ }
50
+ }
51
+
52
+ if (Object.keys(annotations).length === 0) {
53
+ throw new Error('No CDD domain annotations found for these proteins')
54
+ }
55
+
56
+ self.setDomains(annotations)
57
+ }
@@ -7,8 +7,13 @@ import { genomeToTranscriptSeqMapping } from 'g2p_mapper'
7
7
  import { autorun } from 'mobx'
8
8
  import { MSAModelF } from 'react-msaview'
9
9
 
10
+ // re-exported so the inferred (composed) state-model type can name MSAFormat
11
+ // from msa-parsers when emitting declarations (avoids TS2883 portability error)
12
+ export type { MSAFormat } from 'msa-parsers'
13
+
10
14
  import {
11
15
  autoConnectStructures,
16
+ autoLoadProteinDomains,
12
17
  highlightConnectedStructures,
13
18
  launchBlastIfNeeded,
14
19
  loadStoredData,
@@ -16,14 +21,11 @@ import {
16
21
  processInit,
17
22
  runCleanup,
18
23
  storeDataToIndexedDB,
24
+ syncGenomeHoverToMsaColumn,
19
25
  } from './afterCreateAutoruns'
20
- import { genomeToMSA } from './genomeToMSA'
21
26
  import { msaCoordToGenomeCoord } from './msaCoordToGenomeCoord'
22
27
  import { buildAlignmentMaps, runPairwiseAlignment } from './pairwiseAlignment'
23
- import {
24
- getProteinViews,
25
- ungappedToGappedPosition,
26
- } from './structureConnection'
28
+ import { getProteinViews } from './structureConnection'
27
29
  import { getCanonicalRefName } from './util'
28
30
 
29
31
  import type { ProteinView, StructureConnection } from './structureConnection'
@@ -80,9 +82,6 @@ export default function stateModelFactory() {
80
82
  * #property
81
83
  */
82
84
  connectedFeature: types.frozen(),
83
- /**
84
- * #property
85
- */
86
85
  /**
87
86
  * #property
88
87
  */
@@ -131,6 +130,7 @@ export default function stateModelFactory() {
131
130
  error: unknown
132
131
  loadingStoredData: boolean
133
132
  isStoringData: boolean
133
+ domainsRequested: boolean
134
134
  } => ({
135
135
  /**
136
136
  * #volatile
@@ -152,6 +152,12 @@ export default function stateModelFactory() {
152
152
  * #volatile
153
153
  */
154
154
  isStoringData: false,
155
+ /**
156
+ * #volatile
157
+ * guards the one-shot auto-fetch of protein domains so it doesn't refire
158
+ * when NCBI returns no domains (leaving interProAnnotations undefined)
159
+ */
160
+ domainsRequested: false,
155
161
  }),
156
162
  )
157
163
 
@@ -209,41 +215,6 @@ export default function stateModelFactory() {
209
215
  }))
210
216
 
211
217
  .views(self => ({
212
- /**
213
- * #getter
214
- */
215
- get structureHoverCol(): number | undefined {
216
- for (const conn of self.connectedProteinViews) {
217
- const structure = conn.proteinView.structures[conn.structureIdx]
218
- const structurePos = structure?.hoverPosition?.structureSeqPos
219
- if (structurePos !== undefined) {
220
- const msaUngapped = conn.structureToMsa[structurePos]
221
- if (msaUngapped !== undefined) {
222
- const seq = self.getSequenceByRowName(conn.msaRowName)
223
- if (seq) {
224
- const globalCol = ungappedToGappedPosition(seq, msaUngapped)
225
- if (globalCol !== undefined) {
226
- return self.globalColToVisibleCol(globalCol)
227
- }
228
- }
229
- }
230
- }
231
- }
232
- return undefined
233
- },
234
- }))
235
-
236
- .views(self => ({
237
- /**
238
- * #getter
239
- */
240
- get mouseCol2(): number | undefined {
241
- return (
242
- self.structureHoverCol ??
243
- genomeToMSA({ model: self as JBrowsePluginMsaViewModel })
244
- )
245
- },
246
-
247
218
  /**
248
219
  * #getter
249
220
  */
@@ -333,6 +304,12 @@ export default function stateModelFactory() {
333
304
  setIsStoringData(arg: boolean) {
334
305
  self.isStoringData = arg
335
306
  },
307
+ /**
308
+ * #action
309
+ */
310
+ setDomainsRequested(arg: boolean) {
311
+ self.domainsRequested = arg
312
+ },
336
313
  /**
337
314
  * #action
338
315
  */
@@ -394,14 +371,13 @@ export default function stateModelFactory() {
394
371
  ungappedMsaSequence,
395
372
  structureSequence,
396
373
  )
397
- const { seq1ToSeq2, seq2ToSeq1 } = buildAlignmentMaps(alignment)
374
+ const { seq1ToSeq2 } = buildAlignmentMaps(alignment)
398
375
 
399
376
  const connection: StructureConnection = {
400
377
  proteinViewId,
401
378
  structureIdx,
402
379
  msaRowName: rowName,
403
380
  msaToStructure: Object.fromEntries(seq1ToSeq2),
404
- structureToMsa: Object.fromEntries(seq2ToSeq1),
405
381
  }
406
382
 
407
383
  self.connectedStructures.push(connection)
@@ -494,6 +470,7 @@ export default function stateModelFactory() {
494
470
  processInit,
495
471
  highlightConnectedStructures,
496
472
  autoConnectStructures,
473
+ autoLoadProteinDomains,
497
474
  observeProteinHighlights,
498
475
  ]) {
499
476
  addDisposer(
@@ -503,6 +480,7 @@ export default function stateModelFactory() {
503
480
  }),
504
481
  )
505
482
  }
483
+ addDisposer(self, autorun(syncGenomeHoverToMsaColumn(self)))
506
484
  },
507
485
  }))
508
486
  }
@@ -128,13 +128,8 @@ function buildConsensus(alignedSeq1: string, alignedSeq2: string) {
128
128
  for (let i = 0; i < alignedSeq1.length; i++) {
129
129
  const a = alignedSeq1[i]!
130
130
  const b = alignedSeq2[i]!
131
- if (a === '-' || b === '-') {
132
- consensus += ' '
133
- } else if (a.toUpperCase() === b.toUpperCase()) {
134
- consensus += '|'
135
- } else {
136
- consensus += ' '
137
- }
131
+ const match = a !== '-' && b !== '-' && a.toUpperCase() === b.toUpperCase()
132
+ consensus += match ? '|' : ' '
138
133
  }
139
134
  return consensus
140
135
  }
@@ -1,9 +1,6 @@
1
1
  import { describe, expect, test } from 'vitest'
2
2
 
3
- import {
4
- gappedToUngappedPosition,
5
- ungappedToGappedPosition,
6
- } from './structureConnection'
3
+ import { gappedToUngappedPosition } from './structureConnection'
7
4
 
8
5
  describe('gappedToUngappedPosition', () => {
9
6
  test('returns correct ungapped position for non-gap character', () => {
@@ -63,60 +60,3 @@ describe('gappedToUngappedPosition', () => {
63
60
  expect(gappedToUngappedPosition(seq, 2)).toBeUndefined()
64
61
  })
65
62
  })
66
-
67
- describe('ungappedToGappedPosition', () => {
68
- test('returns correct gapped position', () => {
69
- const seq = 'M-KA-A'
70
- // 0 12 34 (gapped)
71
- // 0 1 23 (ungapped)
72
- expect(ungappedToGappedPosition(seq, 0)).toBe(0) // M
73
- expect(ungappedToGappedPosition(seq, 1)).toBe(2) // K
74
- expect(ungappedToGappedPosition(seq, 2)).toBe(3) // A
75
- expect(ungappedToGappedPosition(seq, 3)).toBe(5) // A
76
- })
77
-
78
- test('returns undefined for out-of-bounds ungapped position', () => {
79
- const seq = 'M-KA'
80
- expect(ungappedToGappedPosition(seq, 4)).toBeUndefined()
81
- expect(ungappedToGappedPosition(seq, 100)).toBeUndefined()
82
- })
83
-
84
- test('handles sequence with no gaps', () => {
85
- const seq = 'MKAA'
86
- expect(ungappedToGappedPosition(seq, 0)).toBe(0)
87
- expect(ungappedToGappedPosition(seq, 1)).toBe(1)
88
- expect(ungappedToGappedPosition(seq, 2)).toBe(2)
89
- expect(ungappedToGappedPosition(seq, 3)).toBe(3)
90
- })
91
-
92
- test('handles sequence with leading gaps', () => {
93
- const seq = '--MKA'
94
- expect(ungappedToGappedPosition(seq, 0)).toBe(2) // M
95
- expect(ungappedToGappedPosition(seq, 1)).toBe(3) // K
96
- expect(ungappedToGappedPosition(seq, 2)).toBe(4) // A
97
- })
98
-
99
- test('handles empty sequence', () => {
100
- expect(ungappedToGappedPosition('', 0)).toBeUndefined()
101
- })
102
-
103
- test('handles all-gap sequence', () => {
104
- const seq = '---'
105
- expect(ungappedToGappedPosition(seq, 0)).toBeUndefined()
106
- })
107
- })
108
-
109
- describe('gappedToUngappedPosition and ungappedToGappedPosition are inverses', () => {
110
- test('round-trip conversion works', () => {
111
- const seq = 'M-KA--YL-S'
112
- // For each non-gap position, converting to ungapped and back should return original
113
- for (let i = 0; i < seq.length; i++) {
114
- if (seq[i] !== '-') {
115
- const ungapped = gappedToUngappedPosition(seq, i)
116
- expect(ungapped).toBeDefined()
117
- const backToGapped = ungappedToGappedPosition(seq, ungapped!)
118
- expect(backToGapped).toBe(i)
119
- }
120
- }
121
- })
122
- })
@@ -40,8 +40,6 @@ export interface StructureConnection {
40
40
  msaRowName: string
41
41
  /** Map from MSA ungapped position to structure sequence position */
42
42
  msaToStructure: Record<number, number>
43
- /** Map from structure sequence position to MSA ungapped position */
44
- structureToMsa: Record<number, number>
45
43
  }
46
44
 
47
45
  /**
@@ -69,23 +67,3 @@ export function gappedToUngappedPosition(
69
67
 
70
68
  return ungapped
71
69
  }
72
-
73
- /**
74
- * Helper to convert ungapped position to gapped MSA column for a specific row
75
- */
76
- export function ungappedToGappedPosition(
77
- sequence: string,
78
- ungappedPosition: number,
79
- ): number | undefined {
80
- let ungapped = 0
81
- for (let i = 0; i < sequence.length; i++) {
82
- const element = sequence[i]
83
- if (element !== '-') {
84
- if (ungapped === ungappedPosition) {
85
- return i
86
- }
87
- ungapped++
88
- }
89
- }
90
- return undefined
91
- }
@@ -0,0 +1,112 @@
1
+ import { getSession } from '@jbrowse/core/util'
2
+ import { beforeEach, describe, expect, test, vi } from 'vitest'
3
+
4
+ import { syncGenomeHoverToMsaColumn } from './afterCreateAutoruns'
5
+
6
+ import type { JBrowsePluginMsaViewModel } from './model'
7
+
8
+ // Mock only getSession; keep the rest of the util module real so the
9
+ // afterCreateAutoruns import graph still loads.
10
+ vi.mock('@jbrowse/core/util', async importOriginal => ({
11
+ ...(await importOriginal<Record<string, unknown>>()),
12
+ getSession: vi.fn(),
13
+ }))
14
+
15
+ const mockGetSession = vi.mocked(getSession)
16
+
17
+ const mafRegion = {
18
+ refName: 'chr1',
19
+ start: 1000,
20
+ end: 1010,
21
+ assemblyName: 'hg38',
22
+ }
23
+
24
+ // A model wired through the real genomeToMSA path: a connected genome view
25
+ // over a maf region, with seqPosToVisibleCol as identity so the asserted
26
+ // column equals the ungapped offset into the region.
27
+ function makeModel() {
28
+ const calls: (number | undefined)[] = []
29
+ const model = {
30
+ querySeqName: 'hg38.chr1',
31
+ transcriptToMsaMap: undefined,
32
+ mafRegion,
33
+ connectedView: { initialized: true, assemblyNames: ['hg38'] },
34
+ seqPosToVisibleCol: (_name: string, pos: number) => pos,
35
+ setMousePos: (col?: number) => {
36
+ calls.push(col)
37
+ },
38
+ } as unknown as JBrowsePluginMsaViewModel
39
+ return { model, calls }
40
+ }
41
+
42
+ function hoverGenome(coord: number) {
43
+ mockGetSession.mockReturnValue({
44
+ hovered: { hoverFeature: {}, hoverPosition: { coord, refName: 'chr1' } },
45
+ } as unknown as ReturnType<typeof getSession>)
46
+ }
47
+
48
+ function clearGenomeHover() {
49
+ mockGetSession.mockReturnValue({
50
+ hovered: null,
51
+ } as unknown as ReturnType<typeof getSession>)
52
+ }
53
+
54
+ describe('syncGenomeHoverToMsaColumn (real genomeToMSA mapping)', () => {
55
+ beforeEach(() => {
56
+ vi.clearAllMocks()
57
+ })
58
+
59
+ test('genome hover at coord 1005 highlights MSA column 5', () => {
60
+ const { model, calls } = makeModel()
61
+ const run = syncGenomeHoverToMsaColumn(model)
62
+
63
+ hoverGenome(1005) // 1005 - mafRegion.start(1000) = ungapped 5
64
+ run()
65
+ expect(calls).toEqual([5])
66
+ })
67
+
68
+ test('moving the genome hover moves the highlighted column', () => {
69
+ const { model, calls } = makeModel()
70
+ const run = syncGenomeHoverToMsaColumn(model)
71
+
72
+ hoverGenome(1002)
73
+ run()
74
+ hoverGenome(1007)
75
+ run()
76
+ expect(calls).toEqual([2, 7])
77
+ })
78
+
79
+ test('leaving the genome clears the column it set', () => {
80
+ const { model, calls } = makeModel()
81
+ const run = syncGenomeHoverToMsaColumn(model)
82
+
83
+ hoverGenome(1004)
84
+ run()
85
+ clearGenomeHover()
86
+ run()
87
+ expect(calls).toEqual([4, undefined])
88
+ })
89
+
90
+ test('a hover outside the maf region clears a previously-set column once', () => {
91
+ const { model, calls } = makeModel()
92
+ const run = syncGenomeHoverToMsaColumn(model)
93
+
94
+ hoverGenome(1004)
95
+ run()
96
+ hoverGenome(5000) // outside [1000,1010) -> genomeToMSA returns undefined
97
+ run()
98
+ run()
99
+ expect(calls).toEqual([4, undefined])
100
+ })
101
+
102
+ test('never touches mouseCol when the genome never provides a column, so a direct MSA hover survives unrelated session hovers', () => {
103
+ const { model, calls } = makeModel()
104
+ const run = syncGenomeHoverToMsaColumn(model)
105
+
106
+ clearGenomeHover()
107
+ run()
108
+ hoverGenome(9999) // unrelated/out-of-range hover elsewhere
109
+ run()
110
+ expect(calls).toEqual([])
111
+ })
112
+ })
@@ -0,0 +1,44 @@
1
+ import { openDB } from 'idb'
2
+
3
+ import type { DomainMatch } from './ncbiDomains'
4
+
5
+ const DB_NAME = 'jbrowse-msaview-domain-cache'
6
+ const STORE_NAME = 'domains'
7
+ const DB_VERSION = 1
8
+
9
+ interface CachedDomain {
10
+ accession: string
11
+ matches: DomainMatch[]
12
+ }
13
+
14
+ async function getDB() {
15
+ return openDB(DB_NAME, DB_VERSION, {
16
+ upgrade(db) {
17
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
18
+ db.createObjectStore(STORE_NAME, { keyPath: 'accession' })
19
+ }
20
+ },
21
+ })
22
+ }
23
+
24
+ export async function getCachedDomains(accessions: string[]) {
25
+ const db = await getDB()
26
+ const tx = db.transaction(STORE_NAME, 'readonly')
27
+ const results = await Promise.all(
28
+ accessions.map(
29
+ accession =>
30
+ tx.store.get(accession) as Promise<CachedDomain | undefined>,
31
+ ),
32
+ )
33
+ await tx.done
34
+ return results
35
+ }
36
+
37
+ export async function saveDomains(entries: CachedDomain[]) {
38
+ const db = await getDB()
39
+ const tx = db.transaction(STORE_NAME, 'readwrite')
40
+ for (const entry of entries) {
41
+ await tx.store.put(entry)
42
+ }
43
+ await tx.done
44
+ }
@@ -0,0 +1,16 @@
1
+ // NCBI asks that programmatic E-utilities requests identify themselves with a
2
+ // tool name and contact email so they can reach out before throttling, rather
3
+ // than silently rate-limiting. https://www.ncbi.nlm.nih.gov/books/NBK25497/
4
+ export const NCBI_TOOL = 'jbrowse-plugin-msaview'
5
+ export const NCBI_EMAIL = 'colin.diesh@gmail.com'
6
+
7
+ const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils'
8
+
9
+ export function efetchUrl(params: Record<string, string>) {
10
+ const search = new URLSearchParams({
11
+ ...params,
12
+ tool: NCBI_TOOL,
13
+ email: NCBI_EMAIL,
14
+ })
15
+ return `${EUTILS}/efetch.fcgi?${search.toString()}`
16
+ }
package/src/utils/msa.ts CHANGED
@@ -1,8 +1,10 @@
1
- import { textfetch, timeout } from './fetch'
1
+ import { textfetch } from './fetch'
2
+ import { pollLoop } from './poll'
2
3
 
3
4
  import type { MsaAlgorithm } from '../LaunchMsaView/components/NCBIBlastQuery/consts'
4
5
 
5
6
  const base = `https://www.ebi.ac.uk/Tools/services/rest`
7
+ const email = 'colin.diesh@gmail.com'
6
8
 
7
9
  const algorithms: Record<
8
10
  MsaAlgorithm,
@@ -13,22 +15,22 @@ const algorithms: Record<
13
15
  }
14
16
  > = {
15
17
  clustalo: {
16
- params: { email: 'colin.diesh@gmail.com' },
18
+ params: { email },
17
19
  msaResult: 'aln-clustal_num',
18
20
  treeResult: 'phylotree',
19
21
  },
20
22
  muscle: {
21
- params: { email: 'colin.diesh@gmail.com', format: 'clw', tree: 'tree1' },
23
+ params: { email, format: 'clw', tree: 'tree1' },
22
24
  msaResult: 'fa',
23
25
  treeResult: 'phylotree',
24
26
  },
25
27
  kalign: {
26
- params: { email: 'colin.diesh@gmail.com', stype: 'protein' },
28
+ params: { email, stype: 'protein' },
27
29
  msaResult: 'fa',
28
30
  treeResult: 'phylotree',
29
31
  },
30
32
  mafft: {
31
- params: { email: 'colin.diesh@gmail.com', stype: 'protein' },
33
+ params: { email, stype: 'protein' },
32
34
  msaResult: 'fa',
33
35
  treeResult: 'phylotree',
34
36
  },
@@ -43,20 +45,22 @@ async function wait({
43
45
  algorithm: MsaAlgorithm
44
46
  onProgress: (arg: string) => void
45
47
  }) {
46
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
47
- while (true) {
48
- for (let i = 0; i < 10; i++) {
49
- await timeout(1000)
50
- onProgress(`Re-checking MSA status in... ${10 - i}`)
51
- }
52
- const result = await textfetch(`${base}/${algorithm}/status/${jobId}`)
53
-
54
- if (result === 'FINISHED') {
55
- break
56
- } else if (result.includes('FAILURE')) {
57
- throw new Error(`Failed to run: jobId ${jobId}`)
58
- }
59
- }
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
+ })
60
64
  }
61
65
 
62
66
  export async function launchMSA({
@@ -1,4 +1,5 @@
1
- import { jsonfetch, textfetch, timeout } from './fetch'
1
+ import { jsonfetch, textfetch } from './fetch'
2
+ import { pollLoop } from './poll'
2
3
 
3
4
  import type { BlastResults } from './types'
4
5
  import type {
@@ -110,39 +111,33 @@ async function waitForRid({
110
111
  onProgress: (arg: string) => void
111
112
  baseUrl: string
112
113
  }) {
113
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
114
- while (true) {
115
- const iter = 20
116
- for (let i = 0; i < iter; i++) {
117
- await timeout(1000)
118
- onProgress(`Re-checking BLAST status in... ${iter - i}`)
119
- }
114
+ await pollLoop({
115
+ intervalSeconds: 20,
116
+ onCountdown: s => {
117
+ onProgress(`Re-checking BLAST status in... ${s}`)
118
+ },
119
+ check: async () => {
120
+ const res = await textfetch(
121
+ `${baseUrl}?CMD=Get&FORMAT_OBJECT=SearchInfo&RID=${rid}`,
122
+ )
123
+ const status = /\s+Status=(\S+)/m.exec(res)?.[1]
124
+ const hasHits = /\s+ThereAreHits=yes/m.test(res)
120
125
 
121
- const res = await textfetch(
122
- `${baseUrl}?CMD=Get&FORMAT_OBJECT=SearchInfo&RID=${rid}`,
123
- )
124
- const statusMatch = /\s+Status=(\S+)/m.exec(res)
125
- const status = statusMatch?.[1]
126
- const hasHits = /\s+ThereAreHits=yes/m.test(res)
127
-
128
- if (status === 'WAITING') {
129
- continue
130
- }
131
-
132
- if (status === 'FAILED') {
133
- throw new Error(`BLAST ${rid} failed`)
134
- }
135
-
136
- if (status === 'READY') {
137
- if (hasHits) {
138
- return true
139
- } else {
126
+ if (status === 'WAITING') {
127
+ return false
128
+ }
129
+ if (status === 'FAILED') {
130
+ throw new Error(`BLAST ${rid} failed`)
131
+ }
132
+ if (status === 'READY') {
133
+ if (hasHits) {
134
+ return true
135
+ }
140
136
  throw new Error('No hits found')
141
137
  }
142
- }
143
-
144
- throw new Error(
145
- `BLAST ${rid} returned unexpected status: ${status ?? 'unknown'}`,
146
- )
147
- }
138
+ throw new Error(
139
+ `BLAST ${rid} returned unexpected status: ${status ?? 'unknown'}`,
140
+ )
141
+ },
142
+ })
148
143
  }