import { LOGGER_CATEGORY, RATE_PAIR_SEPARATOR } from './consts'; import { CURRENCY_ERRORS } from './errors'; import { assertValidRate, isValidRate, normalizeCurrencyCode } from './helpers'; import type { CurrencyCode, CurrencyRatesProvider, RateEntry, RatesOptions } from './types'; /** * Create a lazy rates provider backed by a per-pair cache table. */ export function createRates(opts: RatesOptions = {}): CurrencyRatesProvider { const cache = new Map(); const pendingFetches = new Map>(); const now = opts.now ?? Date.now; const { logger } = opts; let cacheVersion = 0; function pairKey(from: CurrencyCode, to: CurrencyCode): string { return `${from}${RATE_PAIR_SEPARATOR}${to}`; } function splitPairKey(key: string): [CurrencyCode, CurrencyCode] { const [from, to] = key.split(RATE_PAIR_SEPARATOR); return [from, to]; } function isActive(entry: RateEntry): boolean { return entry.expiresAt === Infinity || now() < entry.expiresAt; } function getCachedEntry(from: CurrencyCode, to: CurrencyCode): RateEntry | undefined { const key = pairKey(from, to); const entry = cache.get(key); if (entry === undefined) return undefined; if (isActive(entry)) return entry; cache.delete(key); return undefined; } function setCachedRate( from: CurrencyCode, to: CurrencyCode, rate: number, expiresAt = Infinity ): void { assertValidRate(from, to, rate); if (from === to) return; cache.set(pairKey(from, to), { rate, expiresAt }); cache.set(pairKey(to, from), { rate: 1 / rate, expiresAt }); } function getCrossRate(from: CurrencyCode, to: CurrencyCode): number | undefined { for (const [key, first] of cache) { if (!isActive(first)) { cache.delete(key); continue; } const [left, middle] = splitPairKey(key); if (left !== from || middle === to) continue; const second = getCachedEntry(middle, to); if (second === undefined) continue; const rate = first.rate * second.rate; const expiresAt = Math.min(first.expiresAt, second.expiresAt); setCachedRate(from, to, rate, expiresAt); return rate; } return undefined; } if (opts.initial) { const { base, rates, expiresAt = Infinity } = opts.initial; const normalizedBase = normalizeCurrencyCode(base); for (const [currency, rate] of Object.entries(rates)) { setCachedRate(normalizedBase, normalizeCurrencyCode(currency), rate, expiresAt); } } return { async getRate(from: CurrencyCode, to: CurrencyCode): Promise { from = normalizeCurrencyCode(from); to = normalizeCurrencyCode(to); if (from === to) return 1; const direct = getCachedEntry(from, to); if (direct !== undefined) return direct.rate; const cross = getCrossRate(from, to); if (cross !== undefined) return cross; if (!opts.fetchRate) { return undefined; } const fetchKey = pairKey(from, to); if (pendingFetches.has(fetchKey)) { return pendingFetches.get(fetchKey); } const fetchVersion = cacheVersion; const promise = opts .fetchRate(from, to) .then((result) => { pendingFetches.delete(fetchKey); if (fetchVersion !== cacheVersion) return undefined; if (!result) return undefined; if (!isValidRate(result.rate)) { logger?.warn(LOGGER_CATEGORY, CURRENCY_ERRORS.INVALID_RATE(from, to, result.rate), { context: { from, to, rate: result.rate } }); return undefined; } setCachedRate(from, to, result.rate, result.expiresAt ?? Infinity); return result.rate; }) .catch((error: unknown) => { pendingFetches.delete(fetchKey); if (fetchVersion !== cacheVersion) return undefined; logger?.warn(LOGGER_CATEGORY, CURRENCY_ERRORS.RATE_FETCH_FAILED(from, to), { context: { from, to }, error }); return undefined; }); pendingFetches.set(fetchKey, promise); return promise; }, setRate(from: CurrencyCode, to: CurrencyCode, rate: number, expiresAt = Infinity) { setCachedRate(normalizeCurrencyCode(from), normalizeCurrencyCode(to), rate, expiresAt); }, setRates(base: CurrencyCode, rates: Record, expiresAt = Infinity) { const normalizedBase = normalizeCurrencyCode(base); for (const [currency, rate] of Object.entries(rates)) { setCachedRate(normalizedBase, normalizeCurrencyCode(currency), rate, expiresAt); } }, hasRates(): boolean { for (const [key, entry] of cache) { if (isActive(entry)) return true; cache.delete(key); } return false; }, clear(): void { cacheVersion++; cache.clear(); pendingFetches.clear(); } }; } export type { RateFetcher, RatesOptions } from './types';