Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,348 @@
# 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
```text
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
```ts
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`)
```ts
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`)
```ts
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
```ts
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)
```ts
// 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.