Skip to content

Commit d6214a4

Browse files
committed
fix: upgrade idb
1 parent ef989a3 commit d6214a4

File tree

8 files changed

+187
-73
lines changed

8 files changed

+187
-73
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: 153 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,21 @@ export async function iterateCursor<T = unknown>(
6363
})
6464
}
6565

66+
type TransactionError = 'Connection' | 'StoreNotFound' | 'Unknown'
67+
68+
const detectTransactionError = (err: unknown): TransactionError => {
69+
if (!(err instanceof DOMException)) {
70+
return 'Unknown'
71+
}
72+
if (err.name === 'InvalidStateError' || err.name === 'AbortError') {
73+
return 'Connection'
74+
}
75+
if (err.name === 'NotFoundError') {
76+
return 'StoreNotFound'
77+
}
78+
return 'Unknown'
79+
}
80+
6681
export function closedRangeKey(lower: IDBValidKey | undefined, upper: IDBValidKey | undefined): IDBKeyRange | undefined {
6782
if (lower !== undefined && upper !== undefined) {
6883
if (lower > upper) {
@@ -87,53 +102,131 @@ export abstract class BaseIDBStorage<T = Record<string, unknown>> {
87102
private DB_NAME = `tt4b_${chrome.runtime.id}` as const
88103

89104
private db: IDBDatabase | undefined
105+
private static initPromises = new Map<string, Promise<IDBDatabase>>()
106+
90107
abstract indexes: Index<T>[]
91108
abstract key: Key<T> | Key<T>[]
92109
abstract table: Table
93110

94111
protected async initDb(): Promise<IDBDatabase> {
95112
if (this.db) return this.db
96113

114+
let initPromise = BaseIDBStorage.initPromises.get(this.table)
115+
if (!initPromise) {
116+
initPromise = this.doInitDb()
117+
BaseIDBStorage.initPromises.set(this.table, initPromise)
118+
}
119+
120+
try {
121+
this.db = await initPromise
122+
this.setupDbCloseHandler(this.db)
123+
return this.db
124+
} catch (error) {
125+
BaseIDBStorage.initPromises.delete(this.table)
126+
throw error
127+
}
128+
}
129+
130+
private setupDbCloseHandler(db: IDBDatabase): void {
131+
db.onclose = () => {
132+
if (this.db !== db) return
133+
134+
this.db = undefined
135+
BaseIDBStorage.initPromises.delete(this.table)
136+
}
137+
}
138+
139+
private async doInitDb(): Promise<IDBDatabase> {
97140
const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB
98-
const checkRequest = factory.open(this.DB_NAME)
99141

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)
142+
const checkDb = await new Promise<IDBDatabase>((resolve, reject) => {
143+
const checkRequest = factory.open(this.DB_NAME)
144+
checkRequest.onsuccess = () => resolve(checkRequest.result)
145+
checkRequest.onerror = () => reject(checkRequest.error || new Error("Failed to open database"))
146+
})
105147

106-
if (!needUpgrade) {
107-
this.db = db
108-
return resolve(db)
109-
}
148+
return checkDb
149+
}
150+
151+
// Only used for testing, be careful when using in production
152+
public async clear(): Promise<void> {
153+
await this.withStore(store => store.clear(), 'readwrite')
154+
}
155+
156+
async upgrade(): Promise<void> {
157+
const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB
158+
159+
const checkDb = await new Promise<IDBDatabase>((resolve, reject) => {
160+
const checkRequest = factory.open(this.DB_NAME)
161+
checkRequest.onsuccess = () => resolve(checkRequest.result)
162+
checkRequest.onerror = () => reject(checkRequest.error || new Error("Failed to open database"))
163+
checkRequest.onblocked = () => {
164+
console.warn(`Database check blocked for "${this.table}" (DB: ${this.DB_NAME}), waiting for other connections to close`)
165+
}
166+
})
167+
168+
const storeExisted = checkDb.objectStoreNames.contains(this.table)
169+
const needUpgrade = !storeExisted || this.needUpgradeIndexes(checkDb)
170+
171+
if (!needUpgrade) {
172+
checkDb.close()
173+
return
174+
}
110175

111-
const currentVersion = db.version
112-
db.close()
176+
const currentVersion = checkDb.version
177+
checkDb.close()
113178

114-
const upgradeRequest = factory.open(this.DB_NAME, currentVersion + 1)
179+
return new Promise<void>((resolve, reject) => {
180+
const upgradeRequest = factory.open(this.DB_NAME, currentVersion + 1)
115181

116-
upgradeRequest.onupgradeneeded = () => {
182+
upgradeRequest.onupgradeneeded = () => {
183+
try {
117184
const upgradeDb = upgradeRequest.result
118185
const transaction = upgradeRequest.transaction
119-
if (!transaction) return reject("Failed to get transaction of upgrading request")
186+
if (!transaction) {
187+
reject(new Error("Failed to get transaction of upgrading request"))
188+
return
189+
}
190+
191+
transaction.onerror = () => {
192+
reject(transaction.error || new Error("Transaction failed"))
193+
}
194+
195+
transaction.onabort = () => {
196+
reject(new Error("Upgrade transaction was aborted"))
197+
}
120198

121199
let store = upgradeDb.objectStoreNames.contains(this.table)
122200
? transaction.objectStore(this.table)
123-
: upgradeDb.createObjectStore(this.table, { keyPath: this.key })
201+
: upgradeDb.createObjectStore(this.table, { keyPath: this.key as string | string[] })
124202
this.createIndexes(store)
203+
} catch (error) {
204+
console.error("Failed to upgrade database in onupgradeneeded", error)
205+
upgradeRequest.transaction?.abort()
206+
reject(error instanceof Error ? error : new Error(String(error)))
125207
}
208+
}
126209

127-
upgradeRequest.onsuccess = () => {
128-
console.log("IndexedDB upgraded")
129-
this.db = upgradeRequest.result
130-
resolve(upgradeRequest.result)
131-
}
210+
upgradeRequest.onsuccess = () => {
211+
console.log(`IndexedDB upgraded for table "${this.table}"`)
212+
upgradeRequest.result.close()
213+
resolve()
214+
}
132215

133-
upgradeRequest.onerror = () => reject(upgradeRequest.error)
216+
upgradeRequest.onerror = (event) => {
217+
console.error("Failed to upgrade database", event, upgradeRequest.error)
218+
reject(upgradeRequest.error || new Error("Failed to upgrade database"))
134219
}
135220

136-
checkRequest.onerror = () => reject(checkRequest.error)
221+
upgradeRequest.onblocked = () => {
222+
const blockingTables = Array.from(BaseIDBStorage.initPromises.keys())
223+
.filter(table => table !== this.table)
224+
console.warn(
225+
`Database upgrade blocked for table "${this.table}" (DB: ${this.DB_NAME}), ` +
226+
`waiting for other connections to close. ` +
227+
`Other tables with active connections: ${blockingTables.length > 0 ? blockingTables.join(', ') : 'none'}`
228+
)
229+
}
137230
})
138231
}
139232

@@ -170,27 +263,45 @@ export abstract class BaseIDBStorage<T = Record<string, unknown>> {
170263
}
171264

172265
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) { }
266+
let db = await this.initDb()
267+
268+
for (let retryCount = 0; retryCount < 2; retryCount++) {
269+
let trans: IDBTransaction | undefined
270+
try {
271+
trans = db.transaction(this.table, mode ?? 'readwrite')
272+
const store = trans.objectStore(this.table)
273+
const result = await operation(store)
274+
const transaction = trans
275+
await new Promise<void>((resolve, reject) => {
276+
transaction.oncomplete = () => resolve()
277+
transaction.onerror = () => reject(transaction.error)
278+
transaction.onabort = () => reject(new Error('Transaction aborted'))
279+
})
280+
return result
281+
} catch (e) {
282+
const errorType = detectTransactionError(e)
283+
284+
if (errorType === 'Unknown') {
285+
console.error("Failed to process with transaction", e)
286+
if (trans && !trans.error && trans.mode !== 'readonly') {
287+
try {
288+
trans.abort()
289+
} catch (ignored) { }
290+
}
291+
throw e
292+
}
293+
294+
if (errorType === 'StoreNotFound') {
295+
this.db?.close()
296+
await this.upgrade()
297+
}
298+
299+
this.db = undefined
300+
BaseIDBStorage.initPromises.delete(this.table)
301+
db = await this.initDb()
191302
}
192-
throw e
193303
}
304+
throw new Error("Max retries exceeded")
194305
}
195306

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

src/database/stat-database/idb.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { BaseIDBStorage, closedRangeKey, IndexResult, iterateCursor, type Key, req2Promise, type Table } from '@db/common/indexed-storage'
2-
import { cvtGroupId2Host, formatDateStr, increase, zeroRow } from './common'
2+
import { formatDateStr, increase, zeroRow } from './common'
33
import { filterDate, filterHost, filterNumberRange, processCondition, type ProcessedCondition } from './condition'
44
import type { StatCondition, StatDatabase } from './types'
55

@@ -11,7 +11,7 @@ type StoredRow = timer.core.Row & {
1111
function fromStoredRow(stored: StoredRow): timer.core.Row {
1212
if (stored.groupId !== undefined) {
1313
const { groupId, ...row } = stored
14-
return { ...row, host: cvtGroupId2Host(groupId) }
14+
return { ...row, host: `${groupId}` }
1515
}
1616
return stored
1717
}
@@ -267,7 +267,7 @@ export class IDBStatDatabase extends BaseIDBStorage<StoredRow> implements StatDa
267267
return this.withStore(async store => {
268268
const index = super.assertIndex(store, ['date', 'host'])
269269
const dateStr = formatDateStr(date)
270-
const host = cvtGroupId2Host(groupId)
270+
const host = `${groupId}`
271271
const req = index.get([dateStr, host])
272272
const existing = await req2Promise<StoredRow>(req)
273273
const newVal = increase(item, existing)
@@ -288,7 +288,7 @@ export class IDBStatDatabase extends BaseIDBStorage<StoredRow> implements StatDa
288288
return this.withStore(async store => {
289289
const index = super.assertIndex(store, ['date', 'host'])
290290
for (const [groupId, date] of rows) {
291-
const host = cvtGroupId2Host(groupId)
291+
const host = `${groupId}`
292292
const dateStr = formatDateStr(date)
293293
const req = index.getKey([dateStr, host])
294294
const key = await req2Promise(req)
@@ -304,7 +304,7 @@ export class IDBStatDatabase extends BaseIDBStorage<StoredRow> implements StatDa
304304
const [start, end] = range ?? []
305305
const startStr = start ? formatDateStr(start) : undefined
306306
const endStr = end ? formatDateStr(end) : undefined
307-
const host = cvtGroupId2Host(groupId)
307+
const host = `${groupId}`
308308

309309
if (startStr && startStr === endStr) {
310310
// Delete one day

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)