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,270 @@
# 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<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-browser-*` are **NOT** runtime deps. The consumer wires their preferred cache backend before passing the `AbstractCache<Uint8Array>` 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<DriveCacheHandle>
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<Uint8Array>;
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<Uint8Array>;
getStats(): DriveCacheStats;
invalidate(path: string): Promise<void>;
clear(): Promise<void>;
}
export interface DriveCacheStats {
readonly hits: number;
readonly misses: number;
readonly populations: number;
readonly invalidations: number;
readonly errors: number;
}
export function driveCachePlugin(
options: DriveCacheOptions
): DrivePlugin<DriveCacheHandle>;
```
```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.

View File

@@ -0,0 +1,111 @@
# 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, structured `CACHE_*` codes.
- `@suaveplan/cache-storage` — `StorageCache<T>` over any `AbstractStorage` (so OPFS / IndexedDB / file / S3 / CDN-backed caches Just Work).
- `@suaveplan/cache-redis` — `RedisCache<T>` over `RedisLike`.
- Built-in `MemoryCache` registered 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 a `DrivePlugin` whose `install(drive)` registers a `DriveMiddleware` that intercepts `read` (and optionally `write` / `delete` for invalidation) on the supplied `DriveLike`. The factory accepts the cache instance, an invalidation policy, and an optional namespace.
- ADDED: `DriveCacheOptions` interface — `{ cache: AbstractCache<Uint8Array>; invalidateOnWrite?: boolean; invalidateOnDelete?: boolean; namespace?: string; shouldCache?: (path: string, bytes: Uint8Array) => boolean; maxEntryBytes?: number }`.
- ADDED: `DriveCacheHandle` interface — the plugin handle returned from `install()`. Exposes `getStats()` (hits, misses, populations, evictions, invalidations), `invalidate(path)`, `clear()`, and `cache` (the underlying `AbstractCache` reference for advanced wiring like `wrap()` with stale-while-revalidate).
- ADDED: Read-through middleware. On `read(ctx, next)`:
1. Compute the cache key as `${namespace ?? "drive"}::${ctx.path}`.
2. `await cache.get(key)`. If hit, return bytes; emit `cache_hit` event; increment `drive_cache.hits_total{namespace}`.
3. On miss, `await next()` to get plaintext from the next middleware (or the storage). Increment `drive_cache.misses_total{namespace}`.
4. If `shouldCache(path, bytes)` returns true (default: `bytes.byteLength <= maxEntryBytes ?? 16 MiB`), `await cache.set(key, bytes)`. Increment `drive_cache.populations_total{namespace}`. Errors from `cache.set` SHALL be swallowed (logged via `LoggerLike` when wired) — a cache write failure MUST NOT propagate to the consumer.
5. Return bytes.
- ADDED: Write/delete invalidation. On `write(ctx, next)`, if `invalidateOnWrite !== false`, run `next()` first; on success, `await cache.delete(key)` (the cached plaintext is now stale). Symmetric on `delete(ctx, next)` when `invalidateOnDelete !== false`. Errors from `cache.delete` SHALL NOT propagate.
- ADDED: `stat` / `list` passthrough — the middleware does NOT cache stat or list results in this iteration (cache invalidation across `list` prefixes is non-trivial; out of scope). `stat` and `list` SHALL pass through `next()` unchanged.
- ADDED: AbortSignal threading — the middleware does NOT swallow `AbortSignal`. The signal in `ctx` (when present) is forwarded into `cache.get` / `cache.set` / `cache.delete` calls; pre-aborted signals SHALL cause the middleware to skip its own work and call `next()` immediately (the inner middleware/storage layer will surface `DRIVE.ABORTED`).
- ADDED: `DRIVE_CACHE_CODES` registry via `defineCodes("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 underlying `AbstractCache` or `Drive`, which carry their own `CACHE_*` / `DRIVE_*` codes that pass through this middleware unchanged.
- ADDED: `DriveCacheError extends SuaveplanError<DriveCacheErrorCode>` with `path?` and `namespace?` discriminants.
- ADDED: OTel surface — `drive_cache.{hits,misses,populations,invalidations,errors}_total{namespace}` counters; `drive_cache.entry_bytes` histogram (distribution of cached entry sizes); spans on `@suaveplan/drive-cache` tracer wrapping each `read`/`write`/`delete` middleware invocation with `drive.cache.namespace`, `drive.cache.path`, `drive.cache.outcome` attributes.
- ADDED: README composition recipes — memory cache (default), OPFS-backed cache via `cache-storage` + `storage-browser-opfs`, file-backed cache via `cache-storage` + `storage-file`, Redis-backed cache via `cache-redis`, namespaced cache (multi-drive isolation), `wrap()`-based stale-while-revalidate.
## Out of scope
- **Caching `Drive.stat` and `Drive.list` results.** `stat` is cheap; `list` invalidation across prefix mutations is non-trivial (any write under `foo/` would have to invalidate every cached `list("foo/*")` result that happened to enumerate the written path). A future `add-drive-plugin-cache-list` change can take that on; reading is the dominant use case for this iteration.
- **Partial reads / range caching.** `Drive.createReadStream` is **not** intercepted by this middleware — it bypasses the `read` middleware entirely. Range-aware caching belongs in a future iteration paired with `add-drive-mesh-range-reads`. The plugin SHALL document this constraint; consumers needing cached streaming should compose at the URI layer with the future `cacheUriPlugin`.
- **Per-tenant key partitioning.** The `namespace` option gives one knob; multi-tenant per-request scoping (e.g. a `request.tenantId` extracted from `ctx`) would require either a `keyFn(ctx)` extension or a `NamespacedCache` factory per request. Out of scope until a concrete consumer needs it.
- **Cache-aside `wrap()` integration baked in.** The plugin exposes the underlying cache via `handle.cache`, so consumers can call `cache.wrap(key, loader, { staleAge, refreshAhead })` themselves. Wiring `wrap()` directly into the middleware would lock the policy choices that `wrap()` deliberately leaves to the caller (stale-while-revalidate vs. lock-and-load vs. fail-open). Out of scope.
- **A `cacheUriPlugin` for 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 cache `StorageLike.get` results across schemes. Some consumers will want both.
- **`AbstractCache<unknown>` support.** This plugin caches `Uint8Array` only — drive reads return bytes. Generic `T` would require a `Codec<T>` on every call site that has no actual variation; `cache-storage` already covers `Codec<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` / `MiddlewareNext` contract in `@suaveplan/drive` (`openspec/specs/drive/spec.md`).
- The `AbstractCache<T>` contract in `@suaveplan/cache` (`openspec/specs/cache/spec.md`).
- The `CacheProviderRegistry` factory pattern from `add-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 as `DrivePlugin`, registers `DriveMiddleware` on the supplied drive, returns `DriveCacheHandle`.
- `src/middleware/middleware.test.ts` — cache hit short-circuits `next()`, cache miss populates after `next()`, `shouldCache` gates population, `maxEntryBytes` default 16 MiB enforced, `cache.set` failure does NOT propagate, `cache.get` failure does NOT propagate (falls through to `next()`).
- `src/middleware/middleware.invalidation.test.ts` — `write` invalidates post-success, `write` does NOT invalidate when `next()` throws, `delete` symmetric, `invalidateOnWrite: false` skips invalidation, `cache.delete` failure does NOT propagate.
- `src/middleware/middleware.signal.test.ts` — pre-aborted signal short-circuits to `next()` without invoking cache; live signal threads into `cache.get`/`set`/`delete`.
- `src/handle/handle.test.ts` — `getStats()` returns monotonically-increasing counters; `invalidate(path)` deletes one entry; `clear()` clears the underlying cache; `handle.cache` is the exact reference passed in `options.cache`.
- `src/error-codes/error-codes.test.ts` — exhaustive `DRIVE_CACHE_CODES` registry, `DriveCacheError` discriminant fields, every code reachable from a real call site.
- `src/otel/otel.test.ts` — `hits_total` / `misses_total` / `populations_total` / `invalidations_total` / `errors_total` increment as scenarios fire; `entry_bytes` histogram 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` — real `MemoryCache` + real `Drive` over an in-memory `DriveStorage`: write bytes via `drive.write`, read twice, assert second read came from cache (hits === 1).
- `tests/integration/with-storage-cache.test.ts` — real `StorageCache` over real `storage-file` `FileStorage` + real `Drive`: identical scenario, asserts the cache file appears on disk and surviving a fresh `driveCachePlugin` install re-hydrates from disk.
- `tests/integration/with-namespaced-caches.test.ts` — two `driveCachePlugin` instances with distinct `namespace`s share one underlying `AbstractCache` but 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-cache` returning 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.

View File

@@ -0,0 +1,246 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: drive-plugin-cache (add-drive-plugin-cache)
**Package**: `@suaveplan/drive-plugin-cache`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line at authoring time.
## ADDED 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 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<Uint8Array> })` 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"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,81 @@
# Tasks — add-drive-plugin-cache
> Every checkbox MUST reflect work actually done in this change.
## 1. Spec authoring
- [ ] 1.1 Read `openspec/specs/drive/spec.md` and confirm `DrivePlugin` / `DriveMiddleware` shape
- [ ] 1.2 Read `openspec/specs/cache/spec.md` and confirm `AbstractCache<T>` contract
- [ ] 1.3 Read `openspec/archive/2026-05-16-add-cache-storage-redis-backends/` for the cache-family conventions
- [ ] 1.4 Read `openspec/AGENTS.md` §0c, §2b, §6a, §7, §11, §13
- [ ] 1.5 Read `packages/drive/drive/src/types/drive.types.ts` to confirm `MiddlewareContext` and `MiddlewareNext<T>` signatures
- [ ] 1.6 Draft `proposal.md`, `design.md`, `tasks.md`, `specs/drive-plugin-cache/spec.md`
- [ ] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete planned `it(...)` titles
- [ ] 1.8 Build the numbered Requirement/Scenario implementation checklist (§0c step 6) — 12 requirements, 29 scenarios
## 2. Scaffolding
- [ ] 2.1 Create `packages/drive/drive-plugin-cache/` mirroring `packages/drive/drive-uri/` conventions
- [ ] 2.2 Write `package.json` v0.0.0, `"sideEffects": false`, subpath exports for every feature module
- [ ] 2.3 Wire deps: `@suaveplan/error`, `@suaveplan/drive`, `@suaveplan/cache`, `@suaveplan/types`, `@opentelemetry/api`, `tslib` (all `workspace:^` / `catalog:`)
- [ ] 2.4 Wire devDeps: `@suaveplan/testing`, `@suaveplan/storage`, `@suaveplan/storage-file`, `@suaveplan/cache-storage`, `@vitest/coverage-istanbul`, `fast-check`, `typescript`, `vite`, `vite-plugin-dts`, `vitest`
- [ ] 2.5 Write `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts` (via `createTestConfig`)
- [ ] 2.6 Write `tests/setup/polyfills.ts` and `README.md` skeleton
## 3. Implementation
- [ ] 3.1 `src/error-codes/error-codes.ts` — `defineCodes("DRIVE_CACHE", ["INVALID_OPTIONS", "CACHE_TOO_LARGE", "INSTALL_FAILED"] as const)`, `DriveCacheError` class, `path?`/`namespace?` discriminants
- [ ] 3.2 `src/types/options.types.ts` — `DriveCacheOptions`, `DriveCacheHandle`, `DriveCacheStats` interfaces (no inline shapes per §11)
- [ ] 3.3 `src/handle/handle.ts` — `createHandle(cache, stats)`, exposes `getStats()` / `invalidate(path)` / `clear()` / `cache`
- [ ] 3.4 `src/middleware/middleware.ts` — `read(ctx, next)` with hit short-circuit, miss + populate, `shouldCache` gate
- [ ] 3.5 `src/middleware/middleware.invalidation.ts` — `write(ctx, next)` and `delete(ctx, next)` post-success invalidation, `invalidateOnWrite`/`invalidateOnDelete` toggles
- [ ] 3.6 `src/middleware/middleware.signal.ts` — `AbortSignal` short-circuit helper used by all three intercepts
- [ ] 3.7 `src/otel/otel.ts` — meter + tracer wiring, counter `hits`/`misses`/`populations`/`invalidations`/`errors`, histogram `entry_bytes`, span attribute helpers
- [ ] 3.8 `src/plugin/plugin.ts` — `driveCachePlugin(options): DrivePlugin<DriveCacheHandle>` factory with construction-time `INVALID_OPTIONS` check
- [ ] 3.9 `src/index.ts` — explicit named re-exports per §6a
## 4. Tests (every `it(...)` carries `{ timeout: 5000 }`, 30000 for async integration)
- [ ] 4.1 `src/error-codes/error-codes.test.ts` — every code reachable from a real call site, discriminants populated
- [ ] 4.2 `src/plugin/plugin.test.ts` — install registers middleware, install returns handle with same cache reference, INVALID_OPTIONS on missing cache, INVALID_OPTIONS on malformed cache
- [ ] 4.3 `src/middleware/middleware.test.ts` — hit short-circuits, custom namespace used in key, miss populates, shouldCache gate, default 16 MiB ceiling, stat/list passthrough, cache.get rejection falls through, cache.set rejection swallowed
- [ ] 4.4 `src/middleware/middleware.invalidation.test.ts` — write invalidates post-success, write failure does not invalidate, invalidateOnWrite:false skips, delete symmetric, delete failure does not invalidate, cache.delete rejection swallowed
- [ ] 4.5 `src/middleware/middleware.signal.test.ts` — pre-aborted signal skips cache work, live signal forwarded to cache.get/set/delete
- [ ] 4.6 `src/handle/handle.test.ts` — getStats monotonic, invalidate deletes one entry, clear clears cache
- [ ] 4.7 `src/otel/otel.test.ts` — hit/miss/populate/invalidate/error counters increment, entry_bytes histogram observed on populations, errors_total carries `op` label, span outcome attribute populated
- [ ] 4.8 `tests/integration/with-memory-cache.test.ts` — real `Drive` + real `MemoryCache`: write bytes, read twice, assert `hits === 1` on second read
- [ ] 4.9 `tests/integration/with-storage-cache.test.ts` — real `Drive` + real `StorageCache` over real `storage-file` `FileStorage`: cache survives plugin reinstall (disk-backed)
- [ ] 4.10 `tests/integration/with-namespaced-caches.test.ts` — two plugin instances with distinct namespaces share one `AbstractCache` reference but do not see each other's entries
- [ ] 4.11 No imports from `vitest` or `bun:test` in test files — all via `@suaveplan/testing/runner` (rule 11)
- [ ] 4.12 No `vi.mock` / `jest.mock`; all dependencies are real per CLAUDE.md rule 2
## 5. Documentation
- [ ] 5.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
- [ ] 5.2 `README.md` links every module under `## Modules`
- [ ] 5.3 README documents the five composition recipes from `design.md` (memory / OPFS / file / Redis / `wrap()` stale-while-revalidate)
- [ ] 5.4 README documents the `shouldCache` / `maxEntryBytes` defaults and the rationale (16 MiB matches `drive-mesh-protocol`'s frame ceiling)
## 6. Gates
- [ ] 6.1 `bun run check`
- [ ] 6.2 `bunx turbo lint typecheck test --filter=@suaveplan/drive-plugin-cache`
- [ ] 6.3 `bunx turbo test:coverage --filter=@suaveplan/drive-plugin-cache` — 100/100/100/100 Istanbul
- [ ] 6.4 `bunx turbo build --filter=@suaveplan/drive-plugin-cache`
- [ ] 6.5 `bun scripts/lint/check-test-runner-imports.ts` (rule 11)
- [ ] 6.6 Tier-purity grep — `grep -rnE "from ['\"](node:|bun:)" packages/drive/drive-plugin-cache/src/` returns empty
- [ ] 6.7 Banned-pattern scan returns empty (§2)
- [ ] 6.8 Missing-`.md` scan returns empty (§4)
- [ ] 6.9 Non-root `export *` scan returns empty (§6a)
- [ ] 6.10 Bare-version scan returns empty (§1)
- [ ] 6.11 Folder-naming scan (§10) — children under `plugins/`, `middleware/`, `handle/` do not include the parent's singular/plural form as a suffix
- [ ] 6.12 Spec-reconciliation gate — every `### Requirement` is implemented; every `**Verified by:**` test name exists verbatim (§13 step 0)
## 7. Manifest & archival
- [ ] 7.1 Add package to `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`
- [ ] 7.2 Update `STATUS.md` (genesis-original — add to "Completed" once shipped)
- [ ] 7.3 Update `SUMMARY.md` with the one-paragraph description
- [ ] 7.4 `bunx markdownlint-cli2 --fix` on touched docs
- [ ] 7.5 Fold `specs/drive-plugin-cache/spec.md` into `openspec/specs/drive-plugin-cache/spec.md`
- [ ] 7.6 Move `openspec/changes/add-drive-plugin-cache/` → `openspec/archive/<yyyy-mm-dd>-add-drive-plugin-cache/`