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.
Files changed (156) hide show
  1. package/README.md +8 -0
  2. package/dist/LaunchMsaView/cleanProteinSequence.test.d.ts +1 -0
  3. package/dist/LaunchMsaView/cleanProteinSequence.test.js +77 -0
  4. package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +29 -32
  5. package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.js +10 -15
  6. package/dist/LaunchMsaView/components/BlastQuery/CachedBlastResults.js +14 -13
  7. package/dist/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.js +25 -22
  8. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +14 -19
  9. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.d.ts +2 -1
  10. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.js +18 -2
  11. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.test.d.ts +1 -0
  12. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.test.js +13 -0
  13. package/dist/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.js +19 -14
  14. package/dist/LaunchMsaView/components/SubmitCancelActions.d.ts +3 -1
  15. package/dist/LaunchMsaView/components/SubmitCancelActions.js +10 -7
  16. package/dist/LaunchMsaView/components/SubmitCancelActions.test.js +20 -2
  17. package/dist/LaunchMsaView/components/calculateProteinSequence.d.ts +8 -1
  18. package/dist/LaunchMsaView/components/calculateProteinSequence.js +25 -20
  19. package/dist/LaunchMsaView/components/launchConnectedView.d.ts +23 -0
  20. package/dist/LaunchMsaView/components/launchConnectedView.js +39 -0
  21. package/dist/LaunchMsaView/detectQueryRow.js +4 -7
  22. package/dist/LaunchMsaView/util.d.ts +6 -0
  23. package/dist/LaunchMsaView/util.js +7 -1
  24. package/dist/LaunchMsaViewExtensionPoint/index.test.js +15 -0
  25. package/dist/MsaViewPanel/afterCreateAutoruns.js +12 -3
  26. package/dist/MsaViewPanel/applyRegion.d.ts +23 -0
  27. package/dist/MsaViewPanel/applyRegion.js +19 -0
  28. package/dist/MsaViewPanel/applyRegion.test.d.ts +1 -0
  29. package/dist/MsaViewPanel/applyRegion.test.js +45 -0
  30. package/dist/MsaViewPanel/doLaunchBlast.js +2 -1
  31. package/dist/MsaViewPanel/doLaunchBlast.test.js +12 -0
  32. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +1 -1
  33. package/dist/MsaViewPanel/genomeToMSA.js +3 -3
  34. package/dist/MsaViewPanel/genomeToMSA.test.js +69 -23
  35. package/dist/MsaViewPanel/launchAutoruns.test.d.ts +1 -0
  36. package/dist/MsaViewPanel/launchAutoruns.test.js +36 -0
  37. package/dist/MsaViewPanel/model.d.ts +18 -14
  38. package/dist/MsaViewPanel/model.js +17 -0
  39. package/dist/MsaViewPanel/msaDataStore.d.ts +5 -3
  40. package/dist/MsaViewPanel/msaDataStore.js +3 -12
  41. package/dist/MsaViewPanel/msaDataStore.test.js +8 -1
  42. package/dist/MsaViewPanel/observeProteinHighlights.test.js +4 -4
  43. package/dist/MsaViewPanel/regionSnapshot.test.d.ts +1 -0
  44. package/dist/MsaViewPanel/regionSnapshot.test.js +29 -0
  45. package/dist/MsaViewPanel/resolveConnectedTranscript.d.ts +41 -4
  46. package/dist/MsaViewPanel/resolveConnectedTranscript.js +103 -68
  47. package/dist/MsaViewPanel/resolveConnectedTranscript.test.d.ts +1 -0
  48. package/dist/MsaViewPanel/resolveConnectedTranscript.test.js +149 -0
  49. package/dist/MsaViewPanel/runLaunch.d.ts +8 -4
  50. package/dist/MsaViewPanel/runLaunch.js +15 -4
  51. package/dist/MsaViewPanel/runLaunch.test.js +16 -0
  52. package/dist/MsaViewPanel/storedData.test.js +23 -0
  53. package/dist/MsaViewPanel/syncGenomeHoverToMsaColumn.test.js +3 -2
  54. package/dist/MsaViewPanel/util.d.ts +23 -23
  55. package/dist/MsaViewPanel/util.js +23 -24
  56. package/dist/index.js +17 -10
  57. package/dist/jbrowse-plugin-msaview.umd.production.min.js +36 -36
  58. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  59. package/dist/utils/blastCache.d.ts +26 -2
  60. package/dist/utils/blastCache.js +51 -39
  61. package/dist/utils/blastCache.test.js +46 -19
  62. package/dist/utils/domainCache.d.ts +1 -1
  63. package/dist/utils/domainCache.js +18 -14
  64. package/dist/utils/ebiJobDispatcher.d.ts +11 -0
  65. package/dist/utils/ebiJobDispatcher.js +12 -13
  66. package/dist/utils/ebiJobDispatcher.test.js +17 -3
  67. package/dist/utils/eutils.d.ts +14 -0
  68. package/dist/utils/eutils.js +48 -14
  69. package/dist/utils/eutils.test.d.ts +1 -0
  70. package/dist/utils/eutils.test.js +31 -0
  71. package/dist/utils/idb.d.ts +16 -3
  72. package/dist/utils/idb.js +51 -5
  73. package/dist/utils/idb.test.d.ts +1 -0
  74. package/dist/utils/idb.test.js +78 -0
  75. package/dist/utils/ncbiDomains.js +4 -4
  76. package/dist/utils/ncbiOrthologs.d.ts +1 -1
  77. package/dist/utils/ncbiOrthologs.js +18 -17
  78. package/dist/utils/ncbiOrthologs.test.js +17 -1
  79. package/dist/utils/ncbiTaxonomy.js +14 -15
  80. package/dist/utils/taxonomyNames.js +23 -19
  81. package/dist/utils/taxonomyNames.test.d.ts +1 -0
  82. package/dist/utils/taxonomyNames.test.js +33 -0
  83. package/dist/utils/unirefHomologs.js +2 -2
  84. package/dist/utils/unirefHomologs.test.js +8 -1
  85. package/dist/version.d.ts +1 -1
  86. package/dist/version.js +1 -1
  87. package/package.json +9 -9
  88. package/src/LaunchMsaView/cleanProteinSequence.test.ts +91 -0
  89. package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +35 -32
  90. package/src/LaunchMsaView/components/BlastQuery/BlastManualPanel.tsx +10 -14
  91. package/src/LaunchMsaView/components/BlastQuery/CachedBlastResults.tsx +16 -15
  92. package/src/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.tsx +34 -21
  93. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +18 -21
  94. package/src/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.test.ts +16 -0
  95. package/src/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.tsx +24 -2
  96. package/src/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.tsx +19 -13
  97. package/src/LaunchMsaView/components/SubmitCancelActions.test.tsx +27 -2
  98. package/src/LaunchMsaView/components/SubmitCancelActions.tsx +12 -7
  99. package/src/LaunchMsaView/components/calculateProteinSequence.ts +28 -18
  100. package/src/LaunchMsaView/components/launchConnectedView.ts +56 -0
  101. package/src/LaunchMsaView/detectQueryRow.ts +5 -7
  102. package/src/LaunchMsaView/util.ts +7 -1
  103. package/src/LaunchMsaViewExtensionPoint/index.test.ts +16 -0
  104. package/src/LaunchMsaViewExtensionPoint/index.ts +28 -1
  105. package/src/MsaViewPanel/afterCreateAutoruns.ts +12 -3
  106. package/src/MsaViewPanel/applyRegion.test.ts +55 -0
  107. package/src/MsaViewPanel/applyRegion.ts +33 -0
  108. package/src/MsaViewPanel/doLaunchBlast.test.ts +20 -1
  109. package/src/MsaViewPanel/doLaunchBlast.ts +2 -1
  110. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +1 -1
  111. package/src/MsaViewPanel/genomeToMSA.test.ts +75 -23
  112. package/src/MsaViewPanel/genomeToMSA.ts +3 -2
  113. package/src/MsaViewPanel/launchAutoruns.test.ts +45 -0
  114. package/src/MsaViewPanel/model.ts +22 -0
  115. package/src/MsaViewPanel/msaDataStore.test.ts +14 -2
  116. package/src/MsaViewPanel/msaDataStore.ts +4 -12
  117. package/src/MsaViewPanel/observeProteinHighlights.test.ts +4 -4
  118. package/src/MsaViewPanel/regionSnapshot.test.ts +34 -0
  119. package/src/MsaViewPanel/resolveConnectedTranscript.test.ts +183 -0
  120. package/src/MsaViewPanel/resolveConnectedTranscript.ts +154 -71
  121. package/src/MsaViewPanel/runLaunch.test.ts +19 -0
  122. package/src/MsaViewPanel/runLaunch.ts +21 -4
  123. package/src/MsaViewPanel/storedData.test.ts +32 -0
  124. package/src/MsaViewPanel/syncGenomeHoverToMsaColumn.test.ts +3 -2
  125. package/src/MsaViewPanel/util.ts +38 -29
  126. package/src/index.ts +19 -10
  127. package/src/utils/blastCache.test.ts +59 -20
  128. package/src/utils/blastCache.ts +62 -39
  129. package/src/utils/domainCache.ts +27 -15
  130. package/src/utils/ebiJobDispatcher.test.ts +18 -3
  131. package/src/utils/ebiJobDispatcher.ts +12 -16
  132. package/src/utils/eutils.test.ts +39 -0
  133. package/src/utils/eutils.ts +64 -14
  134. package/src/utils/idb.test.ts +106 -0
  135. package/src/utils/idb.ts +57 -5
  136. package/src/utils/ncbiDomains.ts +4 -4
  137. package/src/utils/ncbiOrthologs.test.ts +20 -0
  138. package/src/utils/ncbiOrthologs.ts +26 -28
  139. package/src/utils/ncbiTaxonomy.ts +17 -20
  140. package/src/utils/taxonomyNames.test.ts +41 -0
  141. package/src/utils/taxonomyNames.ts +33 -19
  142. package/src/utils/unirefHomologs.test.ts +11 -1
  143. package/src/utils/unirefHomologs.ts +2 -2
  144. package/src/version.ts +1 -1
  145. package/dist/LaunchMsaView/components/BlastQuery/blastLaunchView.d.ts +0 -16
  146. package/dist/LaunchMsaView/components/BlastQuery/blastLaunchView.js +0 -31
  147. package/dist/LaunchMsaView/components/ManualMSALoader/launchView.d.ts +0 -16
  148. package/dist/LaunchMsaView/components/ManualMSALoader/launchView.js +0 -16
  149. package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.d.ts +0 -9
  150. package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.js +0 -15
  151. package/dist/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.d.ts +0 -13
  152. package/dist/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.js +0 -22
  153. package/src/LaunchMsaView/components/BlastQuery/blastLaunchView.ts +0 -59
  154. package/src/LaunchMsaView/components/ManualMSALoader/launchView.ts +0 -43
  155. package/src/LaunchMsaView/components/OrthologQuery/orthologLaunchView.ts +0 -31
  156. 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
- export declare function saveBlastResult({ proteinSequence, blastDatabase, msaAlgorithm, searchProgram, msa, tree, treeMetadata, rid, geneId, transcriptId, transcriptName, geneName, }: {
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>;
@@ -1,4 +1,5 @@
1
- import { createDbOpener } from './idb';
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
- function createCacheKey({ proteinSequence, blastDatabase, msaAlgorithm, searchProgram, transcriptId, }) {
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
- // phmmer keys are prefixed and blastp keys are left exactly as they were, so
22
- // results cached before phmmer existed still resolve
23
- if (searchProgram === 'phmmer') {
24
- return `phmmer:${blastDatabase}${idPart}:${proteinSequence}`;
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
- export async function saveBlastResult({ proteinSequence, blastDatabase, msaAlgorithm, searchProgram, msa, tree, treeMetadata, rid, geneId, transcriptId, transcriptName, geneName, }) {
32
- const db = await getDB();
33
- const id = createCacheKey({
34
- proteinSequence,
35
- blastDatabase,
36
- msaAlgorithm,
37
- searchProgram,
38
- transcriptId,
39
- });
40
- const entry = {
41
- id,
42
- proteinSequence,
43
- blastDatabase,
44
- msaAlgorithm,
45
- searchProgram,
46
- msa,
47
- tree,
48
- treeMetadata,
49
- rid,
50
- timestamp: Date.now(),
51
- geneId,
52
- transcriptId,
53
- transcriptName,
54
- geneName,
55
- };
56
- await db.put(STORE_NAME, entry);
57
- await evictOldest(db);
58
- return entry;
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
- 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
- },
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
- done: Promise.resolve(),
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 async function getCachedDomains(accessions) {
11
- const db = await getDB();
12
- const tx = db.transaction(STORE_NAME, 'readonly');
13
- const results = await Promise.all(accessions.map(accession => tx.store.get(accession)));
14
- await tx.done;
15
- return results;
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 async function saveDomains(entries) {
18
- const db = await getDB();
19
- const tx = db.transaction(STORE_NAME, 'readwrite');
20
- for (const entry of entries) {
21
- await tx.store.put(entry);
22
- }
23
- await tx.done;
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 the poll is the long part: an
39
- * alignment of a hundred sequences runs for minutes and is checked every ten
40
- * seconds, so a single blip anywhere in that window used to throw away a job
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
- * Consecutive failures still end it, because an endpoint that has genuinely gone
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 MAX_CONSECUTIVE_STATUS_FAILURES = 5;
45
+ export const STATUS_FAILURE_BUDGET_MS = 15 * 60 * 1000;
47
46
  export async function waitForEbiJob({ tool, jobId, intervalSeconds = 10, onCountdown, signal, }) {
48
- let consecutiveFailures = 0;
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
- consecutiveFailures += 1;
65
- if (consecutiveFailures >= MAX_CONSECUTIVE_STATUS_FAILURES) {
66
- throw new Error(`Could not reach EBI to check ${tool} job ${jobId} after ${consecutiveFailures} tries`, { cause: e });
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(`[msaview] EBI status check ${consecutiveFailures} failed, retrying:`, e);
67
+ console.warn('[msaview] EBI status check failed, retrying:', e);
69
68
  return false;
70
69
  }
71
- consecutiveFailures = 0;
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
- const statuses = (seq) => {
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']);
@@ -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&apos;-3&apos;`. */
25
+ export declare function decodeXmlEntities(text: string): string;
26
+ export {};
@@ -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
- const search = new URLSearchParams({
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&apos;-3&apos;`. */
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&apos;-3&apos; exonuclease')).toBe("5'-3' exonuclease");
9
+ expect(decodeXmlEntities('A &amp; B &lt;C&gt; &quot;D&quot;')).toBe('A & B <C> "D"');
10
+ expect(decodeXmlEntities('&#39;&#x41;&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
+ });
@@ -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
- * The connection promise is cached so callers share one connection, and dropped
6
- * again if the open fails, so a later call retries instead of replaying the same
7
- * rejection forever (IndexedDB is unavailable in some private-browsing modes).
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
- * The connection promise is cached so callers share one connection, and dropped
6
- * again if the open fails, so a later call retries instead of replaying the same
7
- * rejection forever (IndexedDB is unavailable in some private-browsing modes).
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 ??= openDB(name, version, { upgrade }).catch((e) => {
16
- dbPromise = undefined;
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 {};