Skip to content
Open
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
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ Here's how you can use LiquidCache in your TypeScript code:
```ts
import { LiquidCache } from 'liquidcache';

// Create a new cache instance
const cache = new LiquidCache<string>();
// Create a new cache instance with a default TTL of 60 seconds
const cache = new LiquidCache<string>(60000);

// Set a value in the cache
cache.set('name', 'FlaringPhoenix');
Expand All @@ -52,6 +52,24 @@ cache.set('name', 'FlaringPhoenix', 5000);
// After 5 seconds, 'name' will be automatically removed from the cache
```

### Options

You can also pass an options object for more control:

```ts
const cache = new LiquidCache<string, string>({
defaultTTL: 60000, // null disables expiration by default
autoRenew: true, // refresh TTL on successful get()
useUnref: true, // allow Node.js process to exit if only timers remain
onExpire: (key, value) => {
console.log(`expired ${key}`, value);
},
});

cache.set('session', 'active', 30000);
const remaining = cache.getRemainingTTL('session');
```

## Support

If you need help with configuring or using LiquidCache, please open an issue [here](https://github.com/FlaringPhoenix/LiquidCache/issues/new).
Expand Down
151 changes: 116 additions & 35 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,125 @@
export interface LiquidCacheOptions<K, V> {
defaultTTL?: number | null;
autoRenew?: boolean;
onExpire?: (key: K, value: V) => void;
useUnref?: boolean;
}

type ExpirationEntry = {
timeout: NodeJS.Timeout;
expiresAt: number;
ttl: number;
};

class LiquidCache<K, V> extends Map<K, V> {
private expirationMap = new Map<K, NodeJS.Timeout>();

constructor(private defaultExpiration: number = 60000) {
super();
private readonly expirationMap = new Map<K, ExpirationEntry>();
private readonly defaultTTL: number | null;
private readonly autoRenew: boolean;
private readonly onExpire?: (key: K, value: V) => void;
private readonly useUnref: boolean;

constructor(defaultTTL?: number | null);
constructor(options?: LiquidCacheOptions<K, V>);
constructor(defaultTTLOrOptions: number | null | LiquidCacheOptions<K, V> = 60000) {
super();

if (typeof defaultTTLOrOptions === 'object' && defaultTTLOrOptions !== null) {
this.defaultTTL = defaultTTLOrOptions.defaultTTL ?? 60000;
this.autoRenew = defaultTTLOrOptions.autoRenew ?? false;
this.onExpire = defaultTTLOrOptions.onExpire;
this.useUnref = defaultTTLOrOptions.useUnref ?? false;
} else {
this.defaultTTL = defaultTTLOrOptions ?? 60000;
this.autoRenew = false;
this.useUnref = false;
}

set(key: K, value: V, expiration?: number): this {
// If the key already exists, clear the previous timeout
if (this.expirationMap.has(key)) {
clearTimeout(this.expirationMap.get(key)!);
}

set(key: K, value: V, ttl?: number | null): this {
this.clearExpiration(key);
super.set(key, value);
this.scheduleExpiration(key, ttl);
return this;
}

get(key: K): V | undefined {
const value = super.get(key);
if (value !== undefined || super.has(key)) {
if (this.autoRenew) {
const entry = this.expirationMap.get(key);
if (entry) {
this.clearExpiration(key);
this.scheduleExpiration(key, entry.ttl);
}
}

super.set(key, value);

const expirationTime = expiration ?? this.defaultExpiration;
const timeout = setTimeout(() => this.delete(key), expirationTime);
this.expirationMap.set(key, timeout);

return this;
}

delete(key: K): boolean {
const timeout = this.expirationMap.get(key);
if (timeout) {
clearTimeout(timeout);
this.expirationMap.delete(key);
}

return super.delete(key);
return value;
}

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

clear(): void {
for (const entry of this.expirationMap.values()) {
clearTimeout(entry.timeout);
}

// Optionally, you can clear the entire cache and all timeouts
clear(): void {
for (const timeout of this.expirationMap.values()) {
clearTimeout(timeout);
this.expirationMap.clear();
super.clear();
}

getRemainingTTL(key: K): number | null {
const entry = this.expirationMap.get(key);
if (!entry) {
return null;
}
return Math.max(0, entry.expiresAt - Date.now());
}

private scheduleExpiration(key: K, ttl?: number | null): void {
const resolvedTTL = ttl ?? this.defaultTTL;
if (resolvedTTL === null || resolvedTTL === undefined) {
return;
}

const expiresAt = Date.now() + resolvedTTL;
const timeout = setTimeout(() => this.expireKey(key), resolvedTTL);
if (this.useUnref && typeof timeout.unref === 'function') {
timeout.unref();
}
this.expirationMap.set(key, {
timeout,
expiresAt,
ttl: resolvedTTL,
});
}

private clearExpiration(key: K): void {
const entry = this.expirationMap.get(key);
if (entry) {
clearTimeout(entry.timeout);
this.expirationMap.delete(key);
}
}

private expireKey(key: K): void {
const entry = this.expirationMap.get(key);
if (!entry) {
return;
}

this.expirationMap.delete(key);
const hadKey = super.has(key);
const value = super.get(key);
if (hadKey) {
super.delete(key);
if (this.onExpire) {
this.onExpire(key, value as V);
}
this.expirationMap.clear();
super.clear();
}
}
}

export default LiquidCache;

export default LiquidCache;

78 changes: 78 additions & 0 deletions test/LiquidCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ describe('LiquidCache', () => {
}, 70);
});

it('should support disabling expiration with null ttl', (done) => {
cache.set('key', 'value', null);
setTimeout(() => {
expect(cache.has('key')).to.be.true;
done();
}, 70);
});

it('should override an existing key', (done) => {
cache.set('key', 'value1');
cache.set('key', 'value2');
Expand All @@ -43,4 +51,74 @@ describe('LiquidCache', () => {
expect(cache.size).to.equal(0);
done();
});

it('should allow reading remaining ttl', (done) => {
cache.set('key', 'value', 100);
const remaining = cache.getRemainingTTL('key');
expect(remaining).to.be.a('number');
expect(remaining).to.be.greaterThan(0);
done();
});

it('should return null remaining ttl for missing keys', (done) => {
expect(cache.getRemainingTTL('missing')).to.equal(null);
done();
});

it('should return null remaining ttl when expiration is disabled', (done) => {
cache.set('key', 'value', null);
expect(cache.getRemainingTTL('key')).to.equal(null);
done();
});

it('should not call onExpire for manual deletes', (done) => {
const events: string[] = [];
const customCache = new LiquidCache<string, string>({
defaultTTL: 50,
onExpire: (key) => events.push(key),
});
customCache.set('key', 'value');
customCache.delete('key');
setTimeout(() => {
expect(events).to.deep.equal([]);
done();
}, 70);
});

it('should clear existing ttl when updating with null', (done) => {
cache.set('key', 'value', 50);
cache.set('key', 'value', null);
setTimeout(() => {
expect(cache.has('key')).to.be.true;
done();
}, 80);
});

it('should call onExpire callback', (done) => {
const events: string[] = [];
const customCache = new LiquidCache<string, string>({
defaultTTL: 30,
onExpire: (key) => events.push(key),
});
customCache.set('key', 'value');
setTimeout(() => {
expect(events).to.deep.equal(['key']);
done();
}, 60);
});

it('should auto-renew ttl on get when enabled', (done) => {
const customCache = new LiquidCache<string, string>({
defaultTTL: 80,
autoRenew: true,
});
customCache.set('key', 'value');
setTimeout(() => {
expect(customCache.get('key')).to.equal('value');
}, 50);
setTimeout(() => {
expect(customCache.has('key')).to.be.true;
done();
}, 130);
});
});