jbrowse-plugin-msaview 3.8.0 → 3.9.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/AddHighlightModel/MsaToGenomeHighlight.js +6 -22
- package/dist/AddHighlightModel/connectedHighlights.d.ts +9 -0
- package/dist/AddHighlightModel/connectedHighlights.js +15 -0
- package/dist/AddHighlightModel/connectedHighlights.test.d.ts +1 -0
- package/dist/AddHighlightModel/connectedHighlights.test.js +36 -0
- package/dist/AddHighlightModel/index.js +0 -1
- package/dist/LaunchMsaView/components/BlastQuery/consts.d.ts +4 -4
- package/dist/LaunchMsaView/components/BlastQuery/consts.js +6 -4
- package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.d.ts +0 -2
- package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.js +2 -2
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.d.ts +0 -1
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.js +1 -1
- package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.js +5 -5
- package/dist/LaunchMsaView/components/calculateProteinSequence.d.ts +0 -6
- package/dist/LaunchMsaView/components/calculateProteinSequence.js +1 -1
- package/dist/LaunchMsaView/components/types.d.ts +0 -3
- package/dist/LaunchMsaView/util.d.ts +0 -1
- package/dist/LaunchMsaView/util.js +1 -1
- package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +5 -14
- package/dist/MsaViewPanel/afterCreateAutoruns.js +77 -86
- package/dist/MsaViewPanel/components/MsaViewPanel.js +1 -1
- package/dist/MsaViewPanel/doLaunchBlast.js +42 -16
- package/dist/MsaViewPanel/doLaunchBlast.test.js +54 -0
- package/dist/MsaViewPanel/doLaunchOrthologs.js +18 -11
- package/dist/MsaViewPanel/launchAutoruns.test.js +0 -1
- package/dist/MsaViewPanel/model.d.ts +13 -7
- package/dist/MsaViewPanel/model.js +39 -10
- package/dist/MsaViewPanel/model.test.js +3 -4
- package/dist/MsaViewPanel/msaDataStore.d.ts +1 -2
- package/dist/MsaViewPanel/msaDataStore.js +11 -26
- package/dist/MsaViewPanel/msaDataStore.test.js +10 -2
- package/dist/MsaViewPanel/observeProteinHighlights.test.js +1 -0
- package/dist/MsaViewPanel/processInit.test.js +1 -7
- package/dist/MsaViewPanel/regionSnapshot.test.js +0 -1
- package/dist/MsaViewPanel/resolveConnectedTranscript.test.js +0 -1
- package/dist/MsaViewPanel/storedData.test.js +238 -145
- package/dist/MsaViewPanel/transcriptMap.d.ts +7 -0
- package/dist/MsaViewPanel/transcriptMap.js +89 -0
- package/dist/MsaViewPanel/transcriptMap.test.d.ts +1 -0
- package/dist/MsaViewPanel/transcriptMap.test.js +108 -0
- package/dist/jbrowse-plugin-msaview.umd.production.min.js +38 -38
- package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
- package/dist/utils/browserAlign.d.ts +5 -3
- package/dist/utils/browserAlign.js +29 -23
- package/dist/utils/browserAlign.test.js +14 -1
- package/dist/utils/ebiBlast.d.ts +0 -8
- package/dist/utils/ebiBlast.js +10 -21
- package/dist/utils/ebiJobDispatcher.d.ts +9 -9
- package/dist/utils/ebiJobDispatcher.js +17 -3
- package/dist/utils/eutils.d.ts +0 -2
- package/dist/utils/eutils.js +11 -3
- package/dist/utils/eutils.test.js +22 -0
- package/dist/utils/msa.js +6 -14
- package/dist/utils/msaRows.d.ts +0 -8
- package/dist/utils/msaRows.js +1 -1
- package/dist/utils/ncbiOrthologs.d.ts +7 -5
- package/dist/utils/ncbiOrthologs.js +20 -14
- package/dist/utils/ncbiOrthologs.test.js +13 -0
- package/dist/utils/ncbiTaxonomy.d.ts +2 -2
- package/dist/utils/ncbiTaxonomy.js +5 -5
- package/dist/utils/pantherOrthologs.d.ts +2 -3
- package/dist/utils/pantherOrthologs.js +18 -12
- package/dist/utils/phmmer.js +7 -14
- package/dist/utils/searchCache.d.ts +18 -0
- package/dist/utils/searchCache.js +33 -0
- package/dist/utils/searchCache.test.d.ts +1 -0
- package/dist/utils/searchCache.test.js +80 -0
- package/dist/utils/taxonomyNames.d.ts +1 -1
- package/dist/utils/taxonomyNames.js +7 -2
- package/dist/utils/taxonomyNamesAbort.test.d.ts +1 -0
- package/dist/utils/taxonomyNamesAbort.test.js +36 -0
- package/dist/utils/unirefHomologs.d.ts +1 -6
- package/dist/utils/unirefHomologs.js +2 -2
- package/dist/utils/useFetch.d.ts +1 -1
- package/dist/utils/useFetch.js +5 -5
- package/dist/utils/useFetchAbort.test.d.ts +1 -0
- package/dist/utils/useFetchAbort.test.js +29 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/AddHighlightModel/MsaToGenomeHighlight.tsx +10 -23
- package/src/AddHighlightModel/connectedHighlights.test.ts +46 -0
- package/src/AddHighlightModel/connectedHighlights.ts +35 -0
- package/src/AddHighlightModel/index.tsx +0 -1
- package/src/LaunchMsaView/components/BlastQuery/consts.ts +6 -4
- package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.ts +2 -2
- package/src/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.tsx +1 -1
- package/src/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.tsx +5 -5
- package/src/LaunchMsaView/components/calculateProteinSequence.ts +1 -1
- package/src/LaunchMsaView/components/types.ts +0 -4
- package/src/LaunchMsaView/util.ts +1 -1
- package/src/MsaViewPanel/afterCreateAutoruns.ts +90 -87
- package/src/MsaViewPanel/components/MsaViewPanel.tsx +1 -1
- package/src/MsaViewPanel/doLaunchBlast.test.ts +71 -0
- package/src/MsaViewPanel/doLaunchBlast.ts +71 -20
- package/src/MsaViewPanel/doLaunchOrthologs.ts +28 -11
- package/src/MsaViewPanel/launchAutoruns.test.ts +0 -1
- package/src/MsaViewPanel/model.test.ts +3 -4
- package/src/MsaViewPanel/model.ts +40 -10
- package/src/MsaViewPanel/msaDataStore.test.ts +13 -2
- package/src/MsaViewPanel/msaDataStore.ts +11 -25
- package/src/MsaViewPanel/observeProteinHighlights.test.ts +1 -0
- package/src/MsaViewPanel/processInit.test.ts +1 -8
- package/src/MsaViewPanel/regionSnapshot.test.ts +0 -1
- package/src/MsaViewPanel/resolveConnectedTranscript.test.ts +0 -1
- package/src/MsaViewPanel/storedData.test.ts +273 -158
- package/src/MsaViewPanel/transcriptMap.test.ts +137 -0
- package/src/MsaViewPanel/transcriptMap.ts +117 -0
- package/src/utils/browserAlign.test.ts +17 -0
- package/src/utils/browserAlign.ts +39 -25
- package/src/utils/ebiBlast.ts +12 -35
- package/src/utils/ebiJobDispatcher.ts +26 -7
- package/src/utils/eutils.test.ts +25 -0
- package/src/utils/eutils.ts +10 -3
- package/src/utils/msa.ts +6 -16
- package/src/utils/msaRows.ts +1 -1
- package/src/utils/ncbiOrthologs.test.ts +16 -0
- package/src/utils/ncbiOrthologs.ts +37 -15
- package/src/utils/ncbiTaxonomy.ts +8 -2
- package/src/utils/pantherOrthologs.ts +28 -12
- package/src/utils/phmmer.ts +7 -16
- package/src/utils/searchCache.test.ts +98 -0
- package/src/utils/searchCache.ts +75 -0
- package/src/utils/taxonomyNames.ts +7 -0
- package/src/utils/taxonomyNamesAbort.test.ts +46 -0
- package/src/utils/unirefHomologs.ts +2 -2
- package/src/utils/useFetch.ts +6 -6
- package/src/utils/useFetchAbort.test.tsx +33 -0
- package/src/version.ts +1 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { cleanProteinSequence } from '../LaunchMsaView/util';
|
|
2
2
|
import { saveBlastResult } from '../utils/blastCache';
|
|
3
|
+
import { checkPairSize } from '../utils/browserAlign';
|
|
3
4
|
import { searchBackends } from '../utils/homologSearch';
|
|
4
5
|
import { launchMSA } from '../utils/msa';
|
|
5
6
|
import { buildSearchMsa } from '../utils/msaRows';
|
|
7
|
+
import { getCachedSearch, saveSearch, searchKey } from '../utils/searchCache';
|
|
6
8
|
import { fetchTaxonomyInfo } from '../utils/taxonomyNames';
|
|
7
9
|
import { resolveUniProtEntry } from '../utils/unirefHomologs';
|
|
8
10
|
import { transcriptFields, transcriptName } from './util';
|
|
@@ -38,23 +40,13 @@ async function resolveQuery(self, scope) {
|
|
|
38
40
|
}
|
|
39
41
|
throw new Error('No query: a search needs a proteinSequence, a UniProt accession, or a connectedTranscript to translate');
|
|
40
42
|
}
|
|
41
|
-
|
|
42
|
-
* A similarity search, then an alignment of what it found. The program is a
|
|
43
|
-
* backend behind one interface (utils/homologSearch.ts); what differs between
|
|
44
|
-
* them is settled by whether the result came back aligned. A program that
|
|
45
|
-
* aligns as it searches (phmmer) hands over the alignment and the tree is
|
|
46
|
-
* built from it in the browser; one that does not (blastp) hands over bare
|
|
47
|
-
* hits and the chosen aligner runs on them.
|
|
48
|
-
*/
|
|
49
|
-
export async function doLaunchBlast({ self, scope, }) {
|
|
43
|
+
async function search({ self, scope, query, querySeqName, key, }) {
|
|
50
44
|
const params = self.blastParams;
|
|
51
|
-
const { selectedTranscript, maxHits, searchProgram = 'blastp' } = params;
|
|
52
|
-
const { sequence: query, name: querySeqName } = await resolveQuery(self, scope);
|
|
53
45
|
const { onProgress, onRid, signal } = scope;
|
|
54
|
-
const { hits, queryRow, rid } = await searchBackends[searchProgram]({
|
|
46
|
+
const { hits, queryRow, rid } = await searchBackends[params.searchProgram ?? 'blastp']({
|
|
55
47
|
query,
|
|
56
48
|
database: params.blastDatabase,
|
|
57
|
-
maxHits,
|
|
49
|
+
maxHits: params.maxHits,
|
|
58
50
|
onProgress,
|
|
59
51
|
onRid,
|
|
60
52
|
signal,
|
|
@@ -63,7 +55,7 @@ export async function doLaunchBlast({ self, scope, }) {
|
|
|
63
55
|
throw new Error('No hits found');
|
|
64
56
|
}
|
|
65
57
|
onProgress('Fetching species taxonomy info...');
|
|
66
|
-
const taxonomyInfo = await fetchTaxonomyInfo(hits.map(h => h.taxid).filter((t) => t !== undefined));
|
|
58
|
+
const taxonomyInfo = await fetchTaxonomyInfo(hits.map(h => h.taxid).filter((t) => t !== undefined), signal);
|
|
67
59
|
const { msa: fasta, treeMetadata } = buildSearchMsa({
|
|
68
60
|
hits,
|
|
69
61
|
query,
|
|
@@ -71,10 +63,44 @@ export async function doLaunchBlast({ self, scope, }) {
|
|
|
71
63
|
taxonomyInfo,
|
|
72
64
|
querySeqName,
|
|
73
65
|
});
|
|
66
|
+
if (!queryRow) {
|
|
67
|
+
await saveSearch({ id: key, fasta, treeMetadata, rid });
|
|
68
|
+
}
|
|
69
|
+
return { fasta, treeMetadata, rid, queryRow };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A similarity search, then an alignment of what it found. The program is a
|
|
73
|
+
* backend behind one interface (utils/homologSearch.ts); what differs between
|
|
74
|
+
* them is settled by whether the result came back aligned. A program that
|
|
75
|
+
* aligns as it searches (phmmer) hands over the alignment and the tree is
|
|
76
|
+
* built from it in the browser; one that does not (blastp) hands over bare
|
|
77
|
+
* hits and the chosen aligner runs on them.
|
|
78
|
+
*/
|
|
79
|
+
export async function doLaunchBlast({ self, scope, }) {
|
|
80
|
+
const params = self.blastParams;
|
|
81
|
+
const { selectedTranscript, maxHits, searchProgram = 'blastp' } = params;
|
|
82
|
+
const msaAlgorithm = params.msaAlgorithm ?? 'browser';
|
|
83
|
+
const { sequence: query, name: querySeqName } = await resolveQuery(self, scope);
|
|
84
|
+
if (searchProgram === 'blastp' && msaAlgorithm === 'browser') {
|
|
85
|
+
checkPairSize(querySeqName, query.length, query.length);
|
|
86
|
+
}
|
|
87
|
+
const { onProgress, signal } = scope;
|
|
88
|
+
const key = searchKey({
|
|
89
|
+
searchProgram,
|
|
90
|
+
database: params.blastDatabase,
|
|
91
|
+
maxHits,
|
|
92
|
+
querySeqName,
|
|
93
|
+
query,
|
|
94
|
+
});
|
|
95
|
+
const found = await getCachedSearch(key);
|
|
96
|
+
if (found) {
|
|
97
|
+
onProgress(`Reusing the ${searchProgram} search from ${new Date(found.timestamp).toLocaleString()}...`);
|
|
98
|
+
}
|
|
99
|
+
const { fasta, treeMetadata, rid, queryRow } = found ?? (await search({ self, scope, query, querySeqName, key }));
|
|
74
100
|
const { msa, tree } = queryRow
|
|
75
101
|
? { msa: fasta, tree: '' }
|
|
76
102
|
: await launchMSA({
|
|
77
|
-
algorithm:
|
|
103
|
+
algorithm: msaAlgorithm,
|
|
78
104
|
sequence: fasta,
|
|
79
105
|
onProgress,
|
|
80
106
|
signal,
|
|
@@ -84,7 +110,7 @@ export async function doLaunchBlast({ self, scope, }) {
|
|
|
84
110
|
await saveBlastResult({
|
|
85
111
|
proteinSequence: query,
|
|
86
112
|
blastDatabase: params.blastDatabase,
|
|
87
|
-
msaAlgorithm: queryRow ? undefined :
|
|
113
|
+
msaAlgorithm: queryRow ? undefined : msaAlgorithm,
|
|
88
114
|
searchProgram: params.searchProgram,
|
|
89
115
|
maxHits,
|
|
90
116
|
msa,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { beforeEach, expect, test, vi } from 'vitest';
|
|
2
2
|
import { saveBlastResult } from '../utils/blastCache';
|
|
3
|
+
import { MAX_PAIR_CELLS } from '../utils/browserAlign';
|
|
3
4
|
import { searchBackends } from '../utils/homologSearch';
|
|
4
5
|
import { launchMSA } from '../utils/msa';
|
|
6
|
+
import { getCachedSearch, saveSearch } from '../utils/searchCache';
|
|
5
7
|
import { fetchTaxonomyInfo } from '../utils/taxonomyNames';
|
|
6
8
|
import { resolveUniProtEntry } from '../utils/unirefHomologs';
|
|
7
9
|
import { doLaunchBlast } from './doLaunchBlast';
|
|
@@ -15,6 +17,11 @@ vi.mock('../utils/msa', () => ({ launchMSA: vi.fn() }));
|
|
|
15
17
|
vi.mock('../utils/taxonomyNames', () => ({ fetchTaxonomyInfo: vi.fn() }));
|
|
16
18
|
vi.mock('../utils/blastCache', () => ({ saveBlastResult: vi.fn() }));
|
|
17
19
|
vi.mock('../utils/unirefHomologs', () => ({ resolveUniProtEntry: vi.fn() }));
|
|
20
|
+
vi.mock('../utils/searchCache', async (importOriginal) => ({
|
|
21
|
+
...(await importOriginal()),
|
|
22
|
+
getCachedSearch: vi.fn(),
|
|
23
|
+
saveSearch: vi.fn(),
|
|
24
|
+
}));
|
|
18
25
|
const blastp = vi.mocked(searchBackends.blastp);
|
|
19
26
|
const phmmer = vi.mocked(searchBackends.phmmer);
|
|
20
27
|
const mockLaunchMSA = vi.mocked(launchMSA);
|
|
@@ -44,6 +51,8 @@ beforeEach(() => {
|
|
|
44
51
|
vi.clearAllMocks();
|
|
45
52
|
vi.mocked(fetchTaxonomyInfo).mockResolvedValue(new Map());
|
|
46
53
|
vi.mocked(saveBlastResult).mockResolvedValue(undefined);
|
|
54
|
+
vi.mocked(getCachedSearch).mockResolvedValue(undefined);
|
|
55
|
+
vi.mocked(saveSearch).mockResolvedValue(undefined);
|
|
47
56
|
});
|
|
48
57
|
test('bare hits go to the chosen aligner, with the query first', async () => {
|
|
49
58
|
blastp.mockResolvedValue({ rid: 'job', hits: [{ ...HIT, sequence: 'MKWV' }] });
|
|
@@ -168,3 +177,48 @@ test('a live Feature handed over in the same session labels it the same way', as
|
|
|
168
177
|
geneName: 'TP53',
|
|
169
178
|
}));
|
|
170
179
|
});
|
|
180
|
+
const BLASTP_BROWSER = {
|
|
181
|
+
searchProgram: 'blastp',
|
|
182
|
+
blastDatabase: 'uniprotkb_swissprot',
|
|
183
|
+
msaAlgorithm: 'browser',
|
|
184
|
+
proteinSequence: 'MKWVTF',
|
|
185
|
+
};
|
|
186
|
+
test('the hits are saved before the aligner runs, so a failed alignment keeps them', async () => {
|
|
187
|
+
blastp.mockResolvedValue({ rid: 'job', hits: [{ ...HIT, sequence: 'MKWV' }] });
|
|
188
|
+
mockLaunchMSA.mockRejectedValue(new Error('aligner fell over'));
|
|
189
|
+
await expect(launch(makeModel(BLASTP_BROWSER))).rejects.toThrow('aligner fell over');
|
|
190
|
+
expect(saveSearch).toHaveBeenCalledWith(expect.objectContaining({
|
|
191
|
+
fasta: '>QUERY\nMKWVTF\n>P1-Mus_musculus\nMKWV',
|
|
192
|
+
rid: 'job',
|
|
193
|
+
}));
|
|
194
|
+
});
|
|
195
|
+
test('a relaunch with saved hits aligns them without searching again', async () => {
|
|
196
|
+
vi.mocked(getCachedSearch).mockResolvedValue({
|
|
197
|
+
id: 'k',
|
|
198
|
+
fasta: '>QUERY\nMKWVTF\n>P1\nMKWV',
|
|
199
|
+
treeMetadata: {},
|
|
200
|
+
rid: 'job',
|
|
201
|
+
timestamp: 0,
|
|
202
|
+
});
|
|
203
|
+
mockLaunchMSA.mockResolvedValue({ msa: 'aligned', tree: '' });
|
|
204
|
+
const result = await launch(makeModel(BLASTP_BROWSER));
|
|
205
|
+
expect(blastp).not.toHaveBeenCalled();
|
|
206
|
+
expect(mockLaunchMSA).toHaveBeenCalledWith(expect.objectContaining({ sequence: '>QUERY\nMKWVTF\n>P1\nMKWV' }));
|
|
207
|
+
expect(result.msa).toBe('aligned');
|
|
208
|
+
});
|
|
209
|
+
test('a query too long for the in-browser aligner is refused before the search', async () => {
|
|
210
|
+
const side = Math.ceil(Math.sqrt(MAX_PAIR_CELLS)) + 1;
|
|
211
|
+
await expect(launch(makeModel({ ...BLASTP_BROWSER, proteinSequence: 'M'.repeat(side) }))).rejects.toThrow(/too large to align in the browser/);
|
|
212
|
+
expect(blastp).not.toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
test('the same long query goes to an EBI aligner without complaint', async () => {
|
|
215
|
+
const side = Math.ceil(Math.sqrt(MAX_PAIR_CELLS)) + 1;
|
|
216
|
+
blastp.mockResolvedValue({ rid: 'job', hits: [{ ...HIT, sequence: 'MKWV' }] });
|
|
217
|
+
mockLaunchMSA.mockResolvedValue({ msa: 'aligned', tree: 'tree' });
|
|
218
|
+
await launch(makeModel({
|
|
219
|
+
...BLASTP_BROWSER,
|
|
220
|
+
msaAlgorithm: 'clustalo',
|
|
221
|
+
proteinSequence: 'M'.repeat(side),
|
|
222
|
+
}));
|
|
223
|
+
expect(blastp).toHaveBeenCalled();
|
|
224
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cleanProteinSequence } from '../LaunchMsaView/util';
|
|
2
|
+
import { isAbortError } from '../utils/fetch';
|
|
2
3
|
import { launchMSA } from '../utils/msa';
|
|
3
4
|
import { dedupeLabels, fetchOrthologRows, fetchProteinForGene, resolveGeneId, } from '../utils/ncbiOrthologs';
|
|
4
5
|
import { fetchPantherOrthologs } from '../utils/pantherOrthologs';
|
|
@@ -33,6 +34,7 @@ export async function doLaunchOrthologs({ self, scope, }) {
|
|
|
33
34
|
exclude: taxId,
|
|
34
35
|
limit: maxSpecies,
|
|
35
36
|
onProgress,
|
|
37
|
+
signal,
|
|
36
38
|
};
|
|
37
39
|
const { geneId, representative, rows } = source === 'panther'
|
|
38
40
|
? await findPantherOrthologs(request)
|
|
@@ -41,7 +43,6 @@ export async function doLaunchOrthologs({ self, scope, }) {
|
|
|
41
43
|
...request,
|
|
42
44
|
identity,
|
|
43
45
|
referenceProteomesOnly,
|
|
44
|
-
signal,
|
|
45
46
|
})
|
|
46
47
|
: await findNcbiOrthologs(request);
|
|
47
48
|
// The query row. The dialog always supplies it — it is the user's OWN
|
|
@@ -68,7 +69,7 @@ export async function doLaunchOrthologs({ self, scope, }) {
|
|
|
68
69
|
// taxon is excluded from that set, but a subspecies can sanitize to the same
|
|
69
70
|
// token, and a collision would silently point the coordinate mapping at
|
|
70
71
|
// another animal's row.
|
|
71
|
-
const queryLabel = await queryRowLabel(taxId, rows);
|
|
72
|
+
const queryLabel = await queryRowLabel(taxId, rows, signal);
|
|
72
73
|
act(() => {
|
|
73
74
|
self.setQuerySeqName(queryLabel);
|
|
74
75
|
});
|
|
@@ -98,16 +99,17 @@ export async function doLaunchOrthologs({ self, scope, }) {
|
|
|
98
99
|
* wants "this gene across species" gets NCBI's own order, which leads with the
|
|
99
100
|
* reference organisms.
|
|
100
101
|
*/
|
|
101
|
-
async function findNcbiOrthologs({ taxId, geneCandidates, onProgress, ...rest }) {
|
|
102
|
+
async function findNcbiOrthologs({ taxId, geneCandidates, onProgress, signal, ...rest }) {
|
|
102
103
|
onProgress('Resolving gene at NCBI...');
|
|
103
|
-
const resolved = await resolveGeneId(geneCandidates, taxId);
|
|
104
|
+
const resolved = await resolveGeneId(geneCandidates, taxId, signal);
|
|
104
105
|
if (!resolved) {
|
|
105
106
|
throw new Error(`Could not resolve any of ${geneCandidates.join(', ')} to an NCBI gene in taxon ${taxId}. Try the NCBI BLAST tab, which needs no gene identifier.`);
|
|
106
107
|
}
|
|
107
|
-
const representative = await fetchRepresentativeQueryProtein(resolved.geneId);
|
|
108
|
+
const representative = await fetchRepresentativeQueryProtein(resolved.geneId, signal);
|
|
108
109
|
const rows = await fetchOrthologRows({
|
|
109
110
|
geneId: resolved.geneId,
|
|
110
111
|
onProgress,
|
|
112
|
+
signal,
|
|
111
113
|
...rest,
|
|
112
114
|
});
|
|
113
115
|
return { geneId: resolved.geneId, representative, rows };
|
|
@@ -122,12 +124,11 @@ const sourceNames = {
|
|
|
122
124
|
* entry the cluster was looked up from, whose sequence a spec-launched row
|
|
123
125
|
* takes and whose accession earns the CDD overlay when the two match.
|
|
124
126
|
*/
|
|
125
|
-
async function findUnirefHomologs({ geneCandidates, identity, referenceProteomesOnly,
|
|
127
|
+
async function findUnirefHomologs({ geneCandidates, identity, referenceProteomesOnly, ...rest }) {
|
|
126
128
|
const found = await fetchUnirefHomologs({
|
|
127
129
|
candidates: geneCandidates,
|
|
128
130
|
identity,
|
|
129
131
|
referenceProteomesOnly,
|
|
130
|
-
signal,
|
|
131
132
|
...rest,
|
|
132
133
|
});
|
|
133
134
|
return {
|
|
@@ -160,13 +161,16 @@ async function findPantherOrthologs({ geneCandidates, ...rest }) {
|
|
|
160
161
|
* marker when NCBI cannot name the taxon, which is a naming failure and must not
|
|
161
162
|
* take down the launch.
|
|
162
163
|
*/
|
|
163
|
-
async function queryRowLabel(taxId, rows) {
|
|
164
|
+
async function queryRowLabel(taxId, rows, signal) {
|
|
164
165
|
let name;
|
|
165
166
|
try {
|
|
166
|
-
const info = (await fetchTaxonomyInfo([taxId])).get(taxId);
|
|
167
|
+
const info = (await fetchTaxonomyInfo([taxId], signal)).get(taxId);
|
|
167
168
|
name = info?.commonName ?? info?.sciname;
|
|
168
169
|
}
|
|
169
170
|
catch (e) {
|
|
171
|
+
if (isAbortError(e)) {
|
|
172
|
+
throw e;
|
|
173
|
+
}
|
|
170
174
|
console.warn('[msaview-orthologs] taxonomy name lookup failed:', e);
|
|
171
175
|
}
|
|
172
176
|
return dedupeLabels([
|
|
@@ -179,11 +183,14 @@ async function queryRowLabel(taxId, rows) {
|
|
|
179
183
|
* that supplied no sequence of its own, the alignment — so it is reported by
|
|
180
184
|
* returning nothing rather than by throwing here.
|
|
181
185
|
*/
|
|
182
|
-
async function fetchRepresentativeQueryProtein(geneId) {
|
|
186
|
+
async function fetchRepresentativeQueryProtein(geneId, signal) {
|
|
183
187
|
try {
|
|
184
|
-
return await fetchProteinForGene(geneId);
|
|
188
|
+
return await fetchProteinForGene(geneId, signal);
|
|
185
189
|
}
|
|
186
190
|
catch (e) {
|
|
191
|
+
if (isAbortError(e)) {
|
|
192
|
+
throw e;
|
|
193
|
+
}
|
|
187
194
|
console.warn('[msaview-orthologs] query protein lookup failed:', e);
|
|
188
195
|
return undefined;
|
|
189
196
|
}
|
|
@@ -8,7 +8,6 @@ vi.mock('@jbrowse/core/util', async (importOriginal) => ({
|
|
|
8
8
|
vi.mock('./doLaunchBlast', () => ({ doLaunchBlast: vi.fn() }));
|
|
9
9
|
vi.mock('./msaDataStore', () => ({
|
|
10
10
|
cleanupOldData: vi.fn(async () => { }),
|
|
11
|
-
deleteMsaData: vi.fn(async () => { }),
|
|
12
11
|
generateDataStoreId: vi.fn(),
|
|
13
12
|
retrieveMsaData: vi.fn(),
|
|
14
13
|
storeMsaData: vi.fn(),
|
|
@@ -933,19 +933,14 @@ export default function stateModelFactory(): import("@jbrowse/mobx-state-tree").
|
|
|
933
933
|
loadingStoredData: boolean;
|
|
934
934
|
isStoringData: boolean;
|
|
935
935
|
lastStoredData: MsaDataPayload | undefined;
|
|
936
|
+
ownsDataStoreRow: boolean;
|
|
936
937
|
launchController: AbortController | undefined;
|
|
937
938
|
domainsRequested: boolean;
|
|
938
939
|
} & {
|
|
939
940
|
/**
|
|
940
941
|
* #getter
|
|
941
942
|
*/
|
|
942
|
-
readonly transcriptToMsaMap:
|
|
943
|
-
g2p: Record<number, number>;
|
|
944
|
-
p2g: Record<number, number>;
|
|
945
|
-
p2gCodon: Record<number, number[]>;
|
|
946
|
-
refName: string;
|
|
947
|
-
strand: number;
|
|
948
|
-
} | undefined;
|
|
943
|
+
readonly transcriptToMsaMap: import("./transcriptMap").TranscriptMap | undefined;
|
|
949
944
|
/**
|
|
950
945
|
* #getter
|
|
951
946
|
*/
|
|
@@ -983,6 +978,13 @@ export default function stateModelFactory(): import("@jbrowse/mobx-state-tree").
|
|
|
983
978
|
* alignment the link does carry.
|
|
984
979
|
*/
|
|
985
980
|
readonly hostRestoresData: boolean;
|
|
981
|
+
/**
|
|
982
|
+
* #getter
|
|
983
|
+
* the documents a reload would lose: too big for react-msaview to keep
|
|
984
|
+
* in the snapshot, and with no filehandle or kept init to refetch them
|
|
985
|
+
* from. IndexedDB holds exactly these.
|
|
986
|
+
*/
|
|
987
|
+
readonly unsavedDocuments: MsaDataPayload;
|
|
986
988
|
} & {
|
|
987
989
|
/**
|
|
988
990
|
* #action
|
|
@@ -1040,6 +1042,10 @@ export default function stateModelFactory(): import("@jbrowse/mobx-state-tree").
|
|
|
1040
1042
|
* #action
|
|
1041
1043
|
*/
|
|
1042
1044
|
setLastStoredData(arg?: MsaDataPayload): void;
|
|
1045
|
+
/**
|
|
1046
|
+
* #action
|
|
1047
|
+
*/
|
|
1048
|
+
setOwnsDataStoreRow(arg: boolean): void;
|
|
1043
1049
|
/**
|
|
1044
1050
|
* #action
|
|
1045
1051
|
*/
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { BaseViewModel } from '@jbrowse/core/pluggableElementTypes';
|
|
2
2
|
import { getSession } from '@jbrowse/core/util';
|
|
3
|
-
import { addDisposer, types } from '@jbrowse/mobx-state-tree';
|
|
4
|
-
import { genomeToTranscriptSeqMapping } from 'g2p_mapper';
|
|
3
|
+
import { addDisposer, getSnapshot, types } from '@jbrowse/mobx-state-tree';
|
|
5
4
|
import { autorun } from 'mobx';
|
|
6
5
|
import { MSAModelF } from 'react-msaview';
|
|
7
6
|
import { autoLoadProteinDomains, launchBlastIfNeeded, launchOrthologsIfNeeded, loadStoredData, observeProteinHighlights, processInit, runCleanup, storeDataToIndexedDB, syncGenomeHoverToMsaColumn, } from './afterCreateAutoruns';
|
|
8
7
|
import { msaCoordToGenomeCoord, msaCoordToGenomeRegions, } from './msaCoordToGenomeCoord';
|
|
9
|
-
import { deleteMsaData } from './msaDataStore';
|
|
10
8
|
import { resolveConnectedTranscriptIfNeeded } from './resolveConnectedTranscript';
|
|
9
|
+
import { transcriptMap } from './transcriptMap';
|
|
11
10
|
/**
|
|
12
11
|
* Where the homolog set comes from. NCBI's ortholog sets cover vertebrates and
|
|
13
12
|
* insects; PANTHER's span its 144 reference proteomes, human to yeast to
|
|
@@ -132,6 +131,13 @@ export default function stateModelFactory() {
|
|
|
132
131
|
* neighbor-joining tree, say) is recognized as needing a new one
|
|
133
132
|
*/
|
|
134
133
|
lastStoredData: undefined,
|
|
134
|
+
/**
|
|
135
|
+
* #volatile
|
|
136
|
+
* whether this view wrote the row `dataStoreId` names, this session;
|
|
137
|
+
* a restored row may be shared with a copied view or a duplicated
|
|
138
|
+
* session, so a write to one the view did not create takes a fresh id
|
|
139
|
+
*/
|
|
140
|
+
ownsDataStoreRow: false,
|
|
135
141
|
/**
|
|
136
142
|
* #volatile
|
|
137
143
|
* aborts the launch currently in flight -- from the Cancel button, and
|
|
@@ -153,7 +159,7 @@ export default function stateModelFactory() {
|
|
|
153
159
|
*/
|
|
154
160
|
get transcriptToMsaMap() {
|
|
155
161
|
return self.connectedFeature
|
|
156
|
-
?
|
|
162
|
+
? transcriptMap(self.connectedFeature)
|
|
157
163
|
: undefined;
|
|
158
164
|
},
|
|
159
165
|
/**
|
|
@@ -219,6 +225,26 @@ export default function stateModelFactory() {
|
|
|
219
225
|
// away for every view this one does not speak for
|
|
220
226
|
return self.hostCarriesData || !!(msaIndexedLocation && msaName);
|
|
221
227
|
},
|
|
228
|
+
/**
|
|
229
|
+
* #getter
|
|
230
|
+
* the documents a reload would lose: too big for react-msaview to keep
|
|
231
|
+
* in the snapshot, and with no filehandle or kept init to refetch them
|
|
232
|
+
* from. IndexedDB holds exactly these.
|
|
233
|
+
*/
|
|
234
|
+
get unsavedDocuments() {
|
|
235
|
+
const { data, init } = self;
|
|
236
|
+
const inSnapshot = getSnapshot(data);
|
|
237
|
+
const unsaved = (key, source) => {
|
|
238
|
+
const text = data[key];
|
|
239
|
+
return text && !source && !inSnapshot[key] ? text : undefined;
|
|
240
|
+
};
|
|
241
|
+
return {
|
|
242
|
+
msa: unsaved('msa', self.msaFilehandle ?? init?.msaIndexedLocation),
|
|
243
|
+
tree: unsaved('tree', self.treeFilehandle),
|
|
244
|
+
treeMetadata: unsaved('treeMetadata', self.treeMetadataFilehandle),
|
|
245
|
+
gff: unsaved('gff', self.gffFilehandle),
|
|
246
|
+
};
|
|
247
|
+
},
|
|
222
248
|
}))
|
|
223
249
|
.actions(self => ({
|
|
224
250
|
/**
|
|
@@ -305,6 +331,12 @@ export default function stateModelFactory() {
|
|
|
305
331
|
setLastStoredData(arg) {
|
|
306
332
|
self.lastStoredData = arg;
|
|
307
333
|
},
|
|
334
|
+
/**
|
|
335
|
+
* #action
|
|
336
|
+
*/
|
|
337
|
+
setOwnsDataStoreRow(arg) {
|
|
338
|
+
self.ownsDataStoreRow = arg;
|
|
339
|
+
},
|
|
308
340
|
/**
|
|
309
341
|
* #action
|
|
310
342
|
*/
|
|
@@ -414,7 +446,7 @@ export default function stateModelFactory() {
|
|
|
414
446
|
* once per view, never fired again.
|
|
415
447
|
*/
|
|
416
448
|
reset() {
|
|
417
|
-
const { displayName, minimized, zoomToBaseLevel
|
|
449
|
+
const { displayName, minimized, zoomToBaseLevel } = self;
|
|
418
450
|
superReset();
|
|
419
451
|
if (displayName !== undefined) {
|
|
420
452
|
self.setDisplayName(displayName);
|
|
@@ -423,10 +455,7 @@ export default function stateModelFactory() {
|
|
|
423
455
|
self.setZoomToBaseLevel(zoomToBaseLevel);
|
|
424
456
|
self.setDomainsRequested(false);
|
|
425
457
|
self.setLastStoredData(undefined);
|
|
426
|
-
|
|
427
|
-
// nothing points at that row now
|
|
428
|
-
void deleteMsaData(dataStoreId);
|
|
429
|
-
}
|
|
458
|
+
self.setOwnsDataStoreRow(false);
|
|
430
459
|
},
|
|
431
460
|
};
|
|
432
461
|
})
|
|
@@ -464,8 +493,8 @@ export default function stateModelFactory() {
|
|
|
464
493
|
addDisposer(self, () => {
|
|
465
494
|
self.launchController?.abort();
|
|
466
495
|
});
|
|
496
|
+
loadStoredData(self);
|
|
467
497
|
for (const fn of [
|
|
468
|
-
loadStoredData,
|
|
469
498
|
storeDataToIndexedDB,
|
|
470
499
|
resolveConnectedTranscriptIfNeeded,
|
|
471
500
|
launchBlastIfNeeded,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
2
|
import { doLaunchBlast } from './doLaunchBlast';
|
|
3
3
|
import stateModelFactory from './model';
|
|
4
|
-
import { deleteMsaData } from './msaDataStore';
|
|
5
4
|
// the launch autoruns are live on a real model, and a blastParams write is what
|
|
6
5
|
// wakes them -- so the search is mocked rather than sent to EBI. The session is
|
|
7
6
|
// mocked for the same reason: the hover and highlight autoruns reach for one on
|
|
@@ -13,7 +12,6 @@ vi.mock('@jbrowse/core/util', async (importOriginal) => ({
|
|
|
13
12
|
vi.mock('./doLaunchBlast', () => ({ doLaunchBlast: vi.fn() }));
|
|
14
13
|
vi.mock('./msaDataStore', () => ({
|
|
15
14
|
cleanupOldData: vi.fn(async () => { }),
|
|
16
|
-
deleteMsaData: vi.fn(async () => { }),
|
|
17
15
|
generateDataStoreId: vi.fn(),
|
|
18
16
|
retrieveMsaData: vi.fn(),
|
|
19
17
|
storeMsaData: vi.fn(),
|
|
@@ -152,15 +150,16 @@ describe('returning to the import form', () => {
|
|
|
152
150
|
expect(model.zoomToBaseLevel).toBe(true);
|
|
153
151
|
expect(model.dataInitialized).toBe(false);
|
|
154
152
|
});
|
|
155
|
-
test('clears the volatiles applySnapshot cannot reach, and the stored
|
|
153
|
+
test('clears the volatiles applySnapshot cannot reach, and the stored id', () => {
|
|
156
154
|
const model = view();
|
|
157
155
|
model.setDomainsRequested(true);
|
|
158
156
|
model.setLastStoredData({ msa: '>a\nMK' });
|
|
159
157
|
model.setDataStoreId('msa-1');
|
|
158
|
+
model.setOwnsDataStoreRow(true);
|
|
160
159
|
model.reset();
|
|
161
160
|
expect(model.domainsRequested).toBe(false);
|
|
162
161
|
expect(model.lastStoredData).toBeUndefined();
|
|
162
|
+
expect(model.ownsDataStoreRow).toBe(false);
|
|
163
163
|
expect(model.dataStoreId).toBeUndefined();
|
|
164
|
-
expect(deleteMsaData).toHaveBeenCalledWith('msa-1');
|
|
165
164
|
});
|
|
166
165
|
});
|
|
@@ -7,12 +7,11 @@ export interface MsaDataPayload {
|
|
|
7
7
|
}
|
|
8
8
|
export declare function generateDataStoreId(): string;
|
|
9
9
|
export declare function storeMsaData(id: string, data: MsaDataPayload): Promise<boolean>;
|
|
10
|
+
/** undefined only when no row has the id; a failed read throws */
|
|
10
11
|
export declare function retrieveMsaData(id: string): Promise<{
|
|
11
12
|
msa?: string;
|
|
12
13
|
tree?: string;
|
|
13
14
|
treeMetadata?: string;
|
|
14
15
|
gff?: string;
|
|
15
16
|
} | undefined>;
|
|
16
|
-
/** drop one view's row, for a reset that just orphaned it */
|
|
17
|
-
export declare function deleteMsaData(id: string): Promise<void>;
|
|
18
17
|
export declare function cleanupOldData(maxAgeMs?: number): Promise<number>;
|
|
@@ -23,39 +23,24 @@ export async function storeMsaData(id, data) {
|
|
|
23
23
|
return false;
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
+
/** undefined only when no row has the id; a failed read throws */
|
|
26
27
|
export async function retrieveMsaData(id) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (!result) {
|
|
31
|
-
return undefined;
|
|
32
|
-
}
|
|
33
|
-
// reading counts as use, which is what makes cleanupOldData's policy
|
|
34
|
-
// "unused for 7 days" rather than "written 7 days ago" -- a session opened
|
|
35
|
-
// every day used to lose its alignment on the eighth
|
|
36
|
-
try {
|
|
37
|
-
await db.put(STORE_NAME, { ...result, timestamp: Date.now() });
|
|
38
|
-
}
|
|
39
|
-
catch (e) {
|
|
40
|
-
console.warn('Failed to refresh MSA data timestamp:', e);
|
|
41
|
-
}
|
|
42
|
-
const { id: _id, timestamp: _timestamp, ...payload } = result;
|
|
43
|
-
return payload;
|
|
44
|
-
}
|
|
45
|
-
catch (e) {
|
|
46
|
-
console.warn('Failed to retrieve MSA data:', e);
|
|
28
|
+
const db = await getDB();
|
|
29
|
+
const result = await db.get(STORE_NAME, id);
|
|
30
|
+
if (!result) {
|
|
47
31
|
return undefined;
|
|
48
32
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
33
|
+
// reading counts as use, which is what makes cleanupOldData's policy
|
|
34
|
+
// "unused for 7 days" rather than "written 7 days ago" -- a session opened
|
|
35
|
+
// every day used to lose its alignment on the eighth
|
|
52
36
|
try {
|
|
53
|
-
|
|
54
|
-
await db.delete(STORE_NAME, id);
|
|
37
|
+
await db.put(STORE_NAME, { ...result, timestamp: Date.now() });
|
|
55
38
|
}
|
|
56
39
|
catch (e) {
|
|
57
|
-
console.warn('Failed to
|
|
40
|
+
console.warn('Failed to refresh MSA data timestamp:', e);
|
|
58
41
|
}
|
|
42
|
+
const { id: _id, timestamp: _timestamp, ...payload } = result;
|
|
43
|
+
return payload;
|
|
59
44
|
}
|
|
60
45
|
export async function cleanupOldData(maxAgeMs = 7 * 24 * 60 * 60 * 1000) {
|
|
61
46
|
try {
|
|
@@ -4,11 +4,13 @@ import { retrieveMsaData, storeMsaData } from './msaDataStore';
|
|
|
4
4
|
// switch for the write half so a browser that refuses writes can be played back.
|
|
5
5
|
const { rows, state } = vi.hoisted(() => ({
|
|
6
6
|
rows: new Map(),
|
|
7
|
-
state: { putFails: false },
|
|
7
|
+
state: { putFails: false, getFails: false },
|
|
8
8
|
}));
|
|
9
9
|
vi.mock('../utils/idb', () => ({
|
|
10
10
|
createDbOpener: () => () => Promise.resolve({
|
|
11
|
-
get: (_store, id) =>
|
|
11
|
+
get: (_store, id) => state.getFails
|
|
12
|
+
? Promise.reject(new Error('InvalidStateError'))
|
|
13
|
+
: Promise.resolve(rows.get(id)),
|
|
12
14
|
put: (_store, value) => {
|
|
13
15
|
if (state.putFails) {
|
|
14
16
|
return Promise.reject(new Error('QuotaExceededError'));
|
|
@@ -21,6 +23,7 @@ vi.mock('../utils/idb', () => ({
|
|
|
21
23
|
beforeEach(() => {
|
|
22
24
|
rows.clear();
|
|
23
25
|
state.putFails = false;
|
|
26
|
+
state.getFails = false;
|
|
24
27
|
});
|
|
25
28
|
// cleanupOldData deletes by timestamp, so without this a session opened every
|
|
26
29
|
// single day still lost its alignment on the eighth
|
|
@@ -34,6 +37,11 @@ test('a row that is no longer there reads as undefined and writes nothing', asyn
|
|
|
34
37
|
expect(await retrieveMsaData('msa-gone')).toBeUndefined();
|
|
35
38
|
expect(rows.size).toBe(0);
|
|
36
39
|
});
|
|
40
|
+
test('a read that fails throws rather than reading as a missing row', async () => {
|
|
41
|
+
rows.set('msa-1', { id: 'msa-1', msa: '>a\nMK', timestamp: 1000 });
|
|
42
|
+
state.getFails = true;
|
|
43
|
+
await expect(retrieveMsaData('msa-1')).rejects.toThrow('InvalidStateError');
|
|
44
|
+
});
|
|
37
45
|
// the refresh is housekeeping; failing it must not turn a readable alignment
|
|
38
46
|
// into a missing one, which is what the caller reports as expired
|
|
39
47
|
test('a refresh that fails still hands back the data it read', async () => {
|
|
@@ -24,6 +24,7 @@ function makeModel({ highlightColumns, querySeqOffset = 0, } = {}) {
|
|
|
24
24
|
// g2p is indexed by genome coord; identity keeps the arithmetic out of the way
|
|
25
25
|
transcriptToMsaMap: {
|
|
26
26
|
g2p: Object.fromEntries([...Array(200).keys()].map(i => [i, i])),
|
|
27
|
+
codingPositions: [...Array(200).keys()],
|
|
27
28
|
},
|
|
28
29
|
highlightColumns,
|
|
29
30
|
highlightedColumns: undefined,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { beforeEach, expect, test, vi } from 'vitest';
|
|
2
|
-
import { processInit
|
|
2
|
+
import { processInit } from './afterCreateAutoruns';
|
|
3
3
|
import { fetchIndexedMsa } from './fetchIndexedMsa';
|
|
4
4
|
vi.mock('./fetchIndexedMsa', () => ({ fetchIndexedMsa: vi.fn() }));
|
|
5
5
|
vi.mock('./msaDataStore', () => ({
|
|
@@ -64,9 +64,3 @@ test('a url init is still resolved once and cleared', async () => {
|
|
|
64
64
|
expect(model.setMSAFilehandle).toHaveBeenCalled();
|
|
65
65
|
expect(model.setInit).toHaveBeenCalledWith(undefined);
|
|
66
66
|
});
|
|
67
|
-
test('an indexed view writes no IndexedDB row', async () => {
|
|
68
|
-
const model = makeModel({ rows: [['a', 'MK']], data: { msa: '>a\nMK' } });
|
|
69
|
-
storeDataToIndexedDB(model);
|
|
70
|
-
await settle();
|
|
71
|
-
expect(model.setDataStoreId).not.toHaveBeenCalled();
|
|
72
|
-
});
|
|
@@ -6,7 +6,6 @@ vi.mock('@jbrowse/core/util', async (importOriginal) => ({
|
|
|
6
6
|
}));
|
|
7
7
|
vi.mock('./msaDataStore', () => ({
|
|
8
8
|
cleanupOldData: vi.fn(async () => { }),
|
|
9
|
-
deleteMsaData: vi.fn(async () => { }),
|
|
10
9
|
generateDataStoreId: vi.fn(),
|
|
11
10
|
retrieveMsaData: vi.fn(),
|
|
12
11
|
storeMsaData: vi.fn(async () => true),
|