Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions etc/sdk-stellar.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 5 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

85 changes: 60 additions & 25 deletions src/chains/stellar/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -197,25 +210,47 @@ export class IndexedDBCache implements AnnouncementCache {
async put(network: Network, announcements: Announcement[]): Promise<void> {
if (announcements.length === 0) return;
const db = await this.openDB();
await new Promise<void>((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<void>((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<void> {
private async evict(targetBytes = this.maxBytes): Promise<void> {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_ANN, 'readwrite');
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/chains/stellar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down
127 changes: 99 additions & 28 deletions test/chains/stellar/cache.idb.test.ts
Original file line number Diff line number Diff line change
@@ -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';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<string, unknown>)['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<void>((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;
Expand All @@ -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',
Expand All @@ -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();
Expand All @@ -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<void>((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<string, unknown>)['indexedDB'] as IDBFactory;
let storeProto: any;
await new Promise<void>((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<string, unknown>)['indexedDB'] as IDBFactory;
let storeProto: any;
await new Promise<void>((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;
});
});
Loading