@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,293 @@
1
+ /*
2
+ CacheManager - Orchestrates all cache instances
3
+
4
+ Central coordinator for all cache types (balance, price, transaction).
5
+ Manages cache lifecycle, health monitoring, and worker coordination.
6
+ */
7
+
8
+ import { BalanceCache } from '../stores/balance-cache';
9
+ import { PriceCache } from '../stores/price-cache';
10
+ import { TransactionCache } from '../stores/transaction-cache';
11
+ import { RefreshWorker, startUnifiedWorker } from '../workers/refresh-worker';
12
+ import type { BaseCache } from './base-cache';
13
+ import type { HealthCheckResult } from '../types';
14
+
15
+ const log = require('@pioneer-platform/loggerdog')();
16
+ const TAG = ' | CacheManager | ';
17
+
18
+ /**
19
+ * Configuration for CacheManager
20
+ */
21
+ export interface CacheManagerConfig {
22
+ redis: any;
23
+ balanceModule?: any; // Optional: if not provided, balance cache won't be initialized
24
+ markets?: any; // Optional: if not provided, price cache won't be initialized
25
+ enableBalanceCache?: boolean;
26
+ enablePriceCache?: boolean;
27
+ enableTransactionCache?: boolean;
28
+ startWorkers?: boolean; // Auto-start workers on initialization
29
+ }
30
+
31
+ /**
32
+ * CacheManager - Central coordinator for all caches
33
+ */
34
+ export class CacheManager {
35
+ private redis: any;
36
+ private balanceCache?: BalanceCache;
37
+ private priceCache?: PriceCache;
38
+ private transactionCache?: TransactionCache;
39
+ private workers: RefreshWorker[] = [];
40
+
41
+ constructor(config: CacheManagerConfig) {
42
+ this.redis = config.redis;
43
+
44
+ // Initialize Balance Cache
45
+ if (config.enableBalanceCache !== false && config.balanceModule) {
46
+ this.balanceCache = new BalanceCache(this.redis, config.balanceModule);
47
+ log.info(TAG, '✅ Balance cache initialized');
48
+ }
49
+
50
+ // Initialize Price Cache
51
+ if (config.enablePriceCache !== false && config.markets) {
52
+ this.priceCache = new PriceCache(this.redis, config.markets);
53
+ log.info(TAG, '✅ Price cache initialized');
54
+ }
55
+
56
+ // Initialize Transaction Cache
57
+ if (config.enableTransactionCache !== false) {
58
+ this.transactionCache = new TransactionCache(this.redis);
59
+ log.info(TAG, '✅ Transaction cache initialized');
60
+ }
61
+
62
+ // Auto-start workers if requested
63
+ if (config.startWorkers) {
64
+ setImmediate(() => {
65
+ this.startWorkers();
66
+ });
67
+ }
68
+
69
+ log.info(TAG, '✅ CacheManager initialized');
70
+ }
71
+
72
+ /**
73
+ * Start background refresh workers for all caches
74
+ */
75
+ async startWorkers(): Promise<void> {
76
+ const tag = TAG + 'startWorkers | ';
77
+
78
+ try {
79
+ log.info(tag, 'Starting refresh workers...');
80
+
81
+ // Create cache registry for workers
82
+ const cacheRegistry = new Map<string, BaseCache<any>>();
83
+
84
+ if (this.balanceCache) {
85
+ cacheRegistry.set('balance', this.balanceCache);
86
+ }
87
+
88
+ if (this.priceCache) {
89
+ cacheRegistry.set('price', this.priceCache);
90
+ }
91
+
92
+ // Start unified worker if we have any caches with queues
93
+ if (cacheRegistry.size > 0) {
94
+ const worker = await startUnifiedWorker(
95
+ this.redis,
96
+ cacheRegistry,
97
+ 'cache-refresh', // Unified queue name
98
+ {
99
+ maxRetries: 3,
100
+ retryDelay: 5000,
101
+ pollInterval: 100
102
+ }
103
+ );
104
+
105
+ this.workers.push(worker);
106
+ log.info(tag, '✅ Refresh worker started for all caches');
107
+ }
108
+
109
+ } catch (error) {
110
+ log.error(tag, '❌ Failed to start workers:', error);
111
+ throw error;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Stop all workers gracefully
117
+ */
118
+ async stopWorkers(): Promise<void> {
119
+ const tag = TAG + 'stopWorkers | ';
120
+
121
+ try {
122
+ log.info(tag, 'Stopping all workers...');
123
+
124
+ for (const worker of this.workers) {
125
+ await worker.stop();
126
+ }
127
+
128
+ this.workers = [];
129
+ log.info(tag, '✅ All workers stopped');
130
+
131
+ } catch (error) {
132
+ log.error(tag, 'Error stopping workers:', error);
133
+ throw error;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Get aggregate health status for all caches
139
+ * @param forceRefresh - Force refresh stats (bypasses 30s cache)
140
+ */
141
+ async getHealth(forceRefresh: boolean = false): Promise<HealthCheckResult & { checks?: any }> {
142
+ const tag = TAG + 'getHealth | ';
143
+
144
+ try {
145
+ const checks: any = {};
146
+ const issues: string[] = [];
147
+ const warnings: string[] = [];
148
+
149
+ // Check balance cache
150
+ if (this.balanceCache) {
151
+ const balanceHealth = await this.balanceCache.getHealth(forceRefresh);
152
+ checks.balance = balanceHealth;
153
+
154
+ if (balanceHealth.status === 'unhealthy') {
155
+ issues.push(...balanceHealth.issues.map(i => `Balance: ${i}`));
156
+ } else if (balanceHealth.status === 'degraded') {
157
+ warnings.push(...balanceHealth.warnings.map(w => `Balance: ${w}`));
158
+ }
159
+ }
160
+
161
+ // Check price cache
162
+ if (this.priceCache) {
163
+ const priceHealth = await this.priceCache.getHealth(forceRefresh);
164
+ checks.price = priceHealth;
165
+
166
+ if (priceHealth.status === 'unhealthy') {
167
+ issues.push(...priceHealth.issues.map(i => `Price: ${i}`));
168
+ } else if (priceHealth.status === 'degraded') {
169
+ warnings.push(...priceHealth.warnings.map(w => `Price: ${w}`));
170
+ }
171
+ }
172
+
173
+ // Check transaction cache (simple stats check)
174
+ if (this.transactionCache) {
175
+ const txStats = await this.transactionCache.getStats();
176
+ checks.transaction = {
177
+ status: 'healthy',
178
+ stats: txStats
179
+ };
180
+ }
181
+
182
+ // Check workers
183
+ for (const worker of this.workers) {
184
+ const workerStats = await worker.getStats();
185
+ checks.worker = workerStats;
186
+
187
+ if (!workerStats.isRunning) {
188
+ issues.push('Refresh worker not running');
189
+ }
190
+ }
191
+
192
+ // Determine overall status
193
+ let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
194
+ if (issues.length > 0) {
195
+ status = 'unhealthy';
196
+ } else if (warnings.length > 0) {
197
+ status = 'degraded';
198
+ }
199
+
200
+ return {
201
+ status,
202
+ queueInitialized: this.workers.length > 0,
203
+ redisConnected: true,
204
+ stats: {
205
+ totalEntries: 0,
206
+ staleEntries: 0,
207
+ freshEntries: 0,
208
+ stalenessRate: '0%'
209
+ },
210
+ issues,
211
+ warnings,
212
+ timestamp: Date.now(),
213
+ timestampISO: new Date().toISOString(),
214
+ checks
215
+ };
216
+
217
+ } catch (error) {
218
+ log.error(tag, 'Health check failed:', error);
219
+ return {
220
+ status: 'unhealthy',
221
+ queueInitialized: false,
222
+ redisConnected: false,
223
+ stats: {
224
+ totalEntries: 0,
225
+ staleEntries: 0,
226
+ freshEntries: 0,
227
+ stalenessRate: '0%'
228
+ },
229
+ issues: [`Health check error: ${error instanceof Error ? error.message : String(error)}`],
230
+ warnings: [],
231
+ timestamp: Date.now(),
232
+ timestampISO: new Date().toISOString()
233
+ };
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Get all cache instances
239
+ */
240
+ getCaches() {
241
+ return {
242
+ balance: this.balanceCache,
243
+ price: this.priceCache,
244
+ transaction: this.transactionCache
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Get specific cache by name
250
+ */
251
+ getCache(name: 'balance' | 'price' | 'transaction') {
252
+ switch (name) {
253
+ case 'balance':
254
+ return this.balanceCache;
255
+ case 'price':
256
+ return this.priceCache;
257
+ case 'transaction':
258
+ return this.transactionCache;
259
+ default:
260
+ return undefined;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Clear all caches (use with caution!)
266
+ */
267
+ async clearAll(): Promise<{ balance?: number; price?: number; transaction?: number }> {
268
+ const tag = TAG + 'clearAll | ';
269
+
270
+ try {
271
+ const result: any = {};
272
+
273
+ if (this.balanceCache) {
274
+ result.balance = await this.balanceCache.clearAll();
275
+ }
276
+
277
+ if (this.priceCache) {
278
+ result.price = await this.priceCache.clearAll();
279
+ }
280
+
281
+ if (this.transactionCache) {
282
+ result.transaction = await this.transactionCache.clearAll();
283
+ }
284
+
285
+ log.info(tag, 'Cleared all caches:', result);
286
+ return result;
287
+
288
+ } catch (error) {
289
+ log.error(tag, 'Error clearing caches:', error);
290
+ throw error;
291
+ }
292
+ }
293
+ }
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ /*
2
+ @pioneer-platform/pioneer-cache
3
+
4
+ Unified caching system for Pioneer Platform.
5
+ Provides stale-while-revalidate caching with TTL, background refresh, and health monitoring.
6
+ */
7
+
8
+ // Core exports
9
+ export { BaseCache } from './core/base-cache';
10
+ export { CacheManager } from './core/cache-manager';
11
+
12
+ // Cache implementations
13
+ export { BalanceCache } from './stores/balance-cache';
14
+ export { PriceCache } from './stores/price-cache';
15
+ export { TransactionCache } from './stores/transaction-cache';
16
+
17
+ // Worker exports
18
+ export { RefreshWorker, startUnifiedWorker } from './workers/refresh-worker';
19
+
20
+ // Type exports
21
+ export type {
22
+ CacheConfig,
23
+ CachedValue,
24
+ CacheResult,
25
+ RefreshJob,
26
+ HealthCheckResult,
27
+ CacheStats
28
+ } from './types';
29
+
30
+ // Data type exports
31
+ export type { BalanceData } from './stores/balance-cache';
32
+ export type { PriceData } from './stores/price-cache';
33
+
34
+ // Config type export
35
+ export type { CacheManagerConfig } from './core/cache-manager';
36
+ export type { WorkerConfig } from './workers/refresh-worker';
@@ -0,0 +1,196 @@
1
+ /*
2
+ BalanceCache - Balance-specific cache implementation
3
+
4
+ Extends BaseCache with balance-specific logic.
5
+ All common logic is inherited from BaseCache.
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
+ * Balance data structure
15
+ */
16
+ export interface BalanceData {
17
+ caip: string;
18
+ pubkey: string;
19
+ balance: string;
20
+ priceUsd?: string;
21
+ valueUsd?: string;
22
+ symbol?: string;
23
+ name?: string;
24
+ networkId?: string;
25
+ }
26
+
27
+ /**
28
+ * BalanceCache - Caches blockchain balance data
29
+ */
30
+ export class BalanceCache extends BaseCache<BalanceData> {
31
+ private balanceModule: any;
32
+
33
+ constructor(redis: any, balanceModule: any, config?: Partial<CacheConfig>) {
34
+ const defaultConfig: CacheConfig = {
35
+ name: 'balance',
36
+ keyPrefix: 'balance_v2:',
37
+ ttl: 5 * 60 * 1000, // 5 minutes
38
+ staleThreshold: 2 * 60 * 1000, // 2 minutes
39
+ enableTTL: true,
40
+ queueName: 'balance-refresh-v2',
41
+ enableQueue: true,
42
+ maxRetries: 3,
43
+ retryDelay: 10000,
44
+ blockOnMiss: true, // Wait for fresh data on first request
45
+ enableLegacyFallback: true,
46
+ defaultValue: {
47
+ caip: '',
48
+ pubkey: '',
49
+ balance: '0',
50
+ },
51
+ maxConcurrentJobs: 10,
52
+ apiTimeout: 15000,
53
+ logCacheHits: false,
54
+ logCacheMisses: true,
55
+ logRefreshJobs: true
56
+ };
57
+
58
+ super(redis, { ...defaultConfig, ...config });
59
+ this.balanceModule = balanceModule;
60
+ }
61
+
62
+ /**
63
+ * Build Redis key for balance data
64
+ * Format: balance_v2:caip:pubkey
65
+ */
66
+ protected buildKey(params: Record<string, any>): string {
67
+ const { caip, pubkey } = params;
68
+ if (!caip || !pubkey) {
69
+ throw new Error('BalanceCache.buildKey: caip and pubkey required');
70
+ }
71
+
72
+ const normalizedCaip = caip.toLowerCase();
73
+ // Don't lowercase pubkeys - xpub/ypub/zpub are case-sensitive base58
74
+ const normalizedPubkey = pubkey;
75
+
76
+ return `${this.config.keyPrefix}${normalizedCaip}:${normalizedPubkey}`;
77
+ }
78
+
79
+ /**
80
+ * Fetch balance from blockchain via balance module
81
+ */
82
+ protected async fetchFromSource(params: Record<string, any>): Promise<BalanceData> {
83
+ const tag = this.TAG + 'fetchFromSource | ';
84
+
85
+ try {
86
+ const { caip, pubkey } = params;
87
+
88
+ // Fetch balance using balance module
89
+ const asset = { caip };
90
+ const owner = { pubkey };
91
+ const balanceInfo = await this.balanceModule.getBalance(asset, owner);
92
+
93
+ if (!balanceInfo || !balanceInfo.balance) {
94
+ log.warn(tag, `No balance returned for ${caip}/${pubkey.substring(0, 10)}...`);
95
+ return {
96
+ caip,
97
+ pubkey,
98
+ balance: '0'
99
+ };
100
+ }
101
+
102
+ // Get asset metadata
103
+ const assetData = require('@pioneer-platform/pioneer-discovery').assetData;
104
+ const { caipToNetworkId } = require('@pioneer-platform/pioneer-caip');
105
+
106
+ const assetInfo = assetData[caip.toUpperCase()] || assetData[caip.toLowerCase()] || {};
107
+ const networkId = caipToNetworkId(caip);
108
+
109
+ return {
110
+ caip,
111
+ pubkey,
112
+ balance: balanceInfo.balance,
113
+ symbol: assetInfo.symbol,
114
+ name: assetInfo.name,
115
+ networkId
116
+ };
117
+
118
+ } catch (error) {
119
+ log.error(tag, 'Error fetching balance:', error);
120
+ throw error;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Try to get balance from legacy cache format
126
+ */
127
+ protected async getLegacyCached(params: Record<string, any>): Promise<BalanceData | null> {
128
+ const tag = this.TAG + 'getLegacyCached | ';
129
+
130
+ try {
131
+ const { caip, pubkey } = params;
132
+ const { caipToNetworkId } = require('@pioneer-platform/pioneer-caip');
133
+ const networkId = caipToNetworkId(caip);
134
+
135
+ // Try legacy format: cache:balance:pubkey:asset
136
+ const legacyKey = `cache:balance:${pubkey}:${networkId}`;
137
+ const legacyData = await this.redis.get(legacyKey);
138
+
139
+ if (legacyData) {
140
+ const parsed = JSON.parse(legacyData);
141
+ if (parsed.balance !== undefined) {
142
+ return {
143
+ caip,
144
+ pubkey,
145
+ balance: typeof parsed.balance === 'string' ? parsed.balance : String(parsed.balance)
146
+ };
147
+ }
148
+ }
149
+
150
+ return null;
151
+
152
+ } catch (error) {
153
+ log.error(tag, 'Error getting legacy cached balance:', error);
154
+ return null;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Get balance for a specific asset and pubkey
160
+ * Convenience method that wraps base get()
161
+ */
162
+ async getBalance(caip: string, pubkey: string, waitForFresh?: boolean): Promise<BalanceData> {
163
+ const result = await this.get({ caip, pubkey }, waitForFresh);
164
+ return result.value || this.config.defaultValue;
165
+ }
166
+
167
+ /**
168
+ * Get balances for multiple assets (batch operation)
169
+ */
170
+ async getBatchBalances(items: Array<{ caip: string; pubkey: string }>, waitForFresh?: boolean): Promise<BalanceData[]> {
171
+ const tag = this.TAG + 'getBatchBalances | ';
172
+ const startTime = Date.now();
173
+
174
+ try {
175
+ log.info(tag, `Batch request for ${items.length} balances`);
176
+
177
+ // Get all balances in parallel
178
+ const promises = items.map(item => this.getBalance(item.caip, item.pubkey, waitForFresh));
179
+ const results = await Promise.all(promises);
180
+
181
+ const responseTime = Date.now() - startTime;
182
+ log.info(tag, `Batch completed: ${results.length} balances in ${responseTime}ms (${(responseTime / results.length).toFixed(1)}ms avg)`);
183
+
184
+ return results;
185
+
186
+ } catch (error) {
187
+ log.error(tag, 'Error in batch balance request:', error);
188
+ // Return defaults for all items
189
+ return items.map(item => ({
190
+ caip: item.caip,
191
+ pubkey: item.pubkey,
192
+ balance: '0'
193
+ }));
194
+ }
195
+ }
196
+ }