diff --git a/package-lock.json b/package-lock.json index 443d1f120..bf4e51fd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ambire-common", - "version": "2.102.3", + "version": "2.103.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ambire-common", - "version": "2.102.3", + "version": "2.103.0", "dependencies": { "@ambire/signature-validator": "^1.5.0", "@lifi/types": "^17.7.1", @@ -21,6 +21,7 @@ "ethers": "^6.8.0", "events": "^3.3.0", "hash-wasm": "^4.12.0", + "idb": "^8.0.3", "js-yaml": "^4.1.0", "scrypt-js": "^3.0.1", "tldts": "7.0.17", @@ -57,6 +58,7 @@ "eslint-plugin-prettier": "5.2.1", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.1.1", + "fake-indexeddb": "^6.2.5", "globals": "15.9.0", "hardhat": "2.24.1", "hardhat-gas-reporter": "1.0.10", @@ -8854,6 +8856,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -9904,6 +9916,12 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index d8aac1bc5..c799fff18 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "ethers": "^6.8.0", "events": "^3.3.0", "hash-wasm": "^4.12.0", + "idb": "^8.0.3", "js-yaml": "^4.1.0", "scrypt-js": "^3.0.1", "tldts": "7.0.17", @@ -76,6 +77,7 @@ "eslint-plugin-prettier": "5.2.1", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.1.1", + "fake-indexeddb": "^6.2.5", "globals": "15.9.0", "hardhat": "2.24.1", "hardhat-gas-reporter": "1.0.10", diff --git a/src/controllers/activity/activity.ts b/src/controllers/activity/activity.ts index a291845e6..426574b91 100644 --- a/src/controllers/activity/activity.ts +++ b/src/controllers/activity/activity.ts @@ -6,9 +6,14 @@ import { pickBetterPoisoningMatch, ScoredAddressPoisoningMatch } from '@/libs/transfer/address-poisoning' +import { ActivityIdbStorage } from '@/services/storage/activityIdb' import { Account, AccountId, IAccountsController } from '../../interfaces/account' -import { IActivityController } from '../../interfaces/activity' +import { + IActivityController, + IActivityIdbStorage, + InternalAccountsOps +} from '../../interfaces/activity' import { Banner } from '../../interfaces/banner' import { IEventEmitterRegistryController } from '../../interfaces/eventEmitter' import { Fetch } from '../../interfaces/fetch' @@ -84,11 +89,6 @@ export interface Filters { identifiedBy?: AccountOpIdentifiedBy } -export interface InternalAccountsOps { - // account => network => SubmittedAccountOp[] - [key: string]: { [key: string]: SubmittedAccountOp[] } -} - export interface ExternalAccountOps { [account: string]: { [network: string]: SubmittedAccountOpLike[] } } @@ -96,6 +96,9 @@ export interface ExternalAccountOps { // We are limiting items array to include no more than 1000 records, // as we trim out the oldest ones (in the beginning of the items array). // We do this to maintain optimal storage and performance. +// Set to true to disable IndexedDB and benchmark against chrome.storage.local +const DISABLE_ACTIVITY_IDB = false + const trim = (items: T[], maxSize = 1000): void => { if (items.length > maxSize) { // If the array size is greater than maxSize, remove the last (oldest) item @@ -244,6 +247,8 @@ export class ActivityController extends EventEmitter implements IActivityControl #fetch: Fetch + #activityIdb?: IActivityIdbStorage + #initialLoadPromise?: Promise #accounts: IAccountsController @@ -322,6 +327,17 @@ export class ActivityController extends EventEmitter implements IActivityControl super(eventEmitterRegistry) this.#storage = storage this.#fetch = fetch + + // Initialize ActivityIdbStorage if available (browser environment) + if (!DISABLE_ACTIVITY_IDB && typeof indexedDB !== 'undefined') { + try { + this.#activityIdb = new ActivityIdbStorage() + console.log('[ActivityController] ActivityIdbStorage initialized') + } catch (error) { + console.error('[ActivityController] Failed to initialize ActivityIdbStorage', error) + } + } + this.#callRelayer = callRelayer this.#accounts = accounts this.#selectedAccount = selectedAccount @@ -338,8 +354,51 @@ export class ActivityController extends EventEmitter implements IActivityControl async #load(): Promise { await this.#accounts.initialLoadPromise await this.#selectedAccount.initialLoadPromise - const [accountsOps, externalAccountOps, signedMessages] = await Promise.all([ - this.#storage.get('accountsOps', {}), + + let accountsOps: InternalAccountsOps = {} + let externalAccountOps: ExternalAccountOps = {} + let signedMessages: InternalSignedMessages = {} + + // Migrate from chrome.storage.local to IDB if needed (first load) + if (this.#activityIdb) { + try { + const idbIsEmpty = await this.#activityIdb.isEmpty() + if (idbIsEmpty) { + const storedOps = await this.#storage.get('accountsOps', {}) + if (Object.keys(storedOps).length > 0) { + console.log('[ActivityController] Migrating accountsOps to IDB') + await this.#activityIdb.migrateFromStorage(storedOps) + console.log('[ActivityController] accountsOps migration complete') + } + } + } catch (error) { + console.error('[ActivityController] Failed to migrate to IDB', error) + } + } + + // Load from IDB or fallback to storage + if (this.#activityIdb) { + try { + console.time('[ActivityController] startup-load:idb') + accountsOps = await this.#activityIdb.loadStartupOps() + console.timeEnd('[ActivityController] startup-load:idb') + } catch (error) { + console.error( + '[ActivityController] Failed to load from IDB, falling back to storage', + error + ) + console.time('[ActivityController] startup-load:storage-fallback') + accountsOps = await this.#storage.get('accountsOps', {}) + console.timeEnd('[ActivityController] startup-load:storage-fallback') + } + } else { + console.time('[ActivityController] startup-load:storage') + accountsOps = await this.#storage.get('accountsOps', {}) + console.timeEnd('[ActivityController] startup-load:storage') + } + + // Always load externalAccountOps from storage (not migrated to IDB yet) + ;[externalAccountOps, signedMessages] = await Promise.all([ this.#storage.get('externalAccountOps', {}), this.#storage.get('signedMessages', {}) ]) @@ -347,7 +406,6 @@ export class ActivityController extends EventEmitter implements IActivityControl this.#accountsOps = accountsOps this.#externalAccountOps = externalAccountOps this.#signedMessages = signedMessages - this.emitUpdate() } @@ -446,14 +504,79 @@ export class ActivityController extends EventEmitter implements IActivityControl ) const enabledNetworkChainIds = this.#networks.networks.map(({ chainId }) => String(chainId)) - const internalAccountOpsByChain = this.#accountsOps[filters.account] || {} + let internalAccountOpsByChain = this.#accountsOps[filters.account] || {} const externalAccountOpsByChain = this.#externalAccountOps[filters.account] || {} + + console.log('[ActivityController] filterAccountsOps called', { + account: filters.account, + chainId: filters.chainId?.toString(), + page: pagination.fromPage, + knownAccounts: Object.keys(this.#accountsOps), + internalChains: Object.entries(internalAccountOpsByChain).map( + ([c, ops]) => `${c}:${ops.length}ops` + ), + externalChains: Object.entries(externalAccountOpsByChain).map( + ([c, ops]) => `${c}:${ops.length}ops` + ), + enabledNetworkChainIds + }) + + // Lazy-load from IDB if requesting pages beyond the in-memory window and IDB is available. + // Skip if in-memory already holds the full history (>= STARTUP_RECENT_OPS_LIMIT means it was + // previously expanded, so a second IDB round-trip would return the same data). + const chainIdString = filters.chainId?.toString() + const inMemoryCount = chainIdString + ? (internalAccountOpsByChain[chainIdString]?.length ?? 0) + : 0 + // IDB startup window loads 20 ops per chain; more than that means the cache was already expanded. + // Lazy-load whenever a chain filter is active and the cache is still in the startup window — + // including page 0, so the correct total page count is shown immediately on chain switch. + const idbStartupWindowSize = 20 + const alreadyFullyLoaded = inMemoryCount > idbStartupWindowSize + if (this.#activityIdb && filters.chainId && chainIdString && !alreadyFullyLoaded) { + try { + console.time(`[ActivityController] lazy-load:idb page=${pagination.fromPage}`) + const fullOpsFromIdb = await this.#activityIdb.getOpsForAccountAndChain( + filters.account, + filters.chainId + ) + console.timeEnd(`[ActivityController] lazy-load:idb page=${pagination.fromPage}`) + if (fullOpsFromIdb) { + console.log( + `[ActivityController] lazy-load: in-memory had ${inMemoryCount} ops → IDB returned ${fullOpsFromIdb.length} ops for ${filters.account}:${chainIdString}` + ) + // Update in-memory cache with full array from IDB + if (!internalAccountOpsByChain[chainIdString]) { + internalAccountOpsByChain[chainIdString] = [] + } + internalAccountOpsByChain[chainIdString] = fullOpsFromIdb + this.#accountsOps[filters.account] = internalAccountOpsByChain + } + } catch (error) { + console.error('ActivityController: Failed to lazy-load from IDB', error) + // Continue with in-memory data + } + } else { + console.log( + `[ActivityController] pagination page=${pagination.fromPage}: served from in-memory (${inMemoryCount} ops cached)` + ) + } + const internalAccountOpsEntriesOnEnabledNetworks = Object.entries( internalAccountOpsByChain ).filter(([chainId]) => enabledNetworkChainIds.includes(chainId)) const internalAccountOps = new Set( internalAccountOpsEntriesOnEnabledNetworks.flatMap(([, accountOps]) => accountOps) ) + + // Build a set of all txnIds from internal ops for dedup at the merge point. + // External ops whose txnId matches an internal op are filtered out here — they are + // duplicates that #removeExternalAccountOpsMatchingInternalOps missed because the + // internal op was outside the startup window when the scanner ran. + const internalTxnIds = new Set( + [...internalAccountOps].flatMap((op) => getInternalAccountOpTxnIds(op).map(normalizeTxnId)) + ) + const accountOpsEntriesOnEnabledNetworks = enabledNetworkChainIds .map( (chainId) => @@ -461,7 +584,9 @@ export class ActivityController extends EventEmitter implements IActivityControl chainId, [ ...(internalAccountOpsByChain[chainId] || []), - ...(externalAccountOpsByChain[chainId] || []) + ...(externalAccountOpsByChain[chainId] || []).filter( + (extOp) => !extOp.txnId || !internalTxnIds.has(normalizeTxnId(extOp.txnId)) + ) ] ] as const ) @@ -490,7 +615,12 @@ export class ActivityController extends EventEmitter implements IActivityControl ) } + console.time(`[ActivityController] paginate page=${pagination.fromPage}`) const result = paginate(filteredItems, pagination.fromPage, pagination.itemsPerPage) + console.timeEnd(`[ActivityController] paginate page=${pagination.fromPage}`) + console.log( + `[ActivityController] paginate: ${filteredItems.length} total ops → page ${pagination.fromPage} has ${result.items.length} items (${result.maxPages} pages total)` + ) this.setDashboardBannersSeen(sessionId, filters.account) this.accountsOps[sessionId] = { result, filters, pagination } @@ -587,8 +717,29 @@ export class ActivityController extends EventEmitter implements IActivityControl await Promise.all(promises) } - private async persistAccountsOps() { - await this.#storage.set('accountsOps', this.#accountsOps) + /** + * Writes accountsOps via IDB if available, otherwise falls back to chrome.storage.local. + * When IDB is available, errors are logged but do NOT fall back to storage — writing + * this.#accountsOps (startup subset) to storage would permanently truncate full history. + */ + async #persistToIdb(fn: () => Promise): Promise { + if (!this.#activityIdb) { + await this.#storage.set('accountsOps', this.#accountsOps) + return + } + try { + await fn() + console.log('ActivityController: IDB persist succeeded') + } catch (error) { + console.error('ActivityController: Failed to persist to IDB', error) + } + } + + /** + * Persist changed ops to IDB (or storage fallback), sync filtered views, and emit an update. + */ + private async persistAccountsOps(changedOps: SubmittedAccountOp[]) { + await this.#persistToIdb(() => this.#activityIdb!.updateOps(changedOps)) await this.syncFilteredAccountsOps() this.emitUpdate() } @@ -701,14 +852,21 @@ export class ActivityController extends EventEmitter implements IActivityControl if (!this.#accountsOps[accountAddr][chainId.toString()]) this.#accountsOps[accountAddr][chainId.toString()] = [] + // Capture the oldest op's id before mutating — it becomes the trimmed id if the + // group is already at capacity (trim() removes exactly one op via .pop()). + const group = this.#accountsOps[accountAddr][chainId.toString()]! + const trimmedId = group.length >= 1000 ? group[group.length - 1]?.id : undefined + // newest SubmittedAccountOp goes first in the list - this.#accountsOps[accountAddr]![chainId.toString()]!.unshift({ ...accountOp }) - trim(this.#accountsOps[accountAddr][chainId.toString()]!) + group.unshift({ ...accountOp }) + trim(group) await this.syncFilteredAccountsOps() - - await this.#storage.set('accountsOps', this.#accountsOps) this.emitUpdate() + + await this.#persistToIdb(() => + this.#activityIdb!.putSingleOp(accountAddr, chainId, accountOp, trimmedId) + ) } async addExternalAccountOp({ @@ -857,6 +1015,7 @@ export class ActivityController extends EventEmitter implements IActivityControl externalAccountOps.unshift(submittedAccountOpLike) trim(externalAccountOps) + // externalAccountOps: using chrome.storage.local only (not migrated to IDB yet) await this.#storage.set('externalAccountOps', this.#externalAccountOps) await this.syncFilteredAccountsOps() this.emitUpdate() @@ -895,7 +1054,7 @@ export class ActivityController extends EventEmitter implements IActivityControl */ async backfillAccountOpBalanceChangesAndPersist(accountOps: SubmittedAccountOp[]) { await Promise.all(accountOps.map((accOp) => this.backfillAccountOpBalanceChanges(accOp))) - await this.persistAccountsOps() + await this.persistAccountsOps(accountOps) } /** @@ -1064,7 +1223,7 @@ export class ActivityController extends EventEmitter implements IActivityControl ) ) ) - await this.persistAccountsOps() + await this.persistAccountsOps(balanceChangesTasks.map((t) => t.accountOp)) } /** @@ -1377,7 +1536,7 @@ export class ActivityController extends EventEmitter implements IActivityControl if (shouldEmitUpdate) { // remove duplicates if encountered during a race condition await this.#removeExternalAccountOpsMatchingInternalOps(updatedAccountsOps) - await this.persistAccountsOps() + await this.persistAccountsOps(updatedAccountsOps) } // record the balance changes but do not await them @@ -1469,8 +1628,22 @@ export class ActivityController extends EventEmitter implements IActivityControl await this.syncFilteredAccountsOps() await this.syncSignedMessages() - await this.#storage.set('accountsOps', this.#accountsOps) - await this.#storage.set('signedMessages', this.#signedMessages) + // Delete from IDB if available + if (this.#activityIdb) { + try { + await this.#activityIdb.deleteAccount(address) + } catch (error) { + console.error( + 'ActivityController: Failed to delete from IDB, falling back to storage', + error + ) + await this.#storage.set('accountsOps', this.#accountsOps) + await this.#storage.set('signedMessages', this.#signedMessages) + } + } else { + await this.#storage.set('accountsOps', this.#accountsOps) + await this.#storage.set('signedMessages', this.#signedMessages) + } this.emitUpdate() } diff --git a/src/interfaces/activity.ts b/src/interfaces/activity.ts index d09077198..3a49248eb 100644 --- a/src/interfaces/activity.ts +++ b/src/interfaces/activity.ts @@ -1,5 +1,88 @@ +import { SubmittedAccountOp, SubmittedAccountOpLike } from '../libs/accountOp/submittedAccountOp' import { ControllerInterface } from './controller' export type IActivityController = ControllerInterface< InstanceType > + +export interface InternalAccountsOps { + // account => network => SubmittedAccountOp[] + [key: string]: { [key: string]: SubmittedAccountOp[] } +} + +/** + * Interface for IndexedDB-backed storage of account operations. + * Provides minimal, targeted reads/writes compared to full blob serialization. + */ +export interface IActivityIdbStorage { + /** + * Load minimal startup dataset: all pending ops + 20 most recent per (account, chainId) + * Used during ActivityController initialization. + */ + loadStartupOps(): Promise + + /** + * Fetch full history for a specific (account, chainId) pair. + * Used for lazy-loading older history during pagination. + */ + getOpsForAccountAndChain( + accountAddr: string, + chainId: bigint | string + ): Promise + + /** + * Write ops for a single (account, chainId) pair. + * Accepts both internal and external account ops. + */ + putOpsForAccountAndChain( + accountAddr: string, + chainId: bigint | string, + ops: (SubmittedAccountOp | SubmittedAccountOpLike)[] + ): Promise + + /** + * Batch write multiple (account, chainId) records in a single transaction. + * More efficient than multiple individual puts. + * Accepts both internal and external account ops. + */ + putMultiple( + records: Array<{ + accountAddr: string + chainId: bigint | string + ops: (SubmittedAccountOp | SubmittedAccountOpLike)[] + }> + ): Promise + + /** + * Delete all ops for an account across all chains. + */ + deleteAccount(accountAddr: string): Promise + + /** + * One-time migration: import all ops from chrome.storage.local into IDB. + * After successful import, the caller should remove the key from chrome.storage.local. + */ + migrateFromStorage(data: InternalAccountsOps): Promise + + /** + * Check if IDB has any data (used to detect if migration is needed). + */ + isEmpty(): Promise + + /** + * Write a single new op and optionally delete the op evicted by the in-memory trim. + * Use instead of putOpsForAccountAndChain when adding one op at a time. + */ + putSingleOp( + accountAddr: string, + chainId: bigint | string, + op: SubmittedAccountOp, + trimmedId?: string + ): Promise + + /** + * Update existing rows in place (e.g. status or balance-change updates). + * Only the provided ops are written — no range-delete. + */ + updateOps(ops: SubmittedAccountOp[]): Promise +} diff --git a/src/services/storage/activityIdb.test.ts b/src/services/storage/activityIdb.test.ts new file mode 100644 index 000000000..0b52d1d11 --- /dev/null +++ b/src/services/storage/activityIdb.test.ts @@ -0,0 +1,379 @@ +import 'fake-indexeddb/auto' + +import { IDBFactory, IDBKeyRange } from 'fake-indexeddb' +import { beforeEach, describe, expect, test } from '@jest/globals' + +import { SubmittedAccountOpLike } from '../../libs/accountOp/submittedAccountOp' +import { AccountOpStatus } from '../../libs/accountOp/types' +import { ActivityIdbStorage } from './activityIdb' +import { BaseIdbStore } from './baseIdbStore' + +// ───────────────────────────────────────────────────────────────────────────── +// Test constants +// ───────────────────────────────────────────────────────────────────────────── + +const ACC_A = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const ACC_B = '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' +const CHAIN_1 = 1n +const CHAIN_137 = 137n + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function makeOp( + id: string, + accountAddr: string, + chainId: bigint, + status: AccountOpStatus, + timestamp: number +): SubmittedAccountOpLike { + return { + id, + accountAddr, + chainId, + calls: [], + gasFeePayment: null as any, + status, + timestamp, + identifiedBy: { type: 'Transaction', identifier: `0x${id}` } + } as SubmittedAccountOpLike +} + +beforeEach(() => { + // Reset the shared DB connection so each test gets a completely isolated environment. + BaseIdbStore.resetDb('ambire') + global.indexedDB = new IDBFactory() + global.IDBKeyRange = IDBKeyRange + // checkQuota() reads navigator.storage — stub it to avoid ReferenceError in Node. + ;(global as any).navigator = {} +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +describe('ActivityIdbStorage', () => { + describe('isEmpty', () => { + test('returns true on a fresh store', async () => { + const store = new ActivityIdbStorage() + expect(await store.isEmpty()).toBe(true) + }) + + test('returns false after data is written', async () => { + const store = new ActivityIdbStorage() + await store.putOpsForAccountAndChain(ACC_A, CHAIN_1, [ + makeOp('op-1', ACC_A, CHAIN_1, AccountOpStatus.Success, 1000) + ]) + expect(await store.isEmpty()).toBe(false) + }) + }) + + describe('putOpsForAccountAndChain + getOpsForAccountAndChain', () => { + test('stores ops and returns them sorted by timestamp descending', async () => { + const store = new ActivityIdbStorage() + await store.putOpsForAccountAndChain(ACC_A, CHAIN_1, [ + makeOp('op-1', ACC_A, CHAIN_1, AccountOpStatus.Success, 1000), + makeOp('op-2', ACC_A, CHAIN_1, AccountOpStatus.Success, 3000), + makeOp('op-3', ACC_A, CHAIN_1, AccountOpStatus.Success, 2000) + ]) + + const result = await store.getOpsForAccountAndChain(ACC_A, CHAIN_1) + expect(result?.map((op) => op.id)).toEqual(['op-2', 'op-3', 'op-1']) + }) + + test('returns undefined when no ops exist for the pair', async () => { + const store = new ActivityIdbStorage() + expect(await store.getOpsForAccountAndChain(ACC_A, CHAIN_1)).toBeUndefined() + }) + + test('accepts bigint chainId — retrieve with bigint or equivalent string', async () => { + const store = new ActivityIdbStorage() + await store.putOpsForAccountAndChain(ACC_A, CHAIN_137, [ + makeOp('op-1', ACC_A, CHAIN_137, AccountOpStatus.Success, 1000) + ]) + + expect(await store.getOpsForAccountAndChain(ACC_A, CHAIN_137)).toHaveLength(1) + expect(await store.getOpsForAccountAndChain(ACC_A, '137')).toHaveLength(1) + }) + + test('replaces existing ops on second write to the same pair', async () => { + const store = new ActivityIdbStorage() + await store.putOpsForAccountAndChain(ACC_A, CHAIN_1, [ + makeOp('old', ACC_A, CHAIN_1, AccountOpStatus.Success, 1000) + ]) + await store.putOpsForAccountAndChain(ACC_A, CHAIN_1, [ + makeOp('new-1', ACC_A, CHAIN_1, AccountOpStatus.Failure, 2000), + makeOp('new-2', ACC_A, CHAIN_1, AccountOpStatus.Success, 3000) + ]) + + const result = await store.getOpsForAccountAndChain(ACC_A, CHAIN_1) + expect(result).toHaveLength(2) + expect(result?.map((op) => op.id)).not.toContain('old') + }) + + test('different chains for the same account are stored independently', async () => { + const store = new ActivityIdbStorage() + await store.putOpsForAccountAndChain(ACC_A, CHAIN_1, [ + makeOp('chain1-op', ACC_A, CHAIN_1, AccountOpStatus.Success, 1000) + ]) + await store.putOpsForAccountAndChain(ACC_A, CHAIN_137, [ + makeOp('chain137-op', ACC_A, CHAIN_137, AccountOpStatus.Success, 2000) + ]) + + expect((await store.getOpsForAccountAndChain(ACC_A, CHAIN_1))![0].id).toBe('chain1-op') + expect((await store.getOpsForAccountAndChain(ACC_A, CHAIN_137))![0].id).toBe('chain137-op') + }) + }) + + describe('putMultiple', () => { + test('writes all (account, chainId) pairs atomically', async () => { + const store = new ActivityIdbStorage() + await store.putMultiple([ + { + accountAddr: ACC_A, + chainId: CHAIN_1, + ops: [makeOp('a1', ACC_A, CHAIN_1, AccountOpStatus.Success, 1)] + }, + { + accountAddr: ACC_A, + chainId: CHAIN_137, + ops: [makeOp('a137', ACC_A, CHAIN_137, AccountOpStatus.Success, 2)] + }, + { + accountAddr: ACC_B, + chainId: CHAIN_1, + ops: [makeOp('b1', ACC_B, CHAIN_1, AccountOpStatus.Success, 3)] + } + ]) + + expect(await store.getOpsForAccountAndChain(ACC_A, CHAIN_1)).toHaveLength(1) + expect(await store.getOpsForAccountAndChain(ACC_A, CHAIN_137)).toHaveLength(1) + expect(await store.getOpsForAccountAndChain(ACC_B, CHAIN_1)).toHaveLength(1) + }) + + test('replaces existing ops per pair', async () => { + const store = new ActivityIdbStorage() + await store.putMultiple([ + { + accountAddr: ACC_A, + chainId: CHAIN_1, + ops: [makeOp('old', ACC_A, CHAIN_1, AccountOpStatus.Success, 1)] + } + ]) + await store.putMultiple([ + { + accountAddr: ACC_A, + chainId: CHAIN_1, + ops: [makeOp('new', ACC_A, CHAIN_1, AccountOpStatus.Success, 2)] + } + ]) + + const result = await store.getOpsForAccountAndChain(ACC_A, CHAIN_1) + expect(result).toHaveLength(1) + expect(result![0].id).toBe('new') + }) + }) + + describe('deleteAccount', () => { + test('removes all chains for the given account', async () => { + const store = new ActivityIdbStorage() + await store.putMultiple([ + { + accountAddr: ACC_A, + chainId: CHAIN_1, + ops: [makeOp('a1', ACC_A, CHAIN_1, AccountOpStatus.Success, 1)] + }, + { + accountAddr: ACC_A, + chainId: CHAIN_137, + ops: [makeOp('a137', ACC_A, CHAIN_137, AccountOpStatus.Success, 2)] + } + ]) + + await store.deleteAccount(ACC_A) + + expect(await store.getOpsForAccountAndChain(ACC_A, CHAIN_1)).toBeUndefined() + expect(await store.getOpsForAccountAndChain(ACC_A, CHAIN_137)).toBeUndefined() + }) + + test('does not affect other accounts', async () => { + const store = new ActivityIdbStorage() + await store.putMultiple([ + { + accountAddr: ACC_A, + chainId: CHAIN_1, + ops: [makeOp('a', ACC_A, CHAIN_1, AccountOpStatus.Success, 1)] + }, + { + accountAddr: ACC_B, + chainId: CHAIN_1, + ops: [makeOp('b', ACC_B, CHAIN_1, AccountOpStatus.Success, 2)] + } + ]) + + await store.deleteAccount(ACC_A) + + expect(await store.getOpsForAccountAndChain(ACC_B, CHAIN_1)).toHaveLength(1) + }) + + test('is a no-op when the account has no ops', async () => { + const store = new ActivityIdbStorage() + await expect(store.deleteAccount(ACC_A)).resolves.not.toThrow() + }) + }) + + describe('migrateFromStorage', () => { + test('imports all ops from InternalAccountsOps format', async () => { + const store = new ActivityIdbStorage() + await store.migrateFromStorage({ + [ACC_A]: { + '1': [makeOp('op-1', ACC_A, CHAIN_1, AccountOpStatus.Success, 1000) as any], + '137': [makeOp('op-2', ACC_A, CHAIN_137, AccountOpStatus.Failure, 2000) as any] + }, + [ACC_B]: { + '1': [makeOp('op-3', ACC_B, CHAIN_1, AccountOpStatus.Success, 3000) as any] + } + }) + + expect(await store.getOpsForAccountAndChain(ACC_A, '1')).toHaveLength(1) + expect(await store.getOpsForAccountAndChain(ACC_A, '137')).toHaveLength(1) + expect(await store.getOpsForAccountAndChain(ACC_B, '1')).toHaveLength(1) + }) + + test('preserves op ids and timestamps after migration', async () => { + const store = new ActivityIdbStorage() + await store.migrateFromStorage({ + [ACC_A]: { + '1': [makeOp('migrate-op', ACC_A, CHAIN_1, AccountOpStatus.Success, 42000) as any] + } + }) + + const result = await store.getOpsForAccountAndChain(ACC_A, '1') + expect(result![0].id).toBe('migrate-op') + expect(result![0].timestamp).toBe(42000) + }) + }) + + describe('loadStartupOps', () => { + test('returns an empty object for an empty store', async () => { + const store = new ActivityIdbStorage() + expect(await store.loadStartupOps()).toEqual({}) + }) + + test('returns all pending ops regardless of how many there are', async () => { + const store = new ActivityIdbStorage() + // 25 pending — more than the 20-op finalized limit + const ops = Array.from({ length: 25 }, (_, i) => + makeOp(`op-${i}`, ACC_A, CHAIN_1, AccountOpStatus.BroadcastedButNotConfirmed, i * 100) + ) + await store.putOpsForAccountAndChain(ACC_A, CHAIN_1, ops) + + const result = await store.loadStartupOps() + expect(result[ACC_A]?.['1']).toHaveLength(25) + }) + + test('limits finalized ops to 20 per (account, chainId) group', async () => { + const store = new ActivityIdbStorage() + const ops = Array.from({ length: 25 }, (_, i) => + makeOp(`op-${i}`, ACC_A, CHAIN_1, AccountOpStatus.Success, i * 100) + ) + await store.putOpsForAccountAndChain(ACC_A, CHAIN_1, ops) + + const result = await store.loadStartupOps() + expect(result[ACC_A]?.['1']).toHaveLength(20) + }) + + test('always includes pending ops even when the finalized limit is already reached', async () => { + const store = new ActivityIdbStorage() + const finalized = Array.from({ length: 20 }, (_, i) => + makeOp(`fin-${i}`, ACC_A, CHAIN_1, AccountOpStatus.Success, i * 10) + ) + const pending = Array.from({ length: 3 }, (_, i) => + makeOp(`pend-${i}`, ACC_A, CHAIN_1, AccountOpStatus.BroadcastedButNotConfirmed, 1000 + i) + ) + await store.putOpsForAccountAndChain(ACC_A, CHAIN_1, [...finalized, ...pending]) + + const result = await store.loadStartupOps() + const ids = result[ACC_A]!['1'].map((op) => op.id) + expect(ids.filter((id) => id.startsWith('pend-'))).toHaveLength(3) + expect(ids.filter((id) => id.startsWith('fin-'))).toHaveLength(20) + }) + + test('returns ops sorted by timestamp descending within each group', async () => { + const store = new ActivityIdbStorage() + await store.putOpsForAccountAndChain(ACC_A, CHAIN_1, [ + makeOp('op-1', ACC_A, CHAIN_1, AccountOpStatus.Success, 100), + makeOp('op-2', ACC_A, CHAIN_1, AccountOpStatus.Success, 300), + makeOp('op-3', ACC_A, CHAIN_1, AccountOpStatus.Success, 200) + ]) + + const result = await store.loadStartupOps() + expect(result[ACC_A]!['1'].map((op) => op.timestamp)).toEqual([300, 200, 100]) + }) + + test('handles multiple accounts and chains independently', async () => { + const store = new ActivityIdbStorage() + await store.putMultiple([ + { + accountAddr: ACC_A, + chainId: CHAIN_1, + ops: [makeOp('a1', ACC_A, CHAIN_1, AccountOpStatus.Success, 1)] + }, + { + accountAddr: ACC_A, + chainId: CHAIN_137, + ops: [makeOp('a137', ACC_A, CHAIN_137, AccountOpStatus.Success, 2)] + }, + { + accountAddr: ACC_B, + chainId: CHAIN_1, + ops: [makeOp('b1', ACC_B, CHAIN_1, AccountOpStatus.Success, 3)] + } + ]) + + const result = await store.loadStartupOps() + expect(result[ACC_A]?.['1']).toHaveLength(1) + expect(result[ACC_A]?.['137']).toHaveLength(1) + expect(result[ACC_B]?.['1']).toHaveLength(1) + }) + + test('selects the 20 newest finalized ops (highest timestamps) per group', async () => { + const store = new ActivityIdbStorage() + // ops 0-24 — op with index 24 has the highest timestamp + const ops = Array.from({ length: 25 }, (_, i) => + makeOp(`op-${i}`, ACC_A, CHAIN_1, AccountOpStatus.Success, i * 100) + ) + await store.putOpsForAccountAndChain(ACC_A, CHAIN_1, ops) + + const result = await store.loadStartupOps() + const timestamps = result[ACC_A]!['1'].map((op) => op.timestamp) + // Expect 2400, 2300, ..., 500 (the 20 newest) + expect(Math.min(...timestamps)).toBe(500) + expect(Math.max(...timestamps)).toBe(2400) + }) + }) + + describe('bigint serialization roundtrip', () => { + test('chainId and nonce survive serialize → store → deserialize', async () => { + const store = new ActivityIdbStorage() + const op = { + id: 'bigint-op', + accountAddr: ACC_A, + chainId: CHAIN_1, + nonce: 42n, + calls: [], + gasFeePayment: null, + status: AccountOpStatus.Success, + timestamp: 1000, + identifiedBy: { type: 'Transaction', identifier: '0xhash' } + } as unknown as SubmittedAccountOpLike + + await store.putOpsForAccountAndChain(ACC_A, CHAIN_1, [op]) + const [retrieved] = (await store.getOpsForAccountAndChain(ACC_A, CHAIN_1))! + + expect(retrieved.chainId).toBe(CHAIN_1) + expect((retrieved as any).nonce).toBe(42n) + }) + }) +}) diff --git a/src/services/storage/activityIdb.ts b/src/services/storage/activityIdb.ts new file mode 100644 index 000000000..ebd101345 --- /dev/null +++ b/src/services/storage/activityIdb.ts @@ -0,0 +1,399 @@ +import { IActivityIdbStorage, InternalAccountsOps } from '../../interfaces/activity' +import { SubmittedAccountOp, SubmittedAccountOpLike } from '../../libs/accountOp/submittedAccountOp' +import { AccountOpStatus } from '../../libs/accountOp/types' +import { BaseIdbStore } from './baseIdbStore' + +const STARTUP_RECENT_OPS_LIMIT = 20 +// Hard cap on the number of IDB rows per (account, chainId) group. +// The in-memory cap is enforced by ActivityController; this guards against IDB +// accumulating more rows than the in-memory limit (e.g. after a startup that +// loaded only the 20-op subset before the limit was enforced). +const MAX_IDB_GROUP_SIZE = 1000 + +interface IdbAccountOpRow { + accountAddr: string + // String copy of op.chainId — BigInt is not a valid IDB key type so it cannot + // be used directly in the compound keyPath or index keys. + chainId: string + id: string + timestamp: number + status: AccountOpStatus + // The full op is stored via the Structured Clone Algorithm, which preserves + // BigInt natively — no JSON serialization needed. + op: SubmittedAccountOp | SubmittedAccountOpLike +} + +// The highest BMP Unicode character — used as a range upper bound to select all keys +// that start with a given prefix, without matching the prefix itself as a key. +const RANGE_HIGH = '￿' + +export class ActivityIdbStorage extends BaseIdbStore implements IActivityIdbStorage { + constructor() { + super({ + dbName: 'ambire', + storeName: 'accountsOps', + keyPath: ['accountAddr', 'chainId', 'id'], + dbVersion: 1, + indexes: [ + { name: 'by-account-chain-timestamp', keyPath: ['accountAddr', 'chainId', 'timestamp'] }, + { name: 'by-account-chain-status', keyPath: ['accountAddr', 'chainId', 'status'] } + ] + }) + } + + // ────────────────────────────────────────────────────────────────────────────── + // Public API + // ────────────────────────────────────────────────────────────────────────────── + + /** + * Load minimal startup dataset: all pending ops + up to STARTUP_RECENT_OPS_LIMIT + * finalized ops per (account, chain). + * + * Two transactions: + * 1. Key-only cursor enumerates (accountAddr, chainId) groups — O(N_groups) reads. + * 2. Per-group queries run in parallel within one transaction: + * - 'by-account-chain-timestamp' cursor (prev) → top N finalized, stops early + * - 'by-account-chain-status' getAll × 2 → all pending ops + * + * All per-group requests are fired before any await resolves, keeping the + * transaction open for the duration. + */ + async loadStartupOps(): Promise { + await this.init() + const db = this.db! + + // Step 1: enumerate (accountAddr, chainId) groups — key-only cursor, O(N_groups) reads + const groups: [string, string][] = [] + { + const tx = db.transaction(this.config.storeName, 'readonly') + let cursor = await tx.objectStore(this.config.storeName).openKeyCursor() + while (cursor) { + const [accountAddr, chainId] = cursor.primaryKey as [string, string, string] + groups.push([accountAddr, chainId]) + cursor = await cursor.continue([accountAddr, chainId, RANGE_HIGH]) + } + } + + if (groups.length === 0) return {} + + // Step 2: fetch per-group data — all groups run in parallel within one transaction. + // Each group's async function fires its IDB requests (1 cursor + 2 getAlls) before + // the first await resolves, so the transaction always has pending requests. + const result: InternalAccountsOps = {} + { + const tx = db.transaction(this.config.storeName, 'readonly') + const store = tx.objectStore(this.config.storeName) + const tsIndex = store.index('by-account-chain-timestamp') + const statusIndex = store.index('by-account-chain-status') + + await Promise.all( + groups.map(async ([accountAddr, chainId]) => { + if (!result[accountAddr]) result[accountAddr] = {} + if (!result[accountAddr]![chainId]) result[accountAddr]![chainId] = [] + const groupOps = result[accountAddr]![chainId]! + + const tsRange = IDBKeyRange.bound( + [accountAddr, chainId, 0], + [accountAddr, chainId, Number.MAX_SAFE_INTEGER] + ) + + // Run timestamp cursor + 2 pending getAlls in parallel for this group. + // The getAlls are fired synchronously (before any await), the cursor IIFE + // fires its first request synchronously too — all 3 are pending at once. + const [, pendingBroadcasted, pendingQueued] = await Promise.all([ + (async () => { + let finalizedCount = 0 + let cur = await tsIndex.openCursor(tsRange, 'prev') + while (cur && finalizedCount < STARTUP_RECENT_OPS_LIMIT) { + const row = cur.value as IdbAccountOpRow + const isPending = + row.status === AccountOpStatus.BroadcastedButNotConfirmed || + row.status === AccountOpStatus.Pending + if (!isPending) { + groupOps.push(row.op as SubmittedAccountOp) + finalizedCount++ + } + cur = await cur.continue() + } + })(), + statusIndex.getAll( + IDBKeyRange.only([accountAddr, chainId, AccountOpStatus.BroadcastedButNotConfirmed]) + ), + statusIndex.getAll(IDBKeyRange.only([accountAddr, chainId, AccountOpStatus.Pending])) + ]) + + for (const row of [...pendingBroadcasted, ...pendingQueued] as IdbAccountOpRow[]) { + groupOps.push(row.op as SubmittedAccountOp) + } + }) + ) + } + + // Sort each group descending by timestamp + for (const chainMap of Object.values(result)) { + for (const ops of Object.values(chainMap)) { + ops.sort((a, b) => b.timestamp - a.timestamp) + } + } + + console.log(`[ActivityIdbStorage] loadStartupOps: ${groups.length} groups loaded`) + return result + } + + /** + * Write a single new op and optionally delete the op evicted by the in-memory trim. + * O(1) IDB operations vs. the full-group rewrite of putOpsForAccountAndChain. + */ + async putSingleOp( + accountAddr: string, + chainId: bigint | string, + op: SubmittedAccountOp, + trimmedId?: string + ): Promise { + await this.init() + const chainIdStr = typeof chainId === 'bigint' ? chainId.toString() : chainId + const tx = this.db!.transaction(this.config.storeName, 'readwrite') + const store = tx.objectStore(this.config.storeName) + + // Fire put before any await so the transaction has a pending request. + // .catch(() => {}) suppresses unhandled-rejection warnings; tx.done still + // rejects on failure and is awaited below. + console.log(`[ActivityIdbStorage] putSingleOp ${accountAddr}:${chainIdStr} op=${op.id}`) + store.put(this.#opToRow(accountAddr, chainIdStr, op)).catch(() => {}) + + if (trimmedId) { + // In-memory trim already identified the op to evict. + console.log( + `[ActivityIdbStorage] putSingleOp evicting in-memory trimmed op=${trimmedId} for ${accountAddr}:${chainIdStr}` + ) + store.delete([accountAddr, chainIdStr, trimmedId]).catch(() => {}) + } else { + // The in-memory group is within its cap, but IDB may have accumulated more + // rows than the in-memory limit (e.g. after a startup that only loaded the + // 20-op subset). Count after the put (IDB serializes requests within a tx) + // and evict the oldest row when the group exceeds the hard cap. + const groupRange = IDBKeyRange.bound( + [accountAddr, chainIdStr, ''], + [accountAddr, chainIdStr, RANGE_HIGH] + ) + const count = await store.count(groupRange) + console.log( + `[ActivityIdbStorage] putSingleOp IDB group size after put: ${count} for ${accountAddr}:${chainIdStr}` + ) + if (count > MAX_IDB_GROUP_SIZE) { + const tsIndex = store.index('by-account-chain-timestamp') + const cursor = await tsIndex.openCursor( + IDBKeyRange.bound( + [accountAddr, chainIdStr, 0], + [accountAddr, chainIdStr, Number.MAX_SAFE_INTEGER] + ) + ) + if (cursor) { + console.log( + `[ActivityIdbStorage] putSingleOp IDB cap hit (${count}/${MAX_IDB_GROUP_SIZE}) — evicting oldest op id=${cursor.value.id} ts=${cursor.value.timestamp} for ${accountAddr}:${chainIdStr}` + ) + store.delete(cursor.primaryKey as IDBValidKey).catch(() => {}) + } + } + } + + await tx.done + console.log(`[ActivityIdbStorage] putSingleOp committed for ${accountAddr}:${chainIdStr}`) + this.checkQuota() + } + + /** + * Update existing rows in place (status or balance-change updates). + * Uses store.put() per op — no range-delete, only touched rows are written. + */ + async updateOps(ops: SubmittedAccountOp[]): Promise { + if (ops.length === 0) return + await this.init() + + console.log( + `[ActivityIdbStorage] updateOps ${ops.length} op(s): ${ops.map((o) => o.id).join(', ')}` + ) + const tx = this.db!.transaction(this.config.storeName, 'readwrite') + const store = tx.objectStore(this.config.storeName) + for (const op of ops) { + store.put(this.#opToRow(op.accountAddr, op.chainId.toString(), op)).catch(() => {}) + } + await tx.done + console.log(`[ActivityIdbStorage] updateOps committed`) + } + + /** + * Fetch all ops for a specific (account, chainId) pair (full history, no limit). + * Used for lazy-loading older history during pagination. + * Returns undefined if no ops found (matches existing caller checks). + */ + async getOpsForAccountAndChain( + accountAddr: string, + chainId: bigint | string + ): Promise { + await this.init() + + const chainIdStr = typeof chainId === 'bigint' ? chainId.toString() : chainId + const range = IDBKeyRange.bound( + [accountAddr, chainIdStr, ''], + [accountAddr, chainIdStr, RANGE_HIGH] + ) + const rows = (await this.db!.getAll(this.config.storeName, range)) as IdbAccountOpRow[] + + if (rows.length === 0) return undefined + + rows.sort((a, b) => b.timestamp - a.timestamp) + const result = rows.map((r) => r.op as SubmittedAccountOp) + console.log( + `[ActivityIdbStorage] getOpsForAccountAndChain ${accountAddr}:${chainIdStr} - found ${result.length} ops` + ) + return result + } + + /** + * Write ops for a single (account, chainId) pair. + * Deletes existing rows for this pair first, then inserts new ones. + */ + async putOpsForAccountAndChain( + accountAddr: string, + chainId: bigint | string, + ops: (SubmittedAccountOp | SubmittedAccountOpLike)[] + ): Promise { + return this.putMultiple([{ accountAddr, chainId, ops }]) + } + + /** + * Batch write multiple (account, chainId) pairs in a single transaction. + * More efficient than multiple individual puts. + */ + async putMultiple( + records: Array<{ + accountAddr: string + chainId: bigint | string + ops: (SubmittedAccountOp | SubmittedAccountOpLike)[] + }> + ): Promise { + await this.init() + + const tx = this.db!.transaction(this.config.storeName, 'readwrite') + const store = tx.objectStore(this.config.storeName) + + for (const { accountAddr, chainId, ops } of records) { + const chainIdStr = typeof chainId === 'bigint' ? chainId.toString() : chainId + this.#writeRecordToStore(store, accountAddr, chainIdStr, this.#dedupeOpsById(ops)) + } + + await tx.done + const totalOps = records.reduce((sum, r) => sum + r.ops.length, 0) + console.log( + `[ActivityIdbStorage] putMultiple complete - wrote ${records.length} records (${totalOps} ops)` + ) + this.checkQuota() + } + + /** + * Delete all ops for an account across all chains. + */ + async deleteAccount(accountAddr: string): Promise { + await this.init() + + const range = IDBKeyRange.bound([accountAddr, '', ''], [accountAddr, RANGE_HIGH, RANGE_HIGH]) + const tx = this.db!.transaction(this.config.storeName, 'readwrite') + await tx.objectStore(this.config.storeName).delete(range) + await tx.done + console.log(`[ActivityIdbStorage] deleteAccount ${accountAddr} - deleted all rows`) + } + + /** + * One-time migration: import all ops from legacy blob storage into IDB. + * After successful import, the caller should remove the key from legacy storage. + */ + async migrateFromStorage(data: InternalAccountsOps): Promise { + const records = Object.entries(data).flatMap(([accountAddr, chainMap]) => + Object.entries(chainMap).map(([chainId, ops]) => ({ accountAddr, chainId, ops })) + ) + const totalOps = records.reduce((sum, r) => sum + r.ops.length, 0) + console.log( + `[ActivityIdbStorage] migrateFromStorage - importing ${records.length} records with ${totalOps} total ops` + ) + return this.putMultiple(records) + } + + /** + * Check if IDB has any data (used to detect if migration is needed). + */ + async isEmpty(): Promise { + await this.init() + + const count = await this.db!.count(this.config.storeName) + const empty = count === 0 + console.log( + `[ActivityIdbStorage] isEmpty check - ${empty ? 'empty' : `${count} records found`}` + ) + return empty + } + + // ────────────────────────────────────────────────────────────────────────────── + // Private helpers + // ────────────────────────────────────────────────────────────────────────────── + + #writeRecordToStore( + store: any, + accountAddr: string, + chainIdStr: string, + ops: (SubmittedAccountOp | SubmittedAccountOpLike)[] + ): void { + // Delete existing rows for this (account, chain), then insert fresh ones + store + .delete( + IDBKeyRange.bound([accountAddr, chainIdStr, ''], [accountAddr, chainIdStr, RANGE_HIGH]) + ) + .catch(() => {}) + for (const op of ops) { + store.put(this.#opToRow(accountAddr, chainIdStr, op)).catch(() => {}) + } + } + + #dedupeOpsById( + ops: (SubmittedAccountOp | SubmittedAccountOpLike)[] + ): (SubmittedAccountOp | SubmittedAccountOpLike)[] { + const deduped = new Map() + + for (const op of ops) { + if (typeof op.id !== 'string' || !op.id) { + console.warn('[ActivityIdbStorage] Skipping op without a valid id', op) + continue + } + deduped.set(op.id, op) + } + + return Array.from(deduped.values()) + } + + #opToRow( + accountAddr: string, + chainIdStr: string, + op: SubmittedAccountOp | SubmittedAccountOpLike + ): IdbAccountOpRow { + if (typeof op.id !== 'string' || !op.id) { + throw new Error('[ActivityIdbStorage] Cannot store op without a valid id') + } + + if (typeof op.timestamp !== 'number') { + throw new Error(`[ActivityIdbStorage] Cannot store op ${op.id} without a valid timestamp`) + } + + if (op.status === undefined) { + throw new Error(`[ActivityIdbStorage] Cannot store op ${op.id} without a valid status`) + } + + return { + accountAddr, + chainId: chainIdStr, + id: op.id, + timestamp: op.timestamp, + status: op.status, + op + } + } +} diff --git a/src/services/storage/baseIdbStore.ts b/src/services/storage/baseIdbStore.ts new file mode 100644 index 000000000..cd6b102d3 --- /dev/null +++ b/src/services/storage/baseIdbStore.ts @@ -0,0 +1,219 @@ +import { IDBPDatabase, openDB } from 'idb' + +export interface IdbIndexDef { + name: string + keyPath: string | string[] +} + +/** + * Configuration for an IndexedDB store. + */ +export interface IdbStoreConfig { + dbName: string + storeName: string + keyPath: string | string[] + dbVersion?: number + indexes?: IdbIndexDef[] +} + +interface DbRegistry { + stores: Map + db: IDBPDatabase | null + openPromise: Promise> | null + version: number +} + +/** + * Base IndexedDB storage service. + * + * Maintains a static registry of all stores per database name so that a single + * database connection is shared across all stores. The one onupgradeneeded + * handler creates every registered store, preventing version conflicts when + * multiple stores share the same database. + * + * Subclasses pass their full config (including indexes) to super() — that is + * enough to participate in the shared connection. No doInit override needed. + */ +export class BaseIdbStore { + private static readonly registries = new Map() + + protected db: IDBPDatabase | null = null + protected initPromise: Promise | null = null + protected config: IdbStoreConfig + + constructor(config: IdbStoreConfig) { + this.config = { dbVersion: 1, ...config } + BaseIdbStore.#registerStore(this.config) + } + + // ────────────────────────────────────────────────────────────────────────────── + // Static registry helpers + // ────────────────────────────────────────────────────────────────────────────── + + static #registerStore(config: IdbStoreConfig): void { + const { dbName, dbVersion = 1 } = config + + if (!BaseIdbStore.registries.has(dbName)) { + BaseIdbStore.registries.set(dbName, { + stores: new Map(), + db: null, + openPromise: null, + version: dbVersion + }) + } + + const registry = BaseIdbStore.registries.get(dbName)! + + if (registry.db || registry.openPromise) { + // The DB is already open — a store was constructed after the first init() call. + // Its schema will not be applied until the next version bump. + console.error( + `[BaseIdbStore] Store "${config.storeName}" registered after "${dbName}" is already open — schema will not be applied until the next version bump.` + ) + } + + registry.stores.set(config.storeName, config) + registry.version = Math.max(registry.version, dbVersion) + } + + static #getDb( + dbName: string, + version: number, + stores: Map + ): Promise> { + const registry = BaseIdbStore.registries.get(dbName)! + + if (registry.db) return Promise.resolve(registry.db) + if (registry.openPromise) return registry.openPromise + + registry.openPromise = openDB(dbName, version, { + upgrade(db, oldVersion) { + console.log(`[BaseIdbStore] Upgrading "${dbName}" from v${oldVersion} → v${version}`) + for (const storeDef of stores.values()) { + if (!db.objectStoreNames.contains(storeDef.storeName)) { + const store = db.createObjectStore(storeDef.storeName, { + keyPath: storeDef.keyPath + }) + for (const idx of storeDef.indexes ?? []) { + store.createIndex(idx.name, idx.keyPath) + } + console.log(`[BaseIdbStore] Created store "${storeDef.storeName}"`) + } + // When oldVersion > 0 and the store already exists, add migration branches here. + } + } + }) + .then((db) => { + registry.db = db + console.log(`[BaseIdbStore] Opened "${dbName}" v${version}`) + return db + }) + .catch((error) => { + // Allow a subsequent openDB attempt after a transient failure. + registry.openPromise = null + throw error + }) + + return registry.openPromise + } + + /** + * Reset the shared connection for a database — for use in tests only. + * Call in beforeEach after replacing global.indexedDB with a fresh IDBFactory. + */ + static resetDb(dbName: string): void { + const registry = BaseIdbStore.registries.get(dbName) + if (!registry) return + registry.db?.close() + registry.db = null + registry.openPromise = null + registry.stores.clear() + BaseIdbStore.registries.delete(dbName) + } + + // ────────────────────────────────────────────────────────────────────────────── + // Instance methods + // ────────────────────────────────────────────────────────────────────────────── + + protected async init(): Promise { + if (this.db) return + if (this.initPromise) return this.initPromise + + console.log( + `[BaseIdbStore] init() starting for "${this.config.dbName}/${this.config.storeName}"` + ) + this.initPromise = this.doInit().catch((error) => { + // Allow a subsequent init() call to retry after a transient failure. + console.error( + `[BaseIdbStore] init() failed for "${this.config.dbName}/${this.config.storeName}", will retry on next call`, + error + ) + this.initPromise = null + throw error + }) + await this.initPromise + console.log(`[BaseIdbStore] init() ready for "${this.config.dbName}/${this.config.storeName}"`) + } + + protected async doInit(): Promise { + const registry = BaseIdbStore.registries.get(this.config.dbName)! + this.db = await BaseIdbStore.#getDb(this.config.dbName, registry.version, registry.stores) + } + + protected async getAll(): Promise { + await this.init() + return this.db!.getAll(this.config.storeName) as Promise + } + + protected async put(record: unknown): Promise { + await this.init() + await this.db!.put(this.config.storeName, record) + } + + protected async putMultiple(records: unknown[]): Promise { + await this.init() + const tx = this.db!.transaction(this.config.storeName, 'readwrite') + await Promise.all(records.map((r) => tx.objectStore(this.config.storeName).put(r))) + await tx.done + } + + protected async delete(key: IDBValidKey | IDBKeyRange): Promise { + await this.init() + await this.db!.delete(this.config.storeName, key as any) + } + + protected async clear(): Promise { + await this.init() + await this.db!.clear(this.config.storeName) + } + + protected async isEmpty(): Promise { + await this.init() + return (await this.db!.count(this.config.storeName)) === 0 + } + + protected checkQuota(): void { + if (!navigator.storage?.estimate) return + + navigator.storage + .estimate() + .then((estimate) => { + if (!estimate.quota || !estimate.usage) return + + const percentUsed = (estimate.usage / estimate.quota) * 100 + const usedMB = (estimate.usage / 1024 / 1024).toFixed(1) + const quotaMB = (estimate.quota / 1024 / 1024).toFixed(1) + + console.log( + `[BaseIdbStore] ${this.config.storeName} quota: ${usedMB}MB / ${quotaMB}MB (${percentUsed.toFixed(1)}%)` + ) + + if (percentUsed > 80) { + console.warn( + `[BaseIdbStore] ${this.config.storeName} quota usage high (${percentUsed.toFixed(1)}%)` + ) + } + }) + .catch(() => {}) + } +}