From 6280b52f55771e17cfba0747a1b462c45429bcbd Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Thu, 11 Sep 2025 06:46:18 +0000 Subject: [PATCH] Resolve "fix(Storage): IndexedDB Adapter issues" --- package-lock.json | 10 +- packages/stack/package.json | 1 + packages/stack/src/storage/store-indexeddb.ts | 207 ++++++++++-------- 3 files changed, 119 insertions(+), 99 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5796767..6915522 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3563,15 +3563,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -14828,6 +14819,7 @@ "@electrum-cash/protocol": "2.1.0-development.8075178089", "@generalprotocols/oracle-client": "^0.0.1", "@xocash/primitives": "*", + "async-mutex": "^0.5.0", "better-sqlite3": "^11.8.1", "idb": "8.0.3", "json-to-graphql-query": "^2.3.0", diff --git a/packages/stack/package.json b/packages/stack/package.json index 53ad8c2..33ddca6 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -40,6 +40,7 @@ "@electrum-cash/protocol": "2.1.0-development.8075178089", "@generalprotocols/oracle-client": "^0.0.1", "@xocash/primitives": "*", + "async-mutex": "^0.5.0", "better-sqlite3": "^11.8.1", "idb": "8.0.3", "json-to-graphql-query": "^2.3.0", diff --git a/packages/stack/src/storage/store-indexeddb.ts b/packages/stack/src/storage/store-indexeddb.ts index 9191ea1..6003a8b 100644 --- a/packages/stack/src/storage/store-indexeddb.ts +++ b/packages/stack/src/storage/store-indexeddb.ts @@ -8,13 +8,16 @@ import { Encrypter } from '@xocash/primitives'; import { pack, unpack } from 'msgpackr'; import { openDB, deleteDB, IDBPDatabase } from 'idb'; +import { Mutex } from 'async-mutex'; export class StorageIndexedDB extends BaseStorage { private db: IDBPDatabase; private dbName: string; - private versionChangePromise: Promise | null = null; private activeStores: Set = new Set(); // Track all active stores + // Mutex to prevent multiple upgrades from happening at the same time, resulting in a blocked upgrade state + private upgradeMutex = new Mutex(); + private constructor(db: IDBPDatabase, dbName: string) { super(); this.db = db; @@ -26,11 +29,32 @@ export class StorageIndexedDB extends BaseStorage { }); } - // Wait for any ongoing version changes to complete - private async waitForVersionChange(): Promise { - if (this.versionChangePromise) { - await this.versionChangePromise; - } + private async upgradeDb(upgrade: (db: IDBPDatabase) => void) { + const currentVersion = this.db.version; + const currentDB = this.db; + + const newDb = await openDB(this.dbName, currentVersion + 1, { + upgrade(db) { + upgrade(db); + }, + blocked() { + console.warn(`Database upgrade is blocked, waiting...`); + currentDB.close(); + }, + blocking() { + console.warn(`This connection is blocking upgrades`); + }, + terminated() { + console.error( + `Database connection was terminated unexpectedly during upgrade`, + ); + }, + }); + + // Update the database reference + this.db = newDb; + + return newDb; } public static async createOrOpen(dbName: string) { @@ -65,66 +89,46 @@ export class StorageIndexedDB extends BaseStorage { } async listStores() { - await this.waitForVersionChange(); + await this.upgradeMutex.waitForUnlock(); return Array.from(this.db.objectStoreNames); } async createStore(storeName: string, opts: StorageOpts = {}) { - await this.waitForVersionChange(); + // Acquire the mutex so we always have the newest database reference when upgrading + const release = await this.upgradeMutex.acquire(); + try { + if (!this.db.objectStoreNames.contains(storeName)) { + const newDb = await this.upgradeDb((db) => { + db.createObjectStore(storeName); + }); - if (!this.db.objectStoreNames.contains(storeName)) { - const currentVersion = this.db.version; - - // Set up the version change promise - this.versionChangePromise = (async () => { - try { - const newDb = await openDB(this.dbName, currentVersion + 1, { - upgrade(db) { - db.createObjectStore(storeName); - }, - blocked() { - console.warn(`Database upgrade is blocked, waiting...`); - }, - blocking() { - console.warn(`This connection is blocking upgrades`); - }, - terminated() { - console.error( - `Database connection was terminated unexpectedly during upgrade`, - ); - }, - }); - - // Update the database reference - this.db = newDb; - - // Update db references in all active stores - for (const store of this.activeStores) { - store.updateDatabaseReference(newDb); - } - } finally { - this.versionChangePromise = null; + // Update db references in all active stores + for (const store of this.activeStores) { + store.updateDatabaseReference(newDb); } - })(); + } - // Wait for the version change to complete - await this.versionChangePromise; + const store = await StoreIndexedDB.create( + this.db, + storeName, + this.upgradeMutex, + opts.encryption, + this, // Pass a reference to the parent storage + ); + + this.activeStores.add(store); // Track this store + return opts.syncInMemory + ? await StoreInMemorySynced.create(store) + : store; + } finally { + // Release the mutex + release(); } - - const store = await StoreIndexedDB.create( - this.db, - storeName, - opts.encryption, - this, // Pass a reference to the parent storage - ); - - this.activeStores.add(store); // Track this store - return opts.syncInMemory ? await StoreInMemorySynced.create(store) : store; } async getStore(storeName: string, opts: StorageOpts = {}) { try { - await this.waitForVersionChange(); + await this.upgradeMutex.waitForUnlock(); if (!this.db.objectStoreNames.contains(storeName)) { return null; @@ -133,6 +137,7 @@ export class StorageIndexedDB extends BaseStorage { const store = await StoreIndexedDB.create( this.db, storeName, + this.upgradeMutex, opts.encryption, this, // Pass reference to parent ); @@ -155,42 +160,20 @@ export class StorageIndexedDB extends BaseStorage { } async deleteStore(storeName: string) { - await this.waitForVersionChange(); + const release = await this.upgradeMutex.acquire(); + try { + if (this.db.objectStoreNames.contains(storeName)) { + const newDb = await this.upgradeDb((db) => { + db.deleteObjectStore(storeName); + }); - if (this.db.objectStoreNames.contains(storeName)) { - const currentVersion = this.db.version; - - this.versionChangePromise = (async () => { - try { - const newDb = await openDB(this.dbName, currentVersion + 1, { - upgrade(db) { - db.deleteObjectStore(storeName); - }, - blocked() { - console.warn(`Database upgrade is blocked, waiting...`); - }, - blocking() { - console.warn(`This connection is blocking upgrades`); - }, - terminated() { - console.error( - `Database connection was terminated unexpectedly during upgrade`, - ); - }, - }); - - this.db = newDb; - - // Update db references in all active stores - for (const store of this.activeStores) { - store.updateDatabaseReference(newDb); - } - } finally { - this.versionChangePromise = null; + // Update db references in all active stores + for (const store of this.activeStores) { + store.updateDatabaseReference(newDb); } - })(); - - await this.versionChangePromise; + } + } finally { + release(); } } @@ -218,6 +201,7 @@ export class StoreIndexedDB extends BaseStore { constructor( public db: IDBPDatabase, public storeName: string, + public databaseUpgradeMutex: Mutex, public encryption?: Encrypter, private parentStorage?: StorageIndexedDB, // Reference to parent storage ) { @@ -235,29 +219,42 @@ export class StoreIndexedDB extends BaseStore { * * @param db - An instance of an upgraded and opened IndexedDB database * @param storeName - The name of the object store within the database to use + * @param databaseUpgradeMutex - A mutex handed down from the parent StorageIndexedDB instance to wait for any upgrades to complete before accessing the database * @param encryption - Optional encrypter for encrypting/decrypting data * @param parentStorage - Reference to the parent StorageIndexedDB instance * * @example * ```typescript - * const store = await StoreIndexedDB.create(db, 'myStore'); + * const store = await StoreIndexedDB.create(db, 'myStore', databaseUpgradeMutex); * ``` */ static async create( db: IDBPDatabase, storeName: string, + databaseUpgradeMutex: Mutex, encryption?: Encrypter, parentStorage?: StorageIndexedDB, ) { - return new this(db, storeName, encryption, parentStorage); + return new this( + db, + storeName, + databaseUpgradeMutex, + encryption, + parentStorage, + ); } async all() { + // Wait for any upgrades to complete + await this.databaseUpgradeMutex.waitForUnlock(); + const tx = this.db.transaction(this.storeName, 'readonly'); const store = tx.objectStore(this.storeName); const keys = await store.getAllKeys(); const values = await store.getAll(); + await tx.done; + return keys.reduce( (acc, key, index) => { acc[key.toString()] = unpack(values[index]); @@ -268,13 +265,22 @@ export class StoreIndexedDB extends BaseStore { } async keys() { + // Wait for any upgrades to complete + await this.databaseUpgradeMutex.waitForUnlock(); + const tx = this.db.transaction(this.storeName, 'readonly'); const store = tx.objectStore(this.storeName); const keys = await store.getAllKeys(); + + await tx.done; + return keys.map((key) => key.toString()); } async get(key: string) { + // Wait for any upgrades to complete + await this.databaseUpgradeMutex.waitForUnlock(); + const encodedValue = await this.db.get(this.storeName, key); if (!encodedValue) { @@ -289,6 +295,9 @@ export class StoreIndexedDB extends BaseStore { } async set(key: string, value: T) { + // Wait for any upgrades to complete + await this.databaseUpgradeMutex.waitForUnlock(); + const encodedValue = pack(value); const encryptedValue = this.encryption @@ -300,11 +309,17 @@ export class StoreIndexedDB extends BaseStore { } async delete(key: string) { + // Wait for any upgrades to complete + await this.databaseUpgradeMutex.waitForUnlock(); + await this.db.delete(this.storeName, key); this.emit('delete', { key }); } async batchGet(keys: string[]): Promise> { + // Wait for any upgrades to complete + await this.databaseUpgradeMutex.waitForUnlock(); + const tx = this.db.transaction(this.storeName, 'readonly'); const store = tx.objectStore(this.storeName); @@ -323,6 +338,9 @@ export class StoreIndexedDB extends BaseStore { } async batchSet(items: Map) { + // Wait for any upgrades to complete + await this.databaseUpgradeMutex.waitForUnlock(); + const tx = this.db.transaction(this.storeName, 'readwrite'); const store = tx.objectStore(this.storeName); @@ -344,14 +362,23 @@ export class StoreIndexedDB extends BaseStore { } async clear() { + // Wait for any upgrades to complete + await this.databaseUpgradeMutex.waitForUnlock(); + await this.db.clear(this.storeName); this.emit('clear', undefined); } async has(key: string) { + // Wait for any upgrades to complete + await this.databaseUpgradeMutex.waitForUnlock(); + const tx = this.db.transaction(this.storeName, 'readonly'); const store = tx.objectStore(this.storeName); const result = await store.getKey(key); + + await tx.done; + return result !== undefined; }