@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 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
+ }
@@ -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
+ };
@@ -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,6 @@
1
+ import { FsCacheStore as o } from "./fs-store.js";
2
+ import { SwrCache as a } from "./swr-cache.js";
3
+ export {
4
+ o as FsCacheStore,
5
+ a as SwrCache
6
+ };
@@ -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
+ };
@@ -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
+ });
@@ -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';
@@ -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
+ }