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

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';

Powered by TurnKey Linux.