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.
Files changed (86) hide show
  1. package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +4 -6
  2. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.d.ts +7 -0
  3. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.js +42 -0
  4. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.d.ts +1 -0
  5. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.js +32 -0
  6. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +2 -1
  7. package/dist/LaunchMsaViewExtensionPoint/index.js +11 -6
  8. package/dist/LaunchMsaViewExtensionPoint/index.test.d.ts +1 -0
  9. package/dist/LaunchMsaViewExtensionPoint/index.test.js +43 -0
  10. package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +15 -0
  11. package/dist/MsaViewPanel/afterCreateAutoruns.js +93 -70
  12. package/dist/MsaViewPanel/components/LaunchProgress.js +11 -2
  13. package/dist/MsaViewPanel/components/MsaViewPanel.test.js +19 -0
  14. package/dist/MsaViewPanel/doLaunchBlast.d.ts +3 -1
  15. package/dist/MsaViewPanel/doLaunchBlast.js +10 -12
  16. package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +3 -1
  17. package/dist/MsaViewPanel/doLaunchOrthologs.js +6 -5
  18. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +38 -24
  19. package/dist/MsaViewPanel/model.d.ts +160 -10
  20. package/dist/MsaViewPanel/model.js +51 -1
  21. package/dist/MsaViewPanel/msaDataStore.d.ts +5 -3
  22. package/dist/MsaViewPanel/msaDataStore.js +16 -7
  23. package/dist/MsaViewPanel/msaDataStore.test.d.ts +1 -0
  24. package/dist/MsaViewPanel/msaDataStore.test.js +44 -0
  25. package/dist/MsaViewPanel/runLaunch.d.ts +38 -0
  26. package/dist/MsaViewPanel/runLaunch.js +65 -0
  27. package/dist/MsaViewPanel/runLaunch.test.d.ts +1 -0
  28. package/dist/MsaViewPanel/runLaunch.test.js +129 -0
  29. package/dist/MsaViewPanel/storedData.test.d.ts +1 -0
  30. package/dist/MsaViewPanel/storedData.test.js +160 -0
  31. package/dist/jbrowse-plugin-msaview.umd.production.min.js +35 -35
  32. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  33. package/dist/utils/blastCache.js +32 -0
  34. package/dist/utils/blastCache.test.d.ts +1 -0
  35. package/dist/utils/blastCache.test.js +72 -0
  36. package/dist/utils/ebiBlast.d.ts +4 -2
  37. package/dist/utils/ebiBlast.js +6 -4
  38. package/dist/utils/ebiJobDispatcher.d.ts +6 -3
  39. package/dist/utils/ebiJobDispatcher.js +13 -6
  40. package/dist/utils/fetch.d.ts +8 -1
  41. package/dist/utils/fetch.js +28 -2
  42. package/dist/utils/msa.d.ts +4 -2
  43. package/dist/utils/msa.js +9 -5
  44. package/dist/utils/ncbiDomains.d.ts +2 -2
  45. package/dist/utils/ncbiOrthologs.d.ts +11 -1
  46. package/dist/utils/ncbiOrthologs.js +26 -7
  47. package/dist/utils/ncbiOrthologs.test.js +23 -1
  48. package/dist/utils/pantherOrthologs.js +2 -10
  49. package/dist/utils/phmmer.d.ts +2 -1
  50. package/dist/utils/phmmer.js +4 -2
  51. package/dist/utils/poll.d.ts +6 -1
  52. package/dist/utils/poll.js +7 -2
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/package.json +14 -14
  56. package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +7 -11
  57. package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.ts +43 -0
  58. package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.ts +64 -0
  59. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +2 -2
  60. package/src/LaunchMsaViewExtensionPoint/index.test.ts +51 -0
  61. package/src/LaunchMsaViewExtensionPoint/index.ts +21 -6
  62. package/src/MsaViewPanel/afterCreateAutoruns.ts +100 -66
  63. package/src/MsaViewPanel/components/LaunchProgress.tsx +20 -2
  64. package/src/MsaViewPanel/components/MsaViewPanel.test.tsx +21 -0
  65. package/src/MsaViewPanel/doLaunchBlast.ts +14 -10
  66. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +41 -26
  67. package/src/MsaViewPanel/doLaunchOrthologs.ts +8 -4
  68. package/src/MsaViewPanel/model.ts +54 -1
  69. package/src/MsaViewPanel/msaDataStore.test.ts +54 -0
  70. package/src/MsaViewPanel/msaDataStore.ts +22 -13
  71. package/src/MsaViewPanel/runLaunch.test.ts +154 -0
  72. package/src/MsaViewPanel/runLaunch.ts +102 -0
  73. package/src/MsaViewPanel/storedData.test.ts +196 -0
  74. package/src/utils/blastCache.test.ts +86 -0
  75. package/src/utils/blastCache.ts +34 -1
  76. package/src/utils/ebiBlast.ts +8 -2
  77. package/src/utils/ebiJobDispatcher.ts +18 -3
  78. package/src/utils/fetch.ts +29 -2
  79. package/src/utils/msa.ts +11 -3
  80. package/src/utils/ncbiDomains.ts +4 -2
  81. package/src/utils/ncbiOrthologs.test.ts +25 -0
  82. package/src/utils/ncbiOrthologs.ts +27 -7
  83. package/src/utils/pantherOrthologs.ts +6 -11
  84. package/src/utils/phmmer.ts +5 -1
  85. package/src/utils/poll.ts +8 -1
  86. 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 doLaunchOrthologs({ self: makeModel(params()) })
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 doLaunchOrthologs({
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 doLaunchOrthologs({ self: makeModel(params()) })
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 doLaunchOrthologs({
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 doLaunchOrthologs({ self: makeModel(params({ taxa: [] })) })
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 doLaunchOrthologs({ self: makeModel(params({ maxSpecies: 12 })) })
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 doLaunchOrthologs({ self: makeModel(params()) })
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 doLaunchOrthologs({ self: makeModel(params()) })
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 doLaunchOrthologs({ self: makeModel(params()) })
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 doLaunchOrthologs({ self: makeModel(params()) })
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 doLaunchOrthologs({ self: makeModel(params()) })
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 doLaunchOrthologs({ self: makeModel(params()) })
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 doLaunchOrthologs({
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
- doLaunchOrthologs({ self: makeModel(params()) }),
210
- ).rejects.toThrow(/No query protein/)
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 doLaunchOrthologs({
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 doLaunchOrthologs({ self: makeModel(params()) })
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 doLaunchOrthologs({
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 doLaunchOrthologs({
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 doLaunchOrthologs({ self: makeModel(params()) })
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 doLaunchOrthologs({
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 doLaunchOrthologs({
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 doLaunchOrthologs({
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
- doLaunchOrthologs({
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 = (arg: string) => {
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
- self.setQuerySeqName(queryLabel)
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 self.rows.find(r => r[0] === rowName)?.[1]
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
- interface StoredMsaData {
10
- id: string
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
- msa: result.msa,
64
- tree: result.tree,
65
- treeMetadata: result.treeMetadata,
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