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 + tracertslib
@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 onpopulations_totalincrements.
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/createWriteStreampass through. Cached streaming lives at the URI tier. clear()scope is namespace-local when the cache exposeskeys(), otherwise no-op with a warning. Two wrappers sharing one cache MUST NOT obliterate each other's entries. The fallback (nokeys()) is conservative; the spec mandates the warning so operators see why theirclear()was a no-op.- Negative results not cached.
inner.getreturningundefinedis always a fresh check. Add later if a real workload requires it (needs a sentinel value and a separate TTL). getStats()(inherited fromAbstractStorage) vs.getCacheStats()(new onCachedStorage). The inherited stats count the inner storage's operations as observed through the wrapper; the newgetCacheStats()counts the cache layer's behaviour. Two distinct surfaces; documented in the README.- One
AbstractCacheper wrapper. Two-tier caches (memory in front of OPFS in front of a remote storage) are out of scope. Compose by stacking twocachedStorage(...)wrappers if you really need that. innerandcacheexposed as readonly properties. Lets the consumer inspect both and bypass the wrapper deliberately (e.g. forcache.wrap()stale-while-revalidate). Same shape asdrive-plugin-cache'shandle.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.