Skip to content

Commit ac9f1b6

Browse files
authored
fix: upgrade idb (#691)
1 parent ef989a3 commit ac9f1b6

File tree

7 files changed

+187
-95
lines changed

7 files changed

+187
-95
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/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: 6 additions & 28 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 { cvtGroupId2Host, formatDateStr, GROUP_PREFIX, increase, zeroRow } from './common'
33
import { filterDate, filterHost, filterNumberRange, processCondition, type ProcessedCondition } from './condition'
44
import type { StatCondition, StatDatabase } from './types'
55

@@ -8,15 +8,7 @@ type StoredRow = timer.core.Row & {
88
groupId?: number
99
}
1010

11-
function fromStoredRow(stored: StoredRow): timer.core.Row {
12-
if (stored.groupId !== undefined) {
13-
const { groupId, ...row } = stored
14-
return { ...row, host: cvtGroupId2Host(groupId) }
15-
}
16-
return stored
17-
}
18-
19-
const GROUP_HOST_PATTERN = /^_g_(\d+)$/
11+
const GROUP_HOST_PATTERN = new RegExp(`^${GROUP_PREFIX}(\\d+)$`)
2012

2113
const INDEXES: (Key<StoredRow> | Key<StoredRow>[])[] = [
2214
'date', 'host', 'groupId',
@@ -144,12 +136,7 @@ export class IDBStatDatabase extends BaseIDBStorage<StoredRow> implements StatDa
144136

145137
const rows = await iterateCursor<StoredRow>(cursorReq)
146138
for (const row of rows) {
147-
if (expectGroup) {
148-
if (!isGroup(row)) continue
149-
} else {
150-
if (isGroup(row)) continue
151-
}
152-
139+
if (expectGroup !== isGroup(row)) continue
153140
if (!filter(row)) continue
154141

155142
if (expectGroup) {
@@ -196,9 +183,8 @@ export class IDBStatDatabase extends BaseIDBStorage<StoredRow> implements StatDa
196183

197184
await iterateCursor(cursorReq, cursor => {
198185
const stored = cursor.value as StoredRow | undefined
199-
if (stored && !isGroup(stored)) {
200-
toUpdate[stored.host] = fromStoredRow(stored)
201-
}
186+
if (!stored || isGroup(stored)) return
187+
toUpdate[stored.host] = stored
202188
})
203189

204190
for (const [host, result] of Object.entries(data)) {
@@ -332,15 +318,7 @@ export class IDBStatDatabase extends BaseIDBStorage<StoredRow> implements StatDa
332318
}
333319

334320
forceUpdate(...rows: timer.core.Row[]): Promise<void> {
335-
return this.withStore(store => {
336-
for (const row of rows) {
337-
const { host, date, time, focus, run } = row
338-
const groupMatch = host.match(GROUP_HOST_PATTERN)
339-
const newData: StoredRow = { host, date, time, focus, run }
340-
groupMatch && (newData.groupId = parseInt(groupMatch[1]))
341-
store.put(newData)
342-
}
343-
}, 'readwrite')
321+
return this.withStore(store => rows.forEach(row => store.put(row)), 'readwrite')
344322
}
345323

346324
forceUpdateGroup(...rows: timer.core.Row[]): Promise<void> {

test/database/idb.ts

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

0 commit comments

Comments
 (0)