All files / src/core/services KVService.ts

100% Statements 25/25
100% Branches 16/16
100% Functions 7/7
100% Lines 24/24

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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 14628x                         28x                                     28x                 6x 2x     4x                         5x 2x     3x                       2x 1x     1x                                         4x   4x 1x     3x   3x 2x     3x                             2x 1x     1x   2x                   2x      
import { KVBinding } from '@/core/bindings/KVBinding'
 
import type { CacheStore } from '@bayudwiyansatria/core'
import type { CloudflareEnv } from '@/types/CloudflareEnv'
 
/**
 * KV namespace binding.
 *
 * Every setting it needs — binding name and the default expiry — arrives
 * resolved from the configuration layer, so nothing about them is decided
 * here. The binding itself is read from `env` per call, which is what makes
 * one module-scope instance safe to share.
 */
const cache = new KVBinding()
 
/**
 * Cache capability, backed by Workers KV.
 *
 * Values are stored as JSON and expire on the TTL the configuration layer
 * settled on, unless a call names its own.
 *
 * @remarks
 * A cache is optional infrastructure: when the namespace is not bound, reads
 * miss and writes are dropped rather than raising. That keeps a Worker
 * deployed without KV working — slower, not broken.
 *
 * @class
 *
 * @author Bayu Dwiyan Satria
 * @version 1.0.0
 * @since 1.0.0
 */
export class KVService<E extends CloudflareEnv = CloudflareEnv> implements CacheStore<E> {
  /**
   * Reads a cached value.
   *
   * @param env The Worker environment.
   * @param key The cache key.
   * @returns The cached value, or `null` on a miss or an unbound namespace.
   */
  public async get<T = unknown>(env: E, key: string): Promise<T | null> {
    if (!cache.isBound(env)) {
      return null
    }
 
    return await cache.getJson<T>(env, key)
  }
 
  /**
   * Writes a value.
   *
   * @param env The Worker environment.
   * @param key The cache key.
   * @param value The value to store.
   * @param ttl Lifetime in seconds. Falls back to the configured default;
   *   Cloudflare enforces a minimum of 60.
   */
  public async set(env: E, key: string, value: unknown, ttl?: number): Promise<void> {
    if (!cache.isBound(env)) {
      return
    }
 
    await cache.putJson(env, key, value, ttl ? { expirationTtl: ttl } : {})
  }
 
  /**
   * Drops a cached value.
   *
   * Deleting a key that was never written is not an error.
   *
   * @param env The Worker environment.
   * @param key The cache key.
   */
  public async remove(env: E, key: string): Promise<void> {
    if (!cache.isBound(env)) {
      return
    }
 
    await cache.delete(env, key)
  }
 
  /**
   * Returns a cached value, computing and caching it on a miss.
   *
   * The read-through pattern most callers actually want — one call instead of
   * a get, a branch, and a set.
   *
   * @example
   * ```ts
   * const article = await this.cache.remember(env, `article:${id}`, () => this.load(env, id))
   * ```
   *
   * @param env The Worker environment.
   * @param key The cache key.
   * @param load Computes the value when the cache misses.
   * @param ttl Lifetime in seconds. Falls back to the configured default.
   * @returns The cached or freshly computed value.
   */
  public async remember<T>(env: E, key: string, load: () => Promise<T>, ttl?: number): Promise<T> {
    const cached = await this.get<T>(env, key)
 
    if (cached !== null) {
      return cached
    }
 
    const value = await load()
 
    if (value !== null && value !== undefined) {
      await this.set(env, key, value, ttl)
    }
 
    return value
  }
 
  /**
   * Lists the keys under a prefix.
   *
   * @remarks
   * One page only — KV paginates, and a cache listing is rarely worth walking
   * to the end.
   *
   * @param env The Worker environment.
   * @param prefix The key prefix to match.
   * @returns The matched key names.
   */
  public async keys(env: E, prefix: string): Promise<string[]> {
    if (!cache.isBound(env)) {
      return []
    }
 
    const page = await cache.list(env, { prefix })
 
    return page.keys.map(key => key.name)
  }
 
  /**
   * Reports whether the cache is bound to this Worker.
   *
   * @param env The Worker environment.
   * @returns `true` when the binding is present.
   */
  public isAvailable(env: E): boolean {
    return cache.isBound(env)
  }
}