From 61ea14595fed24c5ca0609ec62502a7cc56149d0 Mon Sep 17 00:00:00 2001 From: Ajibose Date: Tue, 18 Aug 2026 21:51:46 +0300 Subject: [PATCH 1/6] feat(sdk): add IndexedDB offline cache with stale-while-revalidate (#218) Adds a browser-only offline caching layer to @invofi/sdk backed by IndexedDB (via the `idb` wrapper), implementing stale-while-revalidate semantics for invoice/offer/position reads: - src/cache.ts: getCached/setCached/invalidate/staleWhileRevalidate, a CacheEntry schema ({ key, data, timestamp, version }), per-type TTL config (invoices 5m, offers 2m, positions 1m), and LRU eviction once total estimated cache size exceeds 50MB. Every exported function no-ops safely (never throws) when `indexedDB` is unavailable, so the module is safe to import from the Next.js frontend (SSR) and the Node keeper CLI alike. - src/client.ts: state-changing methods (registerInvoice, cancelInvoice, createOffer, acceptOffer, rejectOffer, repayInvoice, markOverdue, reclaimInvoice, transferPositionToken) now invalidate the affected cache-key prefixes on success, fire-and-forget, via a small internal invalidateCache() helper. - src/index.ts: re-exports the new cache surface (types, TTL config, functions) following the existing banner-comment doc style. - package.json: adds `idb` as a runtime dependency and `fake-indexeddb` as a devDependency for tests. Tests (tests/cache.test.ts, 21 cases) cover the get/set schema round-trip, TTL/staleness semantics, staleWhileRevalidate's immediate cached read + silent background update, Promise.allSettled graceful degradation on a rejecting fetcher, prefix-based invalidation, LRU eviction ordering, and the no-IndexedDB environment guard. Uses fake-indexeddb/auto to polyfill IndexedDB under Vitest's Node environment. Frontend integration (apps/frontend) is intentionally out of scope for this PR to avoid destabilizing existing data-fetching code paths; the SDK-side caching layer is complete and independently tested, ready to be wired into the frontend's React Query hooks as a documented follow-up. --- invofi/apps/sdk/package-lock.json | 62 ++---- invofi/apps/sdk/package.json | 4 +- invofi/apps/sdk/src/cache.ts | 280 +++++++++++++++++++++++ invofi/apps/sdk/src/client.ts | 51 ++++- invofi/apps/sdk/src/index.ts | 31 +++ invofi/apps/sdk/tests/cache.test.ts | 333 ++++++++++++++++++++++++++++ 6 files changed, 709 insertions(+), 52 deletions(-) create mode 100644 invofi/apps/sdk/src/cache.ts create mode 100644 invofi/apps/sdk/tests/cache.test.ts diff --git a/invofi/apps/sdk/package-lock.json b/invofi/apps/sdk/package-lock.json index 9a803797e..36a5112f5 100644 --- a/invofi/apps/sdk/package-lock.json +++ b/invofi/apps/sdk/package-lock.json @@ -8,10 +8,12 @@ "name": "@invofi/sdk", "version": "0.1.0", "dependencies": { - "@stellar/stellar-sdk": "^16.0.1" + "@stellar/stellar-sdk": "^16.0.1", + "idb": "^8.0.3" }, "devDependencies": { "@types/node": "^22.5.4", + "fake-indexeddb": "^6.2.5", "typescript": "^5.5.4", "vitest": "^2.0.5" } @@ -422,9 +424,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -543,9 +542,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -560,9 +556,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -577,9 +570,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -594,9 +584,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -611,9 +598,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -628,9 +612,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -645,9 +626,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -662,9 +640,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -679,9 +654,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -696,9 +668,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -713,9 +682,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -730,9 +696,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -747,9 +710,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1320,6 +1280,16 @@ "node": ">=12.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/feaxios": { "version": "0.0.23", "license": "MIT", @@ -1468,6 +1438,12 @@ "node": ">= 6" } }, + "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/ieee754": { "version": "1.2.1", "funding": [ diff --git a/invofi/apps/sdk/package.json b/invofi/apps/sdk/package.json index 51ce10723..c66664061 100644 --- a/invofi/apps/sdk/package.json +++ b/invofi/apps/sdk/package.json @@ -11,10 +11,12 @@ "test:watch": "vitest" }, "dependencies": { - "@stellar/stellar-sdk": "^16.0.1" + "@stellar/stellar-sdk": "^16.0.1", + "idb": "^8.0.3" }, "devDependencies": { "@types/node": "^22.5.4", + "fake-indexeddb": "^6.2.5", "typescript": "^5.5.4", "vitest": "^2.0.5" } diff --git a/invofi/apps/sdk/src/cache.ts b/invofi/apps/sdk/src/cache.ts new file mode 100644 index 000000000..4d5702e50 --- /dev/null +++ b/invofi/apps/sdk/src/cache.ts @@ -0,0 +1,280 @@ +// ── SDK offline cache (IndexedDB, stale-while-revalidate) — Task 218 ──────── +// +// Caches invoice/offer/position reads in IndexedDB (via the `idb` wrapper) so +// consumers can render immediately from a warm cache while a background +// refresh silently brings the data up to date ("stale-while-revalidate"). +// +// This module is browser-only in spirit but MUST NOT assume a browser is +// present: the SDK is also consumed by a Next.js app that renders on the +// server, and by a Node CLI keeper (apps/scripts). Every exported function +// therefore guards on `isIndexedDbAvailable()` and degrades to a safe no-op +// (resolves `null`/`undefined` for reads, resolves without effect for +// writes) rather than throwing when `indexedDB` is unavailable — this is a +// load-bearing guarantee for SSR/Node callers, not an incidental detail. +// +// Usage: +// import { staleWhileRevalidate, invalidate, CACHE_TTL_MS } from './cache'; +// const { data, isStale, refresh } = await staleWhileRevalidate( +// `invoices:${status}:${page}`, +// CACHE_TTL_MS.invoices, +// () => fetchInvoicesFromChain(status, page), +// ); + +import { openDB, type IDBPDatabase } from 'idb'; + +// ── Schema ──────────────────────────────────────────────────────────────────── + +/** + * Public cache-entry shape. `version` is a caller-controlled schema/format + * tag (defaults to 1) so future format changes can be detected and ignored + * rather than mis-parsed. + */ +export interface CacheEntry { + key: string; + data: T; + timestamp: number; + version: number; +} + +/** Internal on-disk shape: a `CacheEntry` plus LRU bookkeeping. */ +interface StoredEntry extends CacheEntry { + /** Updated on every `getCached` read; drives LRU eviction order. */ + lastAccessed: number; +} + +const DB_NAME = 'invofi-cache'; +const DB_VERSION = 1; +const STORE_NAME = 'invofi-cache'; +const LAST_ACCESSED_INDEX = 'lastAccessed'; + +// ── TTL config (per cache-key prefix) ──────────────────────────────────────── +// Keyed by the same prefix used in cache keys ("invoices:{status}:{page}", +// "offers:{invoiceId}", "positions:{lender}") so callers can look up the +// right TTL from the key prefix. Exported so consumers can inspect/override +// and so it is independently unit-testable. + +export const CACHE_TTL_MS: Record<'invoices' | 'offers' | 'positions', number> = { + invoices: 5 * 60_000, + offers: 2 * 60_000, + positions: 60_000, +}; + +/** + * Total estimated cache size (sum of `JSON.stringify(entry).length` across + * all entries) above which the LRU sweep evicts least-recently-accessed + * entries. 50 MB per the Task 218 storage-limit requirement. + */ +export const MAX_CACHE_SIZE_BYTES = 50 * 1024 * 1024; + +// ── Environment guard ──────────────────────────────────────────────────────── + +/** + * True when a usable `indexedDB` global is present. Checked lazily on every + * call (not cached at module-load time) so SSR/Node callers — where + * `indexedDB` is simply absent from `globalThis` — always resolve to a safe + * no-op instead of throwing a ReferenceError. + */ +export function isIndexedDbAvailable(): boolean { + return typeof indexedDB !== 'undefined' && indexedDB !== null; +} + +/** JSON.stringify that tolerates bigint fields (Invoice/FinancingOffer use bigint amounts). */ +function safeStringify(value: unknown): string { + return JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)); +} + +let dbPromise: Promise | null = null; + +/** + * Lazily opens (and memoizes) the cache database. Returns `null` when + * IndexedDB is unavailable — callers must treat that as "no-op". + */ +function getDb(): Promise | null { + if (!isIndexedDbAvailable()) return null; + if (!dbPromise) { + dbPromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' }); + store.createIndex(LAST_ACCESSED_INDEX, 'lastAccessed'); + } + }, + }); + } + return dbPromise; +} + +// ── Reads ───────────────────────────────────────────────────────────────────── + +/** + * Reads a cache entry by exact key, regardless of staleness — TTL/staleness + * is the caller's decision (see `staleWhileRevalidate`). Also bumps + * `lastAccessed` for LRU purposes. + * + * Resolves `null` when the entry is missing OR when IndexedDB is + * unavailable (SSR/Node) — never throws. + */ +export async function getCached(key: string): Promise | null> { + const db = getDb(); + if (!db) return null; + try { + const conn = await db; + const tx = conn.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const entry = (await store.get(key)) as StoredEntry | undefined; + if (!entry) { + await tx.done; + return null; + } + entry.lastAccessed = Date.now(); + await store.put(entry); + await tx.done; + return { key: entry.key, data: entry.data, timestamp: entry.timestamp, version: entry.version }; + } catch { + // Corrupt entry, blocked transaction, etc. — degrade to "no cache". + return null; + } +} + +// ── Writes ──────────────────────────────────────────────────────────────────── + +/** + * Writes a cache entry (upsert by `key`) with the current timestamp, then + * runs an LRU-eviction sweep (awaited, not fire-and-forget — so a caller + * that awaits `setCached` is guaranteed the store is back under budget + * before it resolves) if the estimated total store size exceeds + * `maxSizeBytes` (defaults to `MAX_CACHE_SIZE_BYTES`; overridable for + * testing). + * + * Resolves without effect when IndexedDB is unavailable — never throws. + */ +export async function setCached( + key: string, + data: T, + version = 1, + maxSizeBytes: number = MAX_CACHE_SIZE_BYTES, +): Promise { + const db = getDb(); + if (!db) return; + try { + const conn = await db; + const now = Date.now(); + const entry: StoredEntry = { key, data, timestamp: now, version, lastAccessed: now }; + await conn.put(STORE_NAME, entry); + await evictLru(conn, maxSizeBytes); + } catch { + // Write failures (quota exceeded, blocked, etc.) must not throw or + // interrupt the caller — the cache is best-effort. + } +} + +/** + * Evicts least-recently-accessed entries (oldest `lastAccessed` first) until + * the estimated total store size is back under `maxSizeBytes`. + */ +async function evictLru(conn: IDBPDatabase, maxSizeBytes: number): Promise { + try { + const all = (await conn.getAll(STORE_NAME)) as StoredEntry[]; + let totalBytes = all.reduce((sum, e) => sum + safeStringify(e).length, 0); + if (totalBytes <= maxSizeBytes) return; + + const oldestFirst = [...all].sort((a, b) => a.lastAccessed - b.lastAccessed); + const tx = conn.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + for (const entry of oldestFirst) { + if (totalBytes <= maxSizeBytes) break; + await store.delete(entry.key); + totalBytes -= safeStringify(entry).length; + } + await tx.done; + } catch { + // Eviction is best-effort cleanup — a failure here must not surface to + // the setCached caller (the write already succeeded). + } +} + +// ── Invalidation ────────────────────────────────────────────────────────────── + +/** + * Deletes a single exact key, or — when `keyOrPrefix` matches the start of + * one or more stored keys — every key sharing that prefix (e.g. + * `invalidate('invoices:')` clears every paginated `invoices:{status}:{page}` + * entry after a mutation, since the mutation doesn't know every page that + * might now be stale). + * + * Resolves without effect when IndexedDB is unavailable — never throws. + */ +export async function invalidate(keyOrPrefix: string): Promise { + const db = getDb(); + if (!db) return; + try { + const conn = await db; + const tx = conn.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + let cursor = await store.openCursor(); + while (cursor) { + const k = cursor.key; + if (typeof k === 'string' && (k === keyOrPrefix || k.startsWith(keyOrPrefix))) { + await cursor.delete(); + } + cursor = await cursor.continue(); + } + await tx.done; + } catch { + // Best-effort — an invalidation failure should not throw into the + // caller's mutation flow (the on-chain write already succeeded). + } +} + +// ── Stale-while-revalidate ─────────────────────────────────────────────────── + +export interface StaleWhileRevalidateResult { + /** Cached value, or `null` when there was no cache entry (or no IndexedDB). */ + data: T | null; + /** True when `data` is missing or older than `ttlMs`. */ + isStale: boolean; + /** + * The background refresh. Resolves with the freshly-fetched value on + * success (after silently writing it to the cache), or `null` if the + * fetch failed — a failed refresh never throws and never touches (let + * alone evicts) the still-valid stale cache entry. Callers that only + * need the immediate cached read may safely ignore this promise. + */ + refresh: Promise; +} + +/** + * The core SWR entry point: reads the cache immediately (fast path), then + * kicks off `fetcher()` in the background via `Promise.allSettled` so a + * rejected fetch degrades gracefully instead of throwing or clearing the + * still-good stale entry. On success the fresh value silently replaces the + * cache entry. + * + * `data`/`isStale` are ready as soon as the returned promise resolves (a + * single fast IndexedDB read); `refresh` is a separate promise the caller + * can `await` or ignore. + */ +export async function staleWhileRevalidate( + key: string, + ttlMs: number, + fetcher: () => Promise, +): Promise> { + const cached = await getCached(key); + const isStale = !cached || Date.now() - cached.timestamp > ttlMs; + + const refresh: Promise = (async () => { + const [outcome] = await Promise.allSettled([fetcher()]); + if (outcome.status === 'fulfilled') { + await setCached(key, outcome.value, cached?.version ?? 1); + return outcome.value; + } + // Swallow the failure — the stale cache entry (if any) is left intact. + return null; + })(); + + return { + data: cached ? cached.data : null, + isStale, + refresh, + }; +} diff --git a/invofi/apps/sdk/src/client.ts b/invofi/apps/sdk/src/client.ts index 0d5fb5da1..2f92dbfcf 100644 --- a/invofi/apps/sdk/src/client.ts +++ b/invofi/apps/sdk/src/client.ts @@ -31,9 +31,24 @@ import { validateAssetString, validateConfigField, } from './validation'; +import { invalidate } from './cache'; export { SdkValidationError, ErrorCode }; +/** + * Invalidates the offline-cache (Task 218) key prefixes affected by a + * state-changing contract call, once it has succeeded. Best-effort and + * side-effect-only: `invalidate()` never throws (see cache.ts), so this + * never affects the caller's return value. Fire-and-forget is intentional — + * callers already have the fresh on-chain result; invalidation just makes + * sure a subsequent cached read doesn't serve stale data. + */ +function invalidateCache(prefixes: string[]): void { + for (const prefix of prefixes) { + void invalidate(prefix); + } +} + const BASE_FEE = '100'; /** A "CODE:ISSUER" asset string, e.g. "POS:GBDD…". */ @@ -252,7 +267,9 @@ export function createInvofiClient(cfg: InvofiClientConfig) { ], originatorAddress, ); - return parseInvoice(val); + const invoice = parseInvoice(val); + invalidateCache(['invoices:']); + return invoice; }, /** @@ -285,7 +302,9 @@ export function createInvofiClient(cfg: InvofiClientConfig) { [encodeSymbol(invoiceId), encodeAddress(originatorAddress)], originatorAddress, ); - return parseInvoice(val); + const invoice = parseInvoice(val); + invalidateCache(['invoices:', `offers:${invoiceId}`]); + return invoice; }, // ── Financing contract ─────────────────────────────────────────────────── @@ -331,7 +350,9 @@ export function createInvofiClient(cfg: InvofiClientConfig) { ], lenderAddress, ); - return parseOffer(val); + const offer = parseOffer(val); + invalidateCache([`offers:${params.invoiceId}`]); + return offer; }, /** @@ -364,7 +385,12 @@ export function createInvofiClient(cfg: InvofiClientConfig) { [encodeSymbol(offerId), encodeAddress(originatorAddress)], originatorAddress, ); - return parseOffer(val); + const offer = parseOffer(val); + // Accepting an offer moves the invoice to Financed, mints a position + // token to the lender, and settles this offer — all three cache + // families are affected. + invalidateCache(['invoices:', `offers:${offer.invoice_id}`, 'positions:']); + return offer; }, /** @@ -384,7 +410,9 @@ export function createInvofiClient(cfg: InvofiClientConfig) { [encodeSymbol(offerId), encodeAddress(originatorAddress)], originatorAddress, ); - return parseOffer(val); + const offer = parseOffer(val); + invalidateCache([`offers:${offer.invoice_id}`]); + return offer; }, // ── Repayment contract ─────────────────────────────────────────────────── @@ -418,7 +446,9 @@ export function createInvofiClient(cfg: InvofiClientConfig) { ], repayerAddress, ); - return parseInvoice(val); + const invoice = parseInvoice(val); + invalidateCache(['invoices:', `offers:${invoiceId}`, 'positions:']); + return invoice; }, /** @@ -438,7 +468,9 @@ export function createInvofiClient(cfg: InvofiClientConfig) { [encodeSymbol(invoiceId)], callerAddress, ); - return parseInvoice(val); + const invoice = parseInvoice(val); + invalidateCache(['invoices:']); + return invoice; }, /** @@ -459,7 +491,9 @@ export function createInvofiClient(cfg: InvofiClientConfig) { [encodeSymbol(invoiceId), encodeSymbol(offerId), encodeAddress(lenderAddress)], lenderAddress, ); - return parseOffer(val); + const offer = parseOffer(val); + invalidateCache(['invoices:', `offers:${invoiceId}`, 'positions:']); + return offer; }, // ── Position tokens (Task 7/8: SEP-41 claim tokens) ───────────────────── @@ -528,6 +562,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { [encodeAddress(fromAddress), encodeAddress(toAddress), encodeI128(amount)], fromAddress, ); + invalidateCache([`positions:${fromAddress}`, `positions:${toAddress}`]); }, // ── Position-token trustline support ───────────────────────────────────── diff --git a/invofi/apps/sdk/src/index.ts b/invofi/apps/sdk/src/index.ts index 36a782633..90b050e17 100644 --- a/invofi/apps/sdk/src/index.ts +++ b/invofi/apps/sdk/src/index.ts @@ -78,3 +78,34 @@ export type { PoolPayoutData, ReputationRecordedData, } from './events'; + +// ── Offline cache (IndexedDB, stale-while-revalidate) ─────────────────────── +// Browser-only, gracefully no-ops under SSR/Node (see cache.ts). Caches +// invoice/offer/position reads with configurable per-type TTLs and evicts +// least-recently-used entries once total cached size exceeds 50 MB. +// `createInvofiClient`'s state-changing methods (register/accept/reject/ +// repay/etc.) call `invalidate()` internally on success, so consumers only +// need this surface for reads. +// +// @example +// ```ts +// import { staleWhileRevalidate, CACHE_TTL_MS } from '@invofi/sdk'; +// +// const { data, isStale, refresh } = await staleWhileRevalidate( +// `invoices:${status}:${page}`, +// CACHE_TTL_MS.invoices, +// () => client.listInvoices(status, page), +// ); +// // Render `data` immediately (may be null/stale); `refresh` resolves once +// // the background re-fetch has silently updated the cache. +// ``` +export { + getCached, + setCached, + invalidate, + staleWhileRevalidate, + isIndexedDbAvailable, + CACHE_TTL_MS, + MAX_CACHE_SIZE_BYTES, +} from './cache'; +export type { CacheEntry, StaleWhileRevalidateResult } from './cache'; diff --git a/invofi/apps/sdk/tests/cache.test.ts b/invofi/apps/sdk/tests/cache.test.ts new file mode 100644 index 000000000..c34936a9e --- /dev/null +++ b/invofi/apps/sdk/tests/cache.test.ts @@ -0,0 +1,333 @@ +/** + * Unit tests — offline cache (IndexedDB, stale-while-revalidate) (Task 218) + * + * Strategy + * -------- + * - Node's default Vitest environment has no `indexedDB` global, so we + * polyfill it via `fake-indexeddb/auto`, which installs `indexedDB` plus + * the full IDBRequest/IDBCursor/IDBKeyRange/etc. constructor set that + * `idb` (the wrapper `src/cache.ts` uses) needs for its instanceof checks. + * - `src/cache.ts` opens ONE memoized connection for the module's lifetime + * (a realistic long-lived app connection). We import it once, statically, + * and reuse that same connection across every test — NOT + * `vi.resetModules()` + a fresh `indexedDB.deleteDatabase()` per test. + * That combination deadlocks: `deleteDatabase()` blocks forever on the + * still-open connection from the previous test (nothing ever closes it), + * and — because IndexedDB processes requests against a given database in + * order — every `open()` issued afterwards queues up behind that stuck + * `delete()` and never resolves either. Instead, `beforeEach` clears the + * object store's contents via a short-lived side connection (opening the + * same DB/version alongside the long-lived one is fine; no version change, + * no blocking). + * - The "no indexedDB" tests delete/restore `globalThis.indexedDB` directly. + * This works without any module reset because every exported function in + * `src/cache.ts` checks `isIndexedDbAvailable()` fresh on *every call*, + * before ever touching the memoized connection promise. + * + * Coverage + * -------- + * 1. Basic get/set round-trip — schema fields (key/data/timestamp/version) + * 2. TTL / staleness semantics per data-type prefix (CACHE_TTL_MS) + * 3. staleWhileRevalidate — immediate cached read + silent background update + * 4. Promise.allSettled graceful degradation — fetcher rejects, stale data kept + * 5. Prefix-based invalidate() — exact key and prefix-family deletion + * 6. LRU eviction — least-recently-accessed entries evicted first over budget + * 7. Environment guard — safe no-op when indexedDB is unavailable + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +// Installs indexedDB + IDBRequest/IDBCursor/IDBKeyRange/etc. as globals — +// `idb` (the wrapper `src/cache.ts` uses) needs the full constructor set, +// not just `indexedDB` itself, to build its promise-based instanceof checks. +import 'fake-indexeddb/auto'; + +import * as cache from '../src/cache'; + +const DB_NAME = 'invofi-cache'; +const STORE_NAME = 'invofi-cache'; + +/** + * Clears the object store's contents via a short-lived side connection, + * without ever closing (or blocking on) the cache module's own long-lived + * connection. Creates the store first if it doesn't exist yet (first run). + */ +function clearStore(): Promise { + return new Promise((resolve, reject) => { + const openReq = indexedDB.open(DB_NAME, 1); + openReq.onupgradeneeded = () => { + const db = openReq.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' }); + store.createIndex('lastAccessed', 'lastAccessed'); + } + }; + openReq.onsuccess = () => { + const db = openReq.result; + const tx = db.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).clear(); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }; + openReq.onerror = () => reject(openReq.error); + }); +} + +beforeEach(async () => { + await clearStore(); +}); + +// ── 1. Basic get/set round-trip ─────────────────────────────────────────────── + +describe('cache — get/set round-trip', () => { + it('returns null for a missing key', async () => { + expect(await cache.getCached('invoices:Pending:1')).toBeNull(); + }); + + it('round-trips data with the correct schema fields', async () => { + const before = Date.now(); + await cache.setCached('offers:inv_001', { foo: 'bar' }, 3); + const entry = await cache.getCached<{ foo: string }>('offers:inv_001'); + + expect(entry).not.toBeNull(); + expect(entry!.key).toBe('offers:inv_001'); + expect(entry!.data).toEqual({ foo: 'bar' }); + expect(entry!.version).toBe(3); + expect(entry!.timestamp).toBeGreaterThanOrEqual(before); + expect(entry!.timestamp).toBeLessThanOrEqual(Date.now()); + }); + + it('defaults version to 1 when not supplied', async () => { + await cache.setCached('positions:GLENDER123', { balance: 100n }); + const entry = await cache.getCached('positions:GLENDER123'); + expect(entry!.version).toBe(1); + }); + + it('overwrites an existing entry on a second setCached for the same key', async () => { + await cache.setCached('offers:inv_001', { v: 1 }); + await cache.setCached('offers:inv_001', { v: 2 }); + const entry = await cache.getCached<{ v: number }>('offers:inv_001'); + expect(entry!.data).toEqual({ v: 2 }); + }); + + it('handles bigint fields in cached data (Invoice/FinancingOffer shapes)', async () => { + await cache.setCached('invoices:Pending:1', { amount: 5_000_000n }); + const entry = await cache.getCached<{ amount: bigint }>('invoices:Pending:1'); + expect(entry!.data.amount).toBe(5_000_000n); + }); +}); + +// ── 2. TTL / staleness config ───────────────────────────────────────────────── + +describe('cache — CACHE_TTL_MS config', () => { + it('defines the required per-type TTLs', () => { + expect(cache.CACHE_TTL_MS.invoices).toBe(5 * 60_000); + expect(cache.CACHE_TTL_MS.offers).toBe(2 * 60_000); + expect(cache.CACHE_TTL_MS.positions).toBe(60_000); + }); +}); + +// ── 3. staleWhileRevalidate ──────────────────────────────────────────────────── + +describe('cache — staleWhileRevalidate', () => { + it('returns null data and isStale=true on a cold cache, then updates the cache in the background', async () => { + const fetcher = vi.fn().mockResolvedValue({ id: 'inv_1' }); + + const result = await cache.staleWhileRevalidate('invoices:Pending:1', 5_000, fetcher); + expect(result.data).toBeNull(); + expect(result.isStale).toBe(true); + + const fresh = await result.refresh; + expect(fresh).toEqual({ id: 'inv_1' }); + expect(fetcher).toHaveBeenCalledOnce(); + + const entry = await cache.getCached('invoices:Pending:1'); + expect(entry!.data).toEqual({ id: 'inv_1' }); + }); + + it('returns cached data immediately (isStale=false) while still refreshing in the background', async () => { + await cache.setCached('offers:inv_002', { id: 'off_1', v: 1 }); + + const fetcher = vi.fn().mockResolvedValue({ id: 'off_1', v: 2 }); + const result = await cache.staleWhileRevalidate('offers:inv_002', 60_000, fetcher); + + // Fresh cache read comes back immediately, synchronously available. + expect(result.data).toEqual({ id: 'off_1', v: 1 }); + expect(result.isStale).toBe(false); + + await result.refresh; + const updated = await cache.getCached('offers:inv_002'); + expect(updated!.data).toEqual({ id: 'off_1', v: 2 }); + }); + + it('marks an entry older than the TTL as stale', async () => { + // Real (short) delay rather than fake timers — fake-indexeddb schedules + // its request callbacks via real timers/microtasks internally, so + // vi.useFakeTimers() here would starve `idb`'s promises and hang the test. + const tinyTtlMs = 5; + await cache.setCached('positions:GLENDER', { balance: 1 }); + await new Promise(resolve => setTimeout(resolve, tinyTtlMs + 15)); + + const fetcher = vi.fn().mockResolvedValue({ balance: 2 }); + const result = await cache.staleWhileRevalidate('positions:GLENDER', tinyTtlMs, fetcher); + expect(result.data).toEqual({ balance: 1 }); // stale value still returned + expect(result.isStale).toBe(true); + }); + + // ── 4. Promise.allSettled graceful degradation ────────────────────────────── + + it('keeps the stale cache entry intact and does not throw when the background fetch rejects', async () => { + await cache.setCached('invoices:Financed:1', { id: 'inv_9' }); + + const fetcher = vi.fn().mockRejectedValue(new Error('network down')); + const result = await cache.staleWhileRevalidate('invoices:Financed:1', 60_000, fetcher); + + expect(result.data).toEqual({ id: 'inv_9' }); + await expect(result.refresh).resolves.toBeNull(); // does not throw or reject + + const stillCached = await cache.getCached('invoices:Financed:1'); + expect(stillCached!.data).toEqual({ id: 'inv_9' }); // untouched + }); + + it('never throws even with no prior cache entry and a rejecting fetcher', async () => { + const fetcher = vi.fn().mockRejectedValue(new Error('boom')); + const result = await cache.staleWhileRevalidate('offers:inv_none', 60_000, fetcher); + expect(result.data).toBeNull(); + await expect(result.refresh).resolves.toBeNull(); + }); +}); + +// ── 5. invalidate() ──────────────────────────────────────────────────────────── + +describe('cache — invalidate', () => { + it('deletes an exact key', async () => { + await cache.setCached('offers:inv_001', { a: 1 }); + await cache.invalidate('offers:inv_001'); + expect(await cache.getCached('offers:inv_001')).toBeNull(); + }); + + it('deletes every key sharing a prefix, leaving unrelated keys untouched', async () => { + await cache.setCached('invoices:Pending:1', { p: 1 }); + await cache.setCached('invoices:Pending:2', { p: 2 }); + await cache.setCached('invoices:Financed:1', { p: 3 }); + await cache.setCached('offers:inv_001', { p: 4 }); + + await cache.invalidate('invoices:'); + + expect(await cache.getCached('invoices:Pending:1')).toBeNull(); + expect(await cache.getCached('invoices:Pending:2')).toBeNull(); + expect(await cache.getCached('invoices:Financed:1')).toBeNull(); + expect(await cache.getCached('offers:inv_001')).not.toBeNull(); + }); + + it('is a no-op (does not throw) for a key/prefix that matches nothing', async () => { + await expect(cache.invalidate('nonexistent:')).resolves.toBeUndefined(); + }); +}); + +// ── 6. LRU eviction ───────────────────────────────────────────────────────────── + +const MAX_CACHE_SIZE_BYTES_FOR_TEST = 50 * 1024 * 1024; + +describe('cache — LRU eviction', () => { + it('evicts least-recently-accessed entries once the size threshold is exceeded', async () => { + // Small threshold so the test doesn't need to write anywhere near 50MB. + // Sized to fit exactly two of these ~equal-size entries (plus slack) so + // adding the third forces exactly one eviction — the least-recently-used + // one — rather than evicting two entries down to a single survivor. + const sampleEntrySize = JSON.stringify({ + key: 'positions:A', + data: { blob: 'x'.repeat(50) }, + timestamp: Date.now(), + version: 1, + lastAccessed: Date.now(), + }).length; + const tinyThreshold = sampleEntrySize * 2 + 20; + // A tiny real delay between each step guarantees strictly increasing + // `lastAccessed` millisecond timestamps — without it, operations can + // complete fast enough to tie, making the LRU sort order (and therefore + // which entry gets evicted) nondeterministic. + const tick = () => new Promise(resolve => setTimeout(resolve, 2)); + + await cache.setCached('positions:A', { blob: 'x'.repeat(50) }, 1, tinyThreshold); + await tick(); + await cache.setCached('positions:B', { blob: 'x'.repeat(50) }, 1, tinyThreshold); + await tick(); + // Access A again so B becomes the least-recently-accessed entry. + await cache.getCached('positions:A'); + await tick(); + // This write pushes the store over budget and triggers eviction. + await cache.setCached('positions:C', { blob: 'x'.repeat(50) }, 1, tinyThreshold); + + const a = await cache.getCached('positions:A'); + const b = await cache.getCached('positions:B'); + const c = await cache.getCached('positions:C'); + + // B was least-recently-accessed and should have been evicted first. + expect(b).toBeNull(); + expect(c).not.toBeNull(); + expect(a).not.toBeNull(); + }); + + it('does not evict anything when under the size threshold', async () => { + await cache.setCached('positions:A', { small: 1 }, 1, MAX_CACHE_SIZE_BYTES_FOR_TEST); + await cache.setCached('positions:B', { small: 2 }, 1, MAX_CACHE_SIZE_BYTES_FOR_TEST); + expect(await cache.getCached('positions:A')).not.toBeNull(); + expect(await cache.getCached('positions:B')).not.toBeNull(); + }); +}); + +// ── 7. Environment guard ───────────────────────────────────────────────────── + +describe('cache — environment guard (no IndexedDB)', () => { + // fake-indexeddb/auto installs `globalThis.indexedDB` once for the whole + // file; these tests temporarily remove it to exercise the SSR/Node path, + // then restore it so later tests (and other describe blocks) are unaffected. + // No module reset is needed: every exported function re-checks + // `isIndexedDbAvailable()` on each call before touching the memoized + // connection, so flipping the global directly is sufficient. + let savedIndexedDb: typeof indexedDB; + + beforeEach(() => { + savedIndexedDb = globalThis.indexedDB; + // @ts-expect-error — simulating an environment with no indexedDB global + delete globalThis.indexedDB; + }); + + afterEach(() => { + globalThis.indexedDB = savedIndexedDb; + }); + + it('isIndexedDbAvailable() reflects the current globalThis.indexedDB', () => { + expect(cache.isIndexedDbAvailable()).toBe(false); + + globalThis.indexedDB = savedIndexedDb; + expect(cache.isIndexedDbAvailable()).toBe(true); + }); + + it('getCached resolves null (never throws) when indexedDB is unavailable', async () => { + await expect(cache.getCached('invoices:Pending:1')).resolves.toBeNull(); + }); + + it('setCached resolves without effect (never throws) when indexedDB is unavailable', async () => { + await expect(cache.setCached('invoices:Pending:1', { a: 1 })).resolves.toBeUndefined(); + }); + + it('invalidate resolves without effect (never throws) when indexedDB is unavailable', async () => { + await expect(cache.invalidate('invoices:')).resolves.toBeUndefined(); + }); + + it('staleWhileRevalidate still calls the fetcher and resolves gracefully with no cache backing', async () => { + const fetcher = vi.fn().mockResolvedValue({ id: 'inv_x' }); + + const result = await cache.staleWhileRevalidate('invoices:Pending:1', 5_000, fetcher); + expect(result.data).toBeNull(); + expect(result.isStale).toBe(true); + await expect(result.refresh).resolves.toEqual({ id: 'inv_x' }); + }); +}); From 9fafb3c5de4ecdc17dba06ef25733f7c45f29ed1 Mon Sep 17 00:00:00 2001 From: Ajibose Date: Wed, 19 Aug 2026 08:52:07 +0300 Subject: [PATCH 2/6] fix(sdk): scope offline cache per account/network, fix CI, avoid full-scan LRU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit review and CI failure on #236: - Cache is now namespaced by network + connected account (CacheScope / setCacheScope / getCacheScope in cache.ts) — each scope gets its own IndexedDB database, so switching wallets never serves one identity's cached data to another. createInvofiClient scopes automatically from cfg.networkPassphrase and the new optional cfg.accountAddress. - Added clearCache() to wipe the active scope's store, for callers to invoke on an explicit wallet disconnect/account change. - LRU eviction no longer does a getAll() + stringify-everything full scan on every write: total size is now tracked incrementally in a metadata record and eviction walks the lastAccessed index only as far as needed. - Fixed the "Frontend / Lint & Type Check" CI failure: apps/frontend's tsconfig path-aliases @invofi/sdk straight to SDK source, so the frontend's own type-check/build must resolve idb too — added it as a frontend dependency. Refs #218 --- invofi/apps/frontend/package-lock.json | 20 ++- invofi/apps/frontend/package.json | 1 + invofi/apps/sdk/src/cache.ts | 195 ++++++++++++++++++++++--- invofi/apps/sdk/src/client.ts | 9 +- invofi/apps/sdk/src/config.ts | 8 + invofi/apps/sdk/src/index.ts | 14 +- invofi/apps/sdk/tests/cache.test.ts | 127 +++++++++++++++- 7 files changed, 339 insertions(+), 35 deletions(-) diff --git a/invofi/apps/frontend/package-lock.json b/invofi/apps/frontend/package-lock.json index 0fb28ad71..5aa9b0cde 100644 --- a/invofi/apps/frontend/package-lock.json +++ b/invofi/apps/frontend/package-lock.json @@ -28,6 +28,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "idb": "^8.0.3", "lucide-react": "^0.577.0", "next": "14.2.35", "next-intl": "^4.13.7", @@ -509,8 +510,9 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "extraneous": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9270,9 +9272,10 @@ "version": "5.0.10", "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "extraneous": true, "hasInstallScript": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -13504,6 +13507,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -14119,6 +14123,12 @@ "@formatjs/icu-messageformat-parser": "^3.4.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/idb-keyval": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz", @@ -14953,9 +14963,10 @@ "version": "5.0.10", "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "extraneous": true, "hasInstallScript": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -15870,8 +15881,9 @@ "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", - "extraneous": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "tslib": "^2.8.0" } diff --git a/invofi/apps/frontend/package.json b/invofi/apps/frontend/package.json index ad921504a..b70ac309e 100644 --- a/invofi/apps/frontend/package.json +++ b/invofi/apps/frontend/package.json @@ -24,6 +24,7 @@ "@lobstrco/signer-extension-api": "^2.0.0", "@stellar/stellar-sdk": "^16.2.0", "@tanstack/react-query": "^5.101.2", + "idb": "^8.0.3", "lucide-react": "^0.577.0", "clsx": "^2.1.1", "tailwind-merge": "^2.5.2", diff --git a/invofi/apps/sdk/src/cache.ts b/invofi/apps/sdk/src/cache.ts index 4d5702e50..c38d5cdf2 100644 --- a/invofi/apps/sdk/src/cache.ts +++ b/invofi/apps/sdk/src/cache.ts @@ -12,6 +12,17 @@ // writes) rather than throwing when `indexedDB` is unavailable — this is a // load-bearing guarantee for SSR/Node callers, not an incidental detail. // +// Scoping (PR #236 review): the cache is namespaced by network + connected +// account (see `CacheScope`/`setCacheScope`) so switching wallets or networks +// never serves one identity's cached data to another — each scope gets its +// own IndexedDB database. `createInvofiClient` (client.ts) calls +// `setCacheScope` automatically from `cfg.networkPassphrase` / +// `cfg.accountAddress`. On an explicit wallet disconnect, callers should +// invoke `clearCache()` to wipe the departing account's store rather than +// leaving it sitting in IndexedDB indefinitely (frontend wiring of that call +// site is a follow-up, same as the rest of the frontend integration — see +// the PR description). +// // Usage: // import { staleWhileRevalidate, invalidate, CACHE_TTL_MS } from './cache'; // const { data, isStale, refresh } = await staleWhileRevalidate( @@ -20,7 +31,7 @@ // () => fetchInvoicesFromChain(status, page), // ); -import { openDB, type IDBPDatabase } from 'idb'; +import { openDB, type IDBPDatabase, type IDBPTransaction } from 'idb'; // ── Schema ──────────────────────────────────────────────────────────────────── @@ -42,9 +53,30 @@ interface StoredEntry extends CacheEntry { lastAccessed: number; } -const DB_NAME = 'invofi-cache'; +/** Single-row running total in `META_STORE_NAME`, kept in sync incrementally. */ +interface MetaRecord { + key: typeof META_KEY; + totalBytes: number; +} + +/** + * Identifies which account/network the active cache database belongs to. + * Both fields are free-form identifiers (e.g. `networkPassphrase` and a + * Stellar `G...` address) — the cache only uses them to build a database + * name, never to validate their format. + */ +export interface CacheScope { + /** e.g. `Networks.PUBLIC` / `Networks.TESTNET`. Omit to share one scope across networks. */ + network?: string; + /** The connected wallet's address. Omit while no wallet is connected. */ + accountAddress?: string; +} + +const DB_NAME_PREFIX = 'invofi-cache'; const DB_VERSION = 1; const STORE_NAME = 'invofi-cache'; +const META_STORE_NAME = 'invofi-cache-meta'; +const META_KEY = 'size'; const LAST_ACCESSED_INDEX = 'lastAccessed'; // ── TTL config (per cache-key prefix) ──────────────────────────────────────── @@ -60,9 +92,10 @@ export const CACHE_TTL_MS: Record<'invoices' | 'offers' | 'positions', number> = }; /** - * Total estimated cache size (sum of `JSON.stringify(entry).length` across - * all entries) above which the LRU sweep evicts least-recently-accessed - * entries. 50 MB per the Task 218 storage-limit requirement. + * Total estimated cache size (tracked incrementally in `META_STORE_NAME`, + * not recomputed from a full scan on every write — see `adjustMetaSize`) + * above which the LRU sweep evicts least-recently-accessed entries. + * 50 MB per the Task 218 storage-limit requirement. */ export const MAX_CACHE_SIZE_BYTES = 50 * 1024 * 1024; @@ -83,27 +116,96 @@ function safeStringify(value: unknown): string { return JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)); } +// ── Scope / connection management ─────────────────────────────────────────── + +let currentScope: CacheScope = {}; let dbPromise: Promise | null = null; +function scopeSegment(value: string | undefined, fallback: string): string { + const trimmed = value?.trim(); + return trimmed ? trimmed.replace(/[^a-zA-Z0-9_.:-]/g, '_') : fallback; +} + +function dbNameFor(scope: CacheScope): string { + const network = scopeSegment(scope.network, 'unscoped'); + const account = scopeSegment(scope.accountAddress, 'anon'); + return `${DB_NAME_PREFIX}:${network}:${account}`; +} + +/** + * Points the cache at the database for `scope` (network + connected + * account). Different scopes never share a database, so switching wallets or + * networks can never serve one identity's cached data to another. Safe to + * call redundantly — a no-op when `scope` matches the currently active one. + * + * Does not clear any data — each scope's store persists across reconnects of + * the same account, which is the point of an offline-first cache. To wipe a + * departing account's data (e.g. on explicit wallet disconnect), call + * `clearCache()` before or after switching scope. + */ +export function setCacheScope(scope: CacheScope): void { + const nextName = dbNameFor(scope); + if (nextName === dbNameFor(currentScope)) { + currentScope = { ...scope }; + return; + } + currentScope = { ...scope }; + const previousDb = dbPromise; + dbPromise = null; + if (previousDb) { + // Best-effort close of the old connection so it doesn't leak — failures + // here (already closed, never resolved, etc.) are not actionable. + void previousDb.then(db => db.close()).catch(() => {}); + } +} + +/** Returns the scope the cache is currently reading/writing against. */ +export function getCacheScope(): CacheScope { + return { ...currentScope }; +} + /** - * Lazily opens (and memoizes) the cache database. Returns `null` when - * IndexedDB is unavailable — callers must treat that as "no-op". + * Lazily opens (and memoizes) the cache database for the current scope. + * Returns `null` when IndexedDB is unavailable — callers must treat that as + * "no-op". */ function getDb(): Promise | null { if (!isIndexedDbAvailable()) return null; if (!dbPromise) { - dbPromise = openDB(DB_NAME, DB_VERSION, { + dbPromise = openDB(dbNameFor(currentScope), DB_VERSION, { upgrade(db) { if (!db.objectStoreNames.contains(STORE_NAME)) { const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' }); store.createIndex(LAST_ACCESSED_INDEX, 'lastAccessed'); } + if (!db.objectStoreNames.contains(META_STORE_NAME)) { + db.createObjectStore(META_STORE_NAME, { keyPath: 'key' }); + } }, }); } return dbPromise; } +// ── Incremental size tracking ─────────────────────────────────────────────── +// Avoids the `getAll()` + stringify-everything full scan on every write: a +// single-row running total is read/written in the same transaction as the +// entry mutation that changed it. + +async function readMetaSize( + tx: IDBPTransaction, +): Promise { + const meta = (await tx.objectStore(META_STORE_NAME).get(META_KEY)) as MetaRecord | undefined; + return meta?.totalBytes ?? 0; +} + +async function writeMetaSize( + tx: IDBPTransaction, + totalBytes: number, +): Promise { + await tx.objectStore(META_STORE_NAME).put({ key: META_KEY, totalBytes: Math.max(0, totalBytes) }); +} + // ── Reads ───────────────────────────────────────────────────────────────────── /** @@ -160,8 +262,21 @@ export async function setCached( const conn = await db; const now = Date.now(); const entry: StoredEntry = { key, data, timestamp: now, version, lastAccessed: now }; - await conn.put(STORE_NAME, entry); - await evictLru(conn, maxSizeBytes); + const newSize = safeStringify(entry).length; + + const tx = conn.transaction([STORE_NAME, META_STORE_NAME], 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const previous = (await store.get(key)) as StoredEntry | undefined; + const previousSize = previous ? safeStringify(previous).length : 0; + + await store.put(entry); + const totalBytes = (await readMetaSize(tx)) - previousSize + newSize; + await writeMetaSize(tx, totalBytes); + await tx.done; + + if (totalBytes > maxSizeBytes) { + await evictLru(conn, maxSizeBytes); + } } catch { // Write failures (quota exceeded, blocked, etc.) must not throw or // interrupt the caller — the cache is best-effort. @@ -169,23 +284,30 @@ export async function setCached( } /** - * Evicts least-recently-accessed entries (oldest `lastAccessed` first) until - * the estimated total store size is back under `maxSizeBytes`. + * Evicts least-recently-accessed entries (oldest `lastAccessed` first), + * walking the `lastAccessed` index via a cursor, until the tracked total + * size is back under `maxSizeBytes`. Unlike a `getAll()` scan, this only + * touches as many entries as actually need evicting. */ async function evictLru(conn: IDBPDatabase, maxSizeBytes: number): Promise { try { - const all = (await conn.getAll(STORE_NAME)) as StoredEntry[]; - let totalBytes = all.reduce((sum, e) => sum + safeStringify(e).length, 0); - if (totalBytes <= maxSizeBytes) return; - - const oldestFirst = [...all].sort((a, b) => a.lastAccessed - b.lastAccessed); - const tx = conn.transaction(STORE_NAME, 'readwrite'); + const tx = conn.transaction([STORE_NAME, META_STORE_NAME], 'readwrite'); const store = tx.objectStore(STORE_NAME); - for (const entry of oldestFirst) { - if (totalBytes <= maxSizeBytes) break; - await store.delete(entry.key); - totalBytes -= safeStringify(entry).length; + let totalBytes = await readMetaSize(tx); + if (totalBytes <= maxSizeBytes) { + await tx.done; + return; } + + let cursor = await store.index(LAST_ACCESSED_INDEX).openCursor(); + while (cursor && totalBytes > maxSizeBytes) { + const entrySize = safeStringify(cursor.value).length; + await cursor.delete(); + totalBytes -= entrySize; + cursor = await cursor.continue(); + } + + await writeMetaSize(tx, totalBytes); await tx.done; } catch { // Eviction is best-effort cleanup — a failure here must not surface to @@ -209,16 +331,19 @@ export async function invalidate(keyOrPrefix: string): Promise { if (!db) return; try { const conn = await db; - const tx = conn.transaction(STORE_NAME, 'readwrite'); + const tx = conn.transaction([STORE_NAME, META_STORE_NAME], 'readwrite'); const store = tx.objectStore(STORE_NAME); + let totalBytes = await readMetaSize(tx); let cursor = await store.openCursor(); while (cursor) { const k = cursor.key; if (typeof k === 'string' && (k === keyOrPrefix || k.startsWith(keyOrPrefix))) { + totalBytes -= safeStringify(cursor.value).length; await cursor.delete(); } cursor = await cursor.continue(); } + await writeMetaSize(tx, totalBytes); await tx.done; } catch { // Best-effort — an invalidation failure should not throw into the @@ -226,6 +351,30 @@ export async function invalidate(keyOrPrefix: string): Promise { } } +/** + * Wipes every entry in the *current* scope's store (see `setCacheScope`) and + * resets its tracked size back to zero. Intended for an explicit wallet + * disconnect / account change: unlike switching scope (which just points at + * a different, untouched database), this clears the store the caller is + * leaving so a departing account's cached data doesn't linger in IndexedDB + * after logout. + * + * Resolves without effect when IndexedDB is unavailable — never throws. + */ +export async function clearCache(): Promise { + const db = getDb(); + if (!db) return; + try { + const conn = await db; + const tx = conn.transaction([STORE_NAME, META_STORE_NAME], 'readwrite'); + await tx.objectStore(STORE_NAME).clear(); + await writeMetaSize(tx, 0); + await tx.done; + } catch { + // Best-effort — clearing must not throw into a disconnect handler. + } +} + // ── Stale-while-revalidate ─────────────────────────────────────────────────── export interface StaleWhileRevalidateResult { diff --git a/invofi/apps/sdk/src/client.ts b/invofi/apps/sdk/src/client.ts index 2f92dbfcf..9ef2d1b8f 100644 --- a/invofi/apps/sdk/src/client.ts +++ b/invofi/apps/sdk/src/client.ts @@ -31,7 +31,7 @@ import { validateAssetString, validateConfigField, } from './validation'; -import { invalidate } from './cache'; +import { invalidate, setCacheScope } from './cache'; export { SdkValidationError, ErrorCode }; @@ -105,6 +105,13 @@ export function createInvofiClient(cfg: InvofiClientConfig) { validateAssetString(cfg.positionTokenAsset, 'cfg.positionTokenAsset'); } + // Scope the offline cache (Task 218) to this network + connected account + // so a client built for one wallet/network never reads another's cached + // data (PR #236 review). Re-scoping on every construction means a caller + // that rebuilds the client on wallet/account change (the normal React + // pattern) gets correct isolation for free. + setCacheScope({ network: cfg.networkPassphrase, accountAddress: cfg.accountAddress }); + const server = () => new SorobanRpc.Server(cfg.rpcUrl, { allowHttp: false }); const horizon = () => new Horizon.Server(cfg.horizonUrl); diff --git a/invofi/apps/sdk/src/config.ts b/invofi/apps/sdk/src/config.ts index 8a0931685..d5455417e 100644 --- a/invofi/apps/sdk/src/config.ts +++ b/invofi/apps/sdk/src/config.ts @@ -20,6 +20,14 @@ export interface InvofiClientConfig { * contract's `get_position_token` (single source of truth on-chain). */ positionTokenAsset?: string; + /** + * The connected wallet's Stellar address, if any. Used only to scope the + * offline cache (Task 218) per-account so switching wallets never serves + * one identity's cached reads to another — omit while no wallet is + * connected. Every state-changing call still takes its own explicit + * address argument; this is not used for authorization. + */ + accountAddress?: string; /** * Signs an XDR transaction with the connected wallet. The SDK assembles and * simulates the transaction, then hands it to this callback and submits the diff --git a/invofi/apps/sdk/src/index.ts b/invofi/apps/sdk/src/index.ts index 90b050e17..3aab67095 100644 --- a/invofi/apps/sdk/src/index.ts +++ b/invofi/apps/sdk/src/index.ts @@ -87,6 +87,12 @@ export type { // repay/etc.) call `invalidate()` internally on success, so consumers only // need this surface for reads. // +// The cache is namespaced per network + connected account (`CacheScope`) — +// `createInvofiClient` calls `setCacheScope` automatically from +// `cfg.networkPassphrase`/`cfg.accountAddress`, so switching wallets never +// serves one identity's cached data to another. On an explicit wallet +// disconnect, call `clearCache()` to wipe the departing account's store. +// // @example // ```ts // import { staleWhileRevalidate, CACHE_TTL_MS } from '@invofi/sdk'; @@ -98,14 +104,20 @@ export type { // ); // // Render `data` immediately (may be null/stale); `refresh` resolves once // // the background re-fetch has silently updated the cache. +// +// // On wallet disconnect: +// await clearCache(); // ``` export { getCached, setCached, invalidate, + clearCache, + setCacheScope, + getCacheScope, staleWhileRevalidate, isIndexedDbAvailable, CACHE_TTL_MS, MAX_CACHE_SIZE_BYTES, } from './cache'; -export type { CacheEntry, StaleWhileRevalidateResult } from './cache'; +export type { CacheEntry, CacheScope, StaleWhileRevalidateResult } from './cache'; diff --git a/invofi/apps/sdk/tests/cache.test.ts b/invofi/apps/sdk/tests/cache.test.ts index c34936a9e..2a9688ad7 100644 --- a/invofi/apps/sdk/tests/cache.test.ts +++ b/invofi/apps/sdk/tests/cache.test.ts @@ -33,6 +33,9 @@ * 5. Prefix-based invalidate() — exact key and prefix-family deletion * 6. LRU eviction — least-recently-accessed entries evicted first over budget * 7. Environment guard — safe no-op when indexedDB is unavailable + * 8. Cache scoping — setCacheScope/getCacheScope isolate databases per + * network+account (PR #236 review) + * 9. clearCache — wipes the active scope's store (disconnect hygiene) */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; @@ -43,28 +46,34 @@ import 'fake-indexeddb/auto'; import * as cache from '../src/cache'; -const DB_NAME = 'invofi-cache'; const STORE_NAME = 'invofi-cache'; +const META_STORE_NAME = 'invofi-cache-meta'; +// Matches cache.ts's dbNameFor({}) for the default (unscoped) CacheScope. +const DEFAULT_DB_NAME = 'invofi-cache:unscoped:anon'; /** - * Clears the object store's contents via a short-lived side connection, + * Clears a scoped database's contents via a short-lived side connection, * without ever closing (or blocking on) the cache module's own long-lived - * connection. Creates the store first if it doesn't exist yet (first run). + * connection. Creates the stores first if they don't exist yet (first run). */ -function clearStore(): Promise { +function clearStore(dbName: string = DEFAULT_DB_NAME): Promise { return new Promise((resolve, reject) => { - const openReq = indexedDB.open(DB_NAME, 1); + const openReq = indexedDB.open(dbName, 1); openReq.onupgradeneeded = () => { const db = openReq.result; if (!db.objectStoreNames.contains(STORE_NAME)) { const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' }); store.createIndex('lastAccessed', 'lastAccessed'); } + if (!db.objectStoreNames.contains(META_STORE_NAME)) { + db.createObjectStore(META_STORE_NAME, { keyPath: 'key' }); + } }; openReq.onsuccess = () => { const db = openReq.result; - const tx = db.transaction(STORE_NAME, 'readwrite'); + const tx = db.transaction([STORE_NAME, META_STORE_NAME], 'readwrite'); tx.objectStore(STORE_NAME).clear(); + tx.objectStore(META_STORE_NAME).clear(); tx.oncomplete = () => { db.close(); resolve(); @@ -79,6 +88,7 @@ function clearStore(): Promise { } beforeEach(async () => { + cache.setCacheScope({}); await clearStore(); }); @@ -331,3 +341,108 @@ describe('cache — environment guard (no IndexedDB)', () => { await expect(result.refresh).resolves.toEqual({ id: 'inv_x' }); }); }); + +// ── 8. Cache scoping ─────────────────────────────────────────────────────────── + +describe('cache — scoping', () => { + afterEach(() => { + cache.setCacheScope({}); + }); + + it('defaults to an unscoped/anonymous scope', () => { + expect(cache.getCacheScope()).toEqual({}); + }); + + it('reflects the scope passed to setCacheScope', () => { + cache.setCacheScope({ network: 'testnet', accountAddress: 'GALICE' }); + expect(cache.getCacheScope()).toEqual({ network: 'testnet', accountAddress: 'GALICE' }); + }); + + it('isolates data between two different accounts on the same network', async () => { + cache.setCacheScope({ network: 'testnet', accountAddress: 'GALICE' }); + await cache.setCached('positions:GALICE', { balance: 100 }); + + cache.setCacheScope({ network: 'testnet', accountAddress: 'GBOB' }); + expect(await cache.getCached('positions:GALICE')).toBeNull(); + await cache.setCached('positions:GBOB', { balance: 5 }); + + cache.setCacheScope({ network: 'testnet', accountAddress: 'GALICE' }); + const aliceEntry = await cache.getCached<{ balance: number }>('positions:GALICE'); + expect(aliceEntry?.data).toEqual({ balance: 100 }); + // Bob's write is invisible from Alice's scope, even under the same key shape. + expect(await cache.getCached('positions:GBOB')).toBeNull(); + }); + + it('isolates data between two different networks for the same account', async () => { + cache.setCacheScope({ network: 'testnet', accountAddress: 'GALICE' }); + await cache.setCached('invoices:Pending:1', { network: 'testnet' }); + + cache.setCacheScope({ network: 'mainnet', accountAddress: 'GALICE' }); + expect(await cache.getCached('invoices:Pending:1')).toBeNull(); + }); + + it('persists a scope\'s data across repeated setCacheScope calls with the same value', async () => { + const scope = { network: 'testnet', accountAddress: 'GALICE' }; + cache.setCacheScope(scope); + await cache.setCached('offers:inv_1', { id: 'off_1' }); + + cache.setCacheScope({ ...scope }); + const entry = await cache.getCached('offers:inv_1'); + expect(entry).not.toBeNull(); + }); +}); + +// ── 9. clearCache ──────────────────────────────────────────────────────────── + +describe('cache — clearCache', () => { + afterEach(() => { + cache.setCacheScope({}); + }); + + it('wipes every entry in the active scope', async () => { + cache.setCacheScope({ network: 'testnet', accountAddress: 'GCARL' }); + await cache.setCached('invoices:Pending:1', { a: 1 }); + await cache.setCached('offers:inv_1', { b: 2 }); + + await cache.clearCache(); + + expect(await cache.getCached('invoices:Pending:1')).toBeNull(); + expect(await cache.getCached('offers:inv_1')).toBeNull(); + }); + + it('does not affect other scopes', async () => { + cache.setCacheScope({ network: 'testnet', accountAddress: 'GCARL' }); + await cache.setCached('invoices:Pending:1', { a: 1 }); + + cache.setCacheScope({ network: 'testnet', accountAddress: 'GDAVE' }); + await cache.setCached('invoices:Pending:1', { a: 2 }); + await cache.clearCache(); + + cache.setCacheScope({ network: 'testnet', accountAddress: 'GCARL' }); + const entry = await cache.getCached<{ a: number }>('invoices:Pending:1'); + expect(entry?.data).toEqual({ a: 1 }); + }); + + it('resets the tracked size so subsequent writes are not evicted prematurely', async () => { + cache.setCacheScope({ network: 'testnet', accountAddress: 'GERIN' }); + await cache.setCached('positions:GERIN', { blob: 'x'.repeat(50) }); + await cache.clearCache(); + + // A tiny maxSizeBytes would immediately trigger eviction if the size + // counter still reflected the pre-clear total instead of resetting to 0. + await cache.setCached('positions:GERIN', { blob: 'y'.repeat(10) }, 1, 1_000); + const entry = await cache.getCached('positions:GERIN'); + expect(entry).not.toBeNull(); + }); + + it('resolves without effect (never throws) when indexedDB is unavailable', async () => { + const savedIndexedDb = globalThis.indexedDB; + // @ts-expect-error — simulating an environment with no indexedDB global + delete globalThis.indexedDB; + try { + await expect(cache.clearCache()).resolves.toBeUndefined(); + } finally { + globalThis.indexedDB = savedIndexedDb; + } + }); +}); From 054e0d01585283d78e25873629cbe90d71087f39 Mon Sep 17 00:00:00 2001 From: Ajibose Date: Wed, 19 Aug 2026 09:24:45 +0300 Subject: [PATCH 3/6] fix(frontend): resolve idb for tsc and webpack, not just npm install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding idb as a frontend dependency (previous commit) fixed npm ci but not module resolution: apps/sdk/src/cache.ts lives outside apps/frontend, so both tsc and webpack walk up from its own directory when resolving `idb` and never reach apps/frontend/node_modules — the same reason @stellar/stellar-sdk already has a tsconfig path + webpack alias here. Added the matching idb entries in both places so CI's Lint & Type Check and Build jobs resolve it too. Refs #218 --- invofi/apps/frontend/next.config.mjs | 6 ++++-- invofi/apps/frontend/tsconfig.json | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/invofi/apps/frontend/next.config.mjs b/invofi/apps/frontend/next.config.mjs index 7eaa96350..34e58a1d4 100644 --- a/invofi/apps/frontend/next.config.mjs +++ b/invofi/apps/frontend/next.config.mjs @@ -82,11 +82,13 @@ const nextConfig = { }; } // Task 15: @invofi/sdk is consumed from source via tsconfig paths, so its - // `@stellar/stellar-sdk` import must resolve to THIS app's copy (the SDK's - // own node_modules isn't installed in CI). Pin it for webpack too. + // dependencies must resolve to THIS app's copy (the SDK's own + // node_modules isn't installed in CI). Pin them for webpack too. + // `idb` (Task 218) is the offline-cache module's only other bare import. config.resolve.alias = { ...config.resolve.alias, '@stellar/stellar-sdk': path.resolve(__dirname, 'node_modules/@stellar/stellar-sdk'), + idb: path.resolve(__dirname, 'node_modules/idb'), }; return config; }, diff --git a/invofi/apps/frontend/tsconfig.json b/invofi/apps/frontend/tsconfig.json index 10ff63334..8ce2cb0e8 100644 --- a/invofi/apps/frontend/tsconfig.json +++ b/invofi/apps/frontend/tsconfig.json @@ -17,7 +17,8 @@ "paths": { "@/*": ["./src/*"], "@invofi/sdk": ["../sdk/src/index.ts"], - "@stellar/stellar-sdk": ["./node_modules/@stellar/stellar-sdk"] + "@stellar/stellar-sdk": ["./node_modules/@stellar/stellar-sdk"], + "idb": ["./node_modules/idb"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], From e4cdd1cc640fb35e6bf00cfe8b13073c47ee1f02 Mon Sep 17 00:00:00 2001 From: Ajibose Date: Wed, 19 Aug 2026 12:06:21 +0300 Subject: [PATCH 4/6] fix(sdk): make offline cache instance-scoped, fix lossy scope encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit's second round of review on #236: - Replaced the module-global currentScope/dbPromise with an instance factory: createCache(scope) returns a CacheHandle bound to one immutable CacheScope, with its own private, memoized IndexedDB connection. Concurrent callers (e.g. two InvofiClient instances for different accounts live at once) no longer share mutable state or race over which database is "current" — each handle simply never looks at another's connection. - createInvofiClient builds its own CacheHandle from cfg.networkPassphrase/cfg.accountAddress and exposes it as client.cache; its state-changing methods now call cache.invalidate(...) against that instance instead of a module-level export. - Replaced scopeSegment's lossy "replace disallowed chars with _" sanitizing (under which e.g. "acct/1" and "acct?1" collided onto the same database) with encodeURIComponent, which is injective (distinct inputs never collide) and reversible (decodeURIComponent undoes it), and never emits the ':' used as the segment separator. - Rewrote cache.test.ts around the instance API: each test now gets its own never-reused scope instead of sharing one connection with manual store-clearing between tests. Added coverage for concurrent differently-scoped instances and previously-colliding scope strings (35 tests, up from 21). Refs #218 --- invofi/apps/sdk/src/cache.ts | 564 ++++++++++++++-------------- invofi/apps/sdk/src/client.ts | 58 +-- invofi/apps/sdk/src/index.ts | 44 +-- invofi/apps/sdk/tests/cache.test.ts | 380 +++++++++++-------- 4 files changed, 572 insertions(+), 474 deletions(-) diff --git a/invofi/apps/sdk/src/cache.ts b/invofi/apps/sdk/src/cache.ts index c38d5cdf2..914692410 100644 --- a/invofi/apps/sdk/src/cache.ts +++ b/invofi/apps/sdk/src/cache.ts @@ -6,26 +6,30 @@ // // This module is browser-only in spirit but MUST NOT assume a browser is // present: the SDK is also consumed by a Next.js app that renders on the -// server, and by a Node CLI keeper (apps/scripts). Every exported function -// therefore guards on `isIndexedDbAvailable()` and degrades to a safe no-op -// (resolves `null`/`undefined` for reads, resolves without effect for -// writes) rather than throwing when `indexedDB` is unavailable — this is a -// load-bearing guarantee for SSR/Node callers, not an incidental detail. +// server, and by a Node CLI keeper (apps/scripts). Every method therefore +// guards on `isIndexedDbAvailable()` and degrades to a safe no-op (resolves +// `null`/`undefined` for reads, resolves without effect for writes) rather +// than throwing when `indexedDB` is unavailable — this is a load-bearing +// guarantee for SSR/Node callers, not an incidental detail. // // Scoping (PR #236 review): the cache is namespaced by network + connected -// account (see `CacheScope`/`setCacheScope`) so switching wallets or networks -// never serves one identity's cached data to another — each scope gets its -// own IndexedDB database. `createInvofiClient` (client.ts) calls -// `setCacheScope` automatically from `cfg.networkPassphrase` / -// `cfg.accountAddress`. On an explicit wallet disconnect, callers should -// invoke `clearCache()` to wipe the departing account's store rather than -// leaving it sitting in IndexedDB indefinitely (frontend wiring of that call -// site is a follow-up, same as the rest of the frontend integration — see -// the PR description). +// account (`CacheScope`), and database selection is instance-scoped, not a +// module-global — call `createCache(scope)` to get a handle bound to one +// immutable scope, with its own private IndexedDB connection. Concurrent +// callers with different scopes (e.g. two clients for different accounts +// live at once) never share mutable state or race on which database is +// "current", which a module-global `currentScope`/`dbPromise` pair would. +// `createInvofiClient` (client.ts) builds one from `cfg.networkPassphrase` / +// `cfg.accountAddress` and exposes it as `client.cache`. On an explicit +// wallet disconnect, callers should call `cache.clearCache()` to wipe the +// departing account's store rather than leaving it sitting in IndexedDB +// indefinitely (frontend wiring of that call site is a follow-up, same as +// the rest of the frontend integration — see the PR description). // // Usage: -// import { staleWhileRevalidate, invalidate, CACHE_TTL_MS } from './cache'; -// const { data, isStale, refresh } = await staleWhileRevalidate( +// import { createCache, CACHE_TTL_MS } from './cache'; +// const cache = createCache({ network: cfg.networkPassphrase, accountAddress }); +// const { data, isStale, refresh } = await cache.staleWhileRevalidate( // `invoices:${status}:${page}`, // CACHE_TTL_MS.invoices, // () => fetchInvoicesFromChain(status, page), @@ -60,10 +64,12 @@ interface MetaRecord { } /** - * Identifies which account/network the active cache database belongs to. + * Identifies which account/network a cache instance's database belongs to. * Both fields are free-form identifiers (e.g. `networkPassphrase` and a * Stellar `G...` address) — the cache only uses them to build a database - * name, never to validate their format. + * name, never to validate their format. Treat a `CacheScope` as immutable + * once passed to `createCache` — the returned instance keeps using the + * database it was built for even if the caller later mutates the object. */ export interface CacheScope { /** e.g. `Networks.PUBLIC` / `Networks.TESTNET`. Omit to share one scope across networks. */ @@ -93,9 +99,9 @@ export const CACHE_TTL_MS: Record<'invoices' | 'offers' | 'positions', number> = /** * Total estimated cache size (tracked incrementally in `META_STORE_NAME`, - * not recomputed from a full scan on every write — see `adjustMetaSize`) - * above which the LRU sweep evicts least-recently-accessed entries. - * 50 MB per the Task 218 storage-limit requirement. + * not recomputed from a full scan on every write — see `evictLru`) above + * which the LRU sweep evicts least-recently-accessed entries. 50 MB per the + * Task 218 storage-limit requirement. */ export const MAX_CACHE_SIZE_BYTES = 50 * 1024 * 1024; @@ -116,77 +122,28 @@ function safeStringify(value: unknown): string { return JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)); } -// ── Scope / connection management ─────────────────────────────────────────── +// ── Scope → database name ─────────────────────────────────────────────────── -let currentScope: CacheScope = {}; -let dbPromise: Promise | null = null; - -function scopeSegment(value: string | undefined, fallback: string): string { +/** + * `encodeURIComponent` gives each segment a reversible, collision-free + * encoding: distinct inputs always produce distinct outputs (unlike a + * lossy "replace disallowed chars with _" scheme, where e.g. "a/b" and + * "a?b" would previously have collided onto the same sanitized segment — + * and therefore the same database, defeating the point of scoping). It + * also never emits a literal ':', so the fixed ':' separators below can't + * be spoofed by a scope value that happens to contain one. + */ +function encodeScopeSegment(value: string | undefined, fallback: string): string { const trimmed = value?.trim(); - return trimmed ? trimmed.replace(/[^a-zA-Z0-9_.:-]/g, '_') : fallback; + return trimmed ? encodeURIComponent(trimmed) : fallback; } function dbNameFor(scope: CacheScope): string { - const network = scopeSegment(scope.network, 'unscoped'); - const account = scopeSegment(scope.accountAddress, 'anon'); + const network = encodeScopeSegment(scope.network, 'unscoped'); + const account = encodeScopeSegment(scope.accountAddress, 'anon'); return `${DB_NAME_PREFIX}:${network}:${account}`; } -/** - * Points the cache at the database for `scope` (network + connected - * account). Different scopes never share a database, so switching wallets or - * networks can never serve one identity's cached data to another. Safe to - * call redundantly — a no-op when `scope` matches the currently active one. - * - * Does not clear any data — each scope's store persists across reconnects of - * the same account, which is the point of an offline-first cache. To wipe a - * departing account's data (e.g. on explicit wallet disconnect), call - * `clearCache()` before or after switching scope. - */ -export function setCacheScope(scope: CacheScope): void { - const nextName = dbNameFor(scope); - if (nextName === dbNameFor(currentScope)) { - currentScope = { ...scope }; - return; - } - currentScope = { ...scope }; - const previousDb = dbPromise; - dbPromise = null; - if (previousDb) { - // Best-effort close of the old connection so it doesn't leak — failures - // here (already closed, never resolved, etc.) are not actionable. - void previousDb.then(db => db.close()).catch(() => {}); - } -} - -/** Returns the scope the cache is currently reading/writing against. */ -export function getCacheScope(): CacheScope { - return { ...currentScope }; -} - -/** - * Lazily opens (and memoizes) the cache database for the current scope. - * Returns `null` when IndexedDB is unavailable — callers must treat that as - * "no-op". - */ -function getDb(): Promise | null { - if (!isIndexedDbAvailable()) return null; - if (!dbPromise) { - dbPromise = openDB(dbNameFor(currentScope), DB_VERSION, { - upgrade(db) { - if (!db.objectStoreNames.contains(STORE_NAME)) { - const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' }); - store.createIndex(LAST_ACCESSED_INDEX, 'lastAccessed'); - } - if (!db.objectStoreNames.contains(META_STORE_NAME)) { - db.createObjectStore(META_STORE_NAME, { keyPath: 'key' }); - } - }, - }); - } - return dbPromise; -} - // ── Incremental size tracking ─────────────────────────────────────────────── // Avoids the `getAll()` + stringify-everything full scan on every write: a // single-row running total is read/written in the same transaction as the @@ -206,224 +163,281 @@ async function writeMetaSize( await tx.objectStore(META_STORE_NAME).put({ key: META_KEY, totalBytes: Math.max(0, totalBytes) }); } -// ── Reads ───────────────────────────────────────────────────────────────────── +// ── Stale-while-revalidate result ─────────────────────────────────────────── -/** - * Reads a cache entry by exact key, regardless of staleness — TTL/staleness - * is the caller's decision (see `staleWhileRevalidate`). Also bumps - * `lastAccessed` for LRU purposes. - * - * Resolves `null` when the entry is missing OR when IndexedDB is - * unavailable (SSR/Node) — never throws. - */ -export async function getCached(key: string): Promise | null> { - const db = getDb(); - if (!db) return null; - try { - const conn = await db; - const tx = conn.transaction(STORE_NAME, 'readwrite'); - const store = tx.objectStore(STORE_NAME); - const entry = (await store.get(key)) as StoredEntry | undefined; - if (!entry) { - await tx.done; - return null; - } - entry.lastAccessed = Date.now(); - await store.put(entry); - await tx.done; - return { key: entry.key, data: entry.data, timestamp: entry.timestamp, version: entry.version }; - } catch { - // Corrupt entry, blocked transaction, etc. — degrade to "no cache". - return null; - } +export interface StaleWhileRevalidateResult { + /** Cached value, or `null` when there was no cache entry (or no IndexedDB). */ + data: T | null; + /** True when `data` is missing or older than `ttlMs`. */ + isStale: boolean; + /** + * The background refresh. Resolves with the freshly-fetched value on + * success (after silently writing it to the cache), or `null` if the + * fetch failed — a failed refresh never throws and never touches (let + * alone evicts) the still-valid stale cache entry. Callers that only + * need the immediate cached read may safely ignore this promise. + */ + refresh: Promise; } -// ── Writes ──────────────────────────────────────────────────────────────────── +/** An instance-scoped cache handle bound to one immutable `CacheScope` — see `createCache`. */ +export interface CacheHandle { + /** The scope this instance was created with (defensive copy — mutating it has no effect). */ + readonly scope: CacheScope; + getCached(key: string): Promise | null>; + setCached(key: string, data: T, version?: number, maxSizeBytes?: number): Promise; + invalidate(keyOrPrefix: string): Promise; + clearCache(): Promise; + staleWhileRevalidate( + key: string, + ttlMs: number, + fetcher: () => Promise, + ): Promise>; +} /** - * Writes a cache entry (upsert by `key`) with the current timestamp, then - * runs an LRU-eviction sweep (awaited, not fire-and-forget — so a caller - * that awaits `setCached` is guaranteed the store is back under budget - * before it resolves) if the estimated total store size exceeds - * `maxSizeBytes` (defaults to `MAX_CACHE_SIZE_BYTES`; overridable for - * testing). - * - * Resolves without effect when IndexedDB is unavailable — never throws. + * Creates a cache handle bound to one immutable `CacheScope` (network + + * connected account, both optional — defaults to a single shared "unscoped" + * database). Each handle owns its own private IndexedDB connection; nothing + * here is shared module-global state, so concurrent handles for different + * scopes (or the same scope, from different callers) never race with each + * other over which database is "current". Different scopes never share a + * database, so switching wallets or networks can never serve one identity's + * cached data to another. */ -export async function setCached( - key: string, - data: T, - version = 1, - maxSizeBytes: number = MAX_CACHE_SIZE_BYTES, -): Promise { - const db = getDb(); - if (!db) return; - try { - const conn = await db; - const now = Date.now(); - const entry: StoredEntry = { key, data, timestamp: now, version, lastAccessed: now }; - const newSize = safeStringify(entry).length; - - const tx = conn.transaction([STORE_NAME, META_STORE_NAME], 'readwrite'); - const store = tx.objectStore(STORE_NAME); - const previous = (await store.get(key)) as StoredEntry | undefined; - const previousSize = previous ? safeStringify(previous).length : 0; - - await store.put(entry); - const totalBytes = (await readMetaSize(tx)) - previousSize + newSize; - await writeMetaSize(tx, totalBytes); - await tx.done; - - if (totalBytes > maxSizeBytes) { - await evictLru(conn, maxSizeBytes); +export function createCache(scope: CacheScope = {}): CacheHandle { + const resolvedScope: CacheScope = { ...scope }; + const dbName = dbNameFor(resolvedScope); + let dbPromise: Promise | null = null; + + /** + * Lazily opens (and memoizes) this instance's database connection. + * Returns `null` when IndexedDB is unavailable — callers must treat that + * as "no-op". + */ + function getDb(): Promise | null { + if (!isIndexedDbAvailable()) return null; + if (!dbPromise) { + dbPromise = openDB(dbName, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' }); + store.createIndex(LAST_ACCESSED_INDEX, 'lastAccessed'); + } + if (!db.objectStoreNames.contains(META_STORE_NAME)) { + db.createObjectStore(META_STORE_NAME, { keyPath: 'key' }); + } + }, + }); } - } catch { - // Write failures (quota exceeded, blocked, etc.) must not throw or - // interrupt the caller — the cache is best-effort. + return dbPromise; } -} -/** - * Evicts least-recently-accessed entries (oldest `lastAccessed` first), - * walking the `lastAccessed` index via a cursor, until the tracked total - * size is back under `maxSizeBytes`. Unlike a `getAll()` scan, this only - * touches as many entries as actually need evicting. - */ -async function evictLru(conn: IDBPDatabase, maxSizeBytes: number): Promise { - try { - const tx = conn.transaction([STORE_NAME, META_STORE_NAME], 'readwrite'); - const store = tx.objectStore(STORE_NAME); - let totalBytes = await readMetaSize(tx); - if (totalBytes <= maxSizeBytes) { + /** + * Evicts least-recently-accessed entries (oldest `lastAccessed` first), + * walking the `lastAccessed` index via a cursor, until the tracked total + * size is back under `maxSizeBytes`. Unlike a `getAll()` scan, this only + * touches as many entries as actually need evicting. + */ + async function evictLru(conn: IDBPDatabase, maxSizeBytes: number): Promise { + try { + const tx = conn.transaction([STORE_NAME, META_STORE_NAME], 'readwrite'); + const store = tx.objectStore(STORE_NAME); + let totalBytes = await readMetaSize(tx); + if (totalBytes <= maxSizeBytes) { + await tx.done; + return; + } + + let cursor = await store.index(LAST_ACCESSED_INDEX).openCursor(); + while (cursor && totalBytes > maxSizeBytes) { + const entrySize = safeStringify(cursor.value).length; + await cursor.delete(); + totalBytes -= entrySize; + cursor = await cursor.continue(); + } + + await writeMetaSize(tx, totalBytes); await tx.done; - return; + } catch { + // Eviction is best-effort cleanup — a failure here must not surface to + // the setCached caller (the write already succeeded). } + } - let cursor = await store.index(LAST_ACCESSED_INDEX).openCursor(); - while (cursor && totalBytes > maxSizeBytes) { - const entrySize = safeStringify(cursor.value).length; - await cursor.delete(); - totalBytes -= entrySize; - cursor = await cursor.continue(); + /** + * Reads a cache entry by exact key, regardless of staleness — TTL/staleness + * is the caller's decision (see `staleWhileRevalidate`). Also bumps + * `lastAccessed` for LRU purposes. + * + * Resolves `null` when the entry is missing OR when IndexedDB is + * unavailable (SSR/Node) — never throws. + */ + async function getCached(key: string): Promise | null> { + const db = getDb(); + if (!db) return null; + try { + const conn = await db; + const tx = conn.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const entry = (await store.get(key)) as StoredEntry | undefined; + if (!entry) { + await tx.done; + return null; + } + entry.lastAccessed = Date.now(); + await store.put(entry); + await tx.done; + return { key: entry.key, data: entry.data, timestamp: entry.timestamp, version: entry.version }; + } catch { + // Corrupt entry, blocked transaction, etc. — degrade to "no cache". + return null; } - - await writeMetaSize(tx, totalBytes); - await tx.done; - } catch { - // Eviction is best-effort cleanup — a failure here must not surface to - // the setCached caller (the write already succeeded). } -} -// ── Invalidation ────────────────────────────────────────────────────────────── + /** + * Writes a cache entry (upsert by `key`) with the current timestamp, then + * runs an LRU-eviction sweep (awaited, not fire-and-forget — so a caller + * that awaits `setCached` is guaranteed the store is back under budget + * before it resolves) if the estimated total store size exceeds + * `maxSizeBytes` (defaults to `MAX_CACHE_SIZE_BYTES`; overridable for + * testing). + * + * Resolves without effect when IndexedDB is unavailable — never throws. + */ + async function setCached( + key: string, + data: T, + version = 1, + maxSizeBytes: number = MAX_CACHE_SIZE_BYTES, + ): Promise { + const db = getDb(); + if (!db) return; + try { + const conn = await db; + const now = Date.now(); + const entry: StoredEntry = { key, data, timestamp: now, version, lastAccessed: now }; + const newSize = safeStringify(entry).length; + + const tx = conn.transaction([STORE_NAME, META_STORE_NAME], 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const previous = (await store.get(key)) as StoredEntry | undefined; + const previousSize = previous ? safeStringify(previous).length : 0; + + await store.put(entry); + const totalBytes = (await readMetaSize(tx)) - previousSize + newSize; + await writeMetaSize(tx, totalBytes); + await tx.done; -/** - * Deletes a single exact key, or — when `keyOrPrefix` matches the start of - * one or more stored keys — every key sharing that prefix (e.g. - * `invalidate('invoices:')` clears every paginated `invoices:{status}:{page}` - * entry after a mutation, since the mutation doesn't know every page that - * might now be stale). - * - * Resolves without effect when IndexedDB is unavailable — never throws. - */ -export async function invalidate(keyOrPrefix: string): Promise { - const db = getDb(); - if (!db) return; - try { - const conn = await db; - const tx = conn.transaction([STORE_NAME, META_STORE_NAME], 'readwrite'); - const store = tx.objectStore(STORE_NAME); - let totalBytes = await readMetaSize(tx); - let cursor = await store.openCursor(); - while (cursor) { - const k = cursor.key; - if (typeof k === 'string' && (k === keyOrPrefix || k.startsWith(keyOrPrefix))) { - totalBytes -= safeStringify(cursor.value).length; - await cursor.delete(); + if (totalBytes > maxSizeBytes) { + await evictLru(conn, maxSizeBytes); } - cursor = await cursor.continue(); + } catch { + // Write failures (quota exceeded, blocked, etc.) must not throw or + // interrupt the caller — the cache is best-effort. } - await writeMetaSize(tx, totalBytes); - await tx.done; - } catch { - // Best-effort — an invalidation failure should not throw into the - // caller's mutation flow (the on-chain write already succeeded). } -} -/** - * Wipes every entry in the *current* scope's store (see `setCacheScope`) and - * resets its tracked size back to zero. Intended for an explicit wallet - * disconnect / account change: unlike switching scope (which just points at - * a different, untouched database), this clears the store the caller is - * leaving so a departing account's cached data doesn't linger in IndexedDB - * after logout. - * - * Resolves without effect when IndexedDB is unavailable — never throws. - */ -export async function clearCache(): Promise { - const db = getDb(); - if (!db) return; - try { - const conn = await db; - const tx = conn.transaction([STORE_NAME, META_STORE_NAME], 'readwrite'); - await tx.objectStore(STORE_NAME).clear(); - await writeMetaSize(tx, 0); - await tx.done; - } catch { - // Best-effort — clearing must not throw into a disconnect handler. + /** + * Deletes a single exact key, or — when `keyOrPrefix` matches the start of + * one or more stored keys — every key sharing that prefix (e.g. + * `invalidate('invoices:')` clears every paginated `invoices:{status}:{page}` + * entry after a mutation, since the mutation doesn't know every page that + * might now be stale). + * + * Resolves without effect when IndexedDB is unavailable — never throws. + */ + async function invalidate(keyOrPrefix: string): Promise { + const db = getDb(); + if (!db) return; + try { + const conn = await db; + const tx = conn.transaction([STORE_NAME, META_STORE_NAME], 'readwrite'); + const store = tx.objectStore(STORE_NAME); + let totalBytes = await readMetaSize(tx); + let cursor = await store.openCursor(); + while (cursor) { + const k = cursor.key; + if (typeof k === 'string' && (k === keyOrPrefix || k.startsWith(keyOrPrefix))) { + totalBytes -= safeStringify(cursor.value).length; + await cursor.delete(); + } + cursor = await cursor.continue(); + } + await writeMetaSize(tx, totalBytes); + await tx.done; + } catch { + // Best-effort — an invalidation failure should not throw into the + // caller's mutation flow (the on-chain write already succeeded). + } } -} -// ── Stale-while-revalidate ─────────────────────────────────────────────────── + /** + * Wipes every entry in this instance's store and resets its tracked size + * back to zero. Intended for an explicit wallet disconnect / account + * change: unlike creating a new `CacheHandle` for a different scope + * (which just points at a different, untouched database), this clears the + * store the caller is leaving so a departing account's cached data + * doesn't linger in IndexedDB after logout. + * + * Resolves without effect when IndexedDB is unavailable — never throws. + */ + async function clearCache(): Promise { + const db = getDb(); + if (!db) return; + try { + const conn = await db; + const tx = conn.transaction([STORE_NAME, META_STORE_NAME], 'readwrite'); + await tx.objectStore(STORE_NAME).clear(); + await writeMetaSize(tx, 0); + await tx.done; + } catch { + // Best-effort — clearing must not throw into a disconnect handler. + } + } -export interface StaleWhileRevalidateResult { - /** Cached value, or `null` when there was no cache entry (or no IndexedDB). */ - data: T | null; - /** True when `data` is missing or older than `ttlMs`. */ - isStale: boolean; /** - * The background refresh. Resolves with the freshly-fetched value on - * success (after silently writing it to the cache), or `null` if the - * fetch failed — a failed refresh never throws and never touches (let - * alone evicts) the still-valid stale cache entry. Callers that only - * need the immediate cached read may safely ignore this promise. + * The core SWR entry point: reads the cache immediately (fast path), then + * kicks off `fetcher()` in the background via `Promise.allSettled` so a + * rejected fetch degrades gracefully instead of throwing or clearing the + * still-good stale entry. On success the fresh value silently replaces the + * cache entry. + * + * `data`/`isStale` are ready as soon as the returned promise resolves (a + * single fast IndexedDB read); `refresh` is a separate promise the caller + * can `await` or ignore. */ - refresh: Promise; -} + async function staleWhileRevalidate( + key: string, + ttlMs: number, + fetcher: () => Promise, + ): Promise> { + const cached = await getCached(key); + const isStale = !cached || Date.now() - cached.timestamp > ttlMs; + + const refresh: Promise = (async () => { + const [outcome] = await Promise.allSettled([fetcher()]); + if (outcome.status === 'fulfilled') { + await setCached(key, outcome.value, cached?.version ?? 1); + return outcome.value; + } + // Swallow the failure — the stale cache entry (if any) is left intact. + return null; + })(); -/** - * The core SWR entry point: reads the cache immediately (fast path), then - * kicks off `fetcher()` in the background via `Promise.allSettled` so a - * rejected fetch degrades gracefully instead of throwing or clearing the - * still-good stale entry. On success the fresh value silently replaces the - * cache entry. - * - * `data`/`isStale` are ready as soon as the returned promise resolves (a - * single fast IndexedDB read); `refresh` is a separate promise the caller - * can `await` or ignore. - */ -export async function staleWhileRevalidate( - key: string, - ttlMs: number, - fetcher: () => Promise, -): Promise> { - const cached = await getCached(key); - const isStale = !cached || Date.now() - cached.timestamp > ttlMs; - - const refresh: Promise = (async () => { - const [outcome] = await Promise.allSettled([fetcher()]); - if (outcome.status === 'fulfilled') { - await setCached(key, outcome.value, cached?.version ?? 1); - return outcome.value; - } - // Swallow the failure — the stale cache entry (if any) is left intact. - return null; - })(); + return { + data: cached ? cached.data : null, + isStale, + refresh, + }; + } return { - data: cached ? cached.data : null, - isStale, - refresh, + scope: resolvedScope, + getCached, + setCached, + invalidate, + clearCache, + staleWhileRevalidate, }; } diff --git a/invofi/apps/sdk/src/client.ts b/invofi/apps/sdk/src/client.ts index 9ef2d1b8f..c4b4612fc 100644 --- a/invofi/apps/sdk/src/client.ts +++ b/invofi/apps/sdk/src/client.ts @@ -31,21 +31,22 @@ import { validateAssetString, validateConfigField, } from './validation'; -import { invalidate, setCacheScope } from './cache'; +import { createCache, type CacheHandle } from './cache'; export { SdkValidationError, ErrorCode }; /** * Invalidates the offline-cache (Task 218) key prefixes affected by a - * state-changing contract call, once it has succeeded. Best-effort and - * side-effect-only: `invalidate()` never throws (see cache.ts), so this - * never affects the caller's return value. Fire-and-forget is intentional — - * callers already have the fresh on-chain result; invalidation just makes - * sure a subsequent cached read doesn't serve stale data. + * state-changing contract call, once it has succeeded, against this + * client's own `CacheHandle`. Best-effort and side-effect-only: + * `cache.invalidate()` never throws (see cache.ts), so this never affects + * the caller's return value. Fire-and-forget is intentional — callers + * already have the fresh on-chain result; invalidation just makes sure a + * subsequent cached read doesn't serve stale data. */ -function invalidateCache(prefixes: string[]): void { +function invalidateCache(cache: CacheHandle, prefixes: string[]): void { for (const prefix of prefixes) { - void invalidate(prefix); + void cache.invalidate(prefix); } } @@ -105,12 +106,14 @@ export function createInvofiClient(cfg: InvofiClientConfig) { validateAssetString(cfg.positionTokenAsset, 'cfg.positionTokenAsset'); } - // Scope the offline cache (Task 218) to this network + connected account - // so a client built for one wallet/network never reads another's cached - // data (PR #236 review). Re-scoping on every construction means a caller - // that rebuilds the client on wallet/account change (the normal React - // pattern) gets correct isolation for free. - setCacheScope({ network: cfg.networkPassphrase, accountAddress: cfg.accountAddress }); + // A cache handle scoped to this network + connected account (Task 218), + // owned by this client instance — not module-global state — so a client + // built for one wallet/network never reads another's cached data, and + // concurrent clients for different accounts never race over which + // database is "current" (PR #236 review). A caller that rebuilds the + // client on wallet/account change (the normal React pattern) gets a + // freshly-scoped cache for free. + const cache = createCache({ network: cfg.networkPassphrase, accountAddress: cfg.accountAddress }); const server = () => new SorobanRpc.Server(cfg.rpcUrl, { allowHttp: false }); const horizon = () => new Horizon.Server(cfg.horizonUrl); @@ -242,6 +245,15 @@ export function createInvofiClient(cfg: InvofiClientConfig) { } return { + /** + * This client's offline-cache handle (Task 218), scoped to + * `cfg.networkPassphrase`/`cfg.accountAddress`. State-changing methods + * below already invalidate the affected prefixes on success; consumers + * only need this directly for reads (`cache.staleWhileRevalidate(...)`) + * or to clear it on an explicit wallet disconnect (`cache.clearCache()`). + */ + cache, + // ── Registry contract ──────────────────────────────────────────────────── /** @@ -275,7 +287,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { originatorAddress, ); const invoice = parseInvoice(val); - invalidateCache(['invoices:']); + invalidateCache(cache, ['invoices:']); return invoice; }, @@ -310,7 +322,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { originatorAddress, ); const invoice = parseInvoice(val); - invalidateCache(['invoices:', `offers:${invoiceId}`]); + invalidateCache(cache, ['invoices:', `offers:${invoiceId}`]); return invoice; }, @@ -358,7 +370,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { lenderAddress, ); const offer = parseOffer(val); - invalidateCache([`offers:${params.invoiceId}`]); + invalidateCache(cache, [`offers:${params.invoiceId}`]); return offer; }, @@ -396,7 +408,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { // Accepting an offer moves the invoice to Financed, mints a position // token to the lender, and settles this offer — all three cache // families are affected. - invalidateCache(['invoices:', `offers:${offer.invoice_id}`, 'positions:']); + invalidateCache(cache, ['invoices:', `offers:${offer.invoice_id}`, 'positions:']); return offer; }, @@ -418,7 +430,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { originatorAddress, ); const offer = parseOffer(val); - invalidateCache([`offers:${offer.invoice_id}`]); + invalidateCache(cache, [`offers:${offer.invoice_id}`]); return offer; }, @@ -454,7 +466,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { repayerAddress, ); const invoice = parseInvoice(val); - invalidateCache(['invoices:', `offers:${invoiceId}`, 'positions:']); + invalidateCache(cache, ['invoices:', `offers:${invoiceId}`, 'positions:']); return invoice; }, @@ -476,7 +488,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { callerAddress, ); const invoice = parseInvoice(val); - invalidateCache(['invoices:']); + invalidateCache(cache, ['invoices:']); return invoice; }, @@ -499,7 +511,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { lenderAddress, ); const offer = parseOffer(val); - invalidateCache(['invoices:', `offers:${invoiceId}`, 'positions:']); + invalidateCache(cache, ['invoices:', `offers:${invoiceId}`, 'positions:']); return offer; }, @@ -569,7 +581,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { [encodeAddress(fromAddress), encodeAddress(toAddress), encodeI128(amount)], fromAddress, ); - invalidateCache([`positions:${fromAddress}`, `positions:${toAddress}`]); + invalidateCache(cache, [`positions:${fromAddress}`, `positions:${toAddress}`]); }, // ── Position-token trustline support ───────────────────────────────────── diff --git a/invofi/apps/sdk/src/index.ts b/invofi/apps/sdk/src/index.ts index 3aab67095..032297b87 100644 --- a/invofi/apps/sdk/src/index.ts +++ b/invofi/apps/sdk/src/index.ts @@ -83,41 +83,33 @@ export type { // Browser-only, gracefully no-ops under SSR/Node (see cache.ts). Caches // invoice/offer/position reads with configurable per-type TTLs and evicts // least-recently-used entries once total cached size exceeds 50 MB. -// `createInvofiClient`'s state-changing methods (register/accept/reject/ -// repay/etc.) call `invalidate()` internally on success, so consumers only -// need this surface for reads. // -// The cache is namespaced per network + connected account (`CacheScope`) — -// `createInvofiClient` calls `setCacheScope` automatically from -// `cfg.networkPassphrase`/`cfg.accountAddress`, so switching wallets never -// serves one identity's cached data to another. On an explicit wallet -// disconnect, call `clearCache()` to wipe the departing account's store. +// Instance-scoped, not module-global (PR #236 review): `createCache(scope)` +// returns a handle bound to one immutable network+account `CacheScope`, each +// with its own private IndexedDB connection, so concurrent handles never +// race over which database is "current" and switching wallets never serves +// one identity's cached data to another. `createInvofiClient` builds one +// automatically from `cfg.networkPassphrase`/`cfg.accountAddress` and +// exposes it as `client.cache` — its state-changing methods +// (register/accept/reject/repay/etc.) already call `cache.invalidate()` +// internally on success. On an explicit wallet disconnect, call +// `client.cache.clearCache()` to wipe the departing account's store. // // @example // ```ts -// import { staleWhileRevalidate, CACHE_TTL_MS } from '@invofi/sdk'; +// import { createCache, CACHE_TTL_MS } from '@invofi/sdk'; // -// const { data, isStale, refresh } = await staleWhileRevalidate( +// // Usually just `client.cache` from createInvofiClient — shown standalone +// // here for a caller that wants a cache without a full client. +// const cache = createCache({ network: cfg.networkPassphrase, accountAddress }); +// +// const { data, isStale, refresh } = await cache.staleWhileRevalidate( // `invoices:${status}:${page}`, // CACHE_TTL_MS.invoices, // () => client.listInvoices(status, page), // ); // // Render `data` immediately (may be null/stale); `refresh` resolves once // // the background re-fetch has silently updated the cache. -// -// // On wallet disconnect: -// await clearCache(); // ``` -export { - getCached, - setCached, - invalidate, - clearCache, - setCacheScope, - getCacheScope, - staleWhileRevalidate, - isIndexedDbAvailable, - CACHE_TTL_MS, - MAX_CACHE_SIZE_BYTES, -} from './cache'; -export type { CacheEntry, CacheScope, StaleWhileRevalidateResult } from './cache'; +export { createCache, isIndexedDbAvailable, CACHE_TTL_MS, MAX_CACHE_SIZE_BYTES } from './cache'; +export type { CacheEntry, CacheHandle, CacheScope, StaleWhileRevalidateResult } from './cache'; diff --git a/invofi/apps/sdk/tests/cache.test.ts b/invofi/apps/sdk/tests/cache.test.ts index 2a9688ad7..a5ed3efa4 100644 --- a/invofi/apps/sdk/tests/cache.test.ts +++ b/invofi/apps/sdk/tests/cache.test.ts @@ -7,22 +7,17 @@ * polyfill it via `fake-indexeddb/auto`, which installs `indexedDB` plus * the full IDBRequest/IDBCursor/IDBKeyRange/etc. constructor set that * `idb` (the wrapper `src/cache.ts` uses) needs for its instanceof checks. - * - `src/cache.ts` opens ONE memoized connection for the module's lifetime - * (a realistic long-lived app connection). We import it once, statically, - * and reuse that same connection across every test — NOT - * `vi.resetModules()` + a fresh `indexedDB.deleteDatabase()` per test. - * That combination deadlocks: `deleteDatabase()` blocks forever on the - * still-open connection from the previous test (nothing ever closes it), - * and — because IndexedDB processes requests against a given database in - * order — every `open()` issued afterwards queues up behind that stuck - * `delete()` and never resolves either. Instead, `beforeEach` clears the - * object store's contents via a short-lived side connection (opening the - * same DB/version alongside the long-lived one is fine; no version change, - * no blocking). - * - The "no indexedDB" tests delete/restore `globalThis.indexedDB` directly. - * This works without any module reset because every exported function in - * `src/cache.ts` checks `isIndexedDbAvailable()` fresh on *every call*, - * before ever touching the memoized connection promise. + * - The cache is instance-scoped (PR #236 review): `createCache(scope)` + * returns a handle with its own private, memoized IndexedDB connection, + * keyed by `scope`. Rather than sharing one connection across tests and + * manually clearing its contents in `beforeEach` (the old module-global + * design needed this to avoid cross-test pollution), each test just uses + * `uniqueScope()` to get its own never-before-seen scope — a fresh, + * isolated database per test, no shared state, no cleanup dance. + * - The "no indexedDB" tests delete/restore `globalThis.indexedDB`. This + * works without any module reset because every method re-checks + * `isIndexedDbAvailable()` fresh on every call, before ever touching the + * memoized connection promise. * * Coverage * -------- @@ -33,73 +28,42 @@ * 5. Prefix-based invalidate() — exact key and prefix-family deletion * 6. LRU eviction — least-recently-accessed entries evicted first over budget * 7. Environment guard — safe no-op when indexedDB is unavailable - * 8. Cache scoping — setCacheScope/getCacheScope isolate databases per - * network+account (PR #236 review) - * 9. clearCache — wipes the active scope's store (disconnect hygiene) + * 8. Scoping — createCache(scope) isolates databases per network+account + * 9. clearCache — wipes one instance's store without affecting others + * 10. Concurrent clients — two instances for different scopes never cross-talk + * 11. Distinct scope strings — collision-free encoding (no shared "_" sanitizing) */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; // Installs indexedDB + IDBRequest/IDBCursor/IDBKeyRange/etc. as globals — // `idb` (the wrapper `src/cache.ts` uses) needs the full constructor set, // not just `indexedDB` itself, to build its promise-based instanceof checks. import 'fake-indexeddb/auto'; -import * as cache from '../src/cache'; +import { createCache, CACHE_TTL_MS, isIndexedDbAvailable, type CacheScope } from '../src/cache'; -const STORE_NAME = 'invofi-cache'; -const META_STORE_NAME = 'invofi-cache-meta'; -// Matches cache.ts's dbNameFor({}) for the default (unscoped) CacheScope. -const DEFAULT_DB_NAME = 'invofi-cache:unscoped:anon'; - -/** - * Clears a scoped database's contents via a short-lived side connection, - * without ever closing (or blocking on) the cache module's own long-lived - * connection. Creates the stores first if they don't exist yet (first run). - */ -function clearStore(dbName: string = DEFAULT_DB_NAME): Promise { - return new Promise((resolve, reject) => { - const openReq = indexedDB.open(dbName, 1); - openReq.onupgradeneeded = () => { - const db = openReq.result; - if (!db.objectStoreNames.contains(STORE_NAME)) { - const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' }); - store.createIndex('lastAccessed', 'lastAccessed'); - } - if (!db.objectStoreNames.contains(META_STORE_NAME)) { - db.createObjectStore(META_STORE_NAME, { keyPath: 'key' }); - } - }; - openReq.onsuccess = () => { - const db = openReq.result; - const tx = db.transaction([STORE_NAME, META_STORE_NAME], 'readwrite'); - tx.objectStore(STORE_NAME).clear(); - tx.objectStore(META_STORE_NAME).clear(); - tx.oncomplete = () => { - db.close(); - resolve(); - }; - tx.onerror = () => { - db.close(); - reject(tx.error); - }; - }; - openReq.onerror = () => reject(openReq.error); - }); +let scopeCounter = 0; +/** A never-before-used id, for building a scope that no other test can collide with. */ +function uniqueId(label = 'test'): string { + scopeCounter += 1; + return `${label}-${scopeCounter}`; } -beforeEach(async () => { - cache.setCacheScope({}); - await clearStore(); -}); +/** A never-before-used scope, so each test gets its own isolated database. */ +function uniqueScope(label = 'test'): CacheScope { + return { network: 'unit-test-net', accountAddress: uniqueId(label) }; +} // ── 1. Basic get/set round-trip ─────────────────────────────────────────────── describe('cache — get/set round-trip', () => { it('returns null for a missing key', async () => { + const cache = createCache(uniqueScope()); expect(await cache.getCached('invoices:Pending:1')).toBeNull(); }); it('round-trips data with the correct schema fields', async () => { + const cache = createCache(uniqueScope()); const before = Date.now(); await cache.setCached('offers:inv_001', { foo: 'bar' }, 3); const entry = await cache.getCached<{ foo: string }>('offers:inv_001'); @@ -113,12 +77,14 @@ describe('cache — get/set round-trip', () => { }); it('defaults version to 1 when not supplied', async () => { + const cache = createCache(uniqueScope()); await cache.setCached('positions:GLENDER123', { balance: 100n }); const entry = await cache.getCached('positions:GLENDER123'); expect(entry!.version).toBe(1); }); it('overwrites an existing entry on a second setCached for the same key', async () => { + const cache = createCache(uniqueScope()); await cache.setCached('offers:inv_001', { v: 1 }); await cache.setCached('offers:inv_001', { v: 2 }); const entry = await cache.getCached<{ v: number }>('offers:inv_001'); @@ -126,6 +92,7 @@ describe('cache — get/set round-trip', () => { }); it('handles bigint fields in cached data (Invoice/FinancingOffer shapes)', async () => { + const cache = createCache(uniqueScope()); await cache.setCached('invoices:Pending:1', { amount: 5_000_000n }); const entry = await cache.getCached<{ amount: bigint }>('invoices:Pending:1'); expect(entry!.data.amount).toBe(5_000_000n); @@ -136,9 +103,9 @@ describe('cache — get/set round-trip', () => { describe('cache — CACHE_TTL_MS config', () => { it('defines the required per-type TTLs', () => { - expect(cache.CACHE_TTL_MS.invoices).toBe(5 * 60_000); - expect(cache.CACHE_TTL_MS.offers).toBe(2 * 60_000); - expect(cache.CACHE_TTL_MS.positions).toBe(60_000); + expect(CACHE_TTL_MS.invoices).toBe(5 * 60_000); + expect(CACHE_TTL_MS.offers).toBe(2 * 60_000); + expect(CACHE_TTL_MS.positions).toBe(60_000); }); }); @@ -146,6 +113,7 @@ describe('cache — CACHE_TTL_MS config', () => { describe('cache — staleWhileRevalidate', () => { it('returns null data and isStale=true on a cold cache, then updates the cache in the background', async () => { + const cache = createCache(uniqueScope()); const fetcher = vi.fn().mockResolvedValue({ id: 'inv_1' }); const result = await cache.staleWhileRevalidate('invoices:Pending:1', 5_000, fetcher); @@ -161,6 +129,7 @@ describe('cache — staleWhileRevalidate', () => { }); it('returns cached data immediately (isStale=false) while still refreshing in the background', async () => { + const cache = createCache(uniqueScope()); await cache.setCached('offers:inv_002', { id: 'off_1', v: 1 }); const fetcher = vi.fn().mockResolvedValue({ id: 'off_1', v: 2 }); @@ -176,6 +145,7 @@ describe('cache — staleWhileRevalidate', () => { }); it('marks an entry older than the TTL as stale', async () => { + const cache = createCache(uniqueScope()); // Real (short) delay rather than fake timers — fake-indexeddb schedules // its request callbacks via real timers/microtasks internally, so // vi.useFakeTimers() here would starve `idb`'s promises and hang the test. @@ -192,6 +162,7 @@ describe('cache — staleWhileRevalidate', () => { // ── 4. Promise.allSettled graceful degradation ────────────────────────────── it('keeps the stale cache entry intact and does not throw when the background fetch rejects', async () => { + const cache = createCache(uniqueScope()); await cache.setCached('invoices:Financed:1', { id: 'inv_9' }); const fetcher = vi.fn().mockRejectedValue(new Error('network down')); @@ -205,6 +176,7 @@ describe('cache — staleWhileRevalidate', () => { }); it('never throws even with no prior cache entry and a rejecting fetcher', async () => { + const cache = createCache(uniqueScope()); const fetcher = vi.fn().mockRejectedValue(new Error('boom')); const result = await cache.staleWhileRevalidate('offers:inv_none', 60_000, fetcher); expect(result.data).toBeNull(); @@ -216,12 +188,14 @@ describe('cache — staleWhileRevalidate', () => { describe('cache — invalidate', () => { it('deletes an exact key', async () => { + const cache = createCache(uniqueScope()); await cache.setCached('offers:inv_001', { a: 1 }); await cache.invalidate('offers:inv_001'); expect(await cache.getCached('offers:inv_001')).toBeNull(); }); it('deletes every key sharing a prefix, leaving unrelated keys untouched', async () => { + const cache = createCache(uniqueScope()); await cache.setCached('invoices:Pending:1', { p: 1 }); await cache.setCached('invoices:Pending:2', { p: 2 }); await cache.setCached('invoices:Financed:1', { p: 3 }); @@ -236,6 +210,7 @@ describe('cache — invalidate', () => { }); it('is a no-op (does not throw) for a key/prefix that matches nothing', async () => { + const cache = createCache(uniqueScope()); await expect(cache.invalidate('nonexistent:')).resolves.toBeUndefined(); }); }); @@ -246,6 +221,7 @@ const MAX_CACHE_SIZE_BYTES_FOR_TEST = 50 * 1024 * 1024; describe('cache — LRU eviction', () => { it('evicts least-recently-accessed entries once the size threshold is exceeded', async () => { + const cache = createCache(uniqueScope()); // Small threshold so the test doesn't need to write anywhere near 50MB. // Sized to fit exactly two of these ~equal-size entries (plus slack) so // adding the third forces exactly one eviction — the least-recently-used @@ -285,6 +261,7 @@ describe('cache — LRU eviction', () => { }); it('does not evict anything when under the size threshold', async () => { + const cache = createCache(uniqueScope()); await cache.setCached('positions:A', { small: 1 }, 1, MAX_CACHE_SIZE_BYTES_FOR_TEST); await cache.setCached('positions:B', { small: 2 }, 1, MAX_CACHE_SIZE_BYTES_FOR_TEST); expect(await cache.getCached('positions:A')).not.toBeNull(); @@ -297,97 +274,112 @@ describe('cache — LRU eviction', () => { describe('cache — environment guard (no IndexedDB)', () => { // fake-indexeddb/auto installs `globalThis.indexedDB` once for the whole // file; these tests temporarily remove it to exercise the SSR/Node path, - // then restore it so later tests (and other describe blocks) are unaffected. - // No module reset is needed: every exported function re-checks - // `isIndexedDbAvailable()` on each call before touching the memoized - // connection, so flipping the global directly is sufficient. - let savedIndexedDb: typeof indexedDB; - - beforeEach(() => { - savedIndexedDb = globalThis.indexedDB; + // then restore it so later tests are unaffected. No module reset is + // needed: every method re-checks `isIndexedDbAvailable()` on each call + // before touching the memoized connection promise. + async function withoutIndexedDb(fn: () => Promise | T): Promise { + const saved = globalThis.indexedDB; // @ts-expect-error — simulating an environment with no indexedDB global delete globalThis.indexedDB; - }); - - afterEach(() => { - globalThis.indexedDB = savedIndexedDb; - }); - - it('isIndexedDbAvailable() reflects the current globalThis.indexedDB', () => { - expect(cache.isIndexedDbAvailable()).toBe(false); + try { + return await fn(); + } finally { + globalThis.indexedDB = saved; + } + } - globalThis.indexedDB = savedIndexedDb; - expect(cache.isIndexedDbAvailable()).toBe(true); + it('isIndexedDbAvailable() reflects the current globalThis.indexedDB', async () => { + await withoutIndexedDb(() => { + expect(isIndexedDbAvailable()).toBe(false); + }); + expect(isIndexedDbAvailable()).toBe(true); }); it('getCached resolves null (never throws) when indexedDB is unavailable', async () => { - await expect(cache.getCached('invoices:Pending:1')).resolves.toBeNull(); + await withoutIndexedDb(async () => { + const cache = createCache(uniqueScope('no-idb')); + await expect(cache.getCached('invoices:Pending:1')).resolves.toBeNull(); + }); }); it('setCached resolves without effect (never throws) when indexedDB is unavailable', async () => { - await expect(cache.setCached('invoices:Pending:1', { a: 1 })).resolves.toBeUndefined(); + await withoutIndexedDb(async () => { + const cache = createCache(uniqueScope('no-idb')); + await expect(cache.setCached('invoices:Pending:1', { a: 1 })).resolves.toBeUndefined(); + }); }); it('invalidate resolves without effect (never throws) when indexedDB is unavailable', async () => { - await expect(cache.invalidate('invoices:')).resolves.toBeUndefined(); + await withoutIndexedDb(async () => { + const cache = createCache(uniqueScope('no-idb')); + await expect(cache.invalidate('invoices:')).resolves.toBeUndefined(); + }); }); it('staleWhileRevalidate still calls the fetcher and resolves gracefully with no cache backing', async () => { - const fetcher = vi.fn().mockResolvedValue({ id: 'inv_x' }); + await withoutIndexedDb(async () => { + const cache = createCache(uniqueScope('no-idb')); + const fetcher = vi.fn().mockResolvedValue({ id: 'inv_x' }); - const result = await cache.staleWhileRevalidate('invoices:Pending:1', 5_000, fetcher); - expect(result.data).toBeNull(); - expect(result.isStale).toBe(true); - await expect(result.refresh).resolves.toEqual({ id: 'inv_x' }); + const result = await cache.staleWhileRevalidate('invoices:Pending:1', 5_000, fetcher); + expect(result.data).toBeNull(); + expect(result.isStale).toBe(true); + await expect(result.refresh).resolves.toEqual({ id: 'inv_x' }); + }); }); }); -// ── 8. Cache scoping ─────────────────────────────────────────────────────────── +// ── 8. Scoping ─────────────────────────────────────────────────────────────── describe('cache — scoping', () => { - afterEach(() => { - cache.setCacheScope({}); + it('createCache() with no scope defaults to an unscoped/anonymous database', () => { + const cache = createCache(); + expect(cache.scope).toEqual({}); }); - it('defaults to an unscoped/anonymous scope', () => { - expect(cache.getCacheScope()).toEqual({}); + it('exposes the scope it was created with', () => { + const scope = { network: 'testnet', accountAddress: 'GALICE' }; + const cache = createCache(scope); + expect(cache.scope).toEqual(scope); }); - it('reflects the scope passed to setCacheScope', () => { - cache.setCacheScope({ network: 'testnet', accountAddress: 'GALICE' }); - expect(cache.getCacheScope()).toEqual({ network: 'testnet', accountAddress: 'GALICE' }); + it('mutating the scope object passed in has no effect on an existing instance', () => { + const scope = { network: 'testnet', accountAddress: 'GALICE' }; + const cache = createCache(scope); + scope.accountAddress = 'GBOB'; + expect(cache.scope.accountAddress).toBe('GALICE'); }); it('isolates data between two different accounts on the same network', async () => { - cache.setCacheScope({ network: 'testnet', accountAddress: 'GALICE' }); - await cache.setCached('positions:GALICE', { balance: 100 }); + const alice = createCache({ network: 'testnet', accountAddress: uniqueId('alice') }); + const bob = createCache({ network: 'testnet', accountAddress: uniqueId('bob') }); - cache.setCacheScope({ network: 'testnet', accountAddress: 'GBOB' }); - expect(await cache.getCached('positions:GALICE')).toBeNull(); - await cache.setCached('positions:GBOB', { balance: 5 }); + await alice.setCached('positions:me', { balance: 100 }); + await bob.setCached('positions:me', { balance: 5 }); - cache.setCacheScope({ network: 'testnet', accountAddress: 'GALICE' }); - const aliceEntry = await cache.getCached<{ balance: number }>('positions:GALICE'); + const aliceEntry = await alice.getCached<{ balance: number }>('positions:me'); + const bobEntry = await bob.getCached<{ balance: number }>('positions:me'); expect(aliceEntry?.data).toEqual({ balance: 100 }); - // Bob's write is invisible from Alice's scope, even under the same key shape. - expect(await cache.getCached('positions:GBOB')).toBeNull(); + expect(bobEntry?.data).toEqual({ balance: 5 }); }); it('isolates data between two different networks for the same account', async () => { - cache.setCacheScope({ network: 'testnet', accountAddress: 'GALICE' }); - await cache.setCached('invoices:Pending:1', { network: 'testnet' }); + const account = uniqueId('shared-account'); + const testnet = createCache({ network: 'testnet', accountAddress: account }); + const mainnet = createCache({ network: 'mainnet', accountAddress: account }); - cache.setCacheScope({ network: 'mainnet', accountAddress: 'GALICE' }); - expect(await cache.getCached('invoices:Pending:1')).toBeNull(); + await testnet.setCached('invoices:Pending:1', { network: 'testnet' }); + + expect(await mainnet.getCached('invoices:Pending:1')).toBeNull(); }); - it('persists a scope\'s data across repeated setCacheScope calls with the same value', async () => { - const scope = { network: 'testnet', accountAddress: 'GALICE' }; - cache.setCacheScope(scope); - await cache.setCached('offers:inv_1', { id: 'off_1' }); + it('two instances created with the same scope share the same database', async () => { + const scope = uniqueScope('shared'); + const first = createCache(scope); + await first.setCached('offers:inv_1', { id: 'off_1' }); - cache.setCacheScope({ ...scope }); - const entry = await cache.getCached('offers:inv_1'); + const second = createCache({ ...scope }); + const entry = await second.getCached('offers:inv_1'); expect(entry).not.toBeNull(); }); }); @@ -395,54 +387,142 @@ describe('cache — scoping', () => { // ── 9. clearCache ──────────────────────────────────────────────────────────── describe('cache — clearCache', () => { - afterEach(() => { - cache.setCacheScope({}); - }); + it('wipes every entry in this instance and does not affect other scopes', async () => { + const carl = createCache(uniqueScope('carl')); + const dave = createCache(uniqueScope('dave')); - it('wipes every entry in the active scope', async () => { - cache.setCacheScope({ network: 'testnet', accountAddress: 'GCARL' }); - await cache.setCached('invoices:Pending:1', { a: 1 }); - await cache.setCached('offers:inv_1', { b: 2 }); + await carl.setCached('invoices:Pending:1', { a: 1 }); + await dave.setCached('invoices:Pending:1', { a: 2 }); - await cache.clearCache(); + await carl.clearCache(); - expect(await cache.getCached('invoices:Pending:1')).toBeNull(); - expect(await cache.getCached('offers:inv_1')).toBeNull(); - }); - - it('does not affect other scopes', async () => { - cache.setCacheScope({ network: 'testnet', accountAddress: 'GCARL' }); - await cache.setCached('invoices:Pending:1', { a: 1 }); - - cache.setCacheScope({ network: 'testnet', accountAddress: 'GDAVE' }); - await cache.setCached('invoices:Pending:1', { a: 2 }); - await cache.clearCache(); - - cache.setCacheScope({ network: 'testnet', accountAddress: 'GCARL' }); - const entry = await cache.getCached<{ a: number }>('invoices:Pending:1'); - expect(entry?.data).toEqual({ a: 1 }); + expect(await carl.getCached('invoices:Pending:1')).toBeNull(); + const daveEntry = await dave.getCached<{ a: number }>('invoices:Pending:1'); + expect(daveEntry?.data).toEqual({ a: 2 }); }); it('resets the tracked size so subsequent writes are not evicted prematurely', async () => { - cache.setCacheScope({ network: 'testnet', accountAddress: 'GERIN' }); - await cache.setCached('positions:GERIN', { blob: 'x'.repeat(50) }); + const cache = createCache(uniqueScope()); + await cache.setCached('positions:me', { blob: 'x'.repeat(50) }); await cache.clearCache(); // A tiny maxSizeBytes would immediately trigger eviction if the size // counter still reflected the pre-clear total instead of resetting to 0. - await cache.setCached('positions:GERIN', { blob: 'y'.repeat(10) }, 1, 1_000); - const entry = await cache.getCached('positions:GERIN'); - expect(entry).not.toBeNull(); + await cache.setCached('positions:me', { blob: 'y'.repeat(10) }, 1, 1_000); + expect(await cache.getCached('positions:me')).not.toBeNull(); }); it('resolves without effect (never throws) when indexedDB is unavailable', async () => { - const savedIndexedDb = globalThis.indexedDB; + const saved = globalThis.indexedDB; // @ts-expect-error — simulating an environment with no indexedDB global delete globalThis.indexedDB; try { + const cache = createCache(uniqueScope('no-idb')); await expect(cache.clearCache()).resolves.toBeUndefined(); } finally { - globalThis.indexedDB = savedIndexedDb; + globalThis.indexedDB = saved; } }); }); + +// ── 10. Concurrent clients ─────────────────────────────────────────────────── +// The module-global `currentScope`/`dbPromise` design this replaced would +// have let one client's `setCacheScope` call redirect another's in-flight +// operations to the wrong database. These tests interleave two +// differently-scoped instances' operations to prove that can't happen now +// that each instance owns its own connection. + +describe('cache — concurrent clients', () => { + it('interleaved writes from two differently-scoped instances never cross-contaminate', async () => { + const alice = createCache(uniqueScope('concurrent-alice')); + const bob = createCache(uniqueScope('concurrent-bob')); + + // Interleave: both instances' operations are in flight at the same time, + // constructed in an order that would trip up any shared "current scope" + // pointer (bob's writes start before alice's finish). + await Promise.all([ + alice.setCached('invoices:Pending:1', { owner: 'alice', n: 1 }), + bob.setCached('invoices:Pending:1', { owner: 'bob', n: 1 }), + alice.setCached('offers:o1', { owner: 'alice', n: 2 }), + bob.setCached('offers:o1', { owner: 'bob', n: 2 }), + ]); + + const [aliceInvoice, bobInvoice, aliceOffer, bobOffer] = await Promise.all([ + alice.getCached<{ owner: string }>('invoices:Pending:1'), + bob.getCached<{ owner: string }>('invoices:Pending:1'), + alice.getCached<{ owner: string }>('offers:o1'), + bob.getCached<{ owner: string }>('offers:o1'), + ]); + + expect(aliceInvoice?.data.owner).toBe('alice'); + expect(bobInvoice?.data.owner).toBe('bob'); + expect(aliceOffer?.data.owner).toBe('alice'); + expect(bobOffer?.data.owner).toBe('bob'); + }); + + it('creating a new scoped instance mid-flight does not affect an existing instance\'s in-flight operations', async () => { + const first = createCache(uniqueScope('mid-flight-first')); + + const writePromise = first.setCached('positions:me', { balance: 1 }); + // Simulate another part of the app switching accounts concurrently — + // this used to mutate module-global state (`setCacheScope`) that + // `first`'s in-flight write would have read from. + const second = createCache(uniqueScope('mid-flight-second')); + await second.setCached('positions:me', { balance: 2 }); + await writePromise; + + const firstEntry = await first.getCached<{ balance: number }>('positions:me'); + const secondEntry = await second.getCached<{ balance: number }>('positions:me'); + expect(firstEntry?.data).toEqual({ balance: 1 }); + expect(secondEntry?.data).toEqual({ balance: 2 }); + }); +}); + +// ── 11. Distinct scope strings (collision-free encoding) ──────────────────── +// The previous implementation sanitized each scope segment by replacing any +// character outside [a-zA-Z0-9_.:-] with "_" — so e.g. accountAddress +// "acct/1" and "acct?1" both sanitized to "acct_1" and collided onto the +// *same* database. encodeURIComponent is injective (distinct inputs never +// produce the same output) and reversible (decodeURIComponent undoes it), +// which is what these tests exercise via observable isolation. + +describe('cache — distinct scope strings', () => { + it('scope strings that would have collided under naive "_" substitution stay isolated', async () => { + const a = createCache({ network: 'testnet', accountAddress: 'acct/1' }); + const b = createCache({ network: 'testnet', accountAddress: 'acct?1' }); + + await a.setCached('offers:x', { from: 'slash' }); + await b.setCached('offers:x', { from: 'question' }); + + const aEntry = await a.getCached<{ from: string }>('offers:x'); + const bEntry = await b.getCached<{ from: string }>('offers:x'); + expect(aEntry?.data).toEqual({ from: 'slash' }); + expect(bEntry?.data).toEqual({ from: 'question' }); + }); + + it('a scope value containing a literal ":" cannot be confused with the segment separator', async () => { + // Old behavior allowed ':' through unsanitized, so a crafted address + // could inject an extra segment boundary. A colon is now always + // percent-encoded, so this can't collide with a differently-shaped scope. + const withColon = createCache({ network: 'testnet', accountAddress: 'evil:anon' }); + const plain = createCache({ network: 'testnet', accountAddress: 'evil' }); + + await withColon.setCached('offers:x', { tag: 'colon' }); + + // 'plain' must not see 'withColon''s data even though a naive ':' split + // of the resulting database name could otherwise line them up. + expect(await plain.getCached('offers:x')).toBeNull(); + }); + + it('many distinct raw scope strings each get their own isolated database', async () => { + const rawValues = ['a b', 'a+b', 'a=b', 'a&b', 'a%b', 'a#b', 'ab', 'a_b']; + const caches = rawValues.map(v => createCache({ network: 'testnet', accountAddress: v })); + + await Promise.all(caches.map((c, i) => c.setCached('offers:x', { i }))); + + const entries = await Promise.all(caches.map(c => c.getCached<{ i: number }>('offers:x'))); + entries.forEach((entry, i) => { + expect(entry?.data).toEqual({ i }); + }); + }); +}); From b5e83062812e1bb497906faad4c6d662a65496cd Mon Sep 17 00:00:00 2001 From: Ajibose Date: Wed, 19 Aug 2026 12:34:44 +0300 Subject: [PATCH 5/6] fix(sdk): restore createCache import dropped by the main merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging main (with #235's typed-error handling) into this branch lost the `import { createCache, type CacheHandle } from './cache'` line in client.ts — the merge kept errors.ts's new import on that same line but dropped this one, even though the file still uses both createCache and CacheHandle. Broke Frontend / Lint & Type Check (TS2304: Cannot find name 'CacheHandle'/'createCache') and, transitively, Unit Tests. Refs #218 --- invofi/apps/sdk/src/client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/invofi/apps/sdk/src/client.ts b/invofi/apps/sdk/src/client.ts index 5faa1b1f1..3856cbac6 100644 --- a/invofi/apps/sdk/src/client.ts +++ b/invofi/apps/sdk/src/client.ts @@ -32,6 +32,7 @@ import { validateConfigField, } from './validation'; import { parseContractError } from './errors'; +import { createCache, type CacheHandle } from './cache'; export { SdkValidationError, ErrorCode }; From 008e4048e58d38c0d90614bbfbc5926a19621f2e Mon Sep 17 00:00:00 2001 From: Ajibose Date: Wed, 19 Aug 2026 12:41:28 +0300 Subject: [PATCH 6/6] fix(frontend): alias idb for Vitest too, not just tsc/webpack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging main (with #235's SdkErrorBoundary.test.tsx) into this branch made the frontend's Vitest suite transitively import cache.ts (via @invofi/sdk -> index.ts) for the first time, which needs the same "SDK's own node_modules isn't installed in CI" workaround idb already has for tsc (tsconfig.json paths) and webpack (next.config.mjs) — just missing from vitest.config.ts. Broke Frontend / Unit Tests with "Failed to resolve import 'idb' from '../sdk/src/cache.ts'". Refs #218 --- invofi/apps/frontend/vitest.config.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/invofi/apps/frontend/vitest.config.ts b/invofi/apps/frontend/vitest.config.ts index 0311097fb..a8712c814 100644 --- a/invofi/apps/frontend/vitest.config.ts +++ b/invofi/apps/frontend/vitest.config.ts @@ -42,10 +42,12 @@ export default defineConfig({ // resolve it too (#223). '@invofi/sdk': path.resolve(__dirname, '../sdk/src/index.ts'), // The SDK's own node_modules isn't installed in CI, so its - // `@stellar/stellar-sdk` import must resolve to this app's copy — - // same reasoning as the webpack alias in next.config.mjs, mirrored - // here for Vitest. + // `@stellar/stellar-sdk` and `idb` (Task 218, cache.ts) imports must + // resolve to this app's copy — same reasoning as the webpack alias in + // next.config.mjs / the tsconfig.json paths entries, mirrored here + // for Vitest. '@stellar/stellar-sdk': path.resolve(__dirname, 'node_modules/@stellar/stellar-sdk'), + idb: path.resolve(__dirname, 'node_modules/idb'), }, }, });