14 KiB
Capability Spec: drive-plugin-cache
Package: @suaveplan/drive-plugin-cache
Every requirement carries a
**Verified by:**line.
Requirements
Requirement: driveCachePlugin(options) factory returns a DrivePlugin
The package SHALL export a driveCachePlugin(options): DrivePlugin<DriveCacheHandle> factory. The returned plugin's install(drive) SHALL register a DriveMiddleware on the supplied DriveLike via drive.useMiddleware(...) and SHALL return a DriveCacheHandle exposing getStats(), invalidate(path), clear(), and the cache reference passed in options.
Scenario: install registers middleware on the drive
- WHEN
drive.usePlugin(driveCachePlugin({ cache }))is called against a realDriveinstance - THEN
drive.useMiddlewareis invoked exactly once with an object exposingread,write, anddeletemiddleware methods
Scenario: install returns a DriveCacheHandle with the same cache reference
- WHEN
drive.usePlugin(driveCachePlugin({ cache }))resolves to a handle - THEN
handle.cache === cache(same reference),typeof handle.getStats === "function",typeof handle.invalidate === "function",typeof handle.clear === "function"
Verified by: src/plugin/plugin.test.ts::"install registers a DriveMiddleware on the drive" AND src/plugin/plugin.test.ts::"install returns a DriveCacheHandle with the same cache reference"
Requirement: Construction-time validation of required options
The driveCachePlugin(options) factory SHALL throw DRIVE_CACHE.INVALID_OPTIONS synchronously when options.cache is missing or is not a function-typed get/set/delete carrier. The factory MUST NOT defer this check to first use.
Scenario: missing cache throws INVALID_OPTIONS
- WHEN
driveCachePlugin({} as unknown as DriveCacheOptions)is called - THEN the call throws
DriveCacheErrorwithcode === "DRIVE_CACHE.INVALID_OPTIONS"
Scenario: cache without get/set/delete methods throws INVALID_OPTIONS
- WHEN
driveCachePlugin({ cache: { name: "x" } as unknown as AbstractCache<Uint8Array> })is called - THEN the call throws
DriveCacheErrorwithcode === "DRIVE_CACHE.INVALID_OPTIONS"
Verified by: src/plugin/plugin.test.ts::"throws INVALID_OPTIONS when cache is missing" AND src/plugin/plugin.test.ts::"throws INVALID_OPTIONS when cache lacks get/set/delete"
Requirement: Read middleware returns cached bytes on hit
When the middleware's read(ctx, next) is invoked and cache.get(key) resolves to a Uint8Array, the middleware SHALL return those bytes WITHOUT invoking next(). The cache key SHALL be ${namespace}::${ctx.path} where namespace defaults to "drive".
Scenario: hit short-circuits next()
- WHEN
drive.read("clip.mov")is called andcache.get("drive::clip.mov")resolves to bytes - THEN the returned value equals those bytes AND the inner storage's
readFileis NOT invoked ANDstats.hits === 1
Scenario: custom namespace is used in the key
- WHEN the plugin is installed with
{ cache, namespace: "media" }anddrive.read("clip.mov")is called - THEN
cache.get("media::clip.mov")is invoked exactly once
Verified by: src/middleware/middleware.test.ts::"hit short-circuits next() and returns cached bytes" AND src/middleware/middleware.test.ts::"uses the configured namespace in the cache key"
Requirement: Read middleware populates the cache on miss
When cache.get(key) returns undefined, the middleware SHALL invoke next() to obtain plaintext bytes from the next middleware (or the storage), SHALL call cache.set(key, bytes) if shouldCache(path, bytes) returns true, and SHALL return the bytes. The default shouldCache SHALL return true when bytes.byteLength <= 16 * 1024 * 1024 (16 MiB).
Scenario: miss invokes next() and populates the cache
- WHEN
drive.read("x.bin")is called andcache.get("drive::x.bin")returnsundefined - THEN
next()is invoked exactly once ANDcache.set("drive::x.bin", returnedBytes)is invoked exactly once ANDstats.misses === 1ANDstats.populations === 1
Scenario: shouldCache predicate gates population
- WHEN the plugin is installed with
{ cache, shouldCache: () => false }anddrive.read("x.bin")is called on a miss - THEN
next()is invoked exactly once ANDcache.setis NOT invoked ANDstats.populations === 0
Scenario: default maxEntryBytes is 16 MiB
- WHEN the plugin is installed without
shouldCacheormaxEntryBytes, anddrive.read("big.bin")resolves to aUint8Arrayof16 * 1024 * 1024 + 1bytes - THEN
cache.setis NOT invoked ANDstats.populations === 0
Verified by: src/middleware/middleware.test.ts::"miss invokes next() and populates the cache" AND src/middleware/middleware.test.ts::"shouldCache predicate gates population" AND src/middleware/middleware.test.ts::"default maxEntryBytes is 16 MiB"
Requirement: Cache failures never propagate
When cache.get, cache.set, or cache.delete throws or rejects, the middleware SHALL increment stats.errors, SHALL record the failure via the OTel drive_cache.errors_total{namespace, op} counter, and SHALL NOT propagate the failure to the consumer. On get failure, the middleware SHALL fall through to next(). On set / delete failure, the middleware SHALL still return the bytes / boolean from next().
Scenario: cache.get rejection falls through to next()
- WHEN
drive.read("x")is called andcache.getrejects withnew Error("redis down") - THEN
next()is invoked AND the returned bytes matchnext()'s result AND the consumer sees no error ANDstats.errors === 1
Scenario: cache.set rejection does not propagate
- WHEN
drive.read("x")is called andcache.setrejects afternext()succeeds - THEN
drive.readresolves withnext()'s bytes ANDstats.errors === 1ANDstats.populations === 0
Scenario: cache.delete rejection on invalidation does not propagate
- WHEN
drive.write("x", bytes)succeeds andcache.deleterejects - THEN
drive.writeresolves successfully ANDstats.errors === 1ANDstats.invalidations === 0
Verified by: src/middleware/middleware.test.ts::"cache.get rejection falls through to next()" AND src/middleware/middleware.test.ts::"cache.set rejection does not propagate" AND src/middleware/middleware.invalidation.test.ts::"cache.delete rejection on invalidation does not propagate"
Requirement: Write invalidates the cache post-success
When the middleware's write(ctx, next) is invoked and invalidateOnWrite !== false, the middleware SHALL invoke next() first; on successful resolution it SHALL call cache.delete(key). When next() throws or rejects, the middleware SHALL propagate the throw WITHOUT invalidating the cache.
Scenario: successful write invalidates the cache entry
- WHEN
drive.write("x.bin", bytes)succeeds - THEN
cache.delete("drive::x.bin")is invoked exactly once ANDstats.invalidations === 1
Scenario: failed write does not invalidate
- WHEN
drive.write("x.bin", bytes)rejects because the underlying storage threw - THEN
cache.deleteis NOT invoked ANDstats.invalidations === 0
Scenario: invalidateOnWrite:false skips invalidation
- WHEN the plugin is installed with
{ cache, invalidateOnWrite: false }anddrive.write("x.bin", bytes)succeeds - THEN
cache.deleteis NOT invoked
Verified by: src/middleware/middleware.invalidation.test.ts::"successful write invalidates the cache entry" AND src/middleware/middleware.invalidation.test.ts::"failed write does not invalidate" AND src/middleware/middleware.invalidation.test.ts::"invalidateOnWrite:false skips invalidation"
Requirement: Delete invalidates the cache post-success
When the middleware's delete(ctx, next) is invoked and invalidateOnDelete !== false, the middleware SHALL invoke next() first; when next() resolves to true, the middleware SHALL call cache.delete(key). When next() resolves to false (the file did not exist), the middleware MAY still call cache.delete(key) to clean any stale entry. When next() throws, the middleware SHALL propagate the throw WITHOUT invalidating.
Scenario: successful delete invalidates the cache entry
- WHEN
drive.delete("x.bin")resolves totrue - THEN
cache.delete("drive::x.bin")is invoked exactly once ANDstats.invalidations === 1
Scenario: failed delete does not invalidate
- WHEN
drive.delete("x.bin")rejects because the storage threw - THEN
cache.deleteis NOT invoked
Verified by: src/middleware/middleware.invalidation.test.ts::"successful delete invalidates the cache entry" AND src/middleware/middleware.invalidation.test.ts::"failed delete does not invalidate"
Requirement: AbortSignal short-circuit on pre-aborted signal
When ctx.signal?.aborted === true at the time the middleware is invoked, the middleware SHALL skip its own work (no cache.get / cache.set / cache.delete calls) and SHALL invoke next() immediately so the inner middleware or storage raises the canonical DRIVE.ABORTED. Live signals SHALL be forwarded into cache.get / cache.set / cache.delete calls when the underlying AbstractCache accepts a signal.
Scenario: pre-aborted signal skips cache work
- WHEN
drive.read("x", { signal: AbortSignal.abort() })is called - THEN
cache.getis NOT invoked ANDnext()is invoked exactly once
Scenario: live signal is forwarded to cache operations
- WHEN
drive.read("x", { signal: liveSignal })is called and the underlying cache records the signal it received - THEN
cache.getreceivesliveSignalas the second argument (whenAbstractCache.getaccepts a signal)
Verified by: src/middleware/middleware.signal.test.ts::"pre-aborted signal skips cache work" AND src/middleware/middleware.signal.test.ts::"live signal is forwarded to cache operations"
Requirement: DriveCacheHandle exposes stats, invalidate, clear, and the cache reference
The handle returned from install() SHALL expose getStats() returning a monotonically-non-decreasing DriveCacheStats snapshot, invalidate(path) deleting the single cache entry for that path, clear() clearing the underlying cache, and a readonly cache reference equal (by ===) to the cache passed in options.cache.
Scenario: getStats returns monotonically increasing counters
- WHEN two hits, one miss, and one population are exercised
- THEN
getStats()returns{ hits: 2, misses: 1, populations: 1, invalidations: 0, errors: 0 }
Scenario: invalidate deletes one cache entry
- WHEN
handle.invalidate("x.bin")is called - THEN
cache.delete("drive::x.bin")is invoked exactly once ANDstats.invalidations === 1
Scenario: clear empties the underlying cache
- WHEN
handle.clear()is called - THEN
cache.clearis invoked exactly once
Verified by: src/handle/handle.test.ts::"getStats returns monotonically increasing counters" AND src/handle/handle.test.ts::"invalidate deletes one cache entry" AND src/handle/handle.test.ts::"clear empties the underlying cache"
Requirement: stat and list middleware passthrough
The middleware SHALL define stat(ctx, next) and list(ctx, next) methods that immediately return await next() without consulting the cache. The cache SHALL NOT be populated or invalidated by stat or list calls.
Scenario: stat passes through unchanged
- WHEN
drive.stat("x.bin")is called - THEN
cache.getis NOT invoked ANDcache.setis NOT invoked AND the returned value equalsnext()'s result
Scenario: list passes through unchanged
- WHEN
drive.list("folder/")is called - THEN
cache.getis NOT invoked ANDcache.setis NOT invoked AND the returned value equalsnext()'s result
Verified by: src/middleware/middleware.test.ts::"stat passes through without touching the cache" AND src/middleware/middleware.test.ts::"list passes through without touching the cache"
Requirement: OpenTelemetry instrumentation
Every middleware operation SHALL emit a span on the @suaveplan/drive-cache tracer with drive.cache.namespace and drive.cache.path attributes and a drive.cache.outcome attribute drawn from {"hit", "miss", "populated", "invalidated", "error"}. The plugin SHALL emit counters drive_cache.{hits, misses, populations, invalidations, errors}_total{namespace} and a drive_cache.entry_bytes{namespace} histogram observing each populations_total increment with the cached entry's byte length.
Scenario: cache hit increments hits_total and records a hit-outcome span
- WHEN a cache hit is exercised
- THEN
drive_cache.hits_total{namespace:"drive"}increments by 1 AND the emitted span hasdrive.cache.outcome === "hit"
Scenario: population records entry_bytes histogram
- WHEN a 4096-byte payload is cached on a miss
- THEN
drive_cache.entry_bytesrecords one observation of4096
Scenario: cache error increments errors_total with op label
- WHEN
cache.setrejects during population - THEN
drive_cache.errors_total{namespace:"drive", op:"set"}increments by 1
Verified by: src/otel/otel.test.ts::"hit increments hits_total and records hit-outcome span" AND src/otel/otel.test.ts::"population records entry_bytes histogram" AND src/otel/otel.test.ts::"cache error increments errors_total with op label"
Requirement: Multi-namespace isolation on a shared cache
Two driveCachePlugin instances installed against two Drive instances with the SAME AbstractCache reference but DISTINCT namespace strings SHALL NOT observe each other's entries. A read on drive A with namespace "a" and a read on drive B with namespace "b" for the same path SHALL produce independent cache hits / misses.
Scenario: distinct namespaces isolate entries on a shared cache
- WHEN plugin A (namespace
"a") populates"clip.mov"and plugin B (namespace"b") then reads"clip.mov" - THEN plugin B observes a miss AND
cache.get("a::clip.mov")andcache.get("b::clip.mov")are distinct calls
Verified by: tests/integration/with-namespaced-caches.test.ts::"distinct namespaces isolate entries on a shared cache"