11 KiB
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 + tracertslib
@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
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
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>;
// 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
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
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
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 onpopulations_totalincrements.
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/listcaching. 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 plannedcacheUriPlugincan wrapdrive://URIs and cache stream output. - One
AbstractCacheper 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 aNamespacedCacheover a layered cache they construct themselves. - Cache write failures swallow without retry. A retrying cache layer belongs inside the
AbstractCacheimplementation (e.g. viaretrymiddleware); the plugin trusts the cache to honour its own contract. - Synchronous
getStats(). Stats are kept in a per-handle counter object updated inline. Reads ofgetStats()are O(1) and lock-free; perfectly adequate for occasional operator inspection.
Composition recipes (in README)
// 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.