jbrowse-plugin-msaview 3.6.0 → 3.7.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/README.md +8 -0
- package/dist/LaunchMsaView/cleanProteinSequence.test.d.ts +1 -0
- package/dist/LaunchMsaView/cleanProteinSequence.test.js +77 -0
- package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +29 -32
- package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.js +10 -15
- package/dist/LaunchMsaView/components/BlastQuery/CachedBlastResults.js +14 -13
- package/dist/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.js +25 -22
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +14 -19
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.d.ts +2 -1
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.js +18 -2
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.test.d.ts +1 -0
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.test.js +13 -0
- package/dist/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.js +19 -14
- package/dist/LaunchMsaView/components/SubmitCancelActions.d.ts +3 -1
- package/dist/LaunchMsaView/components/SubmitCancelActions.js +10 -7
- package/dist/LaunchMsaView/components/SubmitCancelActions.test.js +20 -2
- package/dist/LaunchMsaView/components/calculateProteinSequence.d.ts +8 -1
- package/dist/LaunchMsaView/components/calculateProteinSequence.js +25 -20
- package/dist/LaunchMsaView/components/launchConnectedView.d.ts +23 -0
- package/dist/LaunchMsaView/components/launchConnectedView.js +39 -0
- package/dist/LaunchMsaView/detectQueryRow.js +4 -7
- package/dist/LaunchMsaView/util.d.ts +6 -0
- package/dist/LaunchMsaView/util.js +7 -1
- package/dist/LaunchMsaViewExtensionPoint/index.test.js +15 -0
- package/dist/MsaViewPanel/afterCreateAutoruns.js +12 -3
- package/dist/MsaViewPanel/applyRegion.d.ts +23 -0
- package/dist/MsaViewPanel/applyRegion.js +19 -0
- package/dist/MsaViewPanel/applyRegion.test.d.ts +1 -0
- package/dist/MsaViewPanel/applyRegion.test.js +45 -0
- package/dist/MsaViewPanel/doLaunchBlast.js +2 -1
- package/dist/MsaViewPanel/doLaunchBlast.test.js +12 -0
- package/dist/MsaViewPanel/doLaunchOrthologs.test.js +1 -1
- package/dist/MsaViewPanel/genomeToMSA.js +3 -3
- package/dist/MsaViewPanel/genomeToMSA.test.js +69 -23
- package/dist/MsaViewPanel/launchAutoruns.test.d.ts +1 -0
- package/dist/MsaViewPanel/launchAutoruns.test.js +36 -0
- package/dist/MsaViewPanel/model.d.ts +18 -14
- package/dist/MsaViewPanel/model.js +17 -0
- package/dist/MsaViewPanel/msaDataStore.d.ts +5 -3
- package/dist/MsaViewPanel/msaDataStore.js +3 -12
- package/dist/MsaViewPanel/msaDataStore.test.js +8 -1
- package/dist/MsaViewPanel/observeProteinHighlights.test.js +4 -4
- package/dist/MsaViewPanel/regionSnapshot.test.d.ts +1 -0
- package/dist/MsaViewPanel/regionSnapshot.test.js +29 -0
- package/dist/MsaViewPanel/resolveConnectedTranscript.d.ts +41 -4
- package/dist/MsaViewPanel/resolveConnectedTranscript.js +103 -68
- package/dist/MsaViewPanel/resolveConnectedTranscript.test.d.ts +1 -0
- package/dist/MsaViewPanel/resolveConnectedTranscript.test.js +149 -0
- package/dist/MsaViewPanel/runLaunch.d.ts +8 -4
- package/dist/MsaViewPanel/runLaunch.js +15 -4
- package/dist/MsaViewPanel/runLaunch.test.js +16 -0
- package/dist/MsaViewPanel/storedData.test.js +23 -0
- package/dist/MsaViewPanel/syncGenomeHoverToMsaColumn.test.js +3 -2
- package/dist/MsaViewPanel/util.d.ts +23 -23
- package/dist/MsaViewPanel/util.js +23 -24
- package/dist/index.js +17 -10
- package/dist/jbrowse-plugin-msaview.umd.production.min.js +36 -36
- package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
- package/dist/utils/blastCache.d.ts +26 -2
- package/dist/utils/blastCache.js +51 -39
- package/dist/utils/blastCache.test.js +46 -19
- package/dist/utils/domainCache.d.ts +1 -1
- package/dist/utils/domainCache.js +18 -14
- package/dist/utils/ebiJobDispatcher.d.ts +11 -0
- package/dist/utils/ebiJobDispatcher.js +12 -13
- package/dist/utils/ebiJobDispatcher.test.js +17 -3
- package/dist/utils/eutils.d.ts +14 -0
- package/dist/utils/eutils.js +48 -14
- package/dist/utils/eutils.test.d.ts +1 -0
- package/dist/utils/eutils.test.js +31 -0
- package/dist/utils/idb.d.ts +16 -3
- package/dist/utils/idb.js +51 -5
- package/dist/utils/idb.test.d.ts +1 -0
- package/dist/utils/idb.test.js +78 -0
- package/dist/utils/ncbiDomains.js +4 -4
- package/dist/utils/ncbiOrthologs.d.ts +1 -1
- package/dist/utils/ncbiOrthologs.js +18 -17
- package/dist/utils/ncbiOrthologs.test.js +17 -1
- package/dist/utils/ncbiTaxonomy.js +14 -15
- package/dist/utils/taxonomyNames.js +23 -19
- package/dist/utils/taxonomyNames.test.d.ts +1 -0
- package/dist/utils/taxonomyNames.test.js +33 -0
- package/dist/utils/unirefHomologs.js +2 -2
- package/dist/utils/unirefHomologs.test.js +8 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +9 -9
- package/src/LaunchMsaView/cleanProteinSequence.test.ts +91 -0
- package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +35 -32
- package/src/LaunchMsaView/components/BlastQuery/BlastManualPanel.tsx +10 -14
- package/src/LaunchMsaView/components/BlastQuery/CachedBlastResults.tsx +16 -15
- package/src/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.tsx +34 -21
- package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +18 -21
- package/src/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.test.ts +16 -0
- package/src/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.tsx +24 -2
- package/src/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.tsx +19 -13
- package/src/LaunchMsaView/components/SubmitCancelActions.test.tsx +27 -2
- package/src/LaunchMsaView/components/SubmitCancelActions.tsx +12 -7
- package/src/LaunchMsaView/components/calculateProteinSequence.ts +28 -18
- package/src/LaunchMsaView/components/launchConnectedView.ts +56 -0
- package/src/LaunchMsaView/detectQueryRow.ts +5 -7
- package/src/LaunchMsaView/util.ts +7 -1
- package/src/LaunchMsaViewExtensionPoint/index.test.ts +16 -0
- package/src/LaunchMsaViewExtensionPoint/index.ts +28 -1
- package/src/MsaViewPanel/afterCreateAutoruns.ts +12 -3
- package/src/MsaViewPanel/applyRegion.test.ts +55 -0
- package/src/MsaViewPanel/applyRegion.ts +33 -0
- package/src/MsaViewPanel/doLaunchBlast.test.ts +20 -1
- package/src/MsaViewPanel/doLaunchBlast.ts +2 -1
- package/src/MsaViewPanel/doLaunchOrthologs.test.ts +1 -1
- package/src/MsaViewPanel/genomeToMSA.test.ts +75 -23
- package/src/MsaViewPanel/genomeToMSA.ts +3 -2
- package/src/MsaViewPanel/launchAutoruns.test.ts +45 -0
- package/src/MsaViewPanel/model.ts +22 -0
- package/src/MsaViewPanel/msaDataStore.test.ts +14 -2
- package/src/MsaViewPanel/msaDataStore.ts +4 -12
- package/src/MsaViewPanel/observeProteinHighlights.test.ts +4 -4
- package/src/MsaViewPanel/regionSnapshot.test.ts +34 -0
- package/src/MsaViewPanel/resolveConnectedTranscript.test.ts +183 -0
- package/src/MsaViewPanel/resolveConnectedTranscript.ts +154 -71
- package/src/MsaViewPanel/runLaunch.test.ts +19 -0
- package/src/MsaViewPanel/runLaunch.ts +21 -4
- package/src/MsaViewPanel/storedData.test.ts +32 -0
- package/src/MsaViewPanel/syncGenomeHoverToMsaColumn.test.ts +3 -2
- package/src/MsaViewPanel/util.ts +38 -29
- package/src/index.ts +19 -10
- package/src/utils/blastCache.test.ts +59 -20
- package/src/utils/blastCache.ts +62 -39
- package/src/utils/domainCache.ts +27 -15
- package/src/utils/ebiJobDispatcher.test.ts +18 -3
- package/src/utils/ebiJobDispatcher.ts +12 -16
- package/src/utils/eutils.test.ts +39 -0
- package/src/utils/eutils.ts +64 -14
- package/src/utils/idb.test.ts +106 -0
- package/src/utils/idb.ts +57 -5
- package/src/utils/ncbiDomains.ts +4 -4
- package/src/utils/ncbiOrthologs.test.ts +20 -0
- package/src/utils/ncbiOrthologs.ts +26 -28
- package/src/utils/ncbiTaxonomy.ts +17 -20
- package/src/utils/taxonomyNames.test.ts +41 -0
- package/src/utils/taxonomyNames.ts +33 -19
- package/src/utils/unirefHomologs.test.ts +11 -1
- package/src/utils/unirefHomologs.ts +2 -2
- package/src/version.ts +1 -1
- package/dist/LaunchMsaView/components/BlastQuery/blastLaunchView.d.ts +0 -16
- package/dist/LaunchMsaView/components/BlastQuery/blastLaunchView.js +0 -31
- package/dist/LaunchMsaView/components/ManualMSALoader/launchView.d.ts +0 -16
- package/dist/LaunchMsaView/components/ManualMSALoader/launchView.js +0 -16
- package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.d.ts +0 -9
- package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.js +0 -15
- package/dist/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.d.ts +0 -13
- package/dist/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.js +0 -22
- package/src/LaunchMsaView/components/BlastQuery/blastLaunchView.ts +0 -59
- package/src/LaunchMsaView/components/ManualMSALoader/launchView.ts +0 -43
- package/src/LaunchMsaView/components/OrthologQuery/orthologLaunchView.ts +0 -31
- package/src/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.ts +0 -42
|
@@ -13,6 +13,8 @@ export interface CachedBlastResult {
|
|
|
13
13
|
searchProgram?: SearchProgram;
|
|
14
14
|
/** absent on phmmer rows, which are aligned by the search itself */
|
|
15
15
|
msaAlgorithm?: MsaAlgorithm;
|
|
16
|
+
/** absent on rows saved before it was recorded */
|
|
17
|
+
maxHits?: number;
|
|
16
18
|
msa: string;
|
|
17
19
|
tree: string;
|
|
18
20
|
treeMetadata: string;
|
|
@@ -23,11 +25,33 @@ export interface CachedBlastResult {
|
|
|
23
25
|
transcriptName?: string;
|
|
24
26
|
geneName?: string;
|
|
25
27
|
}
|
|
26
|
-
|
|
28
|
+
/**
|
|
29
|
+
* One history row per distinct search. Every input that changes the stored
|
|
30
|
+
* alignment is in the key, or re-running the query with another aligner or hit
|
|
31
|
+
* count overwrites the earlier row. A part added later is left out at its old
|
|
32
|
+
* value so rows saved before it still resolve: phmmer keys are prefixed and
|
|
33
|
+
* blastp keys are not, and the hit count appears only when it is not the
|
|
34
|
+
* default.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createCacheKey({ proteinSequence, blastDatabase, msaAlgorithm, searchProgram, transcriptId, maxHits, }: {
|
|
27
37
|
proteinSequence: string;
|
|
28
38
|
blastDatabase: BlastDatabase | PhmmerDatabase;
|
|
29
39
|
msaAlgorithm?: MsaAlgorithm;
|
|
30
40
|
searchProgram?: SearchProgram;
|
|
41
|
+
transcriptId?: string;
|
|
42
|
+
maxHits?: number;
|
|
43
|
+
}): string;
|
|
44
|
+
/**
|
|
45
|
+
* Record a finished search in the history. Best effort: the alignment is
|
|
46
|
+
* already in hand, and a browser refusing the write must not turn it into a
|
|
47
|
+
* failed launch.
|
|
48
|
+
*/
|
|
49
|
+
export declare function saveBlastResult({ proteinSequence, blastDatabase, msaAlgorithm, searchProgram, maxHits, msa, tree, treeMetadata, rid, geneId, transcriptId, transcriptName, geneName, }: {
|
|
50
|
+
proteinSequence: string;
|
|
51
|
+
blastDatabase: BlastDatabase | PhmmerDatabase;
|
|
52
|
+
msaAlgorithm?: MsaAlgorithm;
|
|
53
|
+
searchProgram?: SearchProgram;
|
|
54
|
+
maxHits?: number;
|
|
31
55
|
msa: string;
|
|
32
56
|
tree: string;
|
|
33
57
|
treeMetadata: string;
|
|
@@ -36,6 +60,6 @@ export declare function saveBlastResult({ proteinSequence, blastDatabase, msaAlg
|
|
|
36
60
|
transcriptId?: string;
|
|
37
61
|
transcriptName?: string;
|
|
38
62
|
geneName?: string;
|
|
39
|
-
}): Promise<CachedBlastResult>;
|
|
63
|
+
}): Promise<CachedBlastResult | undefined>;
|
|
40
64
|
export declare function getAllCachedResults(): Promise<CachedBlastResult[]>;
|
|
41
65
|
export declare function deleteCachedResult(id: string): Promise<void>;
|
package/dist/utils/blastCache.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { defaultMaxHits } from '../LaunchMsaView/components/BlastQuery/consts';
|
|
2
|
+
import { bestEffort, createDbOpener } from './idb';
|
|
2
3
|
const DB_NAME = 'jbrowse-msaview-blast-cache';
|
|
3
4
|
const STORE_NAME = 'blast-results';
|
|
4
5
|
const DB_VERSION = 2;
|
|
@@ -16,46 +17,57 @@ const getDB = createDbOpener(DB_NAME, DB_VERSION, (db, oldVersion) => {
|
|
|
16
17
|
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
|
17
18
|
}
|
|
18
19
|
});
|
|
19
|
-
|
|
20
|
+
/**
|
|
21
|
+
* One history row per distinct search. Every input that changes the stored
|
|
22
|
+
* alignment is in the key, or re-running the query with another aligner or hit
|
|
23
|
+
* count overwrites the earlier row. A part added later is left out at its old
|
|
24
|
+
* value so rows saved before it still resolve: phmmer keys are prefixed and
|
|
25
|
+
* blastp keys are not, and the hit count appears only when it is not the
|
|
26
|
+
* default.
|
|
27
|
+
*/
|
|
28
|
+
export function createCacheKey({ proteinSequence, blastDatabase, msaAlgorithm, searchProgram, transcriptId, maxHits, }) {
|
|
20
29
|
const idPart = transcriptId ? `:${transcriptId}` : '';
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
// msaAlgorithm is part of the key because the stored msa/tree are produced by
|
|
27
|
-
// it — without it, re-running the same query under a different algorithm
|
|
28
|
-
// overwrites the earlier result and drops it from the history list
|
|
29
|
-
return `${blastDatabase}:${msaAlgorithm}${idPart}:${proteinSequence}`;
|
|
30
|
+
const hitsPart = maxHits === undefined || maxHits === defaultMaxHits ? '' : `:n${maxHits}`;
|
|
31
|
+
return searchProgram === 'phmmer'
|
|
32
|
+
? `phmmer:${blastDatabase}${idPart}${hitsPart}:${proteinSequence}`
|
|
33
|
+
: `${blastDatabase}:${msaAlgorithm}${idPart}${hitsPart}:${proteinSequence}`;
|
|
30
34
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Record a finished search in the history. Best effort: the alignment is
|
|
37
|
+
* already in hand, and a browser refusing the write must not turn it into a
|
|
38
|
+
* failed launch.
|
|
39
|
+
*/
|
|
40
|
+
export function saveBlastResult({ proteinSequence, blastDatabase, msaAlgorithm, searchProgram, maxHits, msa, tree, treeMetadata, rid, geneId, transcriptId, transcriptName, geneName, }) {
|
|
41
|
+
return bestEffort('BLAST history write', async () => {
|
|
42
|
+
const db = await getDB();
|
|
43
|
+
const entry = {
|
|
44
|
+
id: createCacheKey({
|
|
45
|
+
proteinSequence,
|
|
46
|
+
blastDatabase,
|
|
47
|
+
msaAlgorithm,
|
|
48
|
+
searchProgram,
|
|
49
|
+
transcriptId,
|
|
50
|
+
maxHits,
|
|
51
|
+
}),
|
|
52
|
+
proteinSequence,
|
|
53
|
+
blastDatabase,
|
|
54
|
+
msaAlgorithm,
|
|
55
|
+
searchProgram,
|
|
56
|
+
maxHits,
|
|
57
|
+
msa,
|
|
58
|
+
tree,
|
|
59
|
+
treeMetadata,
|
|
60
|
+
rid,
|
|
61
|
+
timestamp: Date.now(),
|
|
62
|
+
geneId,
|
|
63
|
+
transcriptId,
|
|
64
|
+
transcriptName,
|
|
65
|
+
geneName,
|
|
66
|
+
};
|
|
67
|
+
await db.put(STORE_NAME, entry);
|
|
68
|
+
await evictOldest(db);
|
|
69
|
+
return entry;
|
|
70
|
+
}, undefined);
|
|
59
71
|
}
|
|
60
72
|
/**
|
|
61
73
|
* Drop the oldest rows until the store is back at MAX_CACHED_RESULTS.
|
|
@@ -1,29 +1,33 @@
|
|
|
1
|
-
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
|
2
|
-
import { getAllCachedResults, saveBlastResult } from './blastCache';
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { createCacheKey, getAllCachedResults, saveBlastResult, } from './blastCache';
|
|
3
3
|
// An in-memory stand-in for the one object store this module opens. Only the
|
|
4
4
|
// idb calls blastCache makes are implemented; anything else would be untested
|
|
5
5
|
// scaffolding.
|
|
6
|
-
const { rows } = vi.hoisted(() => ({
|
|
6
|
+
const { rows, store } = vi.hoisted(() => ({
|
|
7
7
|
rows: new Map(),
|
|
8
|
+
store: { refuses: false },
|
|
8
9
|
}));
|
|
9
|
-
vi.mock('./idb', () => ({
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
transaction: () => ({
|
|
18
|
-
store: {
|
|
19
|
-
delete: (id) => {
|
|
20
|
-
rows.delete(id);
|
|
21
|
-
return Promise.resolve();
|
|
22
|
-
},
|
|
10
|
+
vi.mock('./idb', async (importOriginal) => ({
|
|
11
|
+
...(await importOriginal()),
|
|
12
|
+
createDbOpener: () => () => store.refuses
|
|
13
|
+
? Promise.reject(new DOMException('site data blocked', 'SecurityError'))
|
|
14
|
+
: Promise.resolve({
|
|
15
|
+
put: (_store, value) => {
|
|
16
|
+
rows.set(value.id, value);
|
|
17
|
+
return Promise.resolve(value.id);
|
|
23
18
|
},
|
|
24
|
-
|
|
19
|
+
count: () => Promise.resolve(rows.size),
|
|
20
|
+
getAll: () => Promise.resolve([...rows.values()]),
|
|
21
|
+
transaction: () => ({
|
|
22
|
+
store: {
|
|
23
|
+
delete: (id) => {
|
|
24
|
+
rows.delete(id);
|
|
25
|
+
return Promise.resolve();
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
done: Promise.resolve(),
|
|
29
|
+
}),
|
|
25
30
|
}),
|
|
26
|
-
}),
|
|
27
31
|
}));
|
|
28
32
|
function save(n) {
|
|
29
33
|
return saveBlastResult({
|
|
@@ -38,6 +42,7 @@ function save(n) {
|
|
|
38
42
|
}
|
|
39
43
|
beforeEach(() => {
|
|
40
44
|
rows.clear();
|
|
45
|
+
store.refuses = false;
|
|
41
46
|
// strictly increasing, so "oldest" is unambiguous -- 55 saves in one
|
|
42
47
|
// millisecond would otherwise all carry the same timestamp
|
|
43
48
|
let clock = 1;
|
|
@@ -70,3 +75,25 @@ test('re-saving the same query overwrites its row rather than growing the store'
|
|
|
70
75
|
await save(0);
|
|
71
76
|
expect(rows.size).toBe(50);
|
|
72
77
|
});
|
|
78
|
+
// the alignment is already in hand by the time it is saved, so a browser that
|
|
79
|
+
// refuses the write must not turn a finished search into a failed launch
|
|
80
|
+
test('a store that refuses the write does not fail the save', async () => {
|
|
81
|
+
vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
82
|
+
store.refuses = true;
|
|
83
|
+
await expect(save(1)).resolves.toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
describe('createCacheKey', () => {
|
|
86
|
+
const base = {
|
|
87
|
+
proteinSequence: 'MKV',
|
|
88
|
+
blastDatabase: 'uniprotkb_swissprot',
|
|
89
|
+
msaAlgorithm: 'clustalo',
|
|
90
|
+
};
|
|
91
|
+
test('a different hit count is a different row', () => {
|
|
92
|
+
expect(createCacheKey({ ...base, maxHits: 50 })).not.toBe(createCacheKey({ ...base, maxHits: 500 }));
|
|
93
|
+
});
|
|
94
|
+
// rows saved before the hit count was in the key must still resolve
|
|
95
|
+
test('the default hit count keeps the key it always had', () => {
|
|
96
|
+
expect(createCacheKey({ ...base, maxHits: 100 })).toBe('uniprotkb_swissprot:clustalo:MKV');
|
|
97
|
+
expect(createCacheKey(base)).toBe('uniprotkb_swissprot:clustalo:MKV');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -4,5 +4,5 @@ interface CachedDomain {
|
|
|
4
4
|
matches: DomainMatch[];
|
|
5
5
|
}
|
|
6
6
|
export declare function getCachedDomains(accessions: string[]): Promise<(CachedDomain | undefined)[]>;
|
|
7
|
-
export declare function saveDomains(entries: CachedDomain[]): Promise<void>;
|
|
7
|
+
export declare function saveDomains(entries: CachedDomain[]): Promise<void | undefined>;
|
|
8
8
|
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createDbOpener } from './idb';
|
|
1
|
+
import { bestEffort, createDbOpener } from './idb';
|
|
2
2
|
const DB_NAME = 'jbrowse-msaview-domain-cache';
|
|
3
3
|
const STORE_NAME = 'domains';
|
|
4
4
|
const DB_VERSION = 1;
|
|
@@ -7,18 +7,22 @@ const getDB = createDbOpener(DB_NAME, DB_VERSION, db => {
|
|
|
7
7
|
db.createObjectStore(STORE_NAME, { keyPath: 'accession' });
|
|
8
8
|
}
|
|
9
9
|
});
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
export function getCachedDomains(accessions) {
|
|
11
|
+
return bestEffort('domain cache read', async () => {
|
|
12
|
+
const db = await getDB();
|
|
13
|
+
const tx = db.transaction(STORE_NAME, 'readonly');
|
|
14
|
+
const results = await Promise.all(accessions.map(accession => tx.store.get(accession)));
|
|
15
|
+
await tx.done;
|
|
16
|
+
return results;
|
|
17
|
+
}, []);
|
|
16
18
|
}
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
export function saveDomains(entries) {
|
|
20
|
+
return bestEffort('domain cache write', async () => {
|
|
21
|
+
const db = await getDB();
|
|
22
|
+
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
await tx.store.put(entry);
|
|
25
|
+
}
|
|
26
|
+
await tx.done;
|
|
27
|
+
}, undefined);
|
|
24
28
|
}
|
|
@@ -21,6 +21,17 @@ export declare function submitEbiJob({ tool, params, signal, }: {
|
|
|
21
21
|
params: Record<string, string>;
|
|
22
22
|
signal?: AbortSignal;
|
|
23
23
|
}): Promise<string>;
|
|
24
|
+
/**
|
|
25
|
+
* A status check that could not reach EBI at all says nothing about the job, so
|
|
26
|
+
* it is not a reason to abandon one. A job the server has accepted keeps running
|
|
27
|
+
* whatever happens to the poller's connection, and EBI's cluster has answered
|
|
28
|
+
* status checks with a 502 for ten minutes on jobs that then finished (see
|
|
29
|
+
* docs/blast.md).
|
|
30
|
+
*
|
|
31
|
+
* So the poll gives up only once checks have failed without a break for this
|
|
32
|
+
* long: an endpoint that has genuinely gone away must not be polled forever.
|
|
33
|
+
*/
|
|
34
|
+
export declare const STATUS_FAILURE_BUDGET_MS: number;
|
|
24
35
|
export declare function waitForEbiJob({ tool, jobId, intervalSeconds, onCountdown, signal, }: {
|
|
25
36
|
tool: string;
|
|
26
37
|
jobId: string;
|
|
@@ -35,17 +35,16 @@ export async function submitEbiJob({ tool, params, signal, }) {
|
|
|
35
35
|
/**
|
|
36
36
|
* A status check that could not reach EBI at all says nothing about the job, so
|
|
37
37
|
* it is not a reason to abandon one. A job the server has accepted keeps running
|
|
38
|
-
* whatever happens to the poller's connection, and
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* that went on to finish.
|
|
38
|
+
* whatever happens to the poller's connection, and EBI's cluster has answered
|
|
39
|
+
* status checks with a 502 for ten minutes on jobs that then finished (see
|
|
40
|
+
* docs/blast.md).
|
|
42
41
|
*
|
|
43
|
-
*
|
|
44
|
-
* away must not be polled forever.
|
|
42
|
+
* So the poll gives up only once checks have failed without a break for this
|
|
43
|
+
* long: an endpoint that has genuinely gone away must not be polled forever.
|
|
45
44
|
*/
|
|
46
|
-
const
|
|
45
|
+
export const STATUS_FAILURE_BUDGET_MS = 15 * 60 * 1000;
|
|
47
46
|
export async function waitForEbiJob({ tool, jobId, intervalSeconds = 10, onCountdown, signal, }) {
|
|
48
|
-
let
|
|
47
|
+
let failingSince;
|
|
49
48
|
await pollLoop({
|
|
50
49
|
intervalSeconds,
|
|
51
50
|
onCountdown,
|
|
@@ -61,14 +60,14 @@ export async function waitForEbiJob({ tool, jobId, intervalSeconds = 10, onCount
|
|
|
61
60
|
if (isAbortError(e)) {
|
|
62
61
|
throw e;
|
|
63
62
|
}
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
throw new Error(`Could not reach EBI to check ${tool} job ${jobId}
|
|
63
|
+
failingSince ??= Date.now();
|
|
64
|
+
if (Date.now() - failingSince >= STATUS_FAILURE_BUDGET_MS) {
|
|
65
|
+
throw new Error(`Could not reach EBI to check ${tool} job ${jobId} for ${Math.round(STATUS_FAILURE_BUDGET_MS / 60_000)} minutes`, { cause: e });
|
|
67
66
|
}
|
|
68
|
-
console.warn(
|
|
67
|
+
console.warn('[msaview] EBI status check failed, retrying:', e);
|
|
69
68
|
return false;
|
|
70
69
|
}
|
|
71
|
-
|
|
70
|
+
failingSince = undefined;
|
|
72
71
|
// exact match, not includes(): a job whose status is ERROR must not be
|
|
73
72
|
// able to poll forever waiting for a FINISHED that will never arrive
|
|
74
73
|
if (status === 'FINISHED') {
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
-
import { waitForEbiJob } from './ebiJobDispatcher';
|
|
2
|
+
import { STATUS_FAILURE_BUDGET_MS, waitForEbiJob } from './ebiJobDispatcher';
|
|
3
3
|
// A long alignment is polled every ten seconds for minutes, so the chance of one
|
|
4
4
|
// failed status check somewhere in that window is not small -- and the job is
|
|
5
5
|
// running at EBI regardless of what happens to the poller's connection.
|
|
6
6
|
describe('waitForEbiJob', () => {
|
|
7
7
|
afterEach(() => {
|
|
8
8
|
vi.unstubAllGlobals();
|
|
9
|
+
vi.useRealTimers();
|
|
10
|
+
vi.restoreAllMocks();
|
|
9
11
|
});
|
|
10
|
-
|
|
12
|
+
// each status check lands `minutesPerCheck` after the last, so a long outage
|
|
13
|
+
// is minutes of clock rather than minutes of test
|
|
14
|
+
const statuses = (seq, minutesPerCheck = 0) => {
|
|
15
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
16
|
+
vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
11
17
|
let i = 0;
|
|
12
18
|
vi.stubGlobal('fetch', () => {
|
|
19
|
+
vi.setSystemTime(Date.now() + minutesPerCheck * 60_000);
|
|
13
20
|
const next = seq[Math.min(i++, seq.length - 1)];
|
|
14
21
|
return next instanceof Error
|
|
15
22
|
? Promise.reject(next)
|
|
@@ -44,9 +51,16 @@ describe('waitForEbiJob', () => {
|
|
|
44
51
|
]);
|
|
45
52
|
await expect(wait()).resolves.toBeUndefined();
|
|
46
53
|
});
|
|
54
|
+
test('ten minutes of failed checks do not abandon a job that then finishes', async () => {
|
|
55
|
+
const outage = Array.from({ length: 10 }, () => new Error('HTTP 502'));
|
|
56
|
+
statuses(['RUNNING', ...outage, 'FINISHED'], 1);
|
|
57
|
+
await expect(wait()).resolves.toBeUndefined();
|
|
58
|
+
});
|
|
47
59
|
test('an endpoint that has genuinely gone away is not polled forever', async () => {
|
|
48
|
-
statuses([new TypeError('Failed to fetch')]);
|
|
60
|
+
statuses([new TypeError('Failed to fetch')], 1);
|
|
61
|
+
const start = Date.now();
|
|
49
62
|
await expect(wait()).rejects.toThrow(/Could not reach EBI/);
|
|
63
|
+
expect(Date.now() - start).toBeGreaterThanOrEqual(STATUS_FAILURE_BUDGET_MS);
|
|
50
64
|
});
|
|
51
65
|
test('a job EBI reports as failed still ends the poll immediately', async () => {
|
|
52
66
|
statuses(['ERROR']);
|
package/dist/utils/eutils.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export declare const NCBI_TOOL = "jbrowse-plugin-msaview";
|
|
2
2
|
export declare const NCBI_EMAIL = "colin.diesh@gmail.com";
|
|
3
|
+
type Endpoint = 'efetch' | 'esearch' | 'esummary';
|
|
4
|
+
export declare function eutilsUrl(endpoint: Endpoint, params: Record<string, string>): string;
|
|
3
5
|
export declare function efetchUrl(params: Record<string, string>): string;
|
|
4
6
|
/**
|
|
5
7
|
* The same request as a POST body. An `id` list of a few hundred accessions
|
|
@@ -10,3 +12,15 @@ export declare function efetchPost(params: Record<string, string>): readonly ["h
|
|
|
10
12
|
readonly method: "POST";
|
|
11
13
|
readonly body: URLSearchParams;
|
|
12
14
|
}];
|
|
15
|
+
/**
|
|
16
|
+
* eutils allows 3 requests a second without an API key and answers a burst
|
|
17
|
+
* over that with no `Access-Control-Allow-Origin`, which the browser reports as
|
|
18
|
+
* a CORS failure. Every eutils request takes the next start slot, so however
|
|
19
|
+
* many callers ask at once the requests go out under the limit.
|
|
20
|
+
*/
|
|
21
|
+
export declare const EUTILS_SPACING_MS = 350;
|
|
22
|
+
export declare function eutilsText(url: string, init?: RequestInit): Promise<string>;
|
|
23
|
+
export declare function eutilsJson<T>(url: string, init?: RequestInit): Promise<T>;
|
|
24
|
+
/** Text lifted out of eutils XML still carries its escapes: `5'-3'`. */
|
|
25
|
+
export declare function decodeXmlEntities(text: string): string;
|
|
26
|
+
export {};
|
package/dist/utils/eutils.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
+
import { jsonfetch, textfetch, timeout } from './fetch';
|
|
1
2
|
// NCBI asks that programmatic E-utilities requests identify themselves with a
|
|
2
3
|
// tool name and contact email so they can reach out before throttling, rather
|
|
3
4
|
// than silently rate-limiting. https://www.ncbi.nlm.nih.gov/books/NBK25497/
|
|
4
5
|
export const NCBI_TOOL = 'jbrowse-plugin-msaview';
|
|
5
6
|
export const NCBI_EMAIL = 'colin.diesh@gmail.com';
|
|
6
7
|
const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
|
|
8
|
+
function identified(params) {
|
|
9
|
+
return new URLSearchParams({ ...params, tool: NCBI_TOOL, email: NCBI_EMAIL });
|
|
10
|
+
}
|
|
11
|
+
export function eutilsUrl(endpoint, params) {
|
|
12
|
+
return `${EUTILS}/${endpoint}.fcgi?${identified(params).toString()}`;
|
|
13
|
+
}
|
|
7
14
|
export function efetchUrl(params) {
|
|
8
|
-
|
|
9
|
-
...params,
|
|
10
|
-
tool: NCBI_TOOL,
|
|
11
|
-
email: NCBI_EMAIL,
|
|
12
|
-
});
|
|
13
|
-
return `${EUTILS}/efetch.fcgi?${search.toString()}`;
|
|
15
|
+
return eutilsUrl('efetch', params);
|
|
14
16
|
}
|
|
15
17
|
/**
|
|
16
18
|
* The same request as a POST body. An `id` list of a few hundred accessions
|
|
@@ -20,13 +22,45 @@ export function efetchUrl(params) {
|
|
|
20
22
|
export function efetchPost(params) {
|
|
21
23
|
return [
|
|
22
24
|
`${EUTILS}/efetch.fcgi`,
|
|
23
|
-
{
|
|
24
|
-
method: 'POST',
|
|
25
|
-
body: new URLSearchParams({
|
|
26
|
-
...params,
|
|
27
|
-
tool: NCBI_TOOL,
|
|
28
|
-
email: NCBI_EMAIL,
|
|
29
|
-
}),
|
|
30
|
-
},
|
|
25
|
+
{ method: 'POST', body: identified(params) },
|
|
31
26
|
];
|
|
32
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* eutils allows 3 requests a second without an API key and answers a burst
|
|
30
|
+
* over that with no `Access-Control-Allow-Origin`, which the browser reports as
|
|
31
|
+
* a CORS failure. Every eutils request takes the next start slot, so however
|
|
32
|
+
* many callers ask at once the requests go out under the limit.
|
|
33
|
+
*/
|
|
34
|
+
export const EUTILS_SPACING_MS = 350;
|
|
35
|
+
let nextSlot = 0;
|
|
36
|
+
async function eutilsSlot(signal) {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const start = Math.max(now, nextSlot);
|
|
39
|
+
nextSlot = start + EUTILS_SPACING_MS;
|
|
40
|
+
if (start > now) {
|
|
41
|
+
await timeout(start - now, signal ?? undefined);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function eutilsText(url, init) {
|
|
45
|
+
await eutilsSlot(init?.signal);
|
|
46
|
+
return textfetch(url, init);
|
|
47
|
+
}
|
|
48
|
+
export async function eutilsJson(url, init) {
|
|
49
|
+
await eutilsSlot(init?.signal);
|
|
50
|
+
return jsonfetch(url, init);
|
|
51
|
+
}
|
|
52
|
+
const namedEntities = {
|
|
53
|
+
amp: '&',
|
|
54
|
+
apos: "'",
|
|
55
|
+
gt: '>',
|
|
56
|
+
lt: '<',
|
|
57
|
+
quot: '"',
|
|
58
|
+
};
|
|
59
|
+
/** Text lifted out of eutils XML still carries its escapes: `5'-3'`. */
|
|
60
|
+
export function decodeXmlEntities(text) {
|
|
61
|
+
return text.replaceAll(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (entity, name) => name.startsWith('#x') || name.startsWith('#X')
|
|
62
|
+
? String.fromCodePoint(Number.parseInt(name.slice(2), 16))
|
|
63
|
+
: name.startsWith('#')
|
|
64
|
+
? String.fromCodePoint(Number(name.slice(1)))
|
|
65
|
+
: (namedEntities[name] ?? entity));
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { afterEach, expect, test, vi } from 'vitest';
|
|
2
|
+
import { EUTILS_SPACING_MS, decodeXmlEntities, eutilsText } from './eutils';
|
|
3
|
+
afterEach(() => {
|
|
4
|
+
vi.unstubAllGlobals();
|
|
5
|
+
vi.useRealTimers();
|
|
6
|
+
});
|
|
7
|
+
test('decodes the escapes GenPept and taxonomy XML carry', () => {
|
|
8
|
+
expect(decodeXmlEntities('5'-3' exonuclease')).toBe("5'-3' exonuclease");
|
|
9
|
+
expect(decodeXmlEntities('A & B <C> "D"')).toBe('A & B <C> "D"');
|
|
10
|
+
expect(decodeXmlEntities(''A&unknown;')).toBe("'A&unknown;");
|
|
11
|
+
});
|
|
12
|
+
// a burst over three a second comes back without CORS headers, which the
|
|
13
|
+
// browser reports as a CORS failure for an endpoint that sends ACAO: *
|
|
14
|
+
test('concurrent eutils requests go out spaced under the rate limit', async () => {
|
|
15
|
+
vi.useFakeTimers();
|
|
16
|
+
const sentAt = [];
|
|
17
|
+
vi.stubGlobal('fetch', () => {
|
|
18
|
+
sentAt.push(Date.now());
|
|
19
|
+
return Promise.resolve({
|
|
20
|
+
ok: true,
|
|
21
|
+
status: 200,
|
|
22
|
+
text: () => Promise.resolve(''),
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
const all = Promise.all([eutilsText('a'), eutilsText('b'), eutilsText('c')]);
|
|
26
|
+
await vi.advanceTimersByTimeAsync(3 * EUTILS_SPACING_MS);
|
|
27
|
+
await all;
|
|
28
|
+
expect(sentAt).toHaveLength(3);
|
|
29
|
+
expect(sentAt[1] - sentAt[0]).toBeGreaterThanOrEqual(EUTILS_SPACING_MS);
|
|
30
|
+
expect(sentAt[2] - sentAt[1]).toBeGreaterThanOrEqual(EUTILS_SPACING_MS);
|
|
31
|
+
});
|
package/dist/utils/idb.d.ts
CHANGED
|
@@ -2,11 +2,24 @@ import type { DBSchema, IDBPDatabase, OpenDBCallbacks } from 'idb';
|
|
|
2
2
|
/**
|
|
3
3
|
* Memoized `openDB` for a typed schema, shared by this plugin's caches.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Callers share one connection. The opener forgets it whenever it stops being
|
|
6
|
+
* usable, so the next call opens a fresh one instead of replaying a rejection
|
|
7
|
+
* or waiting on a dead handle: when the open fails (IndexedDB is unavailable in
|
|
8
|
+
* some private-browsing modes), when another tab is blocked upgrading the
|
|
9
|
+
* database and this connection steps aside for it, and when the browser
|
|
10
|
+
* terminates the connection.
|
|
11
|
+
*
|
|
12
|
+
* An open blocked by another tab's older connection rejects rather than waiting
|
|
13
|
+
* for that tab to close, which may never happen, since a build that predates
|
|
14
|
+
* `blocking` never steps aside.
|
|
8
15
|
*
|
|
9
16
|
* Passing a DBSchema is what keeps `get`/`getAll` from returning `any`: with an
|
|
10
17
|
* untyped database every cached record reaches the UI unchecked.
|
|
11
18
|
*/
|
|
12
19
|
export declare function createDbOpener<T extends DBSchema>(name: string, version: number, upgrade: OpenDBCallbacks<T>['upgrade']): () => Promise<IDBPDatabase<T>>;
|
|
20
|
+
/**
|
|
21
|
+
* Run a cache operation that the caller can do without. A cache only saves
|
|
22
|
+
* work, so a read that fails answers `fallback` (a miss) and a write that fails
|
|
23
|
+
* is skipped: neither may cost the user a search that already finished.
|
|
24
|
+
*/
|
|
25
|
+
export declare function bestEffort<R>(what: string, operation: () => Promise<R>, fallback: R): Promise<R>;
|
package/dist/utils/idb.js
CHANGED
|
@@ -2,20 +2,66 @@ import { openDB } from 'idb';
|
|
|
2
2
|
/**
|
|
3
3
|
* Memoized `openDB` for a typed schema, shared by this plugin's caches.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Callers share one connection. The opener forgets it whenever it stops being
|
|
6
|
+
* usable, so the next call opens a fresh one instead of replaying a rejection
|
|
7
|
+
* or waiting on a dead handle: when the open fails (IndexedDB is unavailable in
|
|
8
|
+
* some private-browsing modes), when another tab is blocked upgrading the
|
|
9
|
+
* database and this connection steps aside for it, and when the browser
|
|
10
|
+
* terminates the connection.
|
|
11
|
+
*
|
|
12
|
+
* An open blocked by another tab's older connection rejects rather than waiting
|
|
13
|
+
* for that tab to close, which may never happen, since a build that predates
|
|
14
|
+
* `blocking` never steps aside.
|
|
8
15
|
*
|
|
9
16
|
* Passing a DBSchema is what keeps `get`/`getAll` from returning `any`: with an
|
|
10
17
|
* untyped database every cached record reaches the UI unchecked.
|
|
11
18
|
*/
|
|
12
19
|
export function createDbOpener(name, version, upgrade) {
|
|
13
20
|
let dbPromise;
|
|
21
|
+
const forget = () => {
|
|
22
|
+
dbPromise = undefined;
|
|
23
|
+
};
|
|
14
24
|
return () => {
|
|
15
|
-
dbPromise ??=
|
|
16
|
-
|
|
25
|
+
dbPromise ??= new Promise((resolve, reject) => {
|
|
26
|
+
let gaveUp = false;
|
|
27
|
+
openDB(name, version, {
|
|
28
|
+
upgrade,
|
|
29
|
+
blocked(currentVersion) {
|
|
30
|
+
gaveUp = true;
|
|
31
|
+
reject(new Error(`${name} is held open at version ${currentVersion} by another tab`));
|
|
32
|
+
},
|
|
33
|
+
blocking(_currentVersion, _blockedVersion, event) {
|
|
34
|
+
;
|
|
35
|
+
event.target.close();
|
|
36
|
+
forget();
|
|
37
|
+
},
|
|
38
|
+
terminated: forget,
|
|
39
|
+
}).then(db => {
|
|
40
|
+
if (gaveUp) {
|
|
41
|
+
db.close();
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
resolve(db);
|
|
45
|
+
}
|
|
46
|
+
}, reject);
|
|
47
|
+
}).catch((e) => {
|
|
48
|
+
forget();
|
|
17
49
|
throw e;
|
|
18
50
|
});
|
|
19
51
|
return dbPromise;
|
|
20
52
|
};
|
|
21
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Run a cache operation that the caller can do without. A cache only saves
|
|
56
|
+
* work, so a read that fails answers `fallback` (a miss) and a write that fails
|
|
57
|
+
* is skipped: neither may cost the user a search that already finished.
|
|
58
|
+
*/
|
|
59
|
+
export async function bestEffort(what, operation, fallback) {
|
|
60
|
+
try {
|
|
61
|
+
return await operation();
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
console.warn(`[msaview] ${what} failed, continuing without it:`, e);
|
|
65
|
+
return fallback;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|