/** * Per-request query cache * * Deduplicates identical database queries within a single page render. * Uses the ALS request context as a WeakMap key so the cache is * automatically GC'd when the request completes. * * When no request context is available (e.g. local dev without D1 * replicas), queries bypass the cache — local SQLite is fast enough * that deduplication doesn't matter. * * The WeakMap is stored on globalThis with a Symbol key to guarantee * a singleton even when bundlers duplicate this module across chunks * (same pattern as request-context.ts). */ import type { EmDashRequestContext } from "./request-context.js"; import { getRequestContext } from "./request-context.js"; type CacheStore = WeakMap>>; const STORE_KEY = Symbol.for("emdash:request-cache"); const g = globalThis as Record; const store: CacheStore = // eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern (see request-context.ts) (g[STORE_KEY] as CacheStore | undefined) ?? (() => { const wm: CacheStore = new WeakMap(); g[STORE_KEY] = wm; return wm; })(); /** * Return a cached result for `key` if one exists in the current * request scope, otherwise call `fn`, cache its promise, and return it. * * Caches the *promise*, not the resolved value, so concurrent calls * with the same key share a single in-flight query. */ export function requestCached(key: string, fn: () => Promise): Promise { const ctx = getRequestContext(); if (!ctx) return fn(); let cache = store.get(ctx); if (!cache) { cache = new Map(); store.set(ctx, cache); } const existing = cache.get(key); if (existing) { if (ctx.metrics) ctx.metrics.cacheHits += 1; // eslint-disable-next-line typescript/no-unsafe-type-assertion -- heterogeneous cache; key namespacing guarantees the stored promise resolves to T return existing as Promise; } if (ctx.metrics) ctx.metrics.cacheMisses += 1; const promise = Promise.resolve() .then(fn) .catch((error) => { cache.delete(key); throw error; }); cache.set(key, promise); return promise; } /** * Look up an entry in the request-scoped cache without inserting one. * * Returns the in-flight or resolved promise if the key exists in the * current request, otherwise `undefined`. Callers can use this to * opportunistically satisfy a narrower query (e.g. `getSiteSetting("seo")`) * from a broader one (`getSiteSettings()`) that's already been loaded * by a parent template — avoiding a redundant round-trip. * * No-ops outside a request context. */ export function peekRequestCache(key: string): Promise | undefined { const ctx = getRequestContext(); if (!ctx) return undefined; const cache = store.get(ctx); // eslint-disable-next-line typescript/no-unsafe-type-assertion -- heterogeneous cache; caller is responsible for using a T-compatible key return cache?.get(key) as Promise | undefined; } /** * Pre-populate the request-scoped cache with a resolved value. * * Internal helper shared between hydration paths (taxonomy terms, * bylines, etc.) that already have the data in hand and want downstream * callers using `requestCached(key, ...)` to skip the database entirely. * Not exported from the package entrypoint — keep it internal until we * have a documented plugin/extension surface for hydration. * * No-ops outside a request context (local dev without ALS). * * Does not overwrite an existing entry — if a query for this key is already * in flight, its promise wins. */ export function setRequestCacheEntry(key: string, value: T): void { const ctx = getRequestContext(); if (!ctx) return; let cache = store.get(ctx); if (!cache) { cache = new Map(); store.set(ctx, cache); } if (cache.has(key)) return; cache.set(key, Promise.resolve(value)); } /** * Remove a key from the request-scoped cache. * * Used by write paths that need to invalidate a downstream read cache — * `setRequestCacheEntry` deliberately doesn't overwrite, and `requestCached` * returns the cached promise even after the underlying data has changed. * Without an explicit clear, a `read → write → read` sequence in a single * request can return stale data on the second read. * * Concrete case: `BylineRepository.update` invalidates * `byline-field-group-values:${translation_group}` after writing a * non-translatable custom-field value, so the post-update `findById` * (and any later reads in the same request) see the fresh value. * * No-ops outside a request context. */ export function clearRequestCacheEntry(key: string): void { const ctx = getRequestContext(); if (!ctx) return; const cache = store.get(ctx); cache?.delete(key); }