diff --git a/lib/util/cache/package/cacheable.spec.ts b/lib/util/cache/package/cacheable.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c19c1fcd1161c60459b1ff459ee2fa162aa93df --- /dev/null +++ b/lib/util/cache/package/cacheable.spec.ts @@ -0,0 +1,127 @@ +import { DateTime } from 'luxon'; +import { Cacheable } from './cacheable'; + +describe('util/cache/package/cacheable', () => { + it('constructs default value', () => { + const res = Cacheable.empty(); + expect(res.ttlMinutes).toBe(15); + }); + + describe('TTL', () => { + it('static method for minutes', () => { + const res = Cacheable.forMinutes(123); + expect(res.ttlMinutes).toBe(123); + }); + + it('method for minutes', () => { + const res = Cacheable.empty(); + expect(res.forMinutes(42).ttlMinutes).toBe(42); + }); + + it('setter for minutes', () => { + const res = Cacheable.empty(); + res.ttlMinutes = 42; + expect(res.ttlMinutes).toBe(42); + }); + + it('static method for hours', () => { + const res = Cacheable.forHours(3); + expect(res.ttlMinutes).toBe(180); + }); + + it('method for hours', () => { + const res = Cacheable.empty(); + expect(res.forHours(3).ttlMinutes).toBe(180); + }); + + it('setter for hours', () => { + const res = Cacheable.empty(); + res.ttlHours = 3; + expect(res.ttlMinutes).toBe(180); + }); + + it('static method for days', () => { + const res = Cacheable.forDays(2); + expect(res.ttlMinutes).toBe(2880); + }); + + it('method for days', () => { + const res = Cacheable.empty(); + expect(res.forDays(2).ttlMinutes).toBe(2880); + }); + + it('setter for days', () => { + const res = Cacheable.empty(); + res.ttlDays = 2; + expect(res.ttlMinutes).toBe(2880); + }); + }); + + describe('public data', () => { + it('via static method', () => { + const res: Cacheable<number> = Cacheable.fromPublic(42); + expect(res.value).toBe(42); + expect(res.isPublic).toBeTrue(); + expect(res.isPrivate).toBeFalse(); + }); + + it('via method', () => { + const res: Cacheable<number> = Cacheable.empty().asPublic(42); + expect(res.value).toBe(42); + expect(res.isPublic).toBeTrue(); + expect(res.isPrivate).toBeFalse(); + }); + }); + + describe('private data', () => { + it('via static method', () => { + const res: Cacheable<number> = Cacheable.fromPrivate(42); + expect(res.value).toBe(42); + expect(res.isPublic).toBeFalse(); + expect(res.isPrivate).toBeTrue(); + }); + + it('via method', () => { + const res: Cacheable<number> = Cacheable.empty().asPrivate(42); + expect(res.value).toBe(42); + expect(res.isPublic).toBeFalse(); + expect(res.isPrivate).toBeTrue(); + }); + }); + + describe('timestamping', () => { + function dateOf<T>(cacheableResult: Cacheable<T>): Date { + return DateTime.fromISO(cacheableResult.cachedAt).toJSDate(); + } + + it('handles dates automatically', () => { + const t1 = new Date(); + + const empty = Cacheable.empty(); + + const t2 = new Date(); + + const a = Cacheable.fromPrivate(42); + const b = Cacheable.fromPublic(42); + const c = empty.asPrivate(42); + const d = empty.asPublic(42); + + const t3 = new Date(); + + expect(dateOf(empty)).toBeAfterOrEqualTo(t1); + expect(dateOf(empty)).toBeBeforeOrEqualTo(t2); + + expect(dateOf(a)).toBeAfterOrEqualTo(t2); + expect(dateOf(a)).toBeBeforeOrEqualTo(t3); + + expect(dateOf(b)).toBeAfterOrEqualTo(t2); + expect(dateOf(b)).toBeBeforeOrEqualTo(t3); + + expect(dateOf(c)).toBeAfterOrEqualTo(t2); + expect(dateOf(c)).toBeBeforeOrEqualTo(t3); + + expect(dateOf(d)).toBeAfterOrEqualTo(t2); + expect(dateOf(d)).toBeBeforeOrEqualTo(t3); + }); + }); +}); diff --git a/lib/util/cache/package/cacheable.ts b/lib/util/cache/package/cacheable.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca50b579ecafabb2c220afdbd1f392e89267a330 --- /dev/null +++ b/lib/util/cache/package/cacheable.ts @@ -0,0 +1,206 @@ +import { DateTime } from 'luxon'; + +/** + * Type that could be reliably cached, including `null` values. + */ +type NotUndefined<T> = T extends undefined ? never : T; + +/** + * Type used for partially initialized `Cacheable` values. + */ +type MaybeUndefined<T> = NotUndefined<T> | undefined; + +/** + * Default cache TTL. + */ +const defaultTtlMinutes = 15; + +export class Cacheable<T> { + private constructor( + private _ttlMinutes: number, + private _cachedAt: DateTime<true>, + private _isPrivate: boolean, + private _value: T, + ) {} + + /** + * Constructs an empty instance for further modification. + */ + public static empty<T>(): Cacheable<MaybeUndefined<T>> { + return new Cacheable<MaybeUndefined<T>>( + defaultTtlMinutes, + DateTime.now(), + true, + undefined, + ); + } + + /** + * Returns the TTL in minutes. + */ + get ttlMinutes(): number { + return this._ttlMinutes; + } + + /** + * Set the TTL in minutes. + */ + set ttlMinutes(minutes: number) { + this._ttlMinutes = minutes; + } + + /** + * Set the TTL in hours. + */ + set ttlHours(hours: number) { + this._ttlMinutes = 60 * hours; + } + + /** + * Set the TTL in days. + */ + set ttlDays(hours: number) { + this._ttlMinutes = 24 * 60 * hours; + } + + /** + * Sets the cache TTL in minutes and returns the same object. + */ + public forMinutes(minutes: number): Cacheable<T> { + this.ttlMinutes = minutes; + return this; + } + + /** + * Construct the empty `Cacheable` instance with pre-configured minutes of TTL. + */ + public static forMinutes<T>(minutes: number): Cacheable<MaybeUndefined<T>> { + return Cacheable.empty<MaybeUndefined<T>>().forMinutes(minutes); + } + + /** + * Sets the cache TTL in hours and returns the same object. + */ + public forHours(hours: number): Cacheable<T> { + return this.forMinutes(60 * hours); + } + + /** + * Construct the empty `Cacheable` instance with pre-configured hours of TTL. + */ + public static forHours<T>(hours: number): Cacheable<MaybeUndefined<T>> { + return Cacheable.empty<MaybeUndefined<T>>().forHours(hours); + } + + /** + * Sets the cache TTL in days and returns the same object. + */ + public forDays(days: number): Cacheable<T> { + return this.forHours(24 * days); + } + + /** + * Construct the empty `Cacheable` instance with pre-configured hours of TTL. + */ + public static forDays<T>(days: number): Cacheable<MaybeUndefined<T>> { + return Cacheable.empty<MaybeUndefined<T>>().forDays(days); + } + + /** + * Construct `Cacheable` instance that SHOULD be persisted and available publicly. + * + * @param value Data to cache + * @returns New `Cacheable` instance with the `value` guaranteed to be defined. + */ + public static fromPublic<T>( + value: NotUndefined<T>, + ): Cacheable<NotUndefined<T>> { + return new Cacheable<NotUndefined<T>>( + defaultTtlMinutes, + DateTime.now(), + false, + value, + ); + } + + /** + * Mark the partially initialized `Cacheable` instance as public, + * for data that SHOULD be persisted and available publicly. + * + * @param value Data to cache + * @returns New `Cacheable` instance with `value` guaranteed to be defined. + */ + public asPublic<T>(value: NotUndefined<T>): Cacheable<NotUndefined<T>> { + return new Cacheable<NotUndefined<T>>( + this._ttlMinutes, + this._cachedAt, + false, + value, + ); + } + + /** + * Construct `Cacheable` instance that MUST NOT be available publicly, + * but still COULD be persisted in self-hosted setups. + * + * @param value Data to cache + * @returns New `Cacheable` instance with `value` guaranteed to be defined. + */ + public static fromPrivate<T>( + value: NotUndefined<T>, + ): Cacheable<NotUndefined<T>> { + return new Cacheable<NotUndefined<T>>( + defaultTtlMinutes, + DateTime.now(), + true, + value, + ); + } + + /** + * Mark the partially initialized `Cacheable` instance as private, + * for data that MUST NOT be available publicly, + * but still COULD be persisted in self-hosted setups. + * + * @param value Data to cache + * @returns New `Cacheable` instance with `value` guaranteed to be defined. + */ + public asPrivate<T>(value: NotUndefined<T>): Cacheable<NotUndefined<T>> { + return new Cacheable<NotUndefined<T>>( + this._ttlMinutes, + this._cachedAt, + true, + value, + ); + } + + /** + * Check whether the instance is private. + */ + get isPrivate(): boolean { + return this._isPrivate; + } + + /** + * Check whether the instance is public. + */ + get isPublic(): boolean { + return !this._isPrivate; + } + + /** + * Cached value + */ + get value(): T { + return this._value; + } + + /** + * The creation date of the cached value, + * which is set during `fromPrivate`, `asPrivate`, + * `fromPublic`, or `asPublic` calls. + */ + get cachedAt(): string { + return this._cachedAt.toUTC().toISO(); + } +}