@mixxtor/currencyx-adonisjs
Version:
AdonisJS integration for CurrencyX.js with database exchange provider and cache support
362 lines (361 loc) • 13.2 kB
JavaScript
import { BaseCurrencyExchange } from '@mixxtor/currencyx-js';
export class DatabaseExchange extends BaseCurrencyExchange {
name = 'database';
model;
columns;
configModel;
cache;
cacheSetupPromise;
config;
#defaultCacheTTL = '1h'; // in milliseconds or human-readable string (e.g., '1d')
#defaultCacheKeyPrefix = 'currency';
constructor(config) {
super();
this.config = config;
this.columns = {
code: config.columns?.code || 'code',
rate: config.columns?.rate || 'exchange_rate',
created_at: config.columns?.created_at || 'created_at',
updated_at: config.columns?.updated_at || 'updated_at',
...config.columns,
};
this.base = config.base || 'USD';
this.configModel = config.model;
// Validate configuration
this.#validateConfig();
}
/**
* Validate configuration to prevent runtime errors
*/
#validateConfig() {
if (!this.configModel) {
throw new Error('Currency model configuration is required');
}
const cacheConfig = this.config.cache;
if (cacheConfig !== false && cacheConfig && !cacheConfig.service) {
throw new Error('Cache service configuration is required when cache is enabled');
}
// Validate base currency format
if (this.base && !/^[A-Z]{3}$/.test(this.base)) {
console.warn(`Base currency '${this.base}' should be a 3-letter ISO currency code`);
}
}
/**
* Imports the model from the provider, returns and caches it
* for further operations.
*/
async getModel() {
if (!this.configModel) {
throw new Error('Currency model not configured');
}
if (this.model && !('hot' in import.meta)) {
return this.model;
}
const importedModel = await this.configModel();
this.model = 'default' in importedModel ? importedModel.default : importedModel;
return this.model;
}
/**
* Imports the cache service from the provider, returns and caches it
* for further operations.
*/
async getCacheService() {
const cacheConfig = this.config.cache;
if (cacheConfig === false || !cacheConfig || !cacheConfig.service) {
throw new Error('Currency cache not configured');
}
if (this.cache && !('hot' in import.meta)) {
return this.cache;
}
const importedCache = await cacheConfig.service();
this.cache = 'default' in importedCache ? importedCache.default : importedCache;
return this.cache;
}
/**
* Setup cache based on configuration (lazy initialization)
*/
async #ensureCacheSetup() {
if (this.cacheSetupPromise) {
return this.cacheSetupPromise;
}
this.cacheSetupPromise = this.#setupCache().catch((error) => {
// Reset the promise on error so it can be retried
this.cacheSetupPromise = undefined;
throw error;
});
return this.cacheSetupPromise;
}
/**
* Setup cache based on configuration
*/
async #setupCache() {
const cacheConfig = this.config.cache;
if (cacheConfig === false || !cacheConfig) {
return;
}
try {
this.cache = await this.getCacheService();
}
catch (error) {
console.warn('Cache setup failed, continuing without cache:', error.message);
}
}
/**
* Convert currency using database rates
*/
async convert(params) {
// Input validation
const { amount, from, to } = params;
if (!amount || amount <= 0) {
return {
success: false,
query: { from, to, amount },
info: { timestamp: Date.now() },
date: new Date().toISOString(),
error: { info: 'Invalid amount: must be greater than 0' },
};
}
if (!from || !to) {
return {
success: false,
query: { from, to, amount },
info: { timestamp: Date.now() },
date: new Date().toISOString(),
error: { info: 'Invalid currency codes: from and to are required' },
};
}
const result = {
success: false,
query: { from, to, amount },
info: { timestamp: Date.now(), rate: 1 },
date: new Date().toISOString(),
result: amount,
};
// Same currency conversion
if (from === to) {
result.success = true;
return result;
}
try {
const currencies = await this.#getCurrenciesByCodes([from, to]);
const fromCurrency = currencies?.find((c) => this.#getCurrencyCode(c) === from);
const toCurrency = currencies?.find((c) => this.#getCurrencyCode(c) === to);
if (!fromCurrency || !toCurrency) {
return {
...result,
error: {
info: `Currency not found: ${!fromCurrency ? from : to}`,
},
};
}
const fromRate = this.#getCurrencyRate(fromCurrency);
const toRate = this.#getCurrencyRate(toCurrency);
const updatedAt = this.#getCurrencyUpdatedAt(fromCurrency) || this.#getCurrencyUpdatedAt(toCurrency);
if (!fromRate || !toRate) {
return {
...result,
error: {
info: 'Invalid exchange rates found in database',
},
};
}
// Conversion formula: amount * (1/fromCurrencyRate) * toCurrencyRate
const convertRate = (1 / fromRate) * toRate;
const convertAmount = amount * convertRate;
result.success = true;
result.info.rate = convertRate;
result.result = convertAmount;
if (updatedAt) {
const timestamp = new Date(updatedAt).getTime();
result.info.timestamp = timestamp;
result.date = new Date(updatedAt).toISOString();
}
return result;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown database error';
return {
success: false,
query: { from, to, amount },
info: { timestamp: Date.now() },
date: new Date().toISOString(),
error: {
info: errorMessage,
type: 'database_error',
},
};
}
}
async #currencyList(useCache = true) {
// Ensure cache is setup before using it
await this.#ensureCacheSetup();
const Model = await this.getModel();
const query = Model.query().select(Object.values(this.columns));
if (!useCache || !this.cache || !this.config.cache) {
return await query;
}
const { prefix = this.#defaultCacheKeyPrefix, ttl = this.#defaultCacheTTL } = this.config.cache;
return await this.cache.getOrSet({ key: prefix, factory: () => query, ttl });
}
/**
* Get specific currencies by codes (optimized for targeted queries)
*/
async #getCurrenciesByCodes(codes, useCache = true) {
if (!codes || codes.length === 0) {
return this.#currencyList(useCache);
}
// Ensure cache is setup before using it
await this.#ensureCacheSetup();
const Model = await this.getModel();
const query = Model.query()
.select(Object.values(this.columns))
.whereIn(this.columns.code, codes);
if (!useCache || !this.cache || !this.config.cache) {
return await query;
}
const { prefix = this.#defaultCacheKeyPrefix, ttl = this.#defaultCacheTTL } = this.config.cache;
const cacheKey = `${prefix}_${codes.sort().join('_')}`;
return await this.cache.getOrSet({ key: cacheKey, factory: () => query, ttl });
}
/**
* Helper method to get currency code from a record
*/
#getCurrencyCode(record) {
return record[this.columns.code];
}
/**
* Helper method to get currency rate from a record
*/
#getCurrencyRate(record) {
return record[this.columns.rate];
}
/**
* Helper method to get currency updated at from a record
*/
#getCurrencyUpdatedAt(record) {
const updatedAtColumn = this.columns.updated_at || 'updated_at';
return record[updatedAtColumn];
}
/**
* Get latest rates (required abstract method)
*/
async latestRates(params) {
const { base = this.base, codes: currencyCodes, cache = true } = params || {};
const result = {
success: false,
timestamp: new Date().getTime(),
date: new Date().toISOString(),
base: base,
rates: {},
error: undefined,
};
try {
const currencies = await this.#currencyList(cache);
if (!currencies || currencies.length === 0) {
result.error = {
info: 'No currencies found in database',
type: 'database_error',
};
return result;
}
let latestDate;
for (const record of currencies) {
const code = this.#getCurrencyCode(record);
const rate = this.#getCurrencyRate(record);
const updatedAt = this.#getCurrencyUpdatedAt(record);
if (!code || rate === undefined || rate === null) {
continue;
}
// Filter by currency codes if specified
if (!currencyCodes || currencyCodes.length === 0 || currencyCodes.includes(code)) {
result.rates[code] = rate;
// Track the latest update date
if (updatedAt) {
const updatedAtDate = new Date(updatedAt);
if (!latestDate || updatedAtDate > latestDate) {
latestDate = updatedAtDate;
}
}
}
}
// Update result with latest date if found
if (latestDate) {
result.date = latestDate.toISOString();
result.timestamp = latestDate.getTime();
}
result.success = Object.keys(result.rates).length > 0;
if (!result.success) {
result.error = {
info: currencyCodes?.length
? `No matching currencies found for codes: ${currencyCodes.join(', ')}`
: 'No valid currencies found in database',
type: 'database_error',
};
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown database error';
result.error = {
info: errorMessage,
type: 'database_error',
};
}
return result;
}
/**
* Clear the currency cache
*/
async clearCache() {
if (!this.cache || !this.config.cache) {
return;
}
const { prefix = this.#defaultCacheKeyPrefix } = this.config.cache;
await this.cache.delete({ key: prefix });
}
/**
* Refresh currency data from database
*/
async refreshCurrencyData() {
await this.clearCache();
// Pre-warm the cache
await this.#currencyList(true);
}
/**
* Get convert rate (required abstract method)
*/
async getConvertRate(from, to) {
try {
const currencies = await this.#getCurrenciesByCodes([from, to]);
const fromCurrency = currencies?.find((c) => this.#getCurrencyCode(c) === from);
const toCurrency = currencies?.find((c) => this.#getCurrencyCode(c) === to);
if (!fromCurrency || !toCurrency) {
return undefined;
}
const fromRate = this.#getCurrencyRate(fromCurrency);
const toRate = this.#getCurrencyRate(toCurrency);
if (fromRate && toRate && fromRate > 0 && toRate > 0) {
return (1 / fromRate) * toRate;
}
return undefined;
}
catch {
return undefined;
}
}
/**
* Cleanup method for graceful shutdown
*/
async cleanup() {
if (this.cacheSetupPromise) {
try {
await this.cacheSetupPromise;
}
catch {
// Ignore errors during cleanup
}
this.cacheSetupPromise = undefined;
}
this.cache = undefined;
this.model = undefined;
}
}