jbrowse-plugin-msaview 3.4.0 → 3.5.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 (171) hide show
  1. package/README.md +7 -0
  2. package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +23 -17
  3. package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.js +1 -0
  4. package/dist/LaunchMsaView/components/BlastQuery/MsaAlgorithmSelect.js +5 -3
  5. package/dist/LaunchMsaView/components/BlastQuery/consts.d.ts +24 -4
  6. package/dist/LaunchMsaView/components/BlastQuery/consts.js +44 -2
  7. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.d.ts +7 -0
  8. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.js +42 -0
  9. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.js +32 -0
  10. package/dist/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.js +1 -0
  11. package/dist/LaunchMsaView/components/ManualMSALoader/launchView.d.ts +3 -1
  12. package/dist/LaunchMsaView/components/ManualMSALoader/launchView.js +2 -1
  13. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +10 -4
  14. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.js +4 -1
  15. package/dist/LaunchMsaView/components/QueryRowSelector.js +21 -5
  16. package/dist/LaunchMsaView/detectQueryRow.d.ts +11 -0
  17. package/dist/LaunchMsaView/detectQueryRow.js +11 -4
  18. package/dist/LaunchMsaView/detectQueryRow.test.js +30 -0
  19. package/dist/LaunchMsaView/useQueryRowName.d.ts +1 -0
  20. package/dist/LaunchMsaView/useQueryRowName.js +5 -1
  21. package/dist/LaunchMsaView/useQueryRowName.test.d.ts +1 -0
  22. package/dist/LaunchMsaView/useQueryRowName.test.js +23 -0
  23. package/dist/LaunchMsaViewExtensionPoint/index.js +19 -12
  24. package/dist/LaunchMsaViewExtensionPoint/index.test.d.ts +1 -0
  25. package/dist/LaunchMsaViewExtensionPoint/index.test.js +64 -0
  26. package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +16 -0
  27. package/dist/MsaViewPanel/afterCreateAutoruns.js +138 -86
  28. package/dist/MsaViewPanel/components/LaunchProgress.js +23 -5
  29. package/dist/MsaViewPanel/components/MsaViewPanel.js +6 -2
  30. package/dist/MsaViewPanel/components/MsaViewPanel.test.js +40 -0
  31. package/dist/MsaViewPanel/doLaunchBlast.d.ts +11 -1
  32. package/dist/MsaViewPanel/doLaunchBlast.js +79 -96
  33. package/dist/MsaViewPanel/doLaunchBlast.test.d.ts +1 -0
  34. package/dist/MsaViewPanel/doLaunchBlast.test.js +158 -0
  35. package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +3 -1
  36. package/dist/MsaViewPanel/doLaunchOrthologs.js +46 -10
  37. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +48 -24
  38. package/dist/MsaViewPanel/genomeToMSA.js +4 -4
  39. package/dist/MsaViewPanel/genomeToMSA.test.js +67 -0
  40. package/dist/MsaViewPanel/loadProteinDomains.d.ts +8 -2
  41. package/dist/MsaViewPanel/loadProteinDomains.js +18 -9
  42. package/dist/MsaViewPanel/loadProteinDomains.test.d.ts +1 -0
  43. package/dist/MsaViewPanel/loadProteinDomains.test.js +41 -0
  44. package/dist/MsaViewPanel/model.d.ts +306 -85
  45. package/dist/MsaViewPanel/model.js +169 -42
  46. package/dist/MsaViewPanel/model.test.d.ts +1 -0
  47. package/dist/MsaViewPanel/model.test.js +100 -0
  48. package/dist/MsaViewPanel/msaCoordToGenomeCoord.d.ts +12 -1
  49. package/dist/MsaViewPanel/msaCoordToGenomeCoord.js +8 -8
  50. package/dist/MsaViewPanel/msaCoordToGenomeCoord.test.js +133 -32
  51. package/dist/MsaViewPanel/msaDataStore.d.ts +7 -3
  52. package/dist/MsaViewPanel/msaDataStore.js +26 -7
  53. package/dist/MsaViewPanel/msaDataStore.test.d.ts +1 -0
  54. package/dist/MsaViewPanel/msaDataStore.test.js +44 -0
  55. package/dist/MsaViewPanel/observeProteinHighlights.test.js +20 -3
  56. package/dist/MsaViewPanel/processInit.test.d.ts +1 -0
  57. package/dist/MsaViewPanel/processInit.test.js +72 -0
  58. package/dist/MsaViewPanel/resolveConnectedTranscript.d.ts +9 -0
  59. package/dist/MsaViewPanel/resolveConnectedTranscript.js +122 -0
  60. package/dist/MsaViewPanel/runLaunch.d.ts +39 -0
  61. package/dist/MsaViewPanel/runLaunch.js +100 -0
  62. package/dist/MsaViewPanel/runLaunch.test.d.ts +1 -0
  63. package/dist/MsaViewPanel/runLaunch.test.js +175 -0
  64. package/dist/MsaViewPanel/storedData.test.d.ts +1 -0
  65. package/dist/MsaViewPanel/storedData.test.js +160 -0
  66. package/dist/MsaViewPanel/structureConnection.d.ts +0 -4
  67. package/dist/MsaViewPanel/structureConnection.js +0 -19
  68. package/dist/MsaViewPanel/util.d.ts +39 -0
  69. package/dist/MsaViewPanel/util.js +44 -0
  70. package/dist/jbrowse-plugin-msaview.umd.production.min.js +46 -45
  71. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  72. package/dist/utils/blastCache.js +32 -0
  73. package/dist/utils/blastCache.test.d.ts +1 -0
  74. package/dist/utils/blastCache.test.js +72 -0
  75. package/dist/utils/browserAlign.d.ts +42 -0
  76. package/dist/utils/browserAlign.js +297 -0
  77. package/dist/utils/browserAlign.test.d.ts +1 -0
  78. package/dist/utils/browserAlign.test.js +85 -0
  79. package/dist/utils/ebiBlast.d.ts +13 -2
  80. package/dist/utils/ebiBlast.js +32 -4
  81. package/dist/utils/ebiJobDispatcher.d.ts +6 -3
  82. package/dist/utils/ebiJobDispatcher.js +13 -6
  83. package/dist/utils/fetch.d.ts +8 -1
  84. package/dist/utils/fetch.js +28 -2
  85. package/dist/utils/homologSearch.d.ts +31 -0
  86. package/dist/utils/homologSearch.js +6 -0
  87. package/dist/utils/msa.d.ts +5 -11
  88. package/dist/utils/msa.js +19 -30
  89. package/dist/utils/msaRows.d.ts +11 -8
  90. package/dist/utils/msaRows.js +14 -12
  91. package/dist/utils/ncbiDomains.d.ts +2 -2
  92. package/dist/utils/ncbiOrthologs.d.ts +11 -1
  93. package/dist/utils/ncbiOrthologs.js +26 -7
  94. package/dist/utils/ncbiOrthologs.test.js +23 -1
  95. package/dist/utils/pantherOrthologs.js +2 -10
  96. package/dist/utils/phmmer.d.ts +12 -1
  97. package/dist/utils/phmmer.js +52 -13
  98. package/dist/utils/poll.d.ts +6 -1
  99. package/dist/utils/poll.js +7 -2
  100. package/dist/utils/unirefHomologs.d.ts +94 -0
  101. package/dist/utils/unirefHomologs.js +193 -0
  102. package/dist/utils/unirefHomologs.test.d.ts +1 -0
  103. package/dist/utils/unirefHomologs.test.js +118 -0
  104. package/dist/version.d.ts +1 -1
  105. package/dist/version.js +1 -1
  106. package/package.json +17 -16
  107. package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +40 -19
  108. package/src/LaunchMsaView/components/BlastQuery/BlastManualPanel.tsx +1 -0
  109. package/src/LaunchMsaView/components/BlastQuery/MsaAlgorithmSelect.tsx +7 -2
  110. package/src/LaunchMsaView/components/BlastQuery/consts.ts +50 -2
  111. package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.ts +43 -0
  112. package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.ts +64 -0
  113. package/src/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.tsx +1 -0
  114. package/src/LaunchMsaView/components/ManualMSALoader/launchView.ts +4 -0
  115. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +12 -5
  116. package/src/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.tsx +4 -1
  117. package/src/LaunchMsaView/components/QueryRowSelector.tsx +35 -7
  118. package/src/LaunchMsaView/detectQueryRow.test.ts +34 -0
  119. package/src/LaunchMsaView/detectQueryRow.ts +22 -4
  120. package/src/LaunchMsaView/useQueryRowName.test.ts +27 -0
  121. package/src/LaunchMsaView/useQueryRowName.ts +5 -1
  122. package/src/LaunchMsaViewExtensionPoint/index.test.ts +74 -0
  123. package/src/LaunchMsaViewExtensionPoint/index.ts +53 -12
  124. package/src/MsaViewPanel/afterCreateAutoruns.ts +150 -81
  125. package/src/MsaViewPanel/components/LaunchProgress.tsx +46 -4
  126. package/src/MsaViewPanel/components/MsaViewPanel.test.tsx +43 -0
  127. package/src/MsaViewPanel/components/MsaViewPanel.tsx +8 -2
  128. package/src/MsaViewPanel/doLaunchBlast.test.ts +207 -0
  129. package/src/MsaViewPanel/doLaunchBlast.ts +94 -140
  130. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +54 -26
  131. package/src/MsaViewPanel/doLaunchOrthologs.ts +63 -9
  132. package/src/MsaViewPanel/genomeToMSA.test.ts +76 -0
  133. package/src/MsaViewPanel/genomeToMSA.ts +8 -4
  134. package/src/MsaViewPanel/loadProteinDomains.test.ts +52 -0
  135. package/src/MsaViewPanel/loadProteinDomains.ts +24 -11
  136. package/src/MsaViewPanel/model.test.ts +121 -0
  137. package/src/MsaViewPanel/model.ts +200 -49
  138. package/src/MsaViewPanel/msaCoordToGenomeCoord.test.ts +152 -32
  139. package/src/MsaViewPanel/msaCoordToGenomeCoord.ts +21 -11
  140. package/src/MsaViewPanel/msaDataStore.test.ts +54 -0
  141. package/src/MsaViewPanel/msaDataStore.ts +32 -13
  142. package/src/MsaViewPanel/observeProteinHighlights.test.ts +27 -3
  143. package/src/MsaViewPanel/processInit.test.ts +84 -0
  144. package/src/MsaViewPanel/resolveConnectedTranscript.ts +150 -0
  145. package/src/MsaViewPanel/runLaunch.test.ts +208 -0
  146. package/src/MsaViewPanel/runLaunch.ts +141 -0
  147. package/src/MsaViewPanel/storedData.test.ts +196 -0
  148. package/src/MsaViewPanel/structureConnection.ts +0 -26
  149. package/src/MsaViewPanel/util.ts +75 -0
  150. package/src/utils/blastCache.test.ts +86 -0
  151. package/src/utils/blastCache.ts +34 -1
  152. package/src/utils/browserAlign.test.ts +102 -0
  153. package/src/utils/browserAlign.ts +352 -0
  154. package/src/utils/ebiBlast.ts +42 -2
  155. package/src/utils/ebiJobDispatcher.ts +18 -3
  156. package/src/utils/fetch.ts +29 -2
  157. package/src/utils/homologSearch.ts +51 -0
  158. package/src/utils/msa.ts +25 -39
  159. package/src/utils/msaRows.ts +20 -16
  160. package/src/utils/ncbiDomains.ts +4 -2
  161. package/src/utils/ncbiOrthologs.test.ts +25 -0
  162. package/src/utils/ncbiOrthologs.ts +27 -7
  163. package/src/utils/pantherOrthologs.ts +6 -11
  164. package/src/utils/phmmer.ts +62 -12
  165. package/src/utils/poll.ts +8 -1
  166. package/src/utils/unirefHomologs.test.ts +149 -0
  167. package/src/utils/unirefHomologs.ts +323 -0
  168. package/src/version.ts +1 -1
  169. package/dist/MsaViewPanel/structureConnection.test.js +0 -53
  170. package/src/MsaViewPanel/structureConnection.test.ts +0 -62
  171. /package/dist/{MsaViewPanel/structureConnection.test.d.ts → LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.d.ts} +0 -0
@@ -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
+ })
@@ -31,29 +31,3 @@ export function isProteinView(view: unknown): view is ProteinView {
31
31
  export function getProteinViews(views: { type: string }[]): ProteinView[] {
32
32
  return (views as unknown[]).filter(isProteinView)
33
33
  }
34
-
35
- /**
36
- * Helper to convert gapped MSA column to ungapped position for a specific row
37
- */
38
- export function gappedToUngappedPosition(
39
- sequence: string,
40
- gappedPosition: number,
41
- ): number | undefined {
42
- if (gappedPosition < 0 || gappedPosition >= sequence.length) {
43
- return undefined
44
- }
45
-
46
- let ungapped = 0
47
- for (let i = 0; i < gappedPosition; i++) {
48
- if (sequence[i] !== '-') {
49
- ungapped++
50
- }
51
- }
52
-
53
- // If the position itself is a gap, return undefined
54
- if (sequence[gappedPosition] === '-') {
55
- return undefined
56
- }
57
-
58
- return ungapped
59
- }
@@ -16,6 +16,81 @@ export function hasQueryRow(model: { rows: string[][]; querySeqName: string }) {
16
16
  return model.rows.some(r => r[0] === model.querySeqName)
17
17
  }
18
18
 
19
+ /**
20
+ * The transcript a launch was started from, as it is stored: plain JSON.
21
+ *
22
+ * blastParams and orthologParams are frozen snapshot properties, so a Feature
23
+ * instance put in one lives only as long as the tab. A reloaded session hands
24
+ * back the JSON the instance serialized to, and `.get()` on that threw --
25
+ * minutes after the EBI job the reload resubmitted had finally come back.
26
+ */
27
+ export type TranscriptRef = Record<string, unknown> | LiveFeature
28
+
29
+ interface LiveFeature {
30
+ toJSON: () => Record<string, unknown>
31
+ }
32
+
33
+ /**
34
+ * Read the transcript's fields whichever shape it arrived in: a caller inside
35
+ * the session may still hand over a live Feature.
36
+ */
37
+ export function transcriptFields(transcript?: TranscriptRef) {
38
+ if (!transcript) {
39
+ return {}
40
+ }
41
+ return typeof (transcript as LiveFeature).toJSON === 'function'
42
+ ? (transcript as LiveFeature).toJSON()
43
+ : (transcript as Record<string, unknown>)
44
+ }
45
+
46
+ function str(val: unknown) {
47
+ return typeof val === 'string' ? val : undefined
48
+ }
49
+
50
+ /** what a transcript is called, preferring its name over its id */
51
+ export function transcriptName(transcript?: TranscriptRef) {
52
+ const fields = transcriptFields(transcript)
53
+ return str(fields.name) ?? str(fields.id)
54
+ }
55
+
56
+ export interface QueryRowModel {
57
+ querySeqName: string
58
+ querySeqOffset: number
59
+ seqPosToVisibleCol: (rowName: string, seqPos: number) => number | undefined
60
+ visibleColToSeqPos: (
61
+ rowName: string,
62
+ visibleCol: number,
63
+ ) => number | undefined
64
+ }
65
+
66
+ /**
67
+ * The visible column showing residue `proteinPos` (0-based) of the transcript,
68
+ * or undefined when the query row does not carry that residue or react-msaview
69
+ * is hiding its column.
70
+ *
71
+ * Two things separate the transcript from the row. `querySeqOffset` is the
72
+ * trimming — a pasted BLAST alignment carries the aligned region, not the whole
73
+ * protein. And the row can simply stop short: react-msaview answers one column
74
+ * past the end for a position it does not have, which would light the last
75
+ * column for every residue beyond the row, so the round trip back through
76
+ * visibleColToSeqPos is what rejects those.
77
+ */
78
+ export function transcriptPosToVisibleCol(
79
+ model: QueryRowModel,
80
+ proteinPos: number,
81
+ ) {
82
+ const { querySeqName, querySeqOffset } = model
83
+ const seqPos = proteinPos - querySeqOffset
84
+ if (seqPos < 0) {
85
+ return undefined
86
+ }
87
+ const col = model.seqPosToVisibleCol(querySeqName, seqPos)
88
+ return col !== undefined &&
89
+ model.visibleColToSeqPos(querySeqName, col) === seqPos
90
+ ? col
91
+ : undefined
92
+ }
93
+
19
94
  export function hasHoverPosition(
20
95
  hovered: unknown,
21
96
  ): hovered is { hoverPosition: { coord: number; refName: string } } {
@@ -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)
@@ -0,0 +1,102 @@
1
+ import { expect, test } from 'vitest'
2
+
3
+ import {
4
+ alignInBrowser,
5
+ alignToQuery,
6
+ mergeOnQuery,
7
+ parseFastaRecords,
8
+ } from './browserAlign'
9
+
10
+ test('an identical target sits residue for residue under the query', () => {
11
+ const { matched, inserts } = alignToQuery('MKWVTFISLL', 'MKWVTFISLL')
12
+ expect(matched.join('')).toBe('MKWVTFISLL')
13
+ expect(inserts.every(i => i === '')).toBe(true)
14
+ })
15
+
16
+ test('a deletion in the target is a gap under the query', () => {
17
+ const { matched, inserts } = alignToQuery(
18
+ 'MKWVTFISLLFLFSSAYS',
19
+ 'MKWVTLLFLFSSAYS',
20
+ )
21
+ expect(matched.join('').replaceAll('-', '')).toBe('MKWVTLLFLFSSAYS')
22
+ expect(matched.filter(c => c === '-')).toHaveLength(3)
23
+ expect(inserts.every(i => i === '')).toBe(true)
24
+ })
25
+
26
+ test('an insertion in the target lands between two query residues', () => {
27
+ const query = 'MKWVTFISLLFLFSSAYS'
28
+ const target = 'MKWVTFISLLGGGGFLFSSAYS'
29
+ const { matched, inserts } = alignToQuery(query, target)
30
+ expect(matched.join('')).toBe(query)
31
+ expect(inserts.join('|')).toBe('|'.repeat(10) + 'GGGG' + '|'.repeat(8))
32
+ })
33
+
34
+ test('end gaps are free, so a domain-only target is not dragged across the flanks', () => {
35
+ const query = 'AAAAAAAAAAMKWVTFISLLFLFSSAYSAAAAAAAAAA'
36
+ const target = 'MKWVTFISLLFLFSSAYS'
37
+ const { matched, inserts } = alignToQuery(query, target)
38
+ expect(matched.join('')).toBe('----------MKWVTFISLLFLFSSAYS----------')
39
+ expect(inserts.every(i => i === '')).toBe(true)
40
+ })
41
+
42
+ test('a target that overhangs the query puts the overhang in the end inserts', () => {
43
+ const query = 'MKWVTFISLLFLFSSAYS'
44
+ const target = 'PPPPMKWVTFISLLFLFSSAYSQQQQ'
45
+ const { matched, inserts } = alignToQuery(query, target)
46
+ expect(matched.join('')).toBe(query)
47
+ expect(inserts[0]).toBe('PPPP')
48
+ expect(inserts.at(-1)).toBe('QQQQ')
49
+ })
50
+
51
+ test('merging pads every row to the longest insert at each query position', () => {
52
+ const query = { name: 'q', sequence: 'MKWVTF' }
53
+ const rows = mergeOnQuery(query, [
54
+ {
55
+ name: 'a',
56
+ alignment: alignToQuery(query.sequence, 'MKWGGVTF'),
57
+ },
58
+ {
59
+ name: 'b',
60
+ alignment: alignToQuery(query.sequence, 'MKWGVTF'),
61
+ },
62
+ {
63
+ name: 'c',
64
+ alignment: alignToQuery(query.sequence, 'MKWVTF'),
65
+ },
66
+ ])
67
+ expect(rows.map(r => r.sequence)).toEqual([
68
+ 'MKW--VTF',
69
+ 'MKWGGVTF',
70
+ 'MKWG-VTF',
71
+ 'MKW--VTF',
72
+ ])
73
+ expect(new Set(rows.map(r => r.sequence.length)).size).toBe(1)
74
+ })
75
+
76
+ test('alignInBrowser returns FASTA with the query first and every row the same length', async () => {
77
+ const progress: string[] = []
78
+ const fasta = await alignInBrowser({
79
+ query: { name: 'QUERY', sequence: 'MEEPQSDPSVEPPLSQETFSDLWKLLPENNV' },
80
+ targets: [
81
+ { name: 'a', sequence: 'MEEPQSDPSVEPPLSQETFSDLWKLLPENNV' },
82
+ { name: 'b', sequence: 'MEPQSDPSVEPPLSQETFSDLW' },
83
+ { name: 'c', sequence: 'SQETFSDLWKLLPENNVXXXXX' },
84
+ ],
85
+ onProgress: s => progress.push(s),
86
+ })
87
+ const records = parseFastaRecords(fasta)
88
+ expect(records.map(r => r.name)).toEqual(['QUERY', 'a', 'b', 'c'])
89
+ const lengths = fasta
90
+ .split('\n')
91
+ .filter(l => !l.startsWith('>'))
92
+ .map(l => l.length)
93
+ expect(new Set(lengths).size).toBe(1)
94
+ expect(progress.length).toBeGreaterThan(0)
95
+ })
96
+
97
+ test('parseFastaRecords strips gaps and stops, and keeps the first header token', () => {
98
+ expect(parseFastaRecords('>a desc\nMK-W*\nVT\n>b\nMM')).toEqual([
99
+ { name: 'a', sequence: 'MKWVT' },
100
+ { name: 'b', sequence: 'MM' },
101
+ ])
102
+ })