# 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` 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 real `Drive` instance - **THEN** `drive.useMiddleware` is invoked exactly once with an object exposing `read`, `write`, and `delete` middleware 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 `DriveCacheError` with `code === "DRIVE_CACHE.INVALID_OPTIONS"` #### Scenario: cache without get/set/delete methods throws INVALID_OPTIONS - **WHEN** `driveCachePlugin({ cache: { name: "x" } as unknown as AbstractCache })` is called - **THEN** the call throws `DriveCacheError` with `code === "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 and `cache.get("drive::clip.mov")` resolves to bytes - **THEN** the returned value equals those bytes AND the inner storage's `readFile` is NOT invoked AND `stats.hits === 1` #### Scenario: custom namespace is used in the key - **WHEN** the plugin is installed with `{ cache, namespace: "media" }` and `drive.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 and `cache.get("drive::x.bin")` returns `undefined` - **THEN** `next()` is invoked exactly once AND `cache.set("drive::x.bin", returnedBytes)` is invoked exactly once AND `stats.misses === 1` AND `stats.populations === 1` #### Scenario: shouldCache predicate gates population - **WHEN** the plugin is installed with `{ cache, shouldCache: () => false }` and `drive.read("x.bin")` is called on a miss - **THEN** `next()` is invoked exactly once AND `cache.set` is NOT invoked AND `stats.populations === 0` #### Scenario: default maxEntryBytes is 16 MiB - **WHEN** the plugin is installed without `shouldCache` or `maxEntryBytes`, and `drive.read("big.bin")` resolves to a `Uint8Array` of `16 * 1024 * 1024 + 1` bytes - **THEN** `cache.set` is NOT invoked AND `stats.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 and `cache.get` rejects with `new Error("redis down")` - **THEN** `next()` is invoked AND the returned bytes match `next()`'s result AND the consumer sees no error AND `stats.errors === 1` #### Scenario: cache.set rejection does not propagate - **WHEN** `drive.read("x")` is called and `cache.set` rejects after `next()` succeeds - **THEN** `drive.read` resolves with `next()`'s bytes AND `stats.errors === 1` AND `stats.populations === 0` #### Scenario: cache.delete rejection on invalidation does not propagate - **WHEN** `drive.write("x", bytes)` succeeds and `cache.delete` rejects - **THEN** `drive.write` resolves successfully AND `stats.errors === 1` AND `stats.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 AND `stats.invalidations === 1` #### Scenario: failed write does not invalidate - **WHEN** `drive.write("x.bin", bytes)` rejects because the underlying storage threw - **THEN** `cache.delete` is NOT invoked AND `stats.invalidations === 0` #### Scenario: invalidateOnWrite:false skips invalidation - **WHEN** the plugin is installed with `{ cache, invalidateOnWrite: false }` and `drive.write("x.bin", bytes)` succeeds - **THEN** `cache.delete` is 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 to `true` - **THEN** `cache.delete("drive::x.bin")` is invoked exactly once AND `stats.invalidations === 1` #### Scenario: failed delete does not invalidate - **WHEN** `drive.delete("x.bin")` rejects because the storage threw - **THEN** `cache.delete` is 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.get` is NOT invoked AND `next()` 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.get` receives `liveSignal` as the second argument (when `AbstractCache.get` accepts 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 AND `stats.invalidations === 1` #### Scenario: clear empties the underlying cache - **WHEN** `handle.clear()` is called - **THEN** `cache.clear` is 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.get` is NOT invoked AND `cache.set` is NOT invoked AND the returned value equals `next()`'s result #### Scenario: list passes through unchanged - **WHEN** `drive.list("folder/")` is called - **THEN** `cache.get` is NOT invoked AND `cache.set` is NOT invoked AND the returned value equals `next()`'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 has `drive.cache.outcome === "hit"` #### Scenario: population records entry_bytes histogram - **WHEN** a 4096-byte payload is cached on a miss - **THEN** `drive_cache.entry_bytes` records one observation of `4096` #### Scenario: cache error increments errors_total with op label - **WHEN** `cache.set` rejects 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")` and `cache.get("b::clip.mov")` are distinct calls **Verified by:** `tests/integration/with-namespaced-caches.test.ts::"distinct namespaces isolate entries on a shared cache"`