@nifrajs/cache 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/cache.d.ts +4 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +84 -0
- package/dist/cache.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/memory-cache.d.ts +29 -0
- package/dist/memory-cache.d.ts.map +1 -0
- package/dist/memory-cache.js +80 -0
- package/dist/memory-cache.js.map +1 -0
- package/dist/types.d.ts +67 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +46 -0
- package/src/cache.ts +98 -0
- package/src/index.ts +20 -0
- package/src/memory-cache.ts +102 -0
- package/src/types.ts +72 -0
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @nifrajs/cache
|
|
2
|
+
|
|
3
|
+
Typed KV cache for nifra — **TTL**, **stale-while-revalidate**, **tag invalidation**, and **single-flight
|
|
4
|
+
stampede protection** on a **pluggable store** (in-memory by default; bring CF KV / Redis for a shared
|
|
5
|
+
cache). **Dependency-free**; runs on Bun/Node/Deno/Workers.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createCache } from "@nifrajs/cache"
|
|
9
|
+
|
|
10
|
+
const cache = createCache({ defaultTtlMs: 30_000 })
|
|
11
|
+
|
|
12
|
+
// Cache-aside in a loader — one DB hit per key per TTL, even under a stampede:
|
|
13
|
+
export async function loader({ params }) {
|
|
14
|
+
const user = await cache.wrap(
|
|
15
|
+
`user:${params.id}`,
|
|
16
|
+
() => db.users.find(params.id),
|
|
17
|
+
{ ttlMs: 30_000, swrMs: 60_000, tags: [`user:${params.id}`] },
|
|
18
|
+
)
|
|
19
|
+
return { user }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// On write, drop everything tagged for that user:
|
|
23
|
+
await cache.invalidateTag(`user:${id}`)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Semantics
|
|
27
|
+
|
|
28
|
+
- **`wrap(key, loader, opts)`** — returns the cached value, or runs `loader`, stores, and returns it.
|
|
29
|
+
- **Fresh** (`now < staleAt`): the cached value, no loader call.
|
|
30
|
+
- **Stale-but-live** (`staleAt ≤ now < expiresAt`, i.e. within `swrMs`): the stale value is returned
|
|
31
|
+
**immediately** while a **background** refresh runs (deduped). Latency stays flat; data self-heals.
|
|
32
|
+
- **Miss/expired**: awaits `loader`. Concurrent misses for the same key share **one** call (no stampede).
|
|
33
|
+
- A throwing `loader` is **not** cached (and in the background path goes to `onError`, never rejects the caller).
|
|
34
|
+
- **`set` / `get` / `has` / `delete`**, **`invalidateTag(tag)`**, **`clear()`**.
|
|
35
|
+
- TTL: `ttlMs` (→ stale) + optional `swrMs` (→ served-stale window) + optional `tags`.
|
|
36
|
+
|
|
37
|
+
## Stores
|
|
38
|
+
|
|
39
|
+
The default `MemoryCache` is in-process with lazy expiry, a tag index, and an optional LRU cap
|
|
40
|
+
(`new MemoryCache({ maxEntries: 10_000 })`). For a cache shared across instances, implement `CacheStore`
|
|
41
|
+
(`get` / `set` / `delete` / `invalidateTag` / `clear`) over CF KV, Redis, etc.:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
const cache = createCache({ store: new RedisCacheStore(redis) })
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
On **Cloudflare Workers** the in-memory cache is per-isolate and short-lived — back it with **CF KV** (or
|
|
48
|
+
the Cache API) via a `CacheStore` for anything that should survive across requests/instances.
|
|
49
|
+
|
|
50
|
+
## API
|
|
51
|
+
|
|
52
|
+
- `createCache(options?)` → `Cache` — `{ store?, defaultTtlMs?, now?, onError? }`.
|
|
53
|
+
- `cache.wrap(key, loader, { ttlMs?, swrMs?, tags? })` · `get` · `has` · `set` · `delete` · `invalidateTag` · `clear`.
|
|
54
|
+
- `MemoryCache({ maxEntries?, now? })` — the default store; implement `CacheStore` for your own.
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,KAAK,EAAE,YAAY,EAAuC,MAAM,YAAY,CAAA;AAE1F,kEAAkE;AAClE,wBAAgB,WAAW,CAAC,OAAO,GAAE,YAAiB,GAAG,KAAK,CAiF7D"}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cache facade — typed get/set/wrap over a {@link CacheStore}, with TTL, stale-while-revalidate, and
|
|
3
|
+
* single-flight stampede protection. Mirrors the other nifra primitives: a factory, an injectable clock,
|
|
4
|
+
* and error isolation (a background revalidation that throws goes to `onError`, never rejects the caller).
|
|
5
|
+
*
|
|
6
|
+
* import { createCache } from "@nifrajs/cache"
|
|
7
|
+
*
|
|
8
|
+
* const cache = createCache({ defaultTtlMs: 30_000 })
|
|
9
|
+
* // Cache-aside in a loader — one DB hit per 30s per key, stampede-safe:
|
|
10
|
+
* const user = await cache.wrap(`user:${id}`, () => db.user(id), { ttlMs: 30_000, swrMs: 60_000, tags: [`user:${id}`] })
|
|
11
|
+
* await cache.invalidateTag(`user:${id}`) // on write
|
|
12
|
+
*/
|
|
13
|
+
import { MemoryCache } from "./memory-cache.js";
|
|
14
|
+
/** Create a cache over the given (or a fresh in-memory) store. */
|
|
15
|
+
export function createCache(options = {}) {
|
|
16
|
+
const defaultTtlMs = options.defaultTtlMs ?? 60_000;
|
|
17
|
+
const now = options.now ?? (() => Date.now());
|
|
18
|
+
// The default store must share the facade's clock, or test-injected timestamps look instantly expired.
|
|
19
|
+
const store = options.store ?? new MemoryCache({ now });
|
|
20
|
+
const onError = options.onError ??
|
|
21
|
+
((error, key) => console.error(`[nifra/cache] revalidate ${JSON.stringify(key)} failed:`, error));
|
|
22
|
+
// Single-flight: concurrent loads for the same key share one promise (miss stampede + SWR dedup).
|
|
23
|
+
const inflight = new Map();
|
|
24
|
+
async function get(key) {
|
|
25
|
+
const entry = await store.get(key);
|
|
26
|
+
return entry === undefined ? undefined : entry.value;
|
|
27
|
+
}
|
|
28
|
+
async function has(key) {
|
|
29
|
+
return (await store.get(key)) !== undefined;
|
|
30
|
+
}
|
|
31
|
+
async function set(key, value, opts = {}) {
|
|
32
|
+
const ttlMs = opts.ttlMs ?? defaultTtlMs;
|
|
33
|
+
const swrMs = Math.max(0, opts.swrMs ?? 0);
|
|
34
|
+
const t = now();
|
|
35
|
+
await store.set(key, { value, staleAt: t + ttlMs, expiresAt: t + ttlMs + swrMs }, opts.tags ?? []);
|
|
36
|
+
}
|
|
37
|
+
function load(key, loader, opts) {
|
|
38
|
+
const existing = inflight.get(key);
|
|
39
|
+
if (existing !== undefined)
|
|
40
|
+
return existing;
|
|
41
|
+
const p = (async () => {
|
|
42
|
+
const value = await loader();
|
|
43
|
+
await set(key, value, opts);
|
|
44
|
+
return value;
|
|
45
|
+
})().finally(() => {
|
|
46
|
+
if (inflight.get(key) === p)
|
|
47
|
+
inflight.delete(key);
|
|
48
|
+
});
|
|
49
|
+
inflight.set(key, p);
|
|
50
|
+
return p;
|
|
51
|
+
}
|
|
52
|
+
function revalidate(key, loader, opts) {
|
|
53
|
+
if (inflight.has(key))
|
|
54
|
+
return; // a refresh is already running
|
|
55
|
+
void load(key, loader, opts).catch((error) => {
|
|
56
|
+
try {
|
|
57
|
+
onError(error, key);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* a throwing onError must not surface */
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async function wrap(key, loader, opts = {}) {
|
|
65
|
+
const entry = await store.get(key);
|
|
66
|
+
if (entry !== undefined) {
|
|
67
|
+
if (now() < entry.staleAt)
|
|
68
|
+
return entry.value; // fresh hit
|
|
69
|
+
revalidate(key, loader, opts); // stale-but-live → serve stale, refresh in background
|
|
70
|
+
return entry.value;
|
|
71
|
+
}
|
|
72
|
+
return (await load(key, loader, opts)); // miss → load (single-flight)
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
get,
|
|
76
|
+
has,
|
|
77
|
+
set,
|
|
78
|
+
wrap,
|
|
79
|
+
delete: (key) => Promise.resolve(store.delete(key)),
|
|
80
|
+
invalidateTag: (tag) => Promise.resolve(store.invalidateTag(tag)),
|
|
81
|
+
clear: () => Promise.resolve(store.clear()),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=cache.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache.js","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/cache — a typed KV cache for nifra: TTL, stale-while-revalidate, tag invalidation, and
|
|
3
|
+
* single-flight stampede protection, on a pluggable store (in-memory by default; bring CF KV / Redis for
|
|
4
|
+
* a shared cache). Dependency-free; runs on Bun/Node/Deno/Workers.
|
|
5
|
+
*
|
|
6
|
+
* import { createCache } from "@nifrajs/cache"
|
|
7
|
+
* const cache = createCache()
|
|
8
|
+
* const data = await cache.wrap("key", () => expensive(), { ttlMs: 60_000 })
|
|
9
|
+
*/
|
|
10
|
+
export { createCache } from "./cache.js";
|
|
11
|
+
export { MemoryCache, type MemoryCacheOptions } from "./memory-cache.js";
|
|
12
|
+
export type { Cache, CacheOptions, CacheStore, SetOptions, StoredEntry, WrapOptions, } from "./types.js";
|
|
13
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AACxC,OAAO,EAAE,WAAW,EAAE,KAAK,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AACxE,YAAY,EACV,KAAK,EACL,YAAY,EACZ,UAAU,EACV,UAAU,EACV,WAAW,EACX,WAAW,GACZ,MAAM,YAAY,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/cache — a typed KV cache for nifra: TTL, stale-while-revalidate, tag invalidation, and
|
|
3
|
+
* single-flight stampede protection, on a pluggable store (in-memory by default; bring CF KV / Redis for
|
|
4
|
+
* a shared cache). Dependency-free; runs on Bun/Node/Deno/Workers.
|
|
5
|
+
*
|
|
6
|
+
* import { createCache } from "@nifrajs/cache"
|
|
7
|
+
* const cache = createCache()
|
|
8
|
+
* const data = await cache.wrap("key", () => expensive(), { ttlMs: 60_000 })
|
|
9
|
+
*/
|
|
10
|
+
export { createCache } from "./cache.js";
|
|
11
|
+
export { MemoryCache } from "./memory-cache.js";
|
|
12
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AACxC,OAAO,EAAE,WAAW,EAA2B,MAAM,mBAAmB,CAAA"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process {@link CacheStore} — a Map with lazy expiry, a tag index for group invalidation, and an
|
|
3
|
+
* optional LRU cap. Correct for a single instance; for a cache shared across instances/workers, implement
|
|
4
|
+
* `CacheStore` over CF KV / Redis. Not persisted (a restart empties it).
|
|
5
|
+
*/
|
|
6
|
+
import type { CacheStore, StoredEntry } from "./types.js";
|
|
7
|
+
export interface MemoryCacheOptions {
|
|
8
|
+
/** Evict the least-recently-used entry once the count exceeds this. `0` (default) = unbounded. */
|
|
9
|
+
readonly maxEntries?: number;
|
|
10
|
+
/** Injectable clock (tests). Default `() => Date.now()`. */
|
|
11
|
+
readonly now?: () => number;
|
|
12
|
+
}
|
|
13
|
+
export declare class MemoryCache implements CacheStore {
|
|
14
|
+
private readonly rows;
|
|
15
|
+
private readonly tagIndex;
|
|
16
|
+
private readonly maxEntries;
|
|
17
|
+
private readonly now;
|
|
18
|
+
constructor(options?: MemoryCacheOptions);
|
|
19
|
+
get(key: string): StoredEntry | undefined;
|
|
20
|
+
set(key: string, entry: StoredEntry, tags: readonly string[]): void;
|
|
21
|
+
delete(key: string): void;
|
|
22
|
+
invalidateTag(tag: string): void;
|
|
23
|
+
clear(): void;
|
|
24
|
+
/** Live entry count (for observability/tests). */
|
|
25
|
+
size(): number;
|
|
26
|
+
/** Remove a key and unlink it from every tag set it belonged to. */
|
|
27
|
+
private evict;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=memory-cache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"memory-cache.d.ts","sourceRoot":"","sources":["../src/memory-cache.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AASzD,MAAM,WAAW,kBAAkB;IACjC,kGAAkG;IAClG,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,4DAA4D;IAC5D,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAC5B;AAED,qBAAa,WAAY,YAAW,UAAU;IAE5C,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAyB;IAC9C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiC;IAC1D,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAQ;IACnC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAc;gBAEtB,OAAO,GAAE,kBAAuB;IAK5C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS;IAazC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI;IAsBnE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAIzB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAMhC,KAAK,IAAI,IAAI;IAKb,kDAAkD;IAClD,IAAI,IAAI,MAAM;IAId,oEAAoE;IACpE,OAAO,CAAC,KAAK;CAYd"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export class MemoryCache {
|
|
2
|
+
// Map iteration is insertion-ordered, so the first key is the LRU victim; `get`/`set` re-insert to touch.
|
|
3
|
+
rows = new Map();
|
|
4
|
+
tagIndex = new Map();
|
|
5
|
+
maxEntries;
|
|
6
|
+
now;
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.maxEntries = options.maxEntries ?? 0;
|
|
9
|
+
this.now = options.now ?? (() => Date.now());
|
|
10
|
+
}
|
|
11
|
+
get(key) {
|
|
12
|
+
const row = this.rows.get(key);
|
|
13
|
+
if (row === undefined)
|
|
14
|
+
return undefined;
|
|
15
|
+
if (this.now() >= row.expiresAt) {
|
|
16
|
+
this.evict(key);
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
// LRU touch.
|
|
20
|
+
this.rows.delete(key);
|
|
21
|
+
this.rows.set(key, row);
|
|
22
|
+
return { value: row.value, expiresAt: row.expiresAt, staleAt: row.staleAt };
|
|
23
|
+
}
|
|
24
|
+
set(key, entry, tags) {
|
|
25
|
+
this.evict(key); // clear any prior tag links before overwriting
|
|
26
|
+
this.rows.set(key, {
|
|
27
|
+
value: entry.value,
|
|
28
|
+
expiresAt: entry.expiresAt,
|
|
29
|
+
staleAt: entry.staleAt,
|
|
30
|
+
tags,
|
|
31
|
+
});
|
|
32
|
+
for (const tag of tags) {
|
|
33
|
+
let set = this.tagIndex.get(tag);
|
|
34
|
+
if (set === undefined) {
|
|
35
|
+
set = new Set();
|
|
36
|
+
this.tagIndex.set(tag, set);
|
|
37
|
+
}
|
|
38
|
+
set.add(key);
|
|
39
|
+
}
|
|
40
|
+
if (this.maxEntries > 0 && this.rows.size > this.maxEntries) {
|
|
41
|
+
const oldest = this.rows.keys().next().value;
|
|
42
|
+
if (oldest !== undefined)
|
|
43
|
+
this.evict(oldest);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
delete(key) {
|
|
47
|
+
this.evict(key);
|
|
48
|
+
}
|
|
49
|
+
invalidateTag(tag) {
|
|
50
|
+
const keys = this.tagIndex.get(tag);
|
|
51
|
+
if (keys === undefined)
|
|
52
|
+
return;
|
|
53
|
+
for (const key of [...keys])
|
|
54
|
+
this.evict(key);
|
|
55
|
+
}
|
|
56
|
+
clear() {
|
|
57
|
+
this.rows.clear();
|
|
58
|
+
this.tagIndex.clear();
|
|
59
|
+
}
|
|
60
|
+
/** Live entry count (for observability/tests). */
|
|
61
|
+
size() {
|
|
62
|
+
return this.rows.size;
|
|
63
|
+
}
|
|
64
|
+
/** Remove a key and unlink it from every tag set it belonged to. */
|
|
65
|
+
evict(key) {
|
|
66
|
+
const row = this.rows.get(key);
|
|
67
|
+
if (row === undefined)
|
|
68
|
+
return;
|
|
69
|
+
this.rows.delete(key);
|
|
70
|
+
for (const tag of row.tags) {
|
|
71
|
+
const set = this.tagIndex.get(tag);
|
|
72
|
+
if (set !== undefined) {
|
|
73
|
+
set.delete(key);
|
|
74
|
+
if (set.size === 0)
|
|
75
|
+
this.tagIndex.delete(tag);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=memory-cache.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"memory-cache.js","sourceRoot":"","sources":["../src/memory-cache.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/cache — types for the typed KV cache.
|
|
3
|
+
*
|
|
4
|
+
* The facade ({@link Cache}) is typed + owns TTL/SWR/stampede logic; the {@link CacheStore} is a raw
|
|
5
|
+
* key→entry adapter (memory by default; bring CF KV / Redis for shared or durable caching). Dependency-free.
|
|
6
|
+
*/
|
|
7
|
+
/** A cached entry as the store holds it. */
|
|
8
|
+
export interface StoredEntry {
|
|
9
|
+
readonly value: unknown;
|
|
10
|
+
/** Epoch-ms after which the entry is gone — a miss. */
|
|
11
|
+
readonly expiresAt: number;
|
|
12
|
+
/** Epoch-ms after which the entry is stale: still served during the SWR window while a refresh runs.
|
|
13
|
+
* Equal to `expiresAt` when there's no stale-while-revalidate window. */
|
|
14
|
+
readonly staleAt: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Raw key→entry storage. The default {@link MemoryCache} is in-process; implement this over CF KV /
|
|
18
|
+
* Redis / etc. for a cache shared across instances. All methods may be sync or async — the cache awaits them.
|
|
19
|
+
*/
|
|
20
|
+
export interface CacheStore {
|
|
21
|
+
/** The live entry for `key`, or `undefined` if missing or hard-expired (`now >= expiresAt`). */
|
|
22
|
+
get(key: string): StoredEntry | undefined | Promise<StoredEntry | undefined>;
|
|
23
|
+
/** Store `entry` under `key`, indexed by `tags` for {@link CacheStore.invalidateTag}. */
|
|
24
|
+
set(key: string, entry: StoredEntry, tags: readonly string[]): void | Promise<void>;
|
|
25
|
+
delete(key: string): void | Promise<void>;
|
|
26
|
+
/** Drop every entry carrying `tag`. */
|
|
27
|
+
invalidateTag(tag: string): void | Promise<void>;
|
|
28
|
+
clear(): void | Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export interface SetOptions {
|
|
31
|
+
/** Time-to-live (ms) before the value goes stale. Default: the cache's `defaultTtlMs`. */
|
|
32
|
+
readonly ttlMs?: number;
|
|
33
|
+
/** Extra ms past TTL during which the stale value is served while a background refresh runs. Default 0. */
|
|
34
|
+
readonly swrMs?: number;
|
|
35
|
+
/** Tags for group invalidation via `invalidateTag`. */
|
|
36
|
+
readonly tags?: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
export type WrapOptions = SetOptions;
|
|
39
|
+
export interface CacheOptions {
|
|
40
|
+
/** Storage adapter. Default: a fresh {@link MemoryCache}. */
|
|
41
|
+
readonly store?: CacheStore;
|
|
42
|
+
/** Default TTL (ms) for `set`/`wrap` when none is given. Default 60_000. */
|
|
43
|
+
readonly defaultTtlMs?: number;
|
|
44
|
+
/** Injectable clock (tests). Default `() => Date.now()`. */
|
|
45
|
+
readonly now?: () => number;
|
|
46
|
+
/** Called when a background SWR revalidation throws. Default: `console.error`. */
|
|
47
|
+
readonly onError?: (error: unknown, key: string) => void;
|
|
48
|
+
}
|
|
49
|
+
export interface Cache {
|
|
50
|
+
/** The cached value for `key`, or `undefined` if missing/expired. Pass `T` for the value type. */
|
|
51
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
52
|
+
/** Whether a live (non-expired) entry exists. */
|
|
53
|
+
has(key: string): Promise<boolean>;
|
|
54
|
+
/** Store `value` under `key`. */
|
|
55
|
+
set<T>(key: string, value: T, options?: SetOptions): Promise<void>;
|
|
56
|
+
delete(key: string): Promise<void>;
|
|
57
|
+
/** Drop every entry tagged `tag`. */
|
|
58
|
+
invalidateTag(tag: string): Promise<void>;
|
|
59
|
+
clear(): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Cache-aside: return the cached value, or run `loader`, store the result, and return it. Stampede-safe
|
|
62
|
+
* (concurrent misses for one key share a single `loader` call) and SWR-aware (a stale-but-live value is
|
|
63
|
+
* returned immediately while a deduped background refresh runs). A throwing `loader` is NOT cached.
|
|
64
|
+
*/
|
|
65
|
+
wrap<T>(key: string, loader: () => T, options?: WrapOptions): Promise<Awaited<T>>;
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,4CAA4C;AAC5C,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;IACvB,uDAAuD;IACvD,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B;6EACyE;IACzE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACzB;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,gGAAgG;IAChG,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,GAAG,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC,CAAA;IAC5E,yFAAyF;IACzF,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACnF,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACzC,uCAAuC;IACvC,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAChD,KAAK,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAC9B;AAED,MAAM,WAAW,UAAU;IACzB,0FAA0F;IAC1F,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,2GAA2G;IAC3G,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,uDAAuD;IACvD,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAClC;AAED,MAAM,MAAM,WAAW,GAAG,UAAU,CAAA;AAEpC,MAAM,WAAW,YAAY;IAC3B,6DAA6D;IAC7D,QAAQ,CAAC,KAAK,CAAC,EAAE,UAAU,CAAA;IAC3B,4EAA4E;IAC5E,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAA;IAC9B,4DAA4D;IAC5D,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAC3B,kFAAkF;IAClF,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CACzD;AAED,MAAM,WAAW,KAAK;IACpB,kGAAkG;IAClG,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAA;IACrD,iDAAiD;IACjD,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAClC,iCAAiC;IACjC,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAClE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAClC,qCAAqC;IACrC,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACzC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACtB;;;;OAIG;IACH,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAA;CAClF"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/cache — types for the typed KV cache.
|
|
3
|
+
*
|
|
4
|
+
* The facade ({@link Cache}) is typed + owns TTL/SWR/stampede logic; the {@link CacheStore} is a raw
|
|
5
|
+
* key→entry adapter (memory by default; bring CF KV / Redis for shared or durable caching). Dependency-free.
|
|
6
|
+
*/
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG"}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nifrajs/cache",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Typed KV cache for nifra — TTL, stale-while-revalidate, tag invalidation, and single-flight stampede protection on a pluggable store (in-memory default; bring CF KV / Redis). Dependency-free, runs on Bun/Node/Deno/Workers.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"typescript",
|
|
8
|
+
"nifra",
|
|
9
|
+
"web-framework",
|
|
10
|
+
"full-stack",
|
|
11
|
+
"edge",
|
|
12
|
+
"ssr"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"module": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"bun": "./src/index.ts",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src"
|
|
28
|
+
],
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/nifrajs/nifra.git",
|
|
40
|
+
"directory": "packages/cache"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/nifrajs/nifra/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/nifrajs/nifra#readme"
|
|
46
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cache facade — typed get/set/wrap over a {@link CacheStore}, with TTL, stale-while-revalidate, and
|
|
3
|
+
* single-flight stampede protection. Mirrors the other nifra primitives: a factory, an injectable clock,
|
|
4
|
+
* and error isolation (a background revalidation that throws goes to `onError`, never rejects the caller).
|
|
5
|
+
*
|
|
6
|
+
* import { createCache } from "@nifrajs/cache"
|
|
7
|
+
*
|
|
8
|
+
* const cache = createCache({ defaultTtlMs: 30_000 })
|
|
9
|
+
* // Cache-aside in a loader — one DB hit per 30s per key, stampede-safe:
|
|
10
|
+
* const user = await cache.wrap(`user:${id}`, () => db.user(id), { ttlMs: 30_000, swrMs: 60_000, tags: [`user:${id}`] })
|
|
11
|
+
* await cache.invalidateTag(`user:${id}`) // on write
|
|
12
|
+
*/
|
|
13
|
+
import { MemoryCache } from "./memory-cache.ts"
|
|
14
|
+
import type { Cache, CacheOptions, CacheStore, SetOptions, WrapOptions } from "./types.ts"
|
|
15
|
+
|
|
16
|
+
/** Create a cache over the given (or a fresh in-memory) store. */
|
|
17
|
+
export function createCache(options: CacheOptions = {}): Cache {
|
|
18
|
+
const defaultTtlMs = options.defaultTtlMs ?? 60_000
|
|
19
|
+
const now = options.now ?? (() => Date.now())
|
|
20
|
+
// The default store must share the facade's clock, or test-injected timestamps look instantly expired.
|
|
21
|
+
const store: CacheStore = options.store ?? new MemoryCache({ now })
|
|
22
|
+
const onError =
|
|
23
|
+
options.onError ??
|
|
24
|
+
((error, key) =>
|
|
25
|
+
console.error(`[nifra/cache] revalidate ${JSON.stringify(key)} failed:`, error))
|
|
26
|
+
|
|
27
|
+
// Single-flight: concurrent loads for the same key share one promise (miss stampede + SWR dedup).
|
|
28
|
+
const inflight = new Map<string, Promise<unknown>>()
|
|
29
|
+
|
|
30
|
+
async function get<T = unknown>(key: string): Promise<T | undefined> {
|
|
31
|
+
const entry = await store.get(key)
|
|
32
|
+
return entry === undefined ? undefined : (entry.value as T)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function has(key: string): Promise<boolean> {
|
|
36
|
+
return (await store.get(key)) !== undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function set<T>(key: string, value: T, opts: SetOptions = {}): Promise<void> {
|
|
40
|
+
const ttlMs = opts.ttlMs ?? defaultTtlMs
|
|
41
|
+
const swrMs = Math.max(0, opts.swrMs ?? 0)
|
|
42
|
+
const t = now()
|
|
43
|
+
await store.set(
|
|
44
|
+
key,
|
|
45
|
+
{ value, staleAt: t + ttlMs, expiresAt: t + ttlMs + swrMs },
|
|
46
|
+
opts.tags ?? [],
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function load(key: string, loader: () => unknown, opts: WrapOptions): Promise<unknown> {
|
|
51
|
+
const existing = inflight.get(key)
|
|
52
|
+
if (existing !== undefined) return existing
|
|
53
|
+
const p = (async () => {
|
|
54
|
+
const value = await loader()
|
|
55
|
+
await set(key, value, opts)
|
|
56
|
+
return value
|
|
57
|
+
})().finally(() => {
|
|
58
|
+
if (inflight.get(key) === p) inflight.delete(key)
|
|
59
|
+
})
|
|
60
|
+
inflight.set(key, p)
|
|
61
|
+
return p
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function revalidate(key: string, loader: () => unknown, opts: WrapOptions): void {
|
|
65
|
+
if (inflight.has(key)) return // a refresh is already running
|
|
66
|
+
void load(key, loader, opts).catch((error) => {
|
|
67
|
+
try {
|
|
68
|
+
onError(error, key)
|
|
69
|
+
} catch {
|
|
70
|
+
/* a throwing onError must not surface */
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function wrap<T>(
|
|
76
|
+
key: string,
|
|
77
|
+
loader: () => T,
|
|
78
|
+
opts: WrapOptions = {},
|
|
79
|
+
): Promise<Awaited<T>> {
|
|
80
|
+
const entry = await store.get(key)
|
|
81
|
+
if (entry !== undefined) {
|
|
82
|
+
if (now() < entry.staleAt) return entry.value as Awaited<T> // fresh hit
|
|
83
|
+
revalidate(key, loader as () => unknown, opts) // stale-but-live → serve stale, refresh in background
|
|
84
|
+
return entry.value as Awaited<T>
|
|
85
|
+
}
|
|
86
|
+
return (await load(key, loader as () => unknown, opts)) as Awaited<T> // miss → load (single-flight)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
get,
|
|
91
|
+
has,
|
|
92
|
+
set,
|
|
93
|
+
wrap,
|
|
94
|
+
delete: (key) => Promise.resolve(store.delete(key)),
|
|
95
|
+
invalidateTag: (tag) => Promise.resolve(store.invalidateTag(tag)),
|
|
96
|
+
clear: () => Promise.resolve(store.clear()),
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/cache — a typed KV cache for nifra: TTL, stale-while-revalidate, tag invalidation, and
|
|
3
|
+
* single-flight stampede protection, on a pluggable store (in-memory by default; bring CF KV / Redis for
|
|
4
|
+
* a shared cache). Dependency-free; runs on Bun/Node/Deno/Workers.
|
|
5
|
+
*
|
|
6
|
+
* import { createCache } from "@nifrajs/cache"
|
|
7
|
+
* const cache = createCache()
|
|
8
|
+
* const data = await cache.wrap("key", () => expensive(), { ttlMs: 60_000 })
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { createCache } from "./cache.ts"
|
|
12
|
+
export { MemoryCache, type MemoryCacheOptions } from "./memory-cache.ts"
|
|
13
|
+
export type {
|
|
14
|
+
Cache,
|
|
15
|
+
CacheOptions,
|
|
16
|
+
CacheStore,
|
|
17
|
+
SetOptions,
|
|
18
|
+
StoredEntry,
|
|
19
|
+
WrapOptions,
|
|
20
|
+
} from "./types.ts"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process {@link CacheStore} — a Map with lazy expiry, a tag index for group invalidation, and an
|
|
3
|
+
* optional LRU cap. Correct for a single instance; for a cache shared across instances/workers, implement
|
|
4
|
+
* `CacheStore` over CF KV / Redis. Not persisted (a restart empties it).
|
|
5
|
+
*/
|
|
6
|
+
import type { CacheStore, StoredEntry } from "./types.ts"
|
|
7
|
+
|
|
8
|
+
interface Row {
|
|
9
|
+
value: unknown
|
|
10
|
+
expiresAt: number
|
|
11
|
+
staleAt: number
|
|
12
|
+
tags: readonly string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MemoryCacheOptions {
|
|
16
|
+
/** Evict the least-recently-used entry once the count exceeds this. `0` (default) = unbounded. */
|
|
17
|
+
readonly maxEntries?: number
|
|
18
|
+
/** Injectable clock (tests). Default `() => Date.now()`. */
|
|
19
|
+
readonly now?: () => number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class MemoryCache implements CacheStore {
|
|
23
|
+
// Map iteration is insertion-ordered, so the first key is the LRU victim; `get`/`set` re-insert to touch.
|
|
24
|
+
private readonly rows = new Map<string, Row>()
|
|
25
|
+
private readonly tagIndex = new Map<string, Set<string>>()
|
|
26
|
+
private readonly maxEntries: number
|
|
27
|
+
private readonly now: () => number
|
|
28
|
+
|
|
29
|
+
constructor(options: MemoryCacheOptions = {}) {
|
|
30
|
+
this.maxEntries = options.maxEntries ?? 0
|
|
31
|
+
this.now = options.now ?? (() => Date.now())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get(key: string): StoredEntry | undefined {
|
|
35
|
+
const row = this.rows.get(key)
|
|
36
|
+
if (row === undefined) return undefined
|
|
37
|
+
if (this.now() >= row.expiresAt) {
|
|
38
|
+
this.evict(key)
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
// LRU touch.
|
|
42
|
+
this.rows.delete(key)
|
|
43
|
+
this.rows.set(key, row)
|
|
44
|
+
return { value: row.value, expiresAt: row.expiresAt, staleAt: row.staleAt }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
set(key: string, entry: StoredEntry, tags: readonly string[]): void {
|
|
48
|
+
this.evict(key) // clear any prior tag links before overwriting
|
|
49
|
+
this.rows.set(key, {
|
|
50
|
+
value: entry.value,
|
|
51
|
+
expiresAt: entry.expiresAt,
|
|
52
|
+
staleAt: entry.staleAt,
|
|
53
|
+
tags,
|
|
54
|
+
})
|
|
55
|
+
for (const tag of tags) {
|
|
56
|
+
let set = this.tagIndex.get(tag)
|
|
57
|
+
if (set === undefined) {
|
|
58
|
+
set = new Set()
|
|
59
|
+
this.tagIndex.set(tag, set)
|
|
60
|
+
}
|
|
61
|
+
set.add(key)
|
|
62
|
+
}
|
|
63
|
+
if (this.maxEntries > 0 && this.rows.size > this.maxEntries) {
|
|
64
|
+
const oldest = this.rows.keys().next().value
|
|
65
|
+
if (oldest !== undefined) this.evict(oldest)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
delete(key: string): void {
|
|
70
|
+
this.evict(key)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
invalidateTag(tag: string): void {
|
|
74
|
+
const keys = this.tagIndex.get(tag)
|
|
75
|
+
if (keys === undefined) return
|
|
76
|
+
for (const key of [...keys]) this.evict(key)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
clear(): void {
|
|
80
|
+
this.rows.clear()
|
|
81
|
+
this.tagIndex.clear()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Live entry count (for observability/tests). */
|
|
85
|
+
size(): number {
|
|
86
|
+
return this.rows.size
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Remove a key and unlink it from every tag set it belonged to. */
|
|
90
|
+
private evict(key: string): void {
|
|
91
|
+
const row = this.rows.get(key)
|
|
92
|
+
if (row === undefined) return
|
|
93
|
+
this.rows.delete(key)
|
|
94
|
+
for (const tag of row.tags) {
|
|
95
|
+
const set = this.tagIndex.get(tag)
|
|
96
|
+
if (set !== undefined) {
|
|
97
|
+
set.delete(key)
|
|
98
|
+
if (set.size === 0) this.tagIndex.delete(tag)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/cache — types for the typed KV cache.
|
|
3
|
+
*
|
|
4
|
+
* The facade ({@link Cache}) is typed + owns TTL/SWR/stampede logic; the {@link CacheStore} is a raw
|
|
5
|
+
* key→entry adapter (memory by default; bring CF KV / Redis for shared or durable caching). Dependency-free.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** A cached entry as the store holds it. */
|
|
9
|
+
export interface StoredEntry {
|
|
10
|
+
readonly value: unknown
|
|
11
|
+
/** Epoch-ms after which the entry is gone — a miss. */
|
|
12
|
+
readonly expiresAt: number
|
|
13
|
+
/** Epoch-ms after which the entry is stale: still served during the SWR window while a refresh runs.
|
|
14
|
+
* Equal to `expiresAt` when there's no stale-while-revalidate window. */
|
|
15
|
+
readonly staleAt: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Raw key→entry storage. The default {@link MemoryCache} is in-process; implement this over CF KV /
|
|
20
|
+
* Redis / etc. for a cache shared across instances. All methods may be sync or async — the cache awaits them.
|
|
21
|
+
*/
|
|
22
|
+
export interface CacheStore {
|
|
23
|
+
/** The live entry for `key`, or `undefined` if missing or hard-expired (`now >= expiresAt`). */
|
|
24
|
+
get(key: string): StoredEntry | undefined | Promise<StoredEntry | undefined>
|
|
25
|
+
/** Store `entry` under `key`, indexed by `tags` for {@link CacheStore.invalidateTag}. */
|
|
26
|
+
set(key: string, entry: StoredEntry, tags: readonly string[]): void | Promise<void>
|
|
27
|
+
delete(key: string): void | Promise<void>
|
|
28
|
+
/** Drop every entry carrying `tag`. */
|
|
29
|
+
invalidateTag(tag: string): void | Promise<void>
|
|
30
|
+
clear(): void | Promise<void>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SetOptions {
|
|
34
|
+
/** Time-to-live (ms) before the value goes stale. Default: the cache's `defaultTtlMs`. */
|
|
35
|
+
readonly ttlMs?: number
|
|
36
|
+
/** Extra ms past TTL during which the stale value is served while a background refresh runs. Default 0. */
|
|
37
|
+
readonly swrMs?: number
|
|
38
|
+
/** Tags for group invalidation via `invalidateTag`. */
|
|
39
|
+
readonly tags?: readonly string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type WrapOptions = SetOptions
|
|
43
|
+
|
|
44
|
+
export interface CacheOptions {
|
|
45
|
+
/** Storage adapter. Default: a fresh {@link MemoryCache}. */
|
|
46
|
+
readonly store?: CacheStore
|
|
47
|
+
/** Default TTL (ms) for `set`/`wrap` when none is given. Default 60_000. */
|
|
48
|
+
readonly defaultTtlMs?: number
|
|
49
|
+
/** Injectable clock (tests). Default `() => Date.now()`. */
|
|
50
|
+
readonly now?: () => number
|
|
51
|
+
/** Called when a background SWR revalidation throws. Default: `console.error`. */
|
|
52
|
+
readonly onError?: (error: unknown, key: string) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Cache {
|
|
56
|
+
/** The cached value for `key`, or `undefined` if missing/expired. Pass `T` for the value type. */
|
|
57
|
+
get<T = unknown>(key: string): Promise<T | undefined>
|
|
58
|
+
/** Whether a live (non-expired) entry exists. */
|
|
59
|
+
has(key: string): Promise<boolean>
|
|
60
|
+
/** Store `value` under `key`. */
|
|
61
|
+
set<T>(key: string, value: T, options?: SetOptions): Promise<void>
|
|
62
|
+
delete(key: string): Promise<void>
|
|
63
|
+
/** Drop every entry tagged `tag`. */
|
|
64
|
+
invalidateTag(tag: string): Promise<void>
|
|
65
|
+
clear(): Promise<void>
|
|
66
|
+
/**
|
|
67
|
+
* Cache-aside: return the cached value, or run `loader`, store the result, and return it. Stampede-safe
|
|
68
|
+
* (concurrent misses for one key share a single `loader` call) and SWR-aware (a stale-but-live value is
|
|
69
|
+
* returned immediately while a deduped background refresh runs). A throwing `loader` is NOT cached.
|
|
70
|
+
*/
|
|
71
|
+
wrap<T>(key: string, loader: () => T, options?: WrapOptions): Promise<Awaited<T>>
|
|
72
|
+
}
|