Skip to content

Commit 84314db

Browse files
committed
fix: upgrade idb
1 parent ef989a3 commit 84314db

File tree

7 files changed

+163
-68
lines changed

7 files changed

+163
-68
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"@types/chrome": "0.1.37",
4040
"@types/decompress": "^4.2.7",
4141
"@types/jest": "^30.0.0",
42-
"@types/node": "^25.3.0",
42+
"@types/node": "^25.3.1",
4343
"@types/punycode": "^2.1.4",
4444
"@vue/babel-plugin-jsx": "^2.0.1",
4545
"babel-loader": "^10.0.0",

src/background/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import badgeTextManager from "./badge-manager"
1515
import initBrowserAction from "./browser-action-manager"
1616
import initCsHandler from "./content-script-handler"
1717
import initDataCleaner from "./data-cleaner"
18-
import handleInstall from "./install-handler"
18+
import handleInstall from './install-handler'
1919
import initLimitProcessor from "./limit-processor"
2020
import MessageDispatcher from "./message-dispatcher"
2121
import VersionMigrator from "./migrator"

src/background/migrator/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ class VersionManager {
4242
}
4343
}
4444

45-
init() {
45+
async init() {
4646
onInstalled(reason => this.onChromeInstalled(reason))
4747
}
4848
}

src/background/migrator/indexed-migrator.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,28 @@
1+
import { BaseIDBStorage } from '@db/common/indexed-storage'
2+
import { IDBStatDatabase } from '@db/stat-database/idb'
13
import timelineDatabase from '@db/timeline-database'
4+
import IDBTimelineDatabase from '@db/timeline-database/idb'
25
import type { Migrator } from './types'
36

7+
async function upgradeIndexedDB() {
8+
try {
9+
const storages: BaseIDBStorage<any>[] = [new IDBStatDatabase(), new IDBTimelineDatabase()]
10+
for (const storage of storages) {
11+
await storage.upgrade()
12+
}
13+
console.log('IndexedDB upgraded successfully')
14+
} catch (error) {
15+
console.error('Failed to upgrade IndexedDB', error)
16+
}
17+
}
18+
419
class IndexedMigrator implements Migrator {
520
onInstall(): void {
621
}
7-
onUpdate(_version: string): void {
22+
23+
async onUpdate(_version: string): Promise<void> {
24+
await upgradeIndexedDB()
25+
826
timelineDatabase.migrateFromClassic()
927
.then(() => console.log('Timeline data migrated to IndexedDB'))
1028
.catch(e => console.error('Failed to migrate timeline data to IndexedDB', e))

src/database/common/indexed-storage.ts

Lines changed: 134 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -87,53 +87,131 @@ export abstract class BaseIDBStorage<T = Record<string, unknown>> {
8787
private DB_NAME = `tt4b_${chrome.runtime.id}` as const
8888

8989
private db: IDBDatabase | undefined
90+
private static initPromises = new Map<string, Promise<IDBDatabase>>()
91+
9092
abstract indexes: Index<T>[]
9193
abstract key: Key<T> | Key<T>[]
9294
abstract table: Table
9395

9496
protected async initDb(): Promise<IDBDatabase> {
9597
if (this.db) return this.db
9698

99+
let initPromise = BaseIDBStorage.initPromises.get(this.table)
100+
if (!initPromise) {
101+
initPromise = this.doInitDb()
102+
BaseIDBStorage.initPromises.set(this.table, initPromise)
103+
}
104+
105+
try {
106+
this.db = await initPromise
107+
this.setupDbCloseHandler(this.db)
108+
return this.db
109+
} catch (error) {
110+
BaseIDBStorage.initPromises.delete(this.table)
111+
throw error
112+
}
113+
}
114+
115+
private setupDbCloseHandler(db: IDBDatabase): void {
116+
db.onclose = () => {
117+
if (this.db !== db) return
118+
119+
this.db = undefined
120+
BaseIDBStorage.initPromises.delete(this.table)
121+
}
122+
}
123+
124+
private async doInitDb(): Promise<IDBDatabase> {
97125
const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB
98-
const checkRequest = factory.open(this.DB_NAME)
99126

100-
return new Promise((resolve, reject) => {
101-
checkRequest.onsuccess = () => {
102-
const db = checkRequest.result
103-
const storeExisted = db.objectStoreNames.contains(this.table)
104-
const needUpgrade = !storeExisted || this.needUpgradeIndexes(db)
127+
const checkDb = await new Promise<IDBDatabase>((resolve, reject) => {
128+
const checkRequest = factory.open(this.DB_NAME)
129+
checkRequest.onsuccess = () => resolve(checkRequest.result)
130+
checkRequest.onerror = () => reject(checkRequest.error || new Error("Failed to open database"))
131+
})
105132

106-
if (!needUpgrade) {
107-
this.db = db
108-
return resolve(db)
109-
}
133+
return checkDb
134+
}
110135

111-
const currentVersion = db.version
112-
db.close()
136+
// Only used for testing, be careful when using in production
137+
public async clear(): Promise<void> {
138+
await this.withStore(store => store.clear(), 'readwrite')
139+
}
113140

114-
const upgradeRequest = factory.open(this.DB_NAME, currentVersion + 1)
141+
async upgrade(): Promise<void> {
142+
const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB
143+
144+
const checkDb = await new Promise<IDBDatabase>((resolve, reject) => {
145+
const checkRequest = factory.open(this.DB_NAME)
146+
checkRequest.onsuccess = () => resolve(checkRequest.result)
147+
checkRequest.onerror = () => reject(checkRequest.error || new Error("Failed to open database"))
148+
checkRequest.onblocked = () => {
149+
console.warn(`Database check blocked for "${this.table}" (DB: ${this.DB_NAME}), waiting for other connections to close`)
150+
}
151+
})
115152

116-
upgradeRequest.onupgradeneeded = () => {
153+
const storeExisted = checkDb.objectStoreNames.contains(this.table)
154+
const needUpgrade = !storeExisted || this.needUpgradeIndexes(checkDb)
155+
156+
if (!needUpgrade) {
157+
checkDb.close()
158+
return
159+
}
160+
161+
const currentVersion = checkDb.version
162+
checkDb.close()
163+
164+
return new Promise<void>((resolve, reject) => {
165+
const upgradeRequest = factory.open(this.DB_NAME, currentVersion + 1)
166+
167+
upgradeRequest.onupgradeneeded = () => {
168+
try {
117169
const upgradeDb = upgradeRequest.result
118170
const transaction = upgradeRequest.transaction
119-
if (!transaction) return reject("Failed to get transaction of upgrading request")
171+
if (!transaction) {
172+
reject(new Error("Failed to get transaction of upgrading request"))
173+
return
174+
}
175+
176+
transaction.onerror = () => {
177+
reject(transaction.error || new Error("Transaction failed"))
178+
}
179+
180+
transaction.onabort = () => {
181+
reject(new Error("Upgrade transaction was aborted"))
182+
}
120183

121184
let store = upgradeDb.objectStoreNames.contains(this.table)
122185
? transaction.objectStore(this.table)
123-
: upgradeDb.createObjectStore(this.table, { keyPath: this.key })
186+
: upgradeDb.createObjectStore(this.table, { keyPath: this.key as string | string[] })
124187
this.createIndexes(store)
188+
} catch (error) {
189+
console.error("Failed to upgrade database in onupgradeneeded", error)
190+
upgradeRequest.transaction?.abort()
191+
reject(error instanceof Error ? error : new Error(String(error)))
125192
}
193+
}
126194

127-
upgradeRequest.onsuccess = () => {
128-
console.log("IndexedDB upgraded")
129-
this.db = upgradeRequest.result
130-
resolve(upgradeRequest.result)
131-
}
195+
upgradeRequest.onsuccess = () => {
196+
console.log(`IndexedDB upgraded for table "${this.table}"`)
197+
upgradeRequest.result.close()
198+
resolve()
199+
}
132200

133-
upgradeRequest.onerror = () => reject(upgradeRequest.error)
201+
upgradeRequest.onerror = (event) => {
202+
console.error("Failed to upgrade database", event, upgradeRequest.error)
203+
reject(upgradeRequest.error || new Error("Failed to upgrade database"))
134204
}
135205

136-
checkRequest.onerror = () => reject(checkRequest.error)
206+
upgradeRequest.onblocked = () => {
207+
const blockingTables = Array.from(BaseIDBStorage.initPromises.keys())
208+
.filter(table => table !== this.table)
209+
console.warn(
210+
`Database upgrade blocked for table "${this.table}" (DB: ${this.DB_NAME}), ` +
211+
`waiting for other connections to close. ` +
212+
`Other tables with active connections: ${blockingTables.length > 0 ? blockingTables.join(', ') : 'none'}`
213+
)
214+
}
137215
})
138216
}
139217

@@ -170,27 +248,41 @@ export abstract class BaseIDBStorage<T = Record<string, unknown>> {
170248
}
171249

172250
protected async withStore<T = unknown>(operation: (store: IDBObjectStore) => T | Promise<T>, mode?: IDBTransactionMode): Promise<T> {
173-
const db = await this.initDb()
174-
const trans = db.transaction(this.table, mode ?? 'readwrite')
175-
try {
176-
const store = trans.objectStore(this.table)
177-
const result = await operation(store)
178-
// Waiting for transaction completed
179-
await new Promise<void>((resolve, reject) => {
180-
trans.oncomplete = () => resolve()
181-
trans.onerror = () => reject(trans.error)
182-
trans.onabort = () => reject(new Error('Transaction aborted'))
183-
})
184-
return result
185-
} catch (e) {
186-
console.error("Failed to process with transaction", e)
187-
if (!trans.error && trans.mode !== 'readonly') {
188-
try {
189-
trans.abort()
190-
} catch (ignored) { }
251+
let db = await this.initDb()
252+
253+
for (let retryCount = 0; retryCount < 2; retryCount++) {
254+
let trans: IDBTransaction | undefined
255+
try {
256+
trans = db.transaction(this.table, mode ?? 'readwrite')
257+
const store = trans.objectStore(this.table)
258+
const result = await operation(store)
259+
const transaction = trans
260+
await new Promise<void>((resolve, reject) => {
261+
transaction.oncomplete = () => resolve()
262+
transaction.onerror = () => reject(transaction.error)
263+
transaction.onabort = () => reject(new Error('Transaction aborted'))
264+
})
265+
return result
266+
} catch (e) {
267+
const isConnectionError = e instanceof DOMException &&
268+
(e.name === 'InvalidStateError' || e.name === 'AbortError')
269+
270+
if (isConnectionError) {
271+
this.db = undefined
272+
BaseIDBStorage.initPromises.delete(this.table)
273+
db = await this.initDb()
274+
} else {
275+
console.error("Failed to process with transaction", e)
276+
if (trans && !trans.error && trans.mode !== 'readonly') {
277+
try {
278+
trans.abort()
279+
} catch (ignored) { }
280+
}
281+
throw e
282+
}
191283
}
192-
throw e
193284
}
285+
throw new Error("Max retries exceeded")
194286
}
195287

196288
protected assertIndex(store: IDBObjectStore, key: Key<T> | Key<T>[]): IDBIndex {

test/database/idb.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.

test/database/stat-database/idb.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,25 @@ import { zeroResult, zeroRow } from '@db/stat-database/common'
22
import { IDBStatDatabase } from '@db/stat-database/idb'
33
import 'fake-indexeddb/auto'
44
import { mockRuntime } from '../../__mock__/runtime'
5-
import { cleanupIDB } from '../idb'
65

7-
let db: IDBStatDatabase
86
const GOOGLE = 'www.google.com'
97
const GITHUB = 'www.github.com'
108
const GITHUB_VIRTUAL = 'www.github.com/sheepzh/**'
119
const GROUP_1 = 1
1210
const GROUP_2 = 2
1311
const MAYBE_GROUP_1 = '1'
1412

13+
let db: IDBStatDatabase
14+
1515
describe('stat-database/idb', () => {
16-
beforeAll(() => mockRuntime())
17-
beforeEach(async () => {
18-
await cleanupIDB()
16+
beforeAll(async () => {
17+
mockRuntime()
1918
db = new IDBStatDatabase()
19+
await db.upgrade()
2020
})
2121

22+
beforeEach(() => db.clear())
23+
2224
test('accumulate', async () => {
2325
await db.accumulate(GITHUB, '20240601', { focus: 10, time: 20 })
2426
await db.accumulate(GOOGLE, new Date(2025, 10, 1), { focus: 1, time: 0 })

0 commit comments

Comments
 (0)