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/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 6e32d6a5f..50ead58db 100644 --- a/invofi/apps/frontend/package.json +++ b/invofi/apps/frontend/package.json @@ -25,6 +25,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/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"], 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'), }, }, }); 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..914692410 --- /dev/null +++ b/invofi/apps/sdk/src/cache.ts @@ -0,0 +1,443 @@ +// ── 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 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 (`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 { 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), +// ); + +import { openDB, type IDBPDatabase, type IDBPTransaction } 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; +} + +/** Single-row running total in `META_STORE_NAME`, kept in sync incrementally. */ +interface MetaRecord { + key: typeof META_KEY; + totalBytes: number; +} + +/** + * 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. 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. */ + 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) ──────────────────────────────────────── +// 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 (tracked incrementally in `META_STORE_NAME`, + * 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; + +// ── 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)); +} + +// ── Scope → database name ─────────────────────────────────────────────────── + +/** + * `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 ? encodeURIComponent(trimmed) : fallback; +} + +function dbNameFor(scope: CacheScope): string { + const network = encodeScopeSegment(scope.network, 'unscoped'); + const account = encodeScopeSegment(scope.accountAddress, 'anon'); + return `${DB_NAME_PREFIX}:${network}:${account}`; +} + +// ── 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) }); +} + +// ── Stale-while-revalidate result ─────────────────────────────────────────── + +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; +} + +/** 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>; +} + +/** + * 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 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' }); + } + }, + }); + } + 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) { + 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 + // the setCached caller (the write already succeeded). + } + } + + /** + * 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; + } + } + + /** + * 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; + + 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. + } + } + + /** + * 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). + } + } + + /** + * 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. + } + } + + /** + * 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. + */ + 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 { + scope: resolvedScope, + getCached, + setCached, + invalidate, + clearCache, + staleWhileRevalidate, + }; +} diff --git a/invofi/apps/sdk/src/client.ts b/invofi/apps/sdk/src/client.ts index a67184fca..3856cbac6 100644 --- a/invofi/apps/sdk/src/client.ts +++ b/invofi/apps/sdk/src/client.ts @@ -32,9 +32,25 @@ import { validateConfigField, } from './validation'; import { parseContractError } from './errors'; +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, 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(cache: CacheHandle, prefixes: string[]): void { + for (const prefix of prefixes) { + void cache.invalidate(prefix); + } +} + const BASE_FEE = '100'; /** A "CODE:ISSUER" asset string, e.g. "POS:GBDD…". */ @@ -91,6 +107,15 @@ export function createInvofiClient(cfg: InvofiClientConfig) { validateAssetString(cfg.positionTokenAsset, 'cfg.positionTokenAsset'); } + // 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); @@ -221,6 +246,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 ──────────────────────────────────────────────────── /** @@ -253,7 +287,9 @@ export function createInvofiClient(cfg: InvofiClientConfig) { ], originatorAddress, ); - return parseInvoice(val); + const invoice = parseInvoice(val); + invalidateCache(cache, ['invoices:']); + return invoice; }, /** @@ -286,7 +322,9 @@ export function createInvofiClient(cfg: InvofiClientConfig) { [encodeSymbol(invoiceId), encodeAddress(originatorAddress)], originatorAddress, ); - return parseInvoice(val); + const invoice = parseInvoice(val); + invalidateCache(cache, ['invoices:', `offers:${invoiceId}`]); + return invoice; }, // ── Financing contract ─────────────────────────────────────────────────── @@ -332,7 +370,9 @@ export function createInvofiClient(cfg: InvofiClientConfig) { ], lenderAddress, ); - return parseOffer(val); + const offer = parseOffer(val); + invalidateCache(cache, [`offers:${params.invoiceId}`]); + return offer; }, /** @@ -365,7 +405,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(cache, ['invoices:', `offers:${offer.invoice_id}`, 'positions:']); + return offer; }, /** @@ -385,7 +430,9 @@ export function createInvofiClient(cfg: InvofiClientConfig) { [encodeSymbol(offerId), encodeAddress(originatorAddress)], originatorAddress, ); - return parseOffer(val); + const offer = parseOffer(val); + invalidateCache(cache, [`offers:${offer.invoice_id}`]); + return offer; }, // ── Repayment contract ─────────────────────────────────────────────────── @@ -419,7 +466,9 @@ export function createInvofiClient(cfg: InvofiClientConfig) { ], repayerAddress, ); - return parseInvoice(val); + const invoice = parseInvoice(val); + invalidateCache(cache, ['invoices:', `offers:${invoiceId}`, 'positions:']); + return invoice; }, /** @@ -439,7 +488,9 @@ export function createInvofiClient(cfg: InvofiClientConfig) { [encodeSymbol(invoiceId)], callerAddress, ); - return parseInvoice(val); + const invoice = parseInvoice(val); + invalidateCache(cache, ['invoices:']); + return invoice; }, /** @@ -460,7 +511,9 @@ export function createInvofiClient(cfg: InvofiClientConfig) { [encodeSymbol(invoiceId), encodeSymbol(offerId), encodeAddress(lenderAddress)], lenderAddress, ); - return parseOffer(val); + const offer = parseOffer(val); + invalidateCache(cache, ['invoices:', `offers:${invoiceId}`, 'positions:']); + return offer; }, // ── Position tokens (Task 7/8: SEP-41 claim tokens) ───────────────────── @@ -529,6 +582,7 @@ export function createInvofiClient(cfg: InvofiClientConfig) { [encodeAddress(fromAddress), encodeAddress(toAddress), encodeI128(amount)], fromAddress, ); + invalidateCache(cache, [`positions:${fromAddress}`, `positions:${toAddress}`]); }, // ── Position-token trustline support ───────────────────────────────────── 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 a5afadc99..421ca13ce 100644 --- a/invofi/apps/sdk/src/index.ts +++ b/invofi/apps/sdk/src/index.ts @@ -99,3 +99,38 @@ 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. +// +// 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 { createCache, CACHE_TTL_MS } from '@invofi/sdk'; +// +// // 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. +// ``` +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 new file mode 100644 index 000000000..a5ed3efa4 --- /dev/null +++ b/invofi/apps/sdk/tests/cache.test.ts @@ -0,0 +1,528 @@ +/** + * 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. + * - 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 + * -------- + * 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 + * 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, 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 { createCache, CACHE_TTL_MS, isIndexedDbAvailable, type CacheScope } from '../src/cache'; + +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}`; +} + +/** 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'); + + 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 () => { + 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'); + expect(entry!.data).toEqual({ v: 2 }); + }); + + 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); + }); +}); + +// ── 2. TTL / staleness config ───────────────────────────────────────────────── + +describe('cache — CACHE_TTL_MS config', () => { + it('defines the required per-type TTLs', () => { + 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); + }); +}); + +// ── 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 cache = createCache(uniqueScope()); + 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 () => { + 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 }); + 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 () => { + 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. + 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 () => { + const cache = createCache(uniqueScope()); + 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 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(); + await expect(result.refresh).resolves.toBeNull(); + }); +}); + +// ── 5. invalidate() ──────────────────────────────────────────────────────────── + +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 }); + 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 () => { + const cache = createCache(uniqueScope()); + 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 () => { + 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 + // 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 () => { + 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(); + 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 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; + try { + return await fn(); + } finally { + globalThis.indexedDB = saved; + } + } + + 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 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 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 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 () => { + 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' }); + }); + }); +}); + +// ── 8. Scoping ─────────────────────────────────────────────────────────────── + +describe('cache — scoping', () => { + it('createCache() with no scope defaults to an unscoped/anonymous database', () => { + const cache = createCache(); + expect(cache.scope).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('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 () => { + const alice = createCache({ network: 'testnet', accountAddress: uniqueId('alice') }); + const bob = createCache({ network: 'testnet', accountAddress: uniqueId('bob') }); + + await alice.setCached('positions:me', { balance: 100 }); + await bob.setCached('positions:me', { balance: 5 }); + + const aliceEntry = await alice.getCached<{ balance: number }>('positions:me'); + const bobEntry = await bob.getCached<{ balance: number }>('positions:me'); + expect(aliceEntry?.data).toEqual({ balance: 100 }); + expect(bobEntry?.data).toEqual({ balance: 5 }); + }); + + it('isolates data between two different networks for the same account', async () => { + const account = uniqueId('shared-account'); + const testnet = createCache({ network: 'testnet', accountAddress: account }); + const mainnet = createCache({ network: 'mainnet', accountAddress: account }); + + await testnet.setCached('invoices:Pending:1', { network: 'testnet' }); + + expect(await mainnet.getCached('invoices:Pending:1')).toBeNull(); + }); + + 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' }); + + const second = createCache({ ...scope }); + const entry = await second.getCached('offers:inv_1'); + expect(entry).not.toBeNull(); + }); +}); + +// ── 9. clearCache ──────────────────────────────────────────────────────────── + +describe('cache — clearCache', () => { + it('wipes every entry in this instance and does not affect other scopes', async () => { + const carl = createCache(uniqueScope('carl')); + const dave = createCache(uniqueScope('dave')); + + await carl.setCached('invoices:Pending:1', { a: 1 }); + await dave.setCached('invoices:Pending:1', { a: 2 }); + + await carl.clearCache(); + + 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 () => { + 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: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 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 = 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 }); + }); + }); +});