jbrowse-plugin-msaview 3.4.0 → 3.4.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.
- package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +4 -6
- package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.d.ts +7 -0
- package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.js +42 -0
- package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.d.ts +1 -0
- package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.js +32 -0
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +2 -1
- package/dist/LaunchMsaViewExtensionPoint/index.js +11 -6
- package/dist/LaunchMsaViewExtensionPoint/index.test.d.ts +1 -0
- package/dist/LaunchMsaViewExtensionPoint/index.test.js +43 -0
- package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +15 -0
- package/dist/MsaViewPanel/afterCreateAutoruns.js +93 -70
- package/dist/MsaViewPanel/components/LaunchProgress.js +11 -2
- package/dist/MsaViewPanel/components/MsaViewPanel.test.js +19 -0
- package/dist/MsaViewPanel/doLaunchBlast.d.ts +3 -1
- package/dist/MsaViewPanel/doLaunchBlast.js +10 -12
- package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +3 -1
- package/dist/MsaViewPanel/doLaunchOrthologs.js +6 -5
- package/dist/MsaViewPanel/doLaunchOrthologs.test.js +38 -24
- package/dist/MsaViewPanel/model.d.ts +160 -10
- package/dist/MsaViewPanel/model.js +51 -1
- package/dist/MsaViewPanel/msaDataStore.d.ts +5 -3
- package/dist/MsaViewPanel/msaDataStore.js +16 -7
- package/dist/MsaViewPanel/msaDataStore.test.d.ts +1 -0
- package/dist/MsaViewPanel/msaDataStore.test.js +44 -0
- package/dist/MsaViewPanel/runLaunch.d.ts +38 -0
- package/dist/MsaViewPanel/runLaunch.js +65 -0
- package/dist/MsaViewPanel/runLaunch.test.d.ts +1 -0
- package/dist/MsaViewPanel/runLaunch.test.js +129 -0
- package/dist/MsaViewPanel/storedData.test.d.ts +1 -0
- package/dist/MsaViewPanel/storedData.test.js +160 -0
- package/dist/jbrowse-plugin-msaview.umd.production.min.js +35 -35
- package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
- package/dist/utils/blastCache.js +32 -0
- package/dist/utils/blastCache.test.d.ts +1 -0
- package/dist/utils/blastCache.test.js +72 -0
- package/dist/utils/ebiBlast.d.ts +4 -2
- package/dist/utils/ebiBlast.js +6 -4
- package/dist/utils/ebiJobDispatcher.d.ts +6 -3
- package/dist/utils/ebiJobDispatcher.js +13 -6
- package/dist/utils/fetch.d.ts +8 -1
- package/dist/utils/fetch.js +28 -2
- package/dist/utils/msa.d.ts +4 -2
- package/dist/utils/msa.js +9 -5
- package/dist/utils/ncbiDomains.d.ts +2 -2
- package/dist/utils/ncbiOrthologs.d.ts +11 -1
- package/dist/utils/ncbiOrthologs.js +26 -7
- package/dist/utils/ncbiOrthologs.test.js +23 -1
- package/dist/utils/pantherOrthologs.js +2 -10
- package/dist/utils/phmmer.d.ts +2 -1
- package/dist/utils/phmmer.js +4 -2
- package/dist/utils/poll.d.ts +6 -1
- package/dist/utils/poll.js +7 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +14 -14
- package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +7 -11
- package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.ts +43 -0
- package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.ts +64 -0
- package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +2 -2
- package/src/LaunchMsaViewExtensionPoint/index.test.ts +51 -0
- package/src/LaunchMsaViewExtensionPoint/index.ts +21 -6
- package/src/MsaViewPanel/afterCreateAutoruns.ts +100 -66
- package/src/MsaViewPanel/components/LaunchProgress.tsx +20 -2
- package/src/MsaViewPanel/components/MsaViewPanel.test.tsx +21 -0
- package/src/MsaViewPanel/doLaunchBlast.ts +14 -10
- package/src/MsaViewPanel/doLaunchOrthologs.test.ts +41 -26
- package/src/MsaViewPanel/doLaunchOrthologs.ts +8 -4
- package/src/MsaViewPanel/model.ts +54 -1
- package/src/MsaViewPanel/msaDataStore.test.ts +54 -0
- package/src/MsaViewPanel/msaDataStore.ts +22 -13
- package/src/MsaViewPanel/runLaunch.test.ts +154 -0
- package/src/MsaViewPanel/runLaunch.ts +102 -0
- package/src/MsaViewPanel/storedData.test.ts +196 -0
- package/src/utils/blastCache.test.ts +86 -0
- package/src/utils/blastCache.ts +34 -1
- package/src/utils/ebiBlast.ts +8 -2
- package/src/utils/ebiJobDispatcher.ts +18 -3
- package/src/utils/fetch.ts +29 -2
- package/src/utils/msa.ts +11 -3
- package/src/utils/ncbiDomains.ts +4 -2
- package/src/utils/ncbiOrthologs.test.ts +25 -0
- package/src/utils/ncbiOrthologs.ts +27 -7
- package/src/utils/pantherOrthologs.ts +6 -11
- package/src/utils/phmmer.ts +5 -1
- package/src/utils/poll.ts +8 -1
- package/src/version.ts +1 -1
|
@@ -13,28 +13,23 @@ import type {
|
|
|
13
13
|
PhmmerDatabase,
|
|
14
14
|
} from '../LaunchMsaView/components/BlastQuery/consts'
|
|
15
15
|
import type { JBrowsePluginMsaViewModel } from './model'
|
|
16
|
+
import type { LaunchScope } from './runLaunch'
|
|
16
17
|
|
|
17
18
|
type TreeMetadata = Record<string, Record<string, string>>
|
|
18
19
|
|
|
19
20
|
export async function doLaunchBlast({
|
|
20
21
|
self,
|
|
22
|
+
scope,
|
|
21
23
|
}: {
|
|
22
24
|
self: JBrowsePluginMsaViewModel
|
|
25
|
+
scope: LaunchScope
|
|
23
26
|
}) {
|
|
24
27
|
// kept whole rather than destructured: the database's type depends on
|
|
25
28
|
// searchProgram, and pulling the two apart loses the link between them
|
|
26
29
|
const params = self.blastParams!
|
|
27
30
|
const { selectedTranscript } = params
|
|
28
31
|
const cleanedSeq = cleanProteinSequence(params.proteinSequence)
|
|
29
|
-
|
|
30
|
-
const onProgress = (arg: string) => {
|
|
31
|
-
self.setProgress(arg)
|
|
32
|
-
}
|
|
33
|
-
// publish the job id before the first poll so the view can link out while the
|
|
34
|
-
// job is still running
|
|
35
|
-
const onRid = (r: string) => {
|
|
36
|
-
self.setRid(r)
|
|
37
|
-
}
|
|
32
|
+
const { onProgress, onRid, signal } = scope
|
|
38
33
|
|
|
39
34
|
const { msa, tree, treeMetadata, rid } =
|
|
40
35
|
params.searchProgram === 'phmmer'
|
|
@@ -43,6 +38,7 @@ export async function doLaunchBlast({
|
|
|
43
38
|
database: params.blastDatabase,
|
|
44
39
|
onProgress,
|
|
45
40
|
onRid,
|
|
41
|
+
signal,
|
|
46
42
|
})
|
|
47
43
|
: await runBlast({
|
|
48
44
|
query: cleanedSeq,
|
|
@@ -50,6 +46,7 @@ export async function doLaunchBlast({
|
|
|
50
46
|
msaAlgorithm: params.msaAlgorithm,
|
|
51
47
|
onProgress,
|
|
52
48
|
onRid,
|
|
49
|
+
signal,
|
|
53
50
|
})
|
|
54
51
|
|
|
55
52
|
const treeMetadataJson = JSON.stringify(treeMetadata)
|
|
@@ -86,18 +83,21 @@ async function runBlast({
|
|
|
86
83
|
msaAlgorithm,
|
|
87
84
|
onProgress,
|
|
88
85
|
onRid,
|
|
86
|
+
signal,
|
|
89
87
|
}: {
|
|
90
88
|
query: string
|
|
91
89
|
blastDatabase: BlastDatabase
|
|
92
90
|
msaAlgorithm: MsaAlgorithm
|
|
93
91
|
onProgress: (arg: string) => void
|
|
94
92
|
onRid: (arg: string) => void
|
|
93
|
+
signal?: AbortSignal
|
|
95
94
|
}) {
|
|
96
95
|
const { hits, rid } = await queryEbiBlast({
|
|
97
96
|
query,
|
|
98
97
|
blastDatabase,
|
|
99
98
|
onProgress,
|
|
100
99
|
onRid,
|
|
100
|
+
signal,
|
|
101
101
|
})
|
|
102
102
|
|
|
103
103
|
onProgress('Fetching species taxonomy info...')
|
|
@@ -123,6 +123,7 @@ async function runBlast({
|
|
|
123
123
|
algorithm: msaAlgorithm,
|
|
124
124
|
sequence: [`>QUERY\n${query}`, ...sequences].join('\n'),
|
|
125
125
|
onProgress,
|
|
126
|
+
signal,
|
|
126
127
|
})
|
|
127
128
|
return { ...result, treeMetadata, rid }
|
|
128
129
|
}
|
|
@@ -139,17 +140,20 @@ async function runPhmmer({
|
|
|
139
140
|
database,
|
|
140
141
|
onProgress,
|
|
141
142
|
onRid,
|
|
143
|
+
signal,
|
|
142
144
|
}: {
|
|
143
145
|
query: string
|
|
144
146
|
database: PhmmerDatabase
|
|
145
147
|
onProgress: (arg: string) => void
|
|
146
148
|
onRid: (arg: string) => void
|
|
149
|
+
signal?: AbortSignal
|
|
147
150
|
}) {
|
|
148
151
|
const { rows, queryRow, rid } = await queryPhmmer({
|
|
149
152
|
query,
|
|
150
153
|
database,
|
|
151
154
|
onProgress,
|
|
152
155
|
onRid,
|
|
156
|
+
signal,
|
|
153
157
|
})
|
|
154
158
|
|
|
155
159
|
onProgress('Fetching species taxonomy info...')
|
|
@@ -164,7 +168,7 @@ async function runPhmmer({
|
|
|
164
168
|
})
|
|
165
169
|
return {
|
|
166
170
|
msa,
|
|
167
|
-
tree: await launchTree({ alignment: msa, onProgress }),
|
|
171
|
+
tree: await launchTree({ alignment: msa, onProgress, signal }),
|
|
168
172
|
treeMetadata,
|
|
169
173
|
rid,
|
|
170
174
|
}
|
|
@@ -47,11 +47,26 @@ const setQuerySeqName = vi.fn()
|
|
|
47
47
|
function makeModel(orthologParams: Record<string, unknown>) {
|
|
48
48
|
return {
|
|
49
49
|
orthologParams,
|
|
50
|
-
setProgress: () => {},
|
|
51
50
|
setQuerySeqName,
|
|
52
51
|
} as unknown as JBrowsePluginMsaViewModel
|
|
53
52
|
}
|
|
54
53
|
|
|
54
|
+
// a scope that never cancels, so these tests see the launch's own behaviour --
|
|
55
|
+
// what the scope does when it IS cancelled is runLaunch.test.ts's subject
|
|
56
|
+
function launch({ self }: { self: JBrowsePluginMsaViewModel }) {
|
|
57
|
+
return doLaunchOrthologs({
|
|
58
|
+
self,
|
|
59
|
+
scope: {
|
|
60
|
+
signal: new AbortController().signal,
|
|
61
|
+
act: fn => {
|
|
62
|
+
fn()
|
|
63
|
+
},
|
|
64
|
+
onProgress: () => {},
|
|
65
|
+
onRid: () => {},
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
function params(extra: Record<string, unknown> = {}) {
|
|
56
71
|
return {
|
|
57
72
|
taxId: HUMAN,
|
|
@@ -105,12 +120,12 @@ beforeEach(() => {
|
|
|
105
120
|
|
|
106
121
|
describe('which species become rows', () => {
|
|
107
122
|
test('omitted taxa asks for no restriction at all, which is every ortholog NCBI has', async () => {
|
|
108
|
-
await
|
|
123
|
+
await launch({ self: makeModel(params()) })
|
|
109
124
|
expect(rowRequest().taxa).toBeUndefined()
|
|
110
125
|
})
|
|
111
126
|
|
|
112
127
|
test('given taxa is taken as written', async () => {
|
|
113
|
-
await
|
|
128
|
+
await launch({
|
|
114
129
|
self: makeModel(params({ taxa: [HUMAN, 10090, 9615] })),
|
|
115
130
|
})
|
|
116
131
|
expect(rowRequest().taxa).toEqual([9606, 9615, 10090])
|
|
@@ -120,21 +135,21 @@ describe('which species become rows', () => {
|
|
|
120
135
|
// and "the query row already covers this one" stay separable -- an unrestricted
|
|
121
136
|
// launch still has to drop the query species.
|
|
122
137
|
test('the query species is excluded whether or not taxa was given', async () => {
|
|
123
|
-
await
|
|
138
|
+
await launch({ self: makeModel(params()) })
|
|
124
139
|
expect(rowRequest().exclude).toBe(HUMAN)
|
|
125
140
|
vi.clearAllMocks()
|
|
126
141
|
mockResolveGeneId.mockResolvedValue({ geneId: GENE_ID, matched: 'NLRP1' })
|
|
127
142
|
mockFetchProtein.mockResolvedValue(REPRESENTATIVE)
|
|
128
143
|
mockFetchRows.mockResolvedValue([] as OrthologRow[])
|
|
129
144
|
mockLaunchMSA.mockResolvedValue({ msa: '', tree: '' })
|
|
130
|
-
await
|
|
145
|
+
await launch({
|
|
131
146
|
self: makeModel(params({ taxa: [HUMAN, 10090] })),
|
|
132
147
|
})
|
|
133
148
|
expect(rowRequest().exclude).toBe(HUMAN)
|
|
134
149
|
})
|
|
135
150
|
|
|
136
151
|
test('an empty list is a request for no rows, not a request for all of them', async () => {
|
|
137
|
-
await
|
|
152
|
+
await launch({ self: makeModel(params({ taxa: [] })) })
|
|
138
153
|
expect(rowRequest().taxa).toEqual([])
|
|
139
154
|
})
|
|
140
155
|
})
|
|
@@ -144,12 +159,12 @@ describe('which species become rows', () => {
|
|
|
144
159
|
// second a row.
|
|
145
160
|
describe('the row cap', () => {
|
|
146
161
|
test('is passed through when given', async () => {
|
|
147
|
-
await
|
|
162
|
+
await launch({ self: makeModel(params({ maxSpecies: 12 })) })
|
|
148
163
|
expect(rowRequest().limit).toBe(12)
|
|
149
164
|
})
|
|
150
165
|
|
|
151
166
|
test('omitted leaves the default to fetchOrthologGenes rather than sending Infinity', async () => {
|
|
152
|
-
await
|
|
167
|
+
await launch({ self: makeModel(params()) })
|
|
153
168
|
expect(rowRequest().limit).toBeUndefined()
|
|
154
169
|
expect(defaultMaxSpecies).toBeGreaterThan(2)
|
|
155
170
|
})
|
|
@@ -161,7 +176,7 @@ describe('the row cap', () => {
|
|
|
161
176
|
// model's querySeqName and the header have to be the same string.
|
|
162
177
|
describe('the query row name', () => {
|
|
163
178
|
test('is the species, marked, rather than a bare QUERY among named rows', async () => {
|
|
164
|
-
await
|
|
179
|
+
await launch({ self: makeModel(params()) })
|
|
165
180
|
expect(queryRowName()).toBe('human_query')
|
|
166
181
|
expect(setQuerySeqName).toHaveBeenCalledWith('human_query')
|
|
167
182
|
})
|
|
@@ -170,7 +185,7 @@ describe('the query row name', () => {
|
|
|
170
185
|
mockFetchRows.mockResolvedValue([
|
|
171
186
|
{ label: 'human_query', sequence: 'MM' },
|
|
172
187
|
] as OrthologRow[])
|
|
173
|
-
await
|
|
188
|
+
await launch({ self: makeModel(params()) })
|
|
174
189
|
expect(queryRowName()).toBe('human_query_2')
|
|
175
190
|
expect(setQuerySeqName).toHaveBeenCalledWith('human_query_2')
|
|
176
191
|
})
|
|
@@ -178,12 +193,12 @@ describe('the query row name', () => {
|
|
|
178
193
|
test('falls back rather than throwing when NCBI cannot name the taxon', async () => {
|
|
179
194
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
180
195
|
mockFetchTaxonomy.mockRejectedValue(new Error('429'))
|
|
181
|
-
await
|
|
196
|
+
await launch({ self: makeModel(params()) })
|
|
182
197
|
expect(queryRowName()).toBe('query_query')
|
|
183
198
|
})
|
|
184
199
|
|
|
185
200
|
test('the metadata that drives the domain overlay is keyed to that same name', async () => {
|
|
186
|
-
const result = await
|
|
201
|
+
const result = await launch({ self: makeModel(params()) })
|
|
187
202
|
expect(Object.keys(JSON.parse(result.treeMetadata))).toContain(
|
|
188
203
|
'human_query',
|
|
189
204
|
)
|
|
@@ -192,12 +207,12 @@ describe('the query row name', () => {
|
|
|
192
207
|
|
|
193
208
|
describe('the query row sequence', () => {
|
|
194
209
|
test('omitted proteinSequence falls back to the representative protein', async () => {
|
|
195
|
-
await
|
|
210
|
+
await launch({ self: makeModel(params()) })
|
|
196
211
|
expect(queryRowSent()).toBe(REPRESENTATIVE.sequence)
|
|
197
212
|
})
|
|
198
213
|
|
|
199
214
|
test('a supplied sequence is used, and is cleaned first', async () => {
|
|
200
|
-
await
|
|
215
|
+
await launch({
|
|
201
216
|
self: makeModel(params({ proteinSequence: 'MAGG*AWGR&' })),
|
|
202
217
|
})
|
|
203
218
|
expect(queryRowSent()).toBe('MAGGAWGR')
|
|
@@ -205,16 +220,16 @@ describe('the query row sequence', () => {
|
|
|
205
220
|
|
|
206
221
|
test('throws when neither a sequence nor a representative is available', async () => {
|
|
207
222
|
mockFetchProtein.mockResolvedValue(undefined)
|
|
208
|
-
await expect(
|
|
209
|
-
|
|
210
|
-
)
|
|
223
|
+
await expect(launch({ self: makeModel(params()) })).rejects.toThrow(
|
|
224
|
+
/No query protein/,
|
|
225
|
+
)
|
|
211
226
|
expect(mockLaunchMSA).not.toHaveBeenCalled()
|
|
212
227
|
})
|
|
213
228
|
|
|
214
229
|
test('a failed representative lookup does not take down a launch that brought its own sequence', async () => {
|
|
215
230
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
216
231
|
mockFetchProtein.mockRejectedValue(new Error('429'))
|
|
217
|
-
await
|
|
232
|
+
await launch({
|
|
218
233
|
self: makeModel(params({ proteinSequence: REPRESENTATIVE.sequence })),
|
|
219
234
|
})
|
|
220
235
|
expect(queryRowSent()).toBe(REPRESENTATIVE.sequence)
|
|
@@ -227,7 +242,7 @@ describe('the query row sequence', () => {
|
|
|
227
242
|
// here, in both directions.
|
|
228
243
|
describe('the Accession that drives the domain overlay', () => {
|
|
229
244
|
test('is attached when the query row IS the representative protein', async () => {
|
|
230
|
-
const result = await
|
|
245
|
+
const result = await launch({ self: makeModel(params()) })
|
|
231
246
|
expect(queryMetadata(result)).toMatchObject({
|
|
232
247
|
'Gene ID': GENE_ID,
|
|
233
248
|
Accession: REPRESENTATIVE.accession,
|
|
@@ -235,7 +250,7 @@ describe('the Accession that drives the domain overlay', () => {
|
|
|
235
250
|
})
|
|
236
251
|
|
|
237
252
|
test('is withheld from a non-representative isoform', async () => {
|
|
238
|
-
const result = await
|
|
253
|
+
const result = await launch({
|
|
239
254
|
self: makeModel(params({ proteinSequence: 'MDIFFERENTISOFORM' })),
|
|
240
255
|
})
|
|
241
256
|
expect(queryMetadata(result).Accession).toBeUndefined()
|
|
@@ -244,7 +259,7 @@ describe('the Accession that drives the domain overlay', () => {
|
|
|
244
259
|
test('is withheld when the representative lookup failed', async () => {
|
|
245
260
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
246
261
|
mockFetchProtein.mockRejectedValue(new Error('429'))
|
|
247
|
-
const result = await
|
|
262
|
+
const result = await launch({
|
|
248
263
|
self: makeModel(params({ proteinSequence: REPRESENTATIVE.sequence })),
|
|
249
264
|
})
|
|
250
265
|
expect(queryMetadata(result).Accession).toBeUndefined()
|
|
@@ -286,13 +301,13 @@ describe('the PANTHER source', () => {
|
|
|
286
301
|
})
|
|
287
302
|
|
|
288
303
|
test('source omitted is NCBI, so an old launch never reaches PANTHER', async () => {
|
|
289
|
-
await
|
|
304
|
+
await launch({ self: makeModel(params()) })
|
|
290
305
|
expect(mockFetchPanther).not.toHaveBeenCalled()
|
|
291
306
|
expect(mockResolveGeneId).toHaveBeenCalled()
|
|
292
307
|
})
|
|
293
308
|
|
|
294
309
|
test('source panther asks PANTHER with the same species semantics, and skips NCBI', async () => {
|
|
295
|
-
await
|
|
310
|
+
await launch({
|
|
296
311
|
self: makeModel({
|
|
297
312
|
taxId: YEAST,
|
|
298
313
|
source: 'panther',
|
|
@@ -314,7 +329,7 @@ describe('the PANTHER source', () => {
|
|
|
314
329
|
})
|
|
315
330
|
|
|
316
331
|
test("the query row is PANTHER's own entry for the gene when no sequence was supplied, and carries its UniProt accession for the domain overlay", async () => {
|
|
317
|
-
const result = await
|
|
332
|
+
const result = await launch({
|
|
318
333
|
self: makeModel({
|
|
319
334
|
taxId: YEAST,
|
|
320
335
|
source: 'panther',
|
|
@@ -335,7 +350,7 @@ describe('the PANTHER source', () => {
|
|
|
335
350
|
})
|
|
336
351
|
|
|
337
352
|
test('a supplied sequence still wins, and a different isoform earns no Accession', async () => {
|
|
338
|
-
const result = await
|
|
353
|
+
const result = await launch({
|
|
339
354
|
self: makeModel({
|
|
340
355
|
taxId: YEAST,
|
|
341
356
|
source: 'panther',
|
|
@@ -351,7 +366,7 @@ describe('the PANTHER source', () => {
|
|
|
351
366
|
test('names PANTHER when it has no protein for the query row', async () => {
|
|
352
367
|
mockFetchPanther.mockResolvedValue({ ...found, query: undefined })
|
|
353
368
|
await expect(
|
|
354
|
-
|
|
369
|
+
launch({
|
|
355
370
|
self: makeModel({
|
|
356
371
|
taxId: YEAST,
|
|
357
372
|
source: 'panther',
|
|
@@ -11,6 +11,7 @@ import { fetchTaxonomyInfo } from '../utils/taxonomyNames'
|
|
|
11
11
|
|
|
12
12
|
import type { OrthologRow } from '../utils/ncbiOrthologs'
|
|
13
13
|
import type { JBrowsePluginMsaViewModel } from './model'
|
|
14
|
+
import type { LaunchScope } from './runLaunch'
|
|
14
15
|
|
|
15
16
|
interface Representative {
|
|
16
17
|
accession: string
|
|
@@ -44,8 +45,10 @@ interface FoundOrthologs {
|
|
|
44
45
|
*/
|
|
45
46
|
export async function doLaunchOrthologs({
|
|
46
47
|
self,
|
|
48
|
+
scope,
|
|
47
49
|
}: {
|
|
48
50
|
self: JBrowsePluginMsaViewModel
|
|
51
|
+
scope: LaunchScope
|
|
49
52
|
}) {
|
|
50
53
|
const {
|
|
51
54
|
taxId,
|
|
@@ -57,9 +60,7 @@ export async function doLaunchOrthologs({
|
|
|
57
60
|
source = 'ncbi',
|
|
58
61
|
} = self.orthologParams!
|
|
59
62
|
|
|
60
|
-
const onProgress
|
|
61
|
-
self.setProgress(arg)
|
|
62
|
-
}
|
|
63
|
+
const { onProgress, act, signal } = scope
|
|
63
64
|
|
|
64
65
|
const request = {
|
|
65
66
|
taxId,
|
|
@@ -103,7 +104,9 @@ export async function doLaunchOrthologs({
|
|
|
103
104
|
// token, and a collision would silently point the coordinate mapping at
|
|
104
105
|
// another animal's row.
|
|
105
106
|
const queryLabel = await queryRowLabel(taxId, rows)
|
|
106
|
-
|
|
107
|
+
act(() => {
|
|
108
|
+
self.setQuerySeqName(queryLabel)
|
|
109
|
+
})
|
|
107
110
|
|
|
108
111
|
const treeMetadata: Record<string, Record<string, string>> = {
|
|
109
112
|
[queryLabel]: buildQueryMetadata(self, geneId, cleanedSeq, representative),
|
|
@@ -119,6 +122,7 @@ export async function doLaunchOrthologs({
|
|
|
119
122
|
...rows.map(r => `>${r.label}\n${r.sequence}`),
|
|
120
123
|
].join('\n'),
|
|
121
124
|
onProgress,
|
|
125
|
+
signal,
|
|
122
126
|
})
|
|
123
127
|
|
|
124
128
|
return {
|
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
MsaAlgorithm,
|
|
31
31
|
PhmmerDatabase,
|
|
32
32
|
} from '../LaunchMsaView/components/BlastQuery/consts'
|
|
33
|
+
import type { MsaDataPayload } from './msaDataStore'
|
|
33
34
|
import type { MafRegion, MsaViewInitState } from './types'
|
|
34
35
|
import type { Feature } from '@jbrowse/core/util'
|
|
35
36
|
import type { Instance } from '@jbrowse/mobx-state-tree'
|
|
@@ -188,6 +189,8 @@ export default function stateModelFactory() {
|
|
|
188
189
|
error: unknown
|
|
189
190
|
loadingStoredData: boolean
|
|
190
191
|
isStoringData: boolean
|
|
192
|
+
lastStoredData: MsaDataPayload | undefined
|
|
193
|
+
launchController: AbortController | undefined
|
|
191
194
|
domainsRequested: boolean
|
|
192
195
|
} => ({
|
|
193
196
|
/**
|
|
@@ -210,6 +213,21 @@ export default function stateModelFactory() {
|
|
|
210
213
|
* #volatile
|
|
211
214
|
*/
|
|
212
215
|
isStoringData: false,
|
|
216
|
+
/**
|
|
217
|
+
* #volatile
|
|
218
|
+
* what IndexedDB was last known to hold for this view, so an edit made
|
|
219
|
+
* after the first write (react-msaview replacing data.tree with a
|
|
220
|
+
* neighbor-joining tree, say) is recognized as needing a new one
|
|
221
|
+
*/
|
|
222
|
+
lastStoredData: undefined,
|
|
223
|
+
/**
|
|
224
|
+
* #volatile
|
|
225
|
+
* aborts the launch currently in flight -- from the Cancel button, and
|
|
226
|
+
* from the disposer afterCreate registers, so a closed view stops
|
|
227
|
+
* polling EBI instead of running to completion and writing to a node
|
|
228
|
+
* that is gone
|
|
229
|
+
*/
|
|
230
|
+
launchController: undefined,
|
|
213
231
|
/**
|
|
214
232
|
* #volatile
|
|
215
233
|
* guards the one-shot auto-fetch of protein domains so it doesn't refire
|
|
@@ -231,7 +249,7 @@ export default function stateModelFactory() {
|
|
|
231
249
|
* #method
|
|
232
250
|
*/
|
|
233
251
|
getSequenceByRowName(rowName: string) {
|
|
234
|
-
return
|
|
252
|
+
return this.getRowByName(rowName)?.[1]
|
|
235
253
|
},
|
|
236
254
|
}))
|
|
237
255
|
|
|
@@ -370,6 +388,35 @@ export default function stateModelFactory() {
|
|
|
370
388
|
setIsStoringData(arg: boolean) {
|
|
371
389
|
self.isStoringData = arg
|
|
372
390
|
},
|
|
391
|
+
/**
|
|
392
|
+
* #action
|
|
393
|
+
*/
|
|
394
|
+
setLastStoredData(arg?: MsaDataPayload) {
|
|
395
|
+
self.lastStoredData = arg
|
|
396
|
+
},
|
|
397
|
+
/**
|
|
398
|
+
* #action
|
|
399
|
+
*/
|
|
400
|
+
setLaunchController(arg?: AbortController) {
|
|
401
|
+
self.launchController = arg
|
|
402
|
+
},
|
|
403
|
+
/**
|
|
404
|
+
* #action
|
|
405
|
+
* Abandon the launch in flight. The EBI job keeps running on their side —
|
|
406
|
+
* nothing here can recall it, and its JobLink stays valid — so this only
|
|
407
|
+
* stops the polling and the writes it would make. Dropping the params is
|
|
408
|
+
* what keeps the request from refiring; the view falls back to the import
|
|
409
|
+
* form, which is where react-msaview's own cancel leaves it too.
|
|
410
|
+
*/
|
|
411
|
+
cancelLaunch() {
|
|
412
|
+
self.launchController?.abort()
|
|
413
|
+
self.launchController = undefined
|
|
414
|
+
self.blastParams = undefined
|
|
415
|
+
self.orthologParams = undefined
|
|
416
|
+
self.progress = ''
|
|
417
|
+
self.rid = undefined
|
|
418
|
+
self.error = undefined
|
|
419
|
+
},
|
|
373
420
|
/**
|
|
374
421
|
* #action
|
|
375
422
|
*/
|
|
@@ -434,6 +481,12 @@ export default function stateModelFactory() {
|
|
|
434
481
|
.actions(self => ({
|
|
435
482
|
afterCreate() {
|
|
436
483
|
runCleanup()
|
|
484
|
+
// ties the launch to the view: closing one mid-BLAST used to leave the
|
|
485
|
+
// poller running for the job's lifetime and then call actions on a
|
|
486
|
+
// destroyed node, which surfaced as an unhandled rejection
|
|
487
|
+
addDisposer(self, () => {
|
|
488
|
+
self.launchController?.abort()
|
|
489
|
+
})
|
|
437
490
|
for (const fn of [
|
|
438
491
|
loadStoredData,
|
|
439
492
|
storeDataToIndexedDB,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { beforeEach, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { retrieveMsaData } from './msaDataStore'
|
|
4
|
+
|
|
5
|
+
// An in-memory stand-in for the one object store this module opens, with a
|
|
6
|
+
// switch for the write half so a browser that refuses writes can be played back.
|
|
7
|
+
const { rows, state } = vi.hoisted(() => ({
|
|
8
|
+
rows: new Map<string, { id: string; msa?: string; timestamp: number }>(),
|
|
9
|
+
state: { putFails: false },
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
vi.mock('../utils/idb', () => ({
|
|
13
|
+
createDbOpener: () => () =>
|
|
14
|
+
Promise.resolve({
|
|
15
|
+
get: (_store: string, id: string) => Promise.resolve(rows.get(id)),
|
|
16
|
+
put: (_store: string, value: { id: string; timestamp: number }) => {
|
|
17
|
+
if (state.putFails) {
|
|
18
|
+
return Promise.reject(new Error('QuotaExceededError'))
|
|
19
|
+
}
|
|
20
|
+
rows.set(value.id, value)
|
|
21
|
+
return Promise.resolve(value.id)
|
|
22
|
+
},
|
|
23
|
+
}),
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
rows.clear()
|
|
28
|
+
state.putFails = false
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// cleanupOldData deletes by timestamp, so without this a session opened every
|
|
32
|
+
// single day still lost its alignment on the eighth
|
|
33
|
+
test('reading a row refreshes its timestamp, making the expiry "unused for 7 days"', async () => {
|
|
34
|
+
rows.set('msa-1', { id: 'msa-1', msa: '>a\nMK', timestamp: 1000 })
|
|
35
|
+
const before = Date.now()
|
|
36
|
+
|
|
37
|
+
expect((await retrieveMsaData('msa-1'))?.msa).toBe('>a\nMK')
|
|
38
|
+
expect(rows.get('msa-1')!.timestamp).toBeGreaterThanOrEqual(before)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('a row that is no longer there reads as undefined and writes nothing', async () => {
|
|
42
|
+
expect(await retrieveMsaData('msa-gone')).toBeUndefined()
|
|
43
|
+
expect(rows.size).toBe(0)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// the refresh is housekeeping; failing it must not turn a readable alignment
|
|
47
|
+
// into a missing one, which is what the caller reports as expired
|
|
48
|
+
test('a refresh that fails still hands back the data it read', async () => {
|
|
49
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
50
|
+
rows.set('msa-1', { id: 'msa-1', msa: '>a\nMK', timestamp: 1000 })
|
|
51
|
+
state.putFails = true
|
|
52
|
+
|
|
53
|
+
expect((await retrieveMsaData('msa-1'))?.msa).toBe('>a\nMK')
|
|
54
|
+
})
|
|
@@ -6,11 +6,15 @@ const DB_NAME = 'jbrowse-msaview-data'
|
|
|
6
6
|
const DB_VERSION = 1
|
|
7
7
|
const STORE_NAME = 'msa-data'
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
/** the documents a view keeps in IndexedDB rather than in its session snapshot */
|
|
10
|
+
export interface MsaDataPayload {
|
|
11
11
|
msa?: string
|
|
12
12
|
tree?: string
|
|
13
13
|
treeMetadata?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface StoredMsaData extends MsaDataPayload {
|
|
17
|
+
id: string
|
|
14
18
|
timestamp: number
|
|
15
19
|
}
|
|
16
20
|
|
|
@@ -33,10 +37,7 @@ export function generateDataStoreId() {
|
|
|
33
37
|
return `msa-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
export async function storeMsaData(
|
|
37
|
-
id: string,
|
|
38
|
-
data: { msa?: string; tree?: string; treeMetadata?: string },
|
|
39
|
-
) {
|
|
40
|
+
export async function storeMsaData(id: string, data: MsaDataPayload) {
|
|
40
41
|
try {
|
|
41
42
|
const db = await getDB()
|
|
42
43
|
const storedData: StoredMsaData = {
|
|
@@ -58,14 +59,22 @@ export async function retrieveMsaData(id: string) {
|
|
|
58
59
|
try {
|
|
59
60
|
const db = await getDB()
|
|
60
61
|
const result = await db.get(STORE_NAME, id)
|
|
61
|
-
if (result) {
|
|
62
|
-
return
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
if (!result) {
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
// reading counts as use, which is what makes cleanupOldData's policy
|
|
66
|
+
// "unused for 7 days" rather than "written 7 days ago" -- a session opened
|
|
67
|
+
// every day used to lose its alignment on the eighth
|
|
68
|
+
try {
|
|
69
|
+
await db.put(STORE_NAME, { ...result, timestamp: Date.now() })
|
|
70
|
+
} catch (e) {
|
|
71
|
+
console.warn('Failed to refresh MSA data timestamp:', e)
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
msa: result.msa,
|
|
75
|
+
tree: result.tree,
|
|
76
|
+
treeMetadata: result.treeMetadata,
|
|
67
77
|
}
|
|
68
|
-
return undefined
|
|
69
78
|
} catch (e) {
|
|
70
79
|
console.warn('Failed to retrieve MSA data:', e)
|
|
71
80
|
return undefined
|