Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- automd -->` 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

Expand Down
14 changes: 7 additions & 7 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,21 @@ 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
- validate callback doesn't receive the original function args as documented
- 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

Expand Down
57 changes: 51 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -228,6 +246,33 @@ Handler function that receives an [`HTTPEvent`](#httpevent) and returns a respon

---

### `invalidateCache`

```ts
async function invalidateCache<ArgsT extends unknown[] = any[]>(
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
Expand Down
30 changes: 30 additions & 0 deletions src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export type CachedFunction<T, ArgsT extends unknown[]> = {
(...args: ArgsT): Promise<T>;
/** Resolves all storage keys (one per base prefix) for the given arguments. */
resolveKeys: (...args: ArgsT) => Promise<string[]>;
/** Invalidates (removes) cached entries for the given arguments across all base prefixes. */
invalidate: (...args: ArgsT) => Promise<void>;
};

/**
Expand Down Expand Up @@ -209,6 +211,7 @@ export function defineCachedFunction<T, ArgsT extends unknown[] = any[]>(
};

cachedFn.resolveKeys = (...args: ArgsT) => resolveCacheKeys({ options: opts, args });
cachedFn.invalidate = (...args: ArgsT) => invalidateCache({ options: opts, args });

return cachedFn;
}
Expand Down Expand Up @@ -254,6 +257,33 @@ export async function resolveCacheKeys<ArgsT extends unknown[] = any[]>(
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<ArgsT extends unknown[] = any[]>(
input: {
options?: Pick<CacheOptions<any, ArgsT>, "base" | "group" | "name" | "getKey">;
args?: ArgsT;
} = {},
): Promise<void> {
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 {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export {
defineCachedFunction,
cachedFunction,
resolveCacheKeys,
invalidateCache,
type CachedFunction,
} from "./cache.ts";

Expand Down
86 changes: 86 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
defineCachedFunction,
defineCachedHandler,
resolveCacheKeys,
invalidateCache,
createMemoryStorage,
setStorage,
useStorage,
Expand Down Expand Up @@ -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";
Expand Down
Loading