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
JavaScript
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