semantic-ds-toolkit
Version:
Performance-first semantic layer for modern data stacks - Stable Column Anchors & intelligent inference
379 lines • 15.4 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.FXCache = exports.OfflineMode = void 0;
var OfflineMode;
(function (OfflineMode) {
OfflineMode["STRICT_OFFLINE"] = "STRICT_OFFLINE";
OfflineMode["CACHE_FIRST"] = "CACHE_FIRST";
OfflineMode["NETWORK_FIRST"] = "NETWORK_FIRST";
OfflineMode["FORCE_NETWORK"] = "FORCE_NETWORK";
})(OfflineMode || (exports.OfflineMode = OfflineMode = {}));
class FXCache {
memoryCache;
config;
lastFetchTime;
redisClient;
defaultMode;
timeoutMs;
constructor(ttlMs = 3600000, config = {}) {
this.config = {
ttlMs,
enableRedis: false,
dataSources: ['ecb'],
...config
};
this.memoryCache = new Map();
this.lastFetchTime = new Map();
this.redisClient = this.config.redisClient;
if (this.config.enableRedis && !this.redisClient) {
console.warn('FXCache: enableRedis is true but no redisClient provided; falling back to in-memory cache.');
}
this.defaultMode = this.config.defaultMode ?? OfflineMode.CACHE_FIRST;
this.timeoutMs = this.config.timeoutMs ?? 5000;
}
getCacheKey(fromCurrency, toCurrency) {
return `${fromCurrency}_${toCurrency}`;
}
isExpired(entry) {
return Date.now() - entry.timestamp > this.config.ttlMs;
}
async getCachedEntry(fromCurrency, toCurrency) {
const cacheKey = this.getCacheKey(fromCurrency, toCurrency);
const memoryEntry = this.memoryCache.get(cacheKey);
if (memoryEntry) {
return memoryEntry;
}
if (this.redisClient) {
try {
const raw = await this.redisClient.get(cacheKey);
if (raw) {
const parsed = JSON.parse(raw);
this.memoryCache.set(cacheKey, parsed);
return parsed;
}
}
catch (error) {
console.warn(`FXCache: failed to read Redis entry for ${cacheKey}: ${error}`);
}
}
return null;
}
async storeCacheEntries(fromCurrency, toCurrency, entry) {
const cacheKey = this.getCacheKey(fromCurrency, toCurrency);
const inverseKey = this.getCacheKey(toCurrency, fromCurrency);
this.memoryCache.set(cacheKey, entry);
this.memoryCache.set(inverseKey, {
rate: 1 / entry.rate,
timestamp: entry.timestamp,
source: entry.source
});
if (this.redisClient) {
const ttlSeconds = Math.max(1, Math.floor(this.config.ttlMs / 1000));
const payload = JSON.stringify(entry);
const inversePayload = JSON.stringify({
rate: 1 / entry.rate,
timestamp: entry.timestamp,
source: entry.source
});
try {
if (this.redisClient.setEx) {
await this.redisClient.setEx(cacheKey, ttlSeconds, payload);
await this.redisClient.setEx(inverseKey, ttlSeconds, inversePayload);
}
else if (this.redisClient.set) {
await this.redisClient.set(cacheKey, payload);
await this.redisClient.set(inverseKey, inversePayload);
if (this.redisClient.expire) {
await this.redisClient.expire(cacheKey, ttlSeconds);
await this.redisClient.expire(inverseKey, ttlSeconds);
}
}
}
catch (error) {
console.warn(`FXCache: failed to write to Redis for ${cacheKey}: ${error}`);
}
}
this.lastFetchTime.set(cacheKey, entry.timestamp);
}
toExchangeRateResult(fromCurrency, toCurrency, entry, source, markStale) {
const now = Date.now();
const ageMs = now - entry.timestamp;
const stale = markStale || this.isExpired(entry);
let confidence = 1;
switch (source) {
case 'ecb':
case 'fed':
confidence = stale ? 0.7 : 1;
break;
case 'cache':
confidence = stale ? 0.5 : 0.9;
break;
case 'fallback':
confidence = 0.5;
break;
default:
confidence = 0.6;
}
return {
rate: entry.rate,
timestamp: new Date(entry.timestamp),
source,
fromCurrency,
toCurrency,
confidence,
ageMs,
stale,
};
}
async fetchAndStore(fromCurrency, toCurrency, timeoutMs) {
const { rate, source } = await this.fetchExchangeRate(fromCurrency, toCurrency, timeoutMs);
const timestamp = Date.now();
const entry = {
rate,
timestamp,
source
};
await this.storeCacheEntries(fromCurrency, toCurrency, entry);
return this.toExchangeRateResult(fromCurrency, toCurrency, entry, source, false);
}
async fetchFromECB(fromCurrency, toCurrency, timeoutMs) {
try {
const response = await this.fetchWithTimeout(`${this.config.ecbEndpoint ?? 'https://api.exchangerate.host/latest'}?base=${fromCurrency}`, timeoutMs ?? this.timeoutMs);
if (!response.ok) {
throw new Error(`ECB API error: ${response.status}`);
}
const data = await response.json();
const rate = data.rates?.[toCurrency];
if (typeof rate !== 'number') {
throw new Error(`Rate not found for ${fromCurrency} to ${toCurrency}`);
}
return rate;
}
catch (error) {
throw new Error(`ECB fetch failed: ${error}`);
}
}
async fetchFromFed(fromCurrency, toCurrency, timeoutMs) {
if (fromCurrency !== 'USD' && toCurrency !== 'USD') {
throw new Error('Fed API only supports USD pairs');
}
try {
const symbol = fromCurrency === 'USD' ? `DEXUS${toCurrency}` : `DEXUS${fromCurrency}`;
const endpoint = this.config.fedEndpoint ?? 'https://api.stlouisfed.org/fred/series/observations';
const response = await this.fetchWithTimeout(`${endpoint}?series_id=${symbol}&api_key=demo&file_type=json&limit=1&sort_order=desc`, timeoutMs ?? this.timeoutMs);
if (!response.ok) {
throw new Error(`Fed API error: ${response.status}`);
}
const data = await response.json();
const observations = data.observations;
if (!observations || observations.length === 0) {
throw new Error(`No data found for ${symbol}`);
}
const rate = parseFloat(observations[0].value);
if (isNaN(rate)) {
throw new Error(`Invalid rate data: ${observations[0].value}`);
}
return fromCurrency === 'USD' ? rate : 1 / rate;
}
catch (error) {
throw new Error(`Fed fetch failed: ${error}`);
}
}
getFallbackRate(fromCurrency, toCurrency) {
const key = this.getCacheKey(fromCurrency, toCurrency);
const reverseKey = this.getCacheKey(toCurrency, fromCurrency);
if (this.config.fallbackRates?.[key]) {
return this.config.fallbackRates[key];
}
if (this.config.fallbackRates?.[reverseKey]) {
return 1 / this.config.fallbackRates[reverseKey];
}
// Basic fallback rates for common pairs
const defaultRates = {
'USD_EUR': 0.85,
'USD_GBP': 0.75,
'USD_JPY': 110,
'USD_CAD': 1.25,
'USD_AUD': 1.35,
'USD_CHF': 0.92,
'USD_CNY': 6.45,
'USD_SEK': 8.5,
'USD_NOK': 8.8,
'EUR_GBP': 0.88,
'EUR_JPY': 129,
};
if (defaultRates[key]) {
return defaultRates[key];
}
const reverseDefault = defaultRates[reverseKey];
if (reverseDefault) {
return 1 / reverseDefault;
}
throw new Error(`No fallback rate available for ${fromCurrency} to ${toCurrency}`);
}
async fetchExchangeRate(fromCurrency, toCurrency, timeoutMs) {
const errors = [];
const dataSources = (this.config.dataSources && this.config.dataSources.length > 0)
? this.config.dataSources
: ['ecb'];
if (dataSources.length === 0) {
const rate = this.getFallbackRate(fromCurrency, toCurrency);
return { rate, source: 'fallback' };
}
for (const source of dataSources) {
try {
let rate;
switch (source) {
case 'ecb':
rate = await this.fetchFromECB(fromCurrency, toCurrency, timeoutMs);
return { rate, source: 'ecb' };
case 'fed':
rate = await this.fetchFromFed(fromCurrency, toCurrency, timeoutMs);
return { rate, source: 'fed' };
default:
throw new Error(`Unknown data source: ${source}`);
}
}
catch (error) {
errors.push(`${source}: ${error}`);
continue;
}
}
// All sources failed, try fallback
try {
const rate = this.getFallbackRate(fromCurrency, toCurrency);
return { rate, source: 'fallback' };
}
catch (fallbackError) {
throw new Error(`All sources failed. Errors: ${errors.join(', ')}. Fallback: ${fallbackError}`);
}
}
async fetchWithTimeout(url, timeoutMs, init = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { ...init, signal: controller.signal });
return response;
}
catch (error) {
if (error.name === 'AbortError') {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw error;
}
finally {
clearTimeout(timer);
}
}
async getExchangeRate(fromCurrency, toCurrency, options = {}) {
const mode = options.mode ?? this.defaultMode;
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
if (fromCurrency === toCurrency) {
const entry = {
rate: 1,
timestamp: Date.now(),
source: 'cache'
};
return this.toExchangeRateResult(fromCurrency, toCurrency, entry, 'cache', false);
}
const cached = await this.getCachedEntry(fromCurrency, toCurrency);
const hasCache = Boolean(cached);
const isExpired = cached ? this.isExpired(cached) : true;
if (mode === OfflineMode.STRICT_OFFLINE) {
if (!cached) {
throw new Error(`No cached rate available for ${fromCurrency}→${toCurrency} in STRICT_OFFLINE mode.`);
}
return this.toExchangeRateResult(fromCurrency, toCurrency, cached, 'cache', true);
}
if (mode === OfflineMode.CACHE_FIRST) {
if (cached && !isExpired) {
return this.toExchangeRateResult(fromCurrency, toCurrency, cached, 'cache', false);
}
try {
return await this.fetchAndStore(fromCurrency, toCurrency, timeoutMs);
}
catch (error) {
if (cached) {
console.warn(`Using stale FX rate for ${fromCurrency}/${toCurrency}: ${error}`);
return this.toExchangeRateResult(fromCurrency, toCurrency, cached, 'cache', true);
}
throw error;
}
}
if (mode === OfflineMode.NETWORK_FIRST) {
try {
return await this.fetchAndStore(fromCurrency, toCurrency, timeoutMs);
}
catch (error) {
if (cached) {
console.warn(`Network fetch failed for ${fromCurrency}/${toCurrency}. Falling back to cache: ${error}`);
return this.toExchangeRateResult(fromCurrency, toCurrency, cached, 'cache', isExpired);
}
throw error;
}
}
if (mode === OfflineMode.FORCE_NETWORK) {
try {
return await this.fetchAndStore(fromCurrency, toCurrency, timeoutMs);
}
catch (error) {
if (hasCache) {
console.warn(`FORCE_NETWORK failed for ${fromCurrency}/${toCurrency}. Returning cached rate: ${error}`);
return this.toExchangeRateResult(fromCurrency, toCurrency, cached, 'cache', true);
}
throw error;
}
}
// Fallback to cache-first behaviour if mode is unrecognized
if (cached && !isExpired) {
return this.toExchangeRateResult(fromCurrency, toCurrency, cached, 'cache', false);
}
return this.fetchAndStore(fromCurrency, toCurrency, timeoutMs);
}
async preloadRates(currencyPairs, options = {}) {
const mode = options.mode ?? OfflineMode.NETWORK_FIRST;
const promises = currencyPairs.map(pair => this.getExchangeRate(pair.from, pair.to, { mode }).catch(err => console.warn(`Failed to preload ${pair.from}/${pair.to}: ${err}`)));
await Promise.all(promises);
}
getCacheStats() {
const entries = Array.from(this.memoryCache.values());
if (entries.length === 0) {
return {
size: 0,
hitRate: 0,
oldestEntry: null,
newestEntry: null
};
}
const timestamps = entries.map(e => e.timestamp);
const oldest = Math.min(...timestamps);
const newest = Math.max(...timestamps);
return {
size: entries.length,
hitRate: 0, // Would need to track hits/misses for accurate calculation
oldestEntry: new Date(oldest),
newestEntry: new Date(newest)
};
}
clearCache() {
this.memoryCache.clear();
this.lastFetchTime.clear();
}
getSupportedCurrencies() {
return [
'USD', 'EUR', 'GBP', 'JPY', 'CAD', 'AUD', 'CHF', 'CNY', 'NZD',
'SEK', 'NOK', 'DKK', 'PLN', 'CZK', 'HUF', 'RON', 'BGN', 'HRK',
'RUB', 'TRY', 'BRL', 'MXN', 'SGD', 'HKD', 'ZAR', 'KRW', 'THB',
'MYR', 'IDR', 'PHP', 'INR', 'AED', 'SAR', 'QAR', 'KWD', 'BHD',
'OMR', 'ILS', 'ARS', 'CLP', 'PEN', 'COP', 'VND', 'NGN', 'GHS',
'KES', 'UGX', 'TZS', 'GEL', 'UAH', 'KZT', 'BTC'
];
}
async warmupCache(baseCurrency = 'USD') {
const currencies = this.getSupportedCurrencies().filter(c => c !== baseCurrency);
const pairs = currencies.map(to => ({ from: baseCurrency, to }));
await this.preloadRates(pairs);
}
}
exports.FXCache = FXCache;
//# sourceMappingURL=fx-cache.js.map