Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/workflows/commitlint.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: commitlint
on: pull_request
jobs:
commitlint:
uses: ScrawnDotDev/.github/.github/workflows/commitlint.yml@master
name: commitlint

on: pull_request

jobs:
commitlint:
uses: ScrawnDotDev/.github/.github/workflows/commitlint.yml@master
2 changes: 1 addition & 1 deletion src/__tests__/unit/utils/apiKeyCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe("apiKeyCache", () => {
expiresAt,
});

// Advance time by > 5 minutes TTL (cacheTTLMinutes = 5)
// Advance time by > 5 minutes TTL
const later = base + 6 * 60 * 1000;
(Date as any).now = () => later;

Expand Down
164 changes: 20 additions & 144 deletions src/utils/apiKeyCache.ts
Original file line number Diff line number Diff line change
@@ -1,152 +1,28 @@
// USING LOCAL CACHING JUST FOR NOW
import { logger } from "../errors/logger";
import { Cache } from "./cacheStore";

interface CachedAPIKey {
id: string;
expiresAt: string;
cachedAt: number;
lastAccessed: number;
}

class APIKeyCache {
private cache: Map<string, CachedAPIKey>; // Map<hash, CachedAPIKey>
private cacheTTL: number; // in milliseconds
private maxSize: number;

constructor(cacheTTLMinutes: number = 5, maxSize: number = 1000) {
this.cache = new Map();
this.cacheTTL = cacheTTLMinutes * 60 * 1000;
this.maxSize = maxSize;

// Run cleanup every minute to remove expired cache entries
setInterval(() => this.cleanup(), 60 * 1000);
}

/**
* Get API key data from cache if it exists and is not expired
* @param hash - The HMAC-SHA256 hash of the API key
*/
get(hash: string): CachedAPIKey | null {
const cached = this.cache.get(hash);

if (!cached) {
return null;
}

const now = Date.now();

// Check if cache entry has expired
if (now - cached.cachedAt > this.cacheTTL) {
this.cache.delete(hash);
return null;
}

// Check if API key itself has expired
const keyExpiresAt = new Date(cached.expiresAt).getTime();
if (now > keyExpiresAt) {
this.cache.delete(hash);
return null;
}

// Update last accessed time for LRU
cached.lastAccessed = now;

return cached;
}

/**
* Set API key data in cache
* @param hash - The HMAC-SHA256 hash of the API key
*/
set(hash: string, data: { id: string; expiresAt: string }): void {
// Check if cache is full and evict LRU entry if needed
if (this.cache.size >= this.maxSize && !this.cache.has(hash)) {
this.evictLRU();
}

const now = Date.now();
this.cache.set(hash, {
id: data.id,
expiresAt: data.expiresAt,
cachedAt: now,
lastAccessed: now,
});
}

/**
* Remove an API key from cache
* @param hash - The HMAC-SHA256 hash of the API key
*/
delete(hash: string): void {
this.cache.delete(hash);
}

/**
* Clear all cache entries
*/
clear(): void {
this.cache.clear();
}

/**
* Evict least recently used entry from cache
*/
private evictLRU(): void {
let oldestKey: string | null = null;
let oldestTime = Infinity;

for (const [key, value] of this.cache.entries()) {
if (value.lastAccessed < oldestTime) {
oldestTime = value.lastAccessed;
oldestKey = key;
}
}

if (oldestKey) {
this.cache.delete(oldestKey);
logger.logDebug(
`Evicted LRU cache entry (cache full at ${this.maxSize})`,
{},
);
}
}

/**
* Clean up expired cache entries
*/
private cleanup(): void {
const now = Date.now();
const keysToDelete: string[] = [];

for (const [key, value] of this.cache.entries()) {
// Remove if cache TTL expired or API key expired
const keyExpiresAt = new Date(value.expiresAt).getTime();
if (now - value.cachedAt > this.cacheTTL || now > keyExpiresAt) {
keysToDelete.push(key);
}
}

keysToDelete.forEach((key) => this.cache.delete(key));

if (keysToDelete.length > 0) {
logger.logDebug(
`Cleaned up ${keysToDelete.length} expired cache entries`,
{},
);
}
}

/**
* Get cache statistics
*/
getStats(): { size: number; maxSize: number; ttlMinutes: number } {
const store = Cache.getStore<string, CachedAPIKey>("api-keys", {
max: 1000,
ttlMs: 5 * 60 * 1000,
validate: (value) => Date.now() <= new Date(value.expiresAt).getTime(),
});

export const apiKeyCache = {
get: (hash: string) => store.get(hash) ?? null,
set: (hash: string, data: CachedAPIKey) => store.set(hash, data),
delete: (hash: string) => store.delete(hash),
clear: () => store.clear(),
getStats: () => {
// for testing and debugging purposes
const stats = store.getStats();
return {
size: this.cache.size,
maxSize: this.maxSize,
ttlMinutes: this.cacheTTL / (60 * 1000),
size: stats.size,
maxSize: stats.max,
ttlMinutes: stats.ttlMs / (60 * 1000),
};
}
}

// Export singleton instance
export const apiKeyCache = new APIKeyCache(5, 1000); // 5 minutes TTL, max 1000 entries
},
};
121 changes: 121 additions & 0 deletions src/utils/cacheStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
export interface CacheConfig<K, V> {
max: number;
ttlMs: number;
validate?: (value: V) => boolean;
}

export const DEFAULT_CACHE_CONFIG: CacheConfig<unknown, unknown> = {
max: 500,
ttlMs: 10 * 60 * 1000,
};

export class CacheStore<K, V> {
private readonly max: number;
private readonly ttlMs: number;
private readonly validate?: (value: V) => boolean;
private readonly store = new Map<K, { value: V; expiresAt: number }>();

constructor(config: CacheConfig<K, V>) {
this.max = config.max;
this.ttlMs = config.ttlMs;
this.validate = config.validate;
}

get(key: K): V | undefined {
const entry = this.store.get(key);
if (!entry) return undefined;

if (this.isExpired(entry.expiresAt) || !this.isValid(entry.value)) {
this.store.delete(key);
return undefined;
}

this.refreshKey(key, entry);
return entry.value;
}

has(key: K): boolean {
const entry = this.store.get(key);
if (!entry) return false;

if (this.isExpired(entry.expiresAt) || !this.isValid(entry.value)) {
this.store.delete(key);
return false;
}

return true;
}

set(key: K, value: V): void {
const expiresAt = Date.now() + this.ttlMs;

if (this.store.has(key)) {
this.store.delete(key);
} else if (this.store.size >= this.max) {
this.evictOldest();
}

this.store.set(key, { value, expiresAt });
}

delete(key: K): boolean {
return this.store.delete(key);
}

clear(): void {
this.store.clear();
}

size(): number {
return this.store.size;
}

getStats(): { size: number; max: number; ttlMs: number } {
return {
size: this.store.size,
max: this.max,
ttlMs: this.ttlMs,
};
}

private isExpired(expiresAt: number): boolean {
return Date.now() > expiresAt;
}

private isValid(value: V): boolean {
return this.validate ? this.validate(value) : true;
}

private refreshKey(key: K, entry: { value: V; expiresAt: number }) {
this.store.delete(key);
this.store.set(key, entry);
}

private evictOldest() {
const oldestKey = this.store.keys().next().value;
if (oldestKey !== undefined) {
this.store.delete(oldestKey);
}
}
}

export class Cache {
private static stores = new Map<string, CacheStore<any, any>>();

static getStore<K, V>(
name: string,
config?: Partial<CacheConfig<K, V>>,
): CacheStore<K, V> {
const existing = Cache.stores.get(name);
if (existing) return existing as CacheStore<K, V>;

const merged: CacheConfig<K, V> = {
...(DEFAULT_CACHE_CONFIG as CacheConfig<K, V>),
...config,
};

const store = new CacheStore<K, V>(merged);
Cache.stores.set(name, store as CacheStore<any, any>);
return store;
}
}
6 changes: 6 additions & 0 deletions src/utils/tagCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Cache } from "./cacheStore";

export const tagCache = Cache.getStore<string, number>("tags", {
max: 500,
ttlMs: 10 * 60 * 1000,
});
Loading