@pioneer-platform/pioneer-cache 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,215 @@
1
+ /*
2
+ PriceCache - Price-specific cache implementation
3
+
4
+ Extends BaseCache with price-specific logic.
5
+ All common logic is inherited from BaseCache (including all 5 fixes!)
6
+ */
7
+
8
+ import { BaseCache } from '../core/base-cache';
9
+ import type { CacheConfig } from '../types';
10
+
11
+ const log = require('@pioneer-platform/loggerdog')();
12
+
13
+ /**
14
+ * Price data structure
15
+ */
16
+ export interface PriceData {
17
+ caip: string;
18
+ price: number;
19
+ source?: string;
20
+ }
21
+
22
+ /**
23
+ * PriceCache - Caches USD prices for assets
24
+ */
25
+ export class PriceCache extends BaseCache<PriceData> {
26
+ private markets: any;
27
+
28
+ constructor(redis: any, markets: any, config?: Partial<CacheConfig>) {
29
+ const defaultConfig: CacheConfig = {
30
+ name: 'price',
31
+ keyPrefix: 'price_v2:',
32
+ ttl: 60 * 60 * 1000, // 1 hour
33
+ staleThreshold: 30 * 60 * 1000, // 30 minutes (refresh after 30min)
34
+ enableTTL: true,
35
+ queueName: 'price-refresh-v2',
36
+ enableQueue: true,
37
+ maxRetries: 3,
38
+ retryDelay: 5000,
39
+ blockOnMiss: false, // Return immediately with $0 (prices less critical)
40
+ enableLegacyFallback: true,
41
+ defaultValue: {
42
+ caip: '',
43
+ price: 0
44
+ },
45
+ maxConcurrentJobs: 5,
46
+ apiTimeout: 5000,
47
+ logCacheHits: false,
48
+ logCacheMisses: true,
49
+ logRefreshJobs: true
50
+ };
51
+
52
+ super(redis, { ...defaultConfig, ...config });
53
+ this.markets = markets;
54
+ }
55
+
56
+ /**
57
+ * Build Redis key for price data
58
+ * Format: price_v2:caip
59
+ */
60
+ protected buildKey(params: Record<string, any>): string {
61
+ const { caip } = params;
62
+ if (!caip) {
63
+ throw new Error('PriceCache.buildKey: caip required');
64
+ }
65
+
66
+ const normalizedCaip = caip.toLowerCase();
67
+ return `${this.config.keyPrefix}${normalizedCaip}`;
68
+ }
69
+
70
+ /**
71
+ * Fetch price from markets API
72
+ */
73
+ protected async fetchFromSource(params: Record<string, any>): Promise<PriceData> {
74
+ const tag = this.TAG + 'fetchFromSource | ';
75
+
76
+ try {
77
+ const { caip } = params;
78
+
79
+ // Map CAIP to symbol for markets API
80
+ const assetData = require('@pioneer-platform/pioneer-discovery').assetData;
81
+ const asset = assetData[caip.toUpperCase()] || assetData[caip.toLowerCase()];
82
+
83
+ if (!asset || !asset.symbol) {
84
+ log.warn(tag, `No asset mapping found for ${caip}`);
85
+ return {
86
+ caip,
87
+ price: 0
88
+ };
89
+ }
90
+
91
+ // Get price from markets module
92
+ const symbol = asset.symbol.toLowerCase();
93
+ const priceResult = await this.markets.getPrice(symbol);
94
+
95
+ // Handle different response formats
96
+ let price = 0;
97
+ if (typeof priceResult === 'object' && priceResult.price) {
98
+ price = parseFloat(priceResult.price);
99
+ } else if (typeof priceResult === 'number') {
100
+ price = priceResult;
101
+ }
102
+
103
+ if (isNaN(price) || price <= 0) {
104
+ log.warn(tag, `Invalid price for ${caip} (${symbol}): ${priceResult}`);
105
+ return {
106
+ caip,
107
+ price: 0
108
+ };
109
+ }
110
+
111
+ log.debug(tag, `Fetched price for ${caip} (${symbol}): $${price}`);
112
+
113
+ return {
114
+ caip,
115
+ price,
116
+ source: 'markets'
117
+ };
118
+
119
+ } catch (error) {
120
+ log.error(tag, 'Error fetching price:', error);
121
+ throw error;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Try to get price from legacy cache formats
127
+ */
128
+ protected async getLegacyCached(params: Record<string, any>): Promise<PriceData | null> {
129
+ const tag = this.TAG + 'getLegacyCached | ';
130
+
131
+ try {
132
+ const { caip } = params;
133
+
134
+ // Try CoinGecko format first (more accurate)
135
+ const coingeckoKey = `coingecko:${caip}`;
136
+ const coingeckoData = await this.redis.get(coingeckoKey);
137
+
138
+ if (coingeckoData) {
139
+ const parsed = JSON.parse(coingeckoData);
140
+ if (parsed.current_price && typeof parsed.current_price === 'number') {
141
+ return {
142
+ caip,
143
+ price: parsed.current_price,
144
+ source: 'coingecko-legacy'
145
+ };
146
+ }
147
+ }
148
+
149
+ // Try CoinCap format
150
+ const coincapKey = `coincap:${caip}`;
151
+ const coincapData = await this.redis.get(coincapKey);
152
+
153
+ if (coincapData) {
154
+ const parsed = JSON.parse(coincapData);
155
+ if (parsed.priceUsd && typeof parsed.priceUsd === 'string') {
156
+ const price = parseFloat(parsed.priceUsd);
157
+ if (!isNaN(price)) {
158
+ return {
159
+ caip,
160
+ price,
161
+ source: 'coincap-legacy'
162
+ };
163
+ }
164
+ }
165
+ }
166
+
167
+ return null;
168
+
169
+ } catch (error) {
170
+ log.error(tag, 'Error getting legacy cached price:', error);
171
+ return null;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Get price for a specific asset
177
+ * Convenience method that wraps base get()
178
+ */
179
+ async getPrice(caip: string, waitForFresh?: boolean): Promise<number> {
180
+ const result = await this.get({ caip }, waitForFresh);
181
+ return result.value?.price || 0;
182
+ }
183
+
184
+ /**
185
+ * Get prices for multiple assets (batch operation)
186
+ */
187
+ async getBatchPrices(caips: string[], waitForFresh?: boolean): Promise<Map<string, number>> {
188
+ const tag = this.TAG + 'getBatchPrices | ';
189
+ const startTime = Date.now();
190
+
191
+ try {
192
+ log.info(tag, `Batch request for ${caips.length} prices`);
193
+
194
+ // Get all prices in parallel
195
+ const promises = caips.map(caip => this.getPrice(caip, waitForFresh));
196
+ const results = await Promise.all(promises);
197
+
198
+ // Build map of caip -> price
199
+ const priceMap = new Map<string, number>();
200
+ caips.forEach((caip, index) => {
201
+ priceMap.set(caip, results[index]);
202
+ });
203
+
204
+ const responseTime = Date.now() - startTime;
205
+ log.info(tag, `Batch completed: ${results.length} prices in ${responseTime}ms (${(responseTime / results.length).toFixed(1)}ms avg)`);
206
+
207
+ return priceMap;
208
+
209
+ } catch (error) {
210
+ log.error(tag, 'Error in batch price request:', error);
211
+ // Return empty map
212
+ return new Map();
213
+ }
214
+ }
215
+ }
@@ -0,0 +1,172 @@
1
+ /*
2
+ TransactionCache - Transaction-specific cache implementation
3
+
4
+ Different pattern from Balance/Price caches:
5
+ - Caches transaction data PERMANENTLY (no TTL)
6
+ - No background refresh (blockchain data is immutable)
7
+ - Simple cache-aside pattern with getOrFetch()
8
+ - No workers needed
9
+
10
+ Does NOT extend BaseCache - intentionally different architecture.
11
+ */
12
+
13
+ const log = require('@pioneer-platform/loggerdog')();
14
+
15
+ /**
16
+ * TransactionCache - Caches immutable blockchain transaction data
17
+ */
18
+ export class TransactionCache {
19
+ private redis: any;
20
+ private readonly keyPrefix = 'tx:';
21
+ private readonly TAG = ' | TransactionCache | ';
22
+
23
+ constructor(redis: any) {
24
+ this.redis = redis;
25
+ log.info(this.TAG, 'TransactionCache initialized (permanent caching, no TTL)');
26
+ }
27
+
28
+ /**
29
+ * Get cached transaction data by txid
30
+ */
31
+ async get(txid: string): Promise<any | null> {
32
+ const tag = this.TAG + 'get | ';
33
+
34
+ try {
35
+ const key = this.buildKey(txid);
36
+ const cached = await this.redis.get(key);
37
+
38
+ if (cached) {
39
+ log.info(tag, `Cache HIT for txid: ${txid.substring(0, 16)}...`);
40
+ return JSON.parse(cached);
41
+ }
42
+
43
+ log.info(tag, `Cache MISS for txid: ${txid.substring(0, 16)}...`);
44
+ return null;
45
+
46
+ } catch (error) {
47
+ log.error(tag, `Error getting cached tx ${txid}:`, error);
48
+ return null; // Fail gracefully
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Cache transaction data PERMANENTLY (no expiry)
54
+ * Transactions are immutable - once on the blockchain, they never change
55
+ */
56
+ async set(txid: string, txData: any): Promise<void> {
57
+ const tag = this.TAG + 'set | ';
58
+
59
+ try {
60
+ const key = this.buildKey(txid);
61
+
62
+ // NO TTL - cache permanently
63
+ await this.redis.set(key, JSON.stringify(txData));
64
+
65
+ log.info(tag, `Cached txid: ${txid.substring(0, 16)}... (PERMANENT - no expiry)`);
66
+
67
+ } catch (error) {
68
+ log.error(tag, `Error caching tx ${txid}:`, error);
69
+ // Don't throw - caching is optional
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Get transaction from cache or fetch from source and cache it
75
+ * Classic cache-aside pattern
76
+ */
77
+ async getOrFetch(txid: string, fetchFn: () => Promise<any>): Promise<any> {
78
+ const tag = this.TAG + 'getOrFetch | ';
79
+
80
+ // Try cache first
81
+ const cached = await this.get(txid);
82
+ if (cached) {
83
+ return cached;
84
+ }
85
+
86
+ // Not in cache - fetch from source
87
+ log.info(tag, `Fetching from source: ${txid.substring(0, 16)}...`);
88
+ const txData = await fetchFn();
89
+
90
+ // Cache the result
91
+ await this.set(txid, txData);
92
+
93
+ return txData;
94
+ }
95
+
96
+ /**
97
+ * Invalidate cached transaction (useful for unconfirmed transactions)
98
+ */
99
+ async invalidate(txid: string): Promise<void> {
100
+ const tag = this.TAG + 'invalidate | ';
101
+
102
+ try {
103
+ const key = this.buildKey(txid);
104
+ await this.redis.del(key);
105
+ log.info(tag, `Invalidated cache for txid: ${txid.substring(0, 16)}...`);
106
+
107
+ } catch (error) {
108
+ log.error(tag, `Error invalidating cached tx ${txid}:`, error);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Get cache statistics
114
+ */
115
+ async getStats(): Promise<{ totalKeys: number; memoryUsage: string }> {
116
+ const tag = this.TAG + 'getStats | ';
117
+
118
+ try {
119
+ const keys = await this.redis.keys(`${this.keyPrefix}*`);
120
+ const totalKeys = keys.length;
121
+
122
+ // Get Redis memory info
123
+ const info = await this.redis.info('memory');
124
+ const memoryMatch = info.match(/used_memory_human:(.+)/);
125
+ const memoryUsage = memoryMatch ? memoryMatch[1].trim() : 'unknown';
126
+
127
+ log.info(tag, `Cache stats: ${totalKeys} keys, ${memoryUsage} memory`);
128
+
129
+ return {
130
+ totalKeys,
131
+ memoryUsage
132
+ };
133
+
134
+ } catch (error) {
135
+ log.error(tag, 'Error getting cache stats:', error);
136
+ return {
137
+ totalKeys: 0,
138
+ memoryUsage: 'unknown'
139
+ };
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Clear all cached transactions (use with caution)
145
+ */
146
+ async clearAll(): Promise<number> {
147
+ const tag = this.TAG + 'clearAll | ';
148
+
149
+ try {
150
+ const keys = await this.redis.keys(`${this.keyPrefix}*`);
151
+ if (keys.length === 0) {
152
+ log.info(tag, 'No cached transactions to clear');
153
+ return 0;
154
+ }
155
+
156
+ await this.redis.del(...keys);
157
+ log.info(tag, `Cleared ${keys.length} cached transactions`);
158
+ return keys.length;
159
+
160
+ } catch (error) {
161
+ log.error(tag, 'Error clearing cache:', error);
162
+ return 0;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Build cache key for a transaction
168
+ */
169
+ private buildKey(txid: string): string {
170
+ return `${this.keyPrefix}${txid}`;
171
+ }
172
+ }
@@ -0,0 +1,121 @@
1
+ /*
2
+ Pioneer Cache - Core Types and Interfaces
3
+ */
4
+
5
+ /**
6
+ * Cache configuration for a specific cache store
7
+ */
8
+ export interface CacheConfig {
9
+ // Cache identification
10
+ name: string; // e.g., 'balance', 'price', 'transaction'
11
+ keyPrefix: string; // Redis key prefix, e.g., 'balance_v2:'
12
+
13
+ // TTL configuration
14
+ ttl: number; // Time-to-live in milliseconds
15
+ staleThreshold?: number; // When to trigger background refresh (optional)
16
+ enableTTL: boolean; // Set to false for permanent caching (transactions)
17
+
18
+ // Queue configuration
19
+ queueName: string; // Redis queue name
20
+ enableQueue: boolean; // Set to false if no background refresh needed
21
+ maxRetries: number; // Max retry attempts for failed jobs
22
+ retryDelay: number; // Delay between retries in ms
23
+
24
+ // Cache behavior
25
+ blockOnMiss: boolean; // Wait for fresh data on cache miss
26
+ enableLegacyFallback: boolean; // Try legacy cache keys on miss
27
+ defaultValue: any; // Default value to return on error
28
+
29
+ // Performance
30
+ maxConcurrentJobs: number; // Max jobs processed concurrently
31
+ apiTimeout: number; // Timeout for source fetch operations
32
+
33
+ // Logging
34
+ logCacheHits: boolean;
35
+ logCacheMisses: boolean;
36
+ logRefreshJobs: boolean;
37
+ }
38
+
39
+ /**
40
+ * Generic cached value wrapper
41
+ */
42
+ export interface CachedValue<T> {
43
+ value: T;
44
+ timestamp: number;
45
+ source: string; // 'network', 'legacy', 'startup'
46
+ lastUpdated: string; // ISO timestamp
47
+ metadata?: Record<string, any>; // Additional metadata
48
+ }
49
+
50
+ /**
51
+ * Cache operation result
52
+ */
53
+ export interface CacheResult<T> {
54
+ success: boolean;
55
+ value?: T;
56
+ cached: boolean; // Was value served from cache?
57
+ fresh: boolean; // Is value fresh (not stale)?
58
+ age?: number; // Age of cached value in ms
59
+ error?: string;
60
+ }
61
+
62
+ /**
63
+ * Health check result
64
+ */
65
+ export interface HealthCheckResult {
66
+ status: 'healthy' | 'degraded' | 'unhealthy';
67
+ queueInitialized: boolean;
68
+ redisConnected: boolean;
69
+ stats: CacheStats;
70
+ issues: string[];
71
+ warnings: string[];
72
+ timestamp: number;
73
+ timestampISO: string;
74
+ }
75
+
76
+ /**
77
+ * Cache statistics
78
+ */
79
+ export interface CacheStats {
80
+ totalEntries: number;
81
+ staleEntries: number;
82
+ freshEntries: number;
83
+ stalenessRate: string; // Percentage string
84
+ sources?: Record<string, number>;
85
+ byNetwork?: Record<string, number>;
86
+ entriesWithoutTTL?: number;
87
+ ttl?: number; // Configured TTL
88
+ staleThreshold?: number; // Configured stale threshold
89
+ }
90
+
91
+ /**
92
+ * Refresh job for background workers
93
+ */
94
+ export interface RefreshJob {
95
+ type: string; // 'REFRESH_BALANCE', 'REFRESH_PRICE', etc.
96
+ key: string; // Cache key or identifier
97
+ params: Record<string, any>; // Job-specific parameters
98
+ priority?: 'high' | 'normal' | 'low';
99
+ retryCount?: number;
100
+ timestamp?: number;
101
+ }
102
+
103
+ /**
104
+ * Source fetcher function signature
105
+ * Returns the data to be cached
106
+ */
107
+ export type SourceFetcher<T> = (params: Record<string, any>) => Promise<T>;
108
+
109
+ /**
110
+ * Key builder function signature
111
+ * Builds Redis key from parameters
112
+ */
113
+ export type KeyBuilder = (...args: any[]) => string;
114
+
115
+ /**
116
+ * Legacy key pattern for migration
117
+ */
118
+ export interface LegacyKeyPattern {
119
+ prefix: string;
120
+ keyBuilder: KeyBuilder;
121
+ }