diff --git a/package.json b/package.json index e470059d..d587a9b2 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@types/chrome": "0.1.37", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", - "@types/node": "^25.3.0", + "@types/node": "^25.3.1", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.0.0", diff --git a/src/background/index.ts b/src/background/index.ts index 1c6fbfa0..4dbad3cb 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -15,7 +15,7 @@ import badgeTextManager from "./badge-manager" import initBrowserAction from "./browser-action-manager" import initCsHandler from "./content-script-handler" import initDataCleaner from "./data-cleaner" -import handleInstall from "./install-handler" +import handleInstall from './install-handler' import initLimitProcessor from "./limit-processor" import MessageDispatcher from "./message-dispatcher" import VersionMigrator from "./migrator" diff --git a/src/background/migrator/indexed-migrator.ts b/src/background/migrator/indexed-migrator.ts index 5c2c7119..63ee0169 100644 --- a/src/background/migrator/indexed-migrator.ts +++ b/src/background/migrator/indexed-migrator.ts @@ -1,10 +1,28 @@ +import { BaseIDBStorage } from '@db/common/indexed-storage' +import { IDBStatDatabase } from '@db/stat-database/idb' import timelineDatabase from '@db/timeline-database' +import IDBTimelineDatabase from '@db/timeline-database/idb' import type { Migrator } from './types' +async function upgradeIndexedDB() { + try { + const storages: BaseIDBStorage[] = [new IDBStatDatabase(), new IDBTimelineDatabase()] + for (const storage of storages) { + await storage.upgrade() + } + console.log('IndexedDB upgraded successfully') + } catch (error) { + console.error('Failed to upgrade IndexedDB', error) + } +} + class IndexedMigrator implements Migrator { onInstall(): void { } - onUpdate(_version: string): void { + + async onUpdate(_version: string): Promise { + await upgradeIndexedDB() + timelineDatabase.migrateFromClassic() .then(() => console.log('Timeline data migrated to IndexedDB')) .catch(e => console.error('Failed to migrate timeline data to IndexedDB', e)) diff --git a/src/database/common/indexed-storage.ts b/src/database/common/indexed-storage.ts index 893d19be..b228bb5f 100644 --- a/src/database/common/indexed-storage.ts +++ b/src/database/common/indexed-storage.ts @@ -63,6 +63,21 @@ export async function iterateCursor( }) } +type TransactionError = 'Connection' | 'StoreNotFound' | 'Unknown' + +const detectTransactionError = (err: unknown): TransactionError => { + if (!(err instanceof DOMException)) { + return 'Unknown' + } + if (err.name === 'InvalidStateError' || err.name === 'AbortError') { + return 'Connection' + } + if (err.name === 'NotFoundError') { + return 'StoreNotFound' + } + return 'Unknown' +} + export function closedRangeKey(lower: IDBValidKey | undefined, upper: IDBValidKey | undefined): IDBKeyRange | undefined { if (lower !== undefined && upper !== undefined) { if (lower > upper) { @@ -87,6 +102,8 @@ export abstract class BaseIDBStorage> { private DB_NAME = `tt4b_${chrome.runtime.id}` as const private db: IDBDatabase | undefined + private static initPromises = new Map>() + abstract indexes: Index[] abstract key: Key | Key[] abstract table: Table @@ -94,46 +111,122 @@ export abstract class BaseIDBStorage> { protected async initDb(): Promise { if (this.db) return this.db + let initPromise = BaseIDBStorage.initPromises.get(this.table) + if (!initPromise) { + initPromise = this.doInitDb() + BaseIDBStorage.initPromises.set(this.table, initPromise) + } + + try { + this.db = await initPromise + this.setupDbCloseHandler(this.db) + return this.db + } catch (error) { + BaseIDBStorage.initPromises.delete(this.table) + throw error + } + } + + private setupDbCloseHandler(db: IDBDatabase): void { + db.onclose = () => { + if (this.db !== db) return + + this.db = undefined + BaseIDBStorage.initPromises.delete(this.table) + } + } + + private async doInitDb(): Promise { const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB - const checkRequest = factory.open(this.DB_NAME) - return new Promise((resolve, reject) => { - checkRequest.onsuccess = () => { - const db = checkRequest.result - const storeExisted = db.objectStoreNames.contains(this.table) - const needUpgrade = !storeExisted || this.needUpgradeIndexes(db) + const checkDb = await new Promise((resolve, reject) => { + const checkRequest = factory.open(this.DB_NAME) + checkRequest.onsuccess = () => resolve(checkRequest.result) + checkRequest.onerror = () => reject(checkRequest.error || new Error("Failed to open database")) + }) - if (!needUpgrade) { - this.db = db - return resolve(db) - } + return checkDb + } + + // Only used for testing, be careful when using in production + public async clear(): Promise { + await this.withStore(store => store.clear(), 'readwrite') + } + + async upgrade(): Promise { + const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB + + const checkDb = await new Promise((resolve, reject) => { + const checkRequest = factory.open(this.DB_NAME) + checkRequest.onsuccess = () => resolve(checkRequest.result) + checkRequest.onerror = () => reject(checkRequest.error || new Error("Failed to open database")) + checkRequest.onblocked = () => { + console.warn(`Database check blocked for "${this.table}" (DB: ${this.DB_NAME}), waiting for other connections to close`) + } + }) + + const storeExisted = checkDb.objectStoreNames.contains(this.table) + const needUpgrade = !storeExisted || this.needUpgradeIndexes(checkDb) + + if (!needUpgrade) { + checkDb.close() + return + } - const currentVersion = db.version - db.close() + const currentVersion = checkDb.version + checkDb.close() - const upgradeRequest = factory.open(this.DB_NAME, currentVersion + 1) + return new Promise((resolve, reject) => { + const upgradeRequest = factory.open(this.DB_NAME, currentVersion + 1) - upgradeRequest.onupgradeneeded = () => { + upgradeRequest.onupgradeneeded = () => { + try { const upgradeDb = upgradeRequest.result const transaction = upgradeRequest.transaction - if (!transaction) return reject("Failed to get transaction of upgrading request") + if (!transaction) { + reject(new Error("Failed to get transaction of upgrading request")) + return + } + + transaction.onerror = () => { + reject(transaction.error || new Error("Transaction failed")) + } + + transaction.onabort = () => { + reject(new Error("Upgrade transaction was aborted")) + } let store = upgradeDb.objectStoreNames.contains(this.table) ? transaction.objectStore(this.table) - : upgradeDb.createObjectStore(this.table, { keyPath: this.key }) + : upgradeDb.createObjectStore(this.table, { keyPath: this.key as string | string[] }) this.createIndexes(store) + } catch (error) { + console.error("Failed to upgrade database in onupgradeneeded", error) + upgradeRequest.transaction?.abort() + reject(error instanceof Error ? error : new Error(String(error))) } + } - upgradeRequest.onsuccess = () => { - console.log("IndexedDB upgraded") - this.db = upgradeRequest.result - resolve(upgradeRequest.result) - } + upgradeRequest.onsuccess = () => { + console.log(`IndexedDB upgraded for table "${this.table}"`) + upgradeRequest.result.close() + resolve() + } - upgradeRequest.onerror = () => reject(upgradeRequest.error) + upgradeRequest.onerror = (event) => { + console.error("Failed to upgrade database", event, upgradeRequest.error) + reject(upgradeRequest.error || new Error("Failed to upgrade database")) } - checkRequest.onerror = () => reject(checkRequest.error) + upgradeRequest.onblocked = () => { + const blockingTables = Array.from(BaseIDBStorage.initPromises.keys()) + .filter(table => table !== this.table) + console.warn( + `Database upgrade blocked for table "${this.table}" (DB: ${this.DB_NAME}), ` + + `waiting for other connections to close. ` + + `Other tables with active connections: ${blockingTables.length > 0 ? blockingTables.join(', ') : 'none'}` + ) + } }) } @@ -170,27 +263,45 @@ export abstract class BaseIDBStorage> { } protected async withStore(operation: (store: IDBObjectStore) => T | Promise, mode?: IDBTransactionMode): Promise { - const db = await this.initDb() - const trans = db.transaction(this.table, mode ?? 'readwrite') - try { - const store = trans.objectStore(this.table) - const result = await operation(store) - // Waiting for transaction completed - await new Promise((resolve, reject) => { - trans.oncomplete = () => resolve() - trans.onerror = () => reject(trans.error) - trans.onabort = () => reject(new Error('Transaction aborted')) - }) - return result - } catch (e) { - console.error("Failed to process with transaction", e) - if (!trans.error && trans.mode !== 'readonly') { - try { - trans.abort() - } catch (ignored) { } + let db = await this.initDb() + + for (let retryCount = 0; retryCount < 2; retryCount++) { + let trans: IDBTransaction | undefined + try { + trans = db.transaction(this.table, mode ?? 'readwrite') + const store = trans.objectStore(this.table) + const result = await operation(store) + const transaction = trans + await new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve() + transaction.onerror = () => reject(transaction.error) + transaction.onabort = () => reject(new Error('Transaction aborted')) + }) + return result + } catch (e) { + const errorType = detectTransactionError(e) + + if (errorType === 'Unknown') { + console.error("Failed to process with transaction", e) + if (trans && !trans.error && trans.mode !== 'readonly') { + try { + trans.abort() + } catch (ignored) { } + } + throw e + } + + if (errorType === 'StoreNotFound') { + this.db?.close() + await this.upgrade() + } + + this.db = undefined + BaseIDBStorage.initPromises.delete(this.table) + db = await this.initDb() } - throw e } + throw new Error("Max retries exceeded") } protected assertIndex(store: IDBObjectStore, key: Key | Key[]): IDBIndex { diff --git a/src/database/stat-database/idb.ts b/src/database/stat-database/idb.ts index b24b99fe..f9e7fba5 100644 --- a/src/database/stat-database/idb.ts +++ b/src/database/stat-database/idb.ts @@ -1,5 +1,5 @@ import { BaseIDBStorage, closedRangeKey, IndexResult, iterateCursor, type Key, req2Promise, type Table } from '@db/common/indexed-storage' -import { cvtGroupId2Host, formatDateStr, increase, zeroRow } from './common' +import { cvtGroupId2Host, formatDateStr, GROUP_PREFIX, increase, zeroRow } from './common' import { filterDate, filterHost, filterNumberRange, processCondition, type ProcessedCondition } from './condition' import type { StatCondition, StatDatabase } from './types' @@ -8,15 +8,7 @@ type StoredRow = timer.core.Row & { groupId?: number } -function fromStoredRow(stored: StoredRow): timer.core.Row { - if (stored.groupId !== undefined) { - const { groupId, ...row } = stored - return { ...row, host: cvtGroupId2Host(groupId) } - } - return stored -} - -const GROUP_HOST_PATTERN = /^_g_(\d+)$/ +const GROUP_HOST_PATTERN = new RegExp(`^${GROUP_PREFIX}(\\d+)$`) const INDEXES: (Key | Key[])[] = [ 'date', 'host', 'groupId', @@ -144,12 +136,7 @@ export class IDBStatDatabase extends BaseIDBStorage implements StatDa const rows = await iterateCursor(cursorReq) for (const row of rows) { - if (expectGroup) { - if (!isGroup(row)) continue - } else { - if (isGroup(row)) continue - } - + if (expectGroup !== isGroup(row)) continue if (!filter(row)) continue if (expectGroup) { @@ -196,9 +183,8 @@ export class IDBStatDatabase extends BaseIDBStorage implements StatDa await iterateCursor(cursorReq, cursor => { const stored = cursor.value as StoredRow | undefined - if (stored && !isGroup(stored)) { - toUpdate[stored.host] = fromStoredRow(stored) - } + if (!stored || isGroup(stored)) return + toUpdate[stored.host] = stored }) for (const [host, result] of Object.entries(data)) { @@ -332,15 +318,7 @@ export class IDBStatDatabase extends BaseIDBStorage implements StatDa } forceUpdate(...rows: timer.core.Row[]): Promise { - return this.withStore(store => { - for (const row of rows) { - const { host, date, time, focus, run } = row - const groupMatch = host.match(GROUP_HOST_PATTERN) - const newData: StoredRow = { host, date, time, focus, run } - groupMatch && (newData.groupId = parseInt(groupMatch[1])) - store.put(newData) - } - }, 'readwrite') + return this.withStore(store => rows.forEach(row => store.put(row)), 'readwrite') } forceUpdateGroup(...rows: timer.core.Row[]): Promise { diff --git a/test/database/idb.ts b/test/database/idb.ts deleted file mode 100644 index 1295425f..00000000 --- a/test/database/idb.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { } from 'fake-indexeddb' -import 'fake-indexeddb/auto' - -/** - * Clean up all IndexedDB databases - */ -export async function cleanupIDB() { - return new Promise((resolve) => { - try { - global.indexedDB = new IDBFactory() - setTimeout(resolve, 0) - } catch (error) { - console.error('Failed to cleanup database:', error) - resolve(undefined) - } - }) -} \ No newline at end of file diff --git a/test/database/stat-database/idb.test.ts b/test/database/stat-database/idb.test.ts index 4f0ed31f..b5afe6a3 100644 --- a/test/database/stat-database/idb.test.ts +++ b/test/database/stat-database/idb.test.ts @@ -2,9 +2,7 @@ import { zeroResult, zeroRow } from '@db/stat-database/common' import { IDBStatDatabase } from '@db/stat-database/idb' import 'fake-indexeddb/auto' import { mockRuntime } from '../../__mock__/runtime' -import { cleanupIDB } from '../idb' -let db: IDBStatDatabase const GOOGLE = 'www.google.com' const GITHUB = 'www.github.com' const GITHUB_VIRTUAL = 'www.github.com/sheepzh/**' @@ -12,13 +10,17 @@ const GROUP_1 = 1 const GROUP_2 = 2 const MAYBE_GROUP_1 = '1' +let db: IDBStatDatabase + describe('stat-database/idb', () => { - beforeAll(() => mockRuntime()) - beforeEach(async () => { - await cleanupIDB() + beforeAll(async () => { + mockRuntime() db = new IDBStatDatabase() + await db.upgrade() }) + beforeEach(() => db.clear()) + test('accumulate', async () => { await db.accumulate(GITHUB, '20240601', { focus: 10, time: 20 }) await db.accumulate(GOOGLE, new Date(2025, 10, 1), { focus: 1, time: 0 })