14 KiB
Add @suaveplan/drive-plugin-cache — read-through cache middleware for Drive
Package: @suaveplan/drive-plugin-cache
Status: PROPOSED
Type: new-package (genesis-original)
Depends on: @suaveplan/drive (archived), @suaveplan/cache family (archived as add-cache-storage-redis-backends on 2026-05-16)
- Design: ./design.md
- Spec delta: ./specs/drive-plugin-cache/spec.md
Why
@suaveplan/drive exposes a DrivePlugin / DriveMiddleware pair as its extension surface: middleware can intercept read / write / delete / stat / list calls with a (ctx, next) continuation pattern. Today no shipped package fills the obvious slot — a read-through cache that sits between consumers and the storage tier, returning hot plaintext bytes from a fast in-memory or OPFS-backed cache instead of re-running the full plugin chain (manifest fetch, peer pull, AEAD verify, reassembly).
The cache infrastructure is already complete and unified:
@suaveplan/cache—AbstractCache<T>,CacheFactory,CacheProviderRegistry, four eviction policies (lru,lfu,fifo,ttl), lifecycle events,wrap()with stale-while-revalidate, namespacing, structuredCACHE_*codes.@suaveplan/cache-storage—StorageCache<T>over anyAbstractStorage(so OPFS / IndexedDB / file / S3 / CDN-backed caches Just Work).@suaveplan/cache-redis—RedisCache<T>overRedisLike.- Built-in
MemoryCacheregistered as"memory".
The result: createCache("memory" | "storage" | "redis", config) is the cache-of-your-choice surface, and every present and future AbstractStorage adapter is automatically a cache backend. What's missing is the consumer that wires Drive.read to that surface.
drive-plugin-cache is that consumer: a tiny middleware (~150 LOC) that accepts a caller-supplied AbstractCache<Uint8Array>, intercepts every read(path) to check the cache first, and populates on miss. Writes / deletes invalidate the corresponding entry. The cache itself is opaque to the plugin — consumers pick the backend, the eviction policy, the TTL, the namespace, the codec, the redis/storage instance — and the plugin just talks AbstractCache.
This closes the most common drive consumer-pattern complaint: every audio/video pipeline today calls drive.read("clip.mov") repeatedly across renders and pays the full reassembly cost each time. With this plugin, the second-and-onward reads of a hot path return cached bytes in microseconds.
What changes
- ADDED:
driveCachePlugin(options): DrivePlugin<DriveCacheHandle>factory. Returns aDrivePluginwhoseinstall(drive)registers aDriveMiddlewarethat interceptsread(and optionallywrite/deletefor invalidation) on the suppliedDriveLike. The factory accepts the cache instance, an invalidation policy, and an optional namespace. - ADDED:
DriveCacheOptionsinterface —{ cache: AbstractCache<Uint8Array>; invalidateOnWrite?: boolean; invalidateOnDelete?: boolean; namespace?: string; shouldCache?: (path: string, bytes: Uint8Array) => boolean; maxEntryBytes?: number }. - ADDED:
DriveCacheHandleinterface — the plugin handle returned frominstall(). ExposesgetStats()(hits, misses, populations, evictions, invalidations),invalidate(path),clear(), andcache(the underlyingAbstractCachereference for advanced wiring likewrap()with stale-while-revalidate). - ADDED: Read-through middleware. On
read(ctx, next):- Compute the cache key as
${namespace ?? "drive"}::${ctx.path}. await cache.get(key). If hit, return bytes; emitcache_hitevent; incrementdrive_cache.hits_total{namespace}.- On miss,
await next()to get plaintext from the next middleware (or the storage). Incrementdrive_cache.misses_total{namespace}. - If
shouldCache(path, bytes)returns true (default:bytes.byteLength <= maxEntryBytes ?? 16 MiB),await cache.set(key, bytes). Incrementdrive_cache.populations_total{namespace}. Errors fromcache.setSHALL be swallowed (logged viaLoggerLikewhen wired) — a cache write failure MUST NOT propagate to the consumer. - Return bytes.
- Compute the cache key as
- ADDED: Write/delete invalidation. On
write(ctx, next), ifinvalidateOnWrite !== false, runnext()first; on success,await cache.delete(key)(the cached plaintext is now stale). Symmetric ondelete(ctx, next)wheninvalidateOnDelete !== false. Errors fromcache.deleteSHALL NOT propagate. - ADDED:
stat/listpassthrough — the middleware does NOT cache stat or list results in this iteration (cache invalidation acrosslistprefixes is non-trivial; out of scope).statandlistSHALL pass throughnext()unchanged. - ADDED: AbortSignal threading — the middleware does NOT swallow
AbortSignal. The signal inctx(when present) is forwarded intocache.get/cache.set/cache.deletecalls; pre-aborted signals SHALL cause the middleware to skip its own work and callnext()immediately (the inner middleware/storage layer will surfaceDRIVE.ABORTED). - ADDED:
DRIVE_CACHE_CODESregistry viadefineCodes("DRIVE_CACHE", [...] as const)— 3 codes:INVALID_OPTIONS,CACHE_TOO_LARGE,INSTALL_FAILED. The plugin's own errors are deliberately few — most failure modes come from the underlyingAbstractCacheorDrive, which carry their ownCACHE_*/DRIVE_*codes that pass through this middleware unchanged. - ADDED:
DriveCacheError extends SuaveplanError<DriveCacheErrorCode>withpath?andnamespace?discriminants. - ADDED: OTel surface —
drive_cache.{hits,misses,populations,invalidations,errors}_total{namespace}counters;drive_cache.entry_byteshistogram (distribution of cached entry sizes); spans on@suaveplan/drive-cachetracer wrapping eachread/write/deletemiddleware invocation withdrive.cache.namespace,drive.cache.path,drive.cache.outcomeattributes. - ADDED: README composition recipes — memory cache (default), OPFS-backed cache via
cache-storage+storage-browser-opfs, file-backed cache viacache-storage+storage-file, Redis-backed cache viacache-redis, namespaced cache (multi-drive isolation),wrap()-based stale-while-revalidate.
Out of scope
- Caching
Drive.statandDrive.listresults.statis cheap;listinvalidation across prefix mutations is non-trivial (any write underfoo/would have to invalidate every cachedlist("foo/*")result that happened to enumerate the written path). A futureadd-drive-plugin-cache-listchange can take that on; reading is the dominant use case for this iteration. - Partial reads / range caching.
Drive.createReadStreamis not intercepted by this middleware — it bypasses thereadmiddleware entirely. Range-aware caching belongs in a future iteration paired withadd-drive-mesh-range-reads. The plugin SHALL document this constraint; consumers needing cached streaming should compose at the URI layer with the futurecacheUriPlugin. - Per-tenant key partitioning. The
namespaceoption gives one knob; multi-tenant per-request scoping (e.g. arequest.tenantIdextracted fromctx) would require either akeyFn(ctx)extension or aNamespacedCachefactory per request. Out of scope until a concrete consumer needs it. - Cache-aside
wrap()integration baked in. The plugin exposes the underlying cache viahandle.cache, so consumers can callcache.wrap(key, loader, { staleAge, refreshAhead })themselves. Wiringwrap()directly into the middleware would lock the policy choices thatwrap()deliberately leaves to the caller (stale-while-revalidate vs. lock-and-load vs. fail-open). Out of scope. - A
cacheUriPluginfor the URI resolver. Different but related concern; will be a sibling proposal (add-storage-uri-cache-plugin). Drive caches plaintext bytes at the drive-middleware tier; URI cache plugins cacheStorageLike.getresults across schemes. Some consumers will want both. AbstractCache<unknown>support. This plugin cachesUint8Arrayonly — drive reads return bytes. GenericTwould require aCodec<T>on every call site that has no actual variation;cache-storagealready coversCodec<T>for the storage-backed cache itself.
Source mapping
This is a genesis-original package; no upstream suaveplan/ reference. Requirements derive from:
- The
DrivePlugin/DriveMiddleware/MiddlewareContext/MiddlewareNextcontract in@suaveplan/drive(openspec/specs/drive/spec.md). - The
AbstractCache<T>contract in@suaveplan/cache(openspec/specs/cache/spec.md). - The
CacheProviderRegistryfactory pattern fromadd-cache-storage-redis-backends(archived 2026-05-16).
Requirements with no source line
| Requirement | Justification |
|---|---|
| Cache failures SHALL NOT propagate to the consumer | A cache is a perf optimisation, not a correctness boundary. A failed cache.set must not turn a successful drive.read into a thrown error — the bytes are still returned. The OTel surface records the failure (drive_cache.errors_total); the consumer never sees it. |
shouldCache default is "byte-length ≤ 16 MiB" |
An unbounded cache turns a large-file read into a memory-pressure event for the entire process. 16 MiB is the same ceiling drive-mesh-protocol's MAX_FRAME_BYTES uses for chunk framing — known-safe in every consumer that's already running drive. |
| Invalidation is post-success, not optimistic | Optimistic invalidation (cache.delete before next()) opens a race window where a concurrent read repopulates the cache with stale bytes during the write. Post-success keeps the cache consistent with whatever storage actually holds. |
stat/list passthrough (not cached) |
Listed in Out of Scope. stat is cheap enough that caching it costs more than it saves on most stores; list invalidation is a separate design problem. |
| Plugin owns only 3 error codes | The interesting failures are upstream (CACHE.*, DRIVE.*) and pass through unchanged. Allocating a wide error registry here would be code-bloat and would mask the real diagnostic surface. |
cache parameter is AbstractCache<Uint8Array>, not a CacheBackendKind enum |
Lets the consumer fully configure the cache before passing it in — eviction policy, TTL, lifecycle events, namespacing, redis/storage instance, codec — without drive-plugin-cache having to mirror every cache option. One injectable, one responsibility. |
Verification approach
Every requirement in the spec delta carries a **Verified by:** line referencing concrete planned it(...) titles in these files:
src/plugin/plugin.test.ts— factory validates options, installs asDrivePlugin, registersDriveMiddlewareon the supplied drive, returnsDriveCacheHandle.src/middleware/middleware.test.ts— cache hit short-circuitsnext(), cache miss populates afternext(),shouldCachegates population,maxEntryBytesdefault 16 MiB enforced,cache.setfailure does NOT propagate,cache.getfailure does NOT propagate (falls through tonext()).src/middleware/middleware.invalidation.test.ts—writeinvalidates post-success,writedoes NOT invalidate whennext()throws,deletesymmetric,invalidateOnWrite: falseskips invalidation,cache.deletefailure does NOT propagate.src/middleware/middleware.signal.test.ts— pre-aborted signal short-circuits tonext()without invoking cache; live signal threads intocache.get/set/delete.src/handle/handle.test.ts—getStats()returns monotonically-increasing counters;invalidate(path)deletes one entry;clear()clears the underlying cache;handle.cacheis the exact reference passed inoptions.cache.src/error-codes/error-codes.test.ts— exhaustiveDRIVE_CACHE_CODESregistry,DriveCacheErrordiscriminant fields, every code reachable from a real call site.src/otel/otel.test.ts—hits_total/misses_total/populations_total/invalidations_total/errors_totalincrement as scenarios fire;entry_byteshistogram observes population events; query-string-equivalent path redaction confirmed (paths are not URLs, so no redaction needed — verified by absence of any URL-shaped attribute).tests/integration/with-memory-cache.test.ts— realMemoryCache+ realDriveover an in-memoryDriveStorage: write bytes viadrive.write, read twice, assert second read came from cache (hits === 1).tests/integration/with-storage-cache.test.ts— realStorageCacheover realstorage-fileFileStorage+ realDrive: identical scenario, asserts the cache file appears on disk and surviving a freshdriveCachePlugininstall re-hydrates from disk.tests/integration/with-namespaced-caches.test.ts— twodriveCachePlugininstances with distinctnamespaces share one underlyingAbstractCachebut do not see each other's entries.
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/drive-plugin-cache to enumerate. The package is new — no dependents currently. After adoption, consumers that already call drive.read(path) repeatedly across renders/requests (audio/video pipelines, asset hydration, drive-template fixtures) benefit directly.
- No dependents (verified by
bunx turbo ls --filter=...@suaveplan/drive-plugin-cachereturning the package itself).
Affected upstreams (none modified)
| Package | Action | Breaking? |
|---|---|---|
@suaveplan/drive |
None — DrivePlugin / DriveMiddleware / MiddlewareContext consumed as-is |
no |
@suaveplan/cache |
None — AbstractCache<Uint8Array> consumed as-is |
no |
@suaveplan/cache-storage |
None — used only as a devDep for the integration test | no |
@suaveplan/storage-file |
None — used only as a devDep for the integration test | no |
drive-plugin-cache MUST NOT silently grow any of these packages' contracts. If a needed extension turns up during implementation (e.g. MiddlewareContext not carrying signal), capture it as a separate proposal against the relevant upstream — never bend the contract here.
Ordering
This change is unblocked: @suaveplan/drive and the @suaveplan/cache* family both ship today on genesis with the consumed surface intact.