You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
163 lines
4.7 KiB
163 lines
4.7 KiB
import { CURRENCY_DIAGNOSTIC_EVENTS, RATE_PAIR_SEPARATOR } from './consts';
|
|
import { createCurrencyDiagnostics, emitCurrencyDiagnostic } from './diagnostics';
|
|
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<string, RateEntry>();
|
|
const pendingFetches = new Map<string, Promise<number | undefined>>();
|
|
const now = opts.now ?? Date.now;
|
|
const { logger } = opts;
|
|
const diagnostics = createCurrencyDiagnostics(logger);
|
|
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<number | undefined> {
|
|
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)) {
|
|
emitCurrencyDiagnostic(diagnostics, CURRENCY_DIAGNOSTIC_EVENTS.INVALID_RATE, {
|
|
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;
|
|
emitCurrencyDiagnostic(diagnostics, CURRENCY_DIAGNOSTIC_EVENTS.RATE_FETCH_FAILED, {
|
|
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<CurrencyCode, number>, 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';
|