diff --git a/docs/generated/changelog.html b/docs/generated/changelog.html index 4191ba4c5..d67a34196 100644 --- a/docs/generated/changelog.html +++ b/docs/generated/changelog.html @@ -33,6 +33,10 @@

Version x.x.x

  • new Verify export on ed25519 because why not
  • Adds support for Uint8Arrays in Principal.from()
  • +
  • + feat: introduces ExpirableMap, a utility class that will return values up until a + configured expiry +
  • chore: increases size limit for agent-js to allow for Ed25519 support for node key signature verification diff --git a/packages/agent/src/utils/expirableMap.test.ts b/packages/agent/src/utils/expirableMap.test.ts new file mode 100644 index 000000000..9bd0b2e12 --- /dev/null +++ b/packages/agent/src/utils/expirableMap.test.ts @@ -0,0 +1,37 @@ +import { ExpirableMap } from './expirableMap'; + +jest.useFakeTimers(); +describe('ExpirableMap', () => { + it('should return undefined if the key is not present', () => { + const map = new ExpirableMap(); + expect(map.get('key')).toBeUndefined(); + }); + it('should return a key if one has been recently set', () => { + const map = new ExpirableMap(); + map.set('key', 'value'); + expect(map.get('key')).toBe('value'); + }); + it('should return undefined if the key has expired', () => { + const map = new ExpirableMap({ expirationTime: 10 }); + map.set('key', 'value'); + jest.advanceTimersByTime(11); + expect(map.get('key')).toBeUndefined(); + }); + it('should support iterable operations', () => { + const map = new ExpirableMap({ + source: [ + ['key1', 8], + ['key2', 1234], + ], + }); + + expect(Array.from(map)).toStrictEqual([ + ['key1', 8], + ['key2', 1234], + ]); + + for (const [key, value] of map) { + expect(map.get(key)).toBe(value); + } + }); +}); diff --git a/packages/agent/src/utils/expirableMap.ts b/packages/agent/src/utils/expirableMap.ts new file mode 100644 index 000000000..39657484c --- /dev/null +++ b/packages/agent/src/utils/expirableMap.ts @@ -0,0 +1,161 @@ +export type ExpirableMapOptions = { + source?: Iterable<[K, V]>; + expirationTime?: number; +}; + +/** + * A map that expires entries after a given time. + * Defaults to 10 minutes. + */ +export class ExpirableMap implements Map { + // Internals + #inner: Map; + #expirationTime: number; + + [Symbol.iterator]: () => IterableIterator<[K, V]> = this.entries.bind(this); + [Symbol.toStringTag] = 'ExpirableMap'; + + /** + * Create a new ExpirableMap. + * @param {ExpirableMapOptions} options - options for the map. + * @param {Iterable<[any, any]>} options.source - an optional source of entries to initialize the map with. + * @param {number} options.expirationTime - the time in milliseconds after which entries will expire. + */ + constructor(options: ExpirableMapOptions = {}) { + const { source = [], expirationTime = 10 * 60 * 1000 } = options; + const currentTime = Date.now(); + this.#inner = new Map( + [...source].map(([key, value]) => [key, { value, timestamp: currentTime }]), + ); + this.#expirationTime = expirationTime; + } + + /** + * Prune removes all expired entries. + */ + prune() { + const currentTime = Date.now(); + for (const [key, entry] of this.#inner.entries()) { + if (currentTime - entry.timestamp > this.#expirationTime) { + this.#inner.delete(key); + } + } + return this; + } + + // Implementing the Map interface + + /** + * Set the value for the given key. Prunes expired entries. + * @param key for the entry + * @param value of the entry + * @returns this + */ + set(key: K, value: V) { + this.prune(); + const entry = { + value, + timestamp: Date.now(), + }; + this.#inner.set(key, entry); + + return this; + } + + /** + * Get the value associated with the key, if it exists and has not expired. + * @param key K + * @returns the value associated with the key, or undefined if the key is not present or has expired. + */ + get(key: K) { + const entry = this.#inner.get(key); + if (entry === undefined) { + return undefined; + } + if (Date.now() - entry.timestamp > this.#expirationTime) { + this.#inner.delete(key); + return undefined; + } + return entry.value; + } + + /** + * Clear all entries. + */ + clear() { + this.#inner.clear(); + } + + /** + * Entries returns the entries of the map, without the expiration time. + * @returns an iterator over the entries of the map. + */ + entries(): IterableIterator<[K, V]> { + const iterator = this.#inner.entries(); + const generator = function* () { + for (const [key, value] of iterator) { + yield [key, value.value] as [K, V]; + } + }; + return generator(); + } + + /** + * Values returns the values of the map, without the expiration time. + * @returns an iterator over the values of the map. + */ + values(): IterableIterator { + const iterator = this.#inner.values(); + const generator = function* () { + for (const value of iterator) { + yield value.value; + } + }; + return generator(); + } + + /** + * Keys returns the keys of the map + * @returns an iterator over the keys of the map. + */ + keys(): IterableIterator { + return this.#inner.keys(); + } + + /** + * forEach calls the callbackfn on each entry of the map. + * @param callbackfn to call on each entry + * @param thisArg to use as this when calling the callbackfn + */ + forEach(callbackfn: (value: V, key: K, map: Map) => void, thisArg?: ExpirableMap) { + for (const [key, value] of this.#inner.entries()) { + callbackfn.call(thisArg, value.value, key, this); + } + } + + /** + * has returns true if the key exists and has not expired. + * @param key K + * @returns true if the key exists and has not expired. + */ + has(key: K): boolean { + return this.#inner.has(key); + } + + /** + * delete the entry for the given key. + * @param key K + * @returns true if the key existed and has been deleted. + */ + delete(key: K) { + return this.#inner.delete(key); + } + + /** + * get size of the map. + * @returns the size of the map. + */ + get size() { + return this.#inner.size; + } +}