jbrowse-plugin-msaview 3.4.0 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +4 -6
- package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.d.ts +7 -0
- package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.js +42 -0
- package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.d.ts +1 -0
- package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.js +32 -0
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +2 -1
- package/dist/LaunchMsaViewExtensionPoint/index.js +11 -6
- package/dist/LaunchMsaViewExtensionPoint/index.test.d.ts +1 -0
- package/dist/LaunchMsaViewExtensionPoint/index.test.js +43 -0
- package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +15 -0
- package/dist/MsaViewPanel/afterCreateAutoruns.js +93 -70
- package/dist/MsaViewPanel/components/LaunchProgress.js +11 -2
- package/dist/MsaViewPanel/components/MsaViewPanel.test.js +19 -0
- package/dist/MsaViewPanel/doLaunchBlast.d.ts +3 -1
- package/dist/MsaViewPanel/doLaunchBlast.js +10 -12
- package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +3 -1
- package/dist/MsaViewPanel/doLaunchOrthologs.js +6 -5
- package/dist/MsaViewPanel/doLaunchOrthologs.test.js +38 -24
- package/dist/MsaViewPanel/model.d.ts +160 -10
- package/dist/MsaViewPanel/model.js +51 -1
- package/dist/MsaViewPanel/msaDataStore.d.ts +5 -3
- package/dist/MsaViewPanel/msaDataStore.js +16 -7
- package/dist/MsaViewPanel/msaDataStore.test.d.ts +1 -0
- package/dist/MsaViewPanel/msaDataStore.test.js +44 -0
- package/dist/MsaViewPanel/runLaunch.d.ts +38 -0
- package/dist/MsaViewPanel/runLaunch.js +65 -0
- package/dist/MsaViewPanel/runLaunch.test.d.ts +1 -0
- package/dist/MsaViewPanel/runLaunch.test.js +129 -0
- package/dist/MsaViewPanel/storedData.test.d.ts +1 -0
- package/dist/MsaViewPanel/storedData.test.js +160 -0
- package/dist/jbrowse-plugin-msaview.umd.production.min.js +35 -35
- package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
- package/dist/utils/blastCache.js +32 -0
- package/dist/utils/blastCache.test.d.ts +1 -0
- package/dist/utils/blastCache.test.js +72 -0
- package/dist/utils/ebiBlast.d.ts +4 -2
- package/dist/utils/ebiBlast.js +6 -4
- package/dist/utils/ebiJobDispatcher.d.ts +6 -3
- package/dist/utils/ebiJobDispatcher.js +13 -6
- package/dist/utils/fetch.d.ts +8 -1
- package/dist/utils/fetch.js +28 -2
- package/dist/utils/msa.d.ts +4 -2
- package/dist/utils/msa.js +9 -5
- package/dist/utils/ncbiDomains.d.ts +2 -2
- package/dist/utils/ncbiOrthologs.d.ts +11 -1
- package/dist/utils/ncbiOrthologs.js +26 -7
- package/dist/utils/ncbiOrthologs.test.js +23 -1
- package/dist/utils/pantherOrthologs.js +2 -10
- package/dist/utils/phmmer.d.ts +2 -1
- package/dist/utils/phmmer.js +4 -2
- package/dist/utils/poll.d.ts +6 -1
- package/dist/utils/poll.js +7 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +14 -14
- package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +7 -11
- package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.ts +43 -0
- package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.ts +64 -0
- package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +2 -2
- package/src/LaunchMsaViewExtensionPoint/index.test.ts +51 -0
- package/src/LaunchMsaViewExtensionPoint/index.ts +21 -6
- package/src/MsaViewPanel/afterCreateAutoruns.ts +100 -66
- package/src/MsaViewPanel/components/LaunchProgress.tsx +20 -2
- package/src/MsaViewPanel/components/MsaViewPanel.test.tsx +21 -0
- package/src/MsaViewPanel/doLaunchBlast.ts +14 -10
- package/src/MsaViewPanel/doLaunchOrthologs.test.ts +41 -26
- package/src/MsaViewPanel/doLaunchOrthologs.ts +8 -4
- package/src/MsaViewPanel/model.ts +54 -1
- package/src/MsaViewPanel/msaDataStore.test.ts +54 -0
- package/src/MsaViewPanel/msaDataStore.ts +22 -13
- package/src/MsaViewPanel/runLaunch.test.ts +154 -0
- package/src/MsaViewPanel/runLaunch.ts +102 -0
- package/src/MsaViewPanel/storedData.test.ts +196 -0
- package/src/utils/blastCache.test.ts +86 -0
- package/src/utils/blastCache.ts +34 -1
- package/src/utils/ebiBlast.ts +8 -2
- package/src/utils/ebiJobDispatcher.ts +18 -3
- package/src/utils/fetch.ts +29 -2
- package/src/utils/msa.ts +11 -3
- package/src/utils/ncbiDomains.ts +4 -2
- package/src/utils/ncbiOrthologs.test.ts +25 -0
- package/src/utils/ncbiOrthologs.ts +27 -7
- package/src/utils/pantherOrthologs.ts +6 -11
- package/src/utils/phmmer.ts +5 -1
- package/src/utils/poll.ts +8 -1
- package/src/version.ts +1 -1
package/dist/utils/blastCache.js
CHANGED
|
@@ -2,6 +2,12 @@ import { createDbOpener } from './idb';
|
|
|
2
2
|
const DB_NAME = 'jbrowse-msaview-blast-cache';
|
|
3
3
|
const STORE_NAME = 'blast-results';
|
|
4
4
|
const DB_VERSION = 2;
|
|
5
|
+
/**
|
|
6
|
+
* How many results the history keeps. Every row holds a whole alignment and its
|
|
7
|
+
* tree — megabytes each — so an unbounded store grows until the browser starts
|
|
8
|
+
* refusing writes to it, and the user never sees why.
|
|
9
|
+
*/
|
|
10
|
+
const MAX_CACHED_RESULTS = 50;
|
|
5
11
|
const getDB = createDbOpener(DB_NAME, DB_VERSION, (db, oldVersion) => {
|
|
6
12
|
if (oldVersion < 2 && db.objectStoreNames.contains(STORE_NAME)) {
|
|
7
13
|
db.deleteObjectStore(STORE_NAME);
|
|
@@ -48,8 +54,34 @@ export async function saveBlastResult({ proteinSequence, blastDatabase, msaAlgor
|
|
|
48
54
|
geneName,
|
|
49
55
|
};
|
|
50
56
|
await db.put(STORE_NAME, entry);
|
|
57
|
+
await evictOldest(db);
|
|
51
58
|
return entry;
|
|
52
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Drop the oldest rows until the store is back at MAX_CACHED_RESULTS.
|
|
62
|
+
*
|
|
63
|
+
* `count` first so the common save reads no values at all: without a timestamp
|
|
64
|
+
* index the oldest have to be found by loading every row, and each one is an
|
|
65
|
+
* entire alignment. Failing to evict must not fail the save — the result is
|
|
66
|
+
* already in hand and losing it to a housekeeping error would be the worse
|
|
67
|
+
* outcome.
|
|
68
|
+
*/
|
|
69
|
+
async function evictOldest(db) {
|
|
70
|
+
try {
|
|
71
|
+
if ((await db.count(STORE_NAME)) <= MAX_CACHED_RESULTS) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const all = await db.getAll(STORE_NAME);
|
|
75
|
+
const doomed = all
|
|
76
|
+
.toSorted((a, b) => a.timestamp - b.timestamp)
|
|
77
|
+
.slice(0, all.length - MAX_CACHED_RESULTS);
|
|
78
|
+
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
79
|
+
await Promise.all([...doomed.map(e => tx.store.delete(e.id)), tx.done]);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
console.warn('Failed to evict old BLAST cache entries:', e);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
53
85
|
export async function getAllCachedResults() {
|
|
54
86
|
const db = await getDB();
|
|
55
87
|
const results = await db.getAll(STORE_NAME);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
|
2
|
+
import { getAllCachedResults, saveBlastResult } from './blastCache';
|
|
3
|
+
// An in-memory stand-in for the one object store this module opens. Only the
|
|
4
|
+
// idb calls blastCache makes are implemented; anything else would be untested
|
|
5
|
+
// scaffolding.
|
|
6
|
+
const { rows } = vi.hoisted(() => ({
|
|
7
|
+
rows: new Map(),
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('./idb', () => ({
|
|
10
|
+
createDbOpener: () => () => Promise.resolve({
|
|
11
|
+
put: (_store, value) => {
|
|
12
|
+
rows.set(value.id, value);
|
|
13
|
+
return Promise.resolve(value.id);
|
|
14
|
+
},
|
|
15
|
+
count: () => Promise.resolve(rows.size),
|
|
16
|
+
getAll: () => Promise.resolve([...rows.values()]),
|
|
17
|
+
transaction: () => ({
|
|
18
|
+
store: {
|
|
19
|
+
delete: (id) => {
|
|
20
|
+
rows.delete(id);
|
|
21
|
+
return Promise.resolve();
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
done: Promise.resolve(),
|
|
25
|
+
}),
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
function save(n) {
|
|
29
|
+
return saveBlastResult({
|
|
30
|
+
proteinSequence: `SEQ${n}`,
|
|
31
|
+
blastDatabase: 'uniprotkb_swissprot',
|
|
32
|
+
msaAlgorithm: 'clustalo',
|
|
33
|
+
msa: '>a\nMK',
|
|
34
|
+
tree: '(a);',
|
|
35
|
+
treeMetadata: '{}',
|
|
36
|
+
rid: `job-${n}`,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
rows.clear();
|
|
41
|
+
// strictly increasing, so "oldest" is unambiguous -- 55 saves in one
|
|
42
|
+
// millisecond would otherwise all carry the same timestamp
|
|
43
|
+
let clock = 1;
|
|
44
|
+
vi.spyOn(Date, 'now').mockImplementation(() => clock++);
|
|
45
|
+
});
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
});
|
|
49
|
+
test('a store at the cap loses nothing', async () => {
|
|
50
|
+
for (let i = 0; i < 50; i++) {
|
|
51
|
+
await save(i);
|
|
52
|
+
}
|
|
53
|
+
expect(rows.size).toBe(50);
|
|
54
|
+
});
|
|
55
|
+
// Every row holds a whole alignment and its tree, so an unbounded store grows
|
|
56
|
+
// until the browser refuses writes to it and the user never learns why.
|
|
57
|
+
test('going over the cap drops the oldest results and keeps the newest 50', async () => {
|
|
58
|
+
for (let i = 0; i < 55; i++) {
|
|
59
|
+
await save(i);
|
|
60
|
+
}
|
|
61
|
+
expect(rows.size).toBe(50);
|
|
62
|
+
const rids = (await getAllCachedResults()).map((r) => r.rid);
|
|
63
|
+
expect(rids.at(0)).toBe('job-54');
|
|
64
|
+
expect(rids.at(-1)).toBe('job-5');
|
|
65
|
+
});
|
|
66
|
+
test('re-saving the same query overwrites its row rather than growing the store', async () => {
|
|
67
|
+
for (let i = 0; i < 50; i++) {
|
|
68
|
+
await save(i);
|
|
69
|
+
}
|
|
70
|
+
await save(0);
|
|
71
|
+
expect(rows.size).toBe(50);
|
|
72
|
+
});
|
package/dist/utils/ebiBlast.d.ts
CHANGED
|
@@ -33,18 +33,20 @@ export declare function normalizeEbiBlastHits(result: EbiBlastJson): BlastHit[];
|
|
|
33
33
|
* yet at the moment the link is on screen.
|
|
34
34
|
*/
|
|
35
35
|
export declare function ebiBlastResultUrl(jobId: string): string;
|
|
36
|
-
export declare function queryEbiBlastFromJobId({ jobId, onProgress, }: {
|
|
36
|
+
export declare function queryEbiBlastFromJobId({ jobId, onProgress, signal, }: {
|
|
37
37
|
jobId: string;
|
|
38
38
|
onProgress: (arg: string) => void;
|
|
39
|
+
signal?: AbortSignal;
|
|
39
40
|
}): Promise<{
|
|
40
41
|
rid: string;
|
|
41
42
|
hits: BlastHit[];
|
|
42
43
|
}>;
|
|
43
|
-
export declare function queryEbiBlast({ query, blastDatabase, onProgress, onRid, }: {
|
|
44
|
+
export declare function queryEbiBlast({ query, blastDatabase, onProgress, onRid, signal, }: {
|
|
44
45
|
query: string;
|
|
45
46
|
blastDatabase: BlastDatabase;
|
|
46
47
|
onProgress: (arg: string) => void;
|
|
47
48
|
onRid: (arg: string) => void;
|
|
49
|
+
signal?: AbortSignal;
|
|
48
50
|
}): Promise<{
|
|
49
51
|
rid: string;
|
|
50
52
|
hits: BlastHit[];
|
package/dist/utils/ebiBlast.js
CHANGED
|
@@ -32,22 +32,23 @@ export function normalizeEbiBlastHits(result) {
|
|
|
32
32
|
export function ebiBlastResultUrl(jobId) {
|
|
33
33
|
return `https://www.ebi.ac.uk/jdispatcher/sss/${TOOL}/summary?jobId=${jobId}`;
|
|
34
34
|
}
|
|
35
|
-
export async function queryEbiBlastFromJobId({ jobId, onProgress, }) {
|
|
35
|
+
export async function queryEbiBlastFromJobId({ jobId, onProgress, signal, }) {
|
|
36
36
|
onProgress(`Checking BLAST status for job: ${jobId}...`);
|
|
37
37
|
await waitForEbiJob({
|
|
38
38
|
tool: TOOL,
|
|
39
39
|
jobId,
|
|
40
|
+
signal,
|
|
40
41
|
onCountdown: s => {
|
|
41
42
|
onProgress(`Re-checking BLAST status in... ${s}`);
|
|
42
43
|
},
|
|
43
44
|
});
|
|
44
|
-
const hits = normalizeEbiBlastHits(JSON.parse(await fetchEbiResult({ tool: TOOL, jobId, type: 'json' })));
|
|
45
|
+
const hits = normalizeEbiBlastHits(JSON.parse(await fetchEbiResult({ tool: TOOL, jobId, type: 'json', signal })));
|
|
45
46
|
if (hits.length === 0) {
|
|
46
47
|
throw new Error('No hits found');
|
|
47
48
|
}
|
|
48
49
|
return { rid: jobId, hits };
|
|
49
50
|
}
|
|
50
|
-
export async function queryEbiBlast({ query, blastDatabase, onProgress, onRid, }) {
|
|
51
|
+
export async function queryEbiBlast({ query, blastDatabase, onProgress, onRid, signal, }) {
|
|
51
52
|
onProgress('Submitting to EBI BLAST...');
|
|
52
53
|
const jobId = await submitEbiJob({
|
|
53
54
|
tool: TOOL,
|
|
@@ -57,7 +58,8 @@ export async function queryEbiBlast({ query, blastDatabase, onProgress, onRid, }
|
|
|
57
58
|
database: blastDatabase,
|
|
58
59
|
sequence: query,
|
|
59
60
|
},
|
|
61
|
+
signal,
|
|
60
62
|
});
|
|
61
63
|
onRid(jobId);
|
|
62
|
-
return queryEbiBlastFromJobId({ jobId, onProgress });
|
|
64
|
+
return queryEbiBlastFromJobId({ jobId, onProgress, signal });
|
|
63
65
|
}
|
|
@@ -16,18 +16,21 @@ export declare const EBI_BASE = "https://www.ebi.ac.uk/Tools/services/rest";
|
|
|
16
16
|
export declare const EBI_EMAIL_STORAGE_KEY = "msa-ebiContactEmail";
|
|
17
17
|
export declare const DEFAULT_EBI_EMAIL = "colin.diesh@gmail.com";
|
|
18
18
|
export declare function getEbiEmail(): string;
|
|
19
|
-
export declare function submitEbiJob({ tool, params, }: {
|
|
19
|
+
export declare function submitEbiJob({ tool, params, signal, }: {
|
|
20
20
|
tool: string;
|
|
21
21
|
params: Record<string, string>;
|
|
22
|
+
signal?: AbortSignal;
|
|
22
23
|
}): Promise<string>;
|
|
23
|
-
export declare function waitForEbiJob({ tool, jobId, intervalSeconds, onCountdown, }: {
|
|
24
|
+
export declare function waitForEbiJob({ tool, jobId, intervalSeconds, onCountdown, signal, }: {
|
|
24
25
|
tool: string;
|
|
25
26
|
jobId: string;
|
|
26
27
|
intervalSeconds?: number;
|
|
27
28
|
onCountdown: (secondsRemaining: number) => void;
|
|
29
|
+
signal?: AbortSignal;
|
|
28
30
|
}): Promise<void>;
|
|
29
|
-
export declare function fetchEbiResult({ tool, jobId, type, }: {
|
|
31
|
+
export declare function fetchEbiResult({ tool, jobId, type, signal, }: {
|
|
30
32
|
tool: string;
|
|
31
33
|
jobId: string;
|
|
32
34
|
type: string;
|
|
35
|
+
signal?: AbortSignal;
|
|
33
36
|
}): Promise<string>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { textfetch } from './fetch';
|
|
1
|
+
import { isAbortError, textfetch } from './fetch';
|
|
2
2
|
import { pollLoop } from './poll';
|
|
3
3
|
import { readLocalStorage } from './useLocalStorage';
|
|
4
4
|
/**
|
|
@@ -24,10 +24,11 @@ export function getEbiEmail() {
|
|
|
24
24
|
}
|
|
25
25
|
/** Statuses that mean the job is over and produced no result. */
|
|
26
26
|
const FAILED_STATUSES = new Set(['ERROR', 'FAILURE', 'NOT_FOUND']);
|
|
27
|
-
export async function submitEbiJob({ tool, params, }) {
|
|
27
|
+
export async function submitEbiJob({ tool, params, signal, }) {
|
|
28
28
|
const jobId = await textfetch(`${EBI_BASE}/${tool}/run`, {
|
|
29
29
|
method: 'POST',
|
|
30
30
|
body: new URLSearchParams({ email: getEbiEmail(), ...params }),
|
|
31
|
+
signal,
|
|
31
32
|
});
|
|
32
33
|
return jobId.trim();
|
|
33
34
|
}
|
|
@@ -43,17 +44,23 @@ export async function submitEbiJob({ tool, params, }) {
|
|
|
43
44
|
* away must not be polled forever.
|
|
44
45
|
*/
|
|
45
46
|
const MAX_CONSECUTIVE_STATUS_FAILURES = 5;
|
|
46
|
-
export async function waitForEbiJob({ tool, jobId, intervalSeconds = 10, onCountdown, }) {
|
|
47
|
+
export async function waitForEbiJob({ tool, jobId, intervalSeconds = 10, onCountdown, signal, }) {
|
|
47
48
|
let consecutiveFailures = 0;
|
|
48
49
|
await pollLoop({
|
|
49
50
|
intervalSeconds,
|
|
50
51
|
onCountdown,
|
|
52
|
+
signal,
|
|
51
53
|
check: async () => {
|
|
52
54
|
let status;
|
|
53
55
|
try {
|
|
54
|
-
status = (await textfetch(`${EBI_BASE}/${tool}/status/${jobId}
|
|
56
|
+
status = (await textfetch(`${EBI_BASE}/${tool}/status/${jobId}`, { signal })).trim();
|
|
55
57
|
}
|
|
56
58
|
catch (e) {
|
|
59
|
+
// a cancelled request is the caller giving up, not EBI being
|
|
60
|
+
// unreachable, so it must end the poll rather than be retried
|
|
61
|
+
if (isAbortError(e)) {
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
57
64
|
consecutiveFailures += 1;
|
|
58
65
|
if (consecutiveFailures >= MAX_CONSECUTIVE_STATUS_FAILURES) {
|
|
59
66
|
throw new Error(`Could not reach EBI to check ${tool} job ${jobId} after ${consecutiveFailures} tries`, { cause: e });
|
|
@@ -75,6 +82,6 @@ export async function waitForEbiJob({ tool, jobId, intervalSeconds = 10, onCount
|
|
|
75
82
|
},
|
|
76
83
|
});
|
|
77
84
|
}
|
|
78
|
-
export async function fetchEbiResult({ tool, jobId, type, }) {
|
|
79
|
-
return textfetch(`${EBI_BASE}/${tool}/result/${jobId}/${type}
|
|
85
|
+
export async function fetchEbiResult({ tool, jobId, type, signal, }) {
|
|
86
|
+
return textfetch(`${EBI_BASE}/${tool}/result/${jobId}/${type}`, { signal });
|
|
80
87
|
}
|
package/dist/utils/fetch.d.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
export declare function handleFetch(url: string, args?: RequestInit): Promise<Response>;
|
|
2
2
|
export declare function textfetch(url: string, args?: RequestInit): Promise<string>;
|
|
3
3
|
export declare function jsonfetch<T>(url: string, args?: RequestInit): Promise<T>;
|
|
4
|
-
export declare function timeout(time: number): Promise<
|
|
4
|
+
export declare function timeout(time: number, signal?: AbortSignal): Promise<void>;
|
|
5
|
+
/**
|
|
6
|
+
* A cancelled launch has to be told apart from a failed one: it must not render
|
|
7
|
+
* the "failed" panel, and it must not touch a model that may already be gone.
|
|
8
|
+
* `fetch` and the sleep above both reject with a DOMException named AbortError,
|
|
9
|
+
* which subclasses Error.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isAbortError(e: unknown): boolean;
|
package/dist/utils/fetch.js
CHANGED
|
@@ -36,6 +36,32 @@ export async function jsonfetch(url, args) {
|
|
|
36
36
|
const response = await handleFetch(url, args);
|
|
37
37
|
return response.json();
|
|
38
38
|
}
|
|
39
|
-
export function timeout(time) {
|
|
40
|
-
return new Promise(
|
|
39
|
+
export function timeout(time, signal) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
if (signal?.aborted) {
|
|
42
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
// the listener is removed on the natural path too: one signal serves every
|
|
46
|
+
// tick of a poll, and a 10-minute job is ~600 sleeps -- leaving each
|
|
47
|
+
// listener behind accumulates them all on that one signal
|
|
48
|
+
const onAbort = () => {
|
|
49
|
+
clearTimeout(id);
|
|
50
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
51
|
+
};
|
|
52
|
+
const id = setTimeout(() => {
|
|
53
|
+
signal?.removeEventListener('abort', onAbort);
|
|
54
|
+
resolve();
|
|
55
|
+
}, time);
|
|
56
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A cancelled launch has to be told apart from a failed one: it must not render
|
|
61
|
+
* the "failed" panel, and it must not touch a model that may already be gone.
|
|
62
|
+
* `fetch` and the sleep above both reject with a DOMException named AbortError,
|
|
63
|
+
* which subclasses Error.
|
|
64
|
+
*/
|
|
65
|
+
export function isAbortError(e) {
|
|
66
|
+
return e instanceof Error && e.name === 'AbortError';
|
|
41
67
|
}
|
package/dist/utils/msa.d.ts
CHANGED
|
@@ -7,14 +7,16 @@ import type { MsaAlgorithm } from '../LaunchMsaView/components/BlastQuery/consts
|
|
|
7
7
|
* even if there were one. simple_phylogeny is clustalw2's neighbour-joining on
|
|
8
8
|
* a real distance matrix, Kimura-corrected for protein distances.
|
|
9
9
|
*/
|
|
10
|
-
export declare function launchTree({ alignment, onProgress, }: {
|
|
10
|
+
export declare function launchTree({ alignment, onProgress, signal, }: {
|
|
11
11
|
alignment: string;
|
|
12
12
|
onProgress: (arg: string) => void;
|
|
13
|
+
signal?: AbortSignal;
|
|
13
14
|
}): Promise<string>;
|
|
14
|
-
export declare function launchMSA({ algorithm, sequence, onProgress, }: {
|
|
15
|
+
export declare function launchMSA({ algorithm, sequence, onProgress, signal, }: {
|
|
15
16
|
algorithm: MsaAlgorithm;
|
|
16
17
|
sequence: string;
|
|
17
18
|
onProgress: (arg: string) => void;
|
|
19
|
+
signal?: AbortSignal;
|
|
18
20
|
}): Promise<{
|
|
19
21
|
msa: string;
|
|
20
22
|
tree: string;
|
package/dist/utils/msa.js
CHANGED
|
@@ -29,7 +29,7 @@ const algorithms = {
|
|
|
29
29
|
* even if there were one. simple_phylogeny is clustalw2's neighbour-joining on
|
|
30
30
|
* a real distance matrix, Kimura-corrected for protein distances.
|
|
31
31
|
*/
|
|
32
|
-
export async function launchTree({ alignment, onProgress, }) {
|
|
32
|
+
export async function launchTree({ alignment, onProgress, signal, }) {
|
|
33
33
|
const tool = 'simple_phylogeny';
|
|
34
34
|
onProgress('Building tree...');
|
|
35
35
|
const jobId = await submitEbiJob({
|
|
@@ -40,34 +40,38 @@ export async function launchTree({ alignment, onProgress, }) {
|
|
|
40
40
|
clustering: 'Neighbour-joining',
|
|
41
41
|
kimura: 'true',
|
|
42
42
|
},
|
|
43
|
+
signal,
|
|
43
44
|
});
|
|
44
45
|
await waitForEbiJob({
|
|
45
46
|
tool,
|
|
46
47
|
jobId,
|
|
48
|
+
signal,
|
|
47
49
|
onCountdown: s => {
|
|
48
50
|
onProgress(`Re-checking tree status in... ${s}`);
|
|
49
51
|
},
|
|
50
52
|
});
|
|
51
|
-
return fetchEbiResult({ tool, jobId, type: 'tree' });
|
|
53
|
+
return fetchEbiResult({ tool, jobId, type: 'tree', signal });
|
|
52
54
|
}
|
|
53
|
-
export async function launchMSA({ algorithm, sequence, onProgress, }) {
|
|
55
|
+
export async function launchMSA({ algorithm, sequence, onProgress, signal, }) {
|
|
54
56
|
const config = algorithms[algorithm];
|
|
55
57
|
onProgress(`Launching ${algorithm} MSA...`);
|
|
56
58
|
const jobId = await submitEbiJob({
|
|
57
59
|
tool: algorithm,
|
|
58
60
|
params: { ...config.params, sequence },
|
|
61
|
+
signal,
|
|
59
62
|
});
|
|
60
63
|
await waitForEbiJob({
|
|
61
64
|
tool: algorithm,
|
|
62
65
|
jobId,
|
|
66
|
+
signal,
|
|
63
67
|
onCountdown: s => {
|
|
64
68
|
onProgress(`Re-checking MSA status in... ${s}`);
|
|
65
69
|
},
|
|
66
70
|
});
|
|
67
71
|
// one finished job, two result files, neither derived from the other
|
|
68
72
|
const [msa, tree] = await Promise.all([
|
|
69
|
-
fetchEbiResult({ tool: algorithm, jobId, type: config.msaResult }),
|
|
70
|
-
fetchEbiResult({ tool: algorithm, jobId, type: config.treeResult }),
|
|
73
|
+
fetchEbiResult({ tool: algorithm, jobId, type: config.msaResult, signal }),
|
|
74
|
+
fetchEbiResult({ tool: algorithm, jobId, type: config.treeResult, signal }),
|
|
71
75
|
]);
|
|
72
76
|
return { msa, tree };
|
|
73
77
|
}
|
|
@@ -5,11 +5,11 @@ export type DomainMatch = InterProScanResults['matches'][number];
|
|
|
5
5
|
* domain and site annotations, keyed by both the versioned and primary
|
|
6
6
|
* accession so callers can look up by whichever NCBI returned.
|
|
7
7
|
*/
|
|
8
|
-
export declare function parseCddDomains(xml: string): Map<string,
|
|
8
|
+
export declare function parseCddDomains(xml: string): Map<string, DomainMatch[]>;
|
|
9
9
|
/**
|
|
10
10
|
* Fetch pre-computed CDD domain and site annotations for NCBI protein
|
|
11
11
|
* accessions. These come baked into the GenPept records, so a single batched
|
|
12
12
|
* efetch returns them with no job submission or polling. Results are cached in
|
|
13
13
|
* IndexedDB so reopening a view doesn't refetch.
|
|
14
14
|
*/
|
|
15
|
-
export declare function fetchProteinDomains(accessions: string[]): Promise<Map<string,
|
|
15
|
+
export declare function fetchProteinDomains(accessions: string[]): Promise<Map<string, DomainMatch[]>>;
|
|
@@ -10,6 +10,13 @@ export interface OrthologRow {
|
|
|
10
10
|
protein: string;
|
|
11
11
|
sequence: string;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* A candidate gene reference as the ortholog services know it: a GFF ID prefix
|
|
15
|
+
* (gene:TP53) and a version suffix (NM_000546.6) stripped off. Shared with
|
|
16
|
+
* pantherOrthologs, which asks PANTHER the same question with the same
|
|
17
|
+
* candidates.
|
|
18
|
+
*/
|
|
19
|
+
export declare function cleanGeneCandidate(raw: string): string;
|
|
13
20
|
/**
|
|
14
21
|
* A free-text gene reference -> NCBI gene id. A bare number is taken as the id
|
|
15
22
|
* itself; anything else is searched as a gene name within the query taxon.
|
|
@@ -50,7 +57,10 @@ export declare function parseFasta(text: string): Map<string, string>;
|
|
|
50
57
|
* Sanitized, unique single-token labels used identically in the FASTA headers,
|
|
51
58
|
* the tree leaf names and the domain GFF seq_ids — that identity is how the
|
|
52
59
|
* viewer pairs a tree leaf to its alignment row to its domain track. Collisions
|
|
53
|
-
* get a numeric suffix rather than silently overwriting a row
|
|
60
|
+
* get a numeric suffix rather than silently overwriting a row, and the suffix
|
|
61
|
+
* climbs past labels already emitted: a species genuinely named `human_2` would
|
|
62
|
+
* otherwise take the label the second `human` is about to get, merging two rows
|
|
63
|
+
* into one.
|
|
54
64
|
*/
|
|
55
65
|
export declare function dedupeLabels(names: string[]): string[];
|
|
56
66
|
/**
|
|
@@ -53,6 +53,18 @@ function ncbiUrl(url) {
|
|
|
53
53
|
const sep = url.includes('?') ? '&' : '?';
|
|
54
54
|
return `${url}${sep}tool=${NCBI_TOOL}&email=${encodeURIComponent(NCBI_EMAIL)}`;
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* A candidate gene reference as the ortholog services know it: a GFF ID prefix
|
|
58
|
+
* (gene:TP53) and a version suffix (NM_000546.6) stripped off. Shared with
|
|
59
|
+
* pantherOrthologs, which asks PANTHER the same question with the same
|
|
60
|
+
* candidates.
|
|
61
|
+
*/
|
|
62
|
+
export function cleanGeneCandidate(raw) {
|
|
63
|
+
return raw
|
|
64
|
+
.trim()
|
|
65
|
+
.replace(/^\w+:/, '')
|
|
66
|
+
.replace(/\.\d+$/, '');
|
|
67
|
+
}
|
|
56
68
|
/**
|
|
57
69
|
* A free-text gene reference -> NCBI gene id. A bare number is taken as the id
|
|
58
70
|
* itself; anything else is searched as a gene name within the query taxon.
|
|
@@ -69,8 +81,7 @@ export async function resolveGeneId(candidates, taxId) {
|
|
|
69
81
|
if (/^\d+$/.test(query)) {
|
|
70
82
|
return { geneId: query, matched: query };
|
|
71
83
|
}
|
|
72
|
-
|
|
73
|
-
const cleaned = query.replace(/^\w+:/, '').replace(/\.\d+$/, '');
|
|
84
|
+
const cleaned = cleanGeneCandidate(query);
|
|
74
85
|
const term = `${cleaned}[Gene Name] AND ${taxId}[taxid]`;
|
|
75
86
|
const json = await jsonfetch(ncbiUrl(`${EUTILS}/esearch.fcgi?db=gene&term=${encodeURIComponent(term)}&retmode=json&retmax=1`));
|
|
76
87
|
const geneId = json.esearchresult?.idlist?.[0];
|
|
@@ -179,15 +190,23 @@ function sanitize(name) {
|
|
|
179
190
|
* Sanitized, unique single-token labels used identically in the FASTA headers,
|
|
180
191
|
* the tree leaf names and the domain GFF seq_ids — that identity is how the
|
|
181
192
|
* viewer pairs a tree leaf to its alignment row to its domain track. Collisions
|
|
182
|
-
* get a numeric suffix rather than silently overwriting a row
|
|
193
|
+
* get a numeric suffix rather than silently overwriting a row, and the suffix
|
|
194
|
+
* climbs past labels already emitted: a species genuinely named `human_2` would
|
|
195
|
+
* otherwise take the label the second `human` is about to get, merging two rows
|
|
196
|
+
* into one.
|
|
183
197
|
*/
|
|
184
198
|
export function dedupeLabels(names) {
|
|
185
|
-
const
|
|
199
|
+
const used = new Set();
|
|
186
200
|
return names.map(name => {
|
|
187
201
|
const base = sanitize(name) || 'row';
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
202
|
+
let label = base;
|
|
203
|
+
let n = 1;
|
|
204
|
+
while (used.has(label)) {
|
|
205
|
+
n += 1;
|
|
206
|
+
label = `${base}_${n}`;
|
|
207
|
+
}
|
|
208
|
+
used.add(label);
|
|
209
|
+
return label;
|
|
191
210
|
});
|
|
192
211
|
}
|
|
193
212
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
-
import { dedupeLabels, fetchOrthologGenes, fetchRepresentativeProteins, parseFasta, } from './ncbiOrthologs';
|
|
2
|
+
import { cleanGeneCandidate, dedupeLabels, fetchOrthologGenes, fetchRepresentativeProteins, parseFasta, } from './ncbiOrthologs';
|
|
3
3
|
describe('dedupeLabels', () => {
|
|
4
4
|
test('sanitizes to single tokens', () => {
|
|
5
5
|
// labels are used identically as FASTA headers, Newick leaf names and GFF
|
|
@@ -22,6 +22,28 @@ describe('dedupeLabels', () => {
|
|
|
22
22
|
test('falls back for a name with no usable characters', () => {
|
|
23
23
|
expect(dedupeLabels(['...', '...'])).toEqual(['row', 'row_2']);
|
|
24
24
|
});
|
|
25
|
+
test('climbs past a suffix a literal name already took', () => {
|
|
26
|
+
// a label pairs a tree leaf to its alignment row, so handing the same one
|
|
27
|
+
// to two rows merges two species into one silently. The third label is ugly
|
|
28
|
+
// and unique, which is the trade
|
|
29
|
+
expect(dedupeLabels(['human', 'human', 'human_2'])).toEqual([
|
|
30
|
+
'human',
|
|
31
|
+
'human_2',
|
|
32
|
+
'human_2_2',
|
|
33
|
+
]);
|
|
34
|
+
expect(dedupeLabels(['human_2', 'human', 'human'])).toEqual([
|
|
35
|
+
'human_2',
|
|
36
|
+
'human',
|
|
37
|
+
'human_3',
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe('cleanGeneCandidate', () => {
|
|
42
|
+
test('strips what a GFF feature carries that a gene lookup does not want', () => {
|
|
43
|
+
expect(cleanGeneCandidate(' gene:TP53 ')).toBe('TP53');
|
|
44
|
+
expect(cleanGeneCandidate('NM_000546.6')).toBe('NM_000546');
|
|
45
|
+
expect(cleanGeneCandidate('TP53')).toBe('TP53');
|
|
46
|
+
});
|
|
25
47
|
});
|
|
26
48
|
describe('parseFasta', () => {
|
|
27
49
|
test('keys by the first header token and joins wrapped lines', () => {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// GenPept records with CDD Region features, so the overlay attaches as it does
|
|
17
17
|
// to a RefSeq accession.
|
|
18
18
|
import { jsonfetch } from './fetch';
|
|
19
|
-
import { dedupeLabels, defaultMaxSpecies } from './ncbiOrthologs';
|
|
19
|
+
import { cleanGeneCandidate, dedupeLabels, defaultMaxSpecies, } from './ncbiOrthologs';
|
|
20
20
|
import { fetchTaxonomyInfo } from './taxonomyNames';
|
|
21
21
|
const PANTHER = 'https://pantherdb.org/services/oai/pantherdb';
|
|
22
22
|
const UNIPROT = 'https://rest.uniprot.org/uniprotkb';
|
|
@@ -122,14 +122,6 @@ async function fetchSequences(accessions) {
|
|
|
122
122
|
}
|
|
123
123
|
return map;
|
|
124
124
|
}
|
|
125
|
-
// strip a version suffix (NM_000546.6) and any GFF ID prefix (gene:TP53), as
|
|
126
|
-
// resolveGeneId does for NCBI
|
|
127
|
-
function cleanCandidate(raw) {
|
|
128
|
-
return raw
|
|
129
|
-
.trim()
|
|
130
|
-
.replace(/^\w+:/, '')
|
|
131
|
-
.replace(/\.\d+$/, '');
|
|
132
|
-
}
|
|
133
125
|
/**
|
|
134
126
|
* One `matchortho` per candidate until PANTHER maps one. A JBrowse feature
|
|
135
127
|
* carries whatever its GFF/BigBed had — `id()`, `name`, `gene_name` — and only
|
|
@@ -139,7 +131,7 @@ function cleanCandidate(raw) {
|
|
|
139
131
|
async function matchOrthologs(candidates, taxId, targets) {
|
|
140
132
|
let matched;
|
|
141
133
|
for (const raw of candidates) {
|
|
142
|
-
const query =
|
|
134
|
+
const query = cleanGeneCandidate(raw);
|
|
143
135
|
if (!query) {
|
|
144
136
|
continue;
|
|
145
137
|
}
|
package/dist/utils/phmmer.d.ts
CHANGED
|
@@ -37,11 +37,12 @@ export declare function parsePhmmerAlignment({ stockholm, query, }: {
|
|
|
37
37
|
stockholm: string;
|
|
38
38
|
query: string;
|
|
39
39
|
}): PhmmerAlignment;
|
|
40
|
-
export declare function queryPhmmer({ query, database, onProgress, onRid, }: {
|
|
40
|
+
export declare function queryPhmmer({ query, database, onProgress, onRid, signal, }: {
|
|
41
41
|
query: string;
|
|
42
42
|
database: PhmmerDatabase;
|
|
43
43
|
onProgress: (arg: string) => void;
|
|
44
44
|
onRid: (arg: string) => void;
|
|
45
|
+
signal?: AbortSignal;
|
|
45
46
|
}): Promise<{
|
|
46
47
|
rows: PhmmerRow[];
|
|
47
48
|
/**
|
package/dist/utils/phmmer.js
CHANGED
|
@@ -88,7 +88,7 @@ export function parsePhmmerAlignment({ stockholm, query, }) {
|
|
|
88
88
|
})),
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
|
-
export async function queryPhmmer({ query, database, onProgress, onRid, }) {
|
|
91
|
+
export async function queryPhmmer({ query, database, onProgress, onRid, signal, }) {
|
|
92
92
|
onProgress('Submitting to EBI phmmer...');
|
|
93
93
|
const jobId = await submitEbiJob({
|
|
94
94
|
tool: TOOL,
|
|
@@ -98,17 +98,19 @@ export async function queryPhmmer({ query, database, onProgress, onRid, }) {
|
|
|
98
98
|
// the alignment is the whole point of using phmmer here
|
|
99
99
|
alignView: 'true',
|
|
100
100
|
},
|
|
101
|
+
signal,
|
|
101
102
|
});
|
|
102
103
|
onRid(jobId);
|
|
103
104
|
await waitForEbiJob({
|
|
104
105
|
tool: TOOL,
|
|
105
106
|
jobId,
|
|
107
|
+
signal,
|
|
106
108
|
onCountdown: s => {
|
|
107
109
|
onProgress(`Re-checking phmmer status in... ${s}`);
|
|
108
110
|
},
|
|
109
111
|
});
|
|
110
112
|
const alignment = parsePhmmerAlignment({
|
|
111
|
-
stockholm: await fetchEbiResult({ tool: TOOL, jobId, type: 'sto' }),
|
|
113
|
+
stockholm: await fetchEbiResult({ tool: TOOL, jobId, type: 'sto', signal }),
|
|
112
114
|
query,
|
|
113
115
|
});
|
|
114
116
|
if (alignment.rows.length === 0) {
|
package/dist/utils/poll.d.ts
CHANGED
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
* false when still pending, and throws on failure. Between checks it counts down
|
|
4
4
|
* `intervalSeconds`, calling `onCountdown` each second so the UI can show
|
|
5
5
|
* progress.
|
|
6
|
+
*
|
|
7
|
+
* `signal` ends the loop with an AbortError: the countdown rejects on the tick
|
|
8
|
+
* it is aborted rather than at the end of the interval, so a cancelled launch
|
|
9
|
+
* stops within a second instead of running for the job's lifetime.
|
|
6
10
|
*/
|
|
7
|
-
export declare function pollLoop({ check, intervalSeconds, onCountdown, }: {
|
|
11
|
+
export declare function pollLoop({ check, intervalSeconds, onCountdown, signal, }: {
|
|
8
12
|
check: () => Promise<boolean>;
|
|
9
13
|
intervalSeconds: number;
|
|
10
14
|
onCountdown: (secondsRemaining: number) => void;
|
|
15
|
+
signal?: AbortSignal;
|
|
11
16
|
}): Promise<void>;
|
package/dist/utils/poll.js
CHANGED
|
@@ -4,16 +4,21 @@ import { timeout } from './fetch';
|
|
|
4
4
|
* false when still pending, and throws on failure. Between checks it counts down
|
|
5
5
|
* `intervalSeconds`, calling `onCountdown` each second so the UI can show
|
|
6
6
|
* progress.
|
|
7
|
+
*
|
|
8
|
+
* `signal` ends the loop with an AbortError: the countdown rejects on the tick
|
|
9
|
+
* it is aborted rather than at the end of the interval, so a cancelled launch
|
|
10
|
+
* stops within a second instead of running for the job's lifetime.
|
|
7
11
|
*/
|
|
8
|
-
export async function pollLoop({ check, intervalSeconds, onCountdown, }) {
|
|
12
|
+
export async function pollLoop({ check, intervalSeconds, onCountdown, signal, }) {
|
|
9
13
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
10
14
|
while (true) {
|
|
15
|
+
signal?.throwIfAborted();
|
|
11
16
|
if (await check()) {
|
|
12
17
|
return;
|
|
13
18
|
}
|
|
14
19
|
for (let i = intervalSeconds; i > 0; i--) {
|
|
15
20
|
onCountdown(i);
|
|
16
|
-
await timeout(1000);
|
|
21
|
+
await timeout(1000, signal);
|
|
17
22
|
}
|
|
18
23
|
}
|
|
19
24
|
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const version = "3.4.
|
|
1
|
+
export declare const version = "3.4.1";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = '3.4.
|
|
1
|
+
export const version = '3.4.1';
|