File size: 4,343 Bytes
7a1ad33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
/**
 * @license
 * Copyright 2026 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

export interface CacheEntry<V> {
  value: V;
  timestamp: number;
  ttl?: number;
}

export interface CacheOptions {
  /**
   * Default Time To Live in milliseconds.
   */
  defaultTtl?: number;

  /**
   * If true, and V is a Promise, the entry will be removed from the cache
   * if the promise rejects.
   */
  deleteOnPromiseFailure?: boolean;

  /**
   * The underlying storage mechanism.
   * Use 'weakmap' (default) for object keys to allow garbage collection.
   * Use 'map' if you need to use strings as keys or need the clear() method.
   */
  storage?: 'map' | 'weakmap';
}

/**
 * A generic caching service with TTL support.
 */
export class CacheService<K extends object | string | undefined, V> {
  private readonly storage:
    | Map<K, CacheEntry<V>>
    | WeakMap<WeakKey, CacheEntry<V>>;
  private readonly defaultTtl?: number;
  private readonly deleteOnPromiseFailure: boolean;

  constructor(options: CacheOptions = {}) {
    // Default to map for safety unless weakmap is explicitly requested.
    this.storage =
      options.storage === 'weakmap'
        ? new WeakMap<WeakKey, CacheEntry<V>>()
        : new Map<K, CacheEntry<V>>();
    this.defaultTtl = options.defaultTtl;
    this.deleteOnPromiseFailure = options.deleteOnPromiseFailure ?? true;
  }

  /**
   * Retrieves a value from the cache. Returns undefined if missing or expired.
   */
  get(key: K): V | undefined {
    // We have to cast to Map or WeakMap specifically to call get()
    // but since they have the same signature for object keys, we can
    // safely cast to 'any' internally for the dispatch.
    // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
    const entry = (this.storage as any).get(key) as CacheEntry<V> | undefined;
    if (!entry) {
      return undefined;
    }

    const ttl = entry.ttl ?? this.defaultTtl;
    if (ttl !== undefined && Date.now() - entry.timestamp > ttl) {
      this.delete(key);
      return undefined;
    }

    return entry.value;
  }

  /**
   * Stores a value in the cache.
   */
  set(key: K, value: V, ttl?: number): void {
    const entry: CacheEntry<V> = {
      value,
      timestamp: Date.now(),
      ttl,
    };

    // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
    (this.storage as any).set(key, entry);

    if (this.deleteOnPromiseFailure && value instanceof Promise) {
      value.catch(() => {
        // Only delete if this exact entry is still in the cache
        // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
        if ((this.storage as any).get(key) === entry) {
          this.delete(key);
        }
      });
    }
  }

  /**
   * Helper to retrieve a value or create it if missing/expired.
   */
  getOrCreate(key: K, creator: () => V, ttl?: number): V {
    let value = this.get(key);
    if (value === undefined) {
      value = creator();
      this.set(key, value, ttl);
    }
    return value;
  }

  /**
   * Removes an entry from the cache.
   */
  delete(key: K): void {
    if (this.storage instanceof Map) {
      this.storage.delete(key);
    } else {
      // WeakMap.delete returns a boolean, we can ignore it.
      // Cast to any to bypass the WeakKey constraint since we've already
      // confirmed the storage type.
      // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
      (this.storage as any).delete(key);
    }
  }

  /**
   * Clears all entries. Only supported if using Map storage.
   */
  clear(): void {
    if (this.storage instanceof Map) {
      this.storage.clear();
    } else {
      throw new Error('clear() is not supported on WeakMap storage');
    }
  }
}

/**
 * Factory function to create a new cache.
 */
export function createCache<K extends string | undefined, V>(
  options: CacheOptions & { storage: 'map' },
): CacheService<K, V>;
export function createCache<K extends object, V>(
  options?: CacheOptions,
): CacheService<K, V>;
export function createCache<K extends object | string | undefined, V>(
  options: CacheOptions = {},
): CacheService<K, V> {
  return new CacheService<K, V>(options);
}