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
@@ -0,0 +1,154 @@
1
+ import { describe, expect, test, vi } from 'vitest'
2
+
3
+ import { runLaunch } from './runLaunch'
4
+
5
+ import type { JBrowsePluginMsaViewModel } from './model'
6
+ import type { LaunchScope } from './runLaunch'
7
+
8
+ /**
9
+ * A model that records every write, so "no model actions after abort" is
10
+ * assertable rather than inferred. The real thing throws on a destroyed node;
11
+ * here a write that should not have happened simply shows up in the log.
12
+ */
13
+ function makeModel() {
14
+ const writes: string[] = []
15
+ const model = {
16
+ launchController: undefined as AbortController | undefined,
17
+ setLaunchController(arg?: AbortController) {
18
+ model.launchController = arg
19
+ writes.push(`setLaunchController:${arg ? 'set' : 'cleared'}`)
20
+ },
21
+ setProgress: (arg: string) => writes.push(`setProgress:${arg}`),
22
+ setError: (e: unknown) =>
23
+ writes.push(`setError:${e instanceof Error ? e.message : String(e)}`),
24
+ setRid: (arg: string) => writes.push(`setRid:${arg}`),
25
+ setData: () => writes.push('setData'),
26
+ } as unknown as JBrowsePluginMsaViewModel & {
27
+ launchController: AbortController | undefined
28
+ }
29
+ return { model, writes }
30
+ }
31
+
32
+ const DATA = { msa: '>a\nMK', tree: '(a);', treeMetadata: '{}' }
33
+
34
+ /** run the microtask queue out so the launch's async body has finished */
35
+ const settle = () => new Promise(res => setTimeout(res, 0))
36
+
37
+ describe('a launch that finishes', () => {
38
+ test('applies its data, clears its params and releases the controller', async () => {
39
+ const { model, writes } = makeModel()
40
+ const onLaunched = vi.fn()
41
+
42
+ runLaunch({
43
+ self: model,
44
+ message: 'Submitting query',
45
+ onLaunched,
46
+ launch: async scope => {
47
+ scope.onRid('ncbiblast-1')
48
+ scope.onProgress('Re-checking BLAST status in... 7')
49
+ return DATA
50
+ },
51
+ })
52
+ await settle()
53
+
54
+ expect(writes).toEqual([
55
+ 'setLaunchController:set',
56
+ 'setProgress:Submitting query',
57
+ 'setError:undefined',
58
+ 'setRid:ncbiblast-1',
59
+ 'setProgress:Re-checking BLAST status in... 7',
60
+ 'setData',
61
+ 'setProgress:',
62
+ 'setLaunchController:cleared',
63
+ ])
64
+ expect(onLaunched).toHaveBeenCalled()
65
+ })
66
+ })
67
+
68
+ // The failure this file exists for: closing a view mid-BLAST left the poller
69
+ // running for the job's lifetime and then writing to a destroyed
70
+ // mobx-state-tree node, whose throw surfaced as an unhandled rejection.
71
+ describe('a launch that is abandoned', () => {
72
+ async function abandonMidFlight(reject: boolean) {
73
+ const { model, writes } = makeModel()
74
+ let released!: () => void
75
+ const held = new Promise<void>(res => {
76
+ released = res
77
+ })
78
+ let captured!: LaunchScope
79
+
80
+ runLaunch({
81
+ self: model,
82
+ message: 'Submitting query',
83
+ onLaunched: () => {
84
+ throw new Error('a cancelled launch must not report success')
85
+ },
86
+ launch: async scope => {
87
+ captured = scope
88
+ await held
89
+ // what the network hop does once its signal is aborted: either it
90
+ // rejects with an AbortError, or it had already resolved and returns
91
+ if (reject) {
92
+ throw new DOMException('Aborted', 'AbortError')
93
+ }
94
+ return DATA
95
+ },
96
+ })
97
+ await settle()
98
+ const before = writes.length
99
+
100
+ model.launchController!.abort()
101
+ // everything the launch would still try to do after the abort
102
+ captured.onProgress('Re-checking BLAST status in... 3')
103
+ captured.onRid('ncbiblast-2')
104
+ captured.act(() => {
105
+ throw new Error('act must not run its callback after an abort')
106
+ })
107
+ released()
108
+ await settle()
109
+
110
+ return writes.slice(before)
111
+ }
112
+
113
+ test('nothing is written to the model once the abort lands', async () => {
114
+ expect(await abandonMidFlight(true)).toEqual([])
115
+ })
116
+
117
+ test('a launch that resolved anyway still applies nothing', async () => {
118
+ expect(await abandonMidFlight(false)).toEqual([])
119
+ })
120
+
121
+ test('an abort renders no error, so a cancel does not read as a failure', async () => {
122
+ const { model, writes } = makeModel()
123
+
124
+ runLaunch({
125
+ self: model,
126
+ message: 'Submitting query',
127
+ onLaunched: () => {},
128
+ launch: () => Promise.reject(new DOMException('Aborted', 'AbortError')),
129
+ })
130
+ await settle()
131
+
132
+ expect(writes.some(w => w.startsWith('setError:Aborted'))).toBe(false)
133
+ })
134
+ })
135
+
136
+ describe('a launch that fails', () => {
137
+ test('records the error and stops the spinner', async () => {
138
+ vi.spyOn(console, 'error').mockImplementation(() => {})
139
+ const { model, writes } = makeModel()
140
+
141
+ runLaunch({
142
+ self: model,
143
+ message: 'Resolving orthologs',
144
+ onLaunched: () => {
145
+ throw new Error('a failed launch must not report success')
146
+ },
147
+ launch: () => Promise.reject(new Error('Only 1 ortholog(s) found')),
148
+ })
149
+ await settle()
150
+
151
+ expect(writes).toContain('setError:Only 1 ortholog(s) found')
152
+ expect(writes.at(-2)).toBe('setProgress:')
153
+ })
154
+ })
@@ -0,0 +1,102 @@
1
+ import { isAbortError } from '../utils/fetch'
2
+
3
+ import type { JBrowsePluginMsaViewModel } from './model'
4
+
5
+ /**
6
+ * What a launch is allowed to do to the model and to the network while it runs.
7
+ *
8
+ * Every write a launch makes goes through `act`, so "nothing touches the model
9
+ * once the launch has been abandoned" is one rule in one place. Spreading that
10
+ * guard across the call sites only takes one omission to undo: an abandoned
11
+ * launch keeps running until its next network hop rejects, and an action on a
12
+ * destroyed mobx-state-tree node throws where nothing is catching.
13
+ */
14
+ export interface LaunchScope {
15
+ /** aborts when the user cancels, and when the view is destroyed */
16
+ signal: AbortSignal
17
+ act: (fn: () => void) => void
18
+ onProgress: (arg: string) => void
19
+ /** the EBI job id, published before the first poll so the view can link out */
20
+ onRid: (arg: string) => void
21
+ }
22
+
23
+ interface LaunchedData {
24
+ msa: string
25
+ tree: string
26
+ treeMetadata: string
27
+ }
28
+
29
+ /**
30
+ * Run one launch attempt, owning the AbortController that ties it to the view.
31
+ *
32
+ * The controller lives on the model because two things end a launch and neither
33
+ * is here: the Cancel button, and the disposer `afterCreate` registers. Before
34
+ * it existed, closing a view mid-BLAST left the poller checking EBI for the
35
+ * job's lifetime and then writing to a node that was gone.
36
+ */
37
+ export function runLaunch({
38
+ self,
39
+ message,
40
+ launch,
41
+ onLaunched,
42
+ }: {
43
+ self: JBrowsePluginMsaViewModel
44
+ message: string
45
+ launch: (scope: LaunchScope) => Promise<LaunchedData>
46
+ onLaunched: () => void
47
+ }) {
48
+ const controller = new AbortController()
49
+ const { signal } = controller
50
+ self.setLaunchController(controller)
51
+
52
+ const act = (fn: () => void) => {
53
+ if (!signal.aborted) {
54
+ fn()
55
+ }
56
+ }
57
+ const scope: LaunchScope = {
58
+ signal,
59
+ act,
60
+ onProgress: arg => {
61
+ act(() => {
62
+ self.setProgress(arg)
63
+ })
64
+ },
65
+ onRid: arg => {
66
+ act(() => {
67
+ self.setRid(arg)
68
+ })
69
+ },
70
+ }
71
+
72
+ void (async () => {
73
+ try {
74
+ act(() => {
75
+ self.setProgress(message)
76
+ self.setError(undefined)
77
+ })
78
+ const data = await launch(scope)
79
+ act(() => {
80
+ self.setData(data)
81
+ onLaunched()
82
+ })
83
+ } catch (e) {
84
+ // a cancel is not a failure: drawing the error panel for one would tell
85
+ // the user their launch broke when they are the one who stopped it
86
+ if (!isAbortError(e)) {
87
+ act(() => {
88
+ self.setError(e)
89
+ })
90
+ console.error(e)
91
+ }
92
+ } finally {
93
+ act(() => {
94
+ self.setProgress('')
95
+ // only if a later launch has not already claimed the slot
96
+ if (self.launchController === controller) {
97
+ self.setLaunchController(undefined)
98
+ }
99
+ })
100
+ }
101
+ })()
102
+ }
@@ -0,0 +1,196 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest'
2
+
3
+ import { loadStoredData, storeDataToIndexedDB } from './afterCreateAutoruns'
4
+ import {
5
+ generateDataStoreId,
6
+ retrieveMsaData,
7
+ storeMsaData,
8
+ } from './msaDataStore'
9
+
10
+ import type { JBrowsePluginMsaViewModel } from './model'
11
+ import type { MsaDataPayload } from './msaDataStore'
12
+
13
+ // IndexedDB itself is msaDataStore.test.ts's subject; what is under test here is
14
+ // which writes these two autoruns decide to make.
15
+ vi.mock('./msaDataStore', () => ({
16
+ cleanupOldData: vi.fn(),
17
+ generateDataStoreId: vi.fn(),
18
+ retrieveMsaData: vi.fn(),
19
+ storeMsaData: vi.fn(),
20
+ }))
21
+
22
+ const mockRetrieve = vi.mocked(retrieveMsaData)
23
+ const mockStore = vi.mocked(storeMsaData)
24
+ const mockGenerateId = vi.mocked(generateDataStoreId)
25
+
26
+ const MSA = '>a\nMK'
27
+ const NEW_ID = 'msa-generated'
28
+
29
+ function makeModel(over: Record<string, unknown> = {}) {
30
+ const model = {
31
+ dataStoreId: undefined as string | undefined,
32
+ rows: [] as string[][],
33
+ data: {} as MsaDataPayload,
34
+ msaFilehandle: undefined,
35
+ treeFilehandle: undefined,
36
+ isStoringData: false,
37
+ lastStoredData: undefined as MsaDataPayload | undefined,
38
+ loadingStoredData: false,
39
+ error: undefined as unknown,
40
+ setDataStoreId(arg?: string) {
41
+ model.dataStoreId = arg
42
+ },
43
+ setIsStoringData(arg: boolean) {
44
+ model.isStoringData = arg
45
+ },
46
+ setLastStoredData(arg?: MsaDataPayload) {
47
+ model.lastStoredData = arg
48
+ },
49
+ setLoadingStoredData(arg: boolean) {
50
+ model.loadingStoredData = arg
51
+ },
52
+ setError(arg: unknown) {
53
+ model.error = arg
54
+ },
55
+ setMSA(arg: string) {
56
+ model.data.msa = arg
57
+ },
58
+ setTree(arg: string) {
59
+ model.data.tree = arg
60
+ },
61
+ setTreeMetadata(arg: string) {
62
+ model.data.treeMetadata = arg
63
+ },
64
+ ...over,
65
+ }
66
+ return model as typeof model & JBrowsePluginMsaViewModel
67
+ }
68
+
69
+ const settle = () => new Promise(res => setTimeout(res, 0))
70
+
71
+ beforeEach(() => {
72
+ vi.clearAllMocks()
73
+ mockGenerateId.mockReturnValue(NEW_ID)
74
+ mockStore.mockResolvedValue(true)
75
+ })
76
+
77
+ describe('restoring a view from IndexedDB', () => {
78
+ test('a row that is still there is applied and recorded as stored', async () => {
79
+ mockRetrieve.mockResolvedValue({
80
+ msa: MSA,
81
+ tree: '(a);',
82
+ treeMetadata: '{}',
83
+ })
84
+ const model = makeModel({ dataStoreId: 'msa-1' })
85
+
86
+ loadStoredData(model)
87
+ await settle()
88
+
89
+ expect(model.data.msa).toBe(MSA)
90
+ expect(model.error).toBeUndefined()
91
+ // recorded, so the store autorun does not write the restored data straight
92
+ // back out again
93
+ expect(model.lastStoredData).toEqual({
94
+ msa: MSA,
95
+ tree: '(a);',
96
+ treeMetadata: '{}',
97
+ })
98
+ model.rows = [['a', 'MK']]
99
+ storeDataToIndexedDB(model)
100
+ expect(mockStore).not.toHaveBeenCalled()
101
+ })
102
+
103
+ // the row expires or the user clears site data, and the view used to reopen as
104
+ // a blank import form with no hint that anything had been lost
105
+ test('a row that is gone says so, in words that name the policy', async () => {
106
+ mockRetrieve.mockResolvedValue(undefined)
107
+ const model = makeModel({ dataStoreId: 'msa-1' })
108
+
109
+ loadStoredData(model)
110
+ await settle()
111
+
112
+ expect(model.error).toBeInstanceOf(Error)
113
+ expect((model.error as Error).message).toMatch(/7 days/)
114
+ // the id names nothing now, so it goes -- otherwise react-msaview's "Return
115
+ // to import form" lands straight back on this error
116
+ expect(model.dataStoreId).toBeUndefined()
117
+ })
118
+ })
119
+
120
+ describe('keeping IndexedDB up to date', () => {
121
+ test('the first alignment is written under a fresh id', async () => {
122
+ const model = makeModel({ rows: [['a', 'MK']], data: { msa: MSA } })
123
+
124
+ storeDataToIndexedDB(model)
125
+ await settle()
126
+
127
+ expect(mockStore).toHaveBeenCalledWith(NEW_ID, { msa: MSA })
128
+ expect(model.dataStoreId).toBe(NEW_ID)
129
+ expect(model.isStoringData).toBe(false)
130
+ })
131
+
132
+ // react-msaview's "calculate neighbor-joining tree" replaces data.tree long
133
+ // after the first write; the row used to keep the alignment's original tree
134
+ // forever, so a reopened session restored a picture the user had replaced
135
+ test('an edit after the first write updates the existing row', async () => {
136
+ const model = makeModel({
137
+ dataStoreId: 'msa-1',
138
+ rows: [['a', 'MK']],
139
+ data: { msa: MSA, tree: '(a);' },
140
+ lastStoredData: { msa: MSA, tree: '(a);' },
141
+ })
142
+
143
+ storeDataToIndexedDB(model)
144
+ expect(mockStore).not.toHaveBeenCalled()
145
+
146
+ model.data.tree = '(a:0.1);'
147
+ storeDataToIndexedDB(model)
148
+ await settle()
149
+
150
+ expect(mockStore).toHaveBeenCalledWith('msa-1', {
151
+ msa: MSA,
152
+ tree: '(a:0.1);',
153
+ treeMetadata: undefined,
154
+ })
155
+ expect(model.dataStoreId).toBe('msa-1')
156
+ })
157
+
158
+ // a browser that refuses IndexedDB (private mode) answers every write the same
159
+ // way, and the autorun reruns on its own isStoringData flag -- so a write that
160
+ // is not recorded as attempted is a write that retries forever
161
+ test('a write that fails is not retried on the same data', async () => {
162
+ vi.spyOn(console, 'error').mockImplementation(() => {})
163
+ mockStore.mockResolvedValue(false)
164
+ const model = makeModel({ rows: [['a', 'MK']], data: { msa: MSA } })
165
+
166
+ storeDataToIndexedDB(model)
167
+ await settle()
168
+ expect(model.dataStoreId).toBeUndefined()
169
+
170
+ storeDataToIndexedDB(model)
171
+ await settle()
172
+ expect(mockStore).toHaveBeenCalledTimes(1)
173
+ })
174
+
175
+ test('a view reading from a filehandle stores nothing, since the file is the source', () => {
176
+ storeDataToIndexedDB(
177
+ makeModel({
178
+ rows: [['a', 'MK']],
179
+ data: { msa: MSA },
180
+ msaFilehandle: { uri: 'msa.fa', locationType: 'UriLocation' },
181
+ }),
182
+ )
183
+ expect(mockStore).not.toHaveBeenCalled()
184
+ })
185
+
186
+ test('a write already in flight is not duplicated', () => {
187
+ storeDataToIndexedDB(
188
+ makeModel({
189
+ rows: [['a', 'MK']],
190
+ data: { msa: MSA },
191
+ isStoringData: true,
192
+ }),
193
+ )
194
+ expect(mockStore).not.toHaveBeenCalled()
195
+ })
196
+ })
@@ -0,0 +1,86 @@
1
+ import { afterEach, beforeEach, expect, test, vi } from 'vitest'
2
+
3
+ import { getAllCachedResults, saveBlastResult } from './blastCache'
4
+
5
+ import type { CachedBlastResult } from './blastCache'
6
+
7
+ // An in-memory stand-in for the one object store this module opens. Only the
8
+ // idb calls blastCache makes are implemented; anything else would be untested
9
+ // scaffolding.
10
+ const { rows } = vi.hoisted(() => ({
11
+ rows: new Map<string, { id: string; timestamp: number }>(),
12
+ }))
13
+
14
+ vi.mock('./idb', () => ({
15
+ createDbOpener: () => () =>
16
+ Promise.resolve({
17
+ put: (_store: string, value: { id: string }) => {
18
+ rows.set(value.id, value as { id: string; timestamp: number })
19
+ return Promise.resolve(value.id)
20
+ },
21
+ count: () => Promise.resolve(rows.size),
22
+ getAll: () => Promise.resolve([...rows.values()]),
23
+ transaction: () => ({
24
+ store: {
25
+ delete: (id: string) => {
26
+ rows.delete(id)
27
+ return Promise.resolve()
28
+ },
29
+ },
30
+ done: Promise.resolve(),
31
+ }),
32
+ }),
33
+ }))
34
+
35
+ function save(n: number) {
36
+ return saveBlastResult({
37
+ proteinSequence: `SEQ${n}`,
38
+ blastDatabase: 'uniprotkb_swissprot',
39
+ msaAlgorithm: 'clustalo',
40
+ msa: '>a\nMK',
41
+ tree: '(a);',
42
+ treeMetadata: '{}',
43
+ rid: `job-${n}`,
44
+ })
45
+ }
46
+
47
+ beforeEach(() => {
48
+ rows.clear()
49
+ // strictly increasing, so "oldest" is unambiguous -- 55 saves in one
50
+ // millisecond would otherwise all carry the same timestamp
51
+ let clock = 1
52
+ vi.spyOn(Date, 'now').mockImplementation(() => clock++)
53
+ })
54
+
55
+ afterEach(() => {
56
+ vi.restoreAllMocks()
57
+ })
58
+
59
+ test('a store at the cap loses nothing', async () => {
60
+ for (let i = 0; i < 50; i++) {
61
+ await save(i)
62
+ }
63
+ expect(rows.size).toBe(50)
64
+ })
65
+
66
+ // Every row holds a whole alignment and its tree, so an unbounded store grows
67
+ // until the browser refuses writes to it and the user never learns why.
68
+ test('going over the cap drops the oldest results and keeps the newest 50', async () => {
69
+ for (let i = 0; i < 55; i++) {
70
+ await save(i)
71
+ }
72
+ expect(rows.size).toBe(50)
73
+ const rids = (await getAllCachedResults()).map(
74
+ (r: CachedBlastResult) => r.rid,
75
+ )
76
+ expect(rids.at(0)).toBe('job-54')
77
+ expect(rids.at(-1)).toBe('job-5')
78
+ })
79
+
80
+ test('re-saving the same query overwrites its row rather than growing the store', async () => {
81
+ for (let i = 0; i < 50; i++) {
82
+ await save(i)
83
+ }
84
+ await save(0)
85
+ expect(rows.size).toBe(50)
86
+ })
@@ -6,12 +6,19 @@ import type {
6
6
  PhmmerDatabase,
7
7
  SearchProgram,
8
8
  } from '../LaunchMsaView/components/BlastQuery/consts'
9
- import type { DBSchema } from 'idb'
9
+ import type { DBSchema, IDBPDatabase } from 'idb'
10
10
 
11
11
  const DB_NAME = 'jbrowse-msaview-blast-cache'
12
12
  const STORE_NAME = 'blast-results'
13
13
  const DB_VERSION = 2
14
14
 
15
+ /**
16
+ * How many results the history keeps. Every row holds a whole alignment and its
17
+ * tree — megabytes each — so an unbounded store grows until the browser starts
18
+ * refusing writes to it, and the user never sees why.
19
+ */
20
+ const MAX_CACHED_RESULTS = 50
21
+
15
22
  export interface CachedBlastResult {
16
23
  id: string
17
24
  proteinSequence: string
@@ -134,9 +141,35 @@ export async function saveBlastResult({
134
141
  geneName,
135
142
  }
136
143
  await db.put(STORE_NAME, entry)
144
+ await evictOldest(db)
137
145
  return entry
138
146
  }
139
147
 
148
+ /**
149
+ * Drop the oldest rows until the store is back at MAX_CACHED_RESULTS.
150
+ *
151
+ * `count` first so the common save reads no values at all: without a timestamp
152
+ * index the oldest have to be found by loading every row, and each one is an
153
+ * entire alignment. Failing to evict must not fail the save — the result is
154
+ * already in hand and losing it to a housekeeping error would be the worse
155
+ * outcome.
156
+ */
157
+ async function evictOldest(db: IDBPDatabase<BlastCacheDB>) {
158
+ try {
159
+ if ((await db.count(STORE_NAME)) <= MAX_CACHED_RESULTS) {
160
+ return
161
+ }
162
+ const all = await db.getAll(STORE_NAME)
163
+ const doomed = all
164
+ .toSorted((a, b) => a.timestamp - b.timestamp)
165
+ .slice(0, all.length - MAX_CACHED_RESULTS)
166
+ const tx = db.transaction(STORE_NAME, 'readwrite')
167
+ await Promise.all([...doomed.map(e => tx.store.delete(e.id)), tx.done])
168
+ } catch (e) {
169
+ console.warn('Failed to evict old BLAST cache entries:', e)
170
+ }
171
+ }
172
+
140
173
  export async function getAllCachedResults() {
141
174
  const db = await getDB()
142
175
  const results = await db.getAll(STORE_NAME)
@@ -64,14 +64,17 @@ export function ebiBlastResultUrl(jobId: string) {
64
64
  export async function queryEbiBlastFromJobId({
65
65
  jobId,
66
66
  onProgress,
67
+ signal,
67
68
  }: {
68
69
  jobId: string
69
70
  onProgress: (arg: string) => void
71
+ signal?: AbortSignal
70
72
  }) {
71
73
  onProgress(`Checking BLAST status for job: ${jobId}...`)
72
74
  await waitForEbiJob({
73
75
  tool: TOOL,
74
76
  jobId,
77
+ signal,
75
78
  onCountdown: s => {
76
79
  onProgress(`Re-checking BLAST status in... ${s}`)
77
80
  },
@@ -79,7 +82,7 @@ export async function queryEbiBlastFromJobId({
79
82
 
80
83
  const hits = normalizeEbiBlastHits(
81
84
  JSON.parse(
82
- await fetchEbiResult({ tool: TOOL, jobId, type: 'json' }),
85
+ await fetchEbiResult({ tool: TOOL, jobId, type: 'json', signal }),
83
86
  ) as EbiBlastJson,
84
87
  )
85
88
  if (hits.length === 0) {
@@ -93,11 +96,13 @@ export async function queryEbiBlast({
93
96
  blastDatabase,
94
97
  onProgress,
95
98
  onRid,
99
+ signal,
96
100
  }: {
97
101
  query: string
98
102
  blastDatabase: BlastDatabase
99
103
  onProgress: (arg: string) => void
100
104
  onRid: (arg: string) => void
105
+ signal?: AbortSignal
101
106
  }) {
102
107
  onProgress('Submitting to EBI BLAST...')
103
108
  const jobId = await submitEbiJob({
@@ -108,7 +113,8 @@ export async function queryEbiBlast({
108
113
  database: blastDatabase,
109
114
  sequence: query,
110
115
  },
116
+ signal,
111
117
  })
112
118
  onRid(jobId)
113
- return queryEbiBlastFromJobId({ jobId, onProgress })
119
+ return queryEbiBlastFromJobId({ jobId, onProgress, signal })
114
120
  }