Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
export {
type BuildInvalidationArgs,
buildInvalidationMiddleware,
type InvalidationHooks,
} from "./middleware.invalidation.js";
export {
type BuildMiddlewareArgs,
buildMiddleware,
} from "./middleware.js";
export { getSignal, isPreAborted } from "./middleware.signal.js";

View File

@@ -0,0 +1,53 @@
# `middleware.invalidation`
## Purpose
`middleware.invalidation.ts` provides the `write` and `delete` hooks of the `DriveMiddleware` built in `middleware.ts`. Each hook runs `next()` first; on successful resolution it deletes the corresponding cache entry. When `next()` throws, the throw propagates and the cache is left untouched — invariably the bytes in the cache are still the bytes the consumer last wrote.
This split keeps `middleware.ts` focused on the read path (the hot path) and makes the invalidation logic independently testable. The two hooks share a single `invalidate(path)` closure that wraps `cache.delete` with stats accounting, OTel recording, and error swallowing.
## Features
- Post-success invalidation — no race window where a concurrent read can repopulate stale bytes between the cache delete and the storage write.
- `invalidateOnWrite` / `invalidateOnDelete` per-side toggles default to `true` but accept `false` for callers wanting append-only or write-through behaviour.
- Underlying `cache.delete` rejection is swallowed, counted on `stats.errors`, and recorded via OTel as `errors_total{namespace, op: "delete"}`.
- The `delete` hook returns the original `next()` boolean unchanged — semantics match `Drive.delete`'s "did the file exist" contract.
## Usage: Basic
```ts
import { buildInvalidationMiddleware } from "@suaveplan/drive-plugin-cache/middleware";
const hooks = buildInvalidationMiddleware({
cache,
stats,
namespace: "drive",
invalidateOnWrite: true,
invalidateOnDelete: true,
});
```
## Usage: Advanced
```ts
const hooks = buildInvalidationMiddleware({
cache,
stats,
namespace: "audit",
invalidateOnWrite: false, // append-only: writes never displace cached bytes
invalidateOnDelete: true,
logger,
});
```
## API Reference
| Export | Shape | Description |
|---|---|---|
| `BuildInvalidationArgs` | `{ cache; stats; namespace; invalidateOnWrite; invalidateOnDelete; logger? }` | Constructor bag. |
| `InvalidationHooks` | `{ write(ctx, next); delete(ctx, next) }` | Hook pair compatible with `DriveMiddleware`. |
| `buildInvalidationMiddleware(args)` | `(BuildInvalidationArgs) => InvalidationHooks` | Returns the two hooks. |
## Implementation Notes
The shared `invalidate(path)` helper is a closure over the per-build state — `cache`, `stats`, `namespace`, optional `logger`. This avoids passing those four parameters through every invocation while keeping the function pure modulo the explicit mutable state. The `delete` hook still calls `invalidate` when `next()` returns `false` (the file did not exist on disk). This is safe — `cache.delete` is idempotent on missing keys — and it cleans any stale cache entry produced by an earlier reader that observed the file before a concurrent removal.

View File

@@ -0,0 +1,146 @@
import type { MiddlewareContext } from "@suaveplan/drive";
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { FakeCache } from "../../tests/setup/fake-cache.js";
import { createMutableStats, type MutableStats } from "../handle/handle.js";
import { buildMiddleware } from "./middleware.js";
function makeCtx(path: string, op = "write"): MiddlewareContext {
return {
operation: op,
path,
drive: {} as MiddlewareContext["drive"],
};
}
describe("middleware: invalidation", () => {
let cache: FakeCache;
let stats: MutableStats;
beforeEach(() => {
cache = new FakeCache();
stats = createMutableStats();
});
it("successful write invalidates the cache entry", {
timeout: 5000,
}, async () => {
cache.store.set("drive::x.bin", new Uint8Array([1]));
const mw = buildMiddleware({ cache, stats });
await mw.write?.(makeCtx("x.bin"), async () => {});
expect(cache.deleteCalls).toEqual(["drive::x.bin"]);
expect(stats.invalidations).toBe(1);
});
it("failed write does not invalidate", {
timeout: 5000,
}, async () => {
const mw = buildMiddleware({ cache, stats });
let threw = false;
try {
await mw.write?.(makeCtx("x.bin"), async () => {
throw new Error("storage failed");
});
} catch {
threw = true;
}
expect(threw).toBe(true);
expect(cache.deleteCalls).toEqual([]);
expect(stats.invalidations).toBe(0);
});
it("invalidateOnWrite:false skips invalidation", {
timeout: 5000,
}, async () => {
const mw = buildMiddleware({
cache,
stats,
invalidateOnWrite: false,
});
await mw.write?.(makeCtx("x.bin"), async () => {});
expect(cache.deleteCalls).toEqual([]);
});
it("successful delete invalidates the cache entry", {
timeout: 5000,
}, async () => {
cache.store.set("drive::x.bin", new Uint8Array([1]));
const mw = buildMiddleware({ cache, stats });
const removed = await mw.delete?.(
makeCtx("x.bin", "delete"),
async () => true
);
expect(removed).toBe(true);
expect(cache.deleteCalls).toEqual(["drive::x.bin"]);
expect(stats.invalidations).toBe(1);
});
it("delete invalidates even when next() returned false", {
timeout: 5000,
}, async () => {
const mw = buildMiddleware({ cache, stats });
const removed = await mw.delete?.(
makeCtx("x.bin", "delete"),
async () => false
);
expect(removed).toBe(false);
// Per spec: "MAY still call cache.delete(key) to clean any stale entry"
expect(cache.deleteCalls).toEqual(["drive::x.bin"]);
});
it("invalidateOnDelete:false skips invalidation", {
timeout: 5000,
}, async () => {
const mw = buildMiddleware({
cache,
stats,
invalidateOnDelete: false,
});
await mw.delete?.(makeCtx("x.bin", "delete"), async () => true);
expect(cache.deleteCalls).toEqual([]);
});
it("failed delete does not invalidate", {
timeout: 5000,
}, async () => {
const mw = buildMiddleware({ cache, stats });
let threw = false;
try {
await mw.delete?.(makeCtx("x.bin", "delete"), async () => {
throw new Error("storage gone");
});
} catch {
threw = true;
}
expect(threw).toBe(true);
expect(cache.deleteCalls).toEqual([]);
});
it("cache.delete rejection on invalidation does not propagate", {
timeout: 5000,
}, async () => {
cache.throwOnDelete = new Error("redis down");
const mw = buildMiddleware({ cache, stats });
// Should NOT throw
await mw.write?.(makeCtx("x.bin"), async () => {});
expect(stats.errors).toBe(1);
expect(stats.invalidations).toBe(0);
});
it("invalidation logger captures suppressed cache.delete errors", {
timeout: 5000,
}, async () => {
cache.throwOnDelete = new Error("redis down");
const logs: string[] = [];
const logger = {
debug: () => {},
info: () => {},
error: () => {},
warn: (msg: string) => {
logs.push(msg);
},
};
const mw = buildMiddleware({ cache, stats, logger });
await mw.write?.(makeCtx("x.bin"), async () => {});
expect(logs.some((m) => m.includes("cache.delete failed"))).toBe(true);
});
});

View File

@@ -0,0 +1,88 @@
import type { AbstractCache } from "@suaveplan/cache";
import type { LoggerLike } from "@suaveplan/types";
import type { MutableStats } from "../handle/handle.js";
import { recordError, recordInvalidate } from "../otel/otel.js";
interface MiddlewareContext {
readonly operation: string;
readonly path: string;
readonly drive: unknown;
readonly [key: string]: unknown;
}
export interface BuildInvalidationArgs {
readonly cache: AbstractCache<Uint8Array>;
readonly stats: MutableStats;
readonly namespace: string;
readonly invalidateOnWrite: boolean;
readonly invalidateOnDelete: boolean;
readonly logger?: LoggerLike | undefined;
}
export interface InvalidationHooks {
write(ctx: MiddlewareContext, next: () => Promise<void>): Promise<void>;
delete(
ctx: MiddlewareContext,
next: () => Promise<boolean>
): Promise<boolean>;
}
/**
* Build the `write` and `delete` `DriveMiddleware` hooks responsible for
* post-success cache invalidation. Each hook runs `next()` first; on
* successful resolution it deletes the corresponding cache entry. When
* `next()` throws, the throw propagates and the cache is left untouched.
*/
export function buildInvalidationMiddleware(
args: BuildInvalidationArgs
): InvalidationHooks {
const {
cache,
stats,
namespace,
invalidateOnWrite,
invalidateOnDelete,
logger,
} = args;
async function invalidate(path: string): Promise<void> {
const key = `${namespace}::${path}`;
try {
await cache.delete(key);
stats.invalidations += 1;
recordInvalidate(namespace, path);
} catch (cause) {
stats.errors += 1;
recordError(namespace, "delete", path);
if (logger !== undefined) {
logger.warn("drive-plugin-cache: cache.delete failed", {
path,
namespace,
cause: String(cause),
});
}
}
}
return {
async write(
ctx: MiddlewareContext,
next: () => Promise<void>
): Promise<void> {
await next();
if (invalidateOnWrite) {
await invalidate(ctx.path);
}
},
async delete(
ctx: MiddlewareContext,
next: () => Promise<boolean>
): Promise<boolean> {
const removed = await next();
if (invalidateOnDelete) {
await invalidate(ctx.path);
}
return removed;
},
};
}

View File

@@ -0,0 +1,57 @@
# `middleware`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`middleware.ts` builds the `DriveMiddleware` registered on a `Drive` by `driveCachePlugin`. The middleware intercepts `read(ctx, next)` to short-circuit hot paths from the cache and to populate fresh entries on a miss. `write` and `delete` interceptions are delegated to `middleware.invalidation`. `stat` and `list` are pure passthroughs in this iteration.
A cache is a perf optimisation, not a correctness boundary. Every underlying cache failure (`get` rejection, `set` rejection, `delete` rejection) is captured into `stats.errors` and the OTel `errors_total{namespace, op}` counter — and is never propagated to the consumer.
## Features
- Pre-aborted `AbortSignal` short-circuits the cache work and defers to `next()` so the inner storage surfaces canonical `DRIVE.ABORTED`.
- Live `AbortSignal` is forwarded to `cache.get` for caches whose `get` accepts a signal.
- `shouldCache(path, bytes)` predicate gates population — defaults to a 16-MiB byte ceiling matching `drive-mesh-protocol`'s `MAX_FRAME_BYTES`.
- Population enforces the byte ceiling defensively; an oversize entry raises a structured `DRIVE_CACHE.CACHE_TOO_LARGE` internally and is suppressed (counted on `stats.errors`).
- `stat` and `list` pass straight to `next()` without touching the cache.
## Usage: Basic
```ts
import { buildMiddleware } from "@suaveplan/drive-plugin-cache/middleware";
import { createMutableStats } from "@suaveplan/drive-plugin-cache/handle";
const stats = createMutableStats();
const mw = buildMiddleware({ cache, stats, namespace: "drive" });
drive.useMiddleware(mw);
```
## Usage: Advanced
```ts
const mw = buildMiddleware({
cache,
stats,
namespace: "media",
invalidateOnWrite: true,
invalidateOnDelete: false,
shouldCache: (path, bytes) =>
path.endsWith(".mov") && bytes.byteLength <= 8 * 1024 * 1024,
maxEntryBytes: 8 * 1024 * 1024,
logger,
});
```
## API Reference
| Export | Shape | Description |
|---|---|---|
| `BuildMiddlewareArgs` | `{ cache; stats; namespace?; invalidateOnWrite?; invalidateOnDelete?; shouldCache?; maxEntryBytes?; logger? }` | Constructor bag. |
| `buildMiddleware(args)` | `(BuildMiddlewareArgs) => DriveMiddleware` | Returns the full `DriveMiddleware` (read + write + delete + stat + list). |
## Implementation Notes
The cache key is computed as `${namespace}::${ctx.path}`. The `::` separator avoids accidental collision with paths that already contain `/`. Population happens **after** `next()` resolves — i.e. only when the inner storage actually produced bytes — so a thrown `next()` cannot poison the cache. Symmetrically, invalidation happens **after** a successful `write`/`delete` `next()`. The optimistic alternative (delete before next()) opens a race window where a concurrent read could repopulate stale bytes between the cache delete and the storage write.

View File

@@ -0,0 +1,46 @@
# `middleware.signal`
## Purpose
`middleware.signal.ts` provides two tiny `AbortSignal` helpers used by the drive-cache middleware: `getSignal(ctx)` extracts a signal from the open-shaped `MiddlewareContext`, and `isPreAborted(ctx)` returns true when the extracted signal is already aborted at the moment the middleware runs.
Drive's core does not currently thread a signal into `ctx`. The `MiddlewareContext` shape is an open `[key: string]: unknown` carrier, so downstream extensions and future Drive versions can. Centralising the lookup here means the read/write/delete middleware code stays a single line, and updating to a future canonical `ctx.signal` field is a one-file change.
## Features
- Type-safe extraction — returns `AbortSignal | undefined`, never throws.
- `isPreAborted` collapses the "have a signal and it is aborted" check into a single boolean.
- No runtime dependency — purely structural over the `MiddlewareContext` open shape.
## Usage: Basic
```ts
import { isPreAborted, getSignal } from "@suaveplan/drive-plugin-cache/middleware";
if (isPreAborted(ctx)) return next();
const signal = getSignal(ctx);
```
## Usage: Advanced
```ts
import { getSignal } from "@suaveplan/drive-plugin-cache/middleware";
const signal = getSignal(ctx);
if (signal !== undefined) {
// Pass the signal into the underlying cache.get when supported.
await (cache as { get(k: string, s?: AbortSignal): Promise<Uint8Array | undefined> })
.get(key, signal);
}
```
## API Reference
| Export | Shape | Description |
|---|---|---|
| `getSignal(ctx)` | `(MiddlewareContext) => AbortSignal \| undefined` | Returns the signal carried on `ctx.signal` when it is an `AbortSignal`, else `undefined`. |
| `isPreAborted(ctx)` | `(MiddlewareContext) => boolean` | True when `getSignal(ctx)?.aborted === true`. |
## Implementation Notes
The `instanceof AbortSignal` guard rejects loose duck-typed shapes that happen to carry an `aborted` boolean but lack the full signal contract (no `addEventListener`, no `reason`). This matters because forwarding such a shape into `cache.get` could cause the cache to call `signal.addEventListener` on a non-EventTarget object. The instanceof check is cheap and correct for every standards-conformant runtime; consumers building their own signal-shaped object should construct a real `AbortController` and surface its `signal`.

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeCache } from "../../tests/setup/fake-cache.js";
import { createMutableStats } from "../handle/handle.js";
import { buildMiddleware } from "./middleware.js";
import { getSignal, isPreAborted } from "./middleware.signal.js";
interface TestCtx {
operation: string;
path: string;
drive: unknown;
[key: string]: unknown;
}
function makeCtx(path: string, signal?: AbortSignal): TestCtx {
const base: TestCtx = {
operation: "read",
path,
drive: {},
};
if (signal !== undefined) {
(base as { signal?: AbortSignal }).signal = signal;
}
return base;
}
describe("middleware.signal helpers", () => {
it("getSignal returns undefined when ctx has no signal", {
timeout: 5000,
}, () => {
expect(getSignal(makeCtx("x"))).toBeUndefined();
});
it("getSignal returns the signal when ctx carries a real AbortSignal", {
timeout: 5000,
}, () => {
const ctrl = new AbortController();
expect(getSignal(makeCtx("x", ctrl.signal))).toBe(ctrl.signal);
});
it("getSignal rejects non-AbortSignal duck shapes", {
timeout: 5000,
}, () => {
const ctx: TestCtx = {
operation: "read",
path: "x",
drive: {},
signal: { aborted: true },
};
expect(getSignal(ctx)).toBeUndefined();
});
it("isPreAborted reflects the signal state", {
timeout: 5000,
}, () => {
const ctrl = new AbortController();
expect(isPreAborted(makeCtx("x", ctrl.signal))).toBe(false);
ctrl.abort();
expect(isPreAborted(makeCtx("x", ctrl.signal))).toBe(true);
});
});
describe("middleware: AbortSignal threading", () => {
it("pre-aborted signal skips cache work", {
timeout: 5000,
}, async () => {
const cache = new FakeCache();
const stats = createMutableStats();
const mw = buildMiddleware({ cache, stats });
const ctrl = new AbortController();
ctrl.abort();
let nextCalls = 0;
await mw.read?.(makeCtx("x", ctrl.signal), async () => {
nextCalls += 1;
return new Uint8Array();
});
expect(cache.getCalls).toEqual([]);
expect(cache.setCalls).toEqual([]);
expect(nextCalls).toBe(1);
});
it("live signal is forwarded to cache operations", {
timeout: 5000,
}, async () => {
const cache = new FakeCache();
const stats = createMutableStats();
const mw = buildMiddleware({ cache, stats });
const ctrl = new AbortController();
await mw.read?.(
makeCtx("x", ctrl.signal),
async () => new Uint8Array([1])
);
expect(cache.getCalls).toHaveLength(1);
expect(cache.getCalls[0].signal).toBe(ctrl.signal);
});
});

View File

@@ -0,0 +1,35 @@
/**
* Structural view of the middleware-context fields this helper reads. Mirrors
* `MiddlewareContext` from `@suaveplan/drive` (which is not re-exported from
* the package root) without binding to the concrete shape — `path`/`drive`/
* `operation` are irrelevant for signal extraction.
*/
export interface SignalContext {
readonly [key: string]: unknown;
}
/**
* Extract the `AbortSignal` (if any) attached to a middleware context.
*
* Drive's core does not currently thread a signal into `ctx`, but the
* underlying `MiddlewareContext` shape is an open `[key: string]: unknown`
* carrier so downstream extensions / future Drive versions can. This helper
* centralises the lookup so the read/write/delete middleware code stays a
* single line.
*/
export function getSignal(ctx: SignalContext): AbortSignal | undefined {
const candidate = ctx["signal"];
if (candidate instanceof AbortSignal) return candidate;
return undefined;
}
/**
* `true` when the given context carries a signal that is already aborted at
* the moment the middleware runs. Used to short-circuit the cache work and
* defer to `next()` so the inner storage layer surfaces `DRIVE.ABORTED`
* canonically.
*/
export function isPreAborted(ctx: SignalContext): boolean {
const signal = getSignal(ctx);
return signal?.aborted ?? false;
}

View File

@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { FakeCache } from "../../tests/setup/fake-cache.js";
import { createMutableStats, type MutableStats } from "../handle/handle.js";
import { buildMiddleware } from "./middleware.js";
interface TestCtx {
operation: string;
path: string;
drive: unknown;
[key: string]: unknown;
}
function makeCtx(path: string, extra?: Record<string, unknown>): TestCtx {
return {
operation: "read",
path,
drive: {},
...extra,
};
}
describe("middleware: read", () => {
let cache: FakeCache;
let stats: MutableStats;
beforeEach(() => {
cache = new FakeCache();
stats = createMutableStats();
});
it("hit short-circuits next() and returns cached bytes", {
timeout: 5000,
}, async () => {
const cached = new Uint8Array([10, 20, 30]);
cache.store.set("drive::clip.mov", cached);
const mw = buildMiddleware({ cache, stats });
let nextCalls = 0;
const out = await mw.read?.(makeCtx("clip.mov"), async () => {
nextCalls += 1;
return new Uint8Array([99]);
});
expect(out).toBe(cached);
expect(nextCalls).toBe(0);
expect(stats.hits).toBe(1);
expect(stats.misses).toBe(0);
});
it("uses the configured namespace in the cache key", {
timeout: 5000,
}, async () => {
const cached = new Uint8Array([1]);
cache.store.set("media::clip.mov", cached);
const mw = buildMiddleware({ cache, stats, namespace: "media" });
await mw.read?.(makeCtx("clip.mov"), async () => new Uint8Array());
expect(cache.getCalls).toEqual([
{ key: "media::clip.mov", signal: undefined },
]);
});
it("miss invokes next() and populates the cache", {
timeout: 5000,
}, async () => {
const bytes = new Uint8Array([5, 6, 7]);
const mw = buildMiddleware({ cache, stats });
const out = await mw.read?.(makeCtx("x.bin"), async () => bytes);
expect(out).toBe(bytes);
expect(stats.misses).toBe(1);
expect(stats.populations).toBe(1);
expect(cache.setCalls).toEqual([{ key: "drive::x.bin", value: bytes }]);
});
it("shouldCache predicate gates population", {
timeout: 5000,
}, async () => {
const mw = buildMiddleware({
cache,
stats,
shouldCache: () => false,
});
await mw.read?.(makeCtx("x.bin"), async () => new Uint8Array([1, 2]));
expect(cache.setCalls).toEqual([]);
expect(stats.populations).toBe(0);
expect(stats.misses).toBe(1);
});
it("default maxEntryBytes is 16 MiB", { timeout: 30000 }, async () => {
const big = new Uint8Array(16 * 1024 * 1024 + 1);
const mw = buildMiddleware({ cache, stats });
await mw.read?.(makeCtx("big.bin"), async () => big);
expect(cache.setCalls).toEqual([]);
expect(stats.populations).toBe(0);
// Default shouldCache short-circuits before the defensive
// CACHE_TOO_LARGE branch — so stats.errors stays 0.
expect(stats.errors).toBe(0);
});
it("populates entries exactly at the 16 MiB boundary", {
timeout: 30000,
}, async () => {
const boundary = new Uint8Array(16 * 1024 * 1024);
const mw = buildMiddleware({ cache, stats });
await mw.read?.(makeCtx("boundary.bin"), async () => boundary);
expect(stats.populations).toBe(1);
});
it("custom shouldCache enforcing maxEntryBytes triggers CACHE_TOO_LARGE swallow", {
timeout: 5000,
}, async () => {
// shouldCache returns true even though bytes exceed maxEntryBytes —
// middleware defensively suppresses and counts an error.
const mw = buildMiddleware({
cache,
stats,
shouldCache: () => true,
maxEntryBytes: 4,
});
await mw.read?.(
makeCtx("x.bin"),
async () => new Uint8Array([1, 2, 3, 4, 5])
);
expect(cache.setCalls).toEqual([]);
expect(stats.errors).toBe(1);
});
it("cache.get rejection falls through to next()", {
timeout: 5000,
}, async () => {
cache.throwOnGet = new Error("redis down");
const bytes = new Uint8Array([42]);
const mw = buildMiddleware({ cache, stats });
const out = await mw.read?.(makeCtx("x"), async () => bytes);
expect(out).toBe(bytes);
expect(stats.errors).toBe(1);
expect(stats.misses).toBe(1);
});
it("cache.set rejection does not propagate", {
timeout: 5000,
}, async () => {
cache.throwOnSet = new Error("disk full");
const bytes = new Uint8Array([1, 2]);
const mw = buildMiddleware({ cache, stats });
const out = await mw.read?.(makeCtx("x"), async () => bytes);
expect(out).toBe(bytes);
expect(stats.errors).toBe(1);
expect(stats.populations).toBe(0);
});
it("logger receives suppressed cache.get error context", {
timeout: 5000,
}, async () => {
cache.throwOnGet = new Error("redis down");
const logs: Array<{
msg: string;
ctx: Record<string, unknown> | undefined;
}> = [];
const logger = {
debug: () => {},
info: () => {},
error: () => {},
warn: (msg: string, ctx?: Record<string, unknown>) => {
logs.push({ msg, ctx });
},
};
const mw = buildMiddleware({ cache, stats, logger });
await mw.read?.(makeCtx("x"), async () => new Uint8Array());
expect(logs.length).toBeGreaterThanOrEqual(1);
expect(logs[0].msg).toContain("cache.get failed");
});
it("logger receives suppressed cache.set error context", {
timeout: 5000,
}, async () => {
cache.throwOnSet = new Error("disk full");
const logs: Array<{ msg: string }> = [];
const logger = {
debug: () => {},
info: () => {},
error: () => {},
warn: (msg: string) => {
logs.push({ msg });
},
};
const mw = buildMiddleware({ cache, stats, logger });
await mw.read?.(makeCtx("x"), async () => new Uint8Array([1]));
expect(logs.some((l) => l.msg.includes("cache.set failed"))).toBe(true);
});
it("stat passes through without touching the cache", {
timeout: 5000,
}, async () => {
const mw = buildMiddleware({ cache, stats });
const stat = { size: 1 };
const out = await mw.stat?.(makeCtx("x"), async () => stat);
expect(out).toBe(stat);
expect(cache.getCalls).toEqual([]);
expect(cache.setCalls).toEqual([]);
});
it("list passes through without touching the cache", {
timeout: 5000,
}, async () => {
const mw = buildMiddleware({ cache, stats });
const listing = [{ path: "a" }];
const out = await mw.list?.(makeCtx("folder/"), async () => listing);
expect(out).toBe(listing);
expect(cache.getCalls).toEqual([]);
expect(cache.setCalls).toEqual([]);
});
});

View File

@@ -0,0 +1,180 @@
import type { AbstractCache } from "@suaveplan/cache";
import type { DriveMiddleware } from "@suaveplan/drive";
import type { LoggerLike } from "@suaveplan/types";
import {
DRIVE_CACHE_CODES,
DriveCacheError,
} from "../error-codes/error-codes.js";
import type { MutableStats } from "../handle/handle.js";
import {
recordError,
recordHit,
recordMiss,
recordPopulate,
} from "../otel/otel.js";
import { buildInvalidationMiddleware } from "./middleware.invalidation.js";
import { getSignal, isPreAborted } from "./middleware.signal.js";
/**
* Structural copy of `MiddlewareContext` from `@suaveplan/drive`. The drive
* package does not currently re-export this type from its root barrel, so we
* mirror the shape here. It is a structural match — any object compatible
* with drive's `MiddlewareContext` is compatible with this one.
*/
interface MiddlewareContext {
readonly operation: string;
readonly path: string;
readonly drive: unknown;
readonly [key: string]: unknown;
}
const DEFAULT_NAMESPACE = "drive";
const DEFAULT_MAX_ENTRY_BYTES = 16 * 1024 * 1024;
export interface BuildMiddlewareArgs {
readonly cache: AbstractCache<Uint8Array>;
readonly stats: MutableStats;
readonly namespace?: string | undefined;
readonly invalidateOnWrite?: boolean | undefined;
readonly invalidateOnDelete?: boolean | undefined;
readonly shouldCache?:
| ((path: string, bytes: Uint8Array) => boolean)
| undefined;
readonly maxEntryBytes?: number | undefined;
readonly logger?: LoggerLike | undefined;
}
/**
* Build the `DriveMiddleware` registered on a `Drive` by `driveCachePlugin`.
*
* Wires the read short-circuit / populate-on-miss behaviour with the
* invalidation hooks for write and delete. `stat` and `list` are pure
* passthroughs in this iteration.
*/
export function buildMiddleware(args: BuildMiddlewareArgs): DriveMiddleware {
const namespace = args.namespace ?? DEFAULT_NAMESPACE;
const maxEntryBytes = args.maxEntryBytes ?? DEFAULT_MAX_ENTRY_BYTES;
const shouldCachePredicate =
args.shouldCache ??
((_path: string, bytes: Uint8Array): boolean =>
bytes.byteLength <= maxEntryBytes);
const cache = args.cache;
const stats = args.stats;
const logger = args.logger;
const invalidation = buildInvalidationMiddleware({
cache,
stats,
namespace,
invalidateOnWrite: args.invalidateOnWrite ?? true,
invalidateOnDelete: args.invalidateOnDelete ?? true,
logger,
});
return {
async read(
ctx: MiddlewareContext,
next: () => Promise<Uint8Array>
): Promise<Uint8Array> {
if (isPreAborted(ctx)) {
return next();
}
const key = `${namespace}::${ctx.path}`;
const signal = getSignal(ctx);
let cached: Uint8Array | undefined;
try {
cached = await invokeCacheGet(cache, key, signal);
} catch (cause) {
stats.errors += 1;
recordError(namespace, "get", ctx.path);
logCacheError(logger, "get", ctx.path, namespace, cause);
cached = undefined;
}
if (cached !== undefined) {
stats.hits += 1;
recordHit(namespace, ctx.path);
return cached;
}
stats.misses += 1;
recordMiss(namespace, ctx.path);
const bytes = await next();
if (shouldCachePredicate(ctx.path, bytes)) {
try {
if (bytes.byteLength > maxEntryBytes) {
throw new DriveCacheError(
DRIVE_CACHE_CODES.CACHE_TOO_LARGE,
"cached entry exceeds configured maxEntryBytes",
{
path: ctx.path,
namespace,
byteLength: bytes.byteLength,
maxEntryBytes,
}
);
}
await cache.set(key, bytes);
stats.populations += 1;
recordPopulate(namespace, ctx.path, bytes.byteLength);
} catch (cause) {
stats.errors += 1;
recordError(namespace, "set", ctx.path);
logCacheError(logger, "set", ctx.path, namespace, cause);
}
}
return bytes;
},
write: invalidation.write,
delete: invalidation.delete,
async stat(
_ctx: MiddlewareContext,
next: () => Promise<unknown | null>
): Promise<unknown | null> {
return next();
},
async list(
_ctx: MiddlewareContext,
next: () => Promise<unknown>
): Promise<unknown> {
return next();
},
};
}
/**
* Call `cache.get(key)`, optionally forwarding an `AbortSignal` to caches
* whose `get` accepts a second argument. The `AbstractCache` base contract
* is single-arg, but concrete implementations may extend it; the cast keeps
* the call site type-safe while still threading the signal where supported.
*/
async function invokeCacheGet(
cache: AbstractCache<Uint8Array>,
key: string,
signal: AbortSignal | undefined
): Promise<Uint8Array | undefined> {
if (signal === undefined) return cache.get(key);
const extended = cache as unknown as {
get(k: string, s?: AbortSignal): Promise<Uint8Array | undefined>;
};
return extended.get(key, signal);
}
function logCacheError(
logger: LoggerLike | undefined,
op: "get" | "set",
path: string,
namespace: string,
cause: unknown
): void {
if (logger === undefined) return;
logger.warn(`drive-plugin-cache: cache.${op} failed`, {
path,
namespace,
cause: String(cause),
});
}