UNPKG

bktide

Version:

Command-line interface for Buildkite CI/CD workflows with rich shell completions (Fish, Bash, Zsh) and Alfred workflow integration for macOS power users

222 lines 7.77 kB
import nodePersist from 'node-persist'; import { createHash } from 'crypto'; import { logger } from './logger.js'; import { XDGPaths } from '../utils/xdgPaths.js'; /** * CacheManager for handling persistent caching with node-persist */ export class CacheManager { ttls; initialized = false; tokenHash = ''; debug = false; // Default TTLs in milliseconds static DEFAULT_TTLs = { viewer: 3600 * 1000, // 1 hour for viewer data organizations: 3600 * 1000, // 1 hour for organization data pipelines: 60 * 1000, // 1 minute for pipelines builds: 30 * 1000, // 30 seconds for builds default: 30 * 1000, // default 30 seconds }; constructor(ttls = {}, debug = false) { this.ttls = ttls; // Merge provided TTLs with defaults this.ttls = { ...CacheManager.DEFAULT_TTLs, ...ttls }; this.debug = debug; } /** * Initialize the storage */ async init() { if (this.initialized) return; if (this.debug) { logger.debug('CacheManager.init - starting initialization'); logger.debug('CacheManager.init - nodePersist:', { type: typeof nodePersist }); } const storageDir = XDGPaths.getAppCacheDir('bktide'); if (this.debug) { logger.debug('CacheManager.init - storageDir:', { storageDir }); } await nodePersist.init({ dir: storageDir, stringify: JSON.stringify, parse: JSON.parse, encoding: 'utf8', logging: false, forgiveParseErrors: true, ttl: this.ttls.default // Default TTL }); this.initialized = true; if (this.debug) { logger.debug('CacheManager.init - initialization complete'); } } /** * Set the token hash to invalidate caches when token changes */ async setTokenHash(token) { await this.init(); const hash = this.hashString(token); if (this.tokenHash !== hash) { // Get current stored token hash const storedHash = await nodePersist.getItem('token_hash'); // If token changed, clear viewer-related caches if (storedHash !== hash) { await this.invalidateType('viewer'); await nodePersist.setItem('token_hash', hash); } this.tokenHash = hash; } } /** * Generate a cache key for a GraphQL query */ generateCacheKey(query, variables) { // Extract operation name from query for better key readability const operationName = query.match(/query\s+(\w+)?/)?.[1] || 'UnnamedQuery'; const varsString = variables ? JSON.stringify(variables) : ''; return `${operationName}:${this.hashString(varsString)}`; } /** * Hash a string using SHA256 */ hashString(str) { return createHash('sha256').update(str).digest('hex'); } /** * Get cache type from query string */ getCacheTypeFromQuery(query) { if (query.includes('viewer') && !query.includes('builds')) { return 'viewer'; } else if (query.includes('organizations')) { return 'organizations'; } else if (query.includes('pipelines')) { return 'pipelines'; } else if (query.includes('builds')) { return 'builds'; } return 'default'; } /** * Get a value from cache */ async get(query, variables) { await this.init(); // Check if this is a direct key (used by REST client) or GraphQL query const key = query.startsWith('REST:') ? query : this.generateCacheKey(query, variables); try { // Add debug logging to see what's happening if (this.debug) { logger.debug('CacheManager.get - nodePersist:', { type: typeof nodePersist }); logger.debug('CacheManager.get - key:', { key }); } const entry = await nodePersist.getItem(key); if (!entry) return null; // Check if manually expired if (Date.now() > entry.expiresAt) { await nodePersist.removeItem(key); return null; } return entry.value; } catch (error) { if (this.debug) { logger.debug('CacheManager.get error:', { error }); } logger.debug(`Cache miss or error for key ${key}:`, error); return null; } } /** * Set a value in cache * @param query The GraphQL query or REST cache key * @param value The value to cache * @param variables Variables for GraphQL query or cache type for REST * @param skipCacheIfAuthError Whether to skip caching if this is an authentication error */ async set(query, value, variables, skipCacheIfAuthError = false) { await this.init(); let key; let cacheType; // Handle different usage patterns between GraphQL and REST clients if (query.startsWith('REST:')) { // REST client usage - variables is actually the cache type key = query; cacheType = variables || 'default'; } else { // GraphQL client usage key = this.generateCacheKey(query, variables); cacheType = this.getCacheTypeFromQuery(query); } // Skip caching if this is an authentication error and skipCacheIfAuthError is true if (skipCacheIfAuthError && this.isAuthenticationError(value)) { if (this.debug) { logger.debug(`Skipping cache for authentication error: ${key}`); } return; } const ttl = this.ttls[cacheType] || CacheManager.DEFAULT_TTLs.default; await nodePersist.setItem(key, { value, expiresAt: Date.now() + ttl, type: cacheType, createdAt: Date.now() }); } /** * Check if a result contains authentication error information */ isAuthenticationError(value) { if (!value) return false; // Check for common authentication error patterns in GraphQL responses if (value.errors) { return value.errors.some((err) => err.message?.includes('unauthorized') || err.message?.includes('authentication') || err.message?.includes('permission') || err.message?.includes('invalid token')); } // Check for REST API error responses if (value.message) { const message = value.message.toLowerCase(); return message.includes('unauthorized') || message.includes('authentication') || message.includes('permission') || message.includes('invalid token'); } return false; } /** * Invalidate all cache entries of a specific type */ async invalidateType(type) { await this.init(); const keys = await nodePersist.keys(); for (const key of keys) { try { const entry = await nodePersist.getItem(key); if (entry && entry.type === type) { await nodePersist.removeItem(key); } } catch (error) { // Continue to next item if there's an error } } } /** * Clear all cache entries */ async clear() { await this.init(); await nodePersist.clear(); } } //# sourceMappingURL=CacheManager.js.map