diff --git a/etc/sdk-stellar.api.md b/etc/sdk-stellar.api.md index 3b4cf2e..e8de379 100644 --- a/etc/sdk-stellar.api.md +++ b/etc/sdk-stellar.api.md @@ -256,6 +256,11 @@ export interface BuildWithdrawCustomAssetOptions { // @public export function bytesToHex(bytes: Uint8Array): string; +// @public +export class CacheQuotaError extends Error { + constructor(message?: string); +} + // @public export function checkStealthAddress(ephemeralPubKey: Uint8Array, viewingKey: Uint8Array, spendingPubKey: Uint8Array, viewTag: number): { isMatch: boolean; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae48c35..8757bfe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,7 +328,7 @@ importers: version: 0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7)) '@react-native/metro-config': specifier: ^0.76.0 - version: 0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7)) + version: 0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@react-native/typescript-config': specifier: ^0.76.0 version: 0.76.9 @@ -2188,7 +2188,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.18.31': resolution: {integrity: sha512-v9llw9fT3Uv+TCM6Xllo54t672CuYtinEQZ2LPJ2EJsCwuTc4Cd2gXQaouuIVD21VoeGQnr5JtJuWbF97sBKzQ==} @@ -10177,7 +10177,7 @@ snapshots: - '@babel/preset-env' - supports-color - '@react-native/metro-config@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))': + '@react-native/metro-config@0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: '@react-native/js-polyfills': 0.76.9 '@react-native/metro-babel-transformer': 0.76.9(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7)) @@ -10186,7 +10186,9 @@ snapshots: transitivePeerDependencies: - '@babel/core' - '@babel/preset-env' + - bufferutil - supports-color + - utf-8-validate '@react-native/normalize-colors@0.72.0': {} diff --git a/src/chains/stellar/cache.ts b/src/chains/stellar/cache.ts index 283b65c..4d23feb 100644 --- a/src/chains/stellar/cache.ts +++ b/src/chains/stellar/cache.ts @@ -3,6 +3,14 @@ import type { Announcement, Network } from './types'; /** Current cache schema version. Bump to force a full rebuild on schema changes. */ const CACHE_VERSION = 1; +/** Thrown when IndexedDB quota is exceeded and eviction cannot free enough space. */ +export class CacheQuotaError extends Error { + constructor(message = 'IndexedDB quota exceeded') { + super(message); + this.name = 'CacheQuotaError'; + } +} + /** Maximum byte budget for IndexedDBCache before LRU eviction. */ const MAX_BYTES = 50 * 1024 * 1024; // 50 MB @@ -155,13 +163,18 @@ export class IndexedDBCache implements AnnouncementCache { req.onupgradeneeded = (evt) => { const db = (evt.target as IDBOpenDBRequest).result; - // Drop stale stores on version bump. - if (db.objectStoreNames.contains(STORE_ANN)) db.deleteObjectStore(STORE_ANN); - if (db.objectStoreNames.contains(STORE_META)) db.deleteObjectStore(STORE_META); - - const annStore = db.createObjectStore(STORE_ANN, { keyPath: '_key' }); - annStore.createIndex('network_ledger', ['network', 'ledger'], { unique: false }); - db.createObjectStore(STORE_META, { keyPath: 'key' }); + const oldVersion = evt.oldVersion; + + // Migrations + if (oldVersion < 1) { + if (!db.objectStoreNames.contains(STORE_ANN)) { + const annStore = db.createObjectStore(STORE_ANN, { keyPath: '_key' }); + annStore.createIndex('network_ledger', ['network', 'ledger'], { unique: false }); + } + if (!db.objectStoreNames.contains(STORE_META)) { + db.createObjectStore(STORE_META, { keyPath: 'key' }); + } + } }; req.onsuccess = () => resolve(req.result); @@ -197,25 +210,47 @@ export class IndexedDBCache implements AnnouncementCache { async put(network: Network, announcements: Announcement[]): Promise { if (announcements.length === 0) return; const db = await this.openDB(); - await new Promise((resolve, reject) => { - const tx = db.transaction(STORE_ANN, 'readwrite'); - const store = tx.objectStore(STORE_ANN); - for (const ann of announcements) { - const record: StoredAnnouncement = { - ...ann, - _key: `${network}:${ann.stealthAddress}`, - network, - _bytes: roughBytesForEntry(ann), - }; - store.put(record); + + const doPut = () => + new Promise((resolve, reject) => { + const tx = db.transaction(STORE_ANN, 'readwrite'); + const store = tx.objectStore(STORE_ANN); + for (const ann of announcements) { + const record: StoredAnnouncement = { + ...ann, + _key: `${network}:${ann.stealthAddress}`, + network, + _bytes: roughBytesForEntry(ann), + }; + store.put(record); + } + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + + try { + await doPut(); + } catch (err: any) { + if (err?.name === 'QuotaExceededError') { + // Fallback: evict aggressively to 50% of budget + await this.evict(this.maxBytes / 2); + try { + await doPut(); + } catch (retryErr: any) { + if (retryErr?.name === 'QuotaExceededError') { + throw new CacheQuotaError(); + } + throw retryErr; + } + } else { + throw err; } - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); - await this.evict(); + } + + await this.evict(this.maxBytes); } - private async evict(): Promise { + private async evict(targetBytes = this.maxBytes): Promise { const db = await this.openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_ANN, 'readwrite'); @@ -226,13 +261,13 @@ export class IndexedDBCache implements AnnouncementCache { req.onsuccess = () => { const all: StoredAnnouncement[] = req.result; const totalBytes = all.reduce((s, r) => s + (r._bytes ?? 0), 0); - if (totalBytes <= this.maxBytes) return; + if (totalBytes <= targetBytes) return; // Evict oldest ledgers first (LRU by ledger sequence). const sorted = all.slice().sort((a, b) => (a.ledger ?? 0) - (b.ledger ?? 0)); let remaining = totalBytes; let i = 0; const step = () => { - if (remaining <= this.maxBytes || i >= sorted.length) return; + if (remaining <= targetBytes || i >= sorted.length) return; const rec = sorted[i++]; remaining -= rec._bytes ?? 0; const delReq = store.delete(rec._key); diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index 049e7a1..755abe1 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -70,7 +70,7 @@ export type { FetchAnnouncementsOptions } from './announcements'; /** * @internal */ -export { MemoryCache, IndexedDBCache, autoSelectCache } from './cache'; +export { MemoryCache, IndexedDBCache, autoSelectCache, CacheQuotaError } from './cache'; export type { AnnouncementCache } from './cache'; /** diff --git a/test/chains/stellar/cache.idb.test.ts b/test/chains/stellar/cache.idb.test.ts index 2ae350d..c4bc14a 100644 --- a/test/chains/stellar/cache.idb.test.ts +++ b/test/chains/stellar/cache.idb.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { IDBFactory, IDBKeyRange } from 'fake-indexeddb'; -import { IndexedDBCache } from '../../../src/chains/stellar/cache'; +import { IndexedDBCache, CacheQuotaError } from '../../../src/chains/stellar/cache'; import type { Announcement } from '../../../src/chains/stellar/types'; // --------------------------------------------------------------------------- @@ -108,11 +108,12 @@ describe('IndexedDBCache', () => { // Version migration // ------------------------------------------------------------------------- - it('drops and rebuilds stores when the schema version bumps', async () => { + it('migrates safely without dropping data when schema version bumps', async () => { const idb = (globalThis as unknown as Record)['indexedDB'] as IDBFactory; - // Phase 1 — seed the DB at version 1 with data in the current schema. + // Phase 1 — seed the DB manually to simulate an older version or previous install await new Promise((resolve, reject) => { + // Simulate what the DB looked like before, or just use the current IndexedDBCache const req = idb.open(DB_NAME, 1); req.onupgradeneeded = (evt) => { const db = (evt.target as IDBOpenDBRequest).result; @@ -122,7 +123,7 @@ describe('IndexedDBCache', () => { }; req.onsuccess = () => { const db = req.result; - const tx = db.transaction('announcements', 'readwrite'); + const tx = db.transaction(['announcements', 'meta'], 'readwrite'); tx.objectStore('announcements').put({ _key: 'testnet:GFOO', network: 'testnet', @@ -134,6 +135,10 @@ describe('IndexedDBCache', () => { ledger: 100, _bytes: 200, }); + tx.objectStore('meta').put({ + key: 'lastSeen:testnet', + value: { ledger: 100, cursor: 'c1' }, + }); tx.oncomplete = () => { db.close(); resolve(); @@ -143,32 +148,98 @@ describe('IndexedDBCache', () => { req.onerror = () => reject(req.error); }); - // Phase 2 — simulate CACHE_VERSION bumping to 2. - // The onupgradeneeded handler in IndexedDBCache drops all stores and recreates them, - // so all previously cached data is wiped on version mismatch. - await new Promise((resolve, reject) => { - const req = idb.open(DB_NAME, 2); - req.onupgradeneeded = (evt) => { - const db = (evt.target as IDBOpenDBRequest).result; - if (db.objectStoreNames.contains('announcements')) db.deleteObjectStore('announcements'); - if (db.objectStoreNames.contains('meta')) db.deleteObjectStore('meta'); - const ann = db.createObjectStore('announcements', { keyPath: '_key' }); - ann.createIndex('network_ledger', ['network', 'ledger'], { unique: false }); - db.createObjectStore('meta', { keyPath: 'key' }); + // Phase 2 — Force IndexedDBCache to open at version 2 (triggering an upgrade) + // We intercept `indexedDB.open` so `IndexedDBCache` triggers its `onupgradeneeded` + // block with `oldVersion = 1` and `newVersion = 2`. + const origOpen = idb.open.bind(idb); + idb.open = (name: string, version?: number) => { + if (name === DB_NAME && version === 1) { + return origOpen(name, 2); // simulate CACHE_VERSION = 2 + } + return origOpen(name, version); + }; + + try { + const cache = new IndexedDBCache(); + const result = await cache.get('testnet', 100, 100); + expect(result).toHaveLength(1); + expect(result![0].stealthAddress).toBe('GFOO'); + + const lastSeen = await cache.getLastSeen('testnet'); + expect(lastSeen).toEqual({ ledger: 100, cursor: 'c1' }); + } finally { + idb.open = origOpen; + } + }); + + // ------------------------------------------------------------------------- + // Quota and Recovery + // ------------------------------------------------------------------------- + + it('handles QuotaExceededError with bounded eviction and preserves lastSeen', async () => { + const cache = new IndexedDBCache(1000); + await cache.put('testnet', [makeAnn('G1', 100), makeAnn('G2', 200)]); + await cache.setLastSeen('testnet', 200, 'cursor-2'); + + // Get the IDBObjectStore prototype dynamically + const idb = (globalThis as unknown as Record)['indexedDB'] as IDBFactory; + let storeProto: any; + await new Promise((resolve) => { + const req = idb.open('dummy-for-proto', 1); + req.onupgradeneeded = (e: any) => { + const store = e.target.result.createObjectStore('dummy'); + storeProto = Object.getPrototypeOf(store); }; - req.onsuccess = () => { - const db = req.result; - const tx = db.transaction('announcements', 'readonly'); - const getAllReq = tx.objectStore('announcements').getAll(); - getAllReq.onsuccess = () => { - // All previously seeded data must be gone after the version bump. - expect(getAllReq.result).toHaveLength(0); - db.close(); - resolve(); - }; - getAllReq.onerror = () => reject(getAllReq.error); + req.onsuccess = () => resolve(); + }); + + const origPut = storeProto.put; + let thrown = false; + storeProto.put = function (this: any, value: any, key: any) { + if (!thrown && value && (value as any).stealthAddress === 'G3') { + thrown = true; + const err = new DOMException('QuotaExceededError', 'QuotaExceededError'); + throw err; + } + return origPut.call(this, value, key); + }; + + try { + await cache.put('testnet', [makeAnn('G3', 300)]); + } catch (e: any) { + // should not throw out, it should recover + } finally { + storeProto.put = origPut; + } + + // Verify it succeeded in saving G3 + const result = await cache.get('testnet', 100, 300); + expect(result?.find((a) => a.stealthAddress === 'G3')).toBeDefined(); + // lastSeen should be preserved + expect(await cache.getLastSeen('testnet')).toEqual({ ledger: 200, cursor: 'cursor-2' }); + }); + + it('throws CacheQuotaError if QuotaExceededError persists after eviction', async () => { + const cache = new IndexedDBCache(1000); + + const idb = (globalThis as unknown as Record)['indexedDB'] as IDBFactory; + let storeProto: any; + await new Promise((resolve) => { + const req = idb.open('dummy-for-proto-2', 1); + req.onupgradeneeded = (e: any) => { + const store = e.target.result.createObjectStore('dummy'); + storeProto = Object.getPrototypeOf(store); }; - req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(); }); + + const origPut = storeProto.put; + storeProto.put = function (this: any, value: any, key: any) { + throw new DOMException('QuotaExceededError', 'QuotaExceededError'); + }; + + await expect(cache.put('testnet', [makeAnn('G4', 400)])).rejects.toThrow(CacheQuotaError); + + storeProto.put = origPut; }); });