jbrowse-plugin-msaview 3.4.0 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +4 -6
  2. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.d.ts +7 -0
  3. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.js +42 -0
  4. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.d.ts +1 -0
  5. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.js +32 -0
  6. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +2 -1
  7. package/dist/LaunchMsaViewExtensionPoint/index.js +11 -6
  8. package/dist/LaunchMsaViewExtensionPoint/index.test.d.ts +1 -0
  9. package/dist/LaunchMsaViewExtensionPoint/index.test.js +43 -0
  10. package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +15 -0
  11. package/dist/MsaViewPanel/afterCreateAutoruns.js +93 -70
  12. package/dist/MsaViewPanel/components/LaunchProgress.js +11 -2
  13. package/dist/MsaViewPanel/components/MsaViewPanel.test.js +19 -0
  14. package/dist/MsaViewPanel/doLaunchBlast.d.ts +3 -1
  15. package/dist/MsaViewPanel/doLaunchBlast.js +10 -12
  16. package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +3 -1
  17. package/dist/MsaViewPanel/doLaunchOrthologs.js +6 -5
  18. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +38 -24
  19. package/dist/MsaViewPanel/model.d.ts +160 -10
  20. package/dist/MsaViewPanel/model.js +51 -1
  21. package/dist/MsaViewPanel/msaDataStore.d.ts +5 -3
  22. package/dist/MsaViewPanel/msaDataStore.js +16 -7
  23. package/dist/MsaViewPanel/msaDataStore.test.d.ts +1 -0
  24. package/dist/MsaViewPanel/msaDataStore.test.js +44 -0
  25. package/dist/MsaViewPanel/runLaunch.d.ts +38 -0
  26. package/dist/MsaViewPanel/runLaunch.js +65 -0
  27. package/dist/MsaViewPanel/runLaunch.test.d.ts +1 -0
  28. package/dist/MsaViewPanel/runLaunch.test.js +129 -0
  29. package/dist/MsaViewPanel/storedData.test.d.ts +1 -0
  30. package/dist/MsaViewPanel/storedData.test.js +160 -0
  31. package/dist/jbrowse-plugin-msaview.umd.production.min.js +35 -35
  32. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  33. package/dist/utils/blastCache.js +32 -0
  34. package/dist/utils/blastCache.test.d.ts +1 -0
  35. package/dist/utils/blastCache.test.js +72 -0
  36. package/dist/utils/ebiBlast.d.ts +4 -2
  37. package/dist/utils/ebiBlast.js +6 -4
  38. package/dist/utils/ebiJobDispatcher.d.ts +6 -3
  39. package/dist/utils/ebiJobDispatcher.js +13 -6
  40. package/dist/utils/fetch.d.ts +8 -1
  41. package/dist/utils/fetch.js +28 -2
  42. package/dist/utils/msa.d.ts +4 -2
  43. package/dist/utils/msa.js +9 -5
  44. package/dist/utils/ncbiDomains.d.ts +2 -2
  45. package/dist/utils/ncbiOrthologs.d.ts +11 -1
  46. package/dist/utils/ncbiOrthologs.js +26 -7
  47. package/dist/utils/ncbiOrthologs.test.js +23 -1
  48. package/dist/utils/pantherOrthologs.js +2 -10
  49. package/dist/utils/phmmer.d.ts +2 -1
  50. package/dist/utils/phmmer.js +4 -2
  51. package/dist/utils/poll.d.ts +6 -1
  52. package/dist/utils/poll.js +7 -2
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/package.json +14 -14
  56. package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +7 -11
  57. package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.ts +43 -0
  58. package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.ts +64 -0
  59. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +2 -2
  60. package/src/LaunchMsaViewExtensionPoint/index.test.ts +51 -0
  61. package/src/LaunchMsaViewExtensionPoint/index.ts +21 -6
  62. package/src/MsaViewPanel/afterCreateAutoruns.ts +100 -66
  63. package/src/MsaViewPanel/components/LaunchProgress.tsx +20 -2
  64. package/src/MsaViewPanel/components/MsaViewPanel.test.tsx +21 -0
  65. package/src/MsaViewPanel/doLaunchBlast.ts +14 -10
  66. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +41 -26
  67. package/src/MsaViewPanel/doLaunchOrthologs.ts +8 -4
  68. package/src/MsaViewPanel/model.ts +54 -1
  69. package/src/MsaViewPanel/msaDataStore.test.ts +54 -0
  70. package/src/MsaViewPanel/msaDataStore.ts +22 -13
  71. package/src/MsaViewPanel/runLaunch.test.ts +154 -0
  72. package/src/MsaViewPanel/runLaunch.ts +102 -0
  73. package/src/MsaViewPanel/storedData.test.ts +196 -0
  74. package/src/utils/blastCache.test.ts +86 -0
  75. package/src/utils/blastCache.ts +34 -1
  76. package/src/utils/ebiBlast.ts +8 -2
  77. package/src/utils/ebiJobDispatcher.ts +18 -3
  78. package/src/utils/fetch.ts +29 -2
  79. package/src/utils/msa.ts +11 -3
  80. package/src/utils/ncbiDomains.ts +4 -2
  81. package/src/utils/ncbiOrthologs.test.ts +25 -0
  82. package/src/utils/ncbiOrthologs.ts +27 -7
  83. package/src/utils/pantherOrthologs.ts +6 -11
  84. package/src/utils/phmmer.ts +5 -1
  85. package/src/utils/poll.ts +8 -1
  86. package/src/version.ts +1 -1
@@ -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
+ });
@@ -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[];
@@ -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}`)).trim();
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
  }
@@ -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<unknown>;
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;
@@ -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(res => setTimeout(res, time));
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
  }
@@ -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, import("msa-parsers").InterProScanMatch[]>;
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, import("msa-parsers").InterProScanMatch[]>>;
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
- // strip a version suffix (NM_000546.6) and any GFF ID prefix (gene:TP53)
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 seen = new Map();
199
+ const used = new Set();
186
200
  return names.map(name => {
187
201
  const base = sanitize(name) || 'row';
188
- const n = seen.get(base) ?? 0;
189
- seen.set(base, n + 1);
190
- return n === 0 ? base : `${base}_${n + 1}`;
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 = cleanCandidate(raw);
134
+ const query = cleanGeneCandidate(raw);
143
135
  if (!query) {
144
136
  continue;
145
137
  }
@@ -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
  /**
@@ -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) {
@@ -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>;
@@ -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.0";
1
+ export declare const version = "3.4.1";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = '3.4.0';
1
+ export const version = '3.4.1';