# Design — @suaveplan/drive-plugin-cache ## Tier and dependency posture Universal tier. Zero `node:` / `bun:` imports in `src/`. Runtime deps: - `@suaveplan/error` — `defineCodes`, `SuaveplanError` - `@suaveplan/drive` — `DrivePlugin`, `DriveMiddleware`, `MiddlewareContext`, `MiddlewareNext`, `DriveLike` (type-only) - `@suaveplan/cache` — `AbstractCache` (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-browser-*` are **NOT** runtime deps. The consumer wires their preferred cache backend before passing the `AbstractCache` instance into `driveCachePlugin(options)`. Same posture as `drive-uri` — the plugin sees an interface, not a concrete provider. DevDeps: `@suaveplan/testing`, `@suaveplan/storage`, `@suaveplan/storage-file`, `@suaveplan/cache-storage`, `@vitest/coverage-istanbul`, `fast-check`, `typescript`, `vite`, `vite-plugin-dts`, `vitest`. The cache-storage + storage-file pair is needed only for the integration test that exercises a real disk-backed cache surviving plugin reinstall. ## File layout ```text packages/drive/drive-plugin-cache/ package.json README.md src/ index.ts # explicit named re-exports (rule 10) error-codes/ error-codes.ts # defineCodes("DRIVE_CACHE", [...]), DriveCacheError error-codes.test.ts error-codes.md index.ts types/ options.types.ts # DriveCacheOptions, DriveCacheHandle, DriveCacheStats options.types.md index.ts handle/ handle.ts # DriveCacheHandle implementation backing the factory's install() handle.test.ts handle.md index.ts middleware/ middleware.ts # The DriveMiddleware (read intercept) middleware.invalidation.ts # write/delete invalidation hooks middleware.signal.ts # AbortSignal short-circuit helpers middleware.test.ts middleware.invalidation.test.ts middleware.signal.test.ts middleware.md middleware.invalidation.md middleware.signal.md index.ts plugin/ plugin.ts # driveCachePlugin(options): DrivePlugin plugin.test.ts plugin.md index.ts otel/ otel.ts # meter + tracer wiring, recordOp(...) otel.test.ts otel.md index.ts tests/ setup/polyfills.ts integration/ with-memory-cache.test.ts # real Drive + real MemoryCache with-storage-cache.test.ts # real Drive + real StorageCache over storage-file with-namespaced-caches.test.ts # two plugin instances, distinct namespaces, one shared cache tsconfig.json tsconfig.lib.json vite.config.ts vitest.config.ts ``` Per AGENTS §10: `handle/`, `middleware/`, `plugin/` ancestors with un-suffixed children (`middleware.ts`, not `middleware-impl.ts`). One feature per folder. Every implementation `.ts` (excluding `*.test.ts`, `*.types.ts` inside feature dirs, `index.ts` barrels) has a sibling `.md`. ## Plugin shape ```ts export interface DriveCacheOptions { cache: AbstractCache; invalidateOnWrite?: boolean; // default true invalidateOnDelete?: boolean; // default true namespace?: string; // default "drive" shouldCache?: (path: string, bytes: Uint8Array) => boolean; maxEntryBytes?: number; // default 16 * 1024 * 1024 logger?: LoggerLike; } export interface DriveCacheHandle { readonly cache: AbstractCache; getStats(): DriveCacheStats; invalidate(path: string): Promise; clear(): Promise; } export interface DriveCacheStats { readonly hits: number; readonly misses: number; readonly populations: number; readonly invalidations: number; readonly errors: number; } export function driveCachePlugin( options: DriveCacheOptions ): DrivePlugin; ``` ```ts // usage const cache = createCache("memory", { policy: "lru", maxItems: 10_000 }); const drive = new Drive({ /* … */ }); await drive.usePlugin(driveCachePlugin({ cache, namespace: "drive-a" })); const bytes1 = await drive.read("clip.mov"); // miss → next() → populate const bytes2 = await drive.read("clip.mov"); // hit → return cached ``` ## Read middleware ```ts async read(ctx, next) { const key = `${namespace}::${ctx.path}`; if (ctx.signal?.aborted) return next(); // short-circuit; let next() raise DRIVE.ABORTED let cached: Uint8Array | undefined; try { cached = await cache.get(key); } catch (err) { stats.errors += 1; recordError("get", err); // Fall through — a get failure is not a correctness boundary. } if (cached !== undefined) { stats.hits += 1; recordHit(key); return cached; } stats.misses += 1; recordMiss(key); const bytes = await next(); if (shouldCachePredicate(ctx.path, bytes)) { try { await cache.set(key, bytes); stats.populations += 1; recordPopulate(key, bytes.byteLength); } catch (err) { stats.errors += 1; recordError("set", err); // Swallow — cache write failure must not propagate. } } return bytes; } ``` The error-swallowing on `cache.get` / `cache.set` / `cache.delete` is the core correctness rule: **a cache is a perf optimisation, not a correctness boundary.** A failed cache call must not turn a successful drive read into a thrown error. ## Invalidation middleware ```ts async write(ctx, next) { await next(); // write succeeds first if (invalidateOnWrite) await invalidate(ctx.path); } async delete(ctx, next) { const removed = await next(); if (removed && invalidateOnDelete) await invalidate(ctx.path); return removed; } async invalidate(path: string) { try { await cache.delete(`${namespace}::${path}`); stats.invalidations += 1; recordInvalidate(path); } catch (err) { stats.errors += 1; recordError("delete", err); } } ``` Post-success invalidation is deliberate. Optimistic (pre-`next()`) invalidation opens a race window where a concurrent read between the `cache.delete` and the storage write repopulates the cache with the *old* bytes that the write was about to replace. ## `shouldCache` default ```ts const DEFAULT_MAX_ENTRY_BYTES = 16 * 1024 * 1024; // 16 MiB function defaultShouldCache(path: string, bytes: Uint8Array): boolean { return bytes.byteLength <= DEFAULT_MAX_ENTRY_BYTES; } ``` 16 MiB matches `drive-mesh-protocol`'s `MAX_FRAME_BYTES`. Empirically safe in every consumer already running drive. Bypass by passing a custom `shouldCache` for use cases that intentionally cache larger blobs. ## OTel surface Tracer + meter: `@suaveplan/drive-cache`. Histograms: - `drive_cache.entry_bytes{namespace}` — distribution of cached entry sizes. Sampled on `populations_total` increments. Counters: - `drive_cache.hits_total{namespace}` - `drive_cache.misses_total{namespace}` - `drive_cache.populations_total{namespace}` - `drive_cache.invalidations_total{namespace}` - `drive_cache.errors_total{namespace, op}` — `op ∈ get | set | delete` Spans: one per middleware-intercept, attributes `drive.cache.namespace`, `drive.cache.path`, `drive.cache.outcome ∈ hit | miss | populated | invalidated | error`. Paths are not URLs so no query-string redaction is needed; the path *is* the discriminant operators want to see. ## Cache key shape `${namespace}::${path}` — the `::` separator avoids accidental collision with paths that contain `/`. Namespace defaults to `"drive"`; multi-drive consumers pass distinct namespaces and share one `AbstractCache` instance with no cross-talk. A future iteration may switch to a typed key (`{ namespace, path }`) once we have a use case that needs to scan by namespace; today the flat string is simplest and matches how `AbstractCache.delete` / `wrap` work. ## Trade-offs accepted - **No `stat` / `list` caching.** See proposal Out of Scope. The list-invalidation problem is genuinely hard and orthogonal; this iteration owns reads only. - **No streaming-read interception.** `Drive.createReadStream(path)` bypasses middleware entirely (drive's design choice). Cached streaming belongs at the URI layer where the resolver owns the streaming surface; the planned `cacheUriPlugin` can wrap `drive://` URIs and cache stream output. - **One `AbstractCache` per plugin instance.** Two-tier caches (e.g. memory in front of OPFS in front of storage) are out of scope. Consumers can compose them by passing a `NamespacedCache` over a layered cache they construct themselves. - **Cache write failures swallow without retry.** A retrying cache layer belongs inside the `AbstractCache` implementation (e.g. via `retry` middleware); the plugin trusts the cache to honour its own contract. - **Synchronous `getStats()`.** Stats are kept in a per-handle counter object updated inline. Reads of `getStats()` are O(1) and lock-free; perfectly adequate for occasional operator inspection. ## Composition recipes (in README) ```ts // 1. Memory cache (zero deps, ephemeral, fastest) const cache = createCache("memory", { policy: "lru", maxItems: 10_000 }); await drive.usePlugin(driveCachePlugin({ cache })); // 2. OPFS-backed cache (browser, survives reloads) import { createOpfsStorage } from "@suaveplan/storage-browser-opfs"; const cache = createCache("storage", { storage: await createOpfsStorage("drive-cache"), policy: "lru", maxBytes: 256 * 1024 * 1024, }); await drive.usePlugin(driveCachePlugin({ cache, namespace: "media" })); // 3. File-backed cache (server, survives process restart) import { createFileStorage } from "@suaveplan/storage-file"; const cache = createCache("storage", { storage: createFileStorage({ rootDir: "/var/cache/drive" }), policy: "ttl", ttlMs: 3600_000, }); await drive.usePlugin(driveCachePlugin({ cache })); // 4. Redis-backed cache (server, cross-process shared) import { createRedisCache } from "@suaveplan/cache-redis"; const cache = createRedisCache({ client, prefix: "drive-cache:" }); await drive.usePlugin(driveCachePlugin({ cache, namespace: "media" })); // 5. Stale-while-revalidate via cache.wrap() const handle = await drive.usePlugin(driveCachePlugin({ cache })); const bytes = await handle.cache.wrap(`drive::clip.mov`, () => drive.read("clip.mov"), { staleAge: 60_000, refreshAhead: true, }); ``` Recipe 5 illustrates why the plugin exposes `handle.cache`: stale-while-revalidate, lock-and-load, refresh-ahead, and every other policy that `wrap()` already supports stays one composition step away — without the plugin having to grow options for each.