Files
2026-05-27 23:29:53 +01:00

16 KiB

Design — @suaveplan/storage-plugin-cache

Tier and dependency posture

Universal tier. Zero node: / bun: imports in src/. Runtime deps:

  • @suaveplan/error — defineCodes, SuaveplanError
  • @suaveplan/storage — AbstractStorage (extended), associated types (StorageMetadata, StorageGetMetadataResult, StorageListResult, etc.)
  • @suaveplan/cache — AbstractCache<T> (type-only consumption; the consumer passes a concrete instance)
  • @suaveplan/types — LoggerLike (optional injectable)
  • @opentelemetry/api — meter + tracer
  • tslib

@suaveplan/cache-storage, cache-redis, storage-file, storage-http, storage-browser-* are NOT runtime deps. The consumer wires their preferred cache backend and inner storage before passing them to cachedStorage(inner, options). Same injection posture as drive-uri and drive-plugin-cache — the wrapper sees interfaces, not concrete providers.

DevDeps: @suaveplan/testing, @suaveplan/storage-file, @suaveplan/storage-http, @suaveplan/cache-storage, @vitest/coverage-istanbul, fast-check, typescript, vite, vite-plugin-dts, vitest.

File layout

packages/storage/storage-plugin-cache/
  package.json
  README.md
  src/
    index.ts                                # explicit named re-exports (rule 10)
    error-codes/
      error-codes.ts                        # defineCodes("STORAGE_CACHE", [...]), CachedStorageError
      error-codes.test.ts
      error-codes.md
      index.ts
    types/
      options.types.ts                      # CachedStorageOptions, CachedStorageStats
      options.types.md
      index.ts
    cached-storage/
      cached-storage.ts                     # class CachedStorage extends AbstractStorage
      cached-storage.test.ts                # construct, instanceof, getCacheStats
      cached-storage.get.ts                 # get() read-through helper (kept ≤300 lines)
      cached-storage.get.test.ts
      cached-storage.write.ts               # set() / setPartial() invalidation helper
      cached-storage.write.test.ts
      cached-storage.delete.ts              # delete() invalidation helper
      cached-storage.delete.test.ts
      cached-storage.clear.ts               # clear() namespaced-iteration helper
      cached-storage.clear.test.ts
      cached-storage.passthrough.ts         # exists/list/getSize/getMetadata/streams delegates
      cached-storage.passthrough.test.ts
      cached-storage.signal.ts              # pre-aborted signal short-circuit helper
      cached-storage.signal.test.ts
      cached-storage.md
      cached-storage.get.md
      cached-storage.write.md
      cached-storage.delete.md
      cached-storage.clear.md
      cached-storage.passthrough.md
      cached-storage.signal.md
      index.ts
    factory/
      factory.ts                            # cachedStorage(inner, options): CachedStorage
      factory.test.ts
      factory.md
      index.ts
    otel/
      otel.ts                               # meter + tracer wiring, recordOp(...)
      otel.test.ts
      otel.md
      index.ts
  tests/
    setup/polyfills.ts
    integration/
      memory-in-front-of-http.test.ts       # real MemoryCache + real storage-http (in-process fetch)
      file-cache-survives-restart.test.ts   # real StorageCache over real storage-file
      namespaced-isolation.test.ts          # two wrappers sharing one cache, distinct namespaces
  tsconfig.json
  tsconfig.lib.json
  vite.config.ts
  vitest.config.ts

The cached-storage/ feature folder is split into one-concern files (get, write, delete, clear, passthrough, signal) to stay well under the 300-line hard limit while keeping each behaviour next to its tests + docs. Every implementation .ts has a sibling .md. The folder pattern mirrors storage-uri's resolver/ split.

Per AGENTS §10: parent cached-storage/ with un-suffixed children. No -plugin- suffix because the children are not nested plugins (the package is named storage-plugin-cache because it functions as a storage-tier cache plugin — the noun "plugin" appears in the package name only, not in any child folder).

Class shape

export class CachedStorage extends AbstractStorage {
  readonly #inner: AbstractStorage;
  readonly #cache: AbstractCache<Uint8Array>;
  readonly #options: ResolvedCachedStorageOptions;
  readonly #stats: MutableStats = { hits: 0, misses: 0, populations: 0, invalidations: 0, errors: 0 };

  public constructor(inner: AbstractStorage, options: CachedStorageOptions) {
    super(/* eventBus from inner is intentionally NOT forwarded — events are observed on the inner */);
    if (!inner) throw new CachedStorageError("INNER_MISSING", "inner storage is required");
    validateOptions(options);
    this.#inner = inner;
    this.#cache = options.cache;
    this.#options = resolveDefaults(options);
  }

  public get cache(): AbstractCache<Uint8Array> { return this.#cache; }
  public get inner(): AbstractStorage { return this.#inner; }

  public getCacheStats(): CachedStorageStats { return { ...this.#stats }; }

  async get(key: string, signal?: AbortSignal): Promise<Uint8Array | undefined> { /* read-through */ }
  async set(key: string, value: Uint8Array, signal?: AbortSignal): Promise<void> { /* delegate + invalidate */ }
  async delete(key: string, signal?: AbortSignal): Promise<boolean> { /* delegate + invalidate */ }
  // … rest of AbstractStorage's 15 abstract methods …
}

export function cachedStorage(
  inner: AbstractStorage,
  options: CachedStorageOptions
): CachedStorage {
  return new CachedStorage(inner, options);
}

The class exposes both cache (the underlying AbstractCache for wrap() / getStats() / direct manipulation) and inner (the wrapped AbstractStorage for inspection / unwrap). The factory is a one-liner over new CachedStorage(...) so consumers can write either form; the factory is what validateOptions lives in (synchronous throw on missing cache before the class constructor runs).

Read-through (get)

async get(key: string, signal?: AbortSignal): Promise<Uint8Array | undefined> {
  if (signal?.aborted) return this.#inner.get(key, signal);   // let inner raise canonical AbortError

  const cacheKey = `${this.#options.namespace}::${key}`;

  let cached: Uint8Array | undefined;
  try {
    cached = await this.#cache.get(cacheKey);
  } catch (err) {
    this.#stats.errors += 1;
    recordError("get", err, this.#options.namespace);
    // fall through — cache failure must not block the read
  }
  if (cached !== undefined) {
    this.#stats.hits += 1;
    recordHit(this.#options.namespace, key);
    return cached;
  }

  this.#stats.misses += 1;
  recordMiss(this.#options.namespace, key);
  const bytes = await this.#inner.get(key, signal);
  if (bytes === undefined) return undefined;    // negative results NOT cached

  if (this.#options.shouldCache(key, bytes)) {
    try {
      await this.#cache.set(cacheKey, bytes);
      this.#stats.populations += 1;
      recordPopulate(this.#options.namespace, key, bytes.byteLength);
    } catch (err) {
      this.#stats.errors += 1;
      recordError("set", err, this.#options.namespace);
      // swallow — same rule as drive-plugin-cache
    }
  }
  return bytes;
}

Invalidation (set / setPartial / delete / clear)

async set(key: string, value: Uint8Array, signal?: AbortSignal): Promise<void> {
  await this.#inner.set(key, value, signal);
  if (this.#options.invalidateOnWrite) {
    await this.#invalidateKey(key);
  }
}

async setPartial(key: string, offset: number, value: Uint8Array, signal?: AbortSignal): Promise<void> {
  await this.#inner.setPartial(key, offset, value, signal);
  if (this.#options.invalidateOnWrite) {
    await this.#invalidateKey(key);   // partial writes invalidate the whole entry — no partial caching
  }
}

async delete(key: string, signal?: AbortSignal): Promise<boolean> {
  const removed = await this.#inner.delete(key, signal);
  if (this.#options.invalidateOnDelete) {
    await this.#invalidateKey(key);   // delete even on inner.delete === false to clear stale entries
  }
  return removed;
}

async clear(signal?: AbortSignal): Promise<void> {
  await this.#inner.clear(signal);
  if (!this.#options.invalidateOnClear) return;

  // Iterate this wrapper's namespaced subset only — never wipe other wrappers' entries.
  if (typeof this.#cache.keys === "function") {
    try {
      const prefix = `${this.#options.namespace}::`;
      for await (const k of this.#cache.keys()) {
        if (k.startsWith(prefix)) await this.#cache.delete(k);
        this.#stats.invalidations += 1;
      }
    } catch (err) {
      this.#stats.errors += 1;
      recordError("clear", err, this.#options.namespace);
    }
  } else {
    // AbstractCache without enumeration: log warning, skip. Consumer can call cache.clear()
    // directly if they accept the wider blast radius.
    this.#options.logger?.warn?.({
      msg: "[storage-cache] cache backend has no keys() — clear() did not evict cache entries",
      namespace: this.#options.namespace,
    });
  }
}

async #invalidateKey(key: string): Promise<void> {
  const cacheKey = `${this.#options.namespace}::${key}`;
  try {
    await this.#cache.delete(cacheKey);
    this.#stats.invalidations += 1;
    recordInvalidate(this.#options.namespace, key);
  } catch (err) {
    this.#stats.errors += 1;
    recordError("delete", err, this.#options.namespace);
  }
}

Post-success invalidation matches drive-plugin-cache's rationale — pre-next() invalidation opens a race window where a concurrent read repopulates the cache with stale bytes during the write.

Passthrough surface

exists       (key, signal?)               → inner.exists(key, signal)
list         (opts?, signal?)             → inner.list(opts, signal)
clear        // handled above with invalidation
getWithMetadata(key, signal?)             → inner.getWithMetadata(key, signal)
getMetadata  (key, signal?)               → inner.getMetadata(key, signal)
getSize      (signal?)                    → inner.getSize(signal)
getItemSize  (key, signal?)               → inner.getItemSize(key, signal)
getPartial   (key, offset, length, sig?)  → inner.getPartial(...)
createReadStream  (key, signal?)          → inner.createReadStream(key, signal)
createWriteStream (key, signal?)          → inner.createWriteStream(key, signal)

These do NOT touch the cache. The cache surface is reads-of-whole-objects + writes-invalidate-whole-objects. Anything that wants cached metadata or cached streaming composes at a different tier (drive-plugin-cache for plaintext, future cacheUriPlugin for URI-resolver results, storage-http's EtagCache for HTTP 304s).

Error-code map

Code When Notes
STORAGE_CACHE.INVALID_OPTIONS Constructor or factory called without a valid cache (missing, wrong shape, missing get/set/delete) Synchronous throw
STORAGE_CACHE.INNER_MISSING Constructor called with a nullish inner Synchronous throw

Every other failure mode (storage errors, cache errors) surfaces with its original code through the wrapper. The wrapper's own registry is intentionally narrow — there's no value in re-namespacing downstream errors.

OTel surface

Tracer + meter: @suaveplan/storage-cache.

Histograms:

  • storage_cache.entry_bytes{namespace} — distribution of cached entry sizes. Sampled on populations_total increments.

Counters:

  • storage_cache.hits_total{namespace}
  • storage_cache.misses_total{namespace}
  • storage_cache.populations_total{namespace}
  • storage_cache.invalidations_total{namespace}
  • storage_cache.errors_total{namespace, op} — op ∈ get | set | delete | clear

Spans: one per public op, attributes storage.cache.namespace, storage.cache.key (or * for clear), storage.cache.outcome ∈ {hit, miss, populated, invalidated, error}. Keys are not URLs; no query-string redaction needed.

Trade-offs accepted

  • No range-byte caching. setPartial(key, offset, bytes) invalidates the whole entry. Avoids the overlapping-range invalidation puzzle. Consumers with random-access read workloads should compose differently (see Out of Scope).
  • No streaming-result caching. createReadStream / createWriteStream pass through. Cached streaming lives at the URI tier.
  • clear() scope is namespace-local when the cache exposes keys(), otherwise no-op with a warning. Two wrappers sharing one cache MUST NOT obliterate each other's entries. The fallback (no keys()) is conservative; the spec mandates the warning so operators see why their clear() was a no-op.
  • Negative results not cached. inner.get returning undefined is always a fresh check. Add later if a real workload requires it (needs a sentinel value and a separate TTL).
  • getStats() (inherited from AbstractStorage) vs. getCacheStats() (new on CachedStorage). The inherited stats count the inner storage's operations as observed through the wrapper; the new getCacheStats() counts the cache layer's behaviour. Two distinct surfaces; documented in the README.
  • One AbstractCache per wrapper. Two-tier caches (memory in front of OPFS in front of a remote storage) are out of scope. Compose by stacking two cachedStorage(...) wrappers if you really need that.
  • inner and cache exposed as readonly properties. Lets the consumer inspect both and bypass the wrapper deliberately (e.g. for cache.wrap() stale-while-revalidate). Same shape as drive-plugin-cache's handle.cache.

Composition recipes (in README)

// 1. Memory cache in front of S3 (server, hot keys)
import { S3Storage } from "@suaveplan/storage-s3";
import { createCache } from "@suaveplan/cache";
import { cachedStorage } from "@suaveplan/storage-plugin-cache";

const inner = new S3Storage({ bucket: "media", /* … */ });
const cache = createCache("memory", { policy: "lru", maxItems: 1_000 });
const storage = cachedStorage(inner, { cache, namespace: "media" });

// Drop straight into anything that takes an AbstractStorage:
const drive = new Drive({ defaultStorage: storage });

// 2. OPFS cache in front of HTTPS (browser, cross-session)
import { createOpfsStorage } from "@suaveplan/storage-browser-opfs";
import { createHttpStorage } from "@suaveplan/storage-http";

const inner = createHttpStorage({ baseUrl: "https://cdn.example.com/start/here/" });
const cache = createCache("storage", {
  storage: await createOpfsStorage("http-cache"),
  policy: "lru",
  maxBytes: 256 * 1024 * 1024,
});
const storage = cachedStorage(inner, { cache });

// 3. File cache in front of Bunny CDN (server, survives process restart)
import { createBunnyStorage } from "@suaveplan/storage-cdn-bunnycdn";
import { createFileStorage } from "@suaveplan/storage-file";

const inner = createBunnyStorage({ /* … */ });
const cache = createCache("storage", {
  storage: createFileStorage({ rootDir: "/var/cache/bunny" }),
  policy: "ttl",
  ttlMs: 3_600_000,
});
const storage = cachedStorage(inner, { cache, namespace: "bunny-prod" });

// 4. Redis cache in front of S3 (server, cross-process shared)
import { createRedisCache } from "@suaveplan/cache-redis";

const inner = new S3Storage({ /* … */ });
const cache = createRedisCache({ client, prefix: "media-cache:" });
const storage = cachedStorage(inner, { cache, namespace: "media" });

// 5. Stale-while-revalidate via cache.wrap()
const storage = cachedStorage(inner, { cache });
const bytes = await storage.cache.wrap(`media::clip.mov`, () => inner.get("clip.mov"), {
  staleAge: 60_000,
  refreshAhead: true,
});

// 6. Composed with drive-uri / storage-uri — the cache wrapper is just another StorageLike
import { createStorageUriResolver, fileUriPlugin } from "@suaveplan/storage-uri";

const cachedFile = cachedStorage(createFileStorage({ rootDir: "/var/media" }), { cache });
const resolver = createStorageUriResolver([
  fileUriPlugin({ rootDir: "/var/media", storage: cachedFile }),
  // … other plugins …
]);

Recipe 6 illustrates the structural payoff: anything that took AbstractStorage before takes CachedStorage now, with zero adjustment, including storage-uri plugins. The cache wrapper composes uniformly across the ecosystem.