diff --git a/AGENTS.md b/AGENTS.md index 4c31428..563ca30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,17 +8,22 @@ Standalone caching utilities extracted from [nitro](https://github.com/nitrojs/n src/ ├── index.ts # Public exports (re-exports from all modules) ├── types.ts # All type definitions (HTTPEvent, CacheEntry, CacheOptions, etc.) -├── cache.ts # Core: defineCachedFunction, cachedFunction +├── cache.ts # Core: defineCachedFunction, cachedFunction, invalidateCache, resolveCacheKeys ├── http.ts # HTTP layer: defineCachedHandler (depends on cache.ts) └── storage.ts # Storage interface + built-in memory storage ``` -## Architecture +## Docs + +Never touch contents inside `` in README.md. They are auto generated (use `pnpm fmt` to update). ### Core caching (`cache.ts`) - `defineCachedFunction(fn, opts)` — wraps any function with caching (SWR, TTL, integrity checks, deduplication of in-flight requests) - `cachedFunction(fn, opts)` — alias for `defineCachedFunction` +- Returned cached function has `.resolveKeys(...args)` and `.invalidate(...args)` methods +- `resolveCacheKeys({ options, args })` — standalone helper to resolve storage keys +- `invalidateCache({ options, args })` — standalone helper to remove cached entries across all base prefixes - Uses `StorageInterface` via `useStorage()` for persistence - Supports `waitUntil` on `event.req` (srvx/Cloudflare ServerRequest pattern) for background cache writes diff --git a/PLAN.md b/PLAN.md index 3267b97..69294b8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -8,7 +8,7 @@ Issues and PRs from [nitrojs/nitro](https://github.com/nitrojs/nitro) that ocach ### Key takeaways for ocache: -- Cache invalidation is the #1 pain point — users need a first-class API for it +- ~~Cache invalidation is the #1 pain point — users need a first-class API for it~~ ✅ Added `invalidateCache()` and `.invalidate()` method - SWR logic is buggy — staleMaxAge isn't respected properly, empty/404 responses get stuck - Expired entries leak memory — they're never cleaned up - Binary/streaming responses aren't handled well @@ -16,13 +16,13 @@ Issues and PRs from [nitrojs/nitro](https://github.com/nitrojs/nitro) that ocach - Query param filtering for cache keys is a common request (allowQuery) - Server-only caching (no Cache-Control headers sent to client) is wanted -### Cache Invalidation (most requested) +### Cache Invalidation (most requested) — ✅ Resolved -- [#2218](https://github.com/nitrojs/nitro/issues/2218) — No API to invalidate cached function entries -- [#3935](https://github.com/nitrojs/nitro/issues/3935) — Need easy cache invalidation helper for `defineCachedFunction`/`defineCachedHandler` -- [#3969](https://github.com/nitrojs/nitro/issues/3969) — Programmatic cache invalidation at runtime -- [#2738](https://github.com/nitrojs/nitro/issues/2738) — Clearing cache from handlers doesn't work as intended -- [#2611](https://github.com/nitrojs/nitro/pull/2611) — PR: remove cached value when revalidation errors +- [#2218](https://github.com/nitrojs/nitro/issues/2218) — ✅ No API to invalidate cached function entries → `fn.invalidate()` + `invalidateCache()` +- [#3935](https://github.com/nitrojs/nitro/issues/3935) — ✅ Need easy cache invalidation helper → `.invalidate(...args)` on cached functions +- [#3969](https://github.com/nitrojs/nitro/issues/3969) — ✅ Programmatic cache invalidation at runtime → `invalidateCache()` standalone helper +- [#2738](https://github.com/nitrojs/nitro/issues/2738) — Clearing cache from handlers doesn't work as intended (browser-side caching — out of scope) +- [#2611](https://github.com/nitrojs/nitro/pull/2611) — PR: remove cached value when revalidation errors (separate concern) ### SWR (Stale-While-Revalidate) Bugs diff --git a/README.md b/README.md index 9bf7f0e..0009459 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,10 @@ const handler = defineCachedHandler(myHandler, { ### Cache Invalidation -Cached functions have a `.resolveKeys()` method that returns all storage keys (one per base prefix) for given arguments, making invalidation straightforward: +Cached functions have an `.invalidate()` method that removes cached entries across all base prefixes: ```ts -import { defineCachedFunction, useStorage } from "ocache"; +import { defineCachedFunction } from "ocache"; const getUser = defineCachedFunction(async (id: string) => db.users.find(id), { name: "getUser", @@ -101,11 +101,29 @@ const getUser = defineCachedFunction(async (id: string) => db.users.find(id), { const user = await getUser("user-123"); -// When you need to invalidate: +// Invalidate a specific entry +await getUser.invalidate("user-123"); + +// Next call will re-invoke the function +const freshUser = await getUser("user-123"); +``` + +You can also use the standalone `invalidateCache()` when you don't have a reference to the cached function — just pass the same options: + +```ts +import { invalidateCache } from "ocache"; + +await invalidateCache({ + options: { name: "getUser", getKey: (id: string) => id }, + args: ["user-123"], +}); +``` + +For advanced use cases, `.resolveKeys()` returns the raw storage keys: + +```ts const keys = await getUser.resolveKeys("user-123"); -for (const key of keys) { - await useStorage().set(key, null); -} +// ["/cache:functions:getUser:user-123.json"] ``` ### Multi-tier Caching @@ -228,6 +246,33 @@ Handler function that receives an [`HTTPEvent`](#httpevent) and returns a respon --- +### `invalidateCache` + +```ts +async function invalidateCache( + input: +``` + +Invalidates (removes) cached entries for given arguments and cache options across all base prefixes. + +Uses the same key derivation as `defineCachedFunction` / `resolveCacheKeys`. + +**Parameters:** + +- **`input`** — Object with `options` (cache options) and optional `args` (function arguments). + +**Example:** + +```ts +// Invalidate a specific cached entry +await invalidateCache({ + options: { name: "fetchUser", getKey: (id: string) => id }, + args: ["user-123"], +}); +``` + +--- + ### `resolveCacheKeys` ```ts diff --git a/src/cache.ts b/src/cache.ts index dad1982..5d15ff3 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -18,6 +18,8 @@ export type CachedFunction = { (...args: ArgsT): Promise; /** Resolves all storage keys (one per base prefix) for the given arguments. */ resolveKeys: (...args: ArgsT) => Promise; + /** Invalidates (removes) cached entries for the given arguments across all base prefixes. */ + invalidate: (...args: ArgsT) => Promise; }; /** @@ -209,6 +211,7 @@ export function defineCachedFunction( }; cachedFn.resolveKeys = (...args: ArgsT) => resolveCacheKeys({ options: opts, args }); + cachedFn.invalidate = (...args: ArgsT) => invalidateCache({ options: opts, args }); return cachedFn; } @@ -254,6 +257,33 @@ export async function resolveCacheKeys( return _normalizeBases(opts.base).map((base) => _buildCacheKey(key, opts, base)); } +/** + * Invalidates (removes) cached entries for given arguments and cache options across all base prefixes. + * + * Uses the same key derivation as `defineCachedFunction` / `resolveCacheKeys`. + * + * @param input - Object with `options` (cache options) and optional `args` (function arguments). + * + * @example + * ```ts + * // Invalidate a specific cached entry + * await invalidateCache({ + * options: { name: "fetchUser", getKey: (id: string) => id }, + * args: ["user-123"], + * }); + * ``` + */ +export async function invalidateCache( + input: { + options?: Pick, "base" | "group" | "name" | "getKey">; + args?: ArgsT; + } = {}, +): Promise { + const keys = await resolveCacheKeys(input); + const storage = useStorage(); + await Promise.all(keys.map((key) => storage.set(key, null))); +} + // --- Internal helpers --- function isHTTPEvent(input: unknown): input is HTTPEvent { diff --git a/src/index.ts b/src/index.ts index e4d7d05..38c3a00 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export { defineCachedFunction, cachedFunction, resolveCacheKeys, + invalidateCache, type CachedFunction, } from "./cache.ts"; diff --git a/test/index.test.ts b/test/index.test.ts index 09b95c9..b51f9e6 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -4,6 +4,7 @@ import { defineCachedFunction, defineCachedHandler, resolveCacheKeys, + invalidateCache, createMemoryStorage, setStorage, useStorage, @@ -1166,6 +1167,91 @@ describe("resolveCacheKeys", () => { }); }); +describe("invalidateCache", () => { + it("removes cached entry so next call re-invokes the function", async () => { + let callCount = 0; + const fn = defineCachedFunction( + () => { + callCount++; + return `v${callCount}`; + }, + { maxAge: 60, name: "myFn", getKey: () => "k", swr: false }, + ); + + expect(await fn()).toBe("v1"); + expect(callCount).toBe(1); + + await fn.invalidate(); + + expect(await fn()).toBe("v2"); + expect(callCount).toBe(2); + }); + + it("invalidates with specific args", async () => { + let callCount = 0; + const fn = defineCachedFunction( + (id: string) => { + callCount++; + return `${id}-v${callCount}`; + }, + { maxAge: 60, name: "byId", getKey: (id: string) => id, swr: false }, + ); + + expect(await fn("a")).toBe("a-v1"); + expect(await fn("b")).toBe("b-v2"); + + // Invalidate only "a" + await fn.invalidate("a"); + + expect(await fn("a")).toBe("a-v3"); // re-invoked + expect(await fn("b")).toBe("b-v2"); // still cached + expect(callCount).toBe(3); + }); + + it("invalidates across all base prefixes (multi-tier)", async () => { + const fn = defineCachedFunction(() => "value", { + maxAge: 60, + base: ["/tier1", "/tier2"], + name: "myFn", + getKey: () => "k", + }); + + await fn(); + + const storage = useStorage(); + expect(await storage.get("/tier1:functions:myFn:k.json")).not.toBeNull(); + expect(await storage.get("/tier2:functions:myFn:k.json")).not.toBeNull(); + + await fn.invalidate(); + + expect(await storage.get("/tier1:functions:myFn:k.json")).toBeNull(); + expect(await storage.get("/tier2:functions:myFn:k.json")).toBeNull(); + }); + + it("standalone invalidateCache works with same options", async () => { + let callCount = 0; + const opts = { maxAge: 60, name: "myFn", getKey: () => "k", swr: false } as const; + const fn = defineCachedFunction(() => { + callCount++; + return `v${callCount}`; + }, opts); + + expect(await fn()).toBe("v1"); + + await invalidateCache({ options: opts }); + + expect(await fn()).toBe("v2"); + expect(callCount).toBe(2); + }); + + it("invalidating non-existent key is a no-op", async () => { + // Should not throw + await invalidateCache({ + options: { name: "nonexistent", getKey: () => "nope" }, + }); + }); +}); + describe("multi-tier base", () => { it("reads from second tier when first is empty", async () => { const sharedIntegrity = "shared-integrity";