@blogmcp/content-cache 0.2.1
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 +82 -0
- package/dist/fs-store.d.ts +28 -0
- package/dist/fs-store.js +83 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +6 -0
- package/dist/swr-cache.d.ts +95 -0
- package/dist/swr-cache.js +106 -0
- package/dist/types.d.ts +41 -0
- package/package.json +46 -0
- package/src/__tests__/fs-store.test.ts +80 -0
- package/src/__tests__/swr-cache.test.ts +240 -0
- package/src/fs-store.ts +131 -0
- package/src/index.ts +24 -0
- package/src/swr-cache.ts +221 -0
- package/src/types.ts +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @blogmcp/content-cache
|
|
2
|
+
|
|
3
|
+
Server-only durable content cache for blogmcp presentation layers.
|
|
4
|
+
|
|
5
|
+
A consuming SSR app (the drhoubicka storefront, the reference blogs) sources
|
|
6
|
+
KB-owned page MDX + navigation through a two-tier read-through: a per-process
|
|
7
|
+
in-memory hot tier over a pluggable **durable** store. The durable copy is
|
|
8
|
+
written through on every successful KB fetch, so it is always the last content
|
|
9
|
+
the KB actually served — a resilient offline floor with **no hand-maintained
|
|
10
|
+
second source of truth to drift**.
|
|
11
|
+
|
|
12
|
+
> Server-only: `FsCacheStore` imports `node:fs`. Import from `*.server.ts`
|
|
13
|
+
> modules only — never a browser bundle.
|
|
14
|
+
|
|
15
|
+
## API
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { SwrCache, FsCacheStore } from '@blogmcp/content-cache';
|
|
19
|
+
|
|
20
|
+
// Public (durable) tier — survives restarts, is the outage floor.
|
|
21
|
+
const articles = new SwrCache<{ mdx: string }>({
|
|
22
|
+
store: new FsCacheStore({ dir: process.env.CACHE_DIR! }),
|
|
23
|
+
freshMs: 60_000, // serve memory without revalidating
|
|
24
|
+
staleMs: 600_000, // serve stale + revalidate in the background
|
|
25
|
+
negTtlMs: 30_000, // suppress refetch of a known-failing key
|
|
26
|
+
keyPrefix: 'article:',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Gated/member tier — NEVER persisted to disk (omit `store`).
|
|
30
|
+
const memberArticles = new SwrCache({ freshMs: 60_000, staleMs: 600_000, negTtlMs: 30_000 });
|
|
31
|
+
|
|
32
|
+
const { value, source } = await articles.get(key, async () => {
|
|
33
|
+
const a = await kb.fetch(key); // fetcher MUST throw on miss/failure
|
|
34
|
+
if (!a) throw new Error('no content');
|
|
35
|
+
return { mdx: a.content };
|
|
36
|
+
});
|
|
37
|
+
// source ∈ fresh | stale | cold | lkg-memory | lkg-durable | negative | error
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Degradation ladder on a KB failure: prior in-memory value → durable store copy →
|
|
41
|
+
`null` (negative-cached). Successful fetches write through to both tiers; the
|
|
42
|
+
durable write is fire-and-forget (never blocks or fails the request path).
|
|
43
|
+
|
|
44
|
+
### Purge
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
await articles.purge(key); // evict one key (memory + durable)
|
|
48
|
+
await articles.purgeAll('article:'); // evict a prefix
|
|
49
|
+
await articles.purgeAll(); // evict everything
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Storage adapters
|
|
53
|
+
|
|
54
|
+
`CacheStore` separates the caching pattern from where the last-known-good copy
|
|
55
|
+
lives:
|
|
56
|
+
|
|
57
|
+
- **`FsCacheStore`** — ships today. One atomic JSON file per entry under a dir.
|
|
58
|
+
- **`S3CacheStore`** — designed-for (shared floor across pods). Not yet implemented.
|
|
59
|
+
- **`PgCacheStore`** — designed-for (transactional/queryable). Not yet implemented.
|
|
60
|
+
|
|
61
|
+
All satisfy the same interface, so adopting one is a constructor swap.
|
|
62
|
+
|
|
63
|
+
`T` must be JSON-serializable — cache the raw nav-tree array, not a built `Map`.
|
|
64
|
+
|
|
65
|
+
## Bounds
|
|
66
|
+
|
|
67
|
+
The in-memory tier has no size cap or idle eviction — expired negative entries are
|
|
68
|
+
reclaimed opportunistically on access, but distinct keys accumulate. Use it with a
|
|
69
|
+
**bounded key space** (the storefront keys by tier + immutable id/filename + locale,
|
|
70
|
+
and by nav scope + locale — dozens of entries). Don't key by unbounded/attacker-
|
|
71
|
+
controlled input; a `maxEntries` LRU is a future add if a consumer needs one.
|
|
72
|
+
|
|
73
|
+
## Build / publish
|
|
74
|
+
|
|
75
|
+
Dual package.json (like `@blogmcp/sdk`): in-repo `exports` point at `./src`;
|
|
76
|
+
`prepack` rewrites them to `./dist` for the npm tarball. Version-locked with the
|
|
77
|
+
`@blogmcp/sdk` fixed changeset group.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npm run build # vite → dist/*.js + tsc → dist/*.d.ts
|
|
81
|
+
npm test # vitest
|
|
82
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CacheEntry, CacheStore } from './types';
|
|
2
|
+
export interface FsCacheStoreOptions {
|
|
3
|
+
/** Directory that holds one JSON file per cache entry. Created on demand. */
|
|
4
|
+
dir: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Filesystem {@link CacheStore}: one JSON file per entry, named by a truncated
|
|
8
|
+
* sha256 of the key (keys contain `|`/`:`/`/`, unsafe as filenames). Writes are
|
|
9
|
+
* atomic (write a unique temp file in the same dir, then `rename`), so a crash
|
|
10
|
+
* mid-write never leaves a torn entry — a reader sees either the old file or the
|
|
11
|
+
* new one. A corrupt/partial file reads as a miss and self-heals on the next set.
|
|
12
|
+
*
|
|
13
|
+
* Server-only (imports `node:fs`/`node:crypto`/`node:path`) — never import from a
|
|
14
|
+
* browser bundle. Per-process on disk: on Kubernetes each pod keeps its own copy
|
|
15
|
+
* (fine for a last-known-good floor); a shared `S3CacheStore` would centralize it.
|
|
16
|
+
*/
|
|
17
|
+
export declare class FsCacheStore implements CacheStore {
|
|
18
|
+
readonly dir: string;
|
|
19
|
+
constructor(opts: FsCacheStoreOptions);
|
|
20
|
+
private fileFor;
|
|
21
|
+
private ensureDir;
|
|
22
|
+
get(key: string): Promise<CacheEntry | null>;
|
|
23
|
+
set(key: string, entry: CacheEntry): Promise<void>;
|
|
24
|
+
delete(key: string): Promise<void>;
|
|
25
|
+
private readAllRecords;
|
|
26
|
+
keys(prefix?: string): Promise<string[]>;
|
|
27
|
+
clear(prefix?: string): Promise<void>;
|
|
28
|
+
}
|
package/dist/fs-store.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { promises as a } from "node:fs";
|
|
2
|
+
import { createHash as c, randomUUID as o } from "node:crypto";
|
|
3
|
+
import { resolve as l, join as n } from "node:path";
|
|
4
|
+
class w {
|
|
5
|
+
dir;
|
|
6
|
+
constructor(t) {
|
|
7
|
+
if (!t || !t.dir) throw new Error("FsCacheStore requires a `dir`");
|
|
8
|
+
this.dir = l(t.dir);
|
|
9
|
+
}
|
|
10
|
+
fileFor(t) {
|
|
11
|
+
const e = c("sha256").update(t).digest("hex").slice(0, 40);
|
|
12
|
+
return n(this.dir, `${e}.json`);
|
|
13
|
+
}
|
|
14
|
+
async ensureDir() {
|
|
15
|
+
await a.mkdir(this.dir, { recursive: !0 });
|
|
16
|
+
}
|
|
17
|
+
async get(t) {
|
|
18
|
+
try {
|
|
19
|
+
const e = await a.readFile(this.fileFor(t), "utf8"), r = JSON.parse(e);
|
|
20
|
+
return !r || r.key !== t || !r.entry ? null : r.entry;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async set(t, e) {
|
|
26
|
+
await this.ensureDir();
|
|
27
|
+
const r = this.fileFor(t), i = `${r}.${o()}.tmp`, s = { key: t, entry: e };
|
|
28
|
+
await a.writeFile(i, JSON.stringify(s), "utf8"), await a.rename(i, r);
|
|
29
|
+
}
|
|
30
|
+
async delete(t) {
|
|
31
|
+
try {
|
|
32
|
+
await a.unlink(this.fileFor(t));
|
|
33
|
+
} catch (e) {
|
|
34
|
+
if (e.code !== "ENOENT") throw e;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async readAllRecords() {
|
|
38
|
+
let t;
|
|
39
|
+
try {
|
|
40
|
+
t = await a.readdir(this.dir);
|
|
41
|
+
} catch (r) {
|
|
42
|
+
if (r.code === "ENOENT") return [];
|
|
43
|
+
throw r;
|
|
44
|
+
}
|
|
45
|
+
const e = [];
|
|
46
|
+
return await Promise.all(
|
|
47
|
+
t.filter((r) => r.endsWith(".json")).map(async (r) => {
|
|
48
|
+
try {
|
|
49
|
+
const i = await a.readFile(n(this.dir, r), "utf8"), s = JSON.parse(i);
|
|
50
|
+
s && typeof s.key == "string" && s.entry && e.push(s);
|
|
51
|
+
} catch {
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
), e;
|
|
55
|
+
}
|
|
56
|
+
async keys(t) {
|
|
57
|
+
const e = (await this.readAllRecords()).map((r) => r.key);
|
|
58
|
+
return t ? e.filter((r) => r.startsWith(t)) : e;
|
|
59
|
+
}
|
|
60
|
+
async clear(t) {
|
|
61
|
+
if (!t) {
|
|
62
|
+
let r;
|
|
63
|
+
try {
|
|
64
|
+
r = await a.readdir(this.dir);
|
|
65
|
+
} catch (i) {
|
|
66
|
+
if (i.code === "ENOENT") return;
|
|
67
|
+
throw i;
|
|
68
|
+
}
|
|
69
|
+
await Promise.all(
|
|
70
|
+
r.filter((i) => i.endsWith(".json") || i.endsWith(".tmp")).map((i) => a.unlink(n(this.dir, i)).catch(() => {
|
|
71
|
+
}))
|
|
72
|
+
);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const e = await this.readAllRecords();
|
|
76
|
+
await Promise.all(
|
|
77
|
+
e.filter((r) => r.key.startsWith(t)).map((r) => this.delete(r.key))
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export {
|
|
82
|
+
w as FsCacheStore
|
|
83
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blogmcp/content-cache — server-only durable content cache for blogmcp
|
|
3
|
+
* presentation layers.
|
|
4
|
+
*
|
|
5
|
+
* A consuming SSR app (the drhoubicka storefront, the reference blogs) sources
|
|
6
|
+
* KB-owned page MDX + navigation through {@link SwrCache}: a per-process hot tier
|
|
7
|
+
* over a pluggable durable {@link CacheStore}. The durable copy is written through
|
|
8
|
+
* on every successful origin fetch, so it is always the last content the KB
|
|
9
|
+
* actually served — the resilient offline floor with NO hand-maintained second
|
|
10
|
+
* source of truth to drift. Storage is separated from the caching pattern:
|
|
11
|
+
* {@link FsCacheStore} ships today; S3/Postgres adapters slot in behind the same
|
|
12
|
+
* interface later.
|
|
13
|
+
*
|
|
14
|
+
* Server-only: {@link FsCacheStore} imports `node:fs`. Import from `*.server.ts`
|
|
15
|
+
* modules only — never a browser bundle.
|
|
16
|
+
*/
|
|
17
|
+
export type { CacheEntry, CacheStore } from './types';
|
|
18
|
+
export { FsCacheStore, type FsCacheStoreOptions } from './fs-store';
|
|
19
|
+
export { SwrCache, type SwrCacheOptions, type SwrResult, type SwrSource, } from './swr-cache';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { CacheStore } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Which tier served a {@link SwrCache.get} result — for observability/logging.
|
|
4
|
+
* - `fresh` — in-memory hit within `freshMs` (no revalidation)
|
|
5
|
+
* - `stale` — in-memory hit within `staleMs` (revalidating in background)
|
|
6
|
+
* - `cold` — fetched live from the origin
|
|
7
|
+
* - `lkg-memory` — origin failed; served the prior in-memory value
|
|
8
|
+
* - `lkg-durable` — origin failed + memory cold; served the durable store's copy
|
|
9
|
+
* - `negative` — recently-failed key with nothing cached (short-circuited)
|
|
10
|
+
* - `error` — origin failed and no value anywhere; caller gets `null`
|
|
11
|
+
*/
|
|
12
|
+
export type SwrSource = 'fresh' | 'stale' | 'cold' | 'lkg-memory' | 'lkg-durable' | 'negative' | 'error';
|
|
13
|
+
export interface SwrResult<T> {
|
|
14
|
+
value: T | null;
|
|
15
|
+
source: SwrSource;
|
|
16
|
+
}
|
|
17
|
+
export interface SwrCacheOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Durable backing tier — the last-known-good floor that survives a process
|
|
20
|
+
* restart. OMIT for an in-memory-only cache (e.g. gated/member content that
|
|
21
|
+
* must never be persisted to disk).
|
|
22
|
+
*/
|
|
23
|
+
store?: CacheStore;
|
|
24
|
+
/** Serve from memory without revalidating for this long after a success. */
|
|
25
|
+
freshMs: number;
|
|
26
|
+
/** Serve stale from memory + revalidate in the background up to this age. */
|
|
27
|
+
staleMs: number;
|
|
28
|
+
/** Suppress refetch of a known-failing key (with no cached value) for this long. */
|
|
29
|
+
negTtlMs: number;
|
|
30
|
+
/** Namespace applied to DURABLE keys so several caches can share one store. */
|
|
31
|
+
keyPrefix?: string;
|
|
32
|
+
/** Clock injection point (defaults to `Date.now`) — for deterministic tests. */
|
|
33
|
+
now?: () => number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Two-tier read-through cache: a per-process in-memory hot tier for latency, over
|
|
37
|
+
* an optional durable {@link CacheStore} for last-known-good survival across
|
|
38
|
+
* restarts. Generalizes the storefront's hand-rolled `swrGet`/`getNavMap` state
|
|
39
|
+
* machines (fresh → stale-while-revalidate → last-known-good → negative cache →
|
|
40
|
+
* single-flight) and adds the durable tier + a purge surface.
|
|
41
|
+
*
|
|
42
|
+
* On a successful fetch the value is written through to BOTH tiers (the durable
|
|
43
|
+
* write is fire-and-forget — it never blocks or fails the request). On an origin
|
|
44
|
+
* failure the cache degrades gracefully: prior memory value → durable value →
|
|
45
|
+
* `null` (negative-cached). The durable copy is therefore always the last content
|
|
46
|
+
* the origin actually served — there is no hand-maintained second source of truth
|
|
47
|
+
* to drift.
|
|
48
|
+
*
|
|
49
|
+
* `T` MUST be JSON-serializable (see {@link CacheEntry}).
|
|
50
|
+
*/
|
|
51
|
+
export declare class SwrCache<T = unknown> {
|
|
52
|
+
private readonly store?;
|
|
53
|
+
private readonly freshMs;
|
|
54
|
+
private readonly staleMs;
|
|
55
|
+
private readonly negTtlMs;
|
|
56
|
+
private readonly keyPrefix;
|
|
57
|
+
private readonly now;
|
|
58
|
+
private readonly mem;
|
|
59
|
+
private readonly neg;
|
|
60
|
+
private readonly inflight;
|
|
61
|
+
/**
|
|
62
|
+
* Bumped by every `purge`/`purgeAll`. A cache-population step (write-back after
|
|
63
|
+
* a fetch, durable-LKG hydration, negative-cache set) captures the generation
|
|
64
|
+
* when it STARTS and only applies its write if the generation is unchanged — so
|
|
65
|
+
* a purge that lands mid-flight is never silently re-populated by an in-flight
|
|
66
|
+
* (possibly stale, possibly coalesced) fetch. Fences the "lost purge" race.
|
|
67
|
+
*/
|
|
68
|
+
private generation;
|
|
69
|
+
constructor(opts: SwrCacheOptions);
|
|
70
|
+
private durableKey;
|
|
71
|
+
/**
|
|
72
|
+
* Coalesce concurrent fetches for the same key onto one in-flight promise, and
|
|
73
|
+
* populate BOTH tiers on success — exactly once per real fetch, so a later
|
|
74
|
+
* request that rides an in-flight fetch (started before a purge) can't smuggle a
|
|
75
|
+
* stale value past that purge. The write-back is gated by the generation at
|
|
76
|
+
* FETCH-START: if a purge bumped it while the fetch was outstanding, the result
|
|
77
|
+
* is returned to callers but NOT cached.
|
|
78
|
+
*/
|
|
79
|
+
private runFetch;
|
|
80
|
+
/** Persist to the durable tier without ever blocking/failing the request path. */
|
|
81
|
+
private writeThrough;
|
|
82
|
+
/**
|
|
83
|
+
* Read `key`, fetching via `fetcher` on a miss. `fetcher` MUST throw to signal a
|
|
84
|
+
* failure/absent value (mirrors the storefront's throwing fetchers); its resolved
|
|
85
|
+
* value is cached. Returns `{ value: null }` only when the origin fails and there
|
|
86
|
+
* is nothing cached in either tier.
|
|
87
|
+
*/
|
|
88
|
+
get(key: string, fetcher: () => Promise<T>): Promise<SwrResult<T>>;
|
|
89
|
+
/** Immediately evict one key from memory AND the durable store. */
|
|
90
|
+
purge(key: string): Promise<void>;
|
|
91
|
+
/** Evict every key (or those starting with `prefix`) from memory AND durable. */
|
|
92
|
+
purgeAll(prefix?: string): Promise<void>;
|
|
93
|
+
/** Test-only: clear the in-memory tiers (leaves the durable store intact). */
|
|
94
|
+
resetMemory(): void;
|
|
95
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
class o {
|
|
2
|
+
store;
|
|
3
|
+
freshMs;
|
|
4
|
+
staleMs;
|
|
5
|
+
negTtlMs;
|
|
6
|
+
keyPrefix;
|
|
7
|
+
now;
|
|
8
|
+
mem = /* @__PURE__ */ new Map();
|
|
9
|
+
neg = /* @__PURE__ */ new Map();
|
|
10
|
+
inflight = /* @__PURE__ */ new Map();
|
|
11
|
+
/**
|
|
12
|
+
* Bumped by every `purge`/`purgeAll`. A cache-population step (write-back after
|
|
13
|
+
* a fetch, durable-LKG hydration, negative-cache set) captures the generation
|
|
14
|
+
* when it STARTS and only applies its write if the generation is unchanged — so
|
|
15
|
+
* a purge that lands mid-flight is never silently re-populated by an in-flight
|
|
16
|
+
* (possibly stale, possibly coalesced) fetch. Fences the "lost purge" race.
|
|
17
|
+
*/
|
|
18
|
+
generation = 0;
|
|
19
|
+
constructor(t) {
|
|
20
|
+
this.store = t.store, this.freshMs = t.freshMs, this.staleMs = t.staleMs, this.negTtlMs = t.negTtlMs, this.keyPrefix = t.keyPrefix ?? "", this.now = t.now ?? (() => Date.now());
|
|
21
|
+
}
|
|
22
|
+
durableKey(t) {
|
|
23
|
+
return this.keyPrefix ? `${this.keyPrefix}${t}` : t;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Coalesce concurrent fetches for the same key onto one in-flight promise, and
|
|
27
|
+
* populate BOTH tiers on success — exactly once per real fetch, so a later
|
|
28
|
+
* request that rides an in-flight fetch (started before a purge) can't smuggle a
|
|
29
|
+
* stale value past that purge. The write-back is gated by the generation at
|
|
30
|
+
* FETCH-START: if a purge bumped it while the fetch was outstanding, the result
|
|
31
|
+
* is returned to callers but NOT cached.
|
|
32
|
+
*/
|
|
33
|
+
runFetch(t, e) {
|
|
34
|
+
const i = this.inflight.get(t);
|
|
35
|
+
if (i) return i;
|
|
36
|
+
const n = this.generation, s = (async () => {
|
|
37
|
+
try {
|
|
38
|
+
const r = await e();
|
|
39
|
+
return this.generation === n && (this.mem.set(t, { value: r, at: this.now() }), this.writeThrough(t, r), this.neg.delete(t)), r;
|
|
40
|
+
} finally {
|
|
41
|
+
this.inflight.delete(t);
|
|
42
|
+
}
|
|
43
|
+
})();
|
|
44
|
+
return this.inflight.set(t, s), s;
|
|
45
|
+
}
|
|
46
|
+
/** Persist to the durable tier without ever blocking/failing the request path. */
|
|
47
|
+
writeThrough(t, e) {
|
|
48
|
+
if (!this.store) return;
|
|
49
|
+
const i = { value: e, storedAt: this.now() };
|
|
50
|
+
this.store.set(this.durableKey(t), i).catch(() => {
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Read `key`, fetching via `fetcher` on a miss. `fetcher` MUST throw to signal a
|
|
55
|
+
* failure/absent value (mirrors the storefront's throwing fetchers); its resolved
|
|
56
|
+
* value is cached. Returns `{ value: null }` only when the origin fails and there
|
|
57
|
+
* is nothing cached in either tier.
|
|
58
|
+
*/
|
|
59
|
+
async get(t, e) {
|
|
60
|
+
const i = this.generation, n = this.now(), s = this.mem.get(t);
|
|
61
|
+
if (s && n - s.at < this.freshMs)
|
|
62
|
+
return { value: s.value, source: "fresh" };
|
|
63
|
+
const r = this.neg.get(t);
|
|
64
|
+
if (!s && r !== void 0) {
|
|
65
|
+
if (n - r < this.negTtlMs) return { value: null, source: "negative" };
|
|
66
|
+
this.neg.delete(t);
|
|
67
|
+
}
|
|
68
|
+
if (s && n - s.at < this.staleMs)
|
|
69
|
+
return this.runFetch(t, e).catch(() => {
|
|
70
|
+
}), { value: s.value, source: "stale" };
|
|
71
|
+
try {
|
|
72
|
+
return { value: await this.runFetch(t, e), source: "cold" };
|
|
73
|
+
} catch {
|
|
74
|
+
if (s)
|
|
75
|
+
return { value: s.value, source: "lkg-memory" };
|
|
76
|
+
if (this.store) {
|
|
77
|
+
const h = await this.store.get(this.durableKey(t)).catch(() => null);
|
|
78
|
+
if (h) {
|
|
79
|
+
const a = h.value;
|
|
80
|
+
return this.generation === i && this.mem.set(t, { value: a, at: h.storedAt }), { value: a, source: "lkg-durable" };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return this.generation === i && this.neg.set(t, this.now()), { value: null, source: "error" };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Immediately evict one key from memory AND the durable store. */
|
|
87
|
+
async purge(t) {
|
|
88
|
+
this.generation++, this.mem.delete(t), this.neg.delete(t), this.store && await this.store.delete(this.durableKey(t));
|
|
89
|
+
}
|
|
90
|
+
/** Evict every key (or those starting with `prefix`) from memory AND durable. */
|
|
91
|
+
async purgeAll(t) {
|
|
92
|
+
if (this.generation++, t) {
|
|
93
|
+
for (const e of [...this.mem.keys()]) e.startsWith(t) && this.mem.delete(e);
|
|
94
|
+
for (const e of [...this.neg.keys()]) e.startsWith(t) && this.neg.delete(e);
|
|
95
|
+
this.store && await this.store.clear(this.durableKey(t));
|
|
96
|
+
} else
|
|
97
|
+
this.mem.clear(), this.neg.clear(), this.store && await this.store.clear();
|
|
98
|
+
}
|
|
99
|
+
/** Test-only: clear the in-memory tiers (leaves the durable store intact). */
|
|
100
|
+
resetMemory() {
|
|
101
|
+
this.mem.clear(), this.neg.clear(), this.inflight.clear();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export {
|
|
105
|
+
o as SwrCache
|
|
106
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single cached value plus the freshness basis a read-through layer needs.
|
|
3
|
+
* `value` MUST be JSON-serializable — the filesystem (and future S3/Postgres)
|
|
4
|
+
* adapters persist it as JSON, so a Map/Set/class instance must be reduced to a
|
|
5
|
+
* plain object/array before caching (e.g. cache the raw nav-tree array, not a
|
|
6
|
+
* built `Map`).
|
|
7
|
+
*/
|
|
8
|
+
export interface CacheEntry<T = unknown> {
|
|
9
|
+
/** The cached value (JSON-serializable). */
|
|
10
|
+
value: T;
|
|
11
|
+
/** Epoch-ms when this entry was stored — the age basis for SWR freshness. */
|
|
12
|
+
storedAt: number;
|
|
13
|
+
/** Optional caller metadata (e.g. source tier, etag) — persisted verbatim. */
|
|
14
|
+
meta?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Pluggable durable storage for cache entries. The read-through logic
|
|
18
|
+
* (`SwrCache`) is storage-agnostic — swap the adapter to change where the
|
|
19
|
+
* last-known-good copy lives.
|
|
20
|
+
*
|
|
21
|
+
* Ships today: {@link FsCacheStore} (local filesystem). Designed-for, not yet
|
|
22
|
+
* implemented: an `S3CacheStore` (shared last-known-good across pods) and a
|
|
23
|
+
* `PgCacheStore` (transactional, queryable) — both satisfy this exact interface,
|
|
24
|
+
* so adopting one is a constructor swap with no change to callers.
|
|
25
|
+
*
|
|
26
|
+
* Implementations must be TOTAL: a missing key, a missing backing store, or a
|
|
27
|
+
* corrupt/partial entry resolves to `null` (a cache miss), never a throw — a
|
|
28
|
+
* cache is a best-effort accelerator and must never take down the request path.
|
|
29
|
+
*/
|
|
30
|
+
export interface CacheStore {
|
|
31
|
+
/** Return the entry for `key`, or `null` on miss/corruption. Never throws for a miss. */
|
|
32
|
+
get(key: string): Promise<CacheEntry | null>;
|
|
33
|
+
/** Persist `entry` under `key`, replacing any existing value atomically. */
|
|
34
|
+
set(key: string, entry: CacheEntry): Promise<void>;
|
|
35
|
+
/** Remove `key`. A missing key is a no-op, not an error. */
|
|
36
|
+
delete(key: string): Promise<void>;
|
|
37
|
+
/** Remove every entry, or only those whose key starts with `prefix`. */
|
|
38
|
+
clear(prefix?: string): Promise<void>;
|
|
39
|
+
/** List stored keys, optionally filtered to those starting with `prefix`. */
|
|
40
|
+
keys(prefix?: string): Promise<string[]>;
|
|
41
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blogmcp/content-cache",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Server-only durable content cache for blogmcp presentation layers: pluggable storage adapters (filesystem now; S3/Postgres later) behind an SWR + last-known-good read-through.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "vite build && tsc --emitDeclarationOnly",
|
|
24
|
+
"dev": "vite build --watch",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:watch": "vitest",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"prepack": "node scripts/prepack.mjs",
|
|
29
|
+
"postpack": "node scripts/postpack.mjs"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^20.10.0",
|
|
33
|
+
"typescript": "^5.3.3",
|
|
34
|
+
"vite": "^7.3.0",
|
|
35
|
+
"vitest": "^3.0.0"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"cache",
|
|
39
|
+
"swr",
|
|
40
|
+
"last-known-good",
|
|
41
|
+
"blogmcp",
|
|
42
|
+
"storage-adapter",
|
|
43
|
+
"server"
|
|
44
|
+
],
|
|
45
|
+
"license": "MIT"
|
|
46
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { FsCacheStore } from '../fs-store';
|
|
7
|
+
import type { CacheEntry } from '../types';
|
|
8
|
+
|
|
9
|
+
let dir: string;
|
|
10
|
+
let store: FsCacheStore;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
dir = join(tmpdir(), `cc-fs-${randomUUID()}`);
|
|
14
|
+
store = new FsCacheStore({ dir });
|
|
15
|
+
});
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const entry = (value: unknown): CacheEntry => ({ value, storedAt: 1_000 });
|
|
21
|
+
|
|
22
|
+
describe('FsCacheStore', () => {
|
|
23
|
+
it('requires a dir', () => {
|
|
24
|
+
// @ts-expect-error — exercising the guard
|
|
25
|
+
expect(() => new FsCacheStore({})).toThrow(/requires a `dir`/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns null for a missing key (and lists nothing) when the dir is absent', async () => {
|
|
29
|
+
expect(await store.get('nope')).toBeNull();
|
|
30
|
+
expect(await store.keys()).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('round-trips set → get', async () => {
|
|
34
|
+
await store.set('a|b', entry({ mdx: 'hello' }));
|
|
35
|
+
const got = await store.get('a|b');
|
|
36
|
+
expect(got?.value).toEqual({ mdx: 'hello' });
|
|
37
|
+
expect(got?.storedAt).toBe(1_000);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('overwrites atomically, leaving exactly one .json file (no temp files)', async () => {
|
|
41
|
+
await store.set('k', entry(1));
|
|
42
|
+
await store.set('k', entry(2));
|
|
43
|
+
expect((await store.get('k'))?.value).toBe(2);
|
|
44
|
+
const names = await fs.readdir(dir);
|
|
45
|
+
expect(names).toHaveLength(1);
|
|
46
|
+
expect(names.every((n) => n.endsWith('.json'))).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('lists and prefix-filters keys by the ORIGINAL key (not the hashed filename)', async () => {
|
|
50
|
+
await store.set('p|x', entry(1));
|
|
51
|
+
await store.set('p|y', entry(2));
|
|
52
|
+
await store.set('q|z', entry(3));
|
|
53
|
+
expect((await store.keys()).sort()).toEqual(['p|x', 'p|y', 'q|z']);
|
|
54
|
+
expect((await store.keys('p|')).sort()).toEqual(['p|x', 'p|y']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('deletes a key; a missing delete is a no-op', async () => {
|
|
58
|
+
await store.set('k', entry(1));
|
|
59
|
+
await store.delete('k');
|
|
60
|
+
expect(await store.get('k')).toBeNull();
|
|
61
|
+
await expect(store.delete('k')).resolves.toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('clears by prefix, then clears all', async () => {
|
|
65
|
+
await store.set('p|x', entry(1));
|
|
66
|
+
await store.set('p|y', entry(2));
|
|
67
|
+
await store.set('q|z', entry(3));
|
|
68
|
+
await store.clear('p|');
|
|
69
|
+
expect((await store.keys()).sort()).toEqual(['q|z']);
|
|
70
|
+
await store.clear();
|
|
71
|
+
expect(await store.keys()).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('treats a corrupt entry file as a miss', async () => {
|
|
75
|
+
await store.set('k', entry(1));
|
|
76
|
+
const [name] = await fs.readdir(dir);
|
|
77
|
+
await fs.writeFile(join(dir, name), '{ not json', 'utf8');
|
|
78
|
+
expect(await store.get('k')).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { SwrCache } from '../swr-cache';
|
|
3
|
+
import type { CacheEntry, CacheStore } from '../types';
|
|
4
|
+
|
|
5
|
+
/** Minimal in-memory {@link CacheStore} double for the durable-tier tests. */
|
|
6
|
+
class MemoryStore implements CacheStore {
|
|
7
|
+
map = new Map<string, CacheEntry>();
|
|
8
|
+
async get(k: string) {
|
|
9
|
+
return this.map.get(k) ?? null;
|
|
10
|
+
}
|
|
11
|
+
async set(k: string, e: CacheEntry) {
|
|
12
|
+
this.map.set(k, e);
|
|
13
|
+
}
|
|
14
|
+
async delete(k: string) {
|
|
15
|
+
this.map.delete(k);
|
|
16
|
+
}
|
|
17
|
+
async clear(prefix?: string) {
|
|
18
|
+
if (!prefix) {
|
|
19
|
+
this.map.clear();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
for (const k of [...this.map.keys()]) if (k.startsWith(prefix)) this.map.delete(k);
|
|
23
|
+
}
|
|
24
|
+
async keys(prefix?: string) {
|
|
25
|
+
const ks = [...this.map.keys()];
|
|
26
|
+
return prefix ? ks.filter((k) => k.startsWith(prefix)) : ks;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TTL = { freshMs: 100, staleMs: 1_000, negTtlMs: 50 };
|
|
31
|
+
|
|
32
|
+
function makeClock(start = 0) {
|
|
33
|
+
let t = start;
|
|
34
|
+
return { now: () => t, advance: (ms: number) => (t += ms) };
|
|
35
|
+
}
|
|
36
|
+
/** Let queued microtasks + fire-and-forget promises settle. */
|
|
37
|
+
const flush = () => new Promise((r) => setTimeout(r, 0));
|
|
38
|
+
|
|
39
|
+
describe('SwrCache', () => {
|
|
40
|
+
it('serves fresh from memory without re-fetching', async () => {
|
|
41
|
+
const clock = makeClock();
|
|
42
|
+
const cache = new SwrCache<number>({ ...TTL, now: clock.now });
|
|
43
|
+
const fetcher = vi.fn(async () => 1);
|
|
44
|
+
expect(await cache.get('k', fetcher)).toEqual({ value: 1, source: 'cold' });
|
|
45
|
+
clock.advance(50);
|
|
46
|
+
expect(await cache.get('k', fetcher)).toEqual({ value: 1, source: 'fresh' });
|
|
47
|
+
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('serves stale immediately and revalidates in the background', async () => {
|
|
51
|
+
const clock = makeClock();
|
|
52
|
+
const cache = new SwrCache<number>({ ...TTL, now: clock.now });
|
|
53
|
+
let n = 1;
|
|
54
|
+
const fetcher = vi.fn(async () => n);
|
|
55
|
+
await cache.get('k', fetcher); // cold → 1
|
|
56
|
+
n = 2;
|
|
57
|
+
clock.advance(200); // > freshMs, < staleMs
|
|
58
|
+
expect(await cache.get('k', fetcher)).toEqual({ value: 1, source: 'stale' });
|
|
59
|
+
await flush(); // background revalidation lands
|
|
60
|
+
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
61
|
+
expect(await cache.get('k', fetcher)).toEqual({ value: 2, source: 'fresh' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('serves last-known-good from memory when a cold refetch fails', async () => {
|
|
65
|
+
const clock = makeClock();
|
|
66
|
+
const cache = new SwrCache<number>({ ...TTL, now: clock.now });
|
|
67
|
+
let fail = false;
|
|
68
|
+
const fetcher = vi.fn(async () => {
|
|
69
|
+
if (fail) throw new Error('down');
|
|
70
|
+
return 7;
|
|
71
|
+
});
|
|
72
|
+
await cache.get('k', fetcher); // cold → 7
|
|
73
|
+
fail = true;
|
|
74
|
+
clock.advance(2_000); // > staleMs → cold path, which now fails
|
|
75
|
+
expect(await cache.get('k', fetcher)).toEqual({ value: 7, source: 'lkg-memory' });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('serves lkg-memory during an outage without masking recovery (not labeled fresh)', async () => {
|
|
79
|
+
const clock = makeClock();
|
|
80
|
+
const cache = new SwrCache<string>({ ...TTL, now: clock.now });
|
|
81
|
+
let value = 'V0';
|
|
82
|
+
let down = false;
|
|
83
|
+
const fetcher = () =>
|
|
84
|
+
down ? Promise.reject(new Error('down')) : Promise.resolve(value);
|
|
85
|
+
await cache.get('k', fetcher); // cold → V0
|
|
86
|
+
down = true;
|
|
87
|
+
clock.advance(2_000); // past staleMs → cold retry, which fails
|
|
88
|
+
expect(await cache.get('k', fetcher)).toEqual({ value: 'V0', source: 'lkg-memory' });
|
|
89
|
+
// Origin recovers with new content — the very next request must pick it up
|
|
90
|
+
// (regression guard: the old fresh-window re-arm would return {V0, fresh} here).
|
|
91
|
+
down = false;
|
|
92
|
+
value = 'V1';
|
|
93
|
+
expect(await cache.get('k', fetcher)).toEqual({ value: 'V1', source: 'cold' });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('re-fetches after the negative-cache window expires', async () => {
|
|
97
|
+
const clock = makeClock();
|
|
98
|
+
const cache = new SwrCache<string>({ ...TTL, now: clock.now });
|
|
99
|
+
let down = true;
|
|
100
|
+
const fetcher = vi.fn(() =>
|
|
101
|
+
down ? Promise.reject(new Error('x')) : Promise.resolve('OK'),
|
|
102
|
+
);
|
|
103
|
+
expect((await cache.get('k', fetcher)).source).toBe('error');
|
|
104
|
+
expect((await cache.get('k', fetcher)).source).toBe('negative'); // within negTtl
|
|
105
|
+
clock.advance(60); // > negTtlMs (50)
|
|
106
|
+
down = false;
|
|
107
|
+
expect(await cache.get('k', fetcher)).toEqual({ value: 'OK', source: 'cold' });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('an in-memory-only cache (no store) persists nothing across a restart', async () => {
|
|
111
|
+
// The hard rule: a member/internal tier constructed WITHOUT a store must leave
|
|
112
|
+
// no durable residue — after a restart + origin down there is nothing to serve.
|
|
113
|
+
const cache = new SwrCache<string>({ ...TTL });
|
|
114
|
+
await cache.get('k', async () => 'M'); // cold success, no durable tier
|
|
115
|
+
cache.resetMemory(); // simulate a process restart
|
|
116
|
+
const r = await cache.get('k', async () => {
|
|
117
|
+
throw new Error('down');
|
|
118
|
+
});
|
|
119
|
+
expect(r).toEqual({ value: null, source: 'error' });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('purge fences an in-flight fetch from re-populating memory + durable', async () => {
|
|
123
|
+
const store = new MemoryStore();
|
|
124
|
+
const clock = makeClock();
|
|
125
|
+
const cache = new SwrCache<string>({ ...TTL, store, keyPrefix: 'a:', now: clock.now });
|
|
126
|
+
|
|
127
|
+
await cache.get('k', async () => 'A'); // cold → A, write-through
|
|
128
|
+
await flush();
|
|
129
|
+
expect((await store.get('a:k'))?.value).toBe('A');
|
|
130
|
+
|
|
131
|
+
// Enter the stale window; kick off a SLOW background revalidation.
|
|
132
|
+
clock.advance(200);
|
|
133
|
+
let release!: () => void;
|
|
134
|
+
const gate = new Promise<void>((r) => (release = r));
|
|
135
|
+
expect(await cache.get('k', () => gate.then(() => 'OLD'))).toEqual({
|
|
136
|
+
value: 'A',
|
|
137
|
+
source: 'stale',
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Operator purges while that revalidation is still in flight.
|
|
141
|
+
await cache.purge('k');
|
|
142
|
+
expect(await store.get('a:k')).toBeNull();
|
|
143
|
+
|
|
144
|
+
// Let the in-flight revalidation resolve — it must NOT re-populate either tier.
|
|
145
|
+
release();
|
|
146
|
+
await flush();
|
|
147
|
+
expect(await store.get('a:k')).toBeNull(); // durable stays purged
|
|
148
|
+
// Memory was not re-populated: the next read is a cold miss, not a stale 'OLD' hit.
|
|
149
|
+
expect(await cache.get('k', async () => 'NEW')).toEqual({ value: 'NEW', source: 'cold' });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('negative-caches a cold failure with no prior value', async () => {
|
|
153
|
+
const clock = makeClock();
|
|
154
|
+
const cache = new SwrCache<number>({ ...TTL, now: clock.now });
|
|
155
|
+
const fetcher = vi.fn(async () => {
|
|
156
|
+
throw new Error('down');
|
|
157
|
+
});
|
|
158
|
+
expect(await cache.get('k', fetcher)).toEqual({ value: null, source: 'error' });
|
|
159
|
+
expect(await cache.get('k', fetcher)).toEqual({ value: null, source: 'negative' });
|
|
160
|
+
expect(fetcher).toHaveBeenCalledTimes(1); // 2nd short-circuited
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('write-throughs to the durable store on a successful fetch', async () => {
|
|
164
|
+
const store = new MemoryStore();
|
|
165
|
+
const cache = new SwrCache<number>({ ...TTL, store, keyPrefix: 'a:' });
|
|
166
|
+
await cache.get('k', async () => 42);
|
|
167
|
+
await flush(); // fire-and-forget durable write
|
|
168
|
+
expect((await store.get('a:k'))?.value).toBe(42);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('falls back to the durable store after a restart (cold memory + origin down)', async () => {
|
|
172
|
+
const store = new MemoryStore();
|
|
173
|
+
// A prior process persisted the value; this process starts with empty memory.
|
|
174
|
+
await store.set('a:k', { value: 99, storedAt: 1 });
|
|
175
|
+
const cache = new SwrCache<number>({ ...TTL, store, keyPrefix: 'a:' });
|
|
176
|
+
const r = await cache.get('k', async () => {
|
|
177
|
+
throw new Error('down');
|
|
178
|
+
});
|
|
179
|
+
expect(r).toEqual({ value: 99, source: 'lkg-durable' });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('returns error when origin fails and both tiers are empty', async () => {
|
|
183
|
+
const store = new MemoryStore();
|
|
184
|
+
const cache = new SwrCache<number>({ ...TTL, store });
|
|
185
|
+
expect(
|
|
186
|
+
await cache.get('k', async () => {
|
|
187
|
+
throw new Error('down');
|
|
188
|
+
}),
|
|
189
|
+
).toEqual({ value: null, source: 'error' });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('reads the live Date.now when no clock is injected (fake-timer friendly)', async () => {
|
|
193
|
+
vi.useFakeTimers();
|
|
194
|
+
try {
|
|
195
|
+
const cache = new SwrCache<number>({ ...TTL }); // no `now` injected → default clock
|
|
196
|
+
let fail = false;
|
|
197
|
+
const fetcher = vi.fn(async () => {
|
|
198
|
+
if (fail) throw new Error('down');
|
|
199
|
+
return 5;
|
|
200
|
+
});
|
|
201
|
+
await cache.get('k', fetcher); // cold → 5, cached at the current fake time
|
|
202
|
+
fail = true;
|
|
203
|
+
vi.setSystemTime(Date.now() + TTL.staleMs + 1); // age past stale → cold retry, which fails
|
|
204
|
+
expect(await cache.get('k', fetcher)).toEqual({ value: 5, source: 'lkg-memory' });
|
|
205
|
+
} finally {
|
|
206
|
+
vi.useRealTimers();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('coalesces concurrent cold fetches (single-flight)', async () => {
|
|
211
|
+
const cache = new SwrCache<number>({ ...TTL });
|
|
212
|
+
let calls = 0;
|
|
213
|
+
const fetcher = () =>
|
|
214
|
+
new Promise<number>((res) => {
|
|
215
|
+
calls++;
|
|
216
|
+
setTimeout(() => res(5), 10);
|
|
217
|
+
});
|
|
218
|
+
const [a, b] = await Promise.all([cache.get('k', fetcher), cache.get('k', fetcher)]);
|
|
219
|
+
expect(a.value).toBe(5);
|
|
220
|
+
expect(b.value).toBe(5);
|
|
221
|
+
expect(calls).toBe(1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('purge evicts memory + durable; purgeAll clears everything', async () => {
|
|
225
|
+
const store = new MemoryStore();
|
|
226
|
+
const cache = new SwrCache<number>({ ...TTL, store, keyPrefix: 'a:' });
|
|
227
|
+
await cache.get('x', async () => 1);
|
|
228
|
+
await cache.get('y', async () => 2);
|
|
229
|
+
await flush();
|
|
230
|
+
|
|
231
|
+
await cache.purge('x');
|
|
232
|
+
expect(await store.get('a:x')).toBeNull();
|
|
233
|
+
const fetcher = vi.fn(async () => 11);
|
|
234
|
+
expect((await cache.get('x', fetcher)).source).toBe('cold'); // memory was evicted too
|
|
235
|
+
await flush();
|
|
236
|
+
|
|
237
|
+
await cache.purgeAll();
|
|
238
|
+
expect(await store.keys()).toEqual([]);
|
|
239
|
+
});
|
|
240
|
+
});
|
package/src/fs-store.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import type { CacheEntry, CacheStore } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* On-disk shape: the original key is stored ALONGSIDE the entry so we can (a)
|
|
8
|
+
* list/prefix-filter keys the caller actually wrote (the filename is a hash, not
|
|
9
|
+
* the key), and (b) reject a sha-prefix collision on read.
|
|
10
|
+
*/
|
|
11
|
+
interface StoredRecord {
|
|
12
|
+
key: string;
|
|
13
|
+
entry: CacheEntry;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FsCacheStoreOptions {
|
|
17
|
+
/** Directory that holds one JSON file per cache entry. Created on demand. */
|
|
18
|
+
dir: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Filesystem {@link CacheStore}: one JSON file per entry, named by a truncated
|
|
23
|
+
* sha256 of the key (keys contain `|`/`:`/`/`, unsafe as filenames). Writes are
|
|
24
|
+
* atomic (write a unique temp file in the same dir, then `rename`), so a crash
|
|
25
|
+
* mid-write never leaves a torn entry — a reader sees either the old file or the
|
|
26
|
+
* new one. A corrupt/partial file reads as a miss and self-heals on the next set.
|
|
27
|
+
*
|
|
28
|
+
* Server-only (imports `node:fs`/`node:crypto`/`node:path`) — never import from a
|
|
29
|
+
* browser bundle. Per-process on disk: on Kubernetes each pod keeps its own copy
|
|
30
|
+
* (fine for a last-known-good floor); a shared `S3CacheStore` would centralize it.
|
|
31
|
+
*/
|
|
32
|
+
export class FsCacheStore implements CacheStore {
|
|
33
|
+
readonly dir: string;
|
|
34
|
+
|
|
35
|
+
constructor(opts: FsCacheStoreOptions) {
|
|
36
|
+
if (!opts || !opts.dir) throw new Error('FsCacheStore requires a `dir`');
|
|
37
|
+
this.dir = resolve(opts.dir);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private fileFor(key: string): string {
|
|
41
|
+
const h = createHash('sha256').update(key).digest('hex').slice(0, 40);
|
|
42
|
+
return join(this.dir, `${h}.json`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private async ensureDir(): Promise<void> {
|
|
46
|
+
await fs.mkdir(this.dir, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async get(key: string): Promise<CacheEntry | null> {
|
|
50
|
+
try {
|
|
51
|
+
const raw = await fs.readFile(this.fileFor(key), 'utf8');
|
|
52
|
+
const rec = JSON.parse(raw) as StoredRecord;
|
|
53
|
+
// Reject a hash-prefix collision, and guard against a malformed record.
|
|
54
|
+
if (!rec || rec.key !== key || !rec.entry) return null;
|
|
55
|
+
return rec.entry;
|
|
56
|
+
} catch {
|
|
57
|
+
// Missing file, partial write, or invalid JSON → a cache miss, never a throw.
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async set(key: string, entry: CacheEntry): Promise<void> {
|
|
63
|
+
await this.ensureDir();
|
|
64
|
+
const dest = this.fileFor(key);
|
|
65
|
+
const tmp = `${dest}.${randomUUID()}.tmp`;
|
|
66
|
+
const rec: StoredRecord = { key, entry };
|
|
67
|
+
await fs.writeFile(tmp, JSON.stringify(rec), 'utf8');
|
|
68
|
+
await fs.rename(tmp, dest); // atomic within the same directory
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async delete(key: string): Promise<void> {
|
|
72
|
+
try {
|
|
73
|
+
await fs.unlink(this.fileFor(key));
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async readAllRecords(): Promise<StoredRecord[]> {
|
|
80
|
+
let names: string[];
|
|
81
|
+
try {
|
|
82
|
+
names = await fs.readdir(this.dir);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
const out: StoredRecord[] = [];
|
|
88
|
+
await Promise.all(
|
|
89
|
+
names
|
|
90
|
+
.filter((n) => n.endsWith('.json')) // skip in-flight *.tmp temp files
|
|
91
|
+
.map(async (n) => {
|
|
92
|
+
try {
|
|
93
|
+
const raw = await fs.readFile(join(this.dir, n), 'utf8');
|
|
94
|
+
const rec = JSON.parse(raw) as StoredRecord;
|
|
95
|
+
if (rec && typeof rec.key === 'string' && rec.entry) out.push(rec);
|
|
96
|
+
} catch {
|
|
97
|
+
/* skip corrupt/partial file */
|
|
98
|
+
}
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async keys(prefix?: string): Promise<string[]> {
|
|
105
|
+
const ks = (await this.readAllRecords()).map((r) => r.key);
|
|
106
|
+
return prefix ? ks.filter((k) => k.startsWith(prefix)) : ks;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async clear(prefix?: string): Promise<void> {
|
|
110
|
+
if (!prefix) {
|
|
111
|
+
let names: string[];
|
|
112
|
+
try {
|
|
113
|
+
names = await fs.readdir(this.dir);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
await Promise.all(
|
|
119
|
+
names
|
|
120
|
+
// Also reclaim any `*.tmp` orphaned by a crash between write and rename.
|
|
121
|
+
.filter((n) => n.endsWith('.json') || n.endsWith('.tmp'))
|
|
122
|
+
.map((n) => fs.unlink(join(this.dir, n)).catch(() => {})),
|
|
123
|
+
);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const recs = await this.readAllRecords();
|
|
127
|
+
await Promise.all(
|
|
128
|
+
recs.filter((r) => r.key.startsWith(prefix)).map((r) => this.delete(r.key)),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blogmcp/content-cache — server-only durable content cache for blogmcp
|
|
3
|
+
* presentation layers.
|
|
4
|
+
*
|
|
5
|
+
* A consuming SSR app (the drhoubicka storefront, the reference blogs) sources
|
|
6
|
+
* KB-owned page MDX + navigation through {@link SwrCache}: a per-process hot tier
|
|
7
|
+
* over a pluggable durable {@link CacheStore}. The durable copy is written through
|
|
8
|
+
* on every successful origin fetch, so it is always the last content the KB
|
|
9
|
+
* actually served — the resilient offline floor with NO hand-maintained second
|
|
10
|
+
* source of truth to drift. Storage is separated from the caching pattern:
|
|
11
|
+
* {@link FsCacheStore} ships today; S3/Postgres adapters slot in behind the same
|
|
12
|
+
* interface later.
|
|
13
|
+
*
|
|
14
|
+
* Server-only: {@link FsCacheStore} imports `node:fs`. Import from `*.server.ts`
|
|
15
|
+
* modules only — never a browser bundle.
|
|
16
|
+
*/
|
|
17
|
+
export type { CacheEntry, CacheStore } from './types';
|
|
18
|
+
export { FsCacheStore, type FsCacheStoreOptions } from './fs-store';
|
|
19
|
+
export {
|
|
20
|
+
SwrCache,
|
|
21
|
+
type SwrCacheOptions,
|
|
22
|
+
type SwrResult,
|
|
23
|
+
type SwrSource,
|
|
24
|
+
} from './swr-cache';
|
package/src/swr-cache.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { CacheEntry, CacheStore } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which tier served a {@link SwrCache.get} result — for observability/logging.
|
|
5
|
+
* - `fresh` — in-memory hit within `freshMs` (no revalidation)
|
|
6
|
+
* - `stale` — in-memory hit within `staleMs` (revalidating in background)
|
|
7
|
+
* - `cold` — fetched live from the origin
|
|
8
|
+
* - `lkg-memory` — origin failed; served the prior in-memory value
|
|
9
|
+
* - `lkg-durable` — origin failed + memory cold; served the durable store's copy
|
|
10
|
+
* - `negative` — recently-failed key with nothing cached (short-circuited)
|
|
11
|
+
* - `error` — origin failed and no value anywhere; caller gets `null`
|
|
12
|
+
*/
|
|
13
|
+
export type SwrSource =
|
|
14
|
+
| 'fresh'
|
|
15
|
+
| 'stale'
|
|
16
|
+
| 'cold'
|
|
17
|
+
| 'lkg-memory'
|
|
18
|
+
| 'lkg-durable'
|
|
19
|
+
| 'negative'
|
|
20
|
+
| 'error';
|
|
21
|
+
|
|
22
|
+
export interface SwrResult<T> {
|
|
23
|
+
value: T | null;
|
|
24
|
+
source: SwrSource;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SwrCacheOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Durable backing tier — the last-known-good floor that survives a process
|
|
30
|
+
* restart. OMIT for an in-memory-only cache (e.g. gated/member content that
|
|
31
|
+
* must never be persisted to disk).
|
|
32
|
+
*/
|
|
33
|
+
store?: CacheStore;
|
|
34
|
+
/** Serve from memory without revalidating for this long after a success. */
|
|
35
|
+
freshMs: number;
|
|
36
|
+
/** Serve stale from memory + revalidate in the background up to this age. */
|
|
37
|
+
staleMs: number;
|
|
38
|
+
/** Suppress refetch of a known-failing key (with no cached value) for this long. */
|
|
39
|
+
negTtlMs: number;
|
|
40
|
+
/** Namespace applied to DURABLE keys so several caches can share one store. */
|
|
41
|
+
keyPrefix?: string;
|
|
42
|
+
/** Clock injection point (defaults to `Date.now`) — for deterministic tests. */
|
|
43
|
+
now?: () => number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface MemEntry<T> {
|
|
47
|
+
value: T;
|
|
48
|
+
at: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Two-tier read-through cache: a per-process in-memory hot tier for latency, over
|
|
53
|
+
* an optional durable {@link CacheStore} for last-known-good survival across
|
|
54
|
+
* restarts. Generalizes the storefront's hand-rolled `swrGet`/`getNavMap` state
|
|
55
|
+
* machines (fresh → stale-while-revalidate → last-known-good → negative cache →
|
|
56
|
+
* single-flight) and adds the durable tier + a purge surface.
|
|
57
|
+
*
|
|
58
|
+
* On a successful fetch the value is written through to BOTH tiers (the durable
|
|
59
|
+
* write is fire-and-forget — it never blocks or fails the request). On an origin
|
|
60
|
+
* failure the cache degrades gracefully: prior memory value → durable value →
|
|
61
|
+
* `null` (negative-cached). The durable copy is therefore always the last content
|
|
62
|
+
* the origin actually served — there is no hand-maintained second source of truth
|
|
63
|
+
* to drift.
|
|
64
|
+
*
|
|
65
|
+
* `T` MUST be JSON-serializable (see {@link CacheEntry}).
|
|
66
|
+
*/
|
|
67
|
+
export class SwrCache<T = unknown> {
|
|
68
|
+
private readonly store?: CacheStore;
|
|
69
|
+
private readonly freshMs: number;
|
|
70
|
+
private readonly staleMs: number;
|
|
71
|
+
private readonly negTtlMs: number;
|
|
72
|
+
private readonly keyPrefix: string;
|
|
73
|
+
private readonly now: () => number;
|
|
74
|
+
private readonly mem = new Map<string, MemEntry<T>>();
|
|
75
|
+
private readonly neg = new Map<string, number>();
|
|
76
|
+
private readonly inflight = new Map<string, Promise<T>>();
|
|
77
|
+
/**
|
|
78
|
+
* Bumped by every `purge`/`purgeAll`. A cache-population step (write-back after
|
|
79
|
+
* a fetch, durable-LKG hydration, negative-cache set) captures the generation
|
|
80
|
+
* when it STARTS and only applies its write if the generation is unchanged — so
|
|
81
|
+
* a purge that lands mid-flight is never silently re-populated by an in-flight
|
|
82
|
+
* (possibly stale, possibly coalesced) fetch. Fences the "lost purge" race.
|
|
83
|
+
*/
|
|
84
|
+
private generation = 0;
|
|
85
|
+
|
|
86
|
+
constructor(opts: SwrCacheOptions) {
|
|
87
|
+
this.store = opts.store;
|
|
88
|
+
this.freshMs = opts.freshMs;
|
|
89
|
+
this.staleMs = opts.staleMs;
|
|
90
|
+
this.negTtlMs = opts.negTtlMs;
|
|
91
|
+
this.keyPrefix = opts.keyPrefix ?? '';
|
|
92
|
+
// Call `Date.now()` at read time (not a captured reference) so a test that installs
|
|
93
|
+
// fake timers AFTER construction still controls this cache's clock.
|
|
94
|
+
this.now = opts.now ?? (() => Date.now());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private durableKey(key: string): string {
|
|
98
|
+
return this.keyPrefix ? `${this.keyPrefix}${key}` : key;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Coalesce concurrent fetches for the same key onto one in-flight promise, and
|
|
103
|
+
* populate BOTH tiers on success — exactly once per real fetch, so a later
|
|
104
|
+
* request that rides an in-flight fetch (started before a purge) can't smuggle a
|
|
105
|
+
* stale value past that purge. The write-back is gated by the generation at
|
|
106
|
+
* FETCH-START: if a purge bumped it while the fetch was outstanding, the result
|
|
107
|
+
* is returned to callers but NOT cached.
|
|
108
|
+
*/
|
|
109
|
+
private runFetch(key: string, fetcher: () => Promise<T>): Promise<T> {
|
|
110
|
+
const existing = this.inflight.get(key);
|
|
111
|
+
if (existing) return existing;
|
|
112
|
+
const gen = this.generation;
|
|
113
|
+
const p = (async () => {
|
|
114
|
+
try {
|
|
115
|
+
const value = await fetcher();
|
|
116
|
+
if (this.generation === gen) {
|
|
117
|
+
this.mem.set(key, { value, at: this.now() });
|
|
118
|
+
this.writeThrough(key, value);
|
|
119
|
+
this.neg.delete(key);
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
} finally {
|
|
123
|
+
this.inflight.delete(key);
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
this.inflight.set(key, p);
|
|
127
|
+
return p;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Persist to the durable tier without ever blocking/failing the request path. */
|
|
131
|
+
private writeThrough(key: string, value: T): void {
|
|
132
|
+
if (!this.store) return;
|
|
133
|
+
const entry: CacheEntry<T> = { value, storedAt: this.now() };
|
|
134
|
+
void this.store.set(this.durableKey(key), entry as CacheEntry).catch(() => {});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read `key`, fetching via `fetcher` on a miss. `fetcher` MUST throw to signal a
|
|
139
|
+
* failure/absent value (mirrors the storefront's throwing fetchers); its resolved
|
|
140
|
+
* value is cached. Returns `{ value: null }` only when the origin fails and there
|
|
141
|
+
* is nothing cached in either tier.
|
|
142
|
+
*/
|
|
143
|
+
async get(key: string, fetcher: () => Promise<T>): Promise<SwrResult<T>> {
|
|
144
|
+
const gen = this.generation; // snapshot for purge-fencing the failure-path writes
|
|
145
|
+
const now = this.now();
|
|
146
|
+
const entry = this.mem.get(key);
|
|
147
|
+
|
|
148
|
+
if (entry && now - entry.at < this.freshMs) {
|
|
149
|
+
return { value: entry.value, source: 'fresh' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Negative cache: a recently-failed key with no positive memory entry
|
|
153
|
+
// short-circuits, so a broken key under load doesn't issue one call per request.
|
|
154
|
+
const failedAt = this.neg.get(key);
|
|
155
|
+
if (!entry && failedAt !== undefined) {
|
|
156
|
+
if (now - failedAt < this.negTtlMs) return { value: null, source: 'negative' };
|
|
157
|
+
this.neg.delete(key); // opportunistically reclaim the expired negative entry
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (entry && now - entry.at < this.staleMs) {
|
|
161
|
+
// Serve stale immediately; revalidate in the background (populates via runFetch).
|
|
162
|
+
void this.runFetch(key, fetcher).catch(() => {});
|
|
163
|
+
return { value: entry.value, source: 'stale' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const value = await this.runFetch(key, fetcher);
|
|
168
|
+
return { value, source: 'cold' };
|
|
169
|
+
} catch {
|
|
170
|
+
// Origin failed. Degrade: in-memory LKG → durable LKG → null (negative-cached).
|
|
171
|
+
// We deliberately DON'T advance `entry.at` here — serving last-known-good must
|
|
172
|
+
// not masquerade as `fresh` nor delay recovery; the very next request retries
|
|
173
|
+
// the origin and picks up a recovered KB immediately (parity with the origin
|
|
174
|
+
// storefront swrGet).
|
|
175
|
+
if (entry) {
|
|
176
|
+
return { value: entry.value, source: 'lkg-memory' };
|
|
177
|
+
}
|
|
178
|
+
if (this.store) {
|
|
179
|
+
const durable = await this.store.get(this.durableKey(key)).catch(() => null);
|
|
180
|
+
if (durable) {
|
|
181
|
+
const value = durable.value as T;
|
|
182
|
+
// Hydrate memory with the durable entry's OWN timestamp (not `now`), so it
|
|
183
|
+
// isn't served as `fresh`, and skip the write if a purge intervened.
|
|
184
|
+
if (this.generation === gen) this.mem.set(key, { value, at: durable.storedAt });
|
|
185
|
+
return { value, source: 'lkg-durable' };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (this.generation === gen) this.neg.set(key, this.now());
|
|
189
|
+
return { value: null, source: 'error' };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Immediately evict one key from memory AND the durable store. */
|
|
194
|
+
async purge(key: string): Promise<void> {
|
|
195
|
+
this.generation++; // fence any in-flight fetch from re-populating what we evict
|
|
196
|
+
this.mem.delete(key);
|
|
197
|
+
this.neg.delete(key);
|
|
198
|
+
if (this.store) await this.store.delete(this.durableKey(key));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Evict every key (or those starting with `prefix`) from memory AND durable. */
|
|
202
|
+
async purgeAll(prefix?: string): Promise<void> {
|
|
203
|
+
this.generation++; // fence in-flight fetches (see `generation`)
|
|
204
|
+
if (prefix) {
|
|
205
|
+
for (const k of [...this.mem.keys()]) if (k.startsWith(prefix)) this.mem.delete(k);
|
|
206
|
+
for (const k of [...this.neg.keys()]) if (k.startsWith(prefix)) this.neg.delete(k);
|
|
207
|
+
if (this.store) await this.store.clear(this.durableKey(prefix));
|
|
208
|
+
} else {
|
|
209
|
+
this.mem.clear();
|
|
210
|
+
this.neg.clear();
|
|
211
|
+
if (this.store) await this.store.clear();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Test-only: clear the in-memory tiers (leaves the durable store intact). */
|
|
216
|
+
resetMemory(): void {
|
|
217
|
+
this.mem.clear();
|
|
218
|
+
this.neg.clear();
|
|
219
|
+
this.inflight.clear();
|
|
220
|
+
}
|
|
221
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single cached value plus the freshness basis a read-through layer needs.
|
|
3
|
+
* `value` MUST be JSON-serializable — the filesystem (and future S3/Postgres)
|
|
4
|
+
* adapters persist it as JSON, so a Map/Set/class instance must be reduced to a
|
|
5
|
+
* plain object/array before caching (e.g. cache the raw nav-tree array, not a
|
|
6
|
+
* built `Map`).
|
|
7
|
+
*/
|
|
8
|
+
export interface CacheEntry<T = unknown> {
|
|
9
|
+
/** The cached value (JSON-serializable). */
|
|
10
|
+
value: T;
|
|
11
|
+
/** Epoch-ms when this entry was stored — the age basis for SWR freshness. */
|
|
12
|
+
storedAt: number;
|
|
13
|
+
/** Optional caller metadata (e.g. source tier, etag) — persisted verbatim. */
|
|
14
|
+
meta?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Pluggable durable storage for cache entries. The read-through logic
|
|
19
|
+
* (`SwrCache`) is storage-agnostic — swap the adapter to change where the
|
|
20
|
+
* last-known-good copy lives.
|
|
21
|
+
*
|
|
22
|
+
* Ships today: {@link FsCacheStore} (local filesystem). Designed-for, not yet
|
|
23
|
+
* implemented: an `S3CacheStore` (shared last-known-good across pods) and a
|
|
24
|
+
* `PgCacheStore` (transactional, queryable) — both satisfy this exact interface,
|
|
25
|
+
* so adopting one is a constructor swap with no change to callers.
|
|
26
|
+
*
|
|
27
|
+
* Implementations must be TOTAL: a missing key, a missing backing store, or a
|
|
28
|
+
* corrupt/partial entry resolves to `null` (a cache miss), never a throw — a
|
|
29
|
+
* cache is a best-effort accelerator and must never take down the request path.
|
|
30
|
+
*/
|
|
31
|
+
export interface CacheStore {
|
|
32
|
+
/** Return the entry for `key`, or `null` on miss/corruption. Never throws for a miss. */
|
|
33
|
+
get(key: string): Promise<CacheEntry | null>;
|
|
34
|
+
/** Persist `entry` under `key`, replacing any existing value atomically. */
|
|
35
|
+
set(key: string, entry: CacheEntry): Promise<void>;
|
|
36
|
+
/** Remove `key`. A missing key is a no-op, not an error. */
|
|
37
|
+
delete(key: string): Promise<void>;
|
|
38
|
+
/** Remove every entry, or only those whose key starts with `prefix`. */
|
|
39
|
+
clear(prefix?: string): Promise<void>;
|
|
40
|
+
/** List stored keys, optionally filtered to those starting with `prefix`. */
|
|
41
|
+
keys(prefix?: string): Promise<string[]>;
|
|
42
|
+
}
|