diff --git a/packages/mcp/src/client/context.ts b/packages/mcp/src/client/context.ts index 498c3b79..b8818863 100644 --- a/packages/mcp/src/client/context.ts +++ b/packages/mcp/src/client/context.ts @@ -23,6 +23,7 @@ import { acquireCollabSession } from "../lib/collab-session.js"; import { withPageLock, isUuid } from "../lib/page-lock.js"; import { getCollabToken, performLogin } from "../lib/auth-utils.js"; import { formatDocmostAxiosError } from "./errors.js"; +import { GetPageConversionCache } from "./getpage-cache.js"; // A generic mixin base constructor (issue #450). Each domain mixin is a factory // `>(Base: T) => class extends Base` @@ -159,6 +160,13 @@ export abstract class DocmostClientContext { // bypassed on a forced refresh (the 401/403 reauth path). null = no token yet. protected collabTokenCache: { token: string; mintedAt: number } | null = null; + // Content-addressed conversion cache for getPage (issue #479). Keyed on + // (canonical pageId, updatedAt, optionsHash) -> the converted Markdown, so a + // re-read of an UNCHANGED page skips the expensive convertProseMirrorToMarkdown + // tree walk. Per-instance (a DocmostClient is built per user / per chat), so a + // cached conversion can never leak across identities. See getpage-cache.ts. + protected getPageCache = new GetPageConversionCache(); + // Two construction forms: // - new DocmostClient(config) // discriminated union (current) // - new DocmostClient(baseURL, email, password) // legacy positional creds diff --git a/packages/mcp/src/client/getpage-cache.ts b/packages/mcp/src/client/getpage-cache.ts new file mode 100644 index 00000000..f3b503c5 --- /dev/null +++ b/packages/mcp/src/client/getpage-cache.ts @@ -0,0 +1,147 @@ +// Content-addressed LRU cache for the PM->Markdown conversion in getPage +// (issue #479). getPage is the dominant agent op (812 calls / 2h, p95 840ms); +// the bulk of its cost is convertProseMirrorToMarkdown — a full ProseMirror-tree +// walk over the page content (hundreds of KB of JSON on large pages) run on +// EVERY read. Since agents re-read far more than they write (812 reads vs 28 +// writes in the sample), most conversions re-produce the SAME markdown from +// UNCHANGED content. This cache skips the recomputation on a hit. +// +// KEY = (pageId, updatedAt, optionsHash): +// - pageId: the page's CANONICAL UUID (resultData.id), not the agent-supplied +// slugId — so a slugId read and a UUID read of the same page share one entry. +// - updatedAt: comes from the SAME /pages/info response as `content`, so the +// two are mutually consistent; a changed page yields a new updatedAt -> a new +// key -> automatic, precise invalidation (no stale markdown is ever served). +// - optionsHash: a stable hash of the conversion options. getPage passes +// `{dropResolvedCommentAnchors:true}` while exportPageMarkdown passes the +// defaults (#328) — DIFFERENT output for the same content, so the options +// MUST be part of the key or a hit would serve the wrong variant. +// +// BOUNDS: evict the LEAST-recently-used entry when EITHER the entry count OR the +// total stored bytes would exceed its cap. Large pages are hundreds of KB, so a +// byte cap (not just a count cap) is what actually bounds memory. A Map iterates +// in insertion order, so the first key is the LRU entry; a hit re-inserts its key +// to move it to the most-recently-used end. +// +// This module is dependency-neutral (no axios/client/prom-client): a plain class +// the shared client context owns one instance of, so the cache persists across +// getPage calls on a single DocmostClient instance (built per user / per chat). + +/** A stable, order-insensitive hash of the conversion options object. */ +export function hashConvertOptions(options: unknown): string { + // JSON.stringify with SORTED keys makes the hash independent of key order, so + // {a:1,b:2} and {b:2,a:1} collapse to one entry. undefined/null options -> a + // fixed empty-object key, matching a caller that passes no options at all. + if (options === undefined || options === null) return "{}"; + return stableStringify(options); +} + +function stableStringify(value: any): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + const keys = Object.keys(value).sort(); + return `{${keys + .map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`) + .join(",")}}`; +} + +interface CacheEntry { + markdown: string; + bytes: number; +} + +export interface GetPageCacheOptions { + /** Max number of entries before LRU eviction. Default 50. */ + maxEntries?: number; + /** Max total stored bytes before LRU eviction. Default 10 MB. */ + maxBytes?: number; +} + +export class GetPageConversionCache { + private readonly maxEntries: number; + private readonly maxBytes: number; + // Insertion-ordered: the FIRST key is the least-recently-used entry. + private readonly map = new Map(); + private totalBytes = 0; + + constructor(opts: GetPageCacheOptions = {}) { + // A non-positive/NaN cap is treated as "use the default", never as an + // unbounded (or always-empty) cache — a silently unbounded cache would leak + // memory, and an always-empty one would defeat the whole optimization. + this.maxEntries = + Number.isFinite(opts.maxEntries) && (opts.maxEntries as number) > 0 + ? Math.floor(opts.maxEntries as number) + : 50; + this.maxBytes = + Number.isFinite(opts.maxBytes) && (opts.maxBytes as number) > 0 + ? Math.floor(opts.maxBytes as number) + : 10 * 1024 * 1024; + } + + /** Compose the content-addressed key from its three parts. */ + static key(pageId: string, updatedAt: string, optionsHash: string): string { + // A space separates the parts so no combination of values can collide by + // concatenation: a canonical UUID and an ISO updatedAt never contain a + // space, so the boundaries between the three parts are unambiguous. + return `${pageId} ${updatedAt} ${optionsHash}`; + } + + /** + * Return the cached markdown for `key`, or undefined on a miss. A hit moves + * the entry to the most-recently-used end (delete + re-set) so the LRU order + * reflects real access, not just insertion. + */ + get(key: string): string | undefined { + const entry = this.map.get(key); + if (entry === undefined) return undefined; + this.map.delete(key); + this.map.set(key, entry); + return entry.markdown; + } + + /** + * Store `markdown` under `key`, then evict LRU entries until BOTH caps hold. + * Re-storing an existing key refreshes its value and recency (its old bytes + * are subtracted first, so totalBytes stays exact). + */ + set(key: string, markdown: string): void { + // Byte size of the stored string (UTF-8). A single entry larger than the + // whole byte cap is still stored (so getPage always gets a hit next time), + // then the eviction loop below simply cannot shrink below it — accepted: + // one oversized page is bounded by the page itself, not a cache leak. + const bytes = Buffer.byteLength(markdown, "utf8"); + const existing = this.map.get(key); + if (existing !== undefined) { + this.totalBytes -= existing.bytes; + this.map.delete(key); + } + this.map.set(key, { markdown, bytes }); + this.totalBytes += bytes; + this.evict(); + } + + /** Evict the LRU entry until both the count and byte caps are satisfied. */ + private evict(): void { + while ( + this.map.size > this.maxEntries || + (this.totalBytes > this.maxBytes && this.map.size > 1) + ) { + // The first key in insertion order is the least-recently-used. + const oldest = this.map.keys().next().value as string | undefined; + if (oldest === undefined) break; + const entry = this.map.get(oldest); + this.map.delete(oldest); + if (entry) this.totalBytes -= entry.bytes; + } + } + + /** Current entry count (test/introspection). */ + get size(): number { + return this.map.size; + } + + /** Current total stored bytes (test/introspection). */ + get bytes(): number { + return this.totalBytes; + } +} diff --git a/packages/mcp/src/client/read.ts b/packages/mcp/src/client/read.ts index 9f8c4a11..26ee65a9 100644 --- a/packages/mcp/src/client/read.ts +++ b/packages/mcp/src/client/read.ts @@ -11,6 +11,10 @@ import { filterSearchResult, } from "../lib/filters.js"; import { convertProseMirrorToMarkdown } from "../lib/markdown-converter.js"; +import { + GetPageConversionCache, + hashConvertOptions, +} from "./getpage-cache.js"; import { collectInternalFileNodes, normalizeFileUrl, @@ -403,13 +407,53 @@ export function ReadMixin>(Base // discussions. Active anchors are kept. (The lossless exportPageMarkdown // round-trip deliberately does NOT pass this flag — resolved anchors there // must be preserved.) - let content = resultData.content - ? convertProseMirrorToMarkdown(resultData.content, { - dropResolvedCommentAnchors: true, - }) - : ""; + // + // Content-addressed conversion cache (issue #479): the PM->Markdown walk is + // the dominant cost of this hot read op. Key on the page's canonical UUID + + // updatedAt (both from THIS /pages/info response, so mutually consistent) + + // a hash of the conversion options. A hit returns the cached markdown and + // skips the walk; a miss converts and stores. The cached value is the + // conversion output BEFORE the {{SUBPAGES}} substitution below, which uses + // live subpage data and stays outside the cache — so the final result is + // byte-identical to the uncached path. + const convertOptions = { dropResolvedCommentAnchors: true }; + let content = ""; + if (resultData.content) { + // Only cache when we have a stable identity+version for the key. Both come + // from the same response; if either is missing (unexpected server shape), + // fall back to converting uncached rather than keying on a partial tuple. + const cacheable = + typeof resultData.id === "string" && + typeof resultData.updatedAt === "string"; + const cacheKey = cacheable + ? GetPageConversionCache.key( + resultData.id, + resultData.updatedAt, + hashConvertOptions(convertOptions), + ) + : null; - // Always fetch subpages to provide context to the agent + const cached = cacheKey ? this.getPageCache.get(cacheKey) : undefined; + if (cached !== undefined) { + content = cached; + this.onMetricFn?.("mcp_getpage_cache_hits_total", 1); + } else { + content = convertProseMirrorToMarkdown(resultData.content, convertOptions); + if (cacheKey) this.getPageCache.set(cacheKey, content); + // A non-cacheable page (missing id/updatedAt) is still a genuine + // conversion, so it counts as a miss for an honest hit-rate. + this.onMetricFn?.("mcp_getpage_cache_misses_total", 1); + } + } + + // Always fetch subpages to provide context to the agent. + // + // NOT parallelizable with the page fetch (issue #479 asked to check): the + // sidebar-pages endpoint REQUIRES spaceId in its POST body, and spaceId is + // only known FROM this page fetch's response (resolvePageId yields the UUID + // but never the spaceId). So `Promise.all([pageFetch, subpagesFetch])` would + // have to invent a spaceId it does not have — the two calls are inherently + // sequential. Correctness wins; the conversion cache above is the real speedup. let subpages: any[] = []; try { // `pageId` may be a slugId, but the sidebar-pages endpoint requires the diff --git a/packages/mcp/test/mock/getpage-conversion-cache.test.mjs b/packages/mcp/test/mock/getpage-conversion-cache.test.mjs new file mode 100644 index 00000000..aaff5174 --- /dev/null +++ b/packages/mcp/test/mock/getpage-conversion-cache.test.mjs @@ -0,0 +1,223 @@ +// Mock-HTTP integration tests for the getPage conversion cache (issue #479). +// A local http.createServer stands in for Docmost (same harness style as +// get-page-context.test.mjs) so everything is deterministic and offline. +// +// Verifies end-to-end through the real client that: +// - the FIRST getPage of a page is a MISS (mcp_getpage_cache_misses_total) +// and converts the content (the server's convert-representative counter); +// - a SECOND getPage of the same (pageId, updatedAt) is a HIT +// (mcp_getpage_cache_hits_total) and returns BYTE-IDENTICAL output while +// skipping the conversion; +// - a changed updatedAt is a fresh key -> MISS again; +// - the returned shape still resolves page + subpages. +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { DocmostClient } from "../../build/client.js"; + +function readBody(req) { + return new Promise((resolve) => { + let raw = ""; + req.on("data", (chunk) => (raw += chunk)); + req.on("end", () => resolve(raw)); + }); +} + +function sendJson(res, status, obj, extraHeaders = {}) { + res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders }); + res.end(JSON.stringify(obj)); +} + +const openServers = []; +after(async () => { + await Promise.all(openServers.map((s) => new Promise((r) => s.close(r)))); +}); + +const PAGE_UUID = "00000000-0000-4000-8000-000000000010"; +const SPACE_UUID = "00000000-0000-4000-8000-0000000000aa"; +const CHILD_UUID = "00000000-0000-4000-8000-0000000000bb"; + +// A small ProseMirror doc so the converter produces non-trivial markdown. +function makeDoc(text) { + return { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text }], + }, + ], + }; +} + +// state.info counts /pages/info hits; state.updatedAt / state.text drive the +// content+version returned; state.sidebar counts sidebar-pages hits; +// state.subpages (when set) drives the child list the sidebar endpoint returns, +// so a test can vary the live subpages across two reads of the same page. +function spawn(state) { + return new Promise((resolve) => { + const server = http.createServer(async (req, res) => { + await readBody(req); + if (req.url === "/api/auth/login") { + return sendJson(res, 200, { success: true }, { + "Set-Cookie": "authToken=t; Path=/; HttpOnly", + }); + } + if (req.url === "/api/pages/info") { + state.info++; + return sendJson(res, 200, { + success: true, + data: { + id: PAGE_UUID, + slugId: "slug123456", + title: "Cached Page", + parentPageId: null, + spaceId: SPACE_UUID, + updatedAt: state.updatedAt, + content: makeDoc(state.text), + }, + }); + } + if (req.url === "/api/pages/sidebar-pages") { + state.sidebar++; + const items = state.subpages ?? [ + { id: CHILD_UUID, title: "Child", hasChildren: false }, + ]; + return sendJson(res, 200, { + success: true, + data: { + items, + meta: { hasNextPage: false, nextCursor: null }, + }, + }); + } + return sendJson(res, 404, { message: "not found" }); + }); + server.listen(0, "127.0.0.1", () => { + openServers.push(server); + resolve(`http://127.0.0.1:${server.address().port}/api`); + }); + }); +} + +function makeClient(baseURL, metrics) { + return new DocmostClient({ + apiUrl: baseURL, + getToken: async () => "access", + onMetric: (name, value) => { + metrics[name] = (metrics[name] ?? 0) + value; + }, + }); +} + +test("first read MISS, second read HIT with byte-identical output; convert runs once", async () => { + const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Hello world" }; + const baseURL = await spawn(state); + const metrics = {}; + const client = makeClient(baseURL, metrics); + + const first = await client.getPage(PAGE_UUID); + assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "first read is a miss"); + assert.equal(metrics["mcp_getpage_cache_hits_total"] ?? 0, 0, "no hit yet"); + + const second = await client.getPage(PAGE_UUID); + assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "second read is a hit"); + assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "still one miss"); + + // BYTE-IDENTICAL: the cache only skips recomputation, never changes output. + assert.deepEqual(second, first, "cached result is identical to the uncached one"); + assert.equal( + JSON.stringify(second), + JSON.stringify(first), + "serialized output is byte-identical", + ); + + // The page fetch + subpages fetch still happen every call (only the CPU + // conversion is cached); both reads hit /pages/info and sidebar-pages. + assert.equal(state.info, 2, "both reads still fetch /pages/info"); + assert.equal(state.sidebar, 2, "both reads still fetch subpages"); + + // Shape sanity: content present, subpages resolved. + assert.equal(typeof second.data.content, "string"); + assert.ok(second.data.content.includes("Hello world")); + assert.deepEqual(second.data.subpages, [{ id: CHILD_UUID, title: "Child" }]); +}); + +test("a changed updatedAt is a fresh key -> MISS again, with the NEW content", async () => { + const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Version one" }; + const baseURL = await spawn(state); + const metrics = {}; + const client = makeClient(baseURL, metrics); + + const a = await client.getPage(PAGE_UUID); // miss + const b = await client.getPage(PAGE_UUID); // hit + assert.equal(metrics["mcp_getpage_cache_misses_total"], 1); + assert.equal(metrics["mcp_getpage_cache_hits_total"], 1); + assert.ok(a.data.content.includes("Version one")); + + // The page changes: new updatedAt AND new content. + state.updatedAt = "2026-02-02T00:00:00Z"; + state.text = "Version two"; + + const c = await client.getPage(PAGE_UUID); // miss on the new key + assert.equal(metrics["mcp_getpage_cache_misses_total"], 2, "changed version -> miss"); + assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "no stale hit"); + assert.ok(c.data.content.includes("Version two"), "the NEW content is served"); + assert.ok(!c.data.content.includes("Version one"), "no stale markdown"); + + const d = await client.getPage(PAGE_UUID); // hit on the new key + assert.equal(metrics["mcp_getpage_cache_hits_total"], 2, "the new snapshot caches too"); +}); + +test("a slugId read and a UUID read of the same page share one cache entry", async () => { + // resolvePageId maps the slugId -> UUID via /pages/info; the cache keys on the + // canonical UUID (resultData.id), so both inputs land on the same entry. + const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Shared" }; + const baseURL = await spawn(state); + const metrics = {}; + const client = makeClient(baseURL, metrics); + + await client.getPage(PAGE_UUID); // miss (keyed on UUID) + await client.getPage("slug123456"); // the server returns the same id -> HIT + assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "one conversion total"); + assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "slugId read hits the UUID entry"); +}); + +test("on a conversion HIT, the {{SUBPAGES}} block reflects the LIVE subpages, not the cached ones", async () => { + // The whole byte-identity guarantee: the cache stores the conversion output + // BEFORE the {{SUBPAGES}} substitution, so a re-read of an UNCHANGED page still + // splices the FRESH subpage list. The page body itself contains {{SUBPAGES}} + // (converts to a literal placeholder); getPage replaces it with the live list. + const CHILD_A = "00000000-0000-4000-8000-0000000000a1"; + const CHILD_B = "00000000-0000-4000-8000-0000000000b2"; + const state = { + info: 0, + sidebar: 0, + updatedAt: "2026-01-01T00:00:00Z", // FIXED across both reads -> conversion cache HIT + text: "Body before {{SUBPAGES}} body after", + subpages: [{ id: CHILD_A, title: "Alpha", hasChildren: false }], + }; + const baseURL = await spawn(state); + const metrics = {}; + const client = makeClient(baseURL, metrics); + + // Read 1: MISS (converts). The substitution runs with list A. + const first = await client.getPage(PAGE_UUID); + assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "first read converts (miss)"); + assert.ok(first.data.content.includes("[Alpha](page:" + CHILD_A + ")"), "list A spliced in"); + assert.ok(!first.data.content.includes("{{SUBPAGES}}"), "placeholder consumed"); + + // The subpages change while the PAGE CONTENT/updatedAt do NOT: same conversion + // cache key -> a HIT that skips the CPU walk, but the live substitution must + // still run on the NEW list B. + state.subpages = [{ id: CHILD_B, title: "Beta", hasChildren: false }]; + + const second = await client.getPage(PAGE_UUID); + assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "second read is a conversion HIT"); + assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "no second conversion"); + + // The cache did NOT freeze the subpages block: list B is present, list A gone. + assert.ok(second.data.content.includes("[Beta](page:" + CHILD_B + ")"), "live list B spliced in on a HIT"); + assert.ok(!second.data.content.includes("Alpha"), "stale list A is NOT frozen into the output"); + assert.deepEqual(second.data.subpages, [{ id: CHILD_B, title: "Beta" }], "subpages field reflects list B"); +}); diff --git a/packages/mcp/test/unit/getpage-cache.test.mjs b/packages/mcp/test/unit/getpage-cache.test.mjs new file mode 100644 index 00000000..be9c0b21 --- /dev/null +++ b/packages/mcp/test/unit/getpage-cache.test.mjs @@ -0,0 +1,113 @@ +// Unit tests for the getPage content-addressed conversion cache (issue #479). +// Exercises the GetPageConversionCache class in isolation: key composition, +// hit/miss, LRU recency on read, and eviction by BOTH the count cap and the +// byte cap. The getPage integration (counter emission, byte-identical output) +// is covered separately in test/mock/getpage-conversion-cache.test.mjs. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + GetPageConversionCache, + hashConvertOptions, +} from "../../build/client/getpage-cache.js"; + +test("key: identical (pageId, updatedAt, optionsHash) collapse to one entry", () => { + const c = new GetPageConversionCache(); + const k1 = GetPageConversionCache.key("uuid-1", "2026-01-01T00:00:00Z", "{}"); + const k2 = GetPageConversionCache.key("uuid-1", "2026-01-01T00:00:00Z", "{}"); + assert.equal(k1, k2, "same parts -> same key"); + c.set(k1, "MD-A"); + assert.equal(c.get(k2), "MD-A", "hit on the identical key"); + assert.equal(c.size, 1); +}); + +test("miss after updatedAt changes (precise invalidation)", () => { + const c = new GetPageConversionCache(); + const oldK = GetPageConversionCache.key("uuid-1", "v1", "{}"); + const newK = GetPageConversionCache.key("uuid-1", "v2", "{}"); + c.set(oldK, "OLD-MD"); + assert.equal(c.get(newK), undefined, "a new updatedAt is a fresh key -> miss"); + assert.equal(c.get(oldK), "OLD-MD", "the old snapshot is still addressable"); +}); + +test("miss after options change (dropResolvedCommentAnchors true vs false)", () => { + const c = new GetPageConversionCache(); + const hDrop = hashConvertOptions({ dropResolvedCommentAnchors: true }); + const hKeep = hashConvertOptions({ dropResolvedCommentAnchors: false }); + assert.notEqual(hDrop, hKeep, "different options hash to different keys"); + const kDrop = GetPageConversionCache.key("uuid-1", "v1", hDrop); + const kKeep = GetPageConversionCache.key("uuid-1", "v1", hKeep); + c.set(kDrop, "AGENT-MD"); + assert.equal( + c.get(kKeep), + undefined, + "the export variant must NOT be served the agent variant", + ); +}); + +test("hashConvertOptions is order-insensitive and handles empty", () => { + assert.equal( + hashConvertOptions({ a: 1, b: 2 }), + hashConvertOptions({ b: 2, a: 1 }), + "key order does not change the hash", + ); + assert.equal(hashConvertOptions(undefined), "{}"); + assert.equal(hashConvertOptions(null), "{}"); + assert.equal(hashConvertOptions({}), "{}"); +}); + +test("LRU eviction by COUNT cap evicts the least-recently-used entry", () => { + const c = new GetPageConversionCache({ maxEntries: 2, maxBytes: 10 * 1024 * 1024 }); + c.set("k1", "a"); + c.set("k2", "b"); + c.set("k3", "c"); // over the count cap -> evict k1 (oldest) + assert.equal(c.size, 2); + assert.equal(c.get("k1"), undefined, "k1 evicted"); + assert.equal(c.get("k2"), "b"); + assert.equal(c.get("k3"), "c"); +}); + +test("a read refreshes recency so the OTHER entry is evicted next", () => { + const c = new GetPageConversionCache({ maxEntries: 2, maxBytes: 10 * 1024 * 1024 }); + c.set("k1", "a"); + c.set("k2", "b"); + // Touch k1 so k2 becomes the least-recently-used. + assert.equal(c.get("k1"), "a"); + c.set("k3", "c"); // evicts the LRU, which is now k2 (not k1) + assert.equal(c.get("k2"), undefined, "k2 was LRU and got evicted"); + assert.equal(c.get("k1"), "a", "k1 survived because it was read"); + assert.equal(c.get("k3"), "c"); +}); + +test("LRU eviction by BYTE cap evicts oldest until under the cap", () => { + // Byte cap of 100; each value is 40 bytes -> at most 2 fit (80 < 100 < 120). + const big = "x".repeat(40); + const c = new GetPageConversionCache({ maxEntries: 50, maxBytes: 100 }); + c.set("k1", big); // 40 + c.set("k2", big); // 80 + assert.equal(c.size, 2); + c.set("k3", big); // 120 > 100 -> evict k1 -> 80 + assert.equal(c.size, 2, "byte cap forced an eviction despite the count cap"); + assert.equal(c.get("k1"), undefined, "oldest evicted by bytes"); + assert.equal(c.get("k2"), big); + assert.equal(c.get("k3"), big); + assert.ok(c.bytes <= 100, "total bytes stays within the cap"); +}); + +test("re-setting an existing key updates value+recency and keeps bytes exact", () => { + const c = new GetPageConversionCache({ maxEntries: 5, maxBytes: 10 * 1024 * 1024 }); + c.set("k1", "short"); + const b1 = c.bytes; + assert.equal(b1, Buffer.byteLength("short", "utf8")); + c.set("k1", "a much longer value"); + assert.equal(c.size, 1, "no duplicate entry"); + assert.equal(c.bytes, Buffer.byteLength("a much longer value", "utf8")); + assert.equal(c.get("k1"), "a much longer value"); +}); + +test("an oversized single entry is still stored (never a permanent miss)", () => { + const c = new GetPageConversionCache({ maxEntries: 5, maxBytes: 10 }); + const big = "y".repeat(1000); + c.set("k1", big); + assert.equal(c.get("k1"), big, "the page bigger than the cap is still served"); + assert.equal(c.size, 1); +});